From bcc0707f9ceab7ff7872aab82725c27d2871245e Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Tue, 21 Jul 2026 15:22:21 -0400 Subject: [PATCH 1/5] Enforce OCF terminal transaction completeness --- .../TestConvertibleCancellation.daml | 21 +- .../OpenCapTable/TestConvertibleTransfer.daml | 5 +- .../TestEquityCompensationCancellation.daml | 27 +- .../TestEquityCompensationTransfer.daml | 8 +- .../TestSecurityIdValidation.daml | 4 +- .../OpenCapTable/TestStockCancellation.daml | 45 +- .../OpenCapTable/TestStockConversion.daml | 5 +- .../OpenCapTable/TestStockRepurchase.daml | 5 +- Test/daml/OpenCapTable/TestStockTransfer.daml | 5 +- .../OpenCapTable/TestWarrantCancellation.daml | 24 +- .../OpenCapTable/TestWarrantExercise.daml | 32 + .../OpenCapTable/TestWarrantTransfer.daml | 5 +- .../0.0.3/OpenCapTable-v34.dar | 4 +- dars/dars.lock | 6 +- .../codegen/templates/CapTable.daml.template | 708 +++++++++++++----- 15 files changed, 683 insertions(+), 221 deletions(-) diff --git a/Test/daml/OpenCapTable/TestConvertibleCancellation.daml b/Test/daml/OpenCapTable/TestConvertibleCancellation.daml index 8cef2086..ed09282b 100644 --- a/Test/daml/OpenCapTable/TestConvertibleCancellation.daml +++ b/Test/daml/OpenCapTable/TestConvertibleCancellation.daml @@ -53,12 +53,31 @@ testConvertibleCancellation_Create = script do deletes = [] pure () +testConvertibleCancellation_PartialWithoutBalanceSecurityFails = script do + TestOcp{issuer, cap_table} <- setupTestOcp + capTableCid <- addDefaultStakeholder issuer cap_table + result <- createConvertibleIssuance issuer capTableCid "SEC-CONVERTIBLE-PARTIAL-NO-BALANCE" 100000.0 + + submitMustFail issuer do + exerciseCmd result.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateConvertibleCancellation ConvertibleCancellationOcfData with + id = "TX_CONVERTIBLE_CANCEL_PARTIAL_NO_BALANCE" + date = DT.time (DA.date 2022 Nov 03) 0 0 0 + security_id = "SEC-CONVERTIBLE-PARTIAL-NO-BALANCE" + amount = OcfMonetary with amount = 40000.0, currency = "USD" + reason_text = "Invalid partial cancellation" + balance_security_id = None + comments = []] + edits = [] + deletes = [] + testConvertibleCancellation_PartialWithOptionals = script do TestOcp{system_operator, issuer, ctx, cap_table} <- setupTestOcp capTableCid <- addDefaultStakeholder issuer cap_table result <- createConvertibleIssuance issuer capTableCid "SEC-2" 100000.0 + balanceResult <- createConvertibleIssuance issuer result.updatedCapTableCid "SEC-CONV-PARTIAL-BAL" 75000.0 _ <- submit issuer do - exerciseCmd result.updatedCapTableCid CT.UpdateCapTable with + exerciseCmd balanceResult.updatedCapTableCid CT.UpdateCapTable with creates = [CT.OcfCreateConvertibleCancellation ConvertibleCancellationOcfData with id = "TX_CONV_CANCEL_PARTIAL" date = DT.time (DA.date 2024 Jun 15) 0 0 0 diff --git a/Test/daml/OpenCapTable/TestConvertibleTransfer.daml b/Test/daml/OpenCapTable/TestConvertibleTransfer.daml index fa70907f..ee07f0a5 100644 --- a/Test/daml/OpenCapTable/TestConvertibleTransfer.daml +++ b/Test/daml/OpenCapTable/TestConvertibleTransfer.daml @@ -18,7 +18,7 @@ createConvertibleIssuance issuer capTableCid securityId amount = submit issuer do exerciseCmd capTableCid CT.UpdateCapTable with creates = [CT.OcfCreateConvertibleIssuance ConvertibleIssuanceOcfData with - id = "TX_CONVERTIBLE_ISS_1" + id = "TX_CONVERTIBLE_ISS_" <> securityId date = DT.time (DA.date 2024 Mar 01) 0 0 0 security_id = securityId custom_id = "SAFE-1" @@ -64,8 +64,9 @@ testConvertibleTransfer_PartialSAFE = script do capTableCid <- addDefaultStakeholder issuer cap_table let securityId = "CSEC-SAFE-PARTIAL" result <- createConvertibleIssuance issuer capTableCid securityId 500000.0 + balanceResult <- createConvertibleIssuance issuer result.updatedCapTableCid "CSEC-SAFE-RETAINED" 300000.0 _ <- submit issuer do - exerciseCmd result.updatedCapTableCid CT.UpdateCapTable with + exerciseCmd balanceResult.updatedCapTableCid CT.UpdateCapTable with creates = [CT.OcfCreateConvertibleTransfer ConvertibleTransferOcfData with id = "TX_CONVERTIBLE_TRANSFER_PARTIAL" date = DT.time (DA.date 2025 Oct 15) 0 0 0 diff --git a/Test/daml/OpenCapTable/TestEquityCompensationCancellation.daml b/Test/daml/OpenCapTable/TestEquityCompensationCancellation.daml index 7e0aaf6e..6150d937 100644 --- a/Test/daml/OpenCapTable/TestEquityCompensationCancellation.daml +++ b/Test/daml/OpenCapTable/TestEquityCompensationCancellation.daml @@ -16,7 +16,7 @@ createEquityCompensationIssuance issuer capTableCid securityId quantity = submit issuer do exerciseCmd capTableCid CT.UpdateCapTable with creates = [CT.OcfCreateEquityCompensationIssuance EquityCompensationIssuanceOcfData with - id = "TX_EC_ISS_1" + id = "TX_EC_ISS_" <> securityId date = DT.time (DA.date 2020 Jan 15) 0 0 0 security_id = securityId custom_id = "GRANT-001" @@ -61,10 +61,30 @@ testEquityCompensationCancellation_CreateAndArchiveByIssuer = script do TestOcp{system_operator, issuer, ctx, cap_table} <- setupTestOcp capTableCid <- addDefaultStakeholder issuer cap_table let securityId = "db5a26d6-3912-4400-87b0-bda74de361af" - result <- createEquityCompensationIssuance issuer capTableCid securityId 250000.0 + -- This fixture models the remaining active grant after the earlier exercises. + result <- createEquityCompensationIssuance issuer capTableCid securityId 214200.0 _ <- createEquityCompensationCancellation issuer result.updatedCapTableCid securityId 214200.0 pure () +testEquityCompensationCancellation_PartialWithoutBalanceSecurityFails = script do + TestOcp{issuer, cap_table} <- setupTestOcp + capTableCid <- addDefaultStakeholder issuer cap_table + let securityId = "SEC-EC-PARTIAL-NO-BALANCE" + result <- createEquityCompensationIssuance issuer capTableCid securityId 100.0 + + submitMustFail issuer do + exerciseCmd result.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateEquityCompensationCancellation EquityCompensationCancellationOcfData with + id = "TX_EC_CANCEL_PARTIAL_NO_BALANCE" + date = DT.time (DA.date 2024 Mar 15) 0 0 0 + security_id = securityId + quantity = 40.0 + reason_text = "Invalid partial cancellation" + balance_security_id = None + comments = []] + edits = [] + deletes = [] + -- Archive with built-in choice (requires both signatories) testEquityCompensationCancellation_CreateAndArchiveBuiltIn = script do TestOcp{system_operator, issuer, ctx, cap_table} <- setupTestOcp @@ -91,8 +111,9 @@ testEquityCompensationCancellation_PartialWithOptionals = script do capTableCid <- addDefaultStakeholder issuer cap_table let securityId = "SEC-EC-PARTIAL" result <- createEquityCompensationIssuance issuer capTableCid securityId 500000.0 + balanceResult <- createEquityCompensationIssuance issuer result.updatedCapTableCid "SEC-EC-PARTIAL-BAL" 350000.0 _ <- submit issuer do - exerciseCmd result.updatedCapTableCid CT.UpdateCapTable with + exerciseCmd balanceResult.updatedCapTableCid CT.UpdateCapTable with creates = [CT.OcfCreateEquityCompensationCancellation EquityCompensationCancellationOcfData with id = "TX_EC_CANCEL_PARTIAL" date = DT.time (DA.date 2024 Jun 30) 0 0 0 diff --git a/Test/daml/OpenCapTable/TestEquityCompensationTransfer.daml b/Test/daml/OpenCapTable/TestEquityCompensationTransfer.daml index a3055eac..71f6a0e5 100644 --- a/Test/daml/OpenCapTable/TestEquityCompensationTransfer.daml +++ b/Test/daml/OpenCapTable/TestEquityCompensationTransfer.daml @@ -17,7 +17,7 @@ createEquityCompensationIssuance issuer capTableCid securityId quantity = submit issuer do exerciseCmd capTableCid CT.UpdateCapTable with creates = [CT.OcfCreateEquityCompensationIssuance EquityCompensationIssuanceOcfData with - id = "TX_EQUITY_COMP_ISS_1" + id = "TX_EQUITY_COMP_ISS_" <> securityId date = DT.time (DA.date 2022 Jan 15) 0 0 0 security_id = securityId custom_id = "OPT-1" @@ -49,8 +49,9 @@ testEquityCompensationTransfer_NSOToFamilyTrust = script do capTableCid <- addDefaultStakeholder issuer cap_table let securityId = "ESEC-NSO-001" result <- createEquityCompensationIssuance issuer capTableCid securityId 10000.0 + balanceResult <- createEquityCompensationIssuance issuer result.updatedCapTableCid "ESEC-NSO-REMAIN" 5000.0 _ <- submit issuer do - exerciseCmd result.updatedCapTableCid CT.UpdateCapTable with + exerciseCmd balanceResult.updatedCapTableCid CT.UpdateCapTable with creates = [CT.OcfCreateEquityCompensationTransfer EquityCompensationTransferOcfData with id = "TX_EQUITY_COMP_TRANSFER_1" date = DT.time (DA.date 2025 Jun 01) 0 0 0 @@ -91,8 +92,9 @@ testEquityCompensationTransfer_WithApprovalComments = script do capTableCid <- addDefaultStakeholder issuer cap_table let securityId = "ESEC-NSO-APPROVAL" result <- createEquityCompensationIssuance issuer capTableCid securityId 15000.0 + balanceResult <- createEquityCompensationIssuance issuer result.updatedCapTableCid "ESEC-NSO-REMAINING" 7000.0 _ <- submit issuer do - exerciseCmd result.updatedCapTableCid CT.UpdateCapTable with + exerciseCmd balanceResult.updatedCapTableCid CT.UpdateCapTable with creates = [CT.OcfCreateEquityCompensationTransfer EquityCompensationTransferOcfData with id = "TX_EQUITY_COMP_TRANSFER_APPROVED" date = DT.time (DA.date 2025 Nov 01) 0 0 0 diff --git a/Test/daml/OpenCapTable/TestSecurityIdValidation.daml b/Test/daml/OpenCapTable/TestSecurityIdValidation.daml index f43dcb17..959b3abd 100644 --- a/Test/daml/OpenCapTable/TestSecurityIdValidation.daml +++ b/Test/daml/OpenCapTable/TestSecurityIdValidation.daml @@ -222,7 +222,7 @@ testStockTransfer_CorrectSecurityIdSucceeds = script do id = "TX_TRANSFER_VALID" date = DT.time (DA.date 2024 Jan 01) 0 0 0 security_id = "SEC-STOCK-1" -- Correct stock security_id - quantity = 50.0 + quantity = 100.0 comments = [] resulting_security_ids = ["NEW-SEC-1"] balance_security_id = None @@ -244,7 +244,7 @@ testWarrantTransfer_CorrectSecurityIdSucceeds = script do id = "TX_WARRANT_TRANSFER_VALID" date = DT.time (DA.date 2024 Jan 01) 0 0 0 security_id = "SEC-WR-1" -- Correct warrant security_id - quantity = 25.0 + quantity = 100.0 comments = [] resulting_security_ids = ["NEW-WARRANT-1"] balance_security_id = None diff --git a/Test/daml/OpenCapTable/TestStockCancellation.daml b/Test/daml/OpenCapTable/TestStockCancellation.daml index e10b5cf1..10c51924 100644 --- a/Test/daml/OpenCapTable/TestStockCancellation.daml +++ b/Test/daml/OpenCapTable/TestStockCancellation.daml @@ -6,7 +6,7 @@ import qualified Fairmint.OpenCapTable.CapTable as CT import Fairmint.OpenCapTable.Types.Monetary (OcfMonetary(..)) import Fairmint.OpenCapTable.Types.Stock (OcfStockClassType(..), OcfInitialSharesAuthorized(..)) import OpenCapTable.Setup -import OpenCapTable.TestHelpers (addPrerequisites) +import OpenCapTable.TestHelpers (addPrerequisites, defaultStockIssuance) import qualified DA.Date as DA import DA.Date (Month(..)) import qualified DA.Time as DT @@ -69,7 +69,7 @@ createStockCancellation issuer capTableCid = id = "TX_STOCK_CANCEL_1" date = DT.time (DA.date 2024 Jan 02) 0 0 0 security_id = "SEC-1" - quantity = 50.0 + quantity = 100.0 balance_security_id = None reason_text = "Forfeit" comments = []] @@ -90,21 +90,46 @@ testStockCancellation_CreateAndArchiveBuiltIn = script do _ <- createStockCancellation issuer result.updatedCapTableCid pure () +testStockCancellation_PartialWithoutBalanceSecurityFails = script do + TestOcp{issuer, cap_table} <- setupTestOcp + capTableCid <- addPrerequisites issuer cap_table + result <- createStockIssuance issuer capTableCid + + submitMustFail issuer do + exerciseCmd result.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateStockCancellation StockCancellationOcfData with + id = "TX_STOCK_CANCEL_PARTIAL_NO_BALANCE" + date = DT.time (DA.date 2024 Jan 02) 0 0 0 + security_id = "SEC-1" + quantity = 50.0 + balance_security_id = None + reason_text = "Invalid partial cancellation" + comments = []] + edits = [] + deletes = [] + -- Optionals Some and arrays non-empty testStockCancellation_OptionalsSome = script do TestOcp{system_operator, issuer, ctx, cap_table} <- setupTestOcp capTableCid <- addPrerequisites issuer cap_table result <- createStockIssuance issuer capTableCid + let cancellationDate = DT.time (DA.date 2024 Jan 03) 0 0 0 + let balanceIssuance = (defaultStockIssuance "TX_STOCK_CANCEL_2_BALANCE" "SEC-1-BAL2" "SH-1" "SC_COMMON") with + date = cancellationDate + quantity = 75.0 _ <- submit issuer do exerciseCmd result.updatedCapTableCid CT.UpdateCapTable with - creates = [CT.OcfCreateStockCancellation StockCancellationOcfData with - id = "TX_STOCK_CANCEL_2" - date = DT.time (DA.date 2024 Jan 03) 0 0 0 - security_id = "SEC-1" - quantity = 25.0 - balance_security_id = Some "SEC-1-BAL2" - reason_text = "Return to pool" - comments = ["Admin correction"]] + creates = + [ CT.OcfCreateStockCancellation StockCancellationOcfData with + id = "TX_STOCK_CANCEL_2" + date = cancellationDate + security_id = "SEC-1" + quantity = 25.0 + balance_security_id = Some balanceIssuance.security_id + reason_text = "Return to pool" + comments = ["Admin correction"] + , CT.OcfCreateStockIssuance balanceIssuance + ] edits = [] deletes = [] pure () diff --git a/Test/daml/OpenCapTable/TestStockConversion.daml b/Test/daml/OpenCapTable/TestStockConversion.daml index ec29ea17..c096245c 100644 --- a/Test/daml/OpenCapTable/TestStockConversion.daml +++ b/Test/daml/OpenCapTable/TestStockConversion.daml @@ -16,7 +16,7 @@ createStockIssuance issuer capTableCid securityId quantity = submit issuer do exerciseCmd capTableCid CT.UpdateCapTable with creates = [CT.OcfCreateStockIssuance StockIssuanceOcfData with - id = "TX_STOCK_ISS_1" + id = "TX_STOCK_ISS_" <> securityId date = DT.time (DA.date 2023 Jan 01) 0 0 0 security_id = securityId custom_id = "PREF-1" @@ -66,8 +66,9 @@ testStockConversion_PartialConversion = script do capTableCid <- addPrerequisites issuer cap_table let securityId = "SSEC-PREF-002" result <- createStockIssuance issuer capTableCid securityId 50000.0 + balanceResult <- createStockIssuance issuer result.updatedCapTableCid "SSEC-PREF-002-REMAIN" 20000.0 _ <- submit issuer do - exerciseCmd result.updatedCapTableCid CT.UpdateCapTable with + exerciseCmd balanceResult.updatedCapTableCid CT.UpdateCapTable with creates = [CT.OcfCreateStockConversion StockConversionOcfData with id = "TX_STOCK_CONVERSION_2" date = DT.time (DA.date 2025 Nov 01) 0 0 0 diff --git a/Test/daml/OpenCapTable/TestStockRepurchase.daml b/Test/daml/OpenCapTable/TestStockRepurchase.daml index fc8aa75b..42da3b3e 100644 --- a/Test/daml/OpenCapTable/TestStockRepurchase.daml +++ b/Test/daml/OpenCapTable/TestStockRepurchase.daml @@ -16,7 +16,7 @@ createStockIssuance issuer capTableCid securityId quantity = submit issuer do exerciseCmd capTableCid CT.UpdateCapTable with creates = [CT.OcfCreateStockIssuance StockIssuanceOcfData with - id = "TX_STOCK_ISS_1" + id = "TX_STOCK_ISS_" <> securityId date = DT.time (DA.date 2022 Jan 01) 0 0 0 security_id = securityId custom_id = "CS-1" @@ -67,8 +67,9 @@ testStockRepurchase_PartialRepurchase = script do capTableCid <- addPrerequisites issuer cap_table let securityId = "SSEC-002" result <- createStockIssuance issuer capTableCid securityId 100000.0 + balanceResult <- createStockIssuance issuer result.updatedCapTableCid "SSEC-002-REMAIN" 50000.0 _ <- submit issuer do - exerciseCmd result.updatedCapTableCid CT.UpdateCapTable with + exerciseCmd balanceResult.updatedCapTableCid CT.UpdateCapTable with creates = [CT.OcfCreateStockRepurchase StockRepurchaseOcfData with id = "TX_STOCK_REPURCHASE_2" date = DT.time (DA.date 2023 Jun 15) 0 0 0 diff --git a/Test/daml/OpenCapTable/TestStockTransfer.daml b/Test/daml/OpenCapTable/TestStockTransfer.daml index 5f65170d..02469e45 100644 --- a/Test/daml/OpenCapTable/TestStockTransfer.daml +++ b/Test/daml/OpenCapTable/TestStockTransfer.daml @@ -16,7 +16,7 @@ createStockIssuance issuer capTableCid securityId quantity = submit issuer do exerciseCmd capTableCid CT.UpdateCapTable with creates = [CT.OcfCreateStockIssuance StockIssuanceOcfData with - id = "TX_STOCK_ISS_1" + id = "TX_STOCK_ISS_" <> securityId date = DT.time (DA.date 2023 Jan 01) 0 0 0 security_id = securityId custom_id = "CS-1" @@ -68,8 +68,9 @@ testStockTransfer_FounderTransferWithConsideration = script do capTableCid <- addPrerequisites issuer cap_table let securityId = "SSEC-FOUNDER-001" result <- createStockIssuance issuer capTableCid securityId 1000.0 + balanceResult <- createStockIssuance issuer result.updatedCapTableCid "SSEC-FOUNDER-REMAIN" 925.0 _ <- submit issuer do - exerciseCmd result.updatedCapTableCid CT.UpdateCapTable with + exerciseCmd balanceResult.updatedCapTableCid CT.UpdateCapTable with creates = [CT.OcfCreateStockTransfer StockTransferOcfData with id = "TX_STOCK_TRANSFER_2" date = DT.time (DA.date 2024 Apr 18) 0 0 0 diff --git a/Test/daml/OpenCapTable/TestWarrantCancellation.daml b/Test/daml/OpenCapTable/TestWarrantCancellation.daml index 1e79b783..23d9f34f 100644 --- a/Test/daml/OpenCapTable/TestWarrantCancellation.daml +++ b/Test/daml/OpenCapTable/TestWarrantCancellation.daml @@ -16,7 +16,7 @@ createWarrantIssuance issuer capTableCid securityId quantity = submit issuer do exerciseCmd capTableCid CT.UpdateCapTable with creates = [CT.OcfCreateWarrantIssuance WarrantIssuanceOcfData with - id = "TX_WARRANT_ISS_1" + id = "TX_WARRANT_ISS_" <> securityId date = DT.time (DA.date 2023 Jun 01) 0 0 0 security_id = securityId custom_id = "W-1" @@ -70,14 +70,34 @@ testWarrantCancellation_CreateAndArchiveBuiltIn = script do _ <- createWarrantCancellation issuer result.updatedCapTableCid securityId 100000.0 pure () +testWarrantCancellation_PartialWithoutBalanceSecurityFails = script do + TestOcp{issuer, cap_table} <- setupTestOcp + capTableCid <- addDefaultStakeholder issuer cap_table + let securityId = "WSEC-PARTIAL-NO-BALANCE" + result <- createWarrantIssuance issuer capTableCid securityId 100.0 + + submitMustFail issuer do + exerciseCmd result.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateWarrantCancellation WarrantCancellationOcfData with + id = "TX_WARRANT_CANCEL_PARTIAL_NO_BALANCE" + date = DT.time (DA.date 2024 Mar 15) 0 0 0 + security_id = securityId + quantity = 40.0 + reason_text = "Invalid partial cancellation" + balance_security_id = None + comments = []] + edits = [] + deletes = [] + -- Partial cancellation with optional fields populated testWarrantCancellation_PartialWithOptionals = script do TestOcp{system_operator, issuer, ctx, cap_table} <- setupTestOcp capTableCid <- addDefaultStakeholder issuer cap_table let securityId = "WSEC-PARTIAL" result <- createWarrantIssuance issuer capTableCid securityId 75000.0 + balanceResult <- createWarrantIssuance issuer result.updatedCapTableCid "WSEC-PARTIAL-BAL" 50000.0 _ <- submit issuer do - exerciseCmd result.updatedCapTableCid CT.UpdateCapTable with + exerciseCmd balanceResult.updatedCapTableCid CT.UpdateCapTable with creates = [CT.OcfCreateWarrantCancellation WarrantCancellationOcfData with id = "TX_WARRANT_CANCEL_PARTIAL" date = DT.time (DA.date 2024 Jun 30) 0 0 0 diff --git a/Test/daml/OpenCapTable/TestWarrantExercise.daml b/Test/daml/OpenCapTable/TestWarrantExercise.daml index 226709c5..e2925bfd 100644 --- a/Test/daml/OpenCapTable/TestWarrantExercise.daml +++ b/Test/daml/OpenCapTable/TestWarrantExercise.daml @@ -2,6 +2,7 @@ module OpenCapTable.TestWarrantExercise where import Fairmint.OpenCapTable.OCF.WarrantIssuance import Fairmint.OpenCapTable.OCF.WarrantExercise +import Fairmint.OpenCapTable.OCF.WarrantAcceptance import Fairmint.OpenCapTable.Types.Monetary (OcfMonetary(..)) import Fairmint.OpenCapTable.Types.Stock (OcfQuantitySourceType(..)) import OpenCapTable.HelpersTriggers (ocfTriggerElectiveAtWill) @@ -82,6 +83,37 @@ testWarrantExercise_PartialWithComments = script do deletes = [] pure () +-- OCF classifies an exercise as terminal for its source security. Even when +-- quantity is populated, later transactions must not target that source ID. +testWarrantExercise_PartialExerciseTerminatesSourceSecurity = script do + TestOcp{issuer, cap_table} <- setupTestOcp + capTableCid <- addDefaultStakeholder issuer cap_table + let securityId = "WSEC-PARTIAL-TERMINAL" + result <- createWarrantIssuance issuer capTableCid securityId 100.0 + exerciseResult <- submit issuer do + exerciseCmd result.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateWarrantExercise WarrantExerciseOcfData with + id = "TX_WARRANT_PARTIAL_TERMINAL" + date = DT.time (DA.date 2025 Aug 01) 0 0 0 + security_id = securityId + trigger_id = "ELECTIVE_AT_WILL" + quantity = Some 40.0 + resulting_security_ids = ["SSEC-FROM-PARTIAL-TERMINAL"] + comments = [] + consideration_text = None] + edits = [] + deletes = [] + + submitMustFail issuer do + exerciseCmd exerciseResult.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateWarrantAcceptance WarrantAcceptanceOcfData with + id = "TX_WARRANT_ACCEPT_AFTER_EXERCISE" + date = DT.time (DA.date 2025 Aug 02) 0 0 0 + security_id = securityId + comments = []] + edits = [] + deletes = [] + -- Test 3: Simple exercise with minimal fields testWarrantExercise_MinimalFields = script do TestOcp{system_operator, issuer, ctx, cap_table} <- setupTestOcp diff --git a/Test/daml/OpenCapTable/TestWarrantTransfer.daml b/Test/daml/OpenCapTable/TestWarrantTransfer.daml index 579a669d..a957cdec 100644 --- a/Test/daml/OpenCapTable/TestWarrantTransfer.daml +++ b/Test/daml/OpenCapTable/TestWarrantTransfer.daml @@ -17,7 +17,7 @@ createWarrantIssuance issuer capTableCid securityId quantity = submit issuer do exerciseCmd capTableCid CT.UpdateCapTable with creates = [CT.OcfCreateWarrantIssuance WarrantIssuanceOcfData with - id = "TX_WARRANT_ISS_1" + id = "TX_WARRANT_ISS_" <> securityId date = DT.time (DA.date 2023 Jun 01) 0 0 0 security_id = securityId custom_id = "W-1" @@ -67,8 +67,9 @@ testWarrantTransfer_PartialTransfer = script do capTableCid <- addDefaultStakeholder issuer cap_table let securityId = "WSEC-PARTIAL" result <- createWarrantIssuance issuer capTableCid securityId 50000.0 + balanceResult <- createWarrantIssuance issuer result.updatedCapTableCid "WSEC-PARTIAL-REMAIN" 30000.0 _ <- submit issuer do - exerciseCmd result.updatedCapTableCid CT.UpdateCapTable with + exerciseCmd balanceResult.updatedCapTableCid CT.UpdateCapTable with creates = [CT.OcfCreateWarrantTransfer WarrantTransferOcfData with id = "TX_WARRANT_TRANSFER_PARTIAL" date = DT.time (DA.date 2025 Jun 01) 0 0 0 diff --git a/dars/OpenCapTable-v34/0.0.3/OpenCapTable-v34.dar b/dars/OpenCapTable-v34/0.0.3/OpenCapTable-v34.dar index 2eed130c..543db37c 100644 --- a/dars/OpenCapTable-v34/0.0.3/OpenCapTable-v34.dar +++ b/dars/OpenCapTable-v34/0.0.3/OpenCapTable-v34.dar @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c1f2c66688da6fc210491f4a6e7be581d3e3dc5ea6bc19eac8177dfc131f863e -size 2508265 +oid sha256:0d20a6d970ef72ee2b0bc302bc573814ce70e3c871d418c0e06d811819fdcc73 +size 2552986 diff --git a/dars/dars.lock b/dars/dars.lock index 97a5c493..de232b18 100644 --- a/dars/dars.lock +++ b/dars/dars.lock @@ -19,10 +19,10 @@ "networks": [] }, "OpenCapTable-v34/0.0.3/OpenCapTable-v34.dar": { - "sha256": "c1f2c66688da6fc210491f4a6e7be581d3e3dc5ea6bc19eac8177dfc131f863e", - "size": 2508265, + "sha256": "0d20a6d970ef72ee2b0bc302bc573814ce70e3c871d418c0e06d811819fdcc73", + "size": 2552986, "sdkVersion": "3.4.10", - "uploadedAt": "2026-07-21T18:21:35.660Z", + "uploadedAt": "2026-07-21T19:21:03.878Z", "networks": [] } } diff --git a/scripts/codegen/templates/CapTable.daml.template b/scripts/codegen/templates/CapTable.daml.template index cc81739d..644aa9bd 100644 --- a/scripts/codegen/templates/CapTable.daml.template +++ b/scripts/codegen/templates/CapTable.daml.template @@ -170,286 +170,622 @@ validateFinancingIssuanceReferences maps = financing.financing_data.issuance_ids) (Map.values (fromOptional Map.empty maps.financings)) --- OCF dates are calendar dates. A non-quantity transaction may occur on the --- same date as a terminal transaction, but it cannot target the retired source --- security on a later OCF date. +-- OCF dates are calendar dates. A transaction may share a date with the +-- terminal event that retires its source security, but it cannot target that +-- source on a later OCF date. ocfDate : Time -> Date ocfDate = Date.toDateUTC isEarlierOcfDate : Time -> Time -> Bool isEarlierOcfDate candidate target = ocfDate candidate < ocfDate target -priorStockRetirementExists : CapTableMaps -> Text -> Time -> Update Bool -priorStockRetirementExists maps securityId targetDate = do +data TerminalEvent = TerminalEvent with + label: Text + date: Time + reduction: Optional Decimal + reduction_currency: Optional Text + balance_security_id: Optional Text + partial_requires_balance: Bool + deriving (Eq, Show) + +type TerminalEventIndex = Map Text [TerminalEvent] + +data SecurityTerminalIndexes = SecurityTerminalIndexes with + stock: TerminalEventIndex + convertible: TerminalEventIndex + warrant: TerminalEventIndex + equity_compensation: TerminalEventIndex + deriving (Eq, Show) + +addTerminalEvent : Text -> TerminalEvent -> TerminalEventIndex -> TerminalEventIndex +addTerminalEvent securityId event index = + Map.insert securityId (event :: fromOptional [] (Map.lookup securityId index)) index + +stockTerminalEventIndex : CapTableMaps -> Update TerminalEventIndex +stockTerminalEventIndex maps = do afterCancellations <- foldlA - (\found cancellationCid -> do - cancellation <- fetch cancellationCid - pure (found || - (cancellation.cancellation_data.security_id == securityId && - isEarlierOcfDate cancellation.cancellation_data.date targetDate))) - False + (\index cid -> do + tx <- fetch cid + pure (addTerminalEvent tx.cancellation_data.security_id + (TerminalEvent with + label = "Stock cancellation " <> tx.cancellation_data.id + date = tx.cancellation_data.date + reduction = Some tx.cancellation_data.quantity + reduction_currency = None + balance_security_id = tx.cancellation_data.balance_security_id + partial_requires_balance = True) + index)) + Map.empty (Map.values maps.stock_cancellations) afterTransfers <- foldlA - (\found transferCid -> do - transfer <- fetch transferCid - pure (found || - (transfer.transfer_data.security_id == securityId && - isEarlierOcfDate transfer.transfer_data.date targetDate))) + (\index cid -> do + tx <- fetch cid + pure (addTerminalEvent tx.transfer_data.security_id + (TerminalEvent with + label = "Stock transfer " <> tx.transfer_data.id + date = tx.transfer_data.date + reduction = Some tx.transfer_data.quantity + reduction_currency = None + balance_security_id = tx.transfer_data.balance_security_id + partial_requires_balance = True) + index)) afterCancellations (Map.values maps.stock_transfers) afterRepurchases <- foldlA - (\found repurchaseCid -> do - repurchase <- fetch repurchaseCid - pure (found || - (repurchase.repurchase_data.security_id == securityId && - isEarlierOcfDate repurchase.repurchase_data.date targetDate))) + (\index cid -> do + tx <- fetch cid + pure (addTerminalEvent tx.repurchase_data.security_id + (TerminalEvent with + label = "Stock repurchase " <> tx.repurchase_data.id + date = tx.repurchase_data.date + reduction = Some tx.repurchase_data.quantity + reduction_currency = None + balance_security_id = tx.repurchase_data.balance_security_id + partial_requires_balance = True) + index)) afterTransfers (Map.values maps.stock_repurchases) afterConversions <- foldlA - (\found conversionCid -> do - conversion <- fetch conversionCid - pure (found || - (conversion.conversion_data.security_id == securityId && - isEarlierOcfDate conversion.conversion_data.date targetDate))) + (\index cid -> do + tx <- fetch cid + pure (addTerminalEvent tx.conversion_data.security_id + (TerminalEvent with + label = "Stock conversion " <> tx.conversion_data.id + date = tx.conversion_data.date + reduction = Some tx.conversion_data.quantity_converted + reduction_currency = None + balance_security_id = tx.conversion_data.balance_security_id + partial_requires_balance = True) + index)) afterRepurchases (Map.values maps.stock_conversions) afterReissuances <- foldlA - (\found reissuanceCid -> do - reissuance <- fetch reissuanceCid - pure (found || - (reissuance.reissuance_data.security_id == securityId && - isEarlierOcfDate reissuance.reissuance_data.date targetDate))) + (\index cid -> do + tx <- fetch cid + pure (addTerminalEvent tx.reissuance_data.security_id + (TerminalEvent with + label = "Stock reissuance " <> tx.reissuance_data.id + date = tx.reissuance_data.date + reduction = None + reduction_currency = None + balance_security_id = None + partial_requires_balance = False) + index)) afterConversions (Map.values maps.stock_reissuances) afterConsolidations <- foldlA - (\found consolidationCid -> do - consolidation <- fetch consolidationCid - pure (found || - (securityId `elem` consolidation.consolidation_data.security_ids && - isEarlierOcfDate consolidation.consolidation_data.date targetDate))) + (\index cid -> do + tx <- fetch cid + let event = TerminalEvent with + label = "Stock consolidation " <> tx.consolidation_data.id + date = tx.consolidation_data.date + reduction = None + reduction_currency = None + balance_security_id = None + partial_requires_balance = False + pure (foldl (\next securityId -> addTerminalEvent securityId event next) index tx.consolidation_data.security_ids)) afterReissuances (Map.values maps.stock_consolidations) foldlA - (\found retractionCid -> do - retraction <- fetch retractionCid - pure (found || - (retraction.retraction_data.security_id == securityId && - isEarlierOcfDate retraction.retraction_data.date targetDate))) + (\index cid -> do + tx <- fetch cid + pure (addTerminalEvent tx.retraction_data.security_id + (TerminalEvent with + label = "Stock retraction " <> tx.retraction_data.id + date = tx.retraction_data.date + reduction = None + reduction_currency = None + balance_security_id = None + partial_requires_balance = False) + index)) afterConsolidations (Map.values maps.stock_retractions) -priorConvertibleRetirementExists : CapTableMaps -> Text -> Time -> Update Bool -priorConvertibleRetirementExists maps securityId targetDate = do +convertibleTerminalEventIndex : CapTableMaps -> Update TerminalEventIndex +convertibleTerminalEventIndex maps = do afterCancellations <- foldlA - (\found cancellationCid -> do - cancellation <- fetch cancellationCid - pure (found || - (cancellation.cancellation_data.security_id == securityId && - isEarlierOcfDate cancellation.cancellation_data.date targetDate))) - False + (\index cid -> do + tx <- fetch cid + pure (addTerminalEvent tx.cancellation_data.security_id + (TerminalEvent with + label = "Convertible cancellation " <> tx.cancellation_data.id + date = tx.cancellation_data.date + reduction = Some tx.cancellation_data.amount.amount + reduction_currency = Some tx.cancellation_data.amount.currency + balance_security_id = tx.cancellation_data.balance_security_id + partial_requires_balance = True) + index)) + Map.empty (Map.values maps.convertible_cancellations) afterTransfers <- foldlA - (\found transferCid -> do - transfer <- fetch transferCid - pure (found || - (transfer.transfer_data.security_id == securityId && - isEarlierOcfDate transfer.transfer_data.date targetDate))) + (\index cid -> do + tx <- fetch cid + pure (addTerminalEvent tx.transfer_data.security_id + (TerminalEvent with + label = "Convertible transfer " <> tx.transfer_data.id + date = tx.transfer_data.date + reduction = Some tx.transfer_data.amount.amount + reduction_currency = Some tx.transfer_data.amount.currency + balance_security_id = tx.transfer_data.balance_security_id + partial_requires_balance = True) + index)) afterCancellations (Map.values maps.convertible_transfers) afterConversions <- foldlA - (\found conversionCid -> do - conversion <- fetch conversionCid - pure (found || - (conversion.conversion_data.security_id == securityId && - isEarlierOcfDate conversion.conversion_data.date targetDate))) + (\index cid -> do + tx <- fetch cid + pure (addTerminalEvent tx.conversion_data.security_id + (TerminalEvent with + label = "Convertible conversion " <> tx.conversion_data.id + date = tx.conversion_data.date + reduction = None + reduction_currency = None + balance_security_id = tx.conversion_data.balance_security_id + partial_requires_balance = False) + index)) afterTransfers (Map.values maps.convertible_conversions) foldlA - (\found retractionCid -> do - retraction <- fetch retractionCid - pure (found || - (retraction.retraction_data.security_id == securityId && - isEarlierOcfDate retraction.retraction_data.date targetDate))) + (\index cid -> do + tx <- fetch cid + pure (addTerminalEvent tx.retraction_data.security_id + (TerminalEvent with + label = "Convertible retraction " <> tx.retraction_data.id + date = tx.retraction_data.date + reduction = None + reduction_currency = None + balance_security_id = None + partial_requires_balance = False) + index)) afterConversions (Map.values maps.convertible_retractions) -priorWarrantRetirementExists : CapTableMaps -> Text -> Time -> Update Bool -priorWarrantRetirementExists maps securityId targetDate = do +warrantTerminalEventIndex : CapTableMaps -> Update TerminalEventIndex +warrantTerminalEventIndex maps = do afterCancellations <- foldlA - (\found cancellationCid -> do - cancellation <- fetch cancellationCid - pure (found || - (cancellation.cancellation_data.security_id == securityId && - isEarlierOcfDate cancellation.cancellation_data.date targetDate))) - False + (\index cid -> do + tx <- fetch cid + pure (addTerminalEvent tx.cancellation_data.security_id + (TerminalEvent with + label = "Warrant cancellation " <> tx.cancellation_data.id + date = tx.cancellation_data.date + reduction = Some tx.cancellation_data.quantity + reduction_currency = None + balance_security_id = tx.cancellation_data.balance_security_id + partial_requires_balance = True) + index)) + Map.empty (Map.values maps.warrant_cancellations) afterTransfers <- foldlA - (\found transferCid -> do - transfer <- fetch transferCid - pure (found || - (transfer.transfer_data.security_id == securityId && - isEarlierOcfDate transfer.transfer_data.date targetDate))) + (\index cid -> do + tx <- fetch cid + pure (addTerminalEvent tx.transfer_data.security_id + (TerminalEvent with + label = "Warrant transfer " <> tx.transfer_data.id + date = tx.transfer_data.date + reduction = Some tx.transfer_data.quantity + reduction_currency = None + balance_security_id = tx.transfer_data.balance_security_id + partial_requires_balance = True) + index)) afterCancellations (Map.values maps.warrant_transfers) - afterRetractions <- foldlA - (\found retractionCid -> do - retraction <- fetch retractionCid - pure (found || - (retraction.retraction_data.security_id == securityId && - isEarlierOcfDate retraction.retraction_data.date targetDate))) + afterExercises <- foldlA + (\index cid -> do + tx <- fetch cid + pure (addTerminalEvent tx.exercise_data.security_id + (TerminalEvent with + label = "Warrant exercise " <> tx.exercise_data.id + date = tx.exercise_data.date + reduction = tx.exercise_data.quantity + reduction_currency = None + balance_security_id = None + partial_requires_balance = False) + index)) afterTransfers + (Map.values maps.warrant_exercises) + foldlA + (\index cid -> do + tx <- fetch cid + pure (addTerminalEvent tx.retraction_data.security_id + (TerminalEvent with + label = "Warrant retraction " <> tx.retraction_data.id + date = tx.retraction_data.date + reduction = None + reduction_currency = None + balance_security_id = None + partial_requires_balance = False) + index)) + afterExercises (Map.values maps.warrant_retractions) - if afterRetractions - then pure True - else case Map.lookup securityId maps.warrant_issuances_by_security_id of - Some issuanceCid -> do - issuance <- fetch issuanceCid - (knownExerciseTotal, unknownExerciseRetirement) <- foldlA - (\(total, retired) exerciseCid -> do - exercise <- fetch exerciseCid - if exercise.exercise_data.security_id /= securityId || - not (isEarlierOcfDate exercise.exercise_data.date targetDate) - then pure (total, retired) - else case exercise.exercise_data.quantity of - Some quantity -> pure (total + quantity, retired) - None -> pure (total, True)) - (0.0, False) - (Map.values maps.warrant_exercises) - pure (unknownExerciseRetirement || case issuance.issuance_data.quantity of - Some quantity -> knownExerciseTotal >= quantity - None -> False) - None -> pure False - -priorEquityCompensationRetirementExists : CapTableMaps -> Text -> Time -> Update Bool -priorEquityCompensationRetirementExists maps securityId targetDate = do + +equityCompensationTerminalEventIndex : CapTableMaps -> Update TerminalEventIndex +equityCompensationTerminalEventIndex maps = do afterCancellations <- foldlA - (\found cancellationCid -> do - cancellation <- fetch cancellationCid - pure (found || - (cancellation.cancellation_data.security_id == securityId && - isEarlierOcfDate cancellation.cancellation_data.date targetDate))) - False + (\index cid -> do + tx <- fetch cid + pure (addTerminalEvent tx.cancellation_data.security_id + (TerminalEvent with + label = "Equity compensation cancellation " <> tx.cancellation_data.id + date = tx.cancellation_data.date + reduction = Some tx.cancellation_data.quantity + reduction_currency = None + balance_security_id = tx.cancellation_data.balance_security_id + partial_requires_balance = True) + index)) + Map.empty (Map.values maps.equity_compensation_cancellations) afterTransfers <- foldlA - (\found transferCid -> do - transfer <- fetch transferCid - pure (found || - (transfer.transfer_data.security_id == securityId && - isEarlierOcfDate transfer.transfer_data.date targetDate))) + (\index cid -> do + tx <- fetch cid + pure (addTerminalEvent tx.transfer_data.security_id + (TerminalEvent with + label = "Equity compensation transfer " <> tx.transfer_data.id + date = tx.transfer_data.date + reduction = Some tx.transfer_data.quantity + reduction_currency = None + balance_security_id = tx.transfer_data.balance_security_id + partial_requires_balance = True) + index)) afterCancellations (Map.values maps.equity_compensation_transfers) - afterRetractions <- foldlA - (\found retractionCid -> do - retraction <- fetch retractionCid - pure (found || - (retraction.retraction_data.security_id == securityId && - isEarlierOcfDate retraction.retraction_data.date targetDate))) + afterExercises <- foldlA + (\index cid -> do + tx <- fetch cid + pure (addTerminalEvent tx.exercise_data.security_id + (TerminalEvent with + label = "Equity compensation exercise " <> tx.exercise_data.id + date = tx.exercise_data.date + reduction = Some tx.exercise_data.quantity + reduction_currency = None + balance_security_id = None + partial_requires_balance = False) + index)) afterTransfers + (Map.values maps.equity_compensation_exercises) + afterReleases <- foldlA + (\index cid -> do + tx <- fetch cid + pure (addTerminalEvent tx.release_data.security_id + (TerminalEvent with + label = "Equity compensation release " <> tx.release_data.id + date = tx.release_data.date + reduction = Some tx.release_data.quantity + reduction_currency = None + balance_security_id = None + partial_requires_balance = False) + index)) + afterExercises + (Map.values maps.equity_compensation_releases) + foldlA + (\index cid -> do + tx <- fetch cid + pure (addTerminalEvent tx.retraction_data.security_id + (TerminalEvent with + label = "Equity compensation retraction " <> tx.retraction_data.id + date = tx.retraction_data.date + reduction = None + reduction_currency = None + balance_security_id = None + partial_requires_balance = False) + index)) + afterReleases (Map.values maps.equity_compensation_retractions) - if afterRetractions - then pure True - else case Map.lookup securityId maps.equity_compensation_issuances_by_security_id of - Some issuanceCid -> do - issuance <- fetch issuanceCid - exercised <- foldlA - (\total exerciseCid -> do - exercise <- fetch exerciseCid - pure (if exercise.exercise_data.security_id == securityId && - isEarlierOcfDate exercise.exercise_data.date targetDate - then total + exercise.exercise_data.quantity - else total)) - 0.0 - (Map.values maps.equity_compensation_exercises) - released <- foldlA - (\total releaseCid -> do - release <- fetch releaseCid - pure (if release.release_data.security_id == securityId && - isEarlierOcfDate release.release_data.date targetDate - then total + release.release_data.quantity - else total)) - 0.0 - (Map.values maps.equity_compensation_releases) - pure (exercised + released >= issuance.issuance_data.quantity) - None -> pure False - -assertActiveStockTarget : CapTableMaps -> Text -> Time -> Text -> Update () -assertActiveStockTarget maps label targetDate securityId = do - retired <- priorStockRetirementExists maps securityId targetDate - assertMsg (label <> " targets inactive stock security " <> securityId) (not retired) - -assertActiveConvertibleTarget : CapTableMaps -> Text -> Time -> Text -> Update () -assertActiveConvertibleTarget maps label targetDate securityId = do - retired <- priorConvertibleRetirementExists maps securityId targetDate - assertMsg (label <> " targets inactive convertible security " <> securityId) (not retired) - -assertActiveWarrantTarget : CapTableMaps -> Text -> Time -> Text -> Update () -assertActiveWarrantTarget maps label targetDate securityId = do - retired <- priorWarrantRetirementExists maps securityId targetDate - assertMsg (label <> " targets inactive warrant security " <> securityId) (not retired) - -assertActiveEquityCompensationTarget : CapTableMaps -> Text -> Time -> Text -> Update () -assertActiveEquityCompensationTarget maps label targetDate securityId = do - retired <- priorEquityCompensationRetirementExists maps securityId targetDate - assertMsg (label <> " targets inactive equity compensation security " <> securityId) (not retired) - -assertActiveVestingTarget : CapTableMaps -> Text -> Time -> Text -> Update () -assertActiveVestingTarget maps label targetDate securityId = + +securityTerminalIndexes : CapTableMaps -> Update SecurityTerminalIndexes +securityTerminalIndexes maps = do + stock <- stockTerminalEventIndex maps + convertible <- convertibleTerminalEventIndex maps + warrant <- warrantTerminalEventIndex maps + equityCompensation <- equityCompensationTerminalEventIndex maps + pure SecurityTerminalIndexes with + stock = stock + convertible = convertible + warrant = warrant + equity_compensation = equityCompensation + +priorTerminalExists : TerminalEventIndex -> Text -> Time -> Bool +priorTerminalExists index securityId targetDate = + any (\event -> isEarlierOcfDate event.date targetDate) + (fromOptional [] (Map.lookup securityId index)) + +assertSingleTerminalEvent : Text -> Text -> [TerminalEvent] -> Update () +assertSingleTerminalEvent family securityId events = + assertMsg + (family <> " security " <> securityId <> " is targeted by multiple terminal transactions") + (length events <= 1) + +validateTerminalEventReferences : Text -> Map Text a -> TerminalEventIndex -> Update () +validateTerminalEventReferences family issuancesBySecurityId index = + mapA_ + (\(securityId, events) -> do + assertMsg + (family <> " terminal transaction references missing issuance security " <> securityId) + (isSome (Map.lookup securityId issuancesBySecurityId)) + assertSingleTerminalEvent family securityId events) + (Map.toList index) + +splitAdjustedQuantity : CapTableMaps -> Text -> Time -> Time -> Decimal -> Update Decimal +splitAdjustedQuantity maps stockClassId issuanceDate eventDate initialQuantity = + foldlA + (\quantity splitCid -> do + split <- fetch splitCid + let splitDate = ocfDate split.split_data.date + pure (if split.split_data.stock_class_id == stockClassId && + splitDate >= ocfDate issuanceDate && splitDate <= ocfDate eventDate + then quantity * split.split_data.split_ratio.numerator / split.split_data.split_ratio.denominator + else quantity)) + initialQuantity + (Map.values maps.stock_class_splits) + +validateStockBalanceSuccessor : CapTableMaps -> StockIssuanceOcfData -> TerminalEvent -> Decimal -> Text -> Update () +validateStockBalanceSuccessor maps source event remainder balanceSecurityId = do + assertMsg (event.label <> " balance security must differ from its retired source") + (balanceSecurityId /= source.security_id) + case Map.lookup balanceSecurityId maps.stock_issuances_by_security_id of + Some cid -> do + balance <- fetch cid + assertMsg (event.label <> " balance stakeholder mismatch") + (balance.issuance_data.stakeholder_id == source.stakeholder_id) + assertMsg (event.label <> " balance stock class mismatch") + (balance.issuance_data.stock_class_id == source.stock_class_id) + assertMsg (event.label <> " balance quantity mismatch: expected " <> show remainder <> ", found " <> show balance.issuance_data.quantity) + (balance.issuance_data.quantity == remainder) + None -> assertMsg (event.label <> " balance stock issuance not found: " <> balanceSecurityId) False + +validateStockTerminalCompleteness : CapTableMaps -> TerminalEventIndex -> Update () +validateStockTerminalCompleteness maps index = + mapA_ + (\cid -> do + issuance <- fetch cid + case Map.lookup issuance.issuance_data.security_id index of + Some [event] -> when event.partial_requires_balance do + currentQuantity <- splitAdjustedQuantity maps issuance.issuance_data.stock_class_id issuance.issuance_data.date event.date issuance.issuance_data.quantity + case event.reduction of + Some reduction -> do + let remainder = currentQuantity - reduction + assertMsg (event.label <> " exceeds current source quantity") (remainder >= 0.0) + case event.balance_security_id of + Some balanceSecurityId -> do + assertMsg (event.label <> " balance security requires a positive remainder") (remainder > 0.0) + validateStockBalanceSuccessor maps issuance.issuance_data event remainder balanceSecurityId + None -> assertMsg + (event.label <> " without balance_security_id must consume the full current source quantity") + (remainder == 0.0) + None -> assertMsg (event.label <> " is missing its reduction quantity") False + _ -> pure ()) + (Map.values maps.stock_issuances) + +validateWarrantBalanceSuccessor : CapTableMaps -> WarrantIssuanceOcfData -> TerminalEvent -> Optional Decimal -> Text -> Update () +validateWarrantBalanceSuccessor maps source event remainder balanceSecurityId = do + assertMsg (event.label <> " balance security must differ from its retired source") + (balanceSecurityId /= source.security_id) + case Map.lookup balanceSecurityId maps.warrant_issuances_by_security_id of + Some cid -> do + balance <- fetch cid + assertMsg (event.label <> " balance stakeholder mismatch") + (balance.issuance_data.stakeholder_id == source.stakeholder_id) + assertMsg (event.label <> " balance quantity mismatch") + (balance.issuance_data.quantity == remainder) + None -> assertMsg (event.label <> " balance warrant issuance not found: " <> balanceSecurityId) False + +validateWarrantTerminalCompleteness : CapTableMaps -> TerminalEventIndex -> Update () +validateWarrantTerminalCompleteness maps index = + mapA_ + (\cid -> do + issuance <- fetch cid + case Map.lookup issuance.issuance_data.security_id index of + Some [event] -> when event.partial_requires_balance do + case issuance.issuance_data.quantity of + Some currentQuantity -> case event.reduction of + Some reduction -> do + let remainder = currentQuantity - reduction + assertMsg (event.label <> " exceeds current source quantity") (remainder >= 0.0) + case event.balance_security_id of + Some balanceSecurityId -> do + assertMsg (event.label <> " balance security requires a positive remainder") (remainder > 0.0) + validateWarrantBalanceSuccessor maps issuance.issuance_data event (Some remainder) balanceSecurityId + None -> assertMsg + (event.label <> " without balance_security_id must consume the full current source quantity") + (remainder == 0.0) + None -> assertMsg (event.label <> " is missing its reduction quantity") False + None -> case event.balance_security_id of + Some balanceSecurityId -> validateWarrantBalanceSuccessor maps issuance.issuance_data event None balanceSecurityId + None -> pure () + _ -> pure ()) + (Map.values maps.warrant_issuances) + +equityCompensationQuantityAt : CapTableMaps -> EquityCompensationIssuanceOcfData -> Time -> Update Decimal +equityCompensationQuantityAt maps source eventDate = case source.stock_class_id of + Some stockClassId -> splitAdjustedQuantity maps stockClassId source.date eventDate source.quantity + None -> pure source.quantity + +validateEquityCompensationBalanceSuccessor : CapTableMaps -> EquityCompensationIssuanceOcfData -> TerminalEvent -> Decimal -> Text -> Update () +validateEquityCompensationBalanceSuccessor maps source event remainder balanceSecurityId = do + assertMsg (event.label <> " balance security must differ from its retired source") + (balanceSecurityId /= source.security_id) + case Map.lookup balanceSecurityId maps.equity_compensation_issuances_by_security_id of + Some cid -> do + balance <- fetch cid + assertMsg (event.label <> " balance stakeholder mismatch") + (balance.issuance_data.stakeholder_id == source.stakeholder_id) + assertMsg (event.label <> " balance stock class mismatch") + (balance.issuance_data.stock_class_id == source.stock_class_id) + assertMsg (event.label <> " balance quantity mismatch: expected " <> show remainder <> ", found " <> show balance.issuance_data.quantity) + (balance.issuance_data.quantity == remainder) + None -> assertMsg (event.label <> " balance equity compensation issuance not found: " <> balanceSecurityId) False + +validateEquityCompensationTerminalCompleteness : CapTableMaps -> TerminalEventIndex -> Update () +validateEquityCompensationTerminalCompleteness maps index = + mapA_ + (\cid -> do + issuance <- fetch cid + case Map.lookup issuance.issuance_data.security_id index of + Some [event] -> when event.partial_requires_balance do + currentQuantity <- equityCompensationQuantityAt maps issuance.issuance_data event.date + case event.reduction of + Some reduction -> do + let remainder = currentQuantity - reduction + assertMsg (event.label <> " exceeds current source quantity") (remainder >= 0.0) + case event.balance_security_id of + Some balanceSecurityId -> do + assertMsg (event.label <> " balance security requires a positive remainder") (remainder > 0.0) + validateEquityCompensationBalanceSuccessor maps issuance.issuance_data event remainder balanceSecurityId + None -> assertMsg + (event.label <> " without balance_security_id must consume the full current source quantity") + (remainder == 0.0) + None -> assertMsg (event.label <> " is missing its reduction quantity") False + _ -> pure ()) + (Map.values maps.equity_compensation_issuances) + +validateConvertibleBalanceSuccessor : CapTableMaps -> ConvertibleIssuanceOcfData -> TerminalEvent -> Decimal -> Text -> Update () +validateConvertibleBalanceSuccessor maps source event remainder balanceSecurityId = do + assertMsg (event.label <> " balance security must differ from its retired source") + (balanceSecurityId /= source.security_id) + case Map.lookup balanceSecurityId maps.convertible_issuances_by_security_id of + Some cid -> do + balance <- fetch cid + assertMsg (event.label <> " balance stakeholder mismatch") + (balance.issuance_data.stakeholder_id == source.stakeholder_id) + assertMsg (event.label <> " balance currency mismatch") + (balance.issuance_data.investment_amount.currency == source.investment_amount.currency) + assertMsg (event.label <> " balance principal mismatch: expected " <> show remainder <> ", found " <> show balance.issuance_data.investment_amount.amount) + (balance.issuance_data.investment_amount.amount == remainder) + None -> assertMsg (event.label <> " balance convertible issuance not found: " <> balanceSecurityId) False + +validateConvertibleTerminalCompleteness : CapTableMaps -> TerminalEventIndex -> Update () +validateConvertibleTerminalCompleteness maps index = + mapA_ + (\cid -> do + issuance <- fetch cid + case Map.lookup issuance.issuance_data.security_id index of + Some [event] -> when event.partial_requires_balance do + case event.reduction of + Some reduction -> do + assertMsg (event.label <> " currency mismatch") + (event.reduction_currency == Some issuance.issuance_data.investment_amount.currency) + let remainder = issuance.issuance_data.investment_amount.amount - reduction + assertMsg (event.label <> " exceeds current source principal") (remainder >= 0.0) + case event.balance_security_id of + Some balanceSecurityId -> do + assertMsg (event.label <> " balance security requires a positive remainder") (remainder > 0.0) + validateConvertibleBalanceSuccessor maps issuance.issuance_data event remainder balanceSecurityId + None -> assertMsg + (event.label <> " without balance_security_id must consume the full current source principal") + (remainder == 0.0) + None -> assertMsg (event.label <> " is missing its reduction amount") False + _ -> pure ()) + (Map.values maps.convertible_issuances) + +validateTerminalTransactionCompleteness : CapTableMaps -> SecurityTerminalIndexes -> Update () +validateTerminalTransactionCompleteness maps indexes = do + validateTerminalEventReferences "Stock" maps.stock_issuances_by_security_id indexes.stock + validateTerminalEventReferences "Convertible" maps.convertible_issuances_by_security_id indexes.convertible + validateTerminalEventReferences "Warrant" maps.warrant_issuances_by_security_id indexes.warrant + validateTerminalEventReferences "Equity compensation" maps.equity_compensation_issuances_by_security_id indexes.equity_compensation + validateStockTerminalCompleteness maps indexes.stock + validateConvertibleTerminalCompleteness maps indexes.convertible + validateWarrantTerminalCompleteness maps indexes.warrant + validateEquityCompensationTerminalCompleteness maps indexes.equity_compensation + +assertActiveStockTarget : SecurityTerminalIndexes -> Text -> Time -> Text -> Update () +assertActiveStockTarget indexes label targetDate securityId = + assertMsg (label <> " targets inactive stock security " <> securityId) + (not (priorTerminalExists indexes.stock securityId targetDate)) + +assertActiveConvertibleTarget : SecurityTerminalIndexes -> Text -> Time -> Text -> Update () +assertActiveConvertibleTarget indexes label targetDate securityId = + assertMsg (label <> " targets inactive convertible security " <> securityId) + (not (priorTerminalExists indexes.convertible securityId targetDate)) + +assertActiveWarrantTarget : SecurityTerminalIndexes -> Text -> Time -> Text -> Update () +assertActiveWarrantTarget indexes label targetDate securityId = + assertMsg (label <> " targets inactive warrant security " <> securityId) + (not (priorTerminalExists indexes.warrant securityId targetDate)) + +assertActiveEquityCompensationTarget : SecurityTerminalIndexes -> Text -> Time -> Text -> Update () +assertActiveEquityCompensationTarget indexes label targetDate securityId = + assertMsg (label <> " targets inactive equity compensation security " <> securityId) + (not (priorTerminalExists indexes.equity_compensation securityId targetDate)) + +assertActiveVestingTarget : CapTableMaps -> SecurityTerminalIndexes -> Text -> Time -> Text -> Update () +assertActiveVestingTarget maps indexes label targetDate securityId = case Map.lookup securityId maps.stock_issuances_by_security_id of - Some _ -> assertActiveStockTarget maps label targetDate securityId + Some _ -> assertActiveStockTarget indexes label targetDate securityId None -> case Map.lookup securityId maps.warrant_issuances_by_security_id of - Some _ -> assertActiveWarrantTarget maps label targetDate securityId + Some _ -> assertActiveWarrantTarget indexes label targetDate securityId None -> case Map.lookup securityId maps.equity_compensation_issuances_by_security_id of - Some _ -> assertActiveEquityCompensationTarget maps label targetDate securityId + Some _ -> assertActiveEquityCompensationTarget indexes label targetDate securityId None -> pure () -validateActiveSecurityTargets : CapTableMaps -> Update () -validateActiveSecurityTargets maps = do +validateActiveSecurityTargets : CapTableMaps -> SecurityTerminalIndexes -> Update () +validateActiveSecurityTargets maps indexes = do mapA_ (\acceptanceCid -> do acceptance <- fetch acceptanceCid - assertActiveStockTarget maps ("Stock acceptance " <> acceptance.acceptance_data.id) + assertActiveStockTarget indexes ("Stock acceptance " <> acceptance.acceptance_data.id) acceptance.acceptance_data.date acceptance.acceptance_data.security_id) (Map.values maps.stock_acceptances) mapA_ (\acceptanceCid -> do acceptance <- fetch acceptanceCid - assertActiveConvertibleTarget maps ("Convertible acceptance " <> acceptance.acceptance_data.id) + assertActiveConvertibleTarget indexes ("Convertible acceptance " <> acceptance.acceptance_data.id) acceptance.acceptance_data.date acceptance.acceptance_data.security_id) (Map.values maps.convertible_acceptances) mapA_ (\acceptanceCid -> do acceptance <- fetch acceptanceCid - assertActiveWarrantTarget maps ("Warrant acceptance " <> acceptance.acceptance_data.id) + assertActiveWarrantTarget indexes ("Warrant acceptance " <> acceptance.acceptance_data.id) acceptance.acceptance_data.date acceptance.acceptance_data.security_id) (Map.values maps.warrant_acceptances) mapA_ (\acceptanceCid -> do acceptance <- fetch acceptanceCid - assertActiveEquityCompensationTarget maps ("Equity compensation acceptance " <> acceptance.acceptance_data.id) + assertActiveEquityCompensationTarget indexes ("Equity compensation acceptance " <> acceptance.acceptance_data.id) acceptance.acceptance_data.date acceptance.acceptance_data.security_id) (Map.values maps.equity_compensation_acceptances) mapA_ (\repricingCid -> do repricing <- fetch repricingCid - assertActiveEquityCompensationTarget maps ("Equity compensation repricing " <> repricing.repricing_data.id) + assertActiveEquityCompensationTarget indexes ("Equity compensation repricing " <> repricing.repricing_data.id) repricing.repricing_data.date repricing.repricing_data.security_id) (Map.values maps.equity_compensation_repricings) mapA_ (\startCid -> do start <- fetch startCid - assertActiveVestingTarget maps ("Vesting start " <> start.vesting_data.id) + assertActiveVestingTarget maps indexes ("Vesting start " <> start.vesting_data.id) start.vesting_data.date start.vesting_data.security_id) (Map.values maps.vesting_starts) mapA_ (\eventCid -> do event <- fetch eventCid - assertActiveVestingTarget maps ("Vesting event " <> event.vesting_data.id) + assertActiveVestingTarget maps indexes ("Vesting event " <> event.vesting_data.id) event.vesting_data.date event.vesting_data.security_id) (Map.values maps.vesting_events) mapA_ (\accelerationCid -> do acceleration <- fetch accelerationCid - assertActiveVestingTarget maps ("Vesting acceleration " <> acceleration.acceleration_data.id) + assertActiveVestingTarget maps indexes ("Vesting acceleration " <> acceleration.acceleration_data.id) acceleration.acceleration_data.date acceleration.acceleration_data.security_id) (Map.values maps.vesting_accelerations) @@ -563,7 +899,9 @@ template CapTable -- Validate Financing references only after the complete batch state is known. validateFinancingIssuanceReferences finalMaps - validateActiveSecurityTargets finalMaps + terminalIndexes <- securityTerminalIndexes finalMaps + validateTerminalTransactionCompleteness finalMaps terminalIndexes + validateActiveSecurityTargets finalMaps terminalIndexes -- Create new CapTable with updated maps and potentially new issuer newCapTableCid <- create this with From 98b2f98f40ebd6c8a55a187895acc54ce4b6b58a Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Wed, 22 Jul 2026 09:54:17 -0400 Subject: [PATCH 2/5] Simplify terminal balance validation --- .../TestConvertibleConversion.daml | 26 +- .../TestEquityCompensationCancellation.daml | 28 +- .../OpenCapTable/TestStockCancellation.daml | 106 ++++++ .../OpenCapTable/TestWarrantCancellation.daml | 22 ++ .../OpenCapTable/TestWarrantExercise.daml | 21 +- .../0.0.3/OpenCapTable-v34.dar | 4 +- dars/dars.lock | 6 +- .../codegen/templates/CapTable.daml.template | 301 ++++++++++++------ 8 files changed, 402 insertions(+), 112 deletions(-) diff --git a/Test/daml/OpenCapTable/TestConvertibleConversion.daml b/Test/daml/OpenCapTable/TestConvertibleConversion.daml index 83452d47..1906bc91 100644 --- a/Test/daml/OpenCapTable/TestConvertibleConversion.daml +++ b/Test/daml/OpenCapTable/TestConvertibleConversion.daml @@ -17,7 +17,7 @@ createConvertibleIssuance issuer capTableCid securityId amount = submit issuer do exerciseCmd capTableCid CT.UpdateCapTable with creates = [CT.OcfCreateConvertibleIssuance ConvertibleIssuanceOcfData with - id = "TX_CONV_ISS_1" + id = "TX_CONV_ISS_" <> securityId date = DT.time (DA.date 2023 Mar 15) 0 0 0 security_id = securityId custom_id = "SAFE-1" @@ -112,8 +112,9 @@ testConvertibleConversion_PartialConversion = script do capTableCid <- addDefaultStakeholder issuer cap_table let securityId = "CONV-SEC-003" result <- createConvertibleIssuance issuer capTableCid securityId 500000.0 + balanceResult <- createConvertibleIssuance issuer result.updatedCapTableCid "CONV-SEC-003-REMAINDER" 200000.0 _ <- submit issuer do - exerciseCmd result.updatedCapTableCid CT.UpdateCapTable with + exerciseCmd balanceResult.updatedCapTableCid CT.UpdateCapTable with creates = [CT.OcfCreateConvertibleConversion ConvertibleConversionOcfData with id = "TX_CONV_CONVERSION_3" date = DT.time (DA.date 2025 Nov 15) 0 0 0 @@ -128,3 +129,24 @@ testConvertibleConversion_PartialConversion = script do edits = [] deletes = [] pure () + +testConvertibleConversion_MissingBalanceSecurityFails = script do + TestOcp{issuer, cap_table} <- setupTestOcp + capTableCid <- addDefaultStakeholder issuer cap_table + let securityId = "CONV-SEC-MISSING-BALANCE" + result <- createConvertibleIssuance issuer capTableCid securityId 500000.0 + submitMustFail issuer do + exerciseCmd result.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateConvertibleConversion ConvertibleConversionOcfData with + id = "TX_CONV_CONVERSION_MISSING_BALANCE" + date = DT.time (DA.date 2025 Nov 15) 0 0 0 + security_id = securityId + trigger_id = "equity-financing-trigger" + reason_text = "Partial conversion" + resulting_security_ids = ["SSEC-PREFERRED-MISSING-BALANCE"] + comments = [] + balance_security_id = Some "CONV-SEC-NOT-FOUND" + capitalization_definition = None + quantity_converted = Some 300000.0] + edits = [] + deletes = [] diff --git a/Test/daml/OpenCapTable/TestEquityCompensationCancellation.daml b/Test/daml/OpenCapTable/TestEquityCompensationCancellation.daml index 6150d937..c19e398f 100644 --- a/Test/daml/OpenCapTable/TestEquityCompensationCancellation.daml +++ b/Test/daml/OpenCapTable/TestEquityCompensationCancellation.daml @@ -1,6 +1,7 @@ module OpenCapTable.TestEquityCompensationCancellation where import Fairmint.OpenCapTable.OCF.EquityCompensationIssuance import Fairmint.OpenCapTable.OCF.EquityCompensationCancellation +import Fairmint.OpenCapTable.OCF.EquityCompensationExercise import Fairmint.OpenCapTable.Types.Monetary (OcfMonetary(..)) import Fairmint.OpenCapTable.Types.Vesting (OcfCompensationType(..)) import qualified Fairmint.OpenCapTable.CapTable as CT @@ -61,9 +62,30 @@ testEquityCompensationCancellation_CreateAndArchiveByIssuer = script do TestOcp{system_operator, issuer, ctx, cap_table} <- setupTestOcp capTableCid <- addDefaultStakeholder issuer cap_table let securityId = "db5a26d6-3912-4400-87b0-bda74de361af" - -- This fixture models the remaining active grant after the earlier exercises. - result <- createEquityCompensationIssuance issuer capTableCid securityId 214200.0 - _ <- createEquityCompensationCancellation issuer result.updatedCapTableCid securityId 214200.0 + result <- createEquityCompensationIssuance issuer capTableCid securityId 250000.0 + exerciseResult <- submit issuer do + exerciseCmd result.updatedCapTableCid CT.UpdateCapTable with + creates = + [ CT.OcfCreateEquityCompensationExercise EquityCompensationExerciseOcfData with + id = "TX_EC_EXERCISE_BEFORE_CANCEL_1" + date = DT.time (DA.date 2021 Oct 01) 0 0 0 + security_id = securityId + quantity = 20000.0 + consideration_text = None + resulting_security_ids = ["STOCK-FROM-EC-EXERCISE-1"] + comments = [] + , CT.OcfCreateEquityCompensationExercise EquityCompensationExerciseOcfData with + id = "TX_EC_EXERCISE_BEFORE_CANCEL_2" + date = DT.time (DA.date 2021 Nov 01) 0 0 0 + security_id = securityId + quantity = 15800.0 + consideration_text = None + resulting_security_ids = ["STOCK-FROM-EC-EXERCISE-2"] + comments = [] + ] + edits = [] + deletes = [] + _ <- createEquityCompensationCancellation issuer exerciseResult.updatedCapTableCid securityId 214200.0 pure () testEquityCompensationCancellation_PartialWithoutBalanceSecurityFails = script do diff --git a/Test/daml/OpenCapTable/TestStockCancellation.daml b/Test/daml/OpenCapTable/TestStockCancellation.daml index 10c51924..e2db8fab 100644 --- a/Test/daml/OpenCapTable/TestStockCancellation.daml +++ b/Test/daml/OpenCapTable/TestStockCancellation.daml @@ -108,6 +108,112 @@ testStockCancellation_PartialWithoutBalanceSecurityFails = script do edits = [] deletes = [] +testStockCancellation_MultipleTerminalTransactionsFail = script do + TestOcp{issuer, cap_table} <- setupTestOcp + capTableCid <- addPrerequisites issuer cap_table + result <- createStockIssuance issuer capTableCid + + submitMustFail issuer do + exerciseCmd result.updatedCapTableCid CT.UpdateCapTable with + creates = + [ CT.OcfCreateStockCancellation StockCancellationOcfData with + id = "TX_STOCK_CANCEL_DUPLICATE_1" + date = DT.time (DA.date 2024 Jan 02) 0 0 0 + security_id = "SEC-1" + quantity = 50.0 + balance_security_id = None + reason_text = "First terminal transaction" + comments = [] + , CT.OcfCreateStockCancellation StockCancellationOcfData with + id = "TX_STOCK_CANCEL_DUPLICATE_2" + date = DT.time (DA.date 2024 Jan 02) 0 0 0 + security_id = "SEC-1" + quantity = 50.0 + balance_security_id = None + reason_text = "Second terminal transaction" + comments = [] + ] + edits = [] + deletes = [] + +testStockCancellation_BalanceSecurityCannotBeReused = script do + TestOcp{issuer, cap_table} <- setupTestOcp + capTableCid <- addPrerequisites issuer cap_table + let sourceA = (defaultStockIssuance "TX_STOCK_SOURCE_A" "SEC-SOURCE-A" "SH-1" "SC_COMMON") with quantity = 100.0 + let sourceB = (defaultStockIssuance "TX_STOCK_SOURCE_B" "SEC-SOURCE-B" "SH-1" "SC_COMMON") with quantity = 100.0 + let sharedBalance = (defaultStockIssuance "TX_STOCK_SHARED_BALANCE" "SEC-SHARED-BALANCE" "SH-1" "SC_COMMON") with quantity = 50.0 + issuanceResult <- submit issuer do + exerciseCmd capTableCid CT.UpdateCapTable with + creates = + [ CT.OcfCreateStockIssuance sourceA + , CT.OcfCreateStockIssuance sourceB + , CT.OcfCreateStockIssuance sharedBalance + ] + edits = [] + deletes = [] + + submitMustFail issuer do + exerciseCmd issuanceResult.updatedCapTableCid CT.UpdateCapTable with + creates = + [ CT.OcfCreateStockCancellation StockCancellationOcfData with + id = "TX_STOCK_CANCEL_SHARED_BALANCE_A" + date = DT.time (DA.date 2024 Jan 02) 0 0 0 + security_id = sourceA.security_id + quantity = 50.0 + balance_security_id = Some sharedBalance.security_id + reason_text = "Partial cancellation A" + comments = [] + , CT.OcfCreateStockCancellation StockCancellationOcfData with + id = "TX_STOCK_CANCEL_SHARED_BALANCE_B" + date = DT.time (DA.date 2024 Jan 02) 0 0 0 + security_id = sourceB.security_id + quantity = 50.0 + balance_security_id = Some sharedBalance.security_id + reason_text = "Partial cancellation B" + comments = [] + ] + edits = [] + deletes = [] + +testStockCancellation_BalanceSecurityMustBeActive = script do + TestOcp{issuer, cap_table} <- setupTestOcp + capTableCid <- addPrerequisites issuer cap_table + let source = (defaultStockIssuance "TX_STOCK_ACTIVE_SOURCE" "SEC-ACTIVE-SOURCE" "SH-1" "SC_COMMON") with quantity = 100.0 + let retiredBalance = (defaultStockIssuance "TX_STOCK_RETIRED_BALANCE" "SEC-RETIRED-BALANCE" "SH-1" "SC_COMMON") with quantity = 50.0 + issuanceResult <- submit issuer do + exerciseCmd capTableCid CT.UpdateCapTable with + creates = + [ CT.OcfCreateStockIssuance source + , CT.OcfCreateStockIssuance retiredBalance + ] + edits = [] + deletes = [] + retirementResult <- submit issuer do + exerciseCmd issuanceResult.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateStockCancellation StockCancellationOcfData with + id = "TX_STOCK_RETIRE_BALANCE" + date = DT.time (DA.date 2024 Jan 02) 0 0 0 + security_id = retiredBalance.security_id + quantity = 50.0 + balance_security_id = None + reason_text = "Retire candidate balance" + comments = []] + edits = [] + deletes = [] + + submitMustFail issuer do + exerciseCmd retirementResult.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateStockCancellation StockCancellationOcfData with + id = "TX_STOCK_REFERENCE_RETIRED_BALANCE" + date = DT.time (DA.date 2024 Jan 03) 0 0 0 + security_id = source.security_id + quantity = 50.0 + balance_security_id = Some retiredBalance.security_id + reason_text = "Invalid retired balance reference" + comments = []] + edits = [] + deletes = [] + -- Optionals Some and arrays non-empty testStockCancellation_OptionalsSome = script do TestOcp{system_operator, issuer, ctx, cap_table} <- setupTestOcp diff --git a/Test/daml/OpenCapTable/TestWarrantCancellation.daml b/Test/daml/OpenCapTable/TestWarrantCancellation.daml index 23d9f34f..09e7e68c 100644 --- a/Test/daml/OpenCapTable/TestWarrantCancellation.daml +++ b/Test/daml/OpenCapTable/TestWarrantCancellation.daml @@ -1,6 +1,7 @@ module OpenCapTable.TestWarrantCancellation where import Fairmint.OpenCapTable.OCF.WarrantIssuance import Fairmint.OpenCapTable.OCF.WarrantCancellation +import Fairmint.OpenCapTable.OCF.WarrantExercise import Fairmint.OpenCapTable.Types.Monetary (OcfMonetary(..)) import OpenCapTable.HelpersTriggers (ocfTriggerElectiveAtWill) import qualified Fairmint.OpenCapTable.CapTable as CT @@ -89,6 +90,27 @@ testWarrantCancellation_PartialWithoutBalanceSecurityFails = script do edits = [] deletes = [] +testWarrantCancellation_AfterPartialExerciseUsesLiveBalance = script do + TestOcp{issuer, cap_table} <- setupTestOcp + capTableCid <- addDefaultStakeholder issuer cap_table + let securityId = "WSEC-EXERCISE-THEN-CANCEL" + result <- createWarrantIssuance issuer capTableCid securityId 100.0 + exerciseResult <- submit issuer do + exerciseCmd result.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateWarrantExercise WarrantExerciseOcfData with + id = "TX_WARRANT_EXERCISE_BEFORE_CANCEL" + date = DT.time (DA.date 2024 Feb 15) 0 0 0 + security_id = securityId + trigger_id = "ELECTIVE_AT_WILL" + quantity = Some 40.0 + resulting_security_ids = ["STOCK-FROM-WARRANT-EXERCISE"] + comments = [] + consideration_text = None] + edits = [] + deletes = [] + _ <- createWarrantCancellation issuer exerciseResult.updatedCapTableCid securityId 60.0 + pure () + -- Partial cancellation with optional fields populated testWarrantCancellation_PartialWithOptionals = script do TestOcp{system_operator, issuer, ctx, cap_table} <- setupTestOcp diff --git a/Test/daml/OpenCapTable/TestWarrantExercise.daml b/Test/daml/OpenCapTable/TestWarrantExercise.daml index e2925bfd..ae882316 100644 --- a/Test/daml/OpenCapTable/TestWarrantExercise.daml +++ b/Test/daml/OpenCapTable/TestWarrantExercise.daml @@ -2,7 +2,6 @@ module OpenCapTable.TestWarrantExercise where import Fairmint.OpenCapTable.OCF.WarrantIssuance import Fairmint.OpenCapTable.OCF.WarrantExercise -import Fairmint.OpenCapTable.OCF.WarrantAcceptance import Fairmint.OpenCapTable.Types.Monetary (OcfMonetary(..)) import Fairmint.OpenCapTable.Types.Stock (OcfQuantitySourceType(..)) import OpenCapTable.HelpersTriggers (ocfTriggerElectiveAtWill) @@ -83,9 +82,10 @@ testWarrantExercise_PartialWithComments = script do deletes = [] pure () --- OCF classifies an exercise as terminal for its source security. Even when --- quantity is populated, later transactions must not target that source ID. -testWarrantExercise_PartialExerciseTerminatesSourceSecurity = script do +-- The current OCF exercise schemas have no balance_security_id. Until that +-- schema gap is resolved, known partial exercises retain the source security's +-- running balance instead of inventing an unreported successor. +testWarrantExercise_PartialExerciseRetainsSourceBalance = script do TestOcp{issuer, cap_table} <- setupTestOcp capTableCid <- addDefaultStakeholder issuer cap_table let securityId = "WSEC-PARTIAL-TERMINAL" @@ -104,15 +104,20 @@ testWarrantExercise_PartialExerciseTerminatesSourceSecurity = script do edits = [] deletes = [] - submitMustFail issuer do + _ <- submit issuer do exerciseCmd exerciseResult.updatedCapTableCid CT.UpdateCapTable with - creates = [CT.OcfCreateWarrantAcceptance WarrantAcceptanceOcfData with - id = "TX_WARRANT_ACCEPT_AFTER_EXERCISE" + creates = [CT.OcfCreateWarrantExercise WarrantExerciseOcfData with + id = "TX_WARRANT_SECOND_PARTIAL_EXERCISE" date = DT.time (DA.date 2025 Aug 02) 0 0 0 security_id = securityId - comments = []] + trigger_id = "ELECTIVE_AT_WILL" + quantity = Some 60.0 + resulting_security_ids = ["SSEC-FROM-SECOND-PARTIAL"] + comments = [] + consideration_text = None] edits = [] deletes = [] + pure () -- Test 3: Simple exercise with minimal fields testWarrantExercise_MinimalFields = script do diff --git a/dars/OpenCapTable-v34/0.0.3/OpenCapTable-v34.dar b/dars/OpenCapTable-v34/0.0.3/OpenCapTable-v34.dar index 543db37c..fd4d95ba 100644 --- a/dars/OpenCapTable-v34/0.0.3/OpenCapTable-v34.dar +++ b/dars/OpenCapTable-v34/0.0.3/OpenCapTable-v34.dar @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0d20a6d970ef72ee2b0bc302bc573814ce70e3c871d418c0e06d811819fdcc73 -size 2552986 +oid sha256:076b74eff580bdb5b99baea11412db7fee2f520afab38d3098fdbb953908a74d +size 2572700 diff --git a/dars/dars.lock b/dars/dars.lock index de232b18..13099a24 100644 --- a/dars/dars.lock +++ b/dars/dars.lock @@ -19,10 +19,10 @@ "networks": [] }, "OpenCapTable-v34/0.0.3/OpenCapTable-v34.dar": { - "sha256": "0d20a6d970ef72ee2b0bc302bc573814ce70e3c871d418c0e06d811819fdcc73", - "size": 2552986, + "sha256": "076b74eff580bdb5b99baea11412db7fee2f520afab38d3098fdbb953908a74d", + "size": 2572700, "sdkVersion": "3.4.10", - "uploadedAt": "2026-07-21T19:21:03.878Z", + "uploadedAt": "2026-07-22T13:53:58.607Z", "networks": [] } } diff --git a/scripts/codegen/templates/CapTable.daml.template b/scripts/codegen/templates/CapTable.daml.template index 644aa9bd..0eb6782b 100644 --- a/scripts/codegen/templates/CapTable.daml.template +++ b/scripts/codegen/templates/CapTable.daml.template @@ -390,20 +390,6 @@ warrantTerminalEventIndex maps = do index)) afterCancellations (Map.values maps.warrant_transfers) - afterExercises <- foldlA - (\index cid -> do - tx <- fetch cid - pure (addTerminalEvent tx.exercise_data.security_id - (TerminalEvent with - label = "Warrant exercise " <> tx.exercise_data.id - date = tx.exercise_data.date - reduction = tx.exercise_data.quantity - reduction_currency = None - balance_security_id = None - partial_requires_balance = False) - index)) - afterTransfers - (Map.values maps.warrant_exercises) foldlA (\index cid -> do tx <- fetch cid @@ -416,7 +402,7 @@ warrantTerminalEventIndex maps = do balance_security_id = None partial_requires_balance = False) index)) - afterExercises + afterTransfers (Map.values maps.warrant_retractions) equityCompensationTerminalEventIndex : CapTableMaps -> Update TerminalEventIndex @@ -449,34 +435,6 @@ equityCompensationTerminalEventIndex maps = do index)) afterCancellations (Map.values maps.equity_compensation_transfers) - afterExercises <- foldlA - (\index cid -> do - tx <- fetch cid - pure (addTerminalEvent tx.exercise_data.security_id - (TerminalEvent with - label = "Equity compensation exercise " <> tx.exercise_data.id - date = tx.exercise_data.date - reduction = Some tx.exercise_data.quantity - reduction_currency = None - balance_security_id = None - partial_requires_balance = False) - index)) - afterTransfers - (Map.values maps.equity_compensation_exercises) - afterReleases <- foldlA - (\index cid -> do - tx <- fetch cid - pure (addTerminalEvent tx.release_data.security_id - (TerminalEvent with - label = "Equity compensation release " <> tx.release_data.id - date = tx.release_data.date - reduction = Some tx.release_data.quantity - reduction_currency = None - balance_security_id = None - partial_requires_balance = False) - index)) - afterExercises - (Map.values maps.equity_compensation_releases) foldlA (\index cid -> do tx <- fetch cid @@ -489,7 +447,7 @@ equityCompensationTerminalEventIndex maps = do balance_security_id = None partial_requires_balance = False) index)) - afterReleases + afterTransfers (Map.values maps.equity_compensation_retractions) securityTerminalIndexes : CapTableMaps -> Update SecurityTerminalIndexes @@ -525,6 +483,28 @@ validateTerminalEventReferences family issuancesBySecurityId index = assertSingleTerminalEvent family securityId events) (Map.toList index) +validateUniqueBalanceSecurityIds : Text -> TerminalEventIndex -> Update () +validateUniqueBalanceSecurityIds family index = do + let claims = foldl + (\claimIndex (_, events) -> + foldl + (\next event -> case event.balance_security_id of + Some balanceSecurityId -> + Map.insert balanceSecurityId + (event.label :: fromOptional [] (Map.lookup balanceSecurityId next)) + next + None -> next) + claimIndex + events) + Map.empty + (Map.toList index) + mapA_ + (\(balanceSecurityId, labels) -> + assertMsg + (family <> " balance security " <> balanceSecurityId <> " is claimed by multiple terminal transactions: " <> show labels) + (length labels == 1)) + (Map.toList claims) + splitAdjustedQuantity : CapTableMaps -> Text -> Time -> Time -> Decimal -> Update Decimal splitAdjustedQuantity maps stockClassId issuanceDate eventDate initialQuantity = foldlA @@ -532,16 +512,20 @@ splitAdjustedQuantity maps stockClassId issuanceDate eventDate initialQuantity = split <- fetch splitCid let splitDate = ocfDate split.split_data.date pure (if split.split_data.stock_class_id == stockClassId && - splitDate >= ocfDate issuanceDate && splitDate <= ocfDate eventDate + -- OCF dates have no intra-day order. Treat an issuance dated on the + -- split date as already expressed in post-split units. + splitDate > ocfDate issuanceDate && splitDate <= ocfDate eventDate then quantity * split.split_data.split_ratio.numerator / split.split_data.split_ratio.denominator else quantity)) initialQuantity (Map.values maps.stock_class_splits) -validateStockBalanceSuccessor : CapTableMaps -> StockIssuanceOcfData -> TerminalEvent -> Decimal -> Text -> Update () -validateStockBalanceSuccessor maps source event remainder balanceSecurityId = do +validateStockBalanceSuccessor : CapTableMaps -> TerminalEventIndex -> StockIssuanceOcfData -> TerminalEvent -> Decimal -> Text -> Update () +validateStockBalanceSuccessor maps index source event remainder balanceSecurityId = do assertMsg (event.label <> " balance security must differ from its retired source") (balanceSecurityId /= source.security_id) + assertMsg (event.label <> " balance security was already inactive: " <> balanceSecurityId) + (not (priorTerminalExists index balanceSecurityId event.date)) case Map.lookup balanceSecurityId maps.stock_issuances_by_security_id of Some cid -> do balance <- fetch cid @@ -549,8 +533,9 @@ validateStockBalanceSuccessor maps source event remainder balanceSecurityId = do (balance.issuance_data.stakeholder_id == source.stakeholder_id) assertMsg (event.label <> " balance stock class mismatch") (balance.issuance_data.stock_class_id == source.stock_class_id) - assertMsg (event.label <> " balance quantity mismatch: expected " <> show remainder <> ", found " <> show balance.issuance_data.quantity) - (balance.issuance_data.quantity == remainder) + balanceQuantity <- splitAdjustedQuantity maps balance.issuance_data.stock_class_id balance.issuance_data.date event.date balance.issuance_data.quantity + assertMsg (event.label <> " balance quantity mismatch: expected " <> show remainder <> ", found " <> show balanceQuantity) + (balanceQuantity == remainder) None -> assertMsg (event.label <> " balance stock issuance not found: " <> balanceSecurityId) False validateStockTerminalCompleteness : CapTableMaps -> TerminalEventIndex -> Update () @@ -568,7 +553,7 @@ validateStockTerminalCompleteness maps index = case event.balance_security_id of Some balanceSecurityId -> do assertMsg (event.label <> " balance security requires a positive remainder") (remainder > 0.0) - validateStockBalanceSuccessor maps issuance.issuance_data event remainder balanceSecurityId + validateStockBalanceSuccessor maps index issuance.issuance_data event remainder balanceSecurityId None -> assertMsg (event.label <> " without balance_security_id must consume the full current source quantity") (remainder == 0.0) @@ -576,19 +561,38 @@ validateStockTerminalCompleteness maps index = _ -> pure ()) (Map.values maps.stock_issuances) -validateWarrantBalanceSuccessor : CapTableMaps -> WarrantIssuanceOcfData -> TerminalEvent -> Optional Decimal -> Text -> Update () -validateWarrantBalanceSuccessor maps source event remainder balanceSecurityId = do +validateWarrantBalanceSuccessor : CapTableMaps -> TerminalEventIndex -> WarrantIssuanceOcfData -> TerminalEvent -> Optional Decimal -> Text -> Update () +validateWarrantBalanceSuccessor maps index source event remainder balanceSecurityId = do assertMsg (event.label <> " balance security must differ from its retired source") (balanceSecurityId /= source.security_id) + assertMsg (event.label <> " balance security was already inactive: " <> balanceSecurityId) + (not (priorTerminalExists index balanceSecurityId event.date)) case Map.lookup balanceSecurityId maps.warrant_issuances_by_security_id of Some cid -> do balance <- fetch cid assertMsg (event.label <> " balance stakeholder mismatch") (balance.issuance_data.stakeholder_id == source.stakeholder_id) - assertMsg (event.label <> " balance quantity mismatch") - (balance.issuance_data.quantity == remainder) + case remainder of + Some expectedQuantity -> + assertMsg (event.label <> " balance quantity mismatch") + (balance.issuance_data.quantity == Some expectedQuantity) + None -> pure () None -> assertMsg (event.label <> " balance warrant issuance not found: " <> balanceSecurityId) False +priorWarrantExerciseQuantity : CapTableMaps -> Text -> Time -> Update (Optional Decimal) +priorWarrantExerciseQuantity maps securityId targetDate = + foldlA + (\total exerciseCid -> do + exercise <- fetch exerciseCid + if exercise.exercise_data.security_id /= securityId || + not (isEarlierOcfDate exercise.exercise_data.date targetDate) + then pure total + else case (total, exercise.exercise_data.quantity) of + (Some current, Some quantity) -> pure (Some (current + quantity)) + (_, _) -> pure None) + (Some 0.0) + (Map.values maps.warrant_exercises) + validateWarrantTerminalCompleteness : CapTableMaps -> TerminalEventIndex -> Update () validateWarrantTerminalCompleteness maps index = mapA_ @@ -596,7 +600,11 @@ validateWarrantTerminalCompleteness maps index = issuance <- fetch cid case Map.lookup issuance.issuance_data.security_id index of Some [event] -> when event.partial_requires_balance do - case issuance.issuance_data.quantity of + exercisedQuantity <- priorWarrantExerciseQuantity maps issuance.issuance_data.security_id event.date + let currentQuantityOpt = case (issuance.issuance_data.quantity, exercisedQuantity) of + (Some issuedQuantity, Some exercised) -> Some (issuedQuantity - exercised) + (_, _) -> None + case currentQuantityOpt of Some currentQuantity -> case event.reduction of Some reduction -> do let remainder = currentQuantity - reduction @@ -604,26 +612,56 @@ validateWarrantTerminalCompleteness maps index = case event.balance_security_id of Some balanceSecurityId -> do assertMsg (event.label <> " balance security requires a positive remainder") (remainder > 0.0) - validateWarrantBalanceSuccessor maps issuance.issuance_data event (Some remainder) balanceSecurityId + validateWarrantBalanceSuccessor maps index issuance.issuance_data event (Some remainder) balanceSecurityId None -> assertMsg (event.label <> " without balance_security_id must consume the full current source quantity") (remainder == 0.0) None -> assertMsg (event.label <> " is missing its reduction quantity") False None -> case event.balance_security_id of - Some balanceSecurityId -> validateWarrantBalanceSuccessor maps issuance.issuance_data event None balanceSecurityId + Some balanceSecurityId -> validateWarrantBalanceSuccessor maps index issuance.issuance_data event None balanceSecurityId None -> pure () _ -> pure ()) (Map.values maps.warrant_issuances) +equityCompensationReductionAt : CapTableMaps -> Optional Text -> Time -> Time -> Decimal -> Update Decimal +equityCompensationReductionAt maps stockClassId reductionDate eventDate quantity = case stockClassId of + Some classId -> splitAdjustedQuantity maps classId reductionDate eventDate quantity + None -> pure quantity + equityCompensationQuantityAt : CapTableMaps -> EquityCompensationIssuanceOcfData -> Time -> Update Decimal -equityCompensationQuantityAt maps source eventDate = case source.stock_class_id of - Some stockClassId -> splitAdjustedQuantity maps stockClassId source.date eventDate source.quantity - None -> pure source.quantity +equityCompensationQuantityAt maps source eventDate = do + issuedQuantity <- case source.stock_class_id of + Some stockClassId -> splitAdjustedQuantity maps stockClassId source.date eventDate source.quantity + None -> pure source.quantity + afterExercises <- foldlA + (\current exerciseCid -> do + exercise <- fetch exerciseCid + if exercise.exercise_data.security_id == source.security_id && + isEarlierOcfDate exercise.exercise_data.date eventDate + then do + reduction <- equityCompensationReductionAt maps source.stock_class_id exercise.exercise_data.date eventDate exercise.exercise_data.quantity + pure (current - reduction) + else pure current) + issuedQuantity + (Map.values maps.equity_compensation_exercises) + foldlA + (\current releaseCid -> do + release <- fetch releaseCid + if release.release_data.security_id == source.security_id && + isEarlierOcfDate release.release_data.date eventDate + then do + reduction <- equityCompensationReductionAt maps source.stock_class_id release.release_data.date eventDate release.release_data.quantity + pure (current - reduction) + else pure current) + afterExercises + (Map.values maps.equity_compensation_releases) -validateEquityCompensationBalanceSuccessor : CapTableMaps -> EquityCompensationIssuanceOcfData -> TerminalEvent -> Decimal -> Text -> Update () -validateEquityCompensationBalanceSuccessor maps source event remainder balanceSecurityId = do +validateEquityCompensationBalanceSuccessor : CapTableMaps -> TerminalEventIndex -> EquityCompensationIssuanceOcfData -> TerminalEvent -> Decimal -> Text -> Update () +validateEquityCompensationBalanceSuccessor maps index source event remainder balanceSecurityId = do assertMsg (event.label <> " balance security must differ from its retired source") (balanceSecurityId /= source.security_id) + assertMsg (event.label <> " balance security was already inactive: " <> balanceSecurityId) + (not (priorTerminalExists index balanceSecurityId event.date)) case Map.lookup balanceSecurityId maps.equity_compensation_issuances_by_security_id of Some cid -> do balance <- fetch cid @@ -631,8 +669,9 @@ validateEquityCompensationBalanceSuccessor maps source event remainder balanceSe (balance.issuance_data.stakeholder_id == source.stakeholder_id) assertMsg (event.label <> " balance stock class mismatch") (balance.issuance_data.stock_class_id == source.stock_class_id) - assertMsg (event.label <> " balance quantity mismatch: expected " <> show remainder <> ", found " <> show balance.issuance_data.quantity) - (balance.issuance_data.quantity == remainder) + balanceQuantity <- equityCompensationQuantityAt maps balance.issuance_data event.date + assertMsg (event.label <> " balance quantity mismatch: expected " <> show remainder <> ", found " <> show balanceQuantity) + (balanceQuantity == remainder) None -> assertMsg (event.label <> " balance equity compensation issuance not found: " <> balanceSecurityId) False validateEquityCompensationTerminalCompleteness : CapTableMaps -> TerminalEventIndex -> Update () @@ -650,7 +689,7 @@ validateEquityCompensationTerminalCompleteness maps index = case event.balance_security_id of Some balanceSecurityId -> do assertMsg (event.label <> " balance security requires a positive remainder") (remainder > 0.0) - validateEquityCompensationBalanceSuccessor maps issuance.issuance_data event remainder balanceSecurityId + validateEquityCompensationBalanceSuccessor maps index issuance.issuance_data event remainder balanceSecurityId None -> assertMsg (event.label <> " without balance_security_id must consume the full current source quantity") (remainder == 0.0) @@ -658,10 +697,12 @@ validateEquityCompensationTerminalCompleteness maps index = _ -> pure ()) (Map.values maps.equity_compensation_issuances) -validateConvertibleBalanceSuccessor : CapTableMaps -> ConvertibleIssuanceOcfData -> TerminalEvent -> Decimal -> Text -> Update () -validateConvertibleBalanceSuccessor maps source event remainder balanceSecurityId = do +validateConvertibleBalanceSuccessor : CapTableMaps -> TerminalEventIndex -> ConvertibleIssuanceOcfData -> TerminalEvent -> Decimal -> Text -> Update () +validateConvertibleBalanceSuccessor maps index source event remainder balanceSecurityId = do assertMsg (event.label <> " balance security must differ from its retired source") (balanceSecurityId /= source.security_id) + assertMsg (event.label <> " balance security was already inactive: " <> balanceSecurityId) + (not (priorTerminalExists index balanceSecurityId event.date)) case Map.lookup balanceSecurityId maps.convertible_issuances_by_security_id of Some cid -> do balance <- fetch cid @@ -673,27 +714,46 @@ validateConvertibleBalanceSuccessor maps source event remainder balanceSecurityI (balance.issuance_data.investment_amount.amount == remainder) None -> assertMsg (event.label <> " balance convertible issuance not found: " <> balanceSecurityId) False +validateConvertibleBalanceReference : CapTableMaps -> TerminalEventIndex -> ConvertibleIssuanceOcfData -> TerminalEvent -> Text -> Update () +validateConvertibleBalanceReference maps index source event balanceSecurityId = do + assertMsg (event.label <> " balance security must differ from its retired source") + (balanceSecurityId /= source.security_id) + assertMsg (event.label <> " balance security was already inactive: " <> balanceSecurityId) + (not (priorTerminalExists index balanceSecurityId event.date)) + case Map.lookup balanceSecurityId maps.convertible_issuances_by_security_id of + Some cid -> do + balance <- fetch cid + assertMsg (event.label <> " balance stakeholder mismatch") + (balance.issuance_data.stakeholder_id == source.stakeholder_id) + assertMsg (event.label <> " balance currency mismatch") + (balance.issuance_data.investment_amount.currency == source.investment_amount.currency) + None -> assertMsg (event.label <> " balance convertible issuance not found: " <> balanceSecurityId) False + validateConvertibleTerminalCompleteness : CapTableMaps -> TerminalEventIndex -> Update () validateConvertibleTerminalCompleteness maps index = mapA_ (\cid -> do issuance <- fetch cid case Map.lookup issuance.issuance_data.security_id index of - Some [event] -> when event.partial_requires_balance do - case event.reduction of - Some reduction -> do - assertMsg (event.label <> " currency mismatch") - (event.reduction_currency == Some issuance.issuance_data.investment_amount.currency) - let remainder = issuance.issuance_data.investment_amount.amount - reduction - assertMsg (event.label <> " exceeds current source principal") (remainder >= 0.0) - case event.balance_security_id of - Some balanceSecurityId -> do - assertMsg (event.label <> " balance security requires a positive remainder") (remainder > 0.0) - validateConvertibleBalanceSuccessor maps issuance.issuance_data event remainder balanceSecurityId - None -> assertMsg - (event.label <> " without balance_security_id must consume the full current source principal") - (remainder == 0.0) - None -> assertMsg (event.label <> " is missing its reduction amount") False + Some [event] -> + if event.partial_requires_balance + then case event.reduction of + Some reduction -> do + assertMsg (event.label <> " currency mismatch") + (event.reduction_currency == Some issuance.issuance_data.investment_amount.currency) + let remainder = issuance.issuance_data.investment_amount.amount - reduction + assertMsg (event.label <> " exceeds current source principal") (remainder >= 0.0) + case event.balance_security_id of + Some balanceSecurityId -> do + assertMsg (event.label <> " balance security requires a positive remainder") (remainder > 0.0) + validateConvertibleBalanceSuccessor maps index issuance.issuance_data event remainder balanceSecurityId + None -> assertMsg + (event.label <> " without balance_security_id must consume the full current source principal") + (remainder == 0.0) + None -> assertMsg (event.label <> " is missing its reduction amount") False + else case event.balance_security_id of + Some balanceSecurityId -> validateConvertibleBalanceReference maps index issuance.issuance_data event balanceSecurityId + None -> pure () _ -> pure ()) (Map.values maps.convertible_issuances) @@ -703,6 +763,10 @@ validateTerminalTransactionCompleteness maps indexes = do validateTerminalEventReferences "Convertible" maps.convertible_issuances_by_security_id indexes.convertible validateTerminalEventReferences "Warrant" maps.warrant_issuances_by_security_id indexes.warrant validateTerminalEventReferences "Equity compensation" maps.equity_compensation_issuances_by_security_id indexes.equity_compensation + validateUniqueBalanceSecurityIds "Stock" indexes.stock + validateUniqueBalanceSecurityIds "Convertible" indexes.convertible + validateUniqueBalanceSecurityIds "Warrant" indexes.warrant + validateUniqueBalanceSecurityIds "Equity compensation" indexes.equity_compensation validateStockTerminalCompleteness maps indexes.stock validateConvertibleTerminalCompleteness maps indexes.convertible validateWarrantTerminalCompleteness maps indexes.warrant @@ -718,24 +782,73 @@ assertActiveConvertibleTarget indexes label targetDate securityId = assertMsg (label <> " targets inactive convertible security " <> securityId) (not (priorTerminalExists indexes.convertible securityId targetDate)) -assertActiveWarrantTarget : SecurityTerminalIndexes -> Text -> Time -> Text -> Update () -assertActiveWarrantTarget indexes label targetDate securityId = +priorWarrantExerciseRetirementExists : CapTableMaps -> Text -> Time -> Update Bool +priorWarrantExerciseRetirementExists maps securityId targetDate = + case Map.lookup securityId maps.warrant_issuances_by_security_id of + Some issuanceCid -> do + issuance <- fetch issuanceCid + (knownExerciseTotal, unknownExerciseRetirement) <- foldlA + (\(total, retired) exerciseCid -> do + exercise <- fetch exerciseCid + if exercise.exercise_data.security_id /= securityId || + not (isEarlierOcfDate exercise.exercise_data.date targetDate) + then pure (total, retired) + else case exercise.exercise_data.quantity of + Some quantity -> pure (total + quantity, retired) + None -> pure (total, True)) + (0.0, False) + (Map.values maps.warrant_exercises) + pure (unknownExerciseRetirement || case issuance.issuance_data.quantity of + Some quantity -> knownExerciseTotal >= quantity + None -> False) + None -> pure False + +assertActiveWarrantTarget : CapTableMaps -> SecurityTerminalIndexes -> Text -> Time -> Text -> Update () +assertActiveWarrantTarget maps indexes label targetDate securityId = do + exerciseRetired <- priorWarrantExerciseRetirementExists maps securityId targetDate assertMsg (label <> " targets inactive warrant security " <> securityId) - (not (priorTerminalExists indexes.warrant securityId targetDate)) - -assertActiveEquityCompensationTarget : SecurityTerminalIndexes -> Text -> Time -> Text -> Update () -assertActiveEquityCompensationTarget indexes label targetDate securityId = + (not (priorTerminalExists indexes.warrant securityId targetDate) && not exerciseRetired) + +priorEquityCompensationSettlementRetirementExists : CapTableMaps -> Text -> Time -> Update Bool +priorEquityCompensationSettlementRetirementExists maps securityId targetDate = + case Map.lookup securityId maps.equity_compensation_issuances_by_security_id of + Some issuanceCid -> do + issuance <- fetch issuanceCid + exercised <- foldlA + (\total exerciseCid -> do + exercise <- fetch exerciseCid + pure (if exercise.exercise_data.security_id == securityId && + isEarlierOcfDate exercise.exercise_data.date targetDate + then total + exercise.exercise_data.quantity + else total)) + 0.0 + (Map.values maps.equity_compensation_exercises) + released <- foldlA + (\total releaseCid -> do + release <- fetch releaseCid + pure (if release.release_data.security_id == securityId && + isEarlierOcfDate release.release_data.date targetDate + then total + release.release_data.quantity + else total)) + 0.0 + (Map.values maps.equity_compensation_releases) + pure (exercised + released >= issuance.issuance_data.quantity) + None -> pure False + +assertActiveEquityCompensationTarget : CapTableMaps -> SecurityTerminalIndexes -> Text -> Time -> Text -> Update () +assertActiveEquityCompensationTarget maps indexes label targetDate securityId = do + settlementRetired <- priorEquityCompensationSettlementRetirementExists maps securityId targetDate assertMsg (label <> " targets inactive equity compensation security " <> securityId) - (not (priorTerminalExists indexes.equity_compensation securityId targetDate)) + (not (priorTerminalExists indexes.equity_compensation securityId targetDate) && not settlementRetired) assertActiveVestingTarget : CapTableMaps -> SecurityTerminalIndexes -> Text -> Time -> Text -> Update () assertActiveVestingTarget maps indexes label targetDate securityId = case Map.lookup securityId maps.stock_issuances_by_security_id of Some _ -> assertActiveStockTarget indexes label targetDate securityId None -> case Map.lookup securityId maps.warrant_issuances_by_security_id of - Some _ -> assertActiveWarrantTarget indexes label targetDate securityId + Some _ -> assertActiveWarrantTarget maps indexes label targetDate securityId None -> case Map.lookup securityId maps.equity_compensation_issuances_by_security_id of - Some _ -> assertActiveEquityCompensationTarget indexes label targetDate securityId + Some _ -> assertActiveEquityCompensationTarget maps indexes label targetDate securityId None -> pure () validateActiveSecurityTargets : CapTableMaps -> SecurityTerminalIndexes -> Update () @@ -755,19 +868,19 @@ validateActiveSecurityTargets maps indexes = do mapA_ (\acceptanceCid -> do acceptance <- fetch acceptanceCid - assertActiveWarrantTarget indexes ("Warrant acceptance " <> acceptance.acceptance_data.id) + assertActiveWarrantTarget maps indexes ("Warrant acceptance " <> acceptance.acceptance_data.id) acceptance.acceptance_data.date acceptance.acceptance_data.security_id) (Map.values maps.warrant_acceptances) mapA_ (\acceptanceCid -> do acceptance <- fetch acceptanceCid - assertActiveEquityCompensationTarget indexes ("Equity compensation acceptance " <> acceptance.acceptance_data.id) + assertActiveEquityCompensationTarget maps indexes ("Equity compensation acceptance " <> acceptance.acceptance_data.id) acceptance.acceptance_data.date acceptance.acceptance_data.security_id) (Map.values maps.equity_compensation_acceptances) mapA_ (\repricingCid -> do repricing <- fetch repricingCid - assertActiveEquityCompensationTarget indexes ("Equity compensation repricing " <> repricing.repricing_data.id) + assertActiveEquityCompensationTarget maps indexes ("Equity compensation repricing " <> repricing.repricing_data.id) repricing.repricing_data.date repricing.repricing_data.security_id) (Map.values maps.equity_compensation_repricings) mapA_ From 10cec0210c7fb0cada3f3d02b6678b6ab4a2c2fe Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Wed, 22 Jul 2026 10:03:26 -0400 Subject: [PATCH 3/5] Use split-adjusted equity compensation balances --- .../TestEquityCompensationAcceptance.daml | 44 ++++++++++++++++++- .../0.0.3/OpenCapTable-v34.dar | 4 +- dars/dars.lock | 6 +-- .../codegen/templates/CapTable.daml.template | 21 +-------- 4 files changed, 50 insertions(+), 25 deletions(-) diff --git a/Test/daml/OpenCapTable/TestEquityCompensationAcceptance.daml b/Test/daml/OpenCapTable/TestEquityCompensationAcceptance.daml index 93196948..4282b9c7 100644 --- a/Test/daml/OpenCapTable/TestEquityCompensationAcceptance.daml +++ b/Test/daml/OpenCapTable/TestEquityCompensationAcceptance.daml @@ -2,11 +2,14 @@ module OpenCapTable.TestEquityCompensationAcceptance where import Fairmint.OpenCapTable.OCF.EquityCompensationIssuance import Fairmint.OpenCapTable.OCF.EquityCompensationAcceptance +import Fairmint.OpenCapTable.OCF.EquityCompensationExercise +import Fairmint.OpenCapTable.OCF.StockClassSplit import qualified Fairmint.OpenCapTable.CapTable as CT import Fairmint.OpenCapTable.Types.Monetary (OcfMonetary(..)) +import Fairmint.OpenCapTable.Types.Stock (OcfRatio(..)) import Fairmint.OpenCapTable.Types.Vesting (OcfCompensationType(..)) import OpenCapTable.Setup -import OpenCapTable.TestHelpers (addDefaultStakeholder) +import OpenCapTable.TestHelpers (addDefaultStakeholder, addPrerequisites) import qualified DA.Date as DA import DA.Date (Month(..)) import qualified DA.Time as DT @@ -80,3 +83,42 @@ testEquityCompensationAcceptance_WithComments = script do edits = [] deletes = [] pure () + +-- A split adjusts both the grant and earlier settlement quantities. Exercising +-- the grant's pre-split quantity therefore leaves an active post-split balance. +testEquityCompensationAcceptance_AfterPartialExerciseAndStockSplit = script do + TestOcp{system_operator, issuer, ctx, cap_table} <- setupTestOcp + capTableCid <- addPrerequisites issuer cap_table + issuanceResult <- createEquityCompensationIssuance issuer capTableCid + splitResult <- submit issuer do + exerciseCmd issuanceResult.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateStockClassSplit StockClassSplitOcfData with + id = "TX_EC_SPLIT_1" + date = DT.time (DA.date 2024 Feb 02) 0 0 0 + stock_class_id = "SC_COMMON" + split_ratio = OcfRatio with numerator = 2.0, denominator = 1.0 + comments = []] + edits = [] + deletes = [] + exerciseResult <- submit issuer do + exerciseCmd splitResult.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateEquityCompensationExercise EquityCompensationExerciseOcfData with + id = "TX_EC_EXERCISE_AFTER_SPLIT_1" + date = DT.time (DA.date 2024 Feb 03) 0 0 0 + security_id = "SEC-OPTION-1" + quantity = 10000.0 + consideration_text = None + resulting_security_ids = ["SEC-STOCK-RESULT-1"] + comments = []] + edits = [] + deletes = [] + _ <- submit issuer do + exerciseCmd exerciseResult.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateEquityCompensationAcceptance EquityCompensationAcceptanceOcfData with + id = "TX_EC_ACCEPT_AFTER_SPLIT_1" + date = DT.time (DA.date 2024 Feb 04) 0 0 0 + security_id = "SEC-OPTION-1" + comments = []] + edits = [] + deletes = [] + pure () diff --git a/dars/OpenCapTable-v34/0.0.3/OpenCapTable-v34.dar b/dars/OpenCapTable-v34/0.0.3/OpenCapTable-v34.dar index fd4d95ba..dbf29fec 100644 --- a/dars/OpenCapTable-v34/0.0.3/OpenCapTable-v34.dar +++ b/dars/OpenCapTable-v34/0.0.3/OpenCapTable-v34.dar @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:076b74eff580bdb5b99baea11412db7fee2f520afab38d3098fdbb953908a74d -size 2572700 +oid sha256:c7b7d4a474a99d66ac2b65d19b28812a8c2019a44d4064ddc7b355c6a009a647 +size 2571050 diff --git a/dars/dars.lock b/dars/dars.lock index 13099a24..dee6b573 100644 --- a/dars/dars.lock +++ b/dars/dars.lock @@ -19,10 +19,10 @@ "networks": [] }, "OpenCapTable-v34/0.0.3/OpenCapTable-v34.dar": { - "sha256": "076b74eff580bdb5b99baea11412db7fee2f520afab38d3098fdbb953908a74d", - "size": 2572700, + "sha256": "c7b7d4a474a99d66ac2b65d19b28812a8c2019a44d4064ddc7b355c6a009a647", + "size": 2571050, "sdkVersion": "3.4.10", - "uploadedAt": "2026-07-22T13:53:58.607Z", + "uploadedAt": "2026-07-22T14:02:32.454Z", "networks": [] } } diff --git a/scripts/codegen/templates/CapTable.daml.template b/scripts/codegen/templates/CapTable.daml.template index 0eb6782b..699afacc 100644 --- a/scripts/codegen/templates/CapTable.daml.template +++ b/scripts/codegen/templates/CapTable.daml.template @@ -814,25 +814,8 @@ priorEquityCompensationSettlementRetirementExists maps securityId targetDate = case Map.lookup securityId maps.equity_compensation_issuances_by_security_id of Some issuanceCid -> do issuance <- fetch issuanceCid - exercised <- foldlA - (\total exerciseCid -> do - exercise <- fetch exerciseCid - pure (if exercise.exercise_data.security_id == securityId && - isEarlierOcfDate exercise.exercise_data.date targetDate - then total + exercise.exercise_data.quantity - else total)) - 0.0 - (Map.values maps.equity_compensation_exercises) - released <- foldlA - (\total releaseCid -> do - release <- fetch releaseCid - pure (if release.release_data.security_id == securityId && - isEarlierOcfDate release.release_data.date targetDate - then total + release.release_data.quantity - else total)) - 0.0 - (Map.values maps.equity_compensation_releases) - pure (exercised + released >= issuance.issuance_data.quantity) + liveQuantity <- equityCompensationQuantityAt maps issuance.issuance_data targetDate + pure (liveQuantity <= 0.0) None -> pure False assertActiveEquityCompensationTarget : CapTableMaps -> SecurityTerminalIndexes -> Text -> Time -> Text -> Update () From 10657ab40a25c60da278dd7ce1c4b664541997c6 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Wed, 22 Jul 2026 10:23:05 -0400 Subject: [PATCH 4/5] Track exercised warrant balances --- .../OpenCapTable/TestWarrantCancellation.daml | 62 +++++++++++++++++++ .../0.0.3/OpenCapTable-v34.dar | 4 +- dars/dars.lock | 6 +- .../codegen/templates/CapTable.daml.template | 48 +++++++------- 4 files changed, 88 insertions(+), 32 deletions(-) diff --git a/Test/daml/OpenCapTable/TestWarrantCancellation.daml b/Test/daml/OpenCapTable/TestWarrantCancellation.daml index 09e7e68c..11895617 100644 --- a/Test/daml/OpenCapTable/TestWarrantCancellation.daml +++ b/Test/daml/OpenCapTable/TestWarrantCancellation.daml @@ -111,6 +111,68 @@ testWarrantCancellation_AfterPartialExerciseUsesLiveBalance = script do _ <- createWarrantCancellation issuer exerciseResult.updatedCapTableCid securityId 60.0 pure () +testWarrantCancellation_AfterFullExerciseFails = script do + TestOcp{issuer, cap_table} <- setupTestOcp + capTableCid <- addDefaultStakeholder issuer cap_table + let securityId = "WSEC-FULL-EXERCISE-THEN-CANCEL" + result <- createWarrantIssuance issuer capTableCid securityId 100.0 + exerciseResult <- submit issuer do + exerciseCmd result.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateWarrantExercise WarrantExerciseOcfData with + id = "TX_WARRANT_FULL_EXERCISE_BEFORE_CANCEL" + date = DT.time (DA.date 2024 Feb 15) 0 0 0 + security_id = securityId + trigger_id = "ELECTIVE_AT_WILL" + quantity = None + resulting_security_ids = ["STOCK-FROM-FULL-WARRANT-EXERCISE"] + comments = [] + consideration_text = None] + edits = [] + deletes = [] + submitMustFail issuer do + exerciseCmd exerciseResult.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateWarrantCancellation WarrantCancellationOcfData with + id = "TX_WARRANT_CANCEL_AFTER_FULL_EXERCISE" + date = DT.time (DA.date 2024 Mar 15) 0 0 0 + security_id = securityId + quantity = 100.0 + reason_text = "Invalid cancellation after full exercise" + balance_security_id = None + comments = []] + edits = [] + deletes = [] + +testWarrantCancellation_FullyExercisedBalanceSecurityFails = script do + TestOcp{issuer, cap_table} <- setupTestOcp + capTableCid <- addDefaultStakeholder issuer cap_table + sourceResult <- createWarrantIssuance issuer capTableCid "WSEC-SOURCE" 100.0 + balanceResult <- createWarrantIssuance issuer sourceResult.updatedCapTableCid "WSEC-EXERCISED-BALANCE" 60.0 + exerciseResult <- submit issuer do + exerciseCmd balanceResult.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateWarrantExercise WarrantExerciseOcfData with + id = "TX_WARRANT_FULL_EXERCISE_BALANCE" + date = DT.time (DA.date 2024 Feb 15) 0 0 0 + security_id = "WSEC-EXERCISED-BALANCE" + trigger_id = "ELECTIVE_AT_WILL" + quantity = None + resulting_security_ids = ["STOCK-FROM-BALANCE-WARRANT"] + comments = [] + consideration_text = None] + edits = [] + deletes = [] + submitMustFail issuer do + exerciseCmd exerciseResult.updatedCapTableCid CT.UpdateCapTable with + creates = [CT.OcfCreateWarrantCancellation WarrantCancellationOcfData with + id = "TX_WARRANT_CANCEL_TO_EXERCISED_BALANCE" + date = DT.time (DA.date 2024 Mar 15) 0 0 0 + security_id = "WSEC-SOURCE" + quantity = 40.0 + reason_text = "Invalid reuse of an exercised balance warrant" + balance_security_id = Some "WSEC-EXERCISED-BALANCE" + comments = []] + edits = [] + deletes = [] + -- Partial cancellation with optional fields populated testWarrantCancellation_PartialWithOptionals = script do TestOcp{system_operator, issuer, ctx, cap_table} <- setupTestOcp diff --git a/dars/OpenCapTable-v34/0.0.3/OpenCapTable-v34.dar b/dars/OpenCapTable-v34/0.0.3/OpenCapTable-v34.dar index dbf29fec..76d787f3 100644 --- a/dars/OpenCapTable-v34/0.0.3/OpenCapTable-v34.dar +++ b/dars/OpenCapTable-v34/0.0.3/OpenCapTable-v34.dar @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c7b7d4a474a99d66ac2b65d19b28812a8c2019a44d4064ddc7b355c6a009a647 -size 2571050 +oid sha256:c44e5eaa7a88e8b6ec3ce91d422b8e3351f14fe7ea8f6dbbe2f42ddba1ed8210 +size 2569240 diff --git a/dars/dars.lock b/dars/dars.lock index dee6b573..ece1d089 100644 --- a/dars/dars.lock +++ b/dars/dars.lock @@ -19,10 +19,10 @@ "networks": [] }, "OpenCapTable-v34/0.0.3/OpenCapTable-v34.dar": { - "sha256": "c7b7d4a474a99d66ac2b65d19b28812a8c2019a44d4064ddc7b355c6a009a647", - "size": 2571050, + "sha256": "c44e5eaa7a88e8b6ec3ce91d422b8e3351f14fe7ea8f6dbbe2f42ddba1ed8210", + "size": 2569240, "sdkVersion": "3.4.10", - "uploadedAt": "2026-07-22T14:02:32.454Z", + "uploadedAt": "2026-07-22T14:22:44.263Z", "networks": [] } } diff --git a/scripts/codegen/templates/CapTable.daml.template b/scripts/codegen/templates/CapTable.daml.template index 699afacc..1ca945c4 100644 --- a/scripts/codegen/templates/CapTable.daml.template +++ b/scripts/codegen/templates/CapTable.daml.template @@ -572,25 +572,32 @@ validateWarrantBalanceSuccessor maps index source event remainder balanceSecurit balance <- fetch cid assertMsg (event.label <> " balance stakeholder mismatch") (balance.issuance_data.stakeholder_id == source.stakeholder_id) + balanceQuantity <- warrantQuantityAt maps balance.issuance_data event.date + assertMsg (event.label <> " balance security was already fully exercised: " <> balanceSecurityId) + (case balanceQuantity of + Some quantity -> quantity > 0.0 + None -> True) case remainder of Some expectedQuantity -> assertMsg (event.label <> " balance quantity mismatch") - (balance.issuance_data.quantity == Some expectedQuantity) + (balanceQuantity == Some expectedQuantity) None -> pure () None -> assertMsg (event.label <> " balance warrant issuance not found: " <> balanceSecurityId) False -priorWarrantExerciseQuantity : CapTableMaps -> Text -> Time -> Update (Optional Decimal) -priorWarrantExerciseQuantity maps securityId targetDate = +warrantQuantityAt : CapTableMaps -> WarrantIssuanceOcfData -> Time -> Update (Optional Decimal) +warrantQuantityAt maps source targetDate = foldlA - (\total exerciseCid -> do + (\current exerciseCid -> do exercise <- fetch exerciseCid - if exercise.exercise_data.security_id /= securityId || + if exercise.exercise_data.security_id /= source.security_id || not (isEarlierOcfDate exercise.exercise_data.date targetDate) - then pure total - else case (total, exercise.exercise_data.quantity) of - (Some current, Some quantity) -> pure (Some (current + quantity)) - (_, _) -> pure None) - (Some 0.0) + then pure current + else case exercise.exercise_data.quantity of + -- OCF only supplies quantity for partial exercises. An omitted + -- quantity therefore consumes the entire remaining warrant. + None -> pure (Some 0.0) + Some quantity -> pure (fmap (\remaining -> remaining - quantity) current)) + source.quantity (Map.values maps.warrant_exercises) validateWarrantTerminalCompleteness : CapTableMaps -> TerminalEventIndex -> Update () @@ -600,10 +607,7 @@ validateWarrantTerminalCompleteness maps index = issuance <- fetch cid case Map.lookup issuance.issuance_data.security_id index of Some [event] -> when event.partial_requires_balance do - exercisedQuantity <- priorWarrantExerciseQuantity maps issuance.issuance_data.security_id event.date - let currentQuantityOpt = case (issuance.issuance_data.quantity, exercisedQuantity) of - (Some issuedQuantity, Some exercised) -> Some (issuedQuantity - exercised) - (_, _) -> None + currentQuantityOpt <- warrantQuantityAt maps issuance.issuance_data event.date case currentQuantityOpt of Some currentQuantity -> case event.reduction of Some reduction -> do @@ -787,19 +791,9 @@ priorWarrantExerciseRetirementExists maps securityId targetDate = case Map.lookup securityId maps.warrant_issuances_by_security_id of Some issuanceCid -> do issuance <- fetch issuanceCid - (knownExerciseTotal, unknownExerciseRetirement) <- foldlA - (\(total, retired) exerciseCid -> do - exercise <- fetch exerciseCid - if exercise.exercise_data.security_id /= securityId || - not (isEarlierOcfDate exercise.exercise_data.date targetDate) - then pure (total, retired) - else case exercise.exercise_data.quantity of - Some quantity -> pure (total + quantity, retired) - None -> pure (total, True)) - (0.0, False) - (Map.values maps.warrant_exercises) - pure (unknownExerciseRetirement || case issuance.issuance_data.quantity of - Some quantity -> knownExerciseTotal >= quantity + liveQuantity <- warrantQuantityAt maps issuance.issuance_data targetDate + pure (case liveQuantity of + Some quantity -> quantity <= 0.0 None -> False) None -> pure False From 5a24256b06d03ff9f4559d54543eb7254c9bc794 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Wed, 22 Jul 2026 10:28:31 -0400 Subject: [PATCH 5/5] Make full warrant exercise order independent --- dars/OpenCapTable-v34/0.0.3/OpenCapTable-v34.dar | 4 ++-- dars/dars.lock | 6 +++--- scripts/codegen/templates/CapTable.daml.template | 15 ++++++++------- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/dars/OpenCapTable-v34/0.0.3/OpenCapTable-v34.dar b/dars/OpenCapTable-v34/0.0.3/OpenCapTable-v34.dar index 76d787f3..c6c07942 100644 --- a/dars/OpenCapTable-v34/0.0.3/OpenCapTable-v34.dar +++ b/dars/OpenCapTable-v34/0.0.3/OpenCapTable-v34.dar @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c44e5eaa7a88e8b6ec3ce91d422b8e3351f14fe7ea8f6dbbe2f42ddba1ed8210 -size 2569240 +oid sha256:de46f26a1225fdcf798b1f17fa71eca57b2105b0df22bb70344233c8fb2dda6d +size 2570059 diff --git a/dars/dars.lock b/dars/dars.lock index ece1d089..2ba4f244 100644 --- a/dars/dars.lock +++ b/dars/dars.lock @@ -19,10 +19,10 @@ "networks": [] }, "OpenCapTable-v34/0.0.3/OpenCapTable-v34.dar": { - "sha256": "c44e5eaa7a88e8b6ec3ce91d422b8e3351f14fe7ea8f6dbbe2f42ddba1ed8210", - "size": 2569240, + "sha256": "de46f26a1225fdcf798b1f17fa71eca57b2105b0df22bb70344233c8fb2dda6d", + "size": 2570059, "sdkVersion": "3.4.10", - "uploadedAt": "2026-07-22T14:22:44.263Z", + "uploadedAt": "2026-07-22T14:28:14.165Z", "networks": [] } } diff --git a/scripts/codegen/templates/CapTable.daml.template b/scripts/codegen/templates/CapTable.daml.template index 1ca945c4..edd82863 100644 --- a/scripts/codegen/templates/CapTable.daml.template +++ b/scripts/codegen/templates/CapTable.daml.template @@ -585,20 +585,21 @@ validateWarrantBalanceSuccessor maps index source event remainder balanceSecurit None -> assertMsg (event.label <> " balance warrant issuance not found: " <> balanceSecurityId) False warrantQuantityAt : CapTableMaps -> WarrantIssuanceOcfData -> Time -> Update (Optional Decimal) -warrantQuantityAt maps source targetDate = - foldlA - (\current exerciseCid -> do +warrantQuantityAt maps source targetDate = do + (remainingQuantity, fullyExercised) <- foldlA + (\(current, full) exerciseCid -> do exercise <- fetch exerciseCid if exercise.exercise_data.security_id /= source.security_id || not (isEarlierOcfDate exercise.exercise_data.date targetDate) - then pure current + then pure (current, full) else case exercise.exercise_data.quantity of -- OCF only supplies quantity for partial exercises. An omitted -- quantity therefore consumes the entire remaining warrant. - None -> pure (Some 0.0) - Some quantity -> pure (fmap (\remaining -> remaining - quantity) current)) - source.quantity + None -> pure (current, True) + Some quantity -> pure (fmap (\remaining -> remaining - quantity) current, full)) + (source.quantity, False) (Map.values maps.warrant_exercises) + pure (if fullyExercised then Some 0.0 else remainingQuantity) validateWarrantTerminalCompleteness : CapTableMaps -> TerminalEventIndex -> Update () validateWarrantTerminalCompleteness maps index =