From 3aa6ff9fff7c419d8b749485ffb668af72340db8 Mon Sep 17 00:00:00 2001 From: "deepak.birdi" Date: Tue, 23 Jun 2026 17:02:25 -0400 Subject: [PATCH 01/15] Initial VestedAmulet draft --- .../daml/Splice/Scripts/TestLockedAmulet.daml | 9 ++++++ daml/splice-amulet/daml/Splice/Amulet.daml | 30 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/daml/splice-amulet-test/daml/Splice/Scripts/TestLockedAmulet.daml b/daml/splice-amulet-test/daml/Splice/Scripts/TestLockedAmulet.daml index 40ce4810c4..8e1b4313a7 100644 --- a/daml/splice-amulet-test/daml/Splice/Scripts/TestLockedAmulet.daml +++ b/daml/splice-amulet-test/daml/Splice/Scripts/TestLockedAmulet.daml @@ -62,3 +62,12 @@ testLocking = do exerciseCmd lockedCid2 LockedAmulet_OwnerExpireLockV2 pure () + +testVesting : Script () +testVesting = do + DefaultAppWithUsers{..} <- setupDefaultAppWithUsers + now <- getTime + + amuletCid <- tap app alice 3652.5 + + pure () diff --git a/daml/splice-amulet/daml/Splice/Amulet.daml b/daml/splice-amulet/daml/Splice/Amulet.daml index 16b918ba83..c5e94ea7ab 100644 --- a/daml/splice-amulet/daml/Splice/Amulet.daml +++ b/daml/splice-amulet/daml/Splice/Amulet.daml @@ -11,6 +11,8 @@ import DA.List (unique) import DA.Map as Map import DA.TextMap as TextMap import DA.Optional (fromOptional, isNone, optionalToList) +import DA.Time +import DA.Numeric import Splice.Api.Token.MetadataV1 qualified as Api.Token.MetadataV1 import Splice.Api.Token.HoldingV1 qualified as Api.Token.HoldingV1 @@ -677,3 +679,31 @@ instance HasCheckedFetch DevelopmentFundCoupon ForOwner where instance HasCheckedFetch DevelopmentFundCoupon ForDso where contractGroupId DevelopmentFundCoupon{..} = ForDso with dso + +calculateAvailableWithdrawAmount : Time -> VestedAmulet -> Decimal +calculateAvailableWithdrawAmount currentDateTime VestedAmulet{..} = let + totalVestedAmulet : Numeric 37 = castAndRound $ aggregateLockedAmulet.amount.initialAmount + withdrawnAmount + in if currentDateTime >= endDate + then (castAndRound totalVestedAmulet) - withdrawnAmount + else + let totalUnlockPeriod : Numeric 0 = intToNumeric . convertRelTimeToMicroseconds $ subTime endDate startDate + unlockPeriodElapsed : Numeric 0 = intToNumeric . convertRelTimeToMicroseconds $ subTime currentDateTime startDate + totalVestedPercentageElapsed : Numeric 37 = max 1.0 $ div unlockPeriodElapsed totalUnlockPeriod + in (castAndRound $ totalVestedAmulet * totalVestedPercentageElapsed) - withdrawnAmount + +template VestedAmulet + with + aggregateLockedAmulet : Amulet + startDate : Time + endDate : Time + withdrawnAmount : Decimal + where + signatory aggregateLockedAmulet.dso, aggregateLockedAmulet.owner + + nonconsuming choice VestedAmulet_WithdrawAvailableUnlockedAmulet : Decimal + controller aggregateLockedAmulet.owner8 + do + now <- getTime + let currentEligibleWithdrawableAmount = calculateAvailableWithdrawAmount now this + + pure currentEligibleWithdrawableAmount From b632ab161790e96433fc996dc1d8bfc6793e76a8 Mon Sep 17 00:00:00 2001 From: "deepak.birdi" Date: Thu, 25 Jun 2026 20:57:41 +0000 Subject: [PATCH 02/15] WIP creating new VestedAmulet after withdraw action --- daml/splice-amulet/daml/Splice/Amulet.daml | 37 +++++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/daml/splice-amulet/daml/Splice/Amulet.daml b/daml/splice-amulet/daml/Splice/Amulet.daml index c5e94ea7ab..e30c1dd2df 100644 --- a/daml/splice-amulet/daml/Splice/Amulet.daml +++ b/daml/splice-amulet/daml/Splice/Amulet.daml @@ -10,6 +10,7 @@ import DA.Assert import DA.List (unique) import DA.Map as Map import DA.TextMap as TextMap +import qualified DA.Text as T (implode) import DA.Optional (fromOptional, isNone, optionalToList) import DA.Time import DA.Numeric @@ -700,10 +701,36 @@ template VestedAmulet where signatory aggregateLockedAmulet.dso, aggregateLockedAmulet.owner - nonconsuming choice VestedAmulet_WithdrawAvailableUnlockedAmulet : Decimal - controller aggregateLockedAmulet.owner8 + choice VestedAmulet_WithdrawAvailableUnlockedAmulet : (Amulet) -- TODO: Make custom return type to follow convention + with + receiver : Party + -- TODO: Looks like context in other tests provide openRound, maybe this should be fine? + openRound : Round + expectedDso : Party + -- Party that the sender expects to represent the DSO party of the AmuletRules contract they are calling. + -- Must always be set to protect from malicious delegees swapping the AmuletRules contract out for + -- one under their control. + controller aggregateLockedAmulet.owner do - now <- getTime - let currentEligibleWithdrawableAmount = calculateAvailableWithdrawAmount now this + flip require (aggregateLockedAmulet.dso /= expectedDso) $ T.implode ["DSO party expected by caller ", show expectedDso, " does not match actual DSO party ", show aggregateLockedAmulet.dso] - pure currentEligibleWithdrawableAmount + now <- getTime + let currentEligibleWithdrawableAmount : Decimal = calculateAvailableWithdrawAmount now this + require "Current wligible withdraw amount for vested amulet is not greater than 0.0" (currentEligibleWithdrawableAmount <= 0.0) + + -- Amulet for Receiver for Unlocked Amount of CC + receiverAmulet <- create Amulet with + dso = expectedDso + owner = aggregateLockedAmulet.owner + amount = ExpiringAmount with + initialAmount = currentEligibleWithdrawableAmount + createdAt = openRound + ratePerRound = aggregateLockedAmulet.amount.ratePerRound + + vestedAmulet <- create VestedAmulet with + startDate + endDate + withdrawnAmount = withdrawnAmount + currentEligibleWithdrawableAmount + --aggregateLockedAmulet = + + pure receiverAmulet From 228d753d50b58ce9ca7062e82478ee5b0cd20505 Mon Sep 17 00:00:00 2001 From: "deepak.birdi" Date: Fri, 26 Jun 2026 18:31:13 +0000 Subject: [PATCH 03/15] Return appropriate VestedAmulet types for withdraw choice --- daml/splice-amulet/daml/Splice/Amulet.daml | 39 ++++++++++++++++------ 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/daml/splice-amulet/daml/Splice/Amulet.daml b/daml/splice-amulet/daml/Splice/Amulet.daml index e30c1dd2df..aff1dd3d9f 100644 --- a/daml/splice-amulet/daml/Splice/Amulet.daml +++ b/daml/splice-amulet/daml/Splice/Amulet.daml @@ -121,6 +121,15 @@ data DevelopmentFundCoupon_RejectResult = DevelopmentFundCoupon_RejectResult wit data DevelopmentFundCoupon_DsoExpireResult = DevelopmentFundCoupon_DsoExpireResult with unclaimedDevelopmentFundCouponCid : ContractId UnclaimedDevelopmentFundCoupon +data VestedAmulet_WithdrawResult + = VestedAmulet_WithdrawResult + with + receiverAmulet : ContractId Amulet -- ^ References new amulet created from the vested + -- amulet's unlocked withdrawn amount, with the owner being the receiver passed in. + vestedAmulet : Optional (ContractId VestedAmulet) -- ^ Optional reference to the vested amulet if any + -- locked aggregate amount remains. None if all of the amount is has already been withdrawn. + deriving (Show, Eq) + -- | A amulet, which can be locked and whose amount expires over time. -- -- The expiry serves to charge an inactivity fee, and thereby ensures that the @@ -701,7 +710,7 @@ template VestedAmulet where signatory aggregateLockedAmulet.dso, aggregateLockedAmulet.owner - choice VestedAmulet_WithdrawAvailableUnlockedAmulet : (Amulet) -- TODO: Make custom return type to follow convention + choice VestedAmulet_WithdrawAvailableUnlockedAmulet : VestedAmulet_WithdrawResult with receiver : Party -- TODO: Looks like context in other tests provide openRound, maybe this should be fine? @@ -716,21 +725,31 @@ template VestedAmulet now <- getTime let currentEligibleWithdrawableAmount : Decimal = calculateAvailableWithdrawAmount now this - require "Current wligible withdraw amount for vested amulet is not greater than 0.0" (currentEligibleWithdrawableAmount <= 0.0) + require "Current eligible withdraw amount for vested amulet is not greater than 0.0" (currentEligibleWithdrawableAmount <= 0.0) -- Amulet for Receiver for Unlocked Amount of CC receiverAmulet <- create Amulet with dso = expectedDso - owner = aggregateLockedAmulet.owner + owner = receiver amount = ExpiringAmount with initialAmount = currentEligibleWithdrawableAmount createdAt = openRound ratePerRound = aggregateLockedAmulet.amount.ratePerRound - vestedAmulet <- create VestedAmulet with - startDate - endDate - withdrawnAmount = withdrawnAmount + currentEligibleWithdrawableAmount - --aggregateLockedAmulet = - - pure receiverAmulet + let remainingAggregateLockedAmount = aggregateLockedAmulet.amount.initialAmount - currentEligibleWithdrawableAmount + + vestedAmulet <- if remainingAggregateLockedAmount > 0.0 then do + Some <$> create VestedAmulet with + startDate + endDate + withdrawnAmount = withdrawnAmount + currentEligibleWithdrawableAmount + aggregateLockedAmulet = Amulet with + dso = expectedDso + owner = aggregateLockedAmulet.owner + amount = ExpiringAmount with + initialAmount = remainingAggregateLockedAmount + createdAt = openRound + ratePerRound = aggregateLockedAmulet.amount.ratePerRound + else return None + + return $ VestedAmulet_WithdrawResult receiverAmulet vestedAmulet From b78829ad8f95b4f92ca3f4c40a5a393ee3820590 Mon Sep 17 00:00:00 2001 From: "deepak.birdi" Date: Mon, 29 Jun 2026 20:57:50 +0000 Subject: [PATCH 04/15] WIP vestingUnlock testing --- .../daml/Splice/Scripts/TestLockedAmulet.daml | 27 ++++++++++++- daml/splice-amulet/daml/Splice/Amulet.daml | 39 ++++++++++++------- 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/daml/splice-amulet-test/daml/Splice/Scripts/TestLockedAmulet.daml b/daml/splice-amulet-test/daml/Splice/Scripts/TestLockedAmulet.daml index 8e1b4313a7..5b0f6f856a 100644 --- a/daml/splice-amulet-test/daml/Splice/Scripts/TestLockedAmulet.daml +++ b/daml/splice-amulet-test/daml/Splice/Scripts/TestLockedAmulet.daml @@ -11,6 +11,7 @@ import Splice.Amulet.TokenApiUtils import Splice.AmuletRules import Splice.Expiry import Splice.Scripts.Util +import Splice.Fees (ExpiringAmount(..)) testLocking : Script () testLocking = do @@ -68,6 +69,30 @@ testVesting = do DefaultAppWithUsers{..} <- setupDefaultAppWithUsers now <- getTime - amuletCid <- tap app alice 3652.5 + amuletCid <- tap app alice 5000.0 + Some(amulet) <- queryContractId @Amulet alice.primaryParty amuletCid + (_, openRound) <- getLatestOpenRound app + + vestedAmuletCid <- submit (actAs alice.primaryParty <> actAs app.dso) $ + createCmd VestedAmulet with + startDate = now + endDate = addRelTime now $ (days 365) + (hours 6) + withdrawnAmount = 0.0 +-- aggregateLockedAmulet = amulet + aggregateLockedAmulet = Amulet with + dso = app.dso + owner = alice.primaryParty + amount = ExpiringAmount with + initialAmount = 3652.5 + createdAt = openRound.round + ratePerRound = openRound.transferConfigUsd.holdingFee + +-- passTime (days 10) + + vestedWithdrawResult <- submit (actAs alice.primaryParty) $ -- <> actAs app.dso) $ + exerciseCmd vestedAmuletCid VestedAmulet_WithdrawAvailableUnlockedAmulet with + receiver = alice.primaryParty + openRound = openRound.round + expectedDso = app.dso pure () diff --git a/daml/splice-amulet/daml/Splice/Amulet.daml b/daml/splice-amulet/daml/Splice/Amulet.daml index aff1dd3d9f..b6754a1420 100644 --- a/daml/splice-amulet/daml/Splice/Amulet.daml +++ b/daml/splice-amulet/daml/Splice/Amulet.daml @@ -690,16 +690,16 @@ instance HasCheckedFetch DevelopmentFundCoupon ForOwner where instance HasCheckedFetch DevelopmentFundCoupon ForDso where contractGroupId DevelopmentFundCoupon{..} = ForDso with dso -calculateAvailableWithdrawAmount : Time -> VestedAmulet -> Decimal -calculateAvailableWithdrawAmount currentDateTime VestedAmulet{..} = let - totalVestedAmulet : Numeric 37 = castAndRound $ aggregateLockedAmulet.amount.initialAmount + withdrawnAmount - in if currentDateTime >= endDate - then (castAndRound totalVestedAmulet) - withdrawnAmount - else - let totalUnlockPeriod : Numeric 0 = intToNumeric . convertRelTimeToMicroseconds $ subTime endDate startDate - unlockPeriodElapsed : Numeric 0 = intToNumeric . convertRelTimeToMicroseconds $ subTime currentDateTime startDate - totalVestedPercentageElapsed : Numeric 37 = max 1.0 $ div unlockPeriodElapsed totalUnlockPeriod - in (castAndRound $ totalVestedAmulet * totalVestedPercentageElapsed) - withdrawnAmount +--calculateAvailableWithdrawAmount : Time -> VestedAmulet -> Decimal +--calculateAvailableWithdrawAmount currentDateTime VestedAmulet{..} = let +-- totalVestedAmulet : Numeric 37 = castAndRound $ aggregateLockedAmulet.amount.initialAmount + withdrawnAmount +-- in if currentDateTime >= endDate +-- then (castAndRound totalVestedAmulet) - withdrawnAmount +-- else +-- let totalUnlockPeriod : Numeric 0 = intToNumeric . convertRelTimeToMicroseconds $ subTime endDate startDate +-- unlockPeriodElapsed : Numeric 0 = intToNumeric . convertRelTimeToMicroseconds $ subTime currentDateTime startDate +-- totalVestedPercentageElapsed : Numeric 37 = max 1.0 $ div unlockPeriodElapsed totalUnlockPeriod +-- in (castAndRound $ totalVestedAmulet * totalVestedPercentageElapsed) - withdrawnAmount template VestedAmulet with @@ -721,11 +721,24 @@ template VestedAmulet -- one under their control. controller aggregateLockedAmulet.owner do - flip require (aggregateLockedAmulet.dso /= expectedDso) $ T.implode ["DSO party expected by caller ", show expectedDso, " does not match actual DSO party ", show aggregateLockedAmulet.dso] + flip require ((show aggregateLockedAmulet.dso) == (show expectedDso)) $ + T.implode [ + "DSO party expected by caller " + , show expectedDso + , " does not match actual DSO party " + , show aggregateLockedAmulet.dso + ] now <- getTime - let currentEligibleWithdrawableAmount : Decimal = calculateAvailableWithdrawAmount now this - require "Current eligible withdraw amount for vested amulet is not greater than 0.0" (currentEligibleWithdrawableAmount <= 0.0) + -- TODO: the function below is giving some troubles during testing for some reason +-- let currentEligibleWithdrawableAmount : Decimal = calculateAvailableWithdrawAmount now this + let currentEligibleWithdrawableAmount : Decimal = 10.0 + require ("Current eligible withdraw amount for vested amulet is not greater than 0.0. " + <> "currentEligibleWithdrawableAmount came back = '" + <> show currentEligibleWithdrawableAmount + <> "'." + ) + (currentEligibleWithdrawableAmount > 0.0) -- Amulet for Receiver for Unlocked Amount of CC receiverAmulet <- create Amulet with From 0dc236feb87cbc26ec50fe0925c5f83f7f84d880 Mon Sep 17 00:00:00 2001 From: "Jonathan D.K. Gibbons" Date: Tue, 30 Jun 2026 16:38:24 +0000 Subject: [PATCH 05/15] WIP: Aggregate Locks + test script --- .../Splice/Scripts/TestAggregateLocks.daml | 133 ++++++++++++ .../daml/Splice/AggregateLock.daml | 205 ++++++++++++++++++ 2 files changed, 338 insertions(+) create mode 100644 daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml create mode 100644 daml/splice-amulet/daml/Splice/AggregateLock.daml diff --git a/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml b/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml new file mode 100644 index 0000000000..b941da06e3 --- /dev/null +++ b/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml @@ -0,0 +1,133 @@ +{-# LANGUAGE ApplicativeDo #-} +module Splice.Scripts.TestAggregateLocks where + +import DA.Time +import Daml.Script +import Splice.Amulet +import Splice.AggregateLock +import Splice.AmuletRules +import Splice.Expiry +import Splice.Scripts.Util + +import DA.Assert +import DA.Optional +import DA.Functor +import DA.Foldable (mapA_, sequence_) +import qualified DA.Map as M + + +testAggregateLocks : Script () +testAggregateLocks = do + DefaultAppWithUsers{..} <- setupDefaultAppWithUsers + amuletCid <- tap app alice 1000.0 + amuletCid2 <- tap app bob 1000.0 + now <- getTime + + aggLock <- submit (actAs [alice.primaryParty, app.dso]) $ createCmd AggregateLock with + dso = app.dso + key = AggregateLockKey $ alice.primaryParty + vestingSchedule = None + + aggTotal <- submit app.dso $ createCmd AggregateTotal with + dso = app.dso + key = AggregateLockKey $ alice.primaryParty + totalAsOf = 0.0 + asOfTime = now + asOfRound = 0 + lowerBound = True + upperBound = True + + let getTotal = do + [(totalContractId, totalContract)] <- query @AggregateTotal alice.primaryParty + (,) totalContractId . fromSome <$> queryDisclosure app.dso totalContractId + + offer <- submit (actAs alice.primaryParty) $ createCmd OpenDelegationOffer with + owner = alice.primaryParty + lock = aggLock + unlockControllerSets = [[alice.primaryParty]] + substituteControllerSets = [[alice.primaryParty]] + + disclosedOfferAlice <- fromSome <$> queryDisclosure alice.primaryParty offer + disclosedLock <- fromSome <$> queryDisclosure alice.primaryParty aggLock + -- disclosedOfferBob <- fromSome <$> queryDisclosure bob.primaryParty offer + + (AggregateLock_LockHoldingResult lockedAmulet) <- submit alice.primaryParty $ exerciseCmd offer OpenDelegationOffer_LockHolding with + holder = alice.primaryParty + holding = amuletCid + + (AggregateLock_LockHoldingResult lockedAmulet2) <- submit (actAs [bob.primaryParty] <> discloseMany [disclosedOfferAlice, disclosedLock]) $ exerciseCmd offer OpenDelegationOffer_LockHolding with + holder = bob.primaryParty + holding = amuletCid2 + + (total, disclosedTotal) <- getTotal + + debug ("total", total) + + unlockedAmulet <- submit (actAs [bob.primaryParty, alice.primaryParty] <> discloseMany [disclosedLock, disclosedTotal]) $ exerciseCmd lockedAmulet2 AggregateLockedAmulet_Unlock with + amount = 500.0 + authorizers = [ bob.primaryParty, alice.primaryParty ] + lock = aggLock + total + + debug ("Unlocked", unlockedAmulet) + + dsoAggregateAutomation app.dso 0 + + onChainTotals <- query @AggregateTotal app.dso + debug ("On Chain Totals", onChainTotals) + + (total, disclosedTotal) <- getTotal + + let lockedAmulet3 = unlockedAmulet.locked + unlockedAmulet2 <- submit (actAs [bob.primaryParty, alice.primaryParty] <> discloseMany [disclosedLock, disclosedTotal]) $ exerciseCmd lockedAmulet3 AggregateLockedAmulet_Unlock with + amount = 250.0 + authorizers = [ bob.primaryParty, alice.primaryParty ] + lock = aggLock + total + + [(_, onChainTotal)] <- query @AggregateTotal app.dso + + assertEq False onChainTotal.lowerBound + assertEq 1500.0 onChainTotal.totalAsOf + + dsoAggregateAutomation app.dso 1 + + [(_, onChainTotal)] <- query @AggregateTotal app.dso + assertEq 1250.0 onChainTotal.totalAsOf + assertEq True onChainTotal.lowerBound + + pure () + + +-- dsoAggregateAutomation :: +dsoAggregateAutomation : Party -> Int -> Script () +dsoAggregateAutomation dso round = do + lockedHoldings <- query @AggregateLockedAmulet dso + let totals = M.fromListWithL (+) $ lockedHoldings <&> \(_, holding) -> + (holding.lockedTo, holding.amulet.amount.initialAmount) + onChainTotals <- query @AggregateTotal dso + let onChainTotalsMap : M.Map AggregateLockKey (Optional (ContractId AggregateTotal)) + onChainTotalsMap = M.fromListWithL (\_ _ -> None) $ onChainTotals <&> \(contractId, total) -> + (total.key, Some contractId) + + now <- getTime + + let updateOneLock : AggregateLockKey -> Decimal -> Optional (ContractId AggregateTotal) -> Optional (Script ()) + updateOneLock key total (Some oldTotalId) = Some $ + submit (actAs dso) $ do + archiveCmd oldTotalId + createCmd AggregateTotal with + totalAsOf = total + asOfTime = now + asOfRound = round + lowerBound = True + upperBound = True + dso + key + pure () + updateOneLock _ _ None = Some $ assertFail "Only one AggregateTotal per key is permitted" + let onChainUpdates = M.merge (\_ _ -> Some $ assertFail "Missing AggregateTotal") (\k -> updateOneLock k (0.0 : Decimal)) updateOneLock totals onChainTotalsMap + + sequence_ onChainUpdates + + pure () diff --git a/daml/splice-amulet/daml/Splice/AggregateLock.daml b/daml/splice-amulet/daml/Splice/AggregateLock.daml new file mode 100644 index 0000000000..636c46aa3d --- /dev/null +++ b/daml/splice-amulet/daml/Splice/AggregateLock.daml @@ -0,0 +1,205 @@ +module Splice.AggregateLock where + +import Splice.Amulet +import DA.Time +import DA.Action +import DA.Foldable (forA_) + + +data AggregateLockKey = AggregateLockKey with + owner : Party + deriving (Eq, Show, Ord) + +data VestingSchedule = VestingSchedule with + period : RelTime -- check this type. + deriving (Eq, Show) + +type ControllerSets = [[Party]] + +template AggregateLock + with + dso : Party + key : AggregateLockKey + vestingSchedule : Optional VestingSchedule + where + signatory key.owner, dso + nonconsuming choice AggregateLock_LockHolding : AggregateLock_LockHoldingResult + with + holder : Party + holding : ContractId Amulet + unlockControllerSets : ControllerSets + substituteControllerSets : ControllerSets + controller key.owner, holder + do + holdingContract <- fetch holding + archive holding + AggregateLock_LockHoldingResult <$> create AggregateLockedAmulet with + amulet = holdingContract + lockedTo = key + unlockControllerSets + substituteControllerSets + + choice AggregateLock_AllowImmediateUnlocks : AggregateLock_AllowImmediateUnlocksResult + controller dso + do + AggregateLock_AllowImmediateUnlocksResult <$> create this with + vestingSchedule = None + +data AggregateLock_LockHoldingResult = AggregateLock_LockHoldingResult (ContractId AggregateLockedAmulet) +data AggregateLock_AllowImmediateUnlocksResult = AggregateLock_AllowImmediateUnlocksResult (ContractId AggregateLock) + +data AggregateLockedAmulet_UnlockResult + = AggregateLockedAmulet_UnlockResult_Vesting with + vesting : ContractId VestingLockedAmulet + locked : ContractId AggregateLockedAmulet + | AggregateLockedAmulet_UnlockResult_Immediate with + unlocked : ContractId Amulet + locked : ContractId AggregateLockedAmulet + deriving (Show) + + +template AggregateLockedAmulet + with + amulet : Amulet + lockedTo : AggregateLockKey + unlockControllerSets : ControllerSets + substituteControllerSets : ControllerSets + where + signatory amulet.dso, amulet.owner + + choice AggregateLockedAmulet_Unlock : AggregateLockedAmulet_UnlockResult + with + amount : Decimal + authorizers : [Party] + lock : ContractId AggregateLock + total : ContractId AggregateTotal + controller authorizers + do + assert $ authorizers `elem` unlockControllerSets + + let remainder = amulet.amount.initialAmount - amount -- FIXME: do something about expiring amulet. + lockParameters <- fetch lock + now <- getTime + totalContract <- fetch total + if totalContract.lowerBound + then do + create totalContract with + lowerBound = False + archive total + else return () + case lockParameters.vestingSchedule of + Some schedule -> do + vested <- create VestingLockedAmulet with + start = now + period = schedule.period + amulet = amulet with + amount = amulet.amount with + initialAmount = amount + locked <- create this with + amulet = amulet with + amount = amulet.amount with + initialAmount = remainder + return $ AggregateLockedAmulet_UnlockResult_Vesting vested locked + None -> do + unlocked <- create this.amulet with + amount = this.amulet.amount with + initialAmount = amount + locked <- create this with + amulet = amulet with + amount = amulet.amount with + initialAmount = remainder + return $ AggregateLockedAmulet_UnlockResult_Immediate unlocked locked + +template VestingLockedAmulet with + start : Time + period : RelTime + amulet : Amulet + where + signatory amulet.dso + +{- + choice AggregateLockedAmulet_Substitute : AggregateLockedAmulet_Substitute + with + amount : Decimal authorizers : [Party] + lock : ContractId AggregateLock + swapWith : Amulet + newUnlockControllerSets : ControllerSets + newSubstituteControllerSets : ControllerSets + controller swapWith.owner :: authorizers + do + assert $ authorizers `elem` substituteControllerSets + let remainder = amulet.amount. - amount + unlocked <- create Amulet with + + + + Nothing -> do + unlocked <- create Amulet with + amount = amulet.amount with + initialAmount = amount + locked <- create this with + amulet = amulet with + amount = amulet.amount with + initialAmount = remainder + return AggregateLockedAmulet_UnlockResult_Immediate unlocked amount +-} + +template AggregateTotal + with + dso : Party + key : AggregateLockKey + totalAsOf : Decimal + asOfTime : Time + asOfRound : Int + lowerBound : Bool + upperBound : Bool + where + signatory dso + observer key.owner + +template AggregateLockDsoAutomation + with + dso : Party + where + signatory dso + nonconsuming choice AggregateLockDsoAutomation_UpdateHolderTotal : AggregateLockDsoAutomation_UpdateHolderTotalResult + with + key : AggregateLockKey + asOfRound : Int + holdings : [ AggregateLockedAmulet ] + controller dso + do + forA_ holdings $ \a -> + assert $ a.lockedTo == key + let totalAsOf = foldl (\t h -> t + h.amulet.amount.initialAmount) 0.0 holdings -- FIXME: use correct amount calculation. + asOfTime <- getTime + create AggregateTotal with + lowerBound = True + upperBound = True + .. + pure AggregateLockDsoAutomation_UpdateHolderTotalResult + +data AggregateLockDsoAutomation_UpdateHolderTotalResult = AggregateLockDsoAutomation_UpdateHolderTotalResult + +template OpenDelegationOffer + with + owner : Party + lock : ContractId AggregateLock + unlockControllerSets : ControllerSets + substituteControllerSets : ControllerSets + where + signatory owner + + nonconsuming choice OpenDelegationOffer_LockHolding : AggregateLock_LockHoldingResult + with + holder : Party + holding : ContractId Amulet + controller holder + do + holdingAmulet <- fetch holding + assert $ holder == holdingAmulet.owner + exercise lock AggregateLock_LockHolding with + unlockControllerSets = (::) holder <$> unlockControllerSets + substituteControllerSets = (::) holder <$> substituteControllerSets + .. + From 8a316e5b0a0ba4c733c556634b57cde034120545 Mon Sep 17 00:00:00 2001 From: "deepak.birdi" Date: Tue, 30 Jun 2026 18:20:35 +0000 Subject: [PATCH 06/15] WIP correct calculateAvailableWithdraw amount calculations --- .../daml/Splice/Scripts/TestLockedAmulet.daml | 2 +- daml/splice-amulet/daml/Splice/Amulet.daml | 25 +++++++++---------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/daml/splice-amulet-test/daml/Splice/Scripts/TestLockedAmulet.daml b/daml/splice-amulet-test/daml/Splice/Scripts/TestLockedAmulet.daml index 5b0f6f856a..a3b31c6285 100644 --- a/daml/splice-amulet-test/daml/Splice/Scripts/TestLockedAmulet.daml +++ b/daml/splice-amulet-test/daml/Splice/Scripts/TestLockedAmulet.daml @@ -87,7 +87,7 @@ testVesting = do createdAt = openRound.round ratePerRound = openRound.transferConfigUsd.holdingFee --- passTime (days 10) + passTime (days 10) vestedWithdrawResult <- submit (actAs alice.primaryParty) $ -- <> actAs app.dso) $ exerciseCmd vestedAmuletCid VestedAmulet_WithdrawAvailableUnlockedAmulet with diff --git a/daml/splice-amulet/daml/Splice/Amulet.daml b/daml/splice-amulet/daml/Splice/Amulet.daml index b6754a1420..ef048f52d5 100644 --- a/daml/splice-amulet/daml/Splice/Amulet.daml +++ b/daml/splice-amulet/daml/Splice/Amulet.daml @@ -690,16 +690,16 @@ instance HasCheckedFetch DevelopmentFundCoupon ForOwner where instance HasCheckedFetch DevelopmentFundCoupon ForDso where contractGroupId DevelopmentFundCoupon{..} = ForDso with dso ---calculateAvailableWithdrawAmount : Time -> VestedAmulet -> Decimal ---calculateAvailableWithdrawAmount currentDateTime VestedAmulet{..} = let --- totalVestedAmulet : Numeric 37 = castAndRound $ aggregateLockedAmulet.amount.initialAmount + withdrawnAmount --- in if currentDateTime >= endDate --- then (castAndRound totalVestedAmulet) - withdrawnAmount --- else --- let totalUnlockPeriod : Numeric 0 = intToNumeric . convertRelTimeToMicroseconds $ subTime endDate startDate --- unlockPeriodElapsed : Numeric 0 = intToNumeric . convertRelTimeToMicroseconds $ subTime currentDateTime startDate --- totalVestedPercentageElapsed : Numeric 37 = max 1.0 $ div unlockPeriodElapsed totalUnlockPeriod --- in (castAndRound $ totalVestedAmulet * totalVestedPercentageElapsed) - withdrawnAmount +calculateAvailableWithdrawAmount : Time -> VestedAmulet -> Decimal +calculateAvailableWithdrawAmount currentDateTime VestedAmulet{..} = let + totalVestedAmulet : Decimal = castAndRound $ aggregateLockedAmulet.amount.initialAmount + withdrawnAmount + in if currentDateTime >= endDate + then (castAndRound totalVestedAmulet) - withdrawnAmount + else + let totalUnlockPeriod = intToDecimal . convertRelTimeToMicroseconds $ subTime endDate startDate + unlockPeriodElapsed = intToDecimal . convertRelTimeToMicroseconds $ subTime currentDateTime startDate + totalVestedPercentageElapsed : Decimal = div unlockPeriodElapsed totalUnlockPeriod + in (castAndRound $ totalVestedAmulet * totalVestedPercentageElapsed) - withdrawnAmount template VestedAmulet with @@ -730,9 +730,8 @@ template VestedAmulet ] now <- getTime - -- TODO: the function below is giving some troubles during testing for some reason --- let currentEligibleWithdrawableAmount : Decimal = calculateAvailableWithdrawAmount now this - let currentEligibleWithdrawableAmount : Decimal = 10.0 + let currentEligibleWithdrawableAmount : Decimal = calculateAvailableWithdrawAmount now this + require ("Current eligible withdraw amount for vested amulet is not greater than 0.0. " <> "currentEligibleWithdrawableAmount came back = '" <> show currentEligibleWithdrawableAmount From 36d07833cec085f8945be40b27126d8735147f3c Mon Sep 17 00:00:00 2001 From: "deepak.birdi" Date: Wed, 1 Jul 2026 20:53:05 +0000 Subject: [PATCH 07/15] WIP wipping up some tests --- .../daml/Splice/Scripts/TestLockedAmulet.daml | 91 +++++++++++++++---- daml/splice-amulet/daml/Splice/Amulet.daml | 6 +- 2 files changed, 76 insertions(+), 21 deletions(-) diff --git a/daml/splice-amulet-test/daml/Splice/Scripts/TestLockedAmulet.daml b/daml/splice-amulet-test/daml/Splice/Scripts/TestLockedAmulet.daml index a3b31c6285..c01ccdbdd8 100644 --- a/daml/splice-amulet-test/daml/Splice/Scripts/TestLockedAmulet.daml +++ b/daml/splice-amulet-test/daml/Splice/Scripts/TestLockedAmulet.daml @@ -3,6 +3,8 @@ module Splice.Scripts.TestLockedAmulet where +import DA.Assert +import DA.Optional import DA.Time import Daml.Script @@ -64,35 +66,88 @@ testLocking = do pure () -testVesting : Script () -testVesting = do +testVestingAmuletUnlock : Script () +testVestingAmuletUnlock = do DefaultAppWithUsers{..} <- setupDefaultAppWithUsers now <- getTime - amuletCid <- tap app alice 5000.0 + amuletCid <- tap app alice 3652.5 Some(amulet) <- queryContractId @Amulet alice.primaryParty amuletCid (_, openRound) <- getLatestOpenRound app + let initialVestedAmuletValues : VestedAmulet = VestedAmulet { aggregateLockedAmulet = amulet + , startDate = now + , endDate = (addRelTime now $ (days 365) + (hours 6)) + , withdrawnAmount = 0.0 + } + vestedAmuletCid <- submit (actAs alice.primaryParty <> actAs app.dso) $ createCmd VestedAmulet with - startDate = now - endDate = addRelTime now $ (days 365) + (hours 6) - withdrawnAmount = 0.0 --- aggregateLockedAmulet = amulet - aggregateLockedAmulet = Amulet with - dso = app.dso - owner = alice.primaryParty - amount = ExpiringAmount with - initialAmount = 3652.5 - createdAt = openRound.round - ratePerRound = openRound.transferConfigUsd.holdingFee - - passTime (days 10) - - vestedWithdrawResult <- submit (actAs alice.primaryParty) $ -- <> actAs app.dso) $ + aggregateLockedAmulet = initialVestedAmuletValues.aggregateLockedAmulet + startDate = initialVestedAmuletValues.startDate + endDate = initialVestedAmuletValues.endDate + withdrawnAmount = initialVestedAmuletValues.withdrawnAmount + + -- Immediate withdraw attempt causes transaction to fail due to the current + -- eligible withdraw amount being zero (no time has passed since locking yet) + submitMustFail (actAs alice.primaryParty) $ exerciseCmd vestedAmuletCid VestedAmulet_WithdrawAvailableUnlockedAmulet with receiver = alice.primaryParty openRound = openRound.round expectedDso = app.dso + let totalTimePassed = days 10 + passTime totalTimePassed + + vestedWithdrawResult <- submit (actAs alice.primaryParty) $ + exerciseCmd vestedAmuletCid VestedAmulet_WithdrawAvailableUnlockedAmulet with + receiver = alice.primaryParty + openRound = openRound.round + expectedDso = app.dso + + let vestedAmuletCid' = fromSome $ mVestedAmuletCid vestedWithdrawResult + receiverAmulet <- fromSome <$> queryContractId alice.primaryParty (receiverAmuletCid vestedWithdrawResult) + vestedAmulet <- fromSome <$> queryContractId alice.primaryParty vestedAmuletCid' + + updatedNow <- getTime + let availableAmount = calculateAvailableWithdrawAmount updatedNow initialVestedAmuletValues + + assertEq receiverAmulet.amount.initialAmount availableAmount + assertEq vestedAmulet.aggregateLockedAmulet.amount.initialAmount $ + initialVestedAmuletValues.aggregateLockedAmulet.amount.initialAmount - availableAmount + + -- Try withdraw again, right after previous withdraw (should fail) + submitMustFail (actAs alice.primaryParty) $ + exerciseCmd vestedAmuletCid' VestedAmulet_WithdrawAvailableUnlockedAmulet with + receiver = alice.primaryParty + openRound = openRound.round + expectedDso = app.dso + + -- Let's try to withdraw right before the full amount is available + passTime ((days 365 + hours 5 + minutes 59 + seconds 59 + milliseconds 59 + microseconds 59) - totalTimePassed) + + vestedWithdrawResult' <- submit (actAs alice.primaryParty) $ + exerciseCmd vestedAmuletCid' VestedAmulet_WithdrawAvailableUnlockedAmulet with + receiver = alice.primaryParty + openRound = openRound.round + expectedDso = app.dso + + -- TODO: prob throw this in a function once it looks better + let vestedAmuletCid'' = fromSome $ mVestedAmuletCid vestedWithdrawResult' + receiverAmulet' <- fromSome <$> queryContractId alice.primaryParty (receiverAmuletCid vestedWithdrawResult') + vestedAmulet' <- fromSome <$> queryContractId alice.primaryParty vestedAmuletCid'' + + updatedNow <- getTime + let availableAmount' = calculateAvailableWithdrawAmount updatedNow initialVestedAmuletValues + + assertEq receiverAmulet'.amount.initialAmount availableAmount' + assertEq vestedAmulet'.aggregateLockedAmulet.amount.initialAmount $ + initialVestedAmuletValues.aggregateLockedAmulet.amount.initialAmount - availableAmount + + debug receiverAmulet' + debug vestedAmulet' + debug "lol" + debug vestedWithdrawResult + debug "lul2" + assertBeforeMsg "8ooof" (addRelTime now $ days 1) pure () diff --git a/daml/splice-amulet/daml/Splice/Amulet.daml b/daml/splice-amulet/daml/Splice/Amulet.daml index ef048f52d5..31dcf153ca 100644 --- a/daml/splice-amulet/daml/Splice/Amulet.daml +++ b/daml/splice-amulet/daml/Splice/Amulet.daml @@ -124,9 +124,9 @@ data DevelopmentFundCoupon_DsoExpireResult = DevelopmentFundCoupon_DsoExpireResu data VestedAmulet_WithdrawResult = VestedAmulet_WithdrawResult with - receiverAmulet : ContractId Amulet -- ^ References new amulet created from the vested + receiverAmuletCid : ContractId Amulet -- ^ References new amulet created from the vested -- amulet's unlocked withdrawn amount, with the owner being the receiver passed in. - vestedAmulet : Optional (ContractId VestedAmulet) -- ^ Optional reference to the vested amulet if any + mVestedAmuletCid : Optional (ContractId VestedAmulet) -- ^ Optional reference to the vested amulet if any -- locked aggregate amount remains. None if all of the amount is has already been withdrawn. deriving (Show, Eq) @@ -713,7 +713,6 @@ template VestedAmulet choice VestedAmulet_WithdrawAvailableUnlockedAmulet : VestedAmulet_WithdrawResult with receiver : Party - -- TODO: Looks like context in other tests provide openRound, maybe this should be fine? openRound : Round expectedDso : Party -- Party that the sender expects to represent the DSO party of the AmuletRules contract they are calling. @@ -750,6 +749,7 @@ template VestedAmulet let remainingAggregateLockedAmount = aggregateLockedAmulet.amount.initialAmount - currentEligibleWithdrawableAmount + -- Vested Amulet in case any amount remains locked in aggregateLockedAmulet vestedAmulet <- if remainingAggregateLockedAmount > 0.0 then do Some <$> create VestedAmulet with startDate From 0cf9867cf52e09a5cca38710a94b9668e2d1adf9 Mon Sep 17 00:00:00 2001 From: "deepak.birdi" Date: Thu, 2 Jul 2026 21:03:33 +0000 Subject: [PATCH 08/15] Iron out some vesting unlock testing --- .../daml/Splice/Scripts/TestLockedAmulet.daml | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/daml/splice-amulet-test/daml/Splice/Scripts/TestLockedAmulet.daml b/daml/splice-amulet-test/daml/Splice/Scripts/TestLockedAmulet.daml index c01ccdbdd8..ede5b40efa 100644 --- a/daml/splice-amulet-test/daml/Splice/Scripts/TestLockedAmulet.daml +++ b/daml/splice-amulet-test/daml/Splice/Scripts/TestLockedAmulet.daml @@ -124,7 +124,7 @@ testVestingAmuletUnlock = do expectedDso = app.dso -- Let's try to withdraw right before the full amount is available - passTime ((days 365 + hours 5 + minutes 59 + seconds 59 + milliseconds 59 + microseconds 59) - totalTimePassed) + passTime ((days 364 + hours 5 + minutes 59 + seconds 59 + milliseconds 59 + microseconds 59) - totalTimePassed) vestedWithdrawResult' <- submit (actAs alice.primaryParty) $ exerciseCmd vestedAmuletCid' VestedAmulet_WithdrawAvailableUnlockedAmulet with @@ -132,22 +132,41 @@ testVestingAmuletUnlock = do openRound = openRound.round expectedDso = app.dso - -- TODO: prob throw this in a function once it looks better let vestedAmuletCid'' = fromSome $ mVestedAmuletCid vestedWithdrawResult' receiverAmulet' <- fromSome <$> queryContractId alice.primaryParty (receiverAmuletCid vestedWithdrawResult') vestedAmulet' <- fromSome <$> queryContractId alice.primaryParty vestedAmuletCid'' updatedNow <- getTime - let availableAmount' = calculateAvailableWithdrawAmount updatedNow initialVestedAmuletValues + let availableAmount' = calculateAvailableWithdrawAmount updatedNow vestedAmulet + -- Receiver amulet and vested amulet should both mathing right assertEq receiverAmulet'.amount.initialAmount availableAmount' assertEq vestedAmulet'.aggregateLockedAmulet.amount.initialAmount $ - initialVestedAmuletValues.aggregateLockedAmulet.amount.initialAmount - availableAmount + vestedAmulet.aggregateLockedAmulet.amount.initialAmount - availableAmount' + + -- Now try to withdraw after the endDate has passed (the full should be withdrawable) + passTime (days 10) + + vestedWithdrawResult'' <- submit (actAs alice.primaryParty) $ + exerciseCmd vestedAmuletCid'' VestedAmulet_WithdrawAvailableUnlockedAmulet with + receiver = alice.primaryParty + openRound = openRound.round + expectedDso = app.dso + + let mVestedAmulet = mVestedAmuletCid vestedWithdrawResult'' + receiverAmulet'' <- fromSome <$> queryContractId alice.primaryParty (receiverAmuletCid vestedWithdrawResult'') + + updatedNow' <- getTime + let availableAmount'' = calculateAvailableWithdrawAmount updatedNow' vestedAmulet' + + -- Receiver amulet should receive last bit, while there should be no more vestedAmulet + assertEq receiverAmulet''.amount.initialAmount availableAmount'' + assertEq mVestedAmulet None + + -- Make sure all receiving Amulets match up to original Amulet values + assertEq initialVestedAmuletValues.aggregateLockedAmulet.amount.initialAmount $ + receiverAmulet.amount.initialAmount + + receiverAmulet'.amount.initialAmount + + receiverAmulet''.amount.initialAmount - debug receiverAmulet' - debug vestedAmulet' - debug "lol" - debug vestedWithdrawResult - debug "lul2" - assertBeforeMsg "8ooof" (addRelTime now $ days 1) pure () From b3ca1a8596d46961674877782ec42cc8fcc3a6aa Mon Sep 17 00:00:00 2001 From: "deepak.birdi" Date: Mon, 6 Jul 2026 17:55:45 +0000 Subject: [PATCH 09/15] Allow receiver to be other parties as well for vestedAmulet_unlock --- .../daml/Splice/Scripts/TestLockedAmulet.daml | 11 ++++++++--- daml/splice-amulet/daml/Splice/Amulet.daml | 3 ++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/daml/splice-amulet-test/daml/Splice/Scripts/TestLockedAmulet.daml b/daml/splice-amulet-test/daml/Splice/Scripts/TestLockedAmulet.daml index ede5b40efa..1105e099d9 100644 --- a/daml/splice-amulet-test/daml/Splice/Scripts/TestLockedAmulet.daml +++ b/daml/splice-amulet-test/daml/Splice/Scripts/TestLockedAmulet.daml @@ -92,6 +92,7 @@ testVestingAmuletUnlock = do -- eligible withdraw amount being zero (no time has passed since locking yet) submitMustFail (actAs alice.primaryParty) $ exerciseCmd vestedAmuletCid VestedAmulet_WithdrawAvailableUnlockedAmulet with + authorizers = [alice.primaryParty] receiver = alice.primaryParty openRound = openRound.round expectedDso = app.dso @@ -99,14 +100,15 @@ testVestingAmuletUnlock = do let totalTimePassed = days 10 passTime totalTimePassed - vestedWithdrawResult <- submit (actAs alice.primaryParty) $ + vestedWithdrawResult <- submit (actAs alice.primaryParty <> actAs bob.primaryParty) $ exerciseCmd vestedAmuletCid VestedAmulet_WithdrawAvailableUnlockedAmulet with - receiver = alice.primaryParty + authorizers = [alice.primaryParty, bob.primaryParty] + receiver = bob.primaryParty openRound = openRound.round expectedDso = app.dso let vestedAmuletCid' = fromSome $ mVestedAmuletCid vestedWithdrawResult - receiverAmulet <- fromSome <$> queryContractId alice.primaryParty (receiverAmuletCid vestedWithdrawResult) + receiverAmulet <- fromSome <$> queryContractId bob.primaryParty (receiverAmuletCid vestedWithdrawResult) vestedAmulet <- fromSome <$> queryContractId alice.primaryParty vestedAmuletCid' updatedNow <- getTime @@ -119,6 +121,7 @@ testVestingAmuletUnlock = do -- Try withdraw again, right after previous withdraw (should fail) submitMustFail (actAs alice.primaryParty) $ exerciseCmd vestedAmuletCid' VestedAmulet_WithdrawAvailableUnlockedAmulet with + authorizers = [alice.primaryParty] receiver = alice.primaryParty openRound = openRound.round expectedDso = app.dso @@ -128,6 +131,7 @@ testVestingAmuletUnlock = do vestedWithdrawResult' <- submit (actAs alice.primaryParty) $ exerciseCmd vestedAmuletCid' VestedAmulet_WithdrawAvailableUnlockedAmulet with + authorizers = [alice.primaryParty] receiver = alice.primaryParty openRound = openRound.round expectedDso = app.dso @@ -149,6 +153,7 @@ testVestingAmuletUnlock = do vestedWithdrawResult'' <- submit (actAs alice.primaryParty) $ exerciseCmd vestedAmuletCid'' VestedAmulet_WithdrawAvailableUnlockedAmulet with + authorizers = [alice.primaryParty] receiver = alice.primaryParty openRound = openRound.round expectedDso = app.dso diff --git a/daml/splice-amulet/daml/Splice/Amulet.daml b/daml/splice-amulet/daml/Splice/Amulet.daml index 31dcf153ca..9cdff4ac7e 100644 --- a/daml/splice-amulet/daml/Splice/Amulet.daml +++ b/daml/splice-amulet/daml/Splice/Amulet.daml @@ -712,13 +712,14 @@ template VestedAmulet choice VestedAmulet_WithdrawAvailableUnlockedAmulet : VestedAmulet_WithdrawResult with + authorizers : [Party] receiver : Party openRound : Round expectedDso : Party -- Party that the sender expects to represent the DSO party of the AmuletRules contract they are calling. -- Must always be set to protect from malicious delegees swapping the AmuletRules contract out for -- one under their control. - controller aggregateLockedAmulet.owner + controller authorizers do flip require ((show aggregateLockedAmulet.dso) == (show expectedDso)) $ T.implode [ From 5dd28b94fc0a4b33700ae497ad143248f344abd1 Mon Sep 17 00:00:00 2001 From: "deepak.birdi" Date: Mon, 6 Jul 2026 19:59:10 +0000 Subject: [PATCH 10/15] Remove any notion of receiver from VestedAmulet workflow --- .../daml/Splice/Scripts/TestLockedAmulet.daml | 34 +++++++------------ daml/splice-amulet/daml/Splice/Amulet.daml | 16 ++++----- 2 files changed, 19 insertions(+), 31 deletions(-) diff --git a/daml/splice-amulet-test/daml/Splice/Scripts/TestLockedAmulet.daml b/daml/splice-amulet-test/daml/Splice/Scripts/TestLockedAmulet.daml index 1105e099d9..06ad95fcd9 100644 --- a/daml/splice-amulet-test/daml/Splice/Scripts/TestLockedAmulet.daml +++ b/daml/splice-amulet-test/daml/Splice/Scripts/TestLockedAmulet.daml @@ -92,37 +92,31 @@ testVestingAmuletUnlock = do -- eligible withdraw amount being zero (no time has passed since locking yet) submitMustFail (actAs alice.primaryParty) $ exerciseCmd vestedAmuletCid VestedAmulet_WithdrawAvailableUnlockedAmulet with - authorizers = [alice.primaryParty] - receiver = alice.primaryParty openRound = openRound.round expectedDso = app.dso let totalTimePassed = days 10 passTime totalTimePassed - vestedWithdrawResult <- submit (actAs alice.primaryParty <> actAs bob.primaryParty) $ + vestedWithdrawResult <- submit (actAs alice.primaryParty) $ exerciseCmd vestedAmuletCid VestedAmulet_WithdrawAvailableUnlockedAmulet with - authorizers = [alice.primaryParty, bob.primaryParty] - receiver = bob.primaryParty openRound = openRound.round expectedDso = app.dso let vestedAmuletCid' = fromSome $ mVestedAmuletCid vestedWithdrawResult - receiverAmulet <- fromSome <$> queryContractId bob.primaryParty (receiverAmuletCid vestedWithdrawResult) + unlockedAmulet <- fromSome <$> queryContractId alice.primaryParty (unlockedAmuletCid vestedWithdrawResult) vestedAmulet <- fromSome <$> queryContractId alice.primaryParty vestedAmuletCid' updatedNow <- getTime let availableAmount = calculateAvailableWithdrawAmount updatedNow initialVestedAmuletValues - assertEq receiverAmulet.amount.initialAmount availableAmount + assertEq unlockedAmulet.amount.initialAmount availableAmount assertEq vestedAmulet.aggregateLockedAmulet.amount.initialAmount $ initialVestedAmuletValues.aggregateLockedAmulet.amount.initialAmount - availableAmount -- Try withdraw again, right after previous withdraw (should fail) submitMustFail (actAs alice.primaryParty) $ exerciseCmd vestedAmuletCid' VestedAmulet_WithdrawAvailableUnlockedAmulet with - authorizers = [alice.primaryParty] - receiver = alice.primaryParty openRound = openRound.round expectedDso = app.dso @@ -131,20 +125,18 @@ testVestingAmuletUnlock = do vestedWithdrawResult' <- submit (actAs alice.primaryParty) $ exerciseCmd vestedAmuletCid' VestedAmulet_WithdrawAvailableUnlockedAmulet with - authorizers = [alice.primaryParty] - receiver = alice.primaryParty openRound = openRound.round expectedDso = app.dso let vestedAmuletCid'' = fromSome $ mVestedAmuletCid vestedWithdrawResult' - receiverAmulet' <- fromSome <$> queryContractId alice.primaryParty (receiverAmuletCid vestedWithdrawResult') + unlockedAmulet' <- fromSome <$> queryContractId alice.primaryParty (unlockedAmuletCid vestedWithdrawResult') vestedAmulet' <- fromSome <$> queryContractId alice.primaryParty vestedAmuletCid'' updatedNow <- getTime let availableAmount' = calculateAvailableWithdrawAmount updatedNow vestedAmulet - -- Receiver amulet and vested amulet should both mathing right - assertEq receiverAmulet'.amount.initialAmount availableAmount' + -- Unlocked amulet and vested amulet should both mathing right + assertEq unlockedAmulet'.amount.initialAmount availableAmount' assertEq vestedAmulet'.aggregateLockedAmulet.amount.initialAmount $ vestedAmulet.aggregateLockedAmulet.amount.initialAmount - availableAmount' @@ -153,25 +145,23 @@ testVestingAmuletUnlock = do vestedWithdrawResult'' <- submit (actAs alice.primaryParty) $ exerciseCmd vestedAmuletCid'' VestedAmulet_WithdrawAvailableUnlockedAmulet with - authorizers = [alice.primaryParty] - receiver = alice.primaryParty openRound = openRound.round expectedDso = app.dso let mVestedAmulet = mVestedAmuletCid vestedWithdrawResult'' - receiverAmulet'' <- fromSome <$> queryContractId alice.primaryParty (receiverAmuletCid vestedWithdrawResult'') + unlockedAmulet'' <- fromSome <$> queryContractId alice.primaryParty (unlockedAmuletCid vestedWithdrawResult'') updatedNow' <- getTime let availableAmount'' = calculateAvailableWithdrawAmount updatedNow' vestedAmulet' - -- Receiver amulet should receive last bit, while there should be no more vestedAmulet - assertEq receiverAmulet''.amount.initialAmount availableAmount'' + -- Unlocked amulet should receive last bit, while there should be no more vestedAmulet + assertEq unlockedAmulet''.amount.initialAmount availableAmount'' assertEq mVestedAmulet None -- Make sure all receiving Amulets match up to original Amulet values assertEq initialVestedAmuletValues.aggregateLockedAmulet.amount.initialAmount $ - receiverAmulet.amount.initialAmount - + receiverAmulet'.amount.initialAmount - + receiverAmulet''.amount.initialAmount + unlockedAmulet.amount.initialAmount + + unlockedAmulet'.amount.initialAmount + + unlockedAmulet''.amount.initialAmount pure () diff --git a/daml/splice-amulet/daml/Splice/Amulet.daml b/daml/splice-amulet/daml/Splice/Amulet.daml index 9cdff4ac7e..eb35e3406a 100644 --- a/daml/splice-amulet/daml/Splice/Amulet.daml +++ b/daml/splice-amulet/daml/Splice/Amulet.daml @@ -124,8 +124,8 @@ data DevelopmentFundCoupon_DsoExpireResult = DevelopmentFundCoupon_DsoExpireResu data VestedAmulet_WithdrawResult = VestedAmulet_WithdrawResult with - receiverAmuletCid : ContractId Amulet -- ^ References new amulet created from the vested - -- amulet's unlocked withdrawn amount, with the owner being the receiver passed in. + unlockedAmuletCid : ContractId Amulet -- ^ References new amulet created from the vested + -- amulet's unlocked withdrawn amount. mVestedAmuletCid : Optional (ContractId VestedAmulet) -- ^ Optional reference to the vested amulet if any -- locked aggregate amount remains. None if all of the amount is has already been withdrawn. deriving (Show, Eq) @@ -712,14 +712,12 @@ template VestedAmulet choice VestedAmulet_WithdrawAvailableUnlockedAmulet : VestedAmulet_WithdrawResult with - authorizers : [Party] - receiver : Party openRound : Round expectedDso : Party -- Party that the sender expects to represent the DSO party of the AmuletRules contract they are calling. -- Must always be set to protect from malicious delegees swapping the AmuletRules contract out for -- one under their control. - controller authorizers + controller aggregateLockedAmulet.owner do flip require ((show aggregateLockedAmulet.dso) == (show expectedDso)) $ T.implode [ @@ -739,10 +737,10 @@ template VestedAmulet ) (currentEligibleWithdrawableAmount > 0.0) - -- Amulet for Receiver for Unlocked Amount of CC - receiverAmulet <- create Amulet with + -- Amulet for Unlocked Amount of CC + unlockedAmulet <- create Amulet with dso = expectedDso - owner = receiver + owner = aggregateLockedAmulet.owner amount = ExpiringAmount with initialAmount = currentEligibleWithdrawableAmount createdAt = openRound @@ -765,4 +763,4 @@ template VestedAmulet ratePerRound = aggregateLockedAmulet.amount.ratePerRound else return None - return $ VestedAmulet_WithdrawResult receiverAmulet vestedAmulet + return $ VestedAmulet_WithdrawResult unlockedAmulet vestedAmulet From 1d9d41727a7dea53315b100e095291d08e662392 Mon Sep 17 00:00:00 2001 From: "deepak.birdi" Date: Tue, 7 Jul 2026 18:11:46 +0000 Subject: [PATCH 11/15] WIP Move VestedAmulet data to appropriate places and add FR-34 requirements --- .../Splice/Scripts/TestAggregateLocks.daml | 115 ++++++++++++++++++ .../daml/Splice/Scripts/TestLockedAmulet.daml | 100 --------------- .../daml/Splice/AggregateLock.daml | 1 - daml/splice-amulet/daml/Splice/Amulet.daml | 87 ------------- 4 files changed, 115 insertions(+), 188 deletions(-) diff --git a/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml b/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml index b941da06e3..40aae592af 100644 --- a/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml +++ b/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml @@ -7,6 +7,7 @@ import Splice.Amulet import Splice.AggregateLock import Splice.AmuletRules import Splice.Expiry +import Splice.VestedAmulet import Splice.Scripts.Util import DA.Assert @@ -131,3 +132,117 @@ dsoAggregateAutomation dso round = do sequence_ onChainUpdates pure () + +testVestingAmuletUnlock : Script () +testVestingAmuletUnlock = do + DefaultAppWithUsers{..} <- setupDefaultAppWithUsers + now <- getTime + + amuletCid <- tap app alice 3652.5 + Some(amulet) <- queryContractId @Amulet alice.primaryParty amuletCid + (_, openRound) <- getLatestOpenRound app + + let initialVestedAmuletValues : VestedAmulet = VestedAmulet { aggregateLockedAmulet = amulet + , startDate = now + , endDate = (addRelTime now $ (days 365) + (hours 6)) + , withdrawnAmount = 0.0 + , unlockControllerSets = [[alice.primaryParty], [bob.primaryParty]] + } + + vestedAmuletCid <- submit (actAs alice.primaryParty <> actAs app.dso) $ + createCmd VestedAmulet with + aggregateLockedAmulet = initialVestedAmuletValues.aggregateLockedAmulet + startDate = initialVestedAmuletValues.startDate + endDate = initialVestedAmuletValues.endDate + withdrawnAmount = initialVestedAmuletValues.withdrawnAmount + unlockControllerSets = initialVestedAmuletValues.unlockControllerSets + + -- Immediate withdraw attempt causes transaction to fail due to the current + -- eligible withdraw amount being zero (no time has passed since locking yet) + submitMustFail (actAs alice.primaryParty) $ + exerciseCmd vestedAmuletCid VestedAmulet_WithdrawAvailableUnlockedAmulet with + authorizers = [alice.primaryParty] + openRound = openRound.round + expectedDso = app.dso + + -- Unlocking with the wrong authorizer should fail + submitMustFail (actAs alice.primaryParty <> actAs charlie.primaryParty) $ + exerciseCmd vestedAmuletCid VestedAmulet_WithdrawAvailableUnlockedAmulet with + authorizers = [charlie.primaryParty] + openRound = openRound.round + expectedDso = app.dso + + let totalTimePassed = days 10 + passTime totalTimePassed + + vestedWithdrawResult <- submit (actAs alice.primaryParty) $ + exerciseCmd vestedAmuletCid VestedAmulet_WithdrawAvailableUnlockedAmulet with + authorizers = [alice.primaryParty] + openRound = openRound.round + expectedDso = app.dso + + let vestedAmuletCid' = fromSome $ mVestedAmuletCid vestedWithdrawResult + unlockedAmulet <- fromSome <$> queryContractId alice.primaryParty (unlockedAmuletCid vestedWithdrawResult) + vestedAmulet <- fromSome <$> queryContractId alice.primaryParty vestedAmuletCid' + + updatedNow <- getTime + let availableAmount = calculateAvailableWithdrawAmount updatedNow initialVestedAmuletValues + + assertEq unlockedAmulet.amount.initialAmount availableAmount + assertEq vestedAmulet.aggregateLockedAmulet.amount.initialAmount $ + initialVestedAmuletValues.aggregateLockedAmulet.amount.initialAmount - availableAmount + + -- Try withdraw again, right after previous withdraw (should fail) + submitMustFail (actAs alice.primaryParty) $ + exerciseCmd vestedAmuletCid' VestedAmulet_WithdrawAvailableUnlockedAmulet with + authorizers = [alice.primaryParty] + openRound = openRound.round + expectedDso = app.dso + + -- Let's try to withdraw right before the full amount is available + passTime ((days 364 + hours 5 + minutes 59 + seconds 59 + milliseconds 59 + microseconds 59) - totalTimePassed) + + vestedWithdrawResult' <- submit (actAs alice.primaryParty <> actAs bob.primaryParty) $ + exerciseCmd vestedAmuletCid' VestedAmulet_WithdrawAvailableUnlockedAmulet with + authorizers = [bob.primaryParty] + openRound = openRound.round + expectedDso = app.dso + + let vestedAmuletCid'' = fromSome $ mVestedAmuletCid vestedWithdrawResult' + unlockedAmulet' <- fromSome <$> queryContractId alice.primaryParty (unlockedAmuletCid vestedWithdrawResult') + vestedAmulet' <- fromSome <$> queryContractId alice.primaryParty vestedAmuletCid'' + + updatedNow <- getTime + let availableAmount' = calculateAvailableWithdrawAmount updatedNow vestedAmulet + + -- Unlocked amulet and vested amulet should both mathing right + assertEq unlockedAmulet'.amount.initialAmount availableAmount' + assertEq vestedAmulet'.aggregateLockedAmulet.amount.initialAmount $ + vestedAmulet.aggregateLockedAmulet.amount.initialAmount - availableAmount' + + -- Now try to withdraw after the endDate has passed (the full should be withdrawable) + passTime (days 10) + + vestedWithdrawResult'' <- submit (actAs alice.primaryParty) $ + exerciseCmd vestedAmuletCid'' VestedAmulet_WithdrawAvailableUnlockedAmulet with + authorizers = [alice.primaryParty] + openRound = openRound.round + expectedDso = app.dso + + let mVestedAmulet = mVestedAmuletCid vestedWithdrawResult'' + unlockedAmulet'' <- fromSome <$> queryContractId alice.primaryParty (unlockedAmuletCid vestedWithdrawResult'') + + updatedNow' <- getTime + let availableAmount'' = calculateAvailableWithdrawAmount updatedNow' vestedAmulet' + + -- Unlocked amulet should receive last bit, while there should be no more vestedAmulet + assertEq unlockedAmulet''.amount.initialAmount availableAmount'' + assertEq mVestedAmulet None + + -- Make sure all receiving Amulets match up to original Amulet values + assertEq initialVestedAmuletValues.aggregateLockedAmulet.amount.initialAmount $ + unlockedAmulet.amount.initialAmount + + unlockedAmulet'.amount.initialAmount + + unlockedAmulet''.amount.initialAmount + + pure () diff --git a/daml/splice-amulet-test/daml/Splice/Scripts/TestLockedAmulet.daml b/daml/splice-amulet-test/daml/Splice/Scripts/TestLockedAmulet.daml index 06ad95fcd9..cde0e592db 100644 --- a/daml/splice-amulet-test/daml/Splice/Scripts/TestLockedAmulet.daml +++ b/daml/splice-amulet-test/daml/Splice/Scripts/TestLockedAmulet.daml @@ -65,103 +65,3 @@ testLocking = do exerciseCmd lockedCid2 LockedAmulet_OwnerExpireLockV2 pure () - -testVestingAmuletUnlock : Script () -testVestingAmuletUnlock = do - DefaultAppWithUsers{..} <- setupDefaultAppWithUsers - now <- getTime - - amuletCid <- tap app alice 3652.5 - Some(amulet) <- queryContractId @Amulet alice.primaryParty amuletCid - (_, openRound) <- getLatestOpenRound app - - let initialVestedAmuletValues : VestedAmulet = VestedAmulet { aggregateLockedAmulet = amulet - , startDate = now - , endDate = (addRelTime now $ (days 365) + (hours 6)) - , withdrawnAmount = 0.0 - } - - vestedAmuletCid <- submit (actAs alice.primaryParty <> actAs app.dso) $ - createCmd VestedAmulet with - aggregateLockedAmulet = initialVestedAmuletValues.aggregateLockedAmulet - startDate = initialVestedAmuletValues.startDate - endDate = initialVestedAmuletValues.endDate - withdrawnAmount = initialVestedAmuletValues.withdrawnAmount - - -- Immediate withdraw attempt causes transaction to fail due to the current - -- eligible withdraw amount being zero (no time has passed since locking yet) - submitMustFail (actAs alice.primaryParty) $ - exerciseCmd vestedAmuletCid VestedAmulet_WithdrawAvailableUnlockedAmulet with - openRound = openRound.round - expectedDso = app.dso - - let totalTimePassed = days 10 - passTime totalTimePassed - - vestedWithdrawResult <- submit (actAs alice.primaryParty) $ - exerciseCmd vestedAmuletCid VestedAmulet_WithdrawAvailableUnlockedAmulet with - openRound = openRound.round - expectedDso = app.dso - - let vestedAmuletCid' = fromSome $ mVestedAmuletCid vestedWithdrawResult - unlockedAmulet <- fromSome <$> queryContractId alice.primaryParty (unlockedAmuletCid vestedWithdrawResult) - vestedAmulet <- fromSome <$> queryContractId alice.primaryParty vestedAmuletCid' - - updatedNow <- getTime - let availableAmount = calculateAvailableWithdrawAmount updatedNow initialVestedAmuletValues - - assertEq unlockedAmulet.amount.initialAmount availableAmount - assertEq vestedAmulet.aggregateLockedAmulet.amount.initialAmount $ - initialVestedAmuletValues.aggregateLockedAmulet.amount.initialAmount - availableAmount - - -- Try withdraw again, right after previous withdraw (should fail) - submitMustFail (actAs alice.primaryParty) $ - exerciseCmd vestedAmuletCid' VestedAmulet_WithdrawAvailableUnlockedAmulet with - openRound = openRound.round - expectedDso = app.dso - - -- Let's try to withdraw right before the full amount is available - passTime ((days 364 + hours 5 + minutes 59 + seconds 59 + milliseconds 59 + microseconds 59) - totalTimePassed) - - vestedWithdrawResult' <- submit (actAs alice.primaryParty) $ - exerciseCmd vestedAmuletCid' VestedAmulet_WithdrawAvailableUnlockedAmulet with - openRound = openRound.round - expectedDso = app.dso - - let vestedAmuletCid'' = fromSome $ mVestedAmuletCid vestedWithdrawResult' - unlockedAmulet' <- fromSome <$> queryContractId alice.primaryParty (unlockedAmuletCid vestedWithdrawResult') - vestedAmulet' <- fromSome <$> queryContractId alice.primaryParty vestedAmuletCid'' - - updatedNow <- getTime - let availableAmount' = calculateAvailableWithdrawAmount updatedNow vestedAmulet - - -- Unlocked amulet and vested amulet should both mathing right - assertEq unlockedAmulet'.amount.initialAmount availableAmount' - assertEq vestedAmulet'.aggregateLockedAmulet.amount.initialAmount $ - vestedAmulet.aggregateLockedAmulet.amount.initialAmount - availableAmount' - - -- Now try to withdraw after the endDate has passed (the full should be withdrawable) - passTime (days 10) - - vestedWithdrawResult'' <- submit (actAs alice.primaryParty) $ - exerciseCmd vestedAmuletCid'' VestedAmulet_WithdrawAvailableUnlockedAmulet with - openRound = openRound.round - expectedDso = app.dso - - let mVestedAmulet = mVestedAmuletCid vestedWithdrawResult'' - unlockedAmulet'' <- fromSome <$> queryContractId alice.primaryParty (unlockedAmuletCid vestedWithdrawResult'') - - updatedNow' <- getTime - let availableAmount'' = calculateAvailableWithdrawAmount updatedNow' vestedAmulet' - - -- Unlocked amulet should receive last bit, while there should be no more vestedAmulet - assertEq unlockedAmulet''.amount.initialAmount availableAmount'' - assertEq mVestedAmulet None - - -- Make sure all receiving Amulets match up to original Amulet values - assertEq initialVestedAmuletValues.aggregateLockedAmulet.amount.initialAmount $ - unlockedAmulet.amount.initialAmount - + unlockedAmulet'.amount.initialAmount - + unlockedAmulet''.amount.initialAmount - - pure () diff --git a/daml/splice-amulet/daml/Splice/AggregateLock.daml b/daml/splice-amulet/daml/Splice/AggregateLock.daml index 636c46aa3d..3ec40df99a 100644 --- a/daml/splice-amulet/daml/Splice/AggregateLock.daml +++ b/daml/splice-amulet/daml/Splice/AggregateLock.daml @@ -2,7 +2,6 @@ module Splice.AggregateLock where import Splice.Amulet import DA.Time -import DA.Action import DA.Foldable (forA_) diff --git a/daml/splice-amulet/daml/Splice/Amulet.daml b/daml/splice-amulet/daml/Splice/Amulet.daml index eb35e3406a..16b918ba83 100644 --- a/daml/splice-amulet/daml/Splice/Amulet.daml +++ b/daml/splice-amulet/daml/Splice/Amulet.daml @@ -10,10 +10,7 @@ import DA.Assert import DA.List (unique) import DA.Map as Map import DA.TextMap as TextMap -import qualified DA.Text as T (implode) import DA.Optional (fromOptional, isNone, optionalToList) -import DA.Time -import DA.Numeric import Splice.Api.Token.MetadataV1 qualified as Api.Token.MetadataV1 import Splice.Api.Token.HoldingV1 qualified as Api.Token.HoldingV1 @@ -121,15 +118,6 @@ data DevelopmentFundCoupon_RejectResult = DevelopmentFundCoupon_RejectResult wit data DevelopmentFundCoupon_DsoExpireResult = DevelopmentFundCoupon_DsoExpireResult with unclaimedDevelopmentFundCouponCid : ContractId UnclaimedDevelopmentFundCoupon -data VestedAmulet_WithdrawResult - = VestedAmulet_WithdrawResult - with - unlockedAmuletCid : ContractId Amulet -- ^ References new amulet created from the vested - -- amulet's unlocked withdrawn amount. - mVestedAmuletCid : Optional (ContractId VestedAmulet) -- ^ Optional reference to the vested amulet if any - -- locked aggregate amount remains. None if all of the amount is has already been withdrawn. - deriving (Show, Eq) - -- | A amulet, which can be locked and whose amount expires over time. -- -- The expiry serves to charge an inactivity fee, and thereby ensures that the @@ -689,78 +677,3 @@ instance HasCheckedFetch DevelopmentFundCoupon ForOwner where instance HasCheckedFetch DevelopmentFundCoupon ForDso where contractGroupId DevelopmentFundCoupon{..} = ForDso with dso - -calculateAvailableWithdrawAmount : Time -> VestedAmulet -> Decimal -calculateAvailableWithdrawAmount currentDateTime VestedAmulet{..} = let - totalVestedAmulet : Decimal = castAndRound $ aggregateLockedAmulet.amount.initialAmount + withdrawnAmount - in if currentDateTime >= endDate - then (castAndRound totalVestedAmulet) - withdrawnAmount - else - let totalUnlockPeriod = intToDecimal . convertRelTimeToMicroseconds $ subTime endDate startDate - unlockPeriodElapsed = intToDecimal . convertRelTimeToMicroseconds $ subTime currentDateTime startDate - totalVestedPercentageElapsed : Decimal = div unlockPeriodElapsed totalUnlockPeriod - in (castAndRound $ totalVestedAmulet * totalVestedPercentageElapsed) - withdrawnAmount - -template VestedAmulet - with - aggregateLockedAmulet : Amulet - startDate : Time - endDate : Time - withdrawnAmount : Decimal - where - signatory aggregateLockedAmulet.dso, aggregateLockedAmulet.owner - - choice VestedAmulet_WithdrawAvailableUnlockedAmulet : VestedAmulet_WithdrawResult - with - openRound : Round - expectedDso : Party - -- Party that the sender expects to represent the DSO party of the AmuletRules contract they are calling. - -- Must always be set to protect from malicious delegees swapping the AmuletRules contract out for - -- one under their control. - controller aggregateLockedAmulet.owner - do - flip require ((show aggregateLockedAmulet.dso) == (show expectedDso)) $ - T.implode [ - "DSO party expected by caller " - , show expectedDso - , " does not match actual DSO party " - , show aggregateLockedAmulet.dso - ] - - now <- getTime - let currentEligibleWithdrawableAmount : Decimal = calculateAvailableWithdrawAmount now this - - require ("Current eligible withdraw amount for vested amulet is not greater than 0.0. " - <> "currentEligibleWithdrawableAmount came back = '" - <> show currentEligibleWithdrawableAmount - <> "'." - ) - (currentEligibleWithdrawableAmount > 0.0) - - -- Amulet for Unlocked Amount of CC - unlockedAmulet <- create Amulet with - dso = expectedDso - owner = aggregateLockedAmulet.owner - amount = ExpiringAmount with - initialAmount = currentEligibleWithdrawableAmount - createdAt = openRound - ratePerRound = aggregateLockedAmulet.amount.ratePerRound - - let remainingAggregateLockedAmount = aggregateLockedAmulet.amount.initialAmount - currentEligibleWithdrawableAmount - - -- Vested Amulet in case any amount remains locked in aggregateLockedAmulet - vestedAmulet <- if remainingAggregateLockedAmount > 0.0 then do - Some <$> create VestedAmulet with - startDate - endDate - withdrawnAmount = withdrawnAmount + currentEligibleWithdrawableAmount - aggregateLockedAmulet = Amulet with - dso = expectedDso - owner = aggregateLockedAmulet.owner - amount = ExpiringAmount with - initialAmount = remainingAggregateLockedAmount - createdAt = openRound - ratePerRound = aggregateLockedAmulet.amount.ratePerRound - else return None - - return $ VestedAmulet_WithdrawResult unlockedAmulet vestedAmulet From 4df3abd590e2f7e18af0a1602724306c9cb58e27 Mon Sep 17 00:00:00 2001 From: "deepak.birdi" Date: Tue, 7 Jul 2026 18:14:46 +0000 Subject: [PATCH 12/15] Add new vestedAmulet daml file --- .../daml/Splice/VestedAmulet.daml | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 daml/splice-amulet/daml/Splice/VestedAmulet.daml diff --git a/daml/splice-amulet/daml/Splice/VestedAmulet.daml b/daml/splice-amulet/daml/Splice/VestedAmulet.daml new file mode 100644 index 0000000000..4225b0c806 --- /dev/null +++ b/daml/splice-amulet/daml/Splice/VestedAmulet.daml @@ -0,0 +1,107 @@ +module Splice.VestedAmulet where + +import qualified DA.Text as T (implode) +import DA.Time +import DA.Numeric + +import Splice.Amulet +import Splice.AggregateLock (ControllerSets) +import Splice.Fees +import Splice.Types +import Splice.Util + +data VestedAmulet_WithdrawResult + = VestedAmulet_WithdrawResult + with + unlockedAmuletCid : ContractId Amulet -- ^ References new amulet created from the vested + -- amulet's unlocked withdrawn amount. + mVestedAmuletCid : Optional (ContractId VestedAmulet) -- ^ Optional reference to the vested amulet if any + -- locked aggregate amount remains. None if all of the amount is has already been withdrawn. + deriving (Show, Eq) + +calculateAvailableWithdrawAmount : Time -> VestedAmulet -> Decimal +calculateAvailableWithdrawAmount currentDateTime VestedAmulet{..} = let + totalVestedAmulet : Decimal = castAndRound $ aggregateLockedAmulet.amount.initialAmount + withdrawnAmount + in if currentDateTime >= endDate + then (castAndRound totalVestedAmulet) - withdrawnAmount + else + let totalUnlockPeriod = intToDecimal . convertRelTimeToMicroseconds $ subTime endDate startDate + unlockPeriodElapsed = intToDecimal . convertRelTimeToMicroseconds $ subTime currentDateTime startDate + totalVestedPercentageElapsed : Decimal = div unlockPeriodElapsed totalUnlockPeriod + in (castAndRound $ totalVestedAmulet * totalVestedPercentageElapsed) - withdrawnAmount + +template VestedAmulet + with + aggregateLockedAmulet : Amulet + startDate : Time + endDate : Time + withdrawnAmount : Decimal + unlockControllerSets : ControllerSets + where + signatory aggregateLockedAmulet.dso, aggregateLockedAmulet.owner + + choice VestedAmulet_WithdrawAvailableUnlockedAmulet : VestedAmulet_WithdrawResult + with + authorizers : [Party] + openRound : Round + expectedDso : Party + -- Party that the sender expects to represent the DSO party of the AmuletRules contract they are calling. + -- Must always be set to protect from malicious delegees swapping the AmuletRules contract out for + -- one under their control. + controller authorizers + do + flip require (authorizers `elem` unlockControllerSets) $ + T.implode [ + "Authorizers: '" + , show authorizers + , "', does not match any allowed unlock configurations: '" + , show unlockControllerSets + , "'." + ] + + flip require ((show aggregateLockedAmulet.dso) == (show expectedDso)) $ + T.implode [ + "DSO party expected by caller " + , show expectedDso + , " does not match actual DSO party " + , show aggregateLockedAmulet.dso + ] + + now <- getTime + let currentEligibleWithdrawableAmount : Decimal = calculateAvailableWithdrawAmount now this + + require ("Current eligible withdraw amount for vested amulet is not greater than 0.0. " + <> "currentEligibleWithdrawableAmount came back = '" + <> show currentEligibleWithdrawableAmount + <> "'." + ) + (currentEligibleWithdrawableAmount > 0.0) + + -- Amulet for Unlocked Amount of CC + unlockedAmulet <- create Amulet with + dso = expectedDso + owner = aggregateLockedAmulet.owner + amount = ExpiringAmount with + initialAmount = currentEligibleWithdrawableAmount + createdAt = openRound + ratePerRound = aggregateLockedAmulet.amount.ratePerRound + + let remainingAggregateLockedAmount = aggregateLockedAmulet.amount.initialAmount - currentEligibleWithdrawableAmount + + -- Vested Amulet in case any amount remains locked in aggregateLockedAmulet + vestedAmulet <- if remainingAggregateLockedAmount > 0.0 then do + Some <$> create VestedAmulet with + startDate + endDate + unlockControllerSets = unlockControllerSets + withdrawnAmount = withdrawnAmount + currentEligibleWithdrawableAmount + aggregateLockedAmulet = Amulet with + dso = expectedDso + owner = aggregateLockedAmulet.owner + amount = ExpiringAmount with + initialAmount = remainingAggregateLockedAmount + createdAt = openRound + ratePerRound = aggregateLockedAmulet.amount.ratePerRound + else return None + + return $ VestedAmulet_WithdrawResult unlockedAmulet vestedAmulet From 6126cfd81cdd6467f88f51eed9fff38588880054 Mon Sep 17 00:00:00 2001 From: "Jonathan D.K. Gibbons" Date: Tue, 7 Jul 2026 19:52:29 +0000 Subject: [PATCH 13/15] Integrate vesting and aggregate templates. --- .../daml/Splice/AggregateLock.daml | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/daml/splice-amulet/daml/Splice/AggregateLock.daml b/daml/splice-amulet/daml/Splice/AggregateLock.daml index 3ec40df99a..fd5f7db97e 100644 --- a/daml/splice-amulet/daml/Splice/AggregateLock.daml +++ b/daml/splice-amulet/daml/Splice/AggregateLock.daml @@ -49,7 +49,7 @@ data AggregateLock_AllowImmediateUnlocksResult = AggregateLock_AllowImmediateUnl data AggregateLockedAmulet_UnlockResult = AggregateLockedAmulet_UnlockResult_Vesting with - vesting : ContractId VestingLockedAmulet + vesting : ContractId VestedAmulet locked : ContractId AggregateLockedAmulet | AggregateLockedAmulet_UnlockResult_Immediate with unlocked : ContractId Amulet @@ -88,12 +88,13 @@ template AggregateLockedAmulet else return () case lockParameters.vestingSchedule of Some schedule -> do - vested <- create VestingLockedAmulet with - start = now - period = schedule.period - amulet = amulet with + vested <- create VestedAmulet with + startDate = now + endDate = addRelTime now schedule.period + aggregateLockedAmulet = amulet with amount = amulet.amount with initialAmount = amount + withdrawnAmount = 0.0 locked <- create this with amulet = amulet with amount = amulet.amount with @@ -109,13 +110,6 @@ template AggregateLockedAmulet initialAmount = remainder return $ AggregateLockedAmulet_UnlockResult_Immediate unlocked locked -template VestingLockedAmulet with - start : Time - period : RelTime - amulet : Amulet - where - signatory amulet.dso - {- choice AggregateLockedAmulet_Substitute : AggregateLockedAmulet_Substitute with From 0f9709c71744a6eb78b9f402172b59e579cff05f Mon Sep 17 00:00:00 2001 From: "deepak.birdi" Date: Wed, 8 Jul 2026 19:56:23 +0000 Subject: [PATCH 14/15] Add initial AggregateLock to VestedAmulet testing --- .../Splice/Scripts/TestAggregateLocks.daml | 112 +++++++++++++++++- .../daml/Splice/Scripts/TestLockedAmulet.daml | 3 - .../daml/Splice/AggregateLock.daml | 7 +- daml/splice-amulet/daml/Splice/Types.daml | 2 + .../daml/Splice/VestedAmulet.daml | 1 - 5 files changed, 114 insertions(+), 11 deletions(-) diff --git a/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml b/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml index 40aae592af..4e827ecba1 100644 --- a/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml +++ b/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml @@ -100,7 +100,6 @@ testAggregateLocks = do pure () --- dsoAggregateAutomation :: dsoAggregateAutomation : Party -> Int -> Script () dsoAggregateAutomation dso round = do lockedHoldings <- query @AggregateLockedAmulet dso @@ -133,6 +132,8 @@ dsoAggregateAutomation dso round = do pure () + +-- Test general VestedAmulet unlcok and edge cases testVestingAmuletUnlock : Script () testVestingAmuletUnlock = do DefaultAppWithUsers{..} <- setupDefaultAppWithUsers @@ -149,7 +150,7 @@ testVestingAmuletUnlock = do , unlockControllerSets = [[alice.primaryParty], [bob.primaryParty]] } - vestedAmuletCid <- submit (actAs alice.primaryParty <> actAs app.dso) $ + vestedAmuletCid <- submit (actAs [alice.primaryParty, app.dso]) $ createCmd VestedAmulet with aggregateLockedAmulet = initialVestedAmuletValues.aggregateLockedAmulet startDate = initialVestedAmuletValues.startDate @@ -166,7 +167,7 @@ testVestingAmuletUnlock = do expectedDso = app.dso -- Unlocking with the wrong authorizer should fail - submitMustFail (actAs alice.primaryParty <> actAs charlie.primaryParty) $ + submitMustFail (actAs [alice.primaryParty, charlie.primaryParty]) $ exerciseCmd vestedAmuletCid VestedAmulet_WithdrawAvailableUnlockedAmulet with authorizers = [charlie.primaryParty] openRound = openRound.round @@ -202,7 +203,7 @@ testVestingAmuletUnlock = do -- Let's try to withdraw right before the full amount is available passTime ((days 364 + hours 5 + minutes 59 + seconds 59 + milliseconds 59 + microseconds 59) - totalTimePassed) - vestedWithdrawResult' <- submit (actAs alice.primaryParty <> actAs bob.primaryParty) $ + vestedWithdrawResult' <- submit (actAs [alice.primaryParty, bob.primaryParty]) $ exerciseCmd vestedAmuletCid' VestedAmulet_WithdrawAvailableUnlockedAmulet with authorizers = [bob.primaryParty] openRound = openRound.round @@ -246,3 +247,106 @@ testVestingAmuletUnlock = do + unlockedAmulet''.amount.initialAmount pure () + +-- Testing being able to use VestedAmulet out of an AggregateLock unlock +testAggregateLockVestingFlow : Script () +testAggregateLockVestingFlow = do + DefaultAppWithUsers{..} <- setupDefaultAppWithUsers + let period = (days 365) + (hours 6) + bobInitialAmount = 1000.0 + + amuletCid <- tap app alice 1000.0 + amuletCid2 <- tap app bob bobInitialAmount + now <- getTime + (_, openRound) <- getLatestOpenRound app + + aggLock <- submit (actAs [alice.primaryParty, app.dso]) $ createCmd AggregateLock with + dso = app.dso + key = AggregateLockKey alice.primaryParty + vestingSchedule = Some $ VestingSchedule with period + + aggTotal <- submit app.dso $ createCmd AggregateTotal with + dso = app.dso + key = AggregateLockKey $ alice.primaryParty + totalAsOf = 0.0 + asOfTime = now + asOfRound = 0 + lowerBound = True + upperBound = True + + let getTotal = do + [(totalContractId, totalContract)] <- query @AggregateTotal alice.primaryParty + (,) totalContractId . fromSome <$> queryDisclosure app.dso totalContractId + + offer <- submit (actAs alice.primaryParty) $ createCmd OpenDelegationOffer with + owner = alice.primaryParty + lock = aggLock + unlockControllerSets = [[alice.primaryParty]] + substituteControllerSets = [[alice.primaryParty]] + + disclosedOfferAlice <- fromSome <$> queryDisclosure alice.primaryParty offer + disclosedLock <- fromSome <$> queryDisclosure alice.primaryParty aggLock + + submit alice.primaryParty $ exerciseCmd offer OpenDelegationOffer_LockHolding with + holder = alice.primaryParty + holding = amuletCid + (AggregateLock_LockHoldingResult lockedAmulet2) <- submit (actAs [bob.primaryParty] <> discloseMany [disclosedOfferAlice, disclosedLock]) $ exerciseCmd offer OpenDelegationOffer_LockHolding with + holder = bob.primaryParty + holding = amuletCid2 + + (total, disclosedTotal) <- getTotal + debug ("total", total) + + unlockedAmulet <- submit (actAs [bob.primaryParty, alice.primaryParty] <> discloseMany [disclosedLock, disclosedTotal]) $ exerciseCmd lockedAmulet2 AggregateLockedAmulet_Unlock with + amount = 500.0 + authorizers = [ bob.primaryParty, alice.primaryParty ] + lock = aggLock + total + debug ("Unlocked", unlockedAmulet) + + -- Once the aggregateLock has unlocked, we can grab the vesting and locked portions + Some(initialVestedAmulet) <- queryContractId bob.primaryParty unlockedAmulet.vesting + Some(lockedAmulet) <- queryContractId @AggregateLockedAmulet bob.primaryParty unlockedAmulet.locked + debug ("vesting", initialVestedAmulet) + debug ("locked", lockedAmulet) + + -- Immediate withdraw attempt causes transaction to fail due to the current + -- eligible withdraw amount being zero (no time has passed since locking yet) + submitMustFail (actAs [bob.primaryParty, alice.primaryParty]) $ + exerciseCmd unlockedAmulet.vesting VestedAmulet_WithdrawAvailableUnlockedAmulet with + authorizers = [bob.primaryParty, alice.primaryParty] + openRound = openRound.round + expectedDso = app.dso + + -- Time needs to pass to be able to withdraw fron the VestedAmulet + passTime (days 10) + + vestedWithdrawResult <- submit (actAs [bob.primaryParty, alice.primaryParty]) $ + exerciseCmd unlockedAmulet.vesting VestedAmulet_WithdrawAvailableUnlockedAmulet with + authorizers = [bob.primaryParty, alice.primaryParty] + openRound = openRound.round + expectedDso = app.dso + debug ("vestedWithdrawResult", vestedWithdrawResult) + + let vestedAmuletCid' = fromSome $ mVestedAmuletCid vestedWithdrawResult + unlockedVestedAmulet <- fromSome <$> queryContractId bob.primaryParty (unlockedAmuletCid vestedWithdrawResult) + vestedAmulet <- fromSome <$> queryContractId bob.primaryParty vestedAmuletCid' + debug ("unlockedVestedAmulet", unlockedVestedAmulet) + debug ("vestedAmulet", vestedAmulet) + + updatedNow <- getTime + let availableAmount = calculateAvailableWithdrawAmount updatedNow initialVestedAmulet + + -- Make sure unlocked/locked vestedAmulet amounts are the expected values + assertEq unlockedVestedAmulet.amount.initialAmount availableAmount + assertEq vestedAmulet.aggregateLockedAmulet.amount.initialAmount $ + initialVestedAmulet.aggregateLockedAmulet.amount.initialAmount - availableAmount + + -- We want to make sure the initial amount is equivalent to + -- all locked/unlocked aggregate/vested amounts + assertEq bobInitialAmount $ + lockedAmulet.amulet.amount.initialAmount + + unlockedVestedAmulet.amount.initialAmount + + vestedAmulet.aggregateLockedAmulet.amount.initialAmount + + pure () diff --git a/daml/splice-amulet-test/daml/Splice/Scripts/TestLockedAmulet.daml b/daml/splice-amulet-test/daml/Splice/Scripts/TestLockedAmulet.daml index cde0e592db..40ce4810c4 100644 --- a/daml/splice-amulet-test/daml/Splice/Scripts/TestLockedAmulet.daml +++ b/daml/splice-amulet-test/daml/Splice/Scripts/TestLockedAmulet.daml @@ -3,8 +3,6 @@ module Splice.Scripts.TestLockedAmulet where -import DA.Assert -import DA.Optional import DA.Time import Daml.Script @@ -13,7 +11,6 @@ import Splice.Amulet.TokenApiUtils import Splice.AmuletRules import Splice.Expiry import Splice.Scripts.Util -import Splice.Fees (ExpiringAmount(..)) testLocking : Script () testLocking = do diff --git a/daml/splice-amulet/daml/Splice/AggregateLock.daml b/daml/splice-amulet/daml/Splice/AggregateLock.daml index fd5f7db97e..16119265b2 100644 --- a/daml/splice-amulet/daml/Splice/AggregateLock.daml +++ b/daml/splice-amulet/daml/Splice/AggregateLock.daml @@ -1,9 +1,11 @@ module Splice.AggregateLock where -import Splice.Amulet import DA.Time import DA.Foldable (forA_) +import Splice.Amulet +import Splice.Types +import Splice.VestedAmulet data AggregateLockKey = AggregateLockKey with owner : Party @@ -13,8 +15,6 @@ data VestingSchedule = VestingSchedule with period : RelTime -- check this type. deriving (Eq, Show) -type ControllerSets = [[Party]] - template AggregateLock with dso : Party @@ -89,6 +89,7 @@ template AggregateLockedAmulet case lockParameters.vestingSchedule of Some schedule -> do vested <- create VestedAmulet with + unlockControllerSets = unlockControllerSets startDate = now endDate = addRelTime now schedule.period aggregateLockedAmulet = amulet with diff --git a/daml/splice-amulet/daml/Splice/Types.daml b/daml/splice-amulet/daml/Splice/Types.daml index 9c976cebd3..906191bfe5 100644 --- a/daml/splice-amulet/daml/Splice/Types.daml +++ b/daml/splice-amulet/daml/Splice/Types.daml @@ -54,3 +54,5 @@ instance HasCheckedFetch TransferInstructionView ForDso where instance HasCheckedFetch AllocationView ForDso where contractGroupId AllocationView {..} = ForDso with dso = allocation.transferLeg.instrumentId.admin + +type ControllerSets = [[Party]] diff --git a/daml/splice-amulet/daml/Splice/VestedAmulet.daml b/daml/splice-amulet/daml/Splice/VestedAmulet.daml index 4225b0c806..32ff214b30 100644 --- a/daml/splice-amulet/daml/Splice/VestedAmulet.daml +++ b/daml/splice-amulet/daml/Splice/VestedAmulet.daml @@ -5,7 +5,6 @@ import DA.Time import DA.Numeric import Splice.Amulet -import Splice.AggregateLock (ControllerSets) import Splice.Fees import Splice.Types import Splice.Util From 8b3122c22dbf13e7bcf5d3649040076cd2447556 Mon Sep 17 00:00:00 2001 From: "deepak.birdi" Date: Wed, 8 Jul 2026 20:52:52 +0000 Subject: [PATCH 15/15] Add dsoAggregationAutomation check to vesting test --- .../daml/Splice/Scripts/TestAggregateLocks.daml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml b/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml index 4e827ecba1..bfb26638d6 100644 --- a/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml +++ b/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml @@ -349,4 +349,6 @@ testAggregateLockVestingFlow = do + unlockedVestedAmulet.amount.initialAmount + vestedAmulet.aggregateLockedAmulet.amount.initialAmount + dsoAggregateAutomation app.dso 1 + pure ()