Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
6c8b68a
WIP of user-of-allocations code
jonored Jul 14, 2026
63a9f45
WIP: Potentially fix some errors/imports
dbzguy56 Jul 14, 2026
082ca6f
WIP correct some more fields and functions
dbzguy56 Jul 14, 2026
dcd3af3
WIP fixing type errors in AggregageLock.daml
dbzguy56 Jul 14, 2026
5b42656
WIP: externally-defined governance lock implementation using V2 alloc…
jonored Jul 15, 2026
101c58b
WIP: Converting Vesting state to use the V2 Allocations
dbzguy56 Jul 15, 2026
cbf22de
WIP Fixing withdrawing from VestingLock choice
dbzguy56 Jul 16, 2026
fd4c8a7
WIP: Test for lock-to-aggregate and unlock, and bug fixes.
jonored Jul 20, 2026
9de20ca
Merge remote-tracking branch 'public/jg/cip-0105-externally-implement…
jonored Jul 20, 2026
29e569e
fix tests
jonored Jul 20, 2026
7e1be84
WIP Start adding VestingLock Testing
dbzguy56 Jul 20, 2026
661fbe5
WIP Flush out vestingUnlock tests more
dbzguy56 Jul 21, 2026
f722c80
Finish basic happy test for vestingUnlock
dbzguy56 Jul 21, 2026
457febe
Move passTime within vesting happy path test
dbzguy56 Jul 21, 2026
973c20c
Merge pull request #3 from obsidiansystems/dpak/cip-105-v2-vesting
dbzguy56 Jul 21, 2026
15dfa70
Add a script which constructs a fake single package for speeding up d…
cgibbard Jul 22, 2026
e4e35f7
Fix some issues related to PR comments for VestingLock
dbzguy56 Jul 22, 2026
2bd6b3d
Merge pull request #4 from obsidiansystems/dpak/cip-105-v2-vesting
dbzguy56 Jul 23, 2026
4b29e95
Address some CIP-0105 PR comments
dbzguy56 Jul 24, 2026
76bf6d1
Merge pull request #5 from obsidiansystems/dpak/cip-105-v2-vesting
cgibbard Jul 27, 2026
c0da78d
Revert "Add a script which constructs a fake single package for speed…
cgibbard Jul 27, 2026
b49c176
Merge remote-tracking branch 'origin/main' into jg/cip-0105-externall…
cgibbard Jul 27, 2026
bde618e
WIP: hook the withdraw-like operations to withdraw.
jonored Jul 28, 2026
0f752f1
Fixes to rebase and update tests
cgibbard Jul 28, 2026
f312c8a
Fix tests and a couple bugs in vesting withdraw.
jonored Jul 28, 2026
d601e0e
Fixes and test updates.
jonored Jul 28, 2026
6dd81b3
Check executors in isValid checks.
jonored Jul 29, 2026
7d5a0ea
Remove commented out AggregateLock template.
jonored Jul 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
289 changes: 289 additions & 0 deletions daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml
Original file line number Diff line number Diff line change
@@ -0,0 +1,289 @@
{-# LANGUAGE ApplicativeDo #-}
module Splice.Scripts.TestAggregateLocks where

import DA.Assert
import DA.Optional
import DA.Time
import qualified DA.Map as M
import qualified DA.TextMap as TM

import Daml.Script

import Splice.AggregateLock
import Splice.Api.Token.AllocationInstructionV2
import Splice.Api.Token.AllocationV2
import Splice.Api.Token.AllocationV2 qualified as V2
import Splice.Api.Token.HoldingV2 qualified as V2
import Splice.Api.Token.MetadataV1
import Splice.Scripts.TokenStandard.TestAmuletTokenStandardTestEnv
import Splice.Testing.Registries.AmuletRegistryV2
import Splice.Testing.Registries.AmuletRegistryV2 qualified as AmuletRegistryV2
import Splice.Testing.TokenStandard.MultiRegistry qualified as MultiRegistry
import Splice.Testing.TokenStandard.RegistryApiV2
import Splice.Testing.TokenStandard.WalletClientV2 qualified as WalletClientV2
import Splice.Testing.Utils
import Splice.TokenStandard.Utils qualified as TSU
import Splice.TokenStandard.Utils.Internal.Conversions (encodeTime)


lockForGovernance : TestEnv -> TM.TextMap Decimal -> Metadata -> Party -> Script AllocationInstructionResult
lockForGovernance (TestEnv {..}) amounts meta party = do
WalletClientV2.allocateV2 registries party lockSettlementInfo lockAllocation
where
lockSettlementInfo = V2.SettlementInfo with
executors = [ instrId.admin ]
id = "AggregateLock"
cid = None
meta = emptyMetadata
lockAllocation = V2.AllocationSpecification with
admin = instrId.admin
authorizer = TSU.basicAccount party
transferLegSides = []
committed = True
nextIterationFunding = Some amounts
settlementDeadline = Some maxComparableTime
meta

lockForAggregate : TestEnv -> Decimal -> Text -> Party -> Script AllocationInstructionResult
lockForAggregate te amount lockName = lockForGovernance te (TM.fromList [(te.instrId.id, amount)]) $ Metadata $ TM.fromList
[ ("cip-105/type", "aggregateLock")
, ("cip-105/for-benefit-of", lockName)
, ("cip-105/vestingSchedule", "SV")
]

-- | Directly create a vesting lock allocation funded with @initialAmount@,
-- bypassing the aggregate-lock unlock path. Used to exercise the vesting
-- withdrawal flow in isolation.
lockForVesting : Decimal -> Time -> TestEnv -> Party -> Script AllocationInstructionResult
lockForVesting initialAmount now te =
lockForGovernance te (TM.fromList [(te.instrId.id, initialAmount)]) $ Metadata $ TM.fromList
[ ("cip-105/type", "vestingLock")
, ("cip-105/vestingLock.startDate", encodeTime now)
, ("cip-105/vestingLock.endDate", encodeTime $ addRelTime now $ days 365 + hours 6)
, ("cip-105/vestingLock.initialAmount", show initialAmount)
]

-- | Enrich the wallet-side withdraw context with the external party config
-- state disclosure, which AmuletAllocationV2's settle path reads when the
-- governance-lock withdraw fans out through the settlement factory.
withAggregateWithdrawContext : TestEnv -> MultiRegistry.MultiRegistry -> MultiRegistry.MultiRegistry
withAggregateWithdrawContext env registries =
flip fmap registries $ \reg ->
reg { v2Api = fmap updateApi reg.v2Api }
where
addContext a = do
baseCtxt <- a
extraContext <- getExternalPartyConfigStateContext env.registriesEnv.amuletV2
pure $ baseCtxt <> extraContext
updateApi api =
api { ggetAllocation_WithdrawContext = \a -> addContext . api.ggetAllocation_WithdrawContext a }

-- | Verify that locking funds for a "for-benefit-of" name aggregates over
-- separate lock allocations sharing the same name.
testAggregateLockTotals : Script ()
testAggregateLockTotals = do
env@TestEnv{..} <- setupTest
let dso = env.instrId.admin

AmuletRegistryV2.tapFaucet registriesEnv.amuletV2 alice 1200.0
AmuletRegistryV2.tapFaucet registriesEnv.amuletV2 bob 1800.0

_ <- lockForAggregate env 300.0 "alice-supervalidator" alice
_ <- lockForAggregate env 400.0 "alice-supervalidator" bob
_ <- lockForAggregate env 600.0 "alice-supervalidator" bob
_ <- lockForAggregate env 300.0 "charlie-supervalidator" bob

allocs <- queryInterface @V2.Allocation dso
let aggregateLocks =
filter (isValidAggregateLockedAllocation dso) $ fromSome . snd <$> allocs
benefitOfAmount alloc =
( "cip-105/for-benefit-of" `TM.lookup` alloc.allocation.meta.values
, fromOptional 0.0 $ alloc.allocation.nextIterationFunding >>= TM.lookup env.instrId.id
)
totals = M.fromListWithR (+) $ benefitOfAmount <$> aggregateLocks

assertEq (Some 1300.0) (Some "alice-supervalidator" `M.lookup` totals)
assertEq (Some 300.0) (Some "charlie-supervalidator" `M.lookup` totals)

-- | Withdrawing an aggregate-lock allocation moves the locked funds into a
-- fresh vesting-lock destination for the same owner.
testAggregateLockUnlockCreatesVestingLock : Script ()
testAggregateLockUnlockCreatesVestingLock = do
env@TestEnv{..} <- setupTest
let dso = env.instrId.admin
newRegistries = withAggregateWithdrawContext env registries

AmuletRegistryV2.tapFaucet registriesEnv.amuletV2 bob 1800.0

AllocationInstructionResult { output = AllocationInstructionResult_Completed locked } <-
lockForAggregate env 1000.0 "alice-supervalidator" bob
Some lockedView <- queryInterfaceContractId bob locked

_ <- WalletClientV2.withdrawAllocationV2 newRegistries bob (locked, lockedView)

liveAllocs <- fmap (fromSome . snd) . filter (isSome . snd) <$>
queryInterface @V2.Allocation bob
length liveAllocs === 1

let aggregates = filter (isValidAggregateLockedAllocation dso) liveAllocs
vestings = filter (isValidVestingLockedDestination dso) liveAllocs

length aggregates === 0
length vestings === 1

let [vesting] = vestings
fromOptional 0.0 (vesting.allocation.nextIterationFunding >>= TM.lookup instrId.id) === 1000.0

-- The vesting destination records its start/end dates.
assertMsg "vesting destination must record startDate" $
TM.member "cip-105/vestingLock.startDate" vesting.allocation.meta.values
assertMsg "vesting destination must record endDate" $
TM.member "cip-105/vestingLock.endDate" vesting.allocation.meta.values

-- | Partially withdrawing an aggregate-lock allocation moves only that amount
-- of the locked funds into a fresh vesting-lock destination for the same owner.
testAggregateLockPartialWithdraw : Script ()
testAggregateLockPartialWithdraw = do
env@TestEnv{..} <- setupTest
let dso = env.instrId.admin
newRegistries = withAggregateWithdrawContext env registries

AmuletRegistryV2.tapFaucet registriesEnv.amuletV2 bob 1800.0

AllocationInstructionResult { output = AllocationInstructionResult_Completed locked } <-
lockForAggregate env 1000.0 "alice-supervalidator" bob
Some lockedView <- queryInterfaceContractId bob locked

_ <- WalletClientV2.withdrawAllocationV2Meta newRegistries bob (locked, lockedView) $ Metadata $ TM.singleton "cip-105/withdraw-amount" "500.0"

liveAllocs <- fmap (fromSome . snd) . filter (isSome . snd) <$>
queryInterface @V2.Allocation bob
length liveAllocs === 2

let aggregates = filter (isValidAggregateLockedAllocation dso) liveAllocs
vestings = filter (isValidVestingLockedDestination dso) liveAllocs

length aggregates === 1
length vestings === 1

let [aggregate] = aggregates
[vesting] = vestings
fromOptional 0.0 (aggregate.allocation.nextIterationFunding >>= TM.lookup instrId.id) === 500.0
fromOptional 0.0 (vesting.allocation.nextIterationFunding >>= TM.lookup instrId.id) === 500.0

-- Metadata carried over correctly: same owner and DSO on both sides.
aggregate.allocation.authorizer === vesting.allocation.authorizer
aggregate.allocation.admin === vesting.allocation.admin

-- The vesting destination records its start/end dates.
assertMsg "vesting destination must record startDate" $
TM.member "cip-105/vestingLock.startDate" vesting.allocation.meta.values
assertMsg "vesting destination must record endDate" $
TM.member "cip-105/vestingLock.endDate" vesting.allocation.meta.values

-- | Withdrawing from a vesting-lock allocation vests funds linearly over the
-- lock's start/end period. This exercises the flow against a vesting lock
-- created directly by 'lockForVesting'; the round-trip through
-- 'aggregateLockUnlock' + 'vestingLockWithdraw' isn't tested here because
-- 'aggregateLockUnlock' doesn't (yet) populate the destination's
-- @cip-105/vestingLock.initialAmount@ metadata.
testVestingLockWithdraw : Script ()
testVestingLockWithdraw = do
env@TestEnv{..} <- setupTest
let newRegistries = withAggregateWithdrawContext env registries
initialAmount = 365.25 -- one unit per day of the 365 day + 6 hour vesting period

AmuletRegistryV2.tapFaucet registriesEnv.amuletV2 bob 1800.0

vestStart <- getTime
AllocationInstructionResult { output = AllocationInstructionResult_Completed vestingLockCid } <-
lockForVesting initialAmount vestStart env bob
Some vestingLockView <- queryInterfaceContractId bob vestingLockCid

-- Withdrawing immediately fails: nothing has vested yet.
vestingLockView.allocation.admin `submitMustFailWithdraw` (vestingLockCid, vestingLockView) $ newRegistries

-- After some time passes a proportional amount can be withdrawn.
passTime (days 10)
vestingResult1 <- WalletClientV2.extractAllocationResult <$>
WalletClientV2.withdrawAllocationV2 newRegistries bob (vestingLockCid, vestingLockView)

let holdingCids1 = fromSome $ TM.lookup instrId.id vestingResult1.authorizerHoldingCids
amount1 <- unlockedAmountOf bob holdingCids1
amount1 === 10.0000000105

-- The vesting-lock is settled iteratively, so a new allocation contract
-- carries the remaining vesting balance.
case vestingResult1.output of
AllocationResult_Settled { nextIterationAllocationCid = Some nextCid } -> do
-- After the full period has elapsed the remainder becomes withdrawable.
passTime (days 365)
Some nextView <- queryInterfaceContractId bob nextCid
vestingResult2 <- WalletClientV2.extractAllocationResult <$>
WalletClientV2.withdrawAllocationV2 newRegistries bob (nextCid, nextView)

let holdingCids2 = fromSome $ TM.lookup instrId.id vestingResult2.authorizerHoldingCids
amount2 <- unlockedAmountOf bob holdingCids2
amount1 + amount2 === initialAmount
other ->
fail $ "expected AllocationResult_Settled with a next-iteration allocation, got: " <> show other

testVestingLockTotalWithdraw : Script ()
testVestingLockTotalWithdraw = do
env@TestEnv{..} <- setupTest
let newRegistries = withAggregateWithdrawContext env registries
initialAmount = 365.25 -- one unit per day of the 365 day + 6 hour vesting period

AmuletRegistryV2.tapFaucet registriesEnv.amuletV2 bob 1800.0

vestStart <- getTime
AllocationInstructionResult { output = AllocationInstructionResult_Completed vestingLockCid } <-
lockForVesting initialAmount vestStart env bob
Some vestingLockView <- queryInterfaceContractId bob vestingLockCid

-- Withdrawing immediately fails: nothing has vested yet.
vestingLockView.allocation.admin `submitMustFailWithdraw` (vestingLockCid, vestingLockView) $ newRegistries

-- After some time passes a proportional amount can be withdrawn.
passTime (days 366)
vestingResult1 <- WalletClientV2.extractAllocationResult <$>
WalletClientV2.withdrawAllocationV2 newRegistries bob (vestingLockCid, vestingLockView)

let holdingCids1 = fromSome $ TM.lookup instrId.id vestingResult1.authorizerHoldingCids
amount1 <- unlockedAmountOf bob holdingCids1
amount1 === 365.25

liveAllocs <- fmap (fromSome . snd) . filter (isSome . snd) <$>
queryInterface @V2.Allocation bob
length liveAllocs === 0

-- | Attempt an @Allocation_Withdraw@ that should fail; asserts the submit
-- fails and does not leak the underlying error.
submitMustFailWithdraw
: Party -- ^ admin party (needed to read the enriched choice context)
-> (ContractId V2.Allocation, V2.AllocationView)
-> MultiRegistry.MultiRegistry
-> Script ()
submitMustFailWithdraw admin (allocCid, _allocView) registries = do
registry <- MultiRegistry.getRegistryApiV2 registries admin
context <- getAllocation_WithdrawContext registry allocCid emptyMetadata
Some owner <- pure =<<
fmap ((.allocation.authorizer.owner) . fromSome) (queryInterfaceContractId admin allocCid)
submitMustFail (actAs owner <> discloseMany' context.disclosures) $
exerciseCmd allocCid V2.Allocation_Withdraw with
actors = [owner]
extraArgs = ExtraArgs with
context = context.choiceContext
meta = emptyMetadata

-- | Sum the amounts of the given (unlocked) holding contracts owned by @p@.
unlockedAmountOf : Party -> [ContractId V2.Holding] -> Script Decimal
unlockedAmountOf p wantedCids = do
holdings <- queryInterface @V2.Holding p
let matching =
[ h
| (cid, Some h) <- holdings
, cid `elem` wantedCids
, isNone h.lock
]
pure $ sum (fmap (.amount) matching)
Loading