From 4bd233d315d8e99907d452e9cc17122896e2b476 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 07:10:57 -0400 Subject: [PATCH 01/13] feat: enforce instrument temporal eligibility --- OpenCapTable-v34/daml.yaml | 2 +- .../OpenCapTable/Types/Conversion.daml | 26 +- Test/daml.yaml | 2 +- .../TestInstrumentTemporalEligibility.daml | 526 ++++++++++++++++++ .../0.0.16/OpenCapTable-v34.dar | 3 + dars/dars.lock | 7 + .../codegen/templates/CapTable.daml.template | 208 ++++++- 7 files changed, 768 insertions(+), 6 deletions(-) create mode 100644 Test/daml/OpenCapTable/TestInstrumentTemporalEligibility.daml create mode 100644 dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar diff --git a/OpenCapTable-v34/daml.yaml b/OpenCapTable-v34/daml.yaml index 454ede48..76f7c6c1 100644 --- a/OpenCapTable-v34/daml.yaml +++ b/OpenCapTable-v34/daml.yaml @@ -1,7 +1,7 @@ sdk-version: 3.4.10 name: OpenCapTable-v34 source: daml -version: 0.0.15 +version: 0.0.16 build-options: - -Wno-crypto-text-is-alpha - --typecheck-upgrades=no diff --git a/OpenCapTable-v34/daml/Fairmint/OpenCapTable/Types/Conversion.daml b/OpenCapTable-v34/daml/Fairmint/OpenCapTable/Types/Conversion.daml index 4340b5e5..bf87f402 100644 --- a/OpenCapTable-v34/daml/Fairmint/OpenCapTable/Types/Conversion.daml +++ b/OpenCapTable-v34/daml/Fairmint/OpenCapTable/Types/Conversion.daml @@ -8,6 +8,7 @@ module Fairmint.OpenCapTable.Types.Conversion where -- OCF: https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/types/ import DA.Optional (isNone, isSome) +import DA.List (sortOn) import Fairmint.Shared.TypeHelpers (validateOptionalText, validateRequiredText) import Fairmint.OpenCapTable.Types.Validation (validateOcfPercentage) import Fairmint.OpenCapTable.Types.Monetary (OcfMonetary, validateOcfMonetary) @@ -72,7 +73,23 @@ data OcfInterestRate = OcfInterestRate deriving (Eq, Show) validateOcfInterestRate : OcfInterestRate -> Bool -validateOcfInterestRate r = validateOcfPercentage r.rate +validateOcfInterestRate r = + validateOcfPercentage r.rate && + optional True (\endDate -> endDate >= r.accrual_start_date) r.accrual_end_date + +validateSortedOcfInterestRateSchedule : [OcfInterestRate] -> Bool +validateSortedOcfInterestRateSchedule rates = case rates of + [] -> True + [rate] -> validateOcfInterestRate rate + current :: next :: rest -> + validateOcfInterestRate current && + current.accrual_start_date < next.accrual_start_date && + optional True (\endDate -> endDate < next.accrual_start_date) current.accrual_end_date && + validateSortedOcfInterestRateSchedule (next :: rest) + +validateOcfInterestRateSchedule : [OcfInterestRate] -> Bool +validateOcfInterestRateSchedule rates = + validateSortedOcfInterestRateSchedule (sortOn (\rate -> rate.accrual_start_date) rates) -- ============================================================================= -- CAPITALIZATION DEFINITION TYPES @@ -360,7 +377,7 @@ data OcfNoteConversionMechanism = OcfNoteConversionMechanism validateOcfNoteConversionMechanism : OcfNoteConversionMechanism -> Bool validateOcfNoteConversionMechanism m = - all validateOcfInterestRate m.interest_rates && + validateOcfInterestRateSchedule m.interest_rates && optional True (\d -> d >= (0.0) && d < (1.0 : Decimal)) m.conversion_discount && optional True validateOcfMonetary m.conversion_valuation_cap && optional True validateOcfRatio m.exit_multiple && @@ -611,6 +628,9 @@ validateOcfConversionTriggerFields triggerType startDate endDate triggerConditio noTriggerDate = isNone triggerDate hasStartDate = isSome startDate hasEndDate = isSome endDate + hasValidRange = case (startDate, endDate) of + (Some start, Some end) -> start <= end + _ -> False noRange = isNone startDate && isNone endDate in case triggerType of OcfTriggerTypeTypeAutomaticOnDate -> @@ -620,7 +640,7 @@ validateOcfConversionTriggerFields triggerType startDate endDate triggerConditio OcfTriggerTypeTypeElectiveOnCondition -> hasCondition && noTriggerDate && noRange OcfTriggerTypeTypeElectiveInRange -> - hasStartDate && hasEndDate && noCondition && noTriggerDate + hasStartDate && hasEndDate && hasValidRange && noCondition && noTriggerDate OcfTriggerTypeTypeElectiveAtWill -> noCondition && noTriggerDate && noRange OcfTriggerTypeTypeUnspecified -> diff --git a/Test/daml.yaml b/Test/daml.yaml index 3cf3a85b..5693c135 100644 --- a/Test/daml.yaml +++ b/Test/daml.yaml @@ -7,6 +7,6 @@ dependencies: - daml-stdlib - daml-script data-dependencies: - - ../OpenCapTable-v34/.daml/dist/OpenCapTable-v34-0.0.15.dar + - ../OpenCapTable-v34/.daml/dist/OpenCapTable-v34-0.0.16.dar build-options: - -Wno-template-interface-depends-on-daml-script diff --git a/Test/daml/OpenCapTable/TestInstrumentTemporalEligibility.daml b/Test/daml/OpenCapTable/TestInstrumentTemporalEligibility.daml new file mode 100644 index 00000000..c6f0c9f1 --- /dev/null +++ b/Test/daml/OpenCapTable/TestInstrumentTemporalEligibility.daml @@ -0,0 +1,526 @@ +module OpenCapTable.TestInstrumentTemporalEligibility where + +import qualified Fairmint.OpenCapTable.CapTable as CT +import Fairmint.OpenCapTable.OCF.ConvertibleConversion (ConvertibleConversionOcfData(..)) +import Fairmint.OpenCapTable.OCF.ConvertibleIssuance (ConvertibleIssuanceOcfData(..), OcfConvertibleConversionTrigger(..)) +import Fairmint.OpenCapTable.OCF.EquityCompensationExercise (EquityCompensationExerciseOcfData(..)) +import Fairmint.OpenCapTable.OCF.EquityCompensationIssuance (EquityCompensationIssuanceOcfData(..)) +import Fairmint.OpenCapTable.OCF.WarrantExercise (WarrantExerciseOcfData(..)) +import Fairmint.OpenCapTable.OCF.WarrantIssuance (WarrantIssuanceOcfData(..)) +import Fairmint.OpenCapTable.Types.Conversion +import OpenCapTable.Setup +import OpenCapTable.TestHelpers + ( addPrerequisites + , defaultConvertibleIssuance + , defaultEquityCompensationIssuance + , defaultWarrantIssuance + ) +import qualified DA.Date as DA +import DA.Date (Month(..)) +import qualified DA.Time as DT +import Daml.Script + +dec31 : Time +dec31 = DT.time (DA.date 2023 Dec 31) 0 0 0 + +jan01 : Time +jan01 = DT.time (DA.date 2024 Jan 01) 0 0 0 + +jan02 : Time +jan02 = DT.time (DA.date 2024 Jan 02) 0 0 0 + +jan03 : Time +jan03 = DT.time (DA.date 2024 Jan 03) 0 0 0 + +jan04 : Time +jan04 = DT.time (DA.date 2024 Jan 04) 0 0 0 + +fixedConvertibleTrigger : Text -> OcfConversionTriggerType -> Optional Time -> Optional Time -> Optional Time -> OcfConvertibleConversionTrigger +fixedConvertibleTrigger triggerId triggerType startDate endDate triggerDate = OcfConvertibleConversionTrigger with + conversion_right = OcfConvertibleConversionRight with + conversion_mechanism = OcfConvMechFixedAmount (OcfFixedAmountConversionMechanism with converts_to_quantity = 100.0) + converts_to_future_round = None + converts_to_stock_class_id = Some "SC_COMMON" + type_ = "CONVERTIBLE_CONVERSION_RIGHT" + end_date = endDate + nickname = None + start_date = startDate + trigger_condition = None + trigger_date = triggerDate + trigger_description = None + trigger_id = triggerId + type_ = triggerType + +fixedWarrantTrigger : Text -> OcfConversionTriggerType -> Optional Time -> Optional Time -> Optional Time -> OcfConversionTrigger +fixedWarrantTrigger triggerId triggerType startDate endDate triggerDate = OcfConversionTrigger with + conversion_right = OcfRightWarrant (OcfWarrantConversionRight with + conversion_mechanism = OcfWarrantMechanismFixedAmount with converts_to_quantity = 100.0 + converts_to_future_round = None + converts_to_stock_class_id = Some "SC_COMMON" + type_ = "WARRANT_CONVERSION_RIGHT") + end_date = endDate + nickname = None + start_date = startDate + trigger_condition = None + trigger_date = triggerDate + trigger_description = None + trigger_id = triggerId + type_ = triggerType + +atWillWarrantTrigger : Text -> OcfConversionTrigger +atWillWarrantTrigger triggerId = + fixedWarrantTrigger triggerId OcfTriggerTypeTypeElectiveAtWill None None None + +noteTrigger : Text -> [OcfInterestRate] -> OcfConvertibleConversionTrigger +noteTrigger triggerId interestRates = OcfConvertibleConversionTrigger with + conversion_right = OcfConvertibleConversionRight with + conversion_mechanism = OcfConvMechNote OcfNoteConversionMechanism with + capitalization_definition = None + capitalization_definition_rules = None + compounding_type = OcfSimple + conversion_discount = None + conversion_mfn = None + conversion_valuation_cap = None + day_count_convention = OcfDayCountActual365 + exit_multiple = None + interest_accrual_period = OcfAccrualAnnual + interest_payout = OcfInterestPayoutDeferred + interest_rates = interestRates + converts_to_future_round = None + converts_to_stock_class_id = None + type_ = "CONVERTIBLE_CONVERSION_RIGHT" + end_date = None + nickname = None + start_date = None + trigger_condition = None + trigger_date = None + trigger_description = None + trigger_id = triggerId + type_ = OcfTriggerTypeTypeUnspecified + +interestRate : Time -> Optional Time -> OcfInterestRate +interestRate startDate endDate = OcfInterestRate with + accrual_start_date = startDate + accrual_end_date = endDate + rate = 0.08 + +convertibleIssuance : Text -> Text -> OcfConvertibleConversionTrigger -> ConvertibleIssuanceOcfData +convertibleIssuance issuanceId securityId trigger = + (defaultConvertibleIssuance issuanceId securityId "SH-1" trigger.trigger_id) with + date = jan01 + conversion_triggers = [trigger] + +warrantIssuance : Text -> Text -> OcfConversionTrigger -> Optional Time -> WarrantIssuanceOcfData +warrantIssuance issuanceId securityId trigger expirationDate = + (defaultWarrantIssuance issuanceId securityId "SH-1") with + date = jan01 + exercise_triggers = [trigger] + warrant_expiration_date = expirationDate + +equityCompensationIssuance : Text -> Text -> Optional Time -> EquityCompensationIssuanceOcfData +equityCompensationIssuance issuanceId securityId expirationDate = + (defaultEquityCompensationIssuance issuanceId securityId "SH-1") with + date = jan01 + expiration_date = expirationDate + stock_class_id = Some "SC_COMMON" + +convertibleConversion : Text -> Text -> Text -> Time -> ConvertibleConversionOcfData +convertibleConversion transactionId securityId triggerId date = ConvertibleConversionOcfData with + id = transactionId + date = date + security_id = securityId + trigger_id = triggerId + reason_text = "Exercise the selected conversion trigger" + resulting_security_ids = [] + comments = [] + balance_security_id = None + capitalization_definition = None + quantity_converted = None + +warrantExercise : Text -> Text -> Text -> Time -> WarrantExerciseOcfData +warrantExercise transactionId securityId triggerId date = WarrantExerciseOcfData with + id = transactionId + date = date + security_id = securityId + trigger_id = triggerId + quantity = Some 100.0 + resulting_security_ids = [] + consideration_text = None + comments = [] + +equityCompensationExercise : Text -> Text -> Time -> EquityCompensationExerciseOcfData +equityCompensationExercise transactionId securityId date = EquityCompensationExerciseOcfData with + id = transactionId + date = date + security_id = securityId + quantity = 10.0 + resulting_security_ids = [] + consideration_text = None + comments = [] + +testInstrumentTemporalEligibility_ReversedTriggerRangesFail = script do + TestOcp{issuer, cap_table} <- setupTestOcp + capTable <- addPrerequisites issuer cap_table + let convertibleTrigger = fixedConvertibleTrigger + "CV-RANGE-REVERSED" + OcfTriggerTypeTypeElectiveInRange + (Some jan03) + (Some jan02) + None + warrantTrigger = fixedWarrantTrigger + "WR-RANGE-REVERSED" + OcfTriggerTypeTypeElectiveInRange + (Some jan03) + (Some jan02) + None + + submitMustFail issuer do + exerciseCmd capTable CT.UpdateCapTable with + creates = [CT.OcfCreateConvertibleIssuance (convertibleIssuance "TX-CV-RANGE-REVERSED" "SEC-CV-RANGE-REVERSED" convertibleTrigger)] + edits = [] + deletes = [] + submitMustFail issuer do + exerciseCmd capTable CT.UpdateCapTable with + creates = [CT.OcfCreateWarrantIssuance (warrantIssuance "TX-WR-RANGE-REVERSED" "SEC-WR-RANGE-REVERSED" warrantTrigger None)] + edits = [] + deletes = [] + +testInstrumentTemporalEligibility_TriggerTermsCannotOutliveWarrantExpiration = script do + TestOcp{issuer, cap_table} <- setupTestOcp + capTable <- addPrerequisites issuer cap_table + let automaticTrigger = fixedWarrantTrigger + "WR-AUTO-AFTER-EXPIRATION" + OcfTriggerTypeTypeAutomaticOnDate + None + None + (Some jan04) + rangeTrigger = fixedWarrantTrigger + "WR-RANGE-AFTER-EXPIRATION" + OcfTriggerTypeTypeElectiveInRange + (Some jan02) + (Some jan04) + None + + submitMustFail issuer do + exerciseCmd capTable CT.UpdateCapTable with + creates = [CT.OcfCreateWarrantIssuance (warrantIssuance "TX-WR-AUTO-AFTER-EXPIRATION" "SEC-WR-AUTO-AFTER-EXPIRATION" automaticTrigger (Some jan03))] + edits = [] + deletes = [] + submitMustFail issuer do + exerciseCmd capTable CT.UpdateCapTable with + creates = [CT.OcfCreateWarrantIssuance (warrantIssuance "TX-WR-RANGE-AFTER-EXPIRATION" "SEC-WR-RANGE-AFTER-EXPIRATION" rangeTrigger (Some jan03))] + edits = [] + deletes = [] + +testInstrumentTemporalEligibility_AutomaticConversionRequiresExactDate = script do + TestOcp{issuer, cap_table} <- setupTestOcp + capTable <- addPrerequisites issuer cap_table + let trigger = fixedConvertibleTrigger + "CV-AUTO-EXACT" + OcfTriggerTypeTypeAutomaticOnDate + None + None + (Some jan02) + issuance = convertibleIssuance "TX-CV-AUTO-EXACT-ISSUANCE" "SEC-CV-AUTO-EXACT" trigger + issuanceResult <- submit issuer do + exerciseCmd capTable CT.UpdateCapTable with + creates = [CT.OcfCreateConvertibleIssuance issuance] + edits = [] + deletes = [] + + _ <- submit issuer do + exerciseCmd issuanceResult.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateConvertibleConversion (convertibleConversion "TX-CV-AUTO-EXACT" issuance.security_id trigger.trigger_id jan02)] + edits = [] + deletes = [] + pure () + +testInstrumentTemporalEligibility_AutomaticConversionRejectsOtherDates = script do + TestOcp{issuer, cap_table} <- setupTestOcp + capTable <- addPrerequisites issuer cap_table + let trigger = fixedConvertibleTrigger + "CV-AUTO-MISMATCH" + OcfTriggerTypeTypeAutomaticOnDate + None + None + (Some jan02) + issuance = convertibleIssuance "TX-CV-AUTO-MISMATCH-ISSUANCE" "SEC-CV-AUTO-MISMATCH" trigger + issuanceResult <- submit issuer do + exerciseCmd capTable CT.UpdateCapTable with + creates = [CT.OcfCreateConvertibleIssuance issuance] + edits = [] + deletes = [] + + submitMustFail issuer do + exerciseCmd issuanceResult.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateConvertibleConversion (convertibleConversion "TX-CV-AUTO-MISMATCH" issuance.security_id trigger.trigger_id jan03)] + edits = [] + deletes = [] + +testInstrumentTemporalEligibility_ElectiveConversionRangeIsInclusive = script do + TestOcp{issuer, cap_table} <- setupTestOcp + capTable <- addPrerequisites issuer cap_table + let trigger = fixedConvertibleTrigger + "CV-RANGE-INCLUSIVE" + OcfTriggerTypeTypeElectiveInRange + (Some jan02) + (Some jan03) + None + startIssuance = convertibleIssuance "TX-CV-RANGE-START-ISSUANCE" "SEC-CV-RANGE-START" trigger + endIssuance = convertibleIssuance "TX-CV-RANGE-END-ISSUANCE" "SEC-CV-RANGE-END" trigger + issuanceResult <- submit issuer do + exerciseCmd capTable CT.UpdateCapTable with + creates = + [ CT.OcfCreateConvertibleIssuance startIssuance + , CT.OcfCreateConvertibleIssuance endIssuance + ] + edits = [] + deletes = [] + startResult <- submit issuer do + exerciseCmd issuanceResult.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateConvertibleConversion (convertibleConversion "TX-CV-RANGE-START" startIssuance.security_id trigger.trigger_id jan02)] + edits = [] + deletes = [] + _ <- submit issuer do + exerciseCmd startResult.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateConvertibleConversion (convertibleConversion "TX-CV-RANGE-END" endIssuance.security_id trigger.trigger_id jan03)] + edits = [] + deletes = [] + pure () + +testInstrumentTemporalEligibility_ElectiveConversionRejectsOutsideRange = script do + TestOcp{issuer, cap_table} <- setupTestOcp + capTable <- addPrerequisites issuer cap_table + let trigger = fixedConvertibleTrigger + "CV-RANGE-OUTSIDE" + OcfTriggerTypeTypeElectiveInRange + (Some jan02) + (Some jan03) + None + beforeIssuance = convertibleIssuance "TX-CV-RANGE-BEFORE-ISSUANCE" "SEC-CV-RANGE-BEFORE" trigger + afterIssuance = convertibleIssuance "TX-CV-RANGE-AFTER-ISSUANCE" "SEC-CV-RANGE-AFTER" trigger + issuanceResult <- submit issuer do + exerciseCmd capTable CT.UpdateCapTable with + creates = + [ CT.OcfCreateConvertibleIssuance beforeIssuance + , CT.OcfCreateConvertibleIssuance afterIssuance + ] + edits = [] + deletes = [] + submitMustFail issuer do + exerciseCmd issuanceResult.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateConvertibleConversion (convertibleConversion "TX-CV-RANGE-BEFORE" beforeIssuance.security_id trigger.trigger_id jan01)] + edits = [] + deletes = [] + submitMustFail issuer do + exerciseCmd issuanceResult.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateConvertibleConversion (convertibleConversion "TX-CV-RANGE-AFTER" afterIssuance.security_id trigger.trigger_id jan04)] + edits = [] + deletes = [] + +testInstrumentTemporalEligibility_WarrantRangeAndExpirationAreInclusive = script do + TestOcp{issuer, cap_table} <- setupTestOcp + capTable <- addPrerequisites issuer cap_table + let trigger = fixedWarrantTrigger + "WR-RANGE-INCLUSIVE" + OcfTriggerTypeTypeElectiveInRange + (Some jan02) + (Some jan03) + None + startIssuance = warrantIssuance "TX-WR-RANGE-START-ISSUANCE" "SEC-WR-RANGE-START" trigger (Some jan03) + endIssuance = warrantIssuance "TX-WR-RANGE-END-ISSUANCE" "SEC-WR-RANGE-END" trigger (Some jan03) + issuanceResult <- submit issuer do + exerciseCmd capTable CT.UpdateCapTable with + creates = + [ CT.OcfCreateWarrantIssuance startIssuance + , CT.OcfCreateWarrantIssuance endIssuance + ] + edits = [] + deletes = [] + startResult <- submit issuer do + exerciseCmd issuanceResult.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateWarrantExercise (warrantExercise "TX-WR-RANGE-START" startIssuance.security_id trigger.trigger_id jan02)] + edits = [] + deletes = [] + _ <- submit issuer do + exerciseCmd startResult.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateWarrantExercise (warrantExercise "TX-WR-RANGE-END" endIssuance.security_id trigger.trigger_id jan03)] + edits = [] + deletes = [] + pure () + +testInstrumentTemporalEligibility_WarrantRejectsOutsideRangeAndAfterExpiration = script do + TestOcp{issuer, cap_table} <- setupTestOcp + capTable <- addPrerequisites issuer cap_table + let rangeTrigger = fixedWarrantTrigger + "WR-RANGE-OUTSIDE" + OcfTriggerTypeTypeElectiveInRange + (Some jan02) + (Some jan03) + None + atWillTrigger = atWillWarrantTrigger "WR-AT-WILL-EXPIRES" + rangeIssuance = warrantIssuance "TX-WR-RANGE-OUTSIDE-ISSUANCE" "SEC-WR-RANGE-OUTSIDE" rangeTrigger None + expiringIssuance = warrantIssuance "TX-WR-EXPIRES-ISSUANCE" "SEC-WR-EXPIRES" atWillTrigger (Some jan03) + issuanceResult <- submit issuer do + exerciseCmd capTable CT.UpdateCapTable with + creates = + [ CT.OcfCreateWarrantIssuance rangeIssuance + , CT.OcfCreateWarrantIssuance expiringIssuance + ] + edits = [] + deletes = [] + submitMustFail issuer do + exerciseCmd issuanceResult.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateWarrantExercise (warrantExercise "TX-WR-RANGE-OUTSIDE" rangeIssuance.security_id rangeTrigger.trigger_id jan01)] + edits = [] + deletes = [] + submitMustFail issuer do + exerciseCmd issuanceResult.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateWarrantExercise (warrantExercise "TX-WR-AFTER-EXPIRATION" expiringIssuance.security_id atWillTrigger.trigger_id jan04)] + edits = [] + deletes = [] + +testInstrumentTemporalEligibility_EquityCompensationExpirationIsInclusive = script do + TestOcp{issuer, cap_table} <- setupTestOcp + capTable <- addPrerequisites issuer cap_table + let exactIssuance = equityCompensationIssuance "TX-EC-EXPIRATION-EXACT-ISSUANCE" "SEC-EC-EXPIRATION-EXACT" (Some jan03) + expiredIssuance = equityCompensationIssuance "TX-EC-EXPIRATION-LATE-ISSUANCE" "SEC-EC-EXPIRATION-LATE" (Some jan03) + issuanceResult <- submit issuer do + exerciseCmd capTable CT.UpdateCapTable with + creates = + [ CT.OcfCreateEquityCompensationIssuance exactIssuance + , CT.OcfCreateEquityCompensationIssuance expiredIssuance + ] + edits = [] + deletes = [] + exactResult <- submit issuer do + exerciseCmd issuanceResult.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateEquityCompensationExercise (equityCompensationExercise "TX-EC-EXPIRATION-EXACT" exactIssuance.security_id jan03)] + edits = [] + deletes = [] + submitMustFail issuer do + exerciseCmd exactResult.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateEquityCompensationExercise (equityCompensationExercise "TX-EC-AFTER-EXPIRATION" expiredIssuance.security_id jan04)] + edits = [] + deletes = [] + +testInstrumentTemporalEligibility_ExpirationCannotPrecedeIssuance = script do + TestOcp{issuer, cap_table} <- setupTestOcp + capTable <- addPrerequisites issuer cap_table + let warrant = warrantIssuance "TX-WR-INVALID-EXPIRATION" "SEC-WR-INVALID-EXPIRATION" (atWillWarrantTrigger "WR-INVALID-EXPIRATION") (Some dec31) + equityCompensation = equityCompensationIssuance "TX-EC-INVALID-EXPIRATION" "SEC-EC-INVALID-EXPIRATION" (Some dec31) + submitMustFail issuer do + exerciseCmd capTable CT.UpdateCapTable with + creates = [CT.OcfCreateWarrantIssuance warrant] + edits = [] + deletes = [] + submitMustFail issuer do + exerciseCmd capTable CT.UpdateCapTable with + creates = [CT.OcfCreateEquityCompensationIssuance equityCompensation] + edits = [] + deletes = [] + +testInstrumentTemporalEligibility_NoteInterestIntervalsMustBeConsistent = script do + TestOcp{issuer, cap_table} <- setupTestOcp + capTable <- addPrerequisites issuer cap_table + let validRates = + [ interestRate jan03 None + , interestRate jan01 (Some jan02) + ] + reversedRates = [interestRate jan03 (Some jan02)] + overlappingRates = + [ interestRate jan01 (Some jan03) + , interestRate jan03 None + ] + duplicateStartRates = + [ interestRate jan01 None + , interestRate jan01 None + ] + validResult <- submit issuer do + exerciseCmd capTable CT.UpdateCapTable with + creates = [CT.OcfCreateConvertibleIssuance (convertibleIssuance "TX-CV-NOTE-VALID" "SEC-CV-NOTE-VALID" (noteTrigger "CV-NOTE-VALID" validRates))] + edits = [] + deletes = [] + submitMustFail issuer do + exerciseCmd validResult.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateConvertibleIssuance (convertibleIssuance "TX-CV-NOTE-REVERSED" "SEC-CV-NOTE-REVERSED" (noteTrigger "CV-NOTE-REVERSED" reversedRates))] + edits = [] + deletes = [] + submitMustFail issuer do + exerciseCmd validResult.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateConvertibleIssuance (convertibleIssuance "TX-CV-NOTE-OVERLAP" "SEC-CV-NOTE-OVERLAP" (noteTrigger "CV-NOTE-OVERLAP" overlappingRates))] + edits = [] + deletes = [] + submitMustFail issuer do + exerciseCmd validResult.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateConvertibleIssuance (convertibleIssuance "TX-CV-NOTE-DUPLICATE-START" "SEC-CV-NOTE-DUPLICATE-START" (noteTrigger "CV-NOTE-DUPLICATE-START" duplicateStartRates))] + edits = [] + deletes = [] + +testInstrumentTemporalEligibility_EditCannotInvalidateRecordedConversion = script do + TestOcp{issuer, cap_table} <- setupTestOcp + capTable <- addPrerequisites issuer cap_table + let validTrigger = fixedConvertibleTrigger + "CV-EDITED-TRIGGER" + OcfTriggerTypeTypeAutomaticOnDate + None + None + (Some jan02) + sourceIssuance = convertibleIssuance "TX-CV-EDITED-ISSUANCE" "SEC-CV-EDITED" validTrigger + issuanceResult <- submit issuer do + exerciseCmd capTable CT.UpdateCapTable with + creates = [CT.OcfCreateConvertibleIssuance sourceIssuance] + edits = [] + deletes = [] + conversionResult <- submit issuer do + exerciseCmd issuanceResult.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateConvertibleConversion (convertibleConversion "TX-CV-BEFORE-EDIT" sourceIssuance.security_id validTrigger.trigger_id jan02)] + edits = [] + deletes = [] + let invalidatingTrigger = validTrigger with trigger_date = Some jan03 + submitMustFail issuer do + exerciseCmd conversionResult.updatedCapTableCid CT.UpdateCapTable with + creates = [] + edits = [CT.OcfEditConvertibleIssuance (sourceIssuance with conversion_triggers = [invalidatingTrigger])] + deletes = [] + +testInstrumentTemporalEligibility_EditCannotExpireExercisedWarrant = script do + TestOcp{issuer, cap_table} <- setupTestOcp + capTable <- addPrerequisites issuer cap_table + let trigger = atWillWarrantTrigger "WR-EDITED-EXPIRATION" + sourceIssuance = warrantIssuance "TX-WR-EDITED-ISSUANCE" "SEC-WR-EDITED" trigger (Some jan03) + issuanceResult <- submit issuer do + exerciseCmd capTable CT.UpdateCapTable with + creates = [CT.OcfCreateWarrantIssuance sourceIssuance] + edits = [] + deletes = [] + exerciseResult <- submit issuer do + exerciseCmd issuanceResult.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateWarrantExercise (warrantExercise "TX-WR-BEFORE-EDIT" sourceIssuance.security_id trigger.trigger_id jan02)] + edits = [] + deletes = [] + submitMustFail issuer do + exerciseCmd exerciseResult.updatedCapTableCid CT.UpdateCapTable with + creates = [] + edits = [CT.OcfEditWarrantIssuance (sourceIssuance with warrant_expiration_date = Some jan01)] + deletes = [] + +testInstrumentTemporalEligibility_EditCannotExpireExercisedEquityCompensation = script do + TestOcp{issuer, cap_table} <- setupTestOcp + capTable <- addPrerequisites issuer cap_table + let sourceIssuance = equityCompensationIssuance "TX-EC-EDITED-ISSUANCE" "SEC-EC-EDITED" (Some jan03) + issuanceResult <- submit issuer do + exerciseCmd capTable CT.UpdateCapTable with + creates = [CT.OcfCreateEquityCompensationIssuance sourceIssuance] + edits = [] + deletes = [] + exerciseResult <- submit issuer do + exerciseCmd issuanceResult.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateEquityCompensationExercise (equityCompensationExercise "TX-EC-BEFORE-EDIT" sourceIssuance.security_id jan02)] + edits = [] + deletes = [] + submitMustFail issuer do + exerciseCmd exerciseResult.updatedCapTableCid CT.UpdateCapTable with + creates = [] + edits = [CT.OcfEditEquityCompensationIssuance (sourceIssuance with expiration_date = Some jan01)] + deletes = [] diff --git a/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar b/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar new file mode 100644 index 00000000..7bbd8d1f --- /dev/null +++ b/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:82fd657a2ec6e8acd13cabc110a1523e883c0912cd533465ff7cdd4a6ed90321 +size 3307198 diff --git a/dars/dars.lock b/dars/dars.lock index 744de0fb..93dc0d52 100644 --- a/dars/dars.lock +++ b/dars/dars.lock @@ -53,6 +53,13 @@ "uploadedAt": "2026-07-11T09:23:02.950Z", "networks": [] }, + "OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar": { + "sha256": "82fd657a2ec6e8acd13cabc110a1523e883c0912cd533465ff7cdd4a6ed90321", + "size": 3307198, + "sdkVersion": "unknown", + "uploadedAt": "2026-07-11T11:07:32.235Z", + "networks": [] + }, "OpenCapTable-v34/0.0.2/OpenCapTable-v34.dar": { "sha256": "2ce462b8afcb1bcdbe351c56b0a47de478477f3c655da00e41cb397f63e997d6", "size": 2446238, diff --git a/scripts/codegen/templates/CapTable.daml.template b/scripts/codegen/templates/CapTable.daml.template index 03c626a7..289642ee 100644 --- a/scripts/codegen/templates/CapTable.daml.template +++ b/scripts/codegen/templates/CapTable.daml.template @@ -18,7 +18,7 @@ import DA.Foldable (mapA_) import DA.Optional (fromOptional, isNone, isSome) import Fairmint.OpenCapTable.Types.Core (Context) -import Fairmint.OpenCapTable.Types.Conversion (OcfAnyConversionRight(..), OcfCapitalizationDefinition, OcfConversionMechanism(..), OcfConversionTrigger(..), OcfConvertibleConversionMechanism(..), OcfConvertibleConversionRight(..), OcfStockClassConversionRight(..), OcfWarrantConversionMechanism(..), OcfWarrantConversionRight(..)) +import Fairmint.OpenCapTable.Types.Conversion (OcfAnyConversionRight(..), OcfCapitalizationDefinition, OcfConversionMechanism(..), OcfConversionTrigger(..), OcfConversionTriggerType(..), OcfConvertibleConversionMechanism(..), OcfConvertibleConversionRight(..), OcfStockClassConversionRight(..), OcfWarrantConversionMechanism(..), OcfWarrantConversionRight(..), validateOcfInterestRateSchedule) import Fairmint.OpenCapTable.Types.Stock (OcfAuthorizedShares(..), OcfInitialSharesAuthorized(..), OcfRatio(..), OcfShareNumberRange(..), shareNumberRangeCoveredByRanges, shareNumberRangesOverlap) import Fairmint.OpenCapTable.Types.Vesting (OcfCompensationType(..), OcfVesting(..)) import qualified Fairmint.OpenCapTable.OCF.ConvertibleIssuance as ConvertibleIssuance (OcfConvertibleConversionTrigger(..)) @@ -4513,6 +4513,211 @@ validateWarrantExerciseTriggerReferences maps = None -> assertMsg ("Security not found: " <> exercise.exercise_data.security_id) False) (Map.values maps.warrant_exercises) +conversionTriggerDefinitionIsTemporallyValid : + Optional Time -> + OcfConversionTriggerType -> + Optional Time -> + Optional Time -> + Optional Time -> + Bool +conversionTriggerDefinitionIsTemporallyValid expirationDate triggerType startDate endDate triggerDate = + let notAfterExpiration date = optional True (\expiration -> date <= expiration) expirationDate + in case triggerType of + OcfTriggerTypeTypeAutomaticOnDate -> + optional False notAfterExpiration triggerDate + OcfTriggerTypeTypeElectiveInRange -> case (startDate, endDate) of + (Some start, Some end) -> + start <= end && notAfterExpiration end + _ -> False + _ -> True + +conversionTriggerIsEligibleAt : + Time -> + OcfConversionTriggerType -> + Optional Time -> + Optional Time -> + Optional Time -> + Bool +conversionTriggerIsEligibleAt eventDate triggerType startDate endDate triggerDate = + case triggerType of + OcfTriggerTypeTypeAutomaticOnDate -> case triggerDate of + Some date -> eventDate == date + None -> False + OcfTriggerTypeTypeElectiveInRange -> case (startDate, endDate) of + (Some start, Some end) -> eventDate >= start && eventDate <= end + _ -> False + _ -> True + +assertConversionTriggerDefinitionIsTemporallyValid : + Text -> + Optional Time -> + OcfConversionTriggerType -> + Optional Time -> + Optional Time -> + Optional Time -> + Update () +assertConversionTriggerDefinitionIsTemporallyValid label expirationDate triggerType startDate endDate triggerDate = + assertMsg + (label <> " has an invalid elective range or dates after the instrument expiration") + (conversionTriggerDefinitionIsTemporallyValid expirationDate triggerType startDate endDate triggerDate) + +assertConversionTriggerIsEligibleAt : + Text -> + Time -> + OcfConversionTriggerType -> + Optional Time -> + Optional Time -> + Optional Time -> + Update () +assertConversionTriggerIsEligibleAt label eventDate triggerType startDate endDate triggerDate = + assertMsg + (label <> " is not eligible on " <> show eventDate <> " under its selected trigger") + (conversionTriggerIsEligibleAt eventDate triggerType startDate endDate triggerDate) + +assertExpirationOnOrAfterIssuance : Text -> Time -> Optional Time -> Update () +assertExpirationOnOrAfterIssuance label issuanceDate expirationDate = + case expirationDate of + Some expiration -> + assertMsg + (label <> " expiration " <> show expiration <> " cannot precede issuance " <> show issuanceDate) + (expiration >= issuanceDate) + None -> pure () + +assertEventOnOrBeforeExpiration : Text -> Time -> Optional Time -> Update () +assertEventOnOrBeforeExpiration label eventDate expirationDate = + case expirationDate of + Some expiration -> + assertMsg + (label <> " date " <> show eventDate <> " cannot follow expiration " <> show expiration) + (eventDate <= expiration) + None -> pure () + +validateConvertibleIssuanceTemporalTerms : CapTableMaps -> Update () +validateConvertibleIssuanceTemporalTerms maps = + mapA_ + (\issuanceCid -> do + issuance <- fetch issuanceCid + mapA_ + (\trigger -> do + let label = "Convertible issuance " <> issuance.issuance_data.id <> " trigger " <> trigger.trigger_id + assertConversionTriggerDefinitionIsTemporallyValid + label + None + trigger.type_ + trigger.start_date + trigger.end_date + trigger.trigger_date + case trigger.conversion_right.conversion_mechanism of + OcfConvMechNote noteMechanism -> do + assertMsg + (label <> " has invalid or overlapping interest rate intervals") + (validateOcfInterestRateSchedule noteMechanism.interest_rates) + _ -> pure ()) + issuance.issuance_data.conversion_triggers) + (Map.values maps.convertible_issuances) + +validateWarrantIssuanceTemporalTerms : CapTableMaps -> Update () +validateWarrantIssuanceTemporalTerms maps = + mapA_ + (\issuanceCid -> do + issuance <- fetch issuanceCid + assertExpirationOnOrAfterIssuance + ("Warrant issuance " <> issuance.issuance_data.id) + issuance.issuance_data.date + issuance.issuance_data.warrant_expiration_date + mapA_ + (\trigger -> + assertConversionTriggerDefinitionIsTemporallyValid + ("Warrant issuance " <> issuance.issuance_data.id <> " trigger " <> trigger.trigger_id) + issuance.issuance_data.warrant_expiration_date + trigger.type_ + trigger.start_date + trigger.end_date + trigger.trigger_date) + issuance.issuance_data.exercise_triggers) + (Map.values maps.warrant_issuances) + +validateEquityCompensationIssuanceTemporalTerms : CapTableMaps -> Update () +validateEquityCompensationIssuanceTemporalTerms maps = + mapA_ + (\issuanceCid -> do + issuance <- fetch issuanceCid + assertExpirationOnOrAfterIssuance + ("Equity compensation issuance " <> issuance.issuance_data.id) + issuance.issuance_data.date + issuance.issuance_data.expiration_date) + (Map.values maps.equity_compensation_issuances) + +validateConvertibleConversionTemporalEligibility : CapTableMaps -> Update () +validateConvertibleConversionTemporalEligibility maps = + mapA_ + (\conversionCid -> do + conversion <- fetch conversionCid + case Map.lookup conversion.conversion_data.security_id maps.convertible_issuances_by_security_id of + Some issuanceCid -> do + issuance <- fetch issuanceCid + case convertibleConversionTrigger issuance.issuance_data conversion.conversion_data.trigger_id of + Some trigger -> + assertConversionTriggerIsEligibleAt + ("Convertible conversion " <> conversion.conversion_data.id) + conversion.conversion_data.date + trigger.type_ + trigger.start_date + trigger.end_date + trigger.trigger_date + None -> assertMsg ("Convertible conversion trigger not found: " <> conversion.conversion_data.trigger_id) False + None -> assertMsg ("Convertible conversion security not found: " <> conversion.conversion_data.security_id) False) + (Map.values maps.convertible_conversions) + +validateWarrantExerciseTemporalEligibility : CapTableMaps -> Update () +validateWarrantExerciseTemporalEligibility maps = + mapA_ + (\exerciseCid -> do + exercise <- fetch exerciseCid + case Map.lookup exercise.exercise_data.security_id maps.warrant_issuances_by_security_id of + Some issuanceCid -> do + issuance <- fetch issuanceCid + assertEventOnOrBeforeExpiration + ("Warrant exercise " <> exercise.exercise_data.id) + exercise.exercise_data.date + issuance.issuance_data.warrant_expiration_date + case warrantExerciseTrigger issuance.issuance_data exercise.exercise_data.trigger_id of + Some trigger -> + assertConversionTriggerIsEligibleAt + ("Warrant exercise " <> exercise.exercise_data.id) + exercise.exercise_data.date + trigger.type_ + trigger.start_date + trigger.end_date + trigger.trigger_date + None -> assertMsg ("Warrant exercise trigger not found: " <> exercise.exercise_data.trigger_id) False + None -> assertMsg ("Warrant exercise security not found: " <> exercise.exercise_data.security_id) False) + (Map.values maps.warrant_exercises) + +validateEquityCompensationExerciseTemporalEligibility : CapTableMaps -> Update () +validateEquityCompensationExerciseTemporalEligibility maps = + mapA_ + (\exerciseCid -> do + exercise <- fetch exerciseCid + case Map.lookup exercise.exercise_data.security_id maps.equity_compensation_issuances_by_security_id of + Some issuanceCid -> do + issuance <- fetch issuanceCid + assertEventOnOrBeforeExpiration + ("Equity compensation exercise " <> exercise.exercise_data.id) + exercise.exercise_data.date + issuance.issuance_data.expiration_date + None -> assertMsg ("Equity compensation exercise security not found: " <> exercise.exercise_data.security_id) False) + (Map.values maps.equity_compensation_exercises) + +validateInstrumentTemporalEligibility : CapTableMaps -> Update () +validateInstrumentTemporalEligibility maps = do + validateConvertibleIssuanceTemporalTerms maps + validateWarrantIssuanceTemporalTerms maps + validateEquityCompensationIssuanceTemporalTerms maps + validateConvertibleConversionTemporalEligibility maps + validateWarrantExerciseTemporalEligibility maps + validateEquityCompensationExerciseTemporalEligibility maps + validateStockReissuanceSplitReferences : CapTableMaps -> Update () validateStockReissuanceSplitReferences maps = mapA_ @@ -6978,6 +7183,7 @@ template CapTable validateFinalSecurityIdUniqueness finalMaps validateSuccessorSecurityIdContinuity finalMaps validateTransactionLifecycleDates finalMaps + validateInstrumentTemporalEligibility finalMaps validateStockBalanceSuccessorQuantities stockEventIndex finalMaps validateNonStockBalanceSuccessorQuantities stockEventIndex nonStockBalanceEventIndex finalMaps validateStockSecurityBalances stockEventIndex finalMaps From 91daec073c7b161fa697719ab6de5d467abd28ca Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 07:38:58 -0400 Subject: [PATCH 02/13] fix: preserve successor grant expiration --- .../TestEquityCompensationTransfer.daml | 17 ++- Test/daml/OpenCapTable/TestHelpers.daml | 7 +- .../TestInstrumentTemporalEligibility.daml | 120 ++++++++++++++++++ .../TestNonStockSecurityBalances.daml | 2 +- .../TestSecurityIdValidation.daml | 4 +- .../0.0.16/OpenCapTable-v34.dar | 4 +- dars/dars.lock | 4 +- .../codegen/templates/CapTable.daml.template | 32 +++++ 8 files changed, 173 insertions(+), 17 deletions(-) diff --git a/Test/daml/OpenCapTable/TestEquityCompensationTransfer.daml b/Test/daml/OpenCapTable/TestEquityCompensationTransfer.daml index 0c79ed1d..b3a8e719 100644 --- a/Test/daml/OpenCapTable/TestEquityCompensationTransfer.daml +++ b/Test/daml/OpenCapTable/TestEquityCompensationTransfer.daml @@ -12,6 +12,9 @@ import DA.Date (Month(..)) import qualified DA.Time as DT import Daml.Script +equityCompensationExpiration : Time +equityCompensationExpiration = DT.time (DA.date 2032 Jan 15) 0 0 0 + -- Create an equity compensation issuance to transfer (follows real workflow) createEquityCompensationIssuance issuer capTableCid securityId quantity issuanceDate = submit issuer do @@ -33,7 +36,7 @@ createEquityCompensationIssuance issuer capTableCid securityId quantity issuance base_price = None exercise_price = Some (OcfMonetary with amount = 0.50, currency = "USD") early_exercisable = None - expiration_date = Some (DT.time (DA.date 2032 Jan 15) 0 0 0) + expiration_date = Some equityCompensationExpiration stock_class_id = None stock_plan_id = None vesting_terms_id = None @@ -54,7 +57,7 @@ testEquityCompensationTransfer_NSOToFamilyTrust = script do balanceResult <- createEquityCompensationIssuance issuer result.updatedCapTableCid "ESEC-NSO-REMAIN" 5000.0 transferDate _ <- submit issuer do exerciseCmd balanceResult.updatedCapTableCid CT.UpdateCapTable with - creates = [equityCompensationSuccessorIssuance "EC-ISS-TRANSFERRED-001" "ESEC-NSO-TRANSFERRED-001" "SH-1" transferDate 5000.0 + creates = [equityCompensationSuccessorIssuance "EC-ISS-TRANSFERRED-001" "ESEC-NSO-TRANSFERRED-001" "SH-1" transferDate 5000.0 (Some equityCompensationExpiration) , CT.OcfCreateEquityCompensationTransfer EquityCompensationTransferOcfData with id = "TX_EQUITY_COMP_TRANSFER_1" date = transferDate @@ -78,7 +81,7 @@ testEquityCompensationTransfer_ResultQuantityMustMatchTransfer = script do submitMustFail issuer do exerciseCmd result.updatedCapTableCid CT.UpdateCapTable with creates = - [ equityCompensationSuccessorIssuance "EC-ISS-QUANTITY-MISMATCH" "ESEC-QUANTITY-MISMATCH-RESULT" transfereeId (DT.time (DA.date 2025 Jun 01) 0 0 0) 59.0 + [ equityCompensationSuccessorIssuance "EC-ISS-QUANTITY-MISMATCH" "ESEC-QUANTITY-MISMATCH-RESULT" transfereeId (DT.time (DA.date 2025 Jun 01) 0 0 0) 59.0 (Some equityCompensationExpiration) , CT.OcfCreateEquityCompensationTransfer EquityCompensationTransferOcfData with id = "TX-EC-TRANSFER-QUANTITY-MISMATCH" date = DT.time (DA.date 2025 Jun 01) 0 0 0 @@ -103,8 +106,8 @@ testEquityCompensationTransfer_BalanceMustKeepSourceStakeholder = script do submitMustFail issuer do exerciseCmd sourceResult.updatedCapTableCid CT.UpdateCapTable with creates = - [ equityCompensationSuccessorIssuance "EC-ISS-BALANCE-HOLDER-RESULT" "ESEC-BALANCE-HOLDER-RESULT" transfereeId transferDate 60.0 - , equityCompensationSuccessorIssuance "EC-ISS-BALANCE-HOLDER-REMAINDER" "ESEC-BALANCE-HOLDER-REMAINDER" transfereeId transferDate 40.0 + [ equityCompensationSuccessorIssuance "EC-ISS-BALANCE-HOLDER-RESULT" "ESEC-BALANCE-HOLDER-RESULT" transfereeId transferDate 60.0 (Some equityCompensationExpiration) + , equityCompensationSuccessorIssuance "EC-ISS-BALANCE-HOLDER-REMAINDER" "ESEC-BALANCE-HOLDER-REMAINDER" transfereeId transferDate 40.0 (Some equityCompensationExpiration) , CT.OcfCreateEquityCompensationTransfer EquityCompensationTransferOcfData with id = "TX-EC-BALANCE-HOLDER-MISMATCH" date = transferDate @@ -127,7 +130,7 @@ testEquityCompensationTransfer_FullTransfer = script do result <- createEquityCompensationIssuance issuer capTableCid securityId 2500.0 (DT.time (DA.date 2022 Jan 15) 0 0 0) _ <- submit issuer do exerciseCmd result.updatedCapTableCid CT.UpdateCapTable with - creates = [equityCompensationSuccessorIssuance "EC-ISS-NEWHOLDER" "ESEC-NSO-NEWHOLDER" transfereeId (DT.time (DA.date 2025 Sep 15) 0 0 0) 2500.0 + creates = [equityCompensationSuccessorIssuance "EC-ISS-NEWHOLDER" "ESEC-NSO-NEWHOLDER" transfereeId (DT.time (DA.date 2025 Sep 15) 0 0 0) 2500.0 (Some equityCompensationExpiration) , CT.OcfCreateEquityCompensationTransfer EquityCompensationTransferOcfData with id = "TX_EQUITY_COMP_TRANSFER_FULL" date = DT.time (DA.date 2025 Sep 15) 0 0 0 @@ -152,7 +155,7 @@ testEquityCompensationTransfer_WithApprovalComments = script do balanceResult <- createEquityCompensationIssuance issuer result.updatedCapTableCid "ESEC-NSO-REMAINING" 7000.0 transferDate _ <- submit issuer do exerciseCmd balanceResult.updatedCapTableCid CT.UpdateCapTable with - creates = [equityCompensationSuccessorIssuance "EC-ISS-TRUST" "ESEC-NSO-TRUST" "SH-1" transferDate 8000.0 + creates = [equityCompensationSuccessorIssuance "EC-ISS-TRUST" "ESEC-NSO-TRUST" "SH-1" transferDate 8000.0 (Some equityCompensationExpiration) , CT.OcfCreateEquityCompensationTransfer EquityCompensationTransferOcfData with id = "TX_EQUITY_COMP_TRANSFER_APPROVED" date = transferDate diff --git a/Test/daml/OpenCapTable/TestHelpers.daml b/Test/daml/OpenCapTable/TestHelpers.daml index aabe6721..d1f865c7 100644 --- a/Test/daml/OpenCapTable/TestHelpers.daml +++ b/Test/daml/OpenCapTable/TestHelpers.daml @@ -362,11 +362,12 @@ convertibleSuccessorIssuance issuanceId securityId stakeholderId date investment date = date investment_amount = OcfMonetary with amount = investmentAmount, currency = "USD") -equityCompensationSuccessorIssuance : Text -> Text -> Text -> Time -> Decimal -> CT.OcfCreateData -equityCompensationSuccessorIssuance issuanceId securityId stakeholderId date quantity = +equityCompensationSuccessorIssuance : Text -> Text -> Text -> Time -> Decimal -> Optional Time -> CT.OcfCreateData +equityCompensationSuccessorIssuance issuanceId securityId stakeholderId date quantity expirationDate = CT.OcfCreateEquityCompensationIssuance ((defaultEquityCompensationIssuance issuanceId securityId stakeholderId) with date = date - quantity = quantity) + quantity = quantity + expiration_date = expirationDate) -- | Returns valid StockLegendTemplateOcfData with sensible defaults. defaultStockLegendTemplate : Text -> StockLegendTemplateOcfData diff --git a/Test/daml/OpenCapTable/TestInstrumentTemporalEligibility.daml b/Test/daml/OpenCapTable/TestInstrumentTemporalEligibility.daml index c6f0c9f1..22ec03b2 100644 --- a/Test/daml/OpenCapTable/TestInstrumentTemporalEligibility.daml +++ b/Test/daml/OpenCapTable/TestInstrumentTemporalEligibility.daml @@ -3,8 +3,10 @@ module OpenCapTable.TestInstrumentTemporalEligibility where import qualified Fairmint.OpenCapTable.CapTable as CT import Fairmint.OpenCapTable.OCF.ConvertibleConversion (ConvertibleConversionOcfData(..)) import Fairmint.OpenCapTable.OCF.ConvertibleIssuance (ConvertibleIssuanceOcfData(..), OcfConvertibleConversionTrigger(..)) +import Fairmint.OpenCapTable.OCF.EquityCompensationCancellation (EquityCompensationCancellationOcfData(..)) import Fairmint.OpenCapTable.OCF.EquityCompensationExercise (EquityCompensationExerciseOcfData(..)) import Fairmint.OpenCapTable.OCF.EquityCompensationIssuance (EquityCompensationIssuanceOcfData(..)) +import Fairmint.OpenCapTable.OCF.EquityCompensationTransfer (EquityCompensationTransferOcfData(..)) import Fairmint.OpenCapTable.OCF.WarrantExercise (WarrantExerciseOcfData(..)) import Fairmint.OpenCapTable.OCF.WarrantIssuance (WarrantIssuanceOcfData(..)) import Fairmint.OpenCapTable.Types.Conversion @@ -158,6 +160,27 @@ equityCompensationExercise transactionId securityId date = EquityCompensationExe consideration_text = None comments = [] +equityCompensationTransfer : Text -> Text -> Text -> Time -> Decimal -> EquityCompensationTransferOcfData +equityCompensationTransfer transactionId securityId resultingSecurityId date quantity = EquityCompensationTransferOcfData with + id = transactionId + date = date + security_id = securityId + quantity = quantity + resulting_security_ids = [resultingSecurityId] + balance_security_id = None + consideration_text = None + comments = [] + +equityCompensationCancellation : Text -> Text -> Text -> Time -> Decimal -> EquityCompensationCancellationOcfData +equityCompensationCancellation transactionId securityId balanceSecurityId date quantity = EquityCompensationCancellationOcfData with + id = transactionId + date = date + security_id = securityId + quantity = quantity + balance_security_id = Some balanceSecurityId + reason_text = "Partial cancellation" + comments = [] + testInstrumentTemporalEligibility_ReversedTriggerRangesFail = script do TestOcp{issuer, cap_table} <- setupTestOcp capTable <- addPrerequisites issuer cap_table @@ -524,3 +547,100 @@ testInstrumentTemporalEligibility_EditCannotExpireExercisedEquityCompensation = creates = [] edits = [CT.OcfEditEquityCompensationIssuance (sourceIssuance with expiration_date = Some jan01)] deletes = [] + +testInstrumentTemporalEligibility_EquityCompensationSuccessorsPreserveExpiration = script do + TestOcp{issuer, cap_table} <- setupTestOcp + capTable <- addPrerequisites issuer cap_table + let sourceIssuance = equityCompensationIssuance "TX-EC-TRANSFER-SOURCE" "SEC-EC-TRANSFER-SOURCE" (Some jan03) + successorIssuance = (equityCompensationIssuance "TX-EC-TRANSFER-SUCCESSOR" "SEC-EC-TRANSFER-SUCCESSOR" (Some jan03)) with date = jan02 + transfer = equityCompensationTransfer + "TX-EC-TRANSFER-PRESERVED-EXPIRATION" + sourceIssuance.security_id + successorIssuance.security_id + jan02 + 100.0 + sourceResult <- submit issuer do + exerciseCmd capTable CT.UpdateCapTable with + creates = [CT.OcfCreateEquityCompensationIssuance sourceIssuance] + edits = [] + deletes = [] + transferResult <- submit issuer do + exerciseCmd sourceResult.updatedCapTableCid CT.UpdateCapTable with + creates = + [ CT.OcfCreateEquityCompensationIssuance successorIssuance + , CT.OcfCreateEquityCompensationTransfer transfer + ] + edits = [] + deletes = [] + exerciseResult <- submit issuer do + exerciseCmd transferResult.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateEquityCompensationExercise (equityCompensationExercise "TX-EC-SUCCESSOR-EXPIRATION-EXACT" successorIssuance.security_id jan03)] + edits = [] + deletes = [] + + submitMustFail issuer do + exerciseCmd exerciseResult.updatedCapTableCid CT.UpdateCapTable with + creates = [] + edits = [CT.OcfEditEquityCompensationIssuance (successorIssuance with expiration_date = Some jan04)] + deletes = [] + +testInstrumentTemporalEligibility_EquityCompensationSuccessorExpirationCannotChange = script do + TestOcp{issuer, cap_table} <- setupTestOcp + capTable <- addPrerequisites issuer cap_table + let missingSource = equityCompensationIssuance "TX-EC-MISSING-EXPIRATION-SOURCE" "SEC-EC-MISSING-EXPIRATION-SOURCE" (Some jan03) + extendedSource = equityCompensationIssuance "TX-EC-EXTENDED-EXPIRATION-SOURCE" "SEC-EC-EXTENDED-EXPIRATION-SOURCE" (Some jan03) + cancelledSource = equityCompensationIssuance "TX-EC-CANCELLED-EXPIRATION-SOURCE" "SEC-EC-CANCELLED-EXPIRATION-SOURCE" (Some jan03) + missingSuccessor = (equityCompensationIssuance "TX-EC-MISSING-EXPIRATION-SUCCESSOR" "SEC-EC-MISSING-EXPIRATION-SUCCESSOR" None) with date = jan02 + extendedSuccessor = (equityCompensationIssuance "TX-EC-EXTENDED-EXPIRATION-SUCCESSOR" "SEC-EC-EXTENDED-EXPIRATION-SUCCESSOR" (Some jan04)) with date = jan02 + cancellationBalance = ((equityCompensationIssuance "TX-EC-CANCELLED-EXPIRATION-BALANCE" "SEC-EC-CANCELLED-EXPIRATION-BALANCE" (Some jan04)) with + date = jan02 + quantity = 90.0) + sourceResult <- submit issuer do + exerciseCmd capTable CT.UpdateCapTable with + creates = + [ CT.OcfCreateEquityCompensationIssuance missingSource + , CT.OcfCreateEquityCompensationIssuance extendedSource + , CT.OcfCreateEquityCompensationIssuance cancelledSource + ] + edits = [] + deletes = [] + + submitMustFail issuer do + exerciseCmd sourceResult.updatedCapTableCid CT.UpdateCapTable with + creates = + [ CT.OcfCreateEquityCompensationIssuance missingSuccessor + , CT.OcfCreateEquityCompensationTransfer (equityCompensationTransfer + "TX-EC-MISSING-SUCCESSOR-EXPIRATION" + missingSource.security_id + missingSuccessor.security_id + jan02 + 100.0) + ] + edits = [] + deletes = [] + submitMustFail issuer do + exerciseCmd sourceResult.updatedCapTableCid CT.UpdateCapTable with + creates = + [ CT.OcfCreateEquityCompensationIssuance extendedSuccessor + , CT.OcfCreateEquityCompensationTransfer (equityCompensationTransfer + "TX-EC-EXTENDED-SUCCESSOR-EXPIRATION" + extendedSource.security_id + extendedSuccessor.security_id + jan02 + 100.0) + ] + edits = [] + deletes = [] + submitMustFail issuer do + exerciseCmd sourceResult.updatedCapTableCid CT.UpdateCapTable with + creates = + [ CT.OcfCreateEquityCompensationIssuance cancellationBalance + , CT.OcfCreateEquityCompensationCancellation (equityCompensationCancellation + "TX-EC-CANCELLATION-BALANCE-EXPIRATION" + cancelledSource.security_id + cancellationBalance.security_id + jan02 + 10.0) + ] + edits = [] + deletes = [] diff --git a/Test/daml/OpenCapTable/TestNonStockSecurityBalances.daml b/Test/daml/OpenCapTable/TestNonStockSecurityBalances.daml index 697351d2..7eec8816 100644 --- a/Test/daml/OpenCapTable/TestNonStockSecurityBalances.daml +++ b/Test/daml/OpenCapTable/TestNonStockSecurityBalances.daml @@ -940,7 +940,7 @@ testEquityCompensationSecurityBalance_BalanceSecurityReductionPreservesRemainder creates = [ CT.OcfCreateEquityCompensationIssuance (equityCompensationIssuance "TX_E_BALANCE_REMAINDER_SOURCE" securityId defaultStakeholderId 100.0) , CT.OcfCreateEquityCompensationIssuance ((equityCompensationIssuance "TX_E_BALANCE_REMAINDER" "ESEC_BALANCE_REMAINDER" defaultStakeholderId 30.0) with date = jan02) - , equityCompensationSuccessorIssuance "EC-ISS-TRANSFER-BEFORE-BALANCE-CANCEL" "TX_E_TRANSFER_BEFORE_BALANCE_CANCEL_RESULT" defaultStakeholderId jan02 40.0 + , equityCompensationSuccessorIssuance "EC-ISS-TRANSFER-BEFORE-BALANCE-CANCEL" "TX_E_TRANSFER_BEFORE_BALANCE_CANCEL_RESULT" defaultStakeholderId jan02 40.0 None , CT.OcfCreateEquityCompensationTransfer (equityCompensationTransfer "TX_E_TRANSFER_BEFORE_BALANCE_CANCEL" securityId 40.0 jan02) , CT.OcfCreateEquityCompensationCancellation ((equityCompensationCancellation "TX_E_BALANCE_CANCEL_REMAINDER" securityId 30.0 jan02) with balance_security_id = Some "ESEC_BALANCE_REMAINDER") ] diff --git a/Test/daml/OpenCapTable/TestSecurityIdValidation.daml b/Test/daml/OpenCapTable/TestSecurityIdValidation.daml index 939e36fd..12376d7f 100644 --- a/Test/daml/OpenCapTable/TestSecurityIdValidation.daml +++ b/Test/daml/OpenCapTable/TestSecurityIdValidation.daml @@ -136,7 +136,7 @@ testEquityCompensationTransfer_NonExistentSecurityIdFails = script do -- Attempt to create an equity compensation transfer without an existing issuance submitMustFail issuer do exerciseCmd cap_table CT.UpdateCapTable with - creates = [equityCompensationSuccessorIssuance "EC-ISS-VALID-TRANSFER" "NEW-EC-1" "SH-1" (DT.time (DA.date 2024 Jan 01) 0 0 0) 100.0 + creates = [equityCompensationSuccessorIssuance "EC-ISS-VALID-TRANSFER" "NEW-EC-1" "SH-1" (DT.time (DA.date 2024 Jan 01) 0 0 0) 100.0 None , CT.OcfCreateEquityCompensationTransfer ECT.EquityCompensationTransferOcfData with id = "TX_EC_TRANSFER_1" date = DT.time (DA.date 2024 Jan 01) 0 0 0 @@ -233,7 +233,7 @@ testEquityCompensationTransfer_WrongSecurityTypeFails = script do -- Attempt to create an equity compensation transfer using the convertible's security_id submitMustFail issuer do exerciseCmd cap_table CT.UpdateCapTable with - creates = [equityCompensationSuccessorIssuance "EC-ISS-WRONG-SOURCE-TYPE" "NEW-EC-1" "SH-1" (DT.time (DA.date 2024 Jan 01) 0 0 0) 100.0 + creates = [equityCompensationSuccessorIssuance "EC-ISS-WRONG-SOURCE-TYPE" "NEW-EC-1" "SH-1" (DT.time (DA.date 2024 Jan 01) 0 0 0) 100.0 None , CT.OcfCreateEquityCompensationTransfer ECT.EquityCompensationTransferOcfData with id = "TX_EC_TRANSFER_WRONG_TYPE" date = DT.time (DA.date 2024 Jan 01) 0 0 0 diff --git a/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar b/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar index 7bbd8d1f..2db3d87a 100644 --- a/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar +++ b/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:82fd657a2ec6e8acd13cabc110a1523e883c0912cd533465ff7cdd4a6ed90321 -size 3307198 +oid sha256:7fa4957fe27196cee0b0af779ee344ea941668038cb2bc93a61a0d914b0d8fee +size 3309320 diff --git a/dars/dars.lock b/dars/dars.lock index 93dc0d52..c55b99ff 100644 --- a/dars/dars.lock +++ b/dars/dars.lock @@ -54,8 +54,8 @@ "networks": [] }, "OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar": { - "sha256": "82fd657a2ec6e8acd13cabc110a1523e883c0912cd533465ff7cdd4a6ed90321", - "size": 3307198, + "sha256": "7fa4957fe27196cee0b0af779ee344ea941668038cb2bc93a61a0d914b0d8fee", + "size": 3309320, "sdkVersion": "unknown", "uploadedAt": "2026-07-11T11:07:32.235Z", "networks": [] diff --git a/scripts/codegen/templates/CapTable.daml.template b/scripts/codegen/templates/CapTable.daml.template index 289642ee..0600db4a 100644 --- a/scripts/codegen/templates/CapTable.daml.template +++ b/scripts/codegen/templates/CapTable.daml.template @@ -5779,6 +5779,22 @@ validateEquityCompensationBalanceSuccessorQuantity maps label expectedStockClass None -> assertMsg (label <> " balance equity compensation issuance not found: " <> securityId) False +validateEquityCompensationSuccessorExpiration : + CapTableMaps -> + Text -> + Optional Time -> + Text -> + Update () +validateEquityCompensationSuccessorExpiration maps label sourceExpiration securityId = + case Map.lookup securityId maps.equity_compensation_issuances_by_security_id of + Some issuanceCid -> do + issuance <- fetch issuanceCid + assertMsg + (label <> " successor expiration mismatch for " <> securityId) + (issuance.issuance_data.expiration_date == sourceExpiration) + None -> + assertMsg (label <> " successor equity compensation issuance not found: " <> securityId) False + validateEquityCompensationCancellationSuccessorContinuity : CapTableMaps -> Update () validateEquityCompensationCancellationSuccessorContinuity maps = mapA_ @@ -5803,6 +5819,11 @@ validateEquityCompensationCancellationSuccessorContinuity maps = (label <> " exceeds the current source quantity for " <> sourceIssuance.issuance_data.security_id) (expectedBalanceQuantity >= 0.0) expectedStockClassIds <- equityCompensationSuccessorStockClassIds maps sourceIssuance.issuance_data + validateEquityCompensationSuccessorExpiration + maps + label + sourceIssuance.issuance_data.expiration_date + balanceSecurityId validateEquityCompensationBalanceSuccessorQuantity maps label @@ -5836,6 +5857,12 @@ validateEquityCompensationTransferSuccessorContinuity maps = sourceIssuance <- fetch sourceIssuanceCid let targetSortKey = stockReductionSortKey transfer.transfer_data.date transfer.transfer_data.balance_security_id expectedStockClassIds <- equityCompensationSuccessorStockClassIds maps sourceIssuance.issuance_data + mapA_ + (validateEquityCompensationSuccessorExpiration + maps + label + sourceIssuance.issuance_data.expiration_date) + transfer.transfer_data.resulting_security_ids resultingQuantity <- equityCompensationIssuanceQuantityTotal maps label @@ -5849,6 +5876,11 @@ validateEquityCompensationTransferSuccessorContinuity maps = case transfer.transfer_data.balance_security_id of Some balanceSecurityId -> do let terminalSortKey = (transfer.transfer_data.date, 2, 1) + validateEquityCompensationSuccessorExpiration + maps + label + sourceIssuance.issuance_data.expiration_date + balanceSecurityId currentQuantity <- equityCompensationSecurityQuantityBeforeEvent maps sourceIssuance.issuance_data From 55c38abde3ebf84fce83ec9247b6082e3e6bef0f Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 07:56:21 -0400 Subject: [PATCH 03/13] fix: clarify trigger timing validation --- dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar | 4 ++-- dars/dars.lock | 4 ++-- scripts/codegen/templates/CapTable.daml.template | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar b/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar index 2db3d87a..595103dd 100644 --- a/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar +++ b/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7fa4957fe27196cee0b0af779ee344ea941668038cb2bc93a61a0d914b0d8fee -size 3309320 +oid sha256:352190dc5c9df9e87877106bf40d969a9608241403b7710bcb38c6e18dfe5a68 +size 3309318 diff --git a/dars/dars.lock b/dars/dars.lock index c55b99ff..f52dfe2c 100644 --- a/dars/dars.lock +++ b/dars/dars.lock @@ -54,8 +54,8 @@ "networks": [] }, "OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar": { - "sha256": "7fa4957fe27196cee0b0af779ee344ea941668038cb2bc93a61a0d914b0d8fee", - "size": 3309320, + "sha256": "352190dc5c9df9e87877106bf40d969a9608241403b7710bcb38c6e18dfe5a68", + "size": 3309318, "sdkVersion": "unknown", "uploadedAt": "2026-07-11T11:07:32.235Z", "networks": [] diff --git a/scripts/codegen/templates/CapTable.daml.template b/scripts/codegen/templates/CapTable.daml.template index 0600db4a..5755a234 100644 --- a/scripts/codegen/templates/CapTable.daml.template +++ b/scripts/codegen/templates/CapTable.daml.template @@ -4558,7 +4558,7 @@ assertConversionTriggerDefinitionIsTemporallyValid : Update () assertConversionTriggerDefinitionIsTemporallyValid label expirationDate triggerType startDate endDate triggerDate = assertMsg - (label <> " has an invalid elective range or dates after the instrument expiration") + (label <> " has invalid trigger timing or extends beyond the instrument expiration") (conversionTriggerDefinitionIsTemporallyValid expirationDate triggerType startDate endDate triggerDate) assertConversionTriggerIsEligibleAt : From fca6e54b6142bba43413cf3049e47bff1f9ae517 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 08:17:51 -0400 Subject: [PATCH 04/13] fix: validate reachable instrument timing --- .../OpenCapTable/TestConvertibleTransfer.daml | 33 +++++++++- .../TestInstrumentTemporalEligibility.daml | 57 +++++++++++++++- .../0.0.16/OpenCapTable-v34.dar | 4 +- dars/dars.lock | 4 +- .../codegen/templates/CapTable.daml.template | 66 ++++++++++++++++++- 5 files changed, 157 insertions(+), 7 deletions(-) diff --git a/Test/daml/OpenCapTable/TestConvertibleTransfer.daml b/Test/daml/OpenCapTable/TestConvertibleTransfer.daml index 9667f5ff..9419c297 100644 --- a/Test/daml/OpenCapTable/TestConvertibleTransfer.daml +++ b/Test/daml/OpenCapTable/TestConvertibleTransfer.daml @@ -108,7 +108,7 @@ testConvertibleTransfer_NoteFullAssignment = script do (capTableCid, assigneeId) <- setupStakeholderWithId issuer capTableCid "SH-ASSIGNEE" "Convertible Assignee" let securityId = "CSEC-NOTE-001" -- Create a convertible note issuance - let noteTriggerDate = DT.time (DA.date 2025 Jun 15) 0 0 0 + let noteTriggerDate = DT.time (DA.date 2026 Jun 15) 0 0 0 result <- submit issuer do exerciseCmd capTableCid CT.UpdateCapTable with creates = [CT.OcfCreateConvertibleIssuance (convertibleTransferIssuance @@ -133,3 +133,34 @@ testConvertibleTransfer_NoteFullAssignment = script do edits = [] deletes = [] pure () + +testConvertibleTransfer_AllTriggersBeforeSuccessorIssuanceFail = script do + TestOcp{issuer, cap_table} <- setupTestOcp + capTableCid <- addDefaultStakeholder issuer cap_table + (capTableCid, assigneeId) <- setupStakeholderWithId issuer capTableCid "SH-ASSIGNEE" "Convertible Assignee" + let securityId = "CSEC-NOTE-EXPIRED-TRIGGER" + triggerDate = DT.time (DA.date 2025 Jun 15) 0 0 0 + transferDate = DT.time (DA.date 2025 Dec 01) 0 0 0 + result <- submit issuer do + exerciseCmd capTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateConvertibleIssuance (convertibleTransferIssuance + "TX_NOTE_EXPIRED_TRIGGER_ISSUANCE" securityId "SH-1" 250000.0 + (DT.time (DA.date 2023 Jun 15) 0 0 0) OcfConvertibleNote triggerDate)] + edits = [] + deletes = [] + submitMustFail issuer do + exerciseCmd result.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateConvertibleIssuance (convertibleTransferIssuance + "TX_NOTE_EXPIRED_TRIGGER_SUCCESSOR" "CSEC-NOTE-EXPIRED-TRIGGER-SUCCESSOR" assigneeId 250000.0 + transferDate OcfConvertibleNote triggerDate) + , CT.OcfCreateConvertibleTransfer ConvertibleTransferOcfData with + id = "TX_NOTE_EXPIRED_TRIGGER_TRANSFER" + date = transferDate + amount = OcfMonetary with amount = 250000.0, currency = "USD" + security_id = securityId + resulting_security_ids = ["CSEC-NOTE-EXPIRED-TRIGGER-SUCCESSOR"] + comments = [] + balance_security_id = None + consideration_text = None] + edits = [] + deletes = [] diff --git a/Test/daml/OpenCapTable/TestInstrumentTemporalEligibility.daml b/Test/daml/OpenCapTable/TestInstrumentTemporalEligibility.daml index 22ec03b2..40560b44 100644 --- a/Test/daml/OpenCapTable/TestInstrumentTemporalEligibility.daml +++ b/Test/daml/OpenCapTable/TestInstrumentTemporalEligibility.daml @@ -235,6 +235,48 @@ testInstrumentTemporalEligibility_TriggerTermsCannotOutliveWarrantExpiration = s edits = [] deletes = [] +testInstrumentTemporalEligibility_TriggerSetMustRemainReachableAfterIssuance = script do + TestOcp{issuer, cap_table} <- setupTestOcp + capTable <- addPrerequisites issuer cap_table + let automaticTrigger = fixedConvertibleTrigger + "CV-AUTO-BEFORE-ISSUANCE" + OcfTriggerTypeTypeAutomaticOnDate + None + None + (Some dec31) + rangeTrigger = fixedWarrantTrigger + "WR-RANGE-BEFORE-ISSUANCE" + OcfTriggerTypeTypeElectiveInRange + (Some dec31) + (Some dec31) + None + futureTrigger = fixedConvertibleTrigger + "CV-AUTO-AFTER-ISSUANCE" + OcfTriggerTypeTypeAutomaticOnDate + None + None + (Some jan02) + mixedTriggerIssuance = + (convertibleIssuance "TX-CV-MIXED-TRIGGERS" "SEC-CV-MIXED-TRIGGERS" automaticTrigger) with + conversion_triggers = [automaticTrigger, futureTrigger] + + submitMustFail issuer do + exerciseCmd capTable CT.UpdateCapTable with + creates = [CT.OcfCreateConvertibleIssuance (convertibleIssuance "TX-CV-AUTO-BEFORE-ISSUANCE" "SEC-CV-AUTO-BEFORE-ISSUANCE" automaticTrigger)] + edits = [] + deletes = [] + submitMustFail issuer do + exerciseCmd capTable CT.UpdateCapTable with + creates = [CT.OcfCreateWarrantIssuance (warrantIssuance "TX-WR-RANGE-BEFORE-ISSUANCE" "SEC-WR-RANGE-BEFORE-ISSUANCE" rangeTrigger None)] + edits = [] + deletes = [] + _ <- submit issuer do + exerciseCmd capTable CT.UpdateCapTable with + creates = [CT.OcfCreateConvertibleIssuance mixedTriggerIssuance] + edits = [] + deletes = [] + pure () + testInstrumentTemporalEligibility_AutomaticConversionRequiresExactDate = script do TestOcp{issuer, cap_table} <- setupTestOcp capTable <- addPrerequisites issuer cap_table @@ -450,6 +492,11 @@ testInstrumentTemporalEligibility_NoteInterestIntervalsMustBeConsistent = script [ interestRate jan03 None , interestRate jan01 (Some jan02) ] + implicitEndRates = + [ interestRate jan01 None + , interestRate jan03 None + ] + preIssuanceRates = [interestRate dec31 None] reversedRates = [interestRate jan03 (Some jan02)] overlappingRates = [ interestRate jan01 (Some jan03) @@ -461,7 +508,15 @@ testInstrumentTemporalEligibility_NoteInterestIntervalsMustBeConsistent = script ] validResult <- submit issuer do exerciseCmd capTable CT.UpdateCapTable with - creates = [CT.OcfCreateConvertibleIssuance (convertibleIssuance "TX-CV-NOTE-VALID" "SEC-CV-NOTE-VALID" (noteTrigger "CV-NOTE-VALID" validRates))] + creates = + [ CT.OcfCreateConvertibleIssuance (convertibleIssuance "TX-CV-NOTE-VALID" "SEC-CV-NOTE-VALID" (noteTrigger "CV-NOTE-VALID" validRates)) + , CT.OcfCreateConvertibleIssuance (convertibleIssuance "TX-CV-NOTE-IMPLICIT-END" "SEC-CV-NOTE-IMPLICIT-END" (noteTrigger "CV-NOTE-IMPLICIT-END" implicitEndRates)) + ] + edits = [] + deletes = [] + submitMustFail issuer do + exerciseCmd validResult.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateConvertibleIssuance (convertibleIssuance "TX-CV-NOTE-PRE-ISSUANCE" "SEC-CV-NOTE-PRE-ISSUANCE" (noteTrigger "CV-NOTE-PRE-ISSUANCE" preIssuanceRates))] edits = [] deletes = [] submitMustFail issuer do diff --git a/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar b/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar index 595103dd..83cc7f2e 100644 --- a/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar +++ b/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:352190dc5c9df9e87877106bf40d969a9608241403b7710bcb38c6e18dfe5a68 -size 3309318 +oid sha256:b5ecd5ef937439c61c1a58444a306b0b785406edf8c6c51adb6260f7da946e24 +size 3315715 diff --git a/dars/dars.lock b/dars/dars.lock index f52dfe2c..f78491e4 100644 --- a/dars/dars.lock +++ b/dars/dars.lock @@ -54,8 +54,8 @@ "networks": [] }, "OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar": { - "sha256": "352190dc5c9df9e87877106bf40d969a9608241403b7710bcb38c6e18dfe5a68", - "size": 3309318, + "sha256": "b5ecd5ef937439c61c1a58444a306b0b785406edf8c6c51adb6260f7da946e24", + "size": 3315715, "sdkVersion": "unknown", "uploadedAt": "2026-07-11T11:07:32.235Z", "networks": [] diff --git a/scripts/codegen/templates/CapTable.daml.template b/scripts/codegen/templates/CapTable.daml.template index 5755a234..b54e7c47 100644 --- a/scripts/codegen/templates/CapTable.daml.template +++ b/scripts/codegen/templates/CapTable.daml.template @@ -4513,6 +4513,27 @@ validateWarrantExerciseTriggerReferences maps = None -> assertMsg ("Security not found: " <> exercise.exercise_data.security_id) False) (Map.values maps.warrant_exercises) +convertibleSuccessorSecurityIds : CapTableMaps -> Update [Text] +convertibleSuccessorSecurityIds maps = do + transferIds <- foldlA + (\ids transferCid -> do + transfer <- fetch transferCid + pure (successorSecurityIdsFor transfer.transfer_data.balance_security_id transfer.transfer_data.resulting_security_ids ++ ids)) + [] + (Map.values maps.convertible_transfers) + cancellationIds <- foldlA + (\ids cancellationCid -> do + cancellation <- fetch cancellationCid + pure (successorSecurityIdsFor cancellation.cancellation_data.balance_security_id [] ++ ids)) + transferIds + (Map.values maps.convertible_cancellations) + foldlA + (\ids conversionCid -> do + conversion <- fetch conversionCid + pure (successorSecurityIdsFor conversion.conversion_data.balance_security_id [] ++ ids)) + cancellationIds + (Map.values maps.convertible_conversions) + conversionTriggerDefinitionIsTemporallyValid : Optional Time -> OcfConversionTriggerType -> @@ -4531,6 +4552,20 @@ conversionTriggerDefinitionIsTemporallyValid expirationDate triggerType startDat _ -> False _ -> True +conversionTriggerCanBeEligibleOnOrAfter : + Time -> + OcfConversionTriggerType -> + Optional Time -> + Optional Time -> + Bool +conversionTriggerCanBeEligibleOnOrAfter issuanceDate triggerType endDate triggerDate = + case triggerType of + OcfTriggerTypeTypeAutomaticOnDate -> + optional False (>= issuanceDate) triggerDate + OcfTriggerTypeTypeElectiveInRange -> + optional False (>= issuanceDate) endDate + _ -> True + conversionTriggerIsEligibleAt : Time -> OcfConversionTriggerType -> @@ -4593,10 +4628,24 @@ assertEventOnOrBeforeExpiration label eventDate expirationDate = None -> pure () validateConvertibleIssuanceTemporalTerms : CapTableMaps -> Update () -validateConvertibleIssuanceTemporalTerms maps = +validateConvertibleIssuanceTemporalTerms maps = do + successorIds <- convertibleSuccessorSecurityIds maps mapA_ (\issuanceCid -> do issuance <- fetch issuanceCid + let rootIssuanceDate = + if issuance.issuance_data.security_id `elem` successorIds + then None + else Some issuance.issuance_data.date + assertMsg + ("Convertible issuance " <> issuance.issuance_data.id <> " has no conversion trigger reachable on or after issuance") + (any + (\trigger -> conversionTriggerCanBeEligibleOnOrAfter + issuance.issuance_data.date + trigger.type_ + trigger.end_date + trigger.trigger_date) + issuance.issuance_data.conversion_triggers) mapA_ (\trigger -> do let label = "Convertible issuance " <> issuance.issuance_data.id <> " trigger " <> trigger.trigger_id @@ -4612,6 +4661,12 @@ validateConvertibleIssuanceTemporalTerms maps = assertMsg (label <> " has invalid or overlapping interest rate intervals") (validateOcfInterestRateSchedule noteMechanism.interest_rates) + case rootIssuanceDate of + Some issuanceDate -> + assertMsg + (label <> " has interest accruing before issuance " <> show issuanceDate) + (all (\rate -> rate.accrual_start_date >= issuanceDate) noteMechanism.interest_rates) + None -> pure () _ -> pure ()) issuance.issuance_data.conversion_triggers) (Map.values maps.convertible_issuances) @@ -4625,6 +4680,15 @@ validateWarrantIssuanceTemporalTerms maps = ("Warrant issuance " <> issuance.issuance_data.id) issuance.issuance_data.date issuance.issuance_data.warrant_expiration_date + assertMsg + ("Warrant issuance " <> issuance.issuance_data.id <> " has no exercise trigger reachable on or after issuance") + (null issuance.issuance_data.exercise_triggers || any + (\trigger -> conversionTriggerCanBeEligibleOnOrAfter + issuance.issuance_data.date + trigger.type_ + trigger.end_date + trigger.trigger_date) + issuance.issuance_data.exercise_triggers) mapA_ (\trigger -> assertConversionTriggerDefinitionIsTemporallyValid From 8a2f806e384cad1d32294a1d1ebd6f3ac3fe2a48 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 08:32:19 -0400 Subject: [PATCH 05/13] fix: reject ambiguous trigger identifiers --- .../OpenCapTable/OCF/ConvertibleIssuance.daml | 2 + .../OpenCapTable/OCF/WarrantIssuance.daml | 2 + .../TestInstrumentTemporalEligibility.daml | 45 +++++++++++++++++++ .../0.0.16/OpenCapTable-v34.dar | 4 +- dars/dars.lock | 4 +- 5 files changed, 53 insertions(+), 4 deletions(-) diff --git a/OpenCapTable-v34/daml/Fairmint/OpenCapTable/OCF/ConvertibleIssuance.daml b/OpenCapTable-v34/daml/Fairmint/OpenCapTable/OCF/ConvertibleIssuance.daml index bbd1d172..c7022e42 100644 --- a/OpenCapTable-v34/daml/Fairmint/OpenCapTable/OCF/ConvertibleIssuance.daml +++ b/OpenCapTable-v34/daml/Fairmint/OpenCapTable/OCF/ConvertibleIssuance.daml @@ -7,6 +7,7 @@ import Fairmint.OpenCapTable.Types.Monetary (OcfMonetary, validateOcfMonetary) import Fairmint.OpenCapTable.Types.Stock (OcfSecurityExemption, validateOcfSecurityExemption) import Fairmint.OpenCapTable.Types.Conversion (OcfConvertibleType, OcfConversionTriggerType(..), OcfConvertibleConversionRight(..), validateOcfConvertibleConversionRight, validateOcfConversionTriggerFields) import Fairmint.Shared.TypeHelpers (validateOptionalText) +import DA.List (unique) template ConvertibleIssuance @@ -85,6 +86,7 @@ validateConvertibleIssuanceOcfData d = validateOptionalText d.consideration_text && -- Required arrays per schema not (null d.conversion_triggers) && + unique (map (\trigger -> trigger.trigger_id) d.conversion_triggers) && all validateOcfConvertibleConversionTrigger d.conversion_triggers diff --git a/OpenCapTable-v34/daml/Fairmint/OpenCapTable/OCF/WarrantIssuance.daml b/OpenCapTable-v34/daml/Fairmint/OpenCapTable/OCF/WarrantIssuance.daml index 8c12c063..6d701690 100644 --- a/OpenCapTable-v34/daml/Fairmint/OpenCapTable/OCF/WarrantIssuance.daml +++ b/OpenCapTable-v34/daml/Fairmint/OpenCapTable/OCF/WarrantIssuance.daml @@ -8,6 +8,7 @@ import Fairmint.OpenCapTable.Types.Stock (OcfQuantitySourceType, OcfSecurityExem import Fairmint.OpenCapTable.Types.Vesting (OcfVesting, validateOcfVesting) import Fairmint.OpenCapTable.Types.Conversion (OcfConversionTrigger, validateOcfConversionTrigger) import Fairmint.Shared.TypeHelpers (validateOptionalText) +import DA.List (unique) template WarrantIssuance with @@ -94,6 +95,7 @@ validateOcfWarrantIssuanceDataTx d = validateOcfMonetary d.purchase_price && validateOptionalText d.consideration_text && -- exercise_triggers: OCF schema requires array but no minItems, so empty array is valid + unique (map (\trigger -> trigger.trigger_id) d.exercise_triggers) && all validateOcfConversionTrigger d.exercise_triggers && validateOptionalText d.vesting_terms_id && -- If vestings array present, enforce items are valid and minItems >= 1 diff --git a/Test/daml/OpenCapTable/TestInstrumentTemporalEligibility.daml b/Test/daml/OpenCapTable/TestInstrumentTemporalEligibility.daml index 40560b44..c2924c35 100644 --- a/Test/daml/OpenCapTable/TestInstrumentTemporalEligibility.daml +++ b/Test/daml/OpenCapTable/TestInstrumentTemporalEligibility.daml @@ -277,6 +277,51 @@ testInstrumentTemporalEligibility_TriggerSetMustRemainReachableAfterIssuance = s deletes = [] pure () +testInstrumentTemporalEligibility_TriggerIdsMustBeUniqueWithinIssuance = script do + TestOcp{issuer, cap_table} <- setupTestOcp + capTable <- addPrerequisites issuer cap_table + let convertibleTriggerA = fixedConvertibleTrigger + "CV-DUPLICATE" + OcfTriggerTypeTypeAutomaticOnDate + None + None + (Some jan02) + convertibleTriggerB = fixedConvertibleTrigger + "CV-DUPLICATE" + OcfTriggerTypeTypeAutomaticOnDate + None + None + (Some jan03) + duplicateConvertibleIssuance = + (convertibleIssuance "TX-CV-DUPLICATE-TRIGGER" "SEC-CV-DUPLICATE-TRIGGER" convertibleTriggerA) with + conversion_triggers = [convertibleTriggerA, convertibleTriggerB] + warrantTriggerA = fixedWarrantTrigger + "WR-DUPLICATE" + OcfTriggerTypeTypeAutomaticOnDate + None + None + (Some jan02) + warrantTriggerB = fixedWarrantTrigger + "WR-DUPLICATE" + OcfTriggerTypeTypeAutomaticOnDate + None + None + (Some jan03) + duplicateWarrantIssuance = + (warrantIssuance "TX-WR-DUPLICATE-TRIGGER" "SEC-WR-DUPLICATE-TRIGGER" warrantTriggerA None) with + exercise_triggers = [warrantTriggerA, warrantTriggerB] + + submitMustFail issuer do + exerciseCmd capTable CT.UpdateCapTable with + creates = [CT.OcfCreateConvertibleIssuance duplicateConvertibleIssuance] + edits = [] + deletes = [] + submitMustFail issuer do + exerciseCmd capTable CT.UpdateCapTable with + creates = [CT.OcfCreateWarrantIssuance duplicateWarrantIssuance] + edits = [] + deletes = [] + testInstrumentTemporalEligibility_AutomaticConversionRequiresExactDate = script do TestOcp{issuer, cap_table} <- setupTestOcp capTable <- addPrerequisites issuer cap_table diff --git a/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar b/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar index 83cc7f2e..1ae17da9 100644 --- a/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar +++ b/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b5ecd5ef937439c61c1a58444a306b0b785406edf8c6c51adb6260f7da946e24 -size 3315715 +oid sha256:de69e1824443dc66470602a2a1f5fd9c5f5fc1ae7c3e2e2587b5bb4fbfbad5a6 +size 3316729 diff --git a/dars/dars.lock b/dars/dars.lock index f78491e4..ca7b63b9 100644 --- a/dars/dars.lock +++ b/dars/dars.lock @@ -54,8 +54,8 @@ "networks": [] }, "OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar": { - "sha256": "b5ecd5ef937439c61c1a58444a306b0b785406edf8c6c51adb6260f7da946e24", - "size": 3315715, + "sha256": "de69e1824443dc66470602a2a1f5fd9c5f5fc1ae7c3e2e2587b5bb4fbfbad5a6", + "size": 3316729, "sdkVersion": "unknown", "uploadedAt": "2026-07-11T11:07:32.235Z", "networks": [] From 3987bda6eaeb90042776cd61824a8e4254eeaece Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 09:49:45 -0400 Subject: [PATCH 06/13] fix: validate final trigger id uniqueness --- .../TestInstrumentTemporalEligibility.daml | 19 +++++++++++++++++++ .../0.0.16/OpenCapTable-v34.dar | 4 ++-- dars/dars.lock | 6 +++--- .../codegen/templates/CapTable.daml.template | 14 ++++++++++++++ 4 files changed, 38 insertions(+), 5 deletions(-) diff --git a/Test/daml/OpenCapTable/TestInstrumentTemporalEligibility.daml b/Test/daml/OpenCapTable/TestInstrumentTemporalEligibility.daml index c2924c35..1beb96d9 100644 --- a/Test/daml/OpenCapTable/TestInstrumentTemporalEligibility.daml +++ b/Test/daml/OpenCapTable/TestInstrumentTemporalEligibility.daml @@ -322,6 +322,25 @@ testInstrumentTemporalEligibility_TriggerIdsMustBeUniqueWithinIssuance = script edits = [] deletes = [] +testInstrumentTemporalEligibility_FinalStateTriggerIdChecksRejectDuplicates = script do + let convertibleTrigger = fixedConvertibleTrigger + "CV-FINAL-DUPLICATE" + OcfTriggerTypeTypeAutomaticOnDate + None + None + (Some jan02) + warrantTrigger = fixedWarrantTrigger + "WR-FINAL-DUPLICATE" + OcfTriggerTypeTypeAutomaticOnDate + None + None + (Some jan02) + + assert (not (CT.convertibleTriggerIdsAreUnique [convertibleTrigger, convertibleTrigger])) + assert (not (CT.warrantTriggerIdsAreUnique [warrantTrigger, warrantTrigger])) + assert (CT.convertibleTriggerIdsAreUnique [convertibleTrigger]) + assert (CT.warrantTriggerIdsAreUnique [warrantTrigger]) + testInstrumentTemporalEligibility_AutomaticConversionRequiresExactDate = script do TestOcp{issuer, cap_table} <- setupTestOcp capTable <- addPrerequisites issuer cap_table diff --git a/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar b/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar index 1ae17da9..1bc00797 100644 --- a/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar +++ b/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:de69e1824443dc66470602a2a1f5fd9c5f5fc1ae7c3e2e2587b5bb4fbfbad5a6 -size 3316729 +oid sha256:ef6e765eaa0e140c6f4804db7232288ca643baa4b9bc8b2a6cd09a546f4a0d3d +size 3318578 diff --git a/dars/dars.lock b/dars/dars.lock index ca7b63b9..32871bf7 100644 --- a/dars/dars.lock +++ b/dars/dars.lock @@ -54,10 +54,10 @@ "networks": [] }, "OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar": { - "sha256": "de69e1824443dc66470602a2a1f5fd9c5f5fc1ae7c3e2e2587b5bb4fbfbad5a6", - "size": 3316729, + "sha256": "ef6e765eaa0e140c6f4804db7232288ca643baa4b9bc8b2a6cd09a546f4a0d3d", + "size": 3318578, "sdkVersion": "unknown", - "uploadedAt": "2026-07-11T11:07:32.235Z", + "uploadedAt": "2026-07-11T13:48:45.000Z", "networks": [] }, "OpenCapTable-v34/0.0.2/OpenCapTable-v34.dar": { diff --git a/scripts/codegen/templates/CapTable.daml.template b/scripts/codegen/templates/CapTable.daml.template index b54e7c47..5195698e 100644 --- a/scripts/codegen/templates/CapTable.daml.template +++ b/scripts/codegen/templates/CapTable.daml.template @@ -4627,12 +4627,23 @@ assertEventOnOrBeforeExpiration label eventDate expirationDate = (eventDate <= expiration) None -> pure () +convertibleTriggerIdsAreUnique : [ConvertibleIssuance.OcfConvertibleConversionTrigger] -> Bool +convertibleTriggerIdsAreUnique triggers = + unique (map (\trigger -> trigger.trigger_id) triggers) + +warrantTriggerIdsAreUnique : [OcfConversionTrigger] -> Bool +warrantTriggerIdsAreUnique triggers = + unique (map (\trigger -> trigger.trigger_id) triggers) + validateConvertibleIssuanceTemporalTerms : CapTableMaps -> Update () validateConvertibleIssuanceTemporalTerms maps = do successorIds <- convertibleSuccessorSecurityIds maps mapA_ (\issuanceCid -> do issuance <- fetch issuanceCid + assertMsg + ("Convertible issuance " <> issuance.issuance_data.id <> " has duplicate conversion trigger IDs") + (convertibleTriggerIdsAreUnique issuance.issuance_data.conversion_triggers) let rootIssuanceDate = if issuance.issuance_data.security_id `elem` successorIds then None @@ -4676,6 +4687,9 @@ validateWarrantIssuanceTemporalTerms maps = mapA_ (\issuanceCid -> do issuance <- fetch issuanceCid + assertMsg + ("Warrant issuance " <> issuance.issuance_data.id <> " has duplicate exercise trigger IDs") + (warrantTriggerIdsAreUnique issuance.issuance_data.exercise_triggers) assertExpirationOnOrAfterIssuance ("Warrant issuance " <> issuance.issuance_data.id) issuance.issuance_data.date From d612cdd4df4991b116577f16307ab65371a4f228 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 10:17:06 -0400 Subject: [PATCH 07/13] chore: refresh temporal eligibility dar --- dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar | 4 ++-- dars/dars.lock | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar b/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar index 1bc00797..a71bbf82 100644 --- a/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar +++ b/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ef6e765eaa0e140c6f4804db7232288ca643baa4b9bc8b2a6cd09a546f4a0d3d -size 3318578 +oid sha256:00c6984bd7d1104f60f63d9d6a279b7feab3ef9e86500068779da258393da2b5 +size 3328374 diff --git a/dars/dars.lock b/dars/dars.lock index 7c34d72a..1f153744 100644 --- a/dars/dars.lock +++ b/dars/dars.lock @@ -54,8 +54,8 @@ "networks": [] }, "OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar": { - "sha256": "ef6e765eaa0e140c6f4804db7232288ca643baa4b9bc8b2a6cd09a546f4a0d3d", - "size": 3318578, + "sha256": "00c6984bd7d1104f60f63d9d6a279b7feab3ef9e86500068779da258393da2b5", + "size": 3328374, "sdkVersion": "unknown", "uploadedAt": "2026-07-11T13:48:45.000Z", "networks": [] From 18740ae988d63e8855d08d28094c525231579311 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 13:32:49 -0400 Subject: [PATCH 08/13] chore: refresh restacked temporal eligibility DAR --- dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar | 4 ++-- dars/dars.lock | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar b/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar index a71bbf82..e0523628 100644 --- a/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar +++ b/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:00c6984bd7d1104f60f63d9d6a279b7feab3ef9e86500068779da258393da2b5 -size 3328374 +oid sha256:63746e0a91051ce792cfa16e26e4fd84818546800438eadacfba016b792529c8 +size 3329230 diff --git a/dars/dars.lock b/dars/dars.lock index 363eecb9..6a5b4a37 100644 --- a/dars/dars.lock +++ b/dars/dars.lock @@ -54,10 +54,10 @@ "networks": [] }, "OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar": { - "sha256": "00c6984bd7d1104f60f63d9d6a279b7feab3ef9e86500068779da258393da2b5", - "size": 3328374, - "sdkVersion": "unknown", - "uploadedAt": "2026-07-11T13:48:45.000Z", + "sha256": "63746e0a91051ce792cfa16e26e4fd84818546800438eadacfba016b792529c8", + "size": 3329230, + "sdkVersion": "3.4.10", + "uploadedAt": "2026-07-11T17:31:18.000Z", "networks": [] }, "OpenCapTable-v34/0.0.2/OpenCapTable-v34.dar": { From a1b2516f4e9de3a15ab31d4b88c0ae3206ebdb44 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 15:44:15 -0400 Subject: [PATCH 09/13] perf: index convertible successor ids --- .../0.0.16/OpenCapTable-v34.dar | 4 ++-- dars/dars.lock | 4 ++-- .../codegen/templates/CapTable.daml.template | 18 ++++++++++++------ 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar b/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar index e0523628..6a4398ad 100644 --- a/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar +++ b/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:63746e0a91051ce792cfa16e26e4fd84818546800438eadacfba016b792529c8 -size 3329230 +oid sha256:2d281f45d76585bd3a05c89b7afdc980f95fa43e411ded0b83010ca133312380 +size 3329513 diff --git a/dars/dars.lock b/dars/dars.lock index 6a5b4a37..50825eba 100644 --- a/dars/dars.lock +++ b/dars/dars.lock @@ -54,8 +54,8 @@ "networks": [] }, "OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar": { - "sha256": "63746e0a91051ce792cfa16e26e4fd84818546800438eadacfba016b792529c8", - "size": 3329230, + "sha256": "2d281f45d76585bd3a05c89b7afdc980f95fa43e411ded0b83010ca133312380", + "size": 3329513, "sdkVersion": "3.4.10", "uploadedAt": "2026-07-11T17:31:18.000Z", "networks": [] diff --git a/scripts/codegen/templates/CapTable.daml.template b/scripts/codegen/templates/CapTable.daml.template index 474ea856..5cd0b4a0 100644 --- a/scripts/codegen/templates/CapTable.daml.template +++ b/scripts/codegen/templates/CapTable.daml.template @@ -4524,24 +4524,30 @@ validateWarrantExerciseTriggerReferences maps = None -> assertMsg ("Security not found: " <> exercise.exercise_data.security_id) False) (Map.values maps.warrant_exercises) -convertibleSuccessorSecurityIds : CapTableMaps -> Update [Text] +convertibleSuccessorSecurityIds : CapTableMaps -> Update (Set Text) convertibleSuccessorSecurityIds maps = do transferIds <- foldlA (\ids transferCid -> do transfer <- fetch transferCid - pure (successorSecurityIdsFor transfer.transfer_data.balance_security_id transfer.transfer_data.resulting_security_ids ++ ids)) - [] + pure (Set.union + (Set.fromList (successorSecurityIdsFor transfer.transfer_data.balance_security_id transfer.transfer_data.resulting_security_ids)) + ids)) + Set.empty (Map.values maps.convertible_transfers) cancellationIds <- foldlA (\ids cancellationCid -> do cancellation <- fetch cancellationCid - pure (successorSecurityIdsFor cancellation.cancellation_data.balance_security_id [] ++ ids)) + pure (Set.union + (Set.fromList (successorSecurityIdsFor cancellation.cancellation_data.balance_security_id [])) + ids)) transferIds (Map.values maps.convertible_cancellations) foldlA (\ids conversionCid -> do conversion <- fetch conversionCid - pure (successorSecurityIdsFor conversion.conversion_data.balance_security_id [] ++ ids)) + pure (Set.union + (Set.fromList (successorSecurityIdsFor conversion.conversion_data.balance_security_id [])) + ids)) cancellationIds (Map.values maps.convertible_conversions) @@ -4656,7 +4662,7 @@ validateConvertibleIssuanceTemporalTerms maps = do ("Convertible issuance " <> issuance.issuance_data.id <> " has duplicate conversion trigger IDs") (convertibleTriggerIdsAreUnique issuance.issuance_data.conversion_triggers) let rootIssuanceDate = - if issuance.issuance_data.security_id `elem` successorIds + if Set.member issuance.issuance_data.security_id successorIds then None else Some issuance.issuance_data.date assertMsg From ff81327955e6c856c761a2ac244a93187cf16c89 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 17:08:45 -0400 Subject: [PATCH 10/13] fix: preserve convertible successor trigger ids --- .../TestConvertibleSecurityBalances.daml | 53 ++++++++++++++++++- Test/daml/OpenCapTable/TestHelpers.daml | 2 +- .../0.0.16/OpenCapTable-v34.dar | 4 +- dars/dars.lock | 4 +- .../codegen/templates/CapTable.daml.template | 9 +--- 5 files changed, 59 insertions(+), 13 deletions(-) diff --git a/Test/daml/OpenCapTable/TestConvertibleSecurityBalances.daml b/Test/daml/OpenCapTable/TestConvertibleSecurityBalances.daml index 59a3cac5..4192c2d7 100644 --- a/Test/daml/OpenCapTable/TestConvertibleSecurityBalances.daml +++ b/Test/daml/OpenCapTable/TestConvertibleSecurityBalances.daml @@ -68,7 +68,7 @@ convertibleConversion transactionId securityId triggerId date resultingSecurityI convertibleIssuance : Text -> Text -> Text -> Time -> Decimal -> ConvertibleIssuanceOcfData convertibleIssuance issuanceId securityId stakeholderId date amount = - (defaultConvertibleIssuance issuanceId securityId stakeholderId ("TRIGGER-" <> issuanceId)) with + (defaultConvertibleIssuance issuanceId securityId stakeholderId "TRIGGER-CONVERTIBLE-CONTINUITY") with date = date investment_amount = OcfMonetary with amount = amount, currency = "USD" @@ -282,6 +282,57 @@ testConvertibleSecurityBalance_TransferCannotChangeSuccessorConversionTerms = sc edits = [] deletes = [] +testConvertibleSecurityBalance_TransferCannotRenameSuccessorTrigger = script do + TestOcp{issuer, cap_table} <- setupTestOcp + capTableReady <- addDefaultStakeholder issuer cap_table + let source = convertibleIssuance "CV-ISS-TRIGGER-ID-SOURCE" "CSEC-TRIGGER-ID-SOURCE" "SH-1" jan01 100.0 + baseResult = convertibleIssuance "CV-ISS-TRIGGER-ID-RESULT" "CSEC-TRIGGER-ID-RESULT" "SH-1" jan02 60.0 + result = baseResult with + conversion_triggers = map + (\trigger -> trigger with trigger_id = "TRIGGER-CONVERTIBLE-RENAMED") + baseResult.conversion_triggers + + submitMustFail issuer do + exerciseCmd capTableReady CT.UpdateCapTable with + creates = + [ CT.OcfCreateConvertibleIssuance source + , CT.OcfCreateConvertibleIssuance result + , CT.OcfCreateConvertibleTransfer (convertibleTransfer + "TX-CV-TRIGGER-ID-TRANSFER" + source.security_id + 60.0 + jan02 + [result.security_id] + None) + ] + edits = [] + deletes = [] + +testConvertibleSecurityBalance_CancellationCannotRenameBalanceTrigger = script do + TestOcp{issuer, cap_table} <- setupTestOcp + capTableReady <- addDefaultStakeholder issuer cap_table + let source = convertibleIssuance "CV-ISS-CANCEL-TRIGGER-ID-SOURCE" "CSEC-CANCEL-TRIGGER-ID-SOURCE" "SH-1" jan01 100.0 + baseBalance = convertibleIssuance "CV-ISS-CANCEL-TRIGGER-ID-BALANCE" "CSEC-CANCEL-TRIGGER-ID-BALANCE" "SH-1" jan02 60.0 + balance = baseBalance with + conversion_triggers = map + (\trigger -> trigger with trigger_id = "TRIGGER-CONVERTIBLE-RENAMED") + baseBalance.conversion_triggers + + submitMustFail issuer do + exerciseCmd capTableReady CT.UpdateCapTable with + creates = + [ CT.OcfCreateConvertibleIssuance source + , CT.OcfCreateConvertibleIssuance balance + , CT.OcfCreateConvertibleCancellation (convertibleCancellation + "TX-CV-CANCEL-TRIGGER-ID" + source.security_id + 40.0 + jan02 + (Some balance.security_id)) + ] + edits = [] + deletes = [] + testConvertibleSecurityBalance_PartialTransferScalesSuccessorProRata = script do TestOcp{issuer, cap_table} <- setupTestOcp capTableReady <- addDefaultStakeholder issuer cap_table diff --git a/Test/daml/OpenCapTable/TestHelpers.daml b/Test/daml/OpenCapTable/TestHelpers.daml index 78305e87..e039c415 100644 --- a/Test/daml/OpenCapTable/TestHelpers.daml +++ b/Test/daml/OpenCapTable/TestHelpers.daml @@ -345,7 +345,7 @@ createWarrantIssuance issuer capTableCid securityId quantity issuanceDate = convertibleSuccessorIssuance : Text -> Text -> Text -> Time -> Decimal -> CT.OcfCreateData convertibleSuccessorIssuance issuanceId securityId stakeholderId date investmentAmount = - CT.OcfCreateConvertibleIssuance ((defaultConvertibleIssuance issuanceId securityId stakeholderId ("TRIGGER-" <> issuanceId)) with + CT.OcfCreateConvertibleIssuance ((defaultConvertibleIssuance issuanceId securityId stakeholderId "TRIGGER-CONVERTIBLE-SUCCESSOR") with date = date investment_amount = OcfMonetary with amount = investmentAmount, currency = "USD") diff --git a/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar b/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar index 6a4398ad..40897a27 100644 --- a/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar +++ b/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2d281f45d76585bd3a05c89b7afdc980f95fa43e411ded0b83010ca133312380 -size 3329513 +oid sha256:f61fbbae68abcb34f68270410a8fac55bc21f21d0370dcf8f5e41163aa4761c1 +size 3329200 diff --git a/dars/dars.lock b/dars/dars.lock index 50825eba..c7bf4de8 100644 --- a/dars/dars.lock +++ b/dars/dars.lock @@ -54,8 +54,8 @@ "networks": [] }, "OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar": { - "sha256": "2d281f45d76585bd3a05c89b7afdc980f95fa43e411ded0b83010ca133312380", - "size": 3329513, + "sha256": "f61fbbae68abcb34f68270410a8fac55bc21f21d0370dcf8f5e41163aa4761c1", + "size": 3329200, "sdkVersion": "3.4.10", "uploadedAt": "2026-07-11T17:31:18.000Z", "networks": [] diff --git a/scripts/codegen/templates/CapTable.daml.template b/scripts/codegen/templates/CapTable.daml.template index 5cd0b4a0..e05580ae 100644 --- a/scripts/codegen/templates/CapTable.daml.template +++ b/scripts/codegen/templates/CapTable.daml.template @@ -2171,11 +2171,6 @@ convertiblePrincipalBeforeConversion maps issuanceData conversionData = do (\event -> convertibleBalanceEventSortKey event < targetSortKey) (sortOn convertibleBalanceEventSortKey events)) -normalizeConvertibleConversionTrigger : - ConvertibleIssuance.OcfConvertibleConversionTrigger -> - ConvertibleIssuance.OcfConvertibleConversionTrigger -normalizeConvertibleConversionTrigger trigger = trigger with trigger_id = "" - scaleConvertibleSuccessorTrigger : Decimal -> Decimal -> @@ -2233,8 +2228,8 @@ validateConvertibleSuccessorTerms label sourceIssuanceData successorIssuanceData (successorIssuanceData.seniority == sourceIssuanceData.seniority) assertMsg (label <> " successor conversion terms mismatch for " <> successorIssuanceData.security_id) - (map normalizeConvertibleConversionTrigger successorIssuanceData.conversion_triggers == - map normalizeConvertibleConversionTrigger (expectedConvertibleSuccessorTriggers sourceIssuanceData successorIssuanceData)) + (successorIssuanceData.conversion_triggers == + expectedConvertibleSuccessorTriggers sourceIssuanceData successorIssuanceData) assertMsg (label <> " successor pro rata terms mismatch for " <> successorIssuanceData.security_id) (successorIssuanceData.pro_rata == expectedConvertibleSuccessorProRata sourceIssuanceData successorIssuanceData) From 41066cf2bd36ddfc5a0ca94f989c34aaa10e7162 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 17:34:36 -0400 Subject: [PATCH 11/13] fix: preserve root trigger reachability for successors --- .../OpenCapTable/TestConvertibleTransfer.daml | 5 ++- .../TestInstrumentTemporalEligibility.daml | 44 +++++++++++++++++++ .../0.0.16/OpenCapTable-v34.dar | 4 +- dars/dars.lock | 6 +-- .../codegen/templates/CapTable.daml.template | 21 +++++---- 5 files changed, 64 insertions(+), 16 deletions(-) diff --git a/Test/daml/OpenCapTable/TestConvertibleTransfer.daml b/Test/daml/OpenCapTable/TestConvertibleTransfer.daml index 9419c297..cb002555 100644 --- a/Test/daml/OpenCapTable/TestConvertibleTransfer.daml +++ b/Test/daml/OpenCapTable/TestConvertibleTransfer.daml @@ -134,7 +134,7 @@ testConvertibleTransfer_NoteFullAssignment = script do deletes = [] pure () -testConvertibleTransfer_AllTriggersBeforeSuccessorIssuanceFail = script do +testConvertibleTransfer_PreservesRootReachableTriggers = script do TestOcp{issuer, cap_table} <- setupTestOcp capTableCid <- addDefaultStakeholder issuer cap_table (capTableCid, assigneeId) <- setupStakeholderWithId issuer capTableCid "SH-ASSIGNEE" "Convertible Assignee" @@ -148,7 +148,7 @@ testConvertibleTransfer_AllTriggersBeforeSuccessorIssuanceFail = script do (DT.time (DA.date 2023 Jun 15) 0 0 0) OcfConvertibleNote triggerDate)] edits = [] deletes = [] - submitMustFail issuer do + _ <- submit issuer do exerciseCmd result.updatedCapTableCid CT.UpdateCapTable with creates = [CT.OcfCreateConvertibleIssuance (convertibleTransferIssuance "TX_NOTE_EXPIRED_TRIGGER_SUCCESSOR" "CSEC-NOTE-EXPIRED-TRIGGER-SUCCESSOR" assigneeId 250000.0 @@ -164,3 +164,4 @@ testConvertibleTransfer_AllTriggersBeforeSuccessorIssuanceFail = script do consideration_text = None] edits = [] deletes = [] + pure () diff --git a/Test/daml/OpenCapTable/TestInstrumentTemporalEligibility.daml b/Test/daml/OpenCapTable/TestInstrumentTemporalEligibility.daml index 1beb96d9..0c275e59 100644 --- a/Test/daml/OpenCapTable/TestInstrumentTemporalEligibility.daml +++ b/Test/daml/OpenCapTable/TestInstrumentTemporalEligibility.daml @@ -3,6 +3,7 @@ module OpenCapTable.TestInstrumentTemporalEligibility where import qualified Fairmint.OpenCapTable.CapTable as CT import Fairmint.OpenCapTable.OCF.ConvertibleConversion (ConvertibleConversionOcfData(..)) import Fairmint.OpenCapTable.OCF.ConvertibleIssuance (ConvertibleIssuanceOcfData(..), OcfConvertibleConversionTrigger(..)) +import Fairmint.OpenCapTable.OCF.ConvertibleTransfer (ConvertibleTransferOcfData(..)) import Fairmint.OpenCapTable.OCF.EquityCompensationCancellation (EquityCompensationCancellationOcfData(..)) import Fairmint.OpenCapTable.OCF.EquityCompensationExercise (EquityCompensationExerciseOcfData(..)) import Fairmint.OpenCapTable.OCF.EquityCompensationIssuance (EquityCompensationIssuanceOcfData(..)) @@ -10,6 +11,7 @@ import Fairmint.OpenCapTable.OCF.EquityCompensationTransfer (EquityCompensationT import Fairmint.OpenCapTable.OCF.WarrantExercise (WarrantExerciseOcfData(..)) import Fairmint.OpenCapTable.OCF.WarrantIssuance (WarrantIssuanceOcfData(..)) import Fairmint.OpenCapTable.Types.Conversion +import Fairmint.OpenCapTable.Types.Monetary (OcfMonetary(..)) import OpenCapTable.Setup import OpenCapTable.TestHelpers ( addPrerequisites @@ -277,6 +279,48 @@ testInstrumentTemporalEligibility_TriggerSetMustRemainReachableAfterIssuance = s deletes = [] pure () +testInstrumentTemporalEligibility_ConvertibleSuccessorKeepsRootReachableTrigger = script do + TestOcp{issuer, cap_table} <- setupTestOcp + capTable <- addPrerequisites issuer cap_table + let trigger = fixedConvertibleTrigger + "CV-AUTO-BEFORE-SUCCESSOR" + OcfTriggerTypeTypeAutomaticOnDate + None + None + (Some jan02) + sourceIssuance = convertibleIssuance + "TX-CV-ROOT-REACHABLE-TRIGGER" + "SEC-CV-ROOT-REACHABLE-TRIGGER" + trigger + successorIssuance = sourceIssuance with + id = "TX-CV-SUCCESSOR-REACHABLE-TRIGGER" + custom_id = "CV-TX-CV-SUCCESSOR-REACHABLE-TRIGGER" + date = jan03 + security_id = "SEC-CV-SUCCESSOR-REACHABLE-TRIGGER" + transfer = ConvertibleTransferOcfData with + id = "TX-CV-TRANSFER-REACHABLE-TRIGGER" + date = jan03 + amount = OcfMonetary with amount = 10000.0, currency = "USD" + security_id = sourceIssuance.security_id + resulting_security_ids = [successorIssuance.security_id] + balance_security_id = None + consideration_text = None + comments = [] + sourceResult <- submit issuer do + exerciseCmd capTable CT.UpdateCapTable with + creates = [CT.OcfCreateConvertibleIssuance sourceIssuance] + edits = [] + deletes = [] + _ <- submit issuer do + exerciseCmd sourceResult.updatedCapTableCid CT.UpdateCapTable with + creates = + [ CT.OcfCreateConvertibleIssuance successorIssuance + , CT.OcfCreateConvertibleTransfer transfer + ] + edits = [] + deletes = [] + pure () + testInstrumentTemporalEligibility_TriggerIdsMustBeUniqueWithinIssuance = script do TestOcp{issuer, cap_table} <- setupTestOcp capTable <- addPrerequisites issuer cap_table diff --git a/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar b/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar index 40897a27..56746c6c 100644 --- a/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar +++ b/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f61fbbae68abcb34f68270410a8fac55bc21f21d0370dcf8f5e41163aa4761c1 -size 3329200 +oid sha256:18b71fb10fc635ceb2712958fc53875705a1d5392ec925c360e2d0acf520258c +size 3329241 diff --git a/dars/dars.lock b/dars/dars.lock index c7bf4de8..1775c355 100644 --- a/dars/dars.lock +++ b/dars/dars.lock @@ -54,10 +54,10 @@ "networks": [] }, "OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar": { - "sha256": "f61fbbae68abcb34f68270410a8fac55bc21f21d0370dcf8f5e41163aa4761c1", - "size": 3329200, + "sha256": "18b71fb10fc635ceb2712958fc53875705a1d5392ec925c360e2d0acf520258c", + "size": 3329241, "sdkVersion": "3.4.10", - "uploadedAt": "2026-07-11T17:31:18.000Z", + "uploadedAt": "2026-07-11T21:33:25.000Z", "networks": [] }, "OpenCapTable-v34/0.0.2/OpenCapTable-v34.dar": { diff --git a/scripts/codegen/templates/CapTable.daml.template b/scripts/codegen/templates/CapTable.daml.template index e05580ae..d9ac2853 100644 --- a/scripts/codegen/templates/CapTable.daml.template +++ b/scripts/codegen/templates/CapTable.daml.template @@ -4660,15 +4660,18 @@ validateConvertibleIssuanceTemporalTerms maps = do if Set.member issuance.issuance_data.security_id successorIds then None else Some issuance.issuance_data.date - assertMsg - ("Convertible issuance " <> issuance.issuance_data.id <> " has no conversion trigger reachable on or after issuance") - (any - (\trigger -> conversionTriggerCanBeEligibleOnOrAfter - issuance.issuance_data.date - trigger.type_ - trigger.end_date - trigger.trigger_date) - issuance.issuance_data.conversion_triggers) + case rootIssuanceDate of + Some issuanceDate -> + assertMsg + ("Convertible issuance " <> issuance.issuance_data.id <> " has no conversion trigger reachable on or after issuance") + (any + (\trigger -> conversionTriggerCanBeEligibleOnOrAfter + issuanceDate + trigger.type_ + trigger.end_date + trigger.trigger_date) + issuance.issuance_data.conversion_triggers) + None -> pure () mapA_ (\trigger -> do let label = "Convertible issuance " <> issuance.issuance_data.id <> " trigger " <> trigger.trigger_id From 0401ec232616b83664c4e2906eb55969c093777e Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 17:43:55 -0400 Subject: [PATCH 12/13] fix: preserve warrant trigger reachability across successors --- .../TestInstrumentTemporalEligibility.daml | 44 +++++++++++++++++++ .../0.0.16/OpenCapTable-v34.dar | 4 +- dars/dars.lock | 6 +-- .../codegen/templates/CapTable.daml.template | 43 +++++++++++++----- 4 files changed, 81 insertions(+), 16 deletions(-) diff --git a/Test/daml/OpenCapTable/TestInstrumentTemporalEligibility.daml b/Test/daml/OpenCapTable/TestInstrumentTemporalEligibility.daml index 0c275e59..2459fbf2 100644 --- a/Test/daml/OpenCapTable/TestInstrumentTemporalEligibility.daml +++ b/Test/daml/OpenCapTable/TestInstrumentTemporalEligibility.daml @@ -10,6 +10,7 @@ import Fairmint.OpenCapTable.OCF.EquityCompensationIssuance (EquityCompensationI import Fairmint.OpenCapTable.OCF.EquityCompensationTransfer (EquityCompensationTransferOcfData(..)) import Fairmint.OpenCapTable.OCF.WarrantExercise (WarrantExerciseOcfData(..)) import Fairmint.OpenCapTable.OCF.WarrantIssuance (WarrantIssuanceOcfData(..)) +import Fairmint.OpenCapTable.OCF.WarrantTransfer (WarrantTransferOcfData(..)) import Fairmint.OpenCapTable.Types.Conversion import Fairmint.OpenCapTable.Types.Monetary (OcfMonetary(..)) import OpenCapTable.Setup @@ -321,6 +322,49 @@ testInstrumentTemporalEligibility_ConvertibleSuccessorKeepsRootReachableTrigger deletes = [] pure () +testInstrumentTemporalEligibility_WarrantSuccessorKeepsRootReachableTrigger = script do + TestOcp{issuer, cap_table} <- setupTestOcp + capTable <- addPrerequisites issuer cap_table + let trigger = fixedWarrantTrigger + "WR-AUTO-BEFORE-SUCCESSOR" + OcfTriggerTypeTypeAutomaticOnDate + None + None + (Some jan02) + sourceIssuance = warrantIssuance + "TX-WR-ROOT-REACHABLE-TRIGGER" + "SEC-WR-ROOT-REACHABLE-TRIGGER" + trigger + (Some jan04) + successorIssuance = sourceIssuance with + id = "TX-WR-SUCCESSOR-REACHABLE-TRIGGER" + custom_id = "WR-TX-WR-SUCCESSOR-REACHABLE-TRIGGER" + date = jan03 + security_id = "SEC-WR-SUCCESSOR-REACHABLE-TRIGGER" + transfer = WarrantTransferOcfData with + id = "TX-WR-TRANSFER-REACHABLE-TRIGGER" + date = jan03 + security_id = sourceIssuance.security_id + quantity = 100.0 + resulting_security_ids = [successorIssuance.security_id] + balance_security_id = None + consideration_text = None + comments = [] + sourceResult <- submit issuer do + exerciseCmd capTable CT.UpdateCapTable with + creates = [CT.OcfCreateWarrantIssuance sourceIssuance] + edits = [] + deletes = [] + _ <- submit issuer do + exerciseCmd sourceResult.updatedCapTableCid CT.UpdateCapTable with + creates = + [ CT.OcfCreateWarrantIssuance successorIssuance + , CT.OcfCreateWarrantTransfer transfer + ] + edits = [] + deletes = [] + pure () + testInstrumentTemporalEligibility_TriggerIdsMustBeUniqueWithinIssuance = script do TestOcp{issuer, cap_table} <- setupTestOcp capTable <- addPrerequisites issuer cap_table diff --git a/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar b/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar index 56746c6c..a06c77a8 100644 --- a/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar +++ b/dars/OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:18b71fb10fc635ceb2712958fc53875705a1d5392ec925c360e2d0acf520258c -size 3329241 +oid sha256:2556607ba1f16b0df0ed91ef2bbc0877c4250563b8fde0cdc9cb2e99ffbe9928 +size 3331338 diff --git a/dars/dars.lock b/dars/dars.lock index 1775c355..cddee110 100644 --- a/dars/dars.lock +++ b/dars/dars.lock @@ -54,10 +54,10 @@ "networks": [] }, "OpenCapTable-v34/0.0.16/OpenCapTable-v34.dar": { - "sha256": "18b71fb10fc635ceb2712958fc53875705a1d5392ec925c360e2d0acf520258c", - "size": 3329241, + "sha256": "2556607ba1f16b0df0ed91ef2bbc0877c4250563b8fde0cdc9cb2e99ffbe9928", + "size": 3331338, "sdkVersion": "3.4.10", - "uploadedAt": "2026-07-11T21:33:25.000Z", + "uploadedAt": "2026-07-11T21:42:33.000Z", "networks": [] }, "OpenCapTable-v34/0.0.2/OpenCapTable-v34.dar": { diff --git a/scripts/codegen/templates/CapTable.daml.template b/scripts/codegen/templates/CapTable.daml.template index d9ac2853..70d7d272 100644 --- a/scripts/codegen/templates/CapTable.daml.template +++ b/scripts/codegen/templates/CapTable.daml.template @@ -15,7 +15,7 @@ import qualified DA.Map as Map import DA.List (sortOn, unique) import DA.Set (Set) import qualified DA.Set as Set -import DA.Action (foldlA, when) +import DA.Action (foldlA, unless, when) import DA.Foldable (mapA_) import DA.Optional (fromOptional, isNone, isSome) @@ -4546,6 +4546,25 @@ convertibleSuccessorSecurityIds maps = do cancellationIds (Map.values maps.convertible_conversions) +warrantSuccessorSecurityIds : CapTableMaps -> Update (Set Text) +warrantSuccessorSecurityIds maps = do + transferIds <- foldlA + (\ids transferCid -> do + transfer <- fetch transferCid + pure (Set.union + (Set.fromList (successorSecurityIdsFor transfer.transfer_data.balance_security_id transfer.transfer_data.resulting_security_ids)) + ids)) + Set.empty + (Map.values maps.warrant_transfers) + foldlA + (\ids cancellationCid -> do + cancellation <- fetch cancellationCid + pure (Set.union + (Set.fromList (successorSecurityIdsFor cancellation.cancellation_data.balance_security_id [])) + ids)) + transferIds + (Map.values maps.warrant_cancellations) + conversionTriggerDefinitionIsTemporallyValid : Optional Time -> OcfConversionTriggerType -> @@ -4698,7 +4717,8 @@ validateConvertibleIssuanceTemporalTerms maps = do (Map.values maps.convertible_issuances) validateWarrantIssuanceTemporalTerms : CapTableMaps -> Update () -validateWarrantIssuanceTemporalTerms maps = +validateWarrantIssuanceTemporalTerms maps = do + successorIds <- warrantSuccessorSecurityIds maps mapA_ (\issuanceCid -> do issuance <- fetch issuanceCid @@ -4709,15 +4729,16 @@ validateWarrantIssuanceTemporalTerms maps = ("Warrant issuance " <> issuance.issuance_data.id) issuance.issuance_data.date issuance.issuance_data.warrant_expiration_date - assertMsg - ("Warrant issuance " <> issuance.issuance_data.id <> " has no exercise trigger reachable on or after issuance") - (null issuance.issuance_data.exercise_triggers || any - (\trigger -> conversionTriggerCanBeEligibleOnOrAfter - issuance.issuance_data.date - trigger.type_ - trigger.end_date - trigger.trigger_date) - issuance.issuance_data.exercise_triggers) + unless (Set.member issuance.issuance_data.security_id successorIds) do + assertMsg + ("Warrant issuance " <> issuance.issuance_data.id <> " has no exercise trigger reachable on or after issuance") + (null issuance.issuance_data.exercise_triggers || any + (\trigger -> conversionTriggerCanBeEligibleOnOrAfter + issuance.issuance_data.date + trigger.type_ + trigger.end_date + trigger.trigger_date) + issuance.issuance_data.exercise_triggers) mapA_ (\trigger -> assertConversionTriggerDefinitionIsTemporallyValid From f4eb3714dbf8de2a5de49102db4149fedb874aae Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 21:21:09 -0400 Subject: [PATCH 13/13] test: preserve temporal eligibility coverage --- Test/daml/OpenCapTable/TestConvertibleSecurityBalances.daml | 6 +++--- .../OpenCapTable/TestInstrumentTemporalEligibility.daml | 5 ++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Test/daml/OpenCapTable/TestConvertibleSecurityBalances.daml b/Test/daml/OpenCapTable/TestConvertibleSecurityBalances.daml index 4192c2d7..2080a406 100644 --- a/Test/daml/OpenCapTable/TestConvertibleSecurityBalances.daml +++ b/Test/daml/OpenCapTable/TestConvertibleSecurityBalances.daml @@ -471,7 +471,7 @@ testConvertibleSecurityBalance_ConversionWithBalanceRetiresSource = script do , CT.OcfCreateConvertibleConversion (convertibleConversion "TX-CV-CONVERSION-BALANCE" "CSEC-CONVERSION-SOURCE" - "TRIGGER-CV-ISS-CONVERSION-SOURCE" + "TRIGGER-CONVERTIBLE-SUCCESSOR" jan02 ["SSEC-CONVERSION-RESULT"] (Some "CSEC-CONVERSION-BALANCE") @@ -498,7 +498,7 @@ testConvertibleSecurityBalance_ConversionWithoutBalanceRetiresSource = script do , CT.OcfCreateConvertibleConversion (convertibleConversion "TX-CV-FULL-CONVERSION" "CSEC-FULL-CONVERSION-SOURCE" - "TRIGGER-CV-ISS-FULL-CONVERSION-SOURCE" + "TRIGGER-CONVERTIBLE-SUCCESSOR" jan02 ["SSEC-FULL-CONVERSION-RESULT"] None @@ -586,7 +586,7 @@ testConvertibleSecurityBalance_ConversionCannotCreateZeroBalance = script do , CT.OcfCreateConvertibleConversion (convertibleConversion "TX-CV-CONVERSION-ZERO-BALANCE" "CSEC-CONVERSION-ZERO-SOURCE" - "TRIGGER-CV-ISS-CONVERSION-ZERO-SOURCE" + "TRIGGER-CONVERTIBLE-SUCCESSOR" jan02 ["SSEC-CONVERSION-ZERO-RESULT"] (Some "CSEC-CONVERSION-ZERO-BALANCE") diff --git a/Test/daml/OpenCapTable/TestInstrumentTemporalEligibility.daml b/Test/daml/OpenCapTable/TestInstrumentTemporalEligibility.daml index 2459fbf2..60422515 100644 --- a/Test/daml/OpenCapTable/TestInstrumentTemporalEligibility.daml +++ b/Test/daml/OpenCapTable/TestInstrumentTemporalEligibility.daml @@ -410,7 +410,10 @@ testInstrumentTemporalEligibility_TriggerIdsMustBeUniqueWithinIssuance = script edits = [] deletes = [] -testInstrumentTemporalEligibility_FinalStateTriggerIdChecksRejectDuplicates = script do +-- Current issuance templates reject duplicate trigger IDs in their own ensure, +-- so upgraded legacy state cannot be constructed directly in this package. +-- These predicates are the final-state guard for contracts created by older packages. +testInstrumentTemporalEligibility_UpgradeGuardPredicatesRejectDuplicateTriggerIds = script do let convertibleTrigger = fixedConvertibleTrigger "CV-FINAL-DUPLICATE" OcfTriggerTypeTypeAutomaticOnDate