From 6c8b68a3e72d48e28e61b6cb6efe3167e3c86220 Mon Sep 17 00:00:00 2001 From: "Jonathan D.K. Gibbons" Date: Tue, 14 Jul 2026 11:15:01 -0400 Subject: [PATCH 01/31] WIP of user-of-allocations code --- .../daml/Splice/AggregateLock.daml | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 daml/splice-amulet/daml/Splice/AggregateLock.daml diff --git a/daml/splice-amulet/daml/Splice/AggregateLock.daml b/daml/splice-amulet/daml/Splice/AggregateLock.daml new file mode 100644 index 0000000000..5ed0ef4eb7 --- /dev/null +++ b/daml/splice-amulet/daml/Splice/AggregateLock.daml @@ -0,0 +1,187 @@ +module Splice.AggregateLock where + +import Splice.Api.Token.AllocationV2 as V2 +import Splice.Api.Token.HoldingV2 (Account) +import Splice.Api.Token.MetadataV1 +import DA.TextMap as TM +import DA.Map as M + + +isValidAggregateLockedAllocation : Party -> V2.Allocation -> Bool +isValidAggregateLockedAllocation dso alloc = + True +-- alloc.allocation.admin = dso +-- && alloc.allocation. + +baseLockedMetadata = emptyMetadata +baseVestingMetadata = emptyMetadata + +type ControllerSets = [[Party]] + +controllerSetFromMeta : Text -> ControllerSets +controllerSetFromMeta = map partiesFromText . splitOn ";" + +data AggregateLocked = AggregateLocked with + unlockControllerSets : ControllerSets + substituteControllerSets : ControllerSets + +aggLockedAmuletFromMeta : Metadata -> Account -> AggregateLocked +aggLockedAmuletFromMeta meta acctParty = AggregateLocked with + unlockControllerSets = controllerSetFromMetaMap "cip-105/unlockControllerSets" + substituteControllerSets = controllerSetFromMetaMap "cip-105/substituteControllerSets" + where + controllerSetFromMetaMap key = controllerSetFromMeta $ fromOptional acctParty $ meta.values `TM.lookup` key + + +template AggregateLock + with + dso: Party + instrumentId : Text + allowImmediateUnlock : Bool + where + signatory dso + nonconsuming choice AggregateLock_Unlock : SettlementFactory_SettleBatchResult + with + factoryCid : ContractId SettlementFactory + lockedCid : ContractId Allocation + withdrawToCid : ContractId Allocation + authorizers : [ Party ] + amount : Decimal + where + controller authorizers + do + locked <- fetch lockedCid + withdrawTo <- fetch withdrawToCid + let ownerParty = fromSomeNote "Account must be basic" $ locked.allocation.authorizer.owner + let aggLockedAmulet = aggLockedAmuletFromMeta locked.meta ownerParty + require "Unlock controller must be one of the options" $ any (\conj -> all (`elem` authorizers) conj) unlockControllerSets + require "Not allowed to unlock to a different party than locked the funds" $ locked.allocation.authorizer == withdrawTo.allocation.authorizer + if not allowImmediateUnlocks then require "Unlock without vesting is only allowed when specifically enabled" else pure () + require "Must be a valid goverance-locked allocation" $ isValidGovernanceLocked locked + require "Must be a valid empty vesting unlock allocation" $ isValidVestingLockedDestination withdrawToCid + let + info = SettlementInfo with + executors = [ dso ] + id = "AggregateLock" + sid = self + transferLegId = "unlock" + + exercise factoryCid $ SettlementFactory_SettleBatch with + info + transferLegs = + [ TransferLeg with + transferLegId + sender = locked.authorizer + receiver = withdrawTo.authorizer + amount + instrumentId + meta = emptyMetadata + ] + allocations = + [ FinalizedAllocation with + allocationCid = locked + extraTransferLegSides = + [ TransferLegSide with + transferLegId + side = SenderSide + otherside = authorizer + amount + instrumentId + meta = baseLockedMetadata + ] + nextIterationFunding = Some $ M.singleton instrumentId newLockedAmount + , FinalizedAllocation with + allocationCid = locked + extraTransferLegSides = + [ TransferLegSide with + transferLegId + side = SenderSide + otherside = authorizer + amount + instrumentId + meta = baseLockedMetadata + ] + nextIterationFunding = Some $ M.singleton instrumentId newLockedAmount + ] + + nonconsuming choice AggregateLock_substitute : SettlementFactory_SettleBatchResult -- AggregateLock_SubstituteResult + with + factoryCid : ContractId SettlementFactory + unlockingCid : ContractId Allocation + fundsToLock : ContractId Allocation + lockingCid : ContractId Allocation + authorizers : [ Party ] + amount : Decimal + where + controller authorizers + do + unlocking <- fetch lockedCid + locking <- fetch withdrawToCid + let ownerParty = fromSomeNote "Account must be basic" $ locked.allocation.authorizer.owner + let aggLockedAmulet = aggLockedAmuletFromMeta locked.meta ownerParty + require "Substitute controller must be one of the options" $ any (\conj -> all (`elem` authorizers) conj) substituteControllerSets + require "Not allowed to unlock to a different party than locked the funds" $ locked.allocation.authorizer == withdrawTo.allocation.authorizer + if not allowImmediateUnlocks then require "Unlock without vesting is only allowed when specifically enabled" else pure () + require "Must be a valid goverance-locked allocation" $ isValidGovernanceLocked locked + require "Must be a valid empty vesting unlock allocation" $ isValidVestingLockedDestination withdrawToCid + let + info = SettlementInfo with + executors = [ dso ] + id = "AggregateLock" + sid = self + transferLegId = "unlock" + + exercise factoryCid AllocationFactory_SettleBatch with + info + transferLegs = + [ TransferLeg with + transferLegId + sender = locked.authorizer + receiver = locked.authorizer + amount + instrumentId + meta = emptyMetadata + , TransferLeg with + transferLegId + sender = locked.authorizer + receiver = locked.authorizer + amount + instrumentId + meta = emptyMetadata + ] + allocations = + [ FinalizedAllocation with + allocationCid = locked + extraTransferLegSides = + [ TransferLegSide with + transferLegId + side = SenderSide + otherside = authorizer + amount + instrumentId + meta = baseLockedMetadata + ] + nextIterationFunding = Some $ M.singleton instrumentId newLockedAmount + , FinalizedAllocation with + allocationCid = locked + extraTransferLegSides = + [ TransferLegSide with + transferLegId + side = SenderSide + otherside = authorizer + amount + instrumentId + meta = baseLockedMetadata + ] + nextIterationFunding = Some $ M.singleton instrumentId newLockedAmount + ] + + {- data AggregateLock_UnlockResult + = AggregateLock_UnlockResult_Vesting with + vesting : ContractId VestedAmulet + locked : ContractId AggregateLockedAmulet + | AggregateLock_UnlockResult_Immediate with + unlocked : ContractId Amulet + locked : ContractId AggregateLockedAmulet + deriving (Show) +-} From 63a9f458bba0ad3e522e3c27756ba1bda61466b7 Mon Sep 17 00:00:00 2001 From: Deepak Birdi Date: Tue, 14 Jul 2026 19:25:44 +0000 Subject: [PATCH 02/31] WIP: Potentially fix some errors/imports --- .../daml/Splice/AggregateLock.daml | 62 ++++++++++++------- 1 file changed, 39 insertions(+), 23 deletions(-) diff --git a/daml/splice-amulet/daml/Splice/AggregateLock.daml b/daml/splice-amulet/daml/Splice/AggregateLock.daml index 5ed0ef4eb7..2abc8c2e84 100644 --- a/daml/splice-amulet/daml/Splice/AggregateLock.daml +++ b/daml/splice-amulet/daml/Splice/AggregateLock.daml @@ -3,8 +3,12 @@ module Splice.AggregateLock where import Splice.Api.Token.AllocationV2 as V2 import Splice.Api.Token.HoldingV2 (Account) import Splice.Api.Token.MetadataV1 -import DA.TextMap as TM +import Splice.Util + import DA.Map as M +import DA.Optional +import DA.Text +import DA.TextMap as TM isValidAggregateLockedAllocation : Party -> V2.Allocation -> Bool @@ -19,7 +23,7 @@ baseVestingMetadata = emptyMetadata type ControllerSets = [[Party]] controllerSetFromMeta : Text -> ControllerSets -controllerSetFromMeta = map partiesFromText . splitOn ";" +controllerSetFromMeta = map partyFromText . splitOn ";" data AggregateLocked = AggregateLocked with unlockControllerSets : ControllerSets @@ -56,18 +60,22 @@ template AggregateLock let aggLockedAmulet = aggLockedAmuletFromMeta locked.meta ownerParty require "Unlock controller must be one of the options" $ any (\conj -> all (`elem` authorizers) conj) unlockControllerSets require "Not allowed to unlock to a different party than locked the funds" $ locked.allocation.authorizer == withdrawTo.allocation.authorizer - if not allowImmediateUnlocks then require "Unlock without vesting is only allowed when specifically enabled" else pure () + if not allowImmediateUnlock then require "Unlock without vesting is only allowed when specifically enabled" else pure () + -- TODO: What is isValidGovernanceLocked? require "Must be a valid goverance-locked allocation" $ isValidGovernanceLocked locked require "Must be a valid empty vesting unlock allocation" $ isValidVestingLockedDestination withdrawToCid let info = SettlementInfo with executors = [ dso ] - id = "AggregateLock" - sid = self + id = "AggregateLock_Unlock" + cid = Some self + meta = emptyMetadata transferLegId = "unlock" exercise factoryCid $ SettlementFactory_SettleBatch with - info + settlement = info + actors = [ dso ] + extraArgs = ExtraArgs emptyChoiceContext emptyMetadata transferLegs = [ TransferLeg with transferLegId @@ -89,6 +97,7 @@ template AggregateLock instrumentId meta = baseLockedMetadata ] + -- TODO: what's newLockedAmount nextIterationFunding = Some $ M.singleton instrumentId newLockedAmount , FinalizedAllocation with allocationCid = locked @@ -101,10 +110,11 @@ template AggregateLock instrumentId meta = baseLockedMetadata ] + -- TODO: what's newLockedAmount nextIterationFunding = Some $ M.singleton instrumentId newLockedAmount ] - nonconsuming choice AggregateLock_substitute : SettlementFactory_SettleBatchResult -- AggregateLock_SubstituteResult + nonconsuming choice AggregateLock_Substitute : SettlementFactory_SettleBatchResult -- AggregateLock_SubstituteResult with factoryCid : ContractId SettlementFactory unlockingCid : ContractId Allocation @@ -117,41 +127,45 @@ template AggregateLock do unlocking <- fetch lockedCid locking <- fetch withdrawToCid - let ownerParty = fromSomeNote "Account must be basic" $ locked.allocation.authorizer.owner - let aggLockedAmulet = aggLockedAmuletFromMeta locked.meta ownerParty + let ownerParty = fromSomeNote "Account must be basic" $ unlocking.allocation.authorizer.owner + let aggLockedAmulet = aggLockedAmuletFromMeta unlocking.meta ownerParty require "Substitute controller must be one of the options" $ any (\conj -> all (`elem` authorizers) conj) substituteControllerSets - require "Not allowed to unlock to a different party than locked the funds" $ locked.allocation.authorizer == withdrawTo.allocation.authorizer - if not allowImmediateUnlocks then require "Unlock without vesting is only allowed when specifically enabled" else pure () - require "Must be a valid goverance-locked allocation" $ isValidGovernanceLocked locked + require "Not allowed to unlock to a different party than locked the funds" $ unlocking.allocation.authorizer == withdrawTo.allocation.authorizer + if not allowImmediateUnlock then require "Unlock without vesting is only allowed when specifically enabled" else pure () + -- TODO: What is isValidGovernanceLocked? + require "Must be a valid goverance-locked allocation" $ isValidGovernanceLocked unlocking require "Must be a valid empty vesting unlock allocation" $ isValidVestingLockedDestination withdrawToCid let info = SettlementInfo with executors = [ dso ] - id = "AggregateLock" - sid = self - transferLegId = "unlock" + id = "AggregateLock_substitute" + cid = Some self + meta = emptyMetadata + transferLegId = "substitute" - exercise factoryCid AllocationFactory_SettleBatch with - info + exercise factoryCid SettlementFactory_SettleBatch with + settlement = info + actors = [dso] + extraArgs = ExtraArgs emptyChoiceContext emptyMetadata transferLegs = [ TransferLeg with transferLegId - sender = locked.authorizer - receiver = locked.authorizer + sender = unlocking.authorizer + receiver = unlocking.authorizer amount instrumentId meta = emptyMetadata , TransferLeg with transferLegId - sender = locked.authorizer - receiver = locked.authorizer + sender = unlocking.authorizer + receiver = unlocking.authorizer amount instrumentId meta = emptyMetadata ] allocations = [ FinalizedAllocation with - allocationCid = locked + allocationCid = unlocking extraTransferLegSides = [ TransferLegSide with transferLegId @@ -161,9 +175,10 @@ template AggregateLock instrumentId meta = baseLockedMetadata ] + -- TODO: what's newLockedAmount nextIterationFunding = Some $ M.singleton instrumentId newLockedAmount , FinalizedAllocation with - allocationCid = locked + allocationCid = unlocking extraTransferLegSides = [ TransferLegSide with transferLegId @@ -173,6 +188,7 @@ template AggregateLock instrumentId meta = baseLockedMetadata ] + -- TODO: what's newLockedAmount nextIterationFunding = Some $ M.singleton instrumentId newLockedAmount ] From 082ca6f525b99310f3bc88768fad659ef2b42c41 Mon Sep 17 00:00:00 2001 From: Deepak Birdi Date: Tue, 14 Jul 2026 20:53:37 +0000 Subject: [PATCH 03/31] WIP correct some more fields and functions --- .../daml/Splice/AggregateLock.daml | 37 +++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/daml/splice-amulet/daml/Splice/AggregateLock.daml b/daml/splice-amulet/daml/Splice/AggregateLock.daml index 2abc8c2e84..85fa67daa4 100644 --- a/daml/splice-amulet/daml/Splice/AggregateLock.daml +++ b/daml/splice-amulet/daml/Splice/AggregateLock.daml @@ -5,20 +5,29 @@ import Splice.Api.Token.HoldingV2 (Account) import Splice.Api.Token.MetadataV1 import Splice.Util -import DA.Map as M +import qualified DA.Map as M import DA.Optional import DA.Text -import DA.TextMap as TM +import qualified DA.TextMap as TM isValidAggregateLockedAllocation : Party -> V2.Allocation -> Bool isValidAggregateLockedAllocation dso alloc = - True --- alloc.allocation.admin = dso --- && alloc.allocation. + and [ alloc.allocation.admin == dso + , alloc.allocation.committed + , null alloc.allocation.transferLegSides + , TM.lookup alloc.allocation.meta.values "cip-105/type" == Some "aggregateLock" + , alloc.allocation.expiresAt == Some maxBound + ] -baseLockedMetadata = emptyMetadata -baseVestingMetadata = emptyMetadata +isValidVestingLockedDestination : Party -> V2.Allocation -> Bool +isValidVestingLockedDestination dso alloc = + and [ alloc.allocation.admin == dso + , alloc.allocation.committed + , null alloc.allocation.transferLegSides + , TM.lookup alloc.allocation.meta.values "cip-105/type" == Some "aggregateLock" + , alloc.allocation.expiresAt == Some maxBound + ] type ControllerSets = [[Party]] @@ -61,8 +70,7 @@ template AggregateLock require "Unlock controller must be one of the options" $ any (\conj -> all (`elem` authorizers) conj) unlockControllerSets require "Not allowed to unlock to a different party than locked the funds" $ locked.allocation.authorizer == withdrawTo.allocation.authorizer if not allowImmediateUnlock then require "Unlock without vesting is only allowed when specifically enabled" else pure () - -- TODO: What is isValidGovernanceLocked? - require "Must be a valid goverance-locked allocation" $ isValidGovernanceLocked locked + require "Must be a valid governance-locked allocation" $ isValidAggregateLockedAllocation locked require "Must be a valid empty vesting unlock allocation" $ isValidVestingLockedDestination withdrawToCid let info = SettlementInfo with @@ -95,7 +103,7 @@ template AggregateLock otherside = authorizer amount instrumentId - meta = baseLockedMetadata + meta = emptyMetadata ] -- TODO: what's newLockedAmount nextIterationFunding = Some $ M.singleton instrumentId newLockedAmount @@ -108,7 +116,7 @@ template AggregateLock otherside = authorizer amount instrumentId - meta = baseLockedMetadata + meta = emptyMetadata ] -- TODO: what's newLockedAmount nextIterationFunding = Some $ M.singleton instrumentId newLockedAmount @@ -132,8 +140,7 @@ template AggregateLock require "Substitute controller must be one of the options" $ any (\conj -> all (`elem` authorizers) conj) substituteControllerSets require "Not allowed to unlock to a different party than locked the funds" $ unlocking.allocation.authorizer == withdrawTo.allocation.authorizer if not allowImmediateUnlock then require "Unlock without vesting is only allowed when specifically enabled" else pure () - -- TODO: What is isValidGovernanceLocked? - require "Must be a valid goverance-locked allocation" $ isValidGovernanceLocked unlocking + require "Must be a valid goverance-locked allocation" $ isValidAggregateLockedAllocation unlocking require "Must be a valid empty vesting unlock allocation" $ isValidVestingLockedDestination withdrawToCid let info = SettlementInfo with @@ -173,7 +180,7 @@ template AggregateLock otherside = authorizer amount instrumentId - meta = baseLockedMetadata + meta = emptyMetadata ] -- TODO: what's newLockedAmount nextIterationFunding = Some $ M.singleton instrumentId newLockedAmount @@ -186,7 +193,7 @@ template AggregateLock otherside = authorizer amount instrumentId - meta = baseLockedMetadata + meta = emptyMetadata ] -- TODO: what's newLockedAmount nextIterationFunding = Some $ M.singleton instrumentId newLockedAmount From dcd3af3e4706ed8f1b8fd3083dbab8f36eef198b Mon Sep 17 00:00:00 2001 From: Deepak Birdi Date: Tue, 14 Jul 2026 21:26:12 +0000 Subject: [PATCH 04/31] WIP fixing type errors in AggregageLock.daml --- .../daml/Splice/AggregateLock.daml | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/daml/splice-amulet/daml/Splice/AggregateLock.daml b/daml/splice-amulet/daml/Splice/AggregateLock.daml index 85fa67daa4..8305d5c371 100644 --- a/daml/splice-amulet/daml/Splice/AggregateLock.daml +++ b/daml/splice-amulet/daml/Splice/AggregateLock.daml @@ -16,7 +16,7 @@ isValidAggregateLockedAllocation dso alloc = and [ alloc.allocation.admin == dso , alloc.allocation.committed , null alloc.allocation.transferLegSides - , TM.lookup alloc.allocation.meta.values "cip-105/type" == Some "aggregateLock" + , TM.lookup "cip-105/type" alloc.allocation.meta.values == Some "aggregateLock" , alloc.allocation.expiresAt == Some maxBound ] @@ -25,14 +25,17 @@ isValidVestingLockedDestination dso alloc = and [ alloc.allocation.admin == dso , alloc.allocation.committed , null alloc.allocation.transferLegSides - , TM.lookup alloc.allocation.meta.values "cip-105/type" == Some "aggregateLock" + , TM.lookup "cip-105/type" alloc.allocation.meta.values == Some "aggregateLock" , alloc.allocation.expiresAt == Some maxBound ] type ControllerSets = [[Party]] -controllerSetFromMeta : Text -> ControllerSets -controllerSetFromMeta = map partyFromText . splitOn ";" +partiesFromText : Text -> Optional [Party] +partiesFromText = mapA partyFromText . splitOn "," + +controllerSetFromMeta : Text -> Optional ControllerSets +controllerSetFromMeta = mapA partiesFromText . splitOn ";" data AggregateLocked = AggregateLocked with unlockControllerSets : ControllerSets @@ -72,6 +75,7 @@ template AggregateLock if not allowImmediateUnlock then require "Unlock without vesting is only allowed when specifically enabled" else pure () require "Must be a valid governance-locked allocation" $ isValidAggregateLockedAllocation locked require "Must be a valid empty vesting unlock allocation" $ isValidVestingLockedDestination withdrawToCid + require "Must have amounts reserved within the next settlement iteration" $ isSome locked.allocation.nextIterationFunding let info = SettlementInfo with executors = [ dso ] @@ -80,6 +84,10 @@ template AggregateLock meta = emptyMetadata transferLegId = "unlock" + newLockedAmount <- case locked.allocation.nextIterationFunding of + Some a -> pure $ a - amount + None -> require "Must have amounts reserved within the next settlement iteration" False + exercise factoryCid $ SettlementFactory_SettleBatch with settlement = info actors = [ dso ] @@ -95,7 +103,7 @@ template AggregateLock ] allocations = [ FinalizedAllocation with - allocationCid = locked + allocationCid = lockedCid extraTransferLegSides = [ TransferLegSide with transferLegId @@ -105,10 +113,9 @@ template AggregateLock instrumentId meta = emptyMetadata ] - -- TODO: what's newLockedAmount nextIterationFunding = Some $ M.singleton instrumentId newLockedAmount , FinalizedAllocation with - allocationCid = locked + allocationCid = withdrawToCid extraTransferLegSides = [ TransferLegSide with transferLegId @@ -118,10 +125,9 @@ template AggregateLock instrumentId meta = emptyMetadata ] - -- TODO: what's newLockedAmount nextIterationFunding = Some $ M.singleton instrumentId newLockedAmount ] - +{- nonconsuming choice AggregateLock_Substitute : SettlementFactory_SettleBatchResult -- AggregateLock_SubstituteResult with factoryCid : ContractId SettlementFactory @@ -134,7 +140,7 @@ template AggregateLock controller authorizers do unlocking <- fetch lockedCid - locking <- fetch withdrawToCid + withdrawTo <- fetch withdrawToCid let ownerParty = fromSomeNote "Account must be basic" $ unlocking.allocation.authorizer.owner let aggLockedAmulet = aggLockedAmuletFromMeta unlocking.meta ownerParty require "Substitute controller must be one of the options" $ any (\conj -> all (`elem` authorizers) conj) substituteControllerSets @@ -172,7 +178,7 @@ template AggregateLock ] allocations = [ FinalizedAllocation with - allocationCid = unlocking + allocationCid = unlockingCid extraTransferLegSides = [ TransferLegSide with transferLegId @@ -185,7 +191,7 @@ template AggregateLock -- TODO: what's newLockedAmount nextIterationFunding = Some $ M.singleton instrumentId newLockedAmount , FinalizedAllocation with - allocationCid = unlocking + allocationCid = unlockingCid extraTransferLegSides = [ TransferLegSide with transferLegId @@ -198,7 +204,7 @@ template AggregateLock -- TODO: what's newLockedAmount nextIterationFunding = Some $ M.singleton instrumentId newLockedAmount ] - +-} {- data AggregateLock_UnlockResult = AggregateLock_UnlockResult_Vesting with vesting : ContractId VestedAmulet From 5b426568154613f69e09b16984b7352b49eac1a6 Mon Sep 17 00:00:00 2001 From: "Jonathan D.K. Gibbons" Date: Wed, 15 Jul 2026 00:38:28 +0000 Subject: [PATCH 05/31] WIP: externally-defined governance lock implementation using V2 allocations. --- .../daml/Splice/AggregateLock.daml | 147 ++++-------------- 1 file changed, 33 insertions(+), 114 deletions(-) diff --git a/daml/splice-amulet/daml/Splice/AggregateLock.daml b/daml/splice-amulet/daml/Splice/AggregateLock.daml index 8305d5c371..292f85372a 100644 --- a/daml/splice-amulet/daml/Splice/AggregateLock.daml +++ b/daml/splice-amulet/daml/Splice/AggregateLock.daml @@ -1,32 +1,30 @@ module Splice.AggregateLock where import Splice.Api.Token.AllocationV2 as V2 -import Splice.Api.Token.HoldingV2 (Account) import Splice.Api.Token.MetadataV1 import Splice.Util -import qualified DA.Map as M import DA.Optional import DA.Text import qualified DA.TextMap as TM -isValidAggregateLockedAllocation : Party -> V2.Allocation -> Bool +isValidAggregateLockedAllocation : Party -> V2.AllocationView -> Bool isValidAggregateLockedAllocation dso alloc = and [ alloc.allocation.admin == dso , alloc.allocation.committed , null alloc.allocation.transferLegSides , TM.lookup "cip-105/type" alloc.allocation.meta.values == Some "aggregateLock" - , alloc.allocation.expiresAt == Some maxBound + , alloc.expiresAt == Some maxBound ] -isValidVestingLockedDestination : Party -> V2.Allocation -> Bool +isValidVestingLockedDestination : Party -> V2.AllocationView -> Bool isValidVestingLockedDestination dso alloc = and [ alloc.allocation.admin == dso , alloc.allocation.committed , null alloc.allocation.transferLegSides , TM.lookup "cip-105/type" alloc.allocation.meta.values == Some "aggregateLock" - , alloc.allocation.expiresAt == Some maxBound + , alloc.expiresAt == Some maxBound ] type ControllerSets = [[Party]] @@ -37,18 +35,25 @@ partiesFromText = mapA partyFromText . splitOn "," controllerSetFromMeta : Text -> Optional ControllerSets controllerSetFromMeta = mapA partiesFromText . splitOn ";" -data AggregateLocked = AggregateLocked with +data GovernanceLockedControllers = GovernanceLockedControllers with unlockControllerSets : ControllerSets substituteControllerSets : ControllerSets -aggLockedAmuletFromMeta : Metadata -> Account -> AggregateLocked -aggLockedAmuletFromMeta meta acctParty = AggregateLocked with +governanceLockedControllersFromMeta : Metadata -> Party -> GovernanceLockedControllers +governanceLockedControllersFromMeta meta acctParty = GovernanceLockedControllers with unlockControllerSets = controllerSetFromMetaMap "cip-105/unlockControllerSets" substituteControllerSets = controllerSetFromMetaMap "cip-105/substituteControllerSets" where - controllerSetFromMetaMap key = controllerSetFromMeta $ fromOptional acctParty $ meta.values `TM.lookup` key + controllerSetFromMetaMap key = fromOptional [[acctParty]] $ controllerSetFromMeta =<< key `TM.lookup` meta.values +-- | The intention is to treat the overall aggregate lock for a SV as if it is +-- "the settlement" for the allocations locked to it, and use iterated +-- settlement to execute updates on the committed allocations as needed. +-- This approach would permit most of the specific governance locking and +-- vesting to be in a separate dar from amulet only needed for SVs and +-- interested observers. + template AggregateLock with dso: Party @@ -66,27 +71,28 @@ template AggregateLock where controller authorizers do - locked <- fetch lockedCid - withdrawTo <- fetch withdrawToCid - let ownerParty = fromSomeNote "Account must be basic" $ locked.allocation.authorizer.owner - let aggLockedAmulet = aggLockedAmuletFromMeta locked.meta ownerParty + locked <- view <$> fetch lockedCid + withdrawTo <- view <$> fetch withdrawToCid + ownerParty <- whenNone locked.allocation.authorizer.owner $ + assertFail "The requirement 'governance locked allocations must be owned by basic accounts' was not met" + let GovernanceLockedControllers{..} = governanceLockedControllersFromMeta locked.meta ownerParty require "Unlock controller must be one of the options" $ any (\conj -> all (`elem` authorizers) conj) unlockControllerSets require "Not allowed to unlock to a different party than locked the funds" $ locked.allocation.authorizer == withdrawTo.allocation.authorizer - if not allowImmediateUnlock then require "Unlock without vesting is only allowed when specifically enabled" else pure () - require "Must be a valid governance-locked allocation" $ isValidAggregateLockedAllocation locked - require "Must be a valid empty vesting unlock allocation" $ isValidVestingLockedDestination withdrawToCid + if not allowImmediateUnlock then require "Unlock without vesting is only allowed when specifically enabled" False else pure () + require "Must be a valid governance-locked allocation" $ isValidAggregateLockedAllocation dso locked + require "Must be a valid empty vesting unlock allocation" $ isValidVestingLockedDestination dso withdrawTo require "Must have amounts reserved within the next settlement iteration" $ isSome locked.allocation.nextIterationFunding let info = SettlementInfo with executors = [ dso ] id = "AggregateLock_Unlock" - cid = Some self + cid = Some $ coerceContractId self meta = emptyMetadata transferLegId = "unlock" - newLockedAmount <- case locked.allocation.nextIterationFunding of - Some a -> pure $ a - amount - None -> require "Must have amounts reserved within the next settlement iteration" False + currentLockedAmount <- whenNone (locked.allocation.nextIterationFunding >>= TM.lookup instrumentId) $ + assertFail "The requirement 'Must have amounts reserved within the next settlement iteration' was not met" + let newLockedAmount = currentLockedAmount - amount exercise factoryCid $ SettlementFactory_SettleBatch with settlement = info @@ -95,8 +101,8 @@ template AggregateLock transferLegs = [ TransferLeg with transferLegId - sender = locked.authorizer - receiver = withdrawTo.authorizer + sender = locked.allocation.authorizer + receiver = withdrawTo.allocation.authorizer amount instrumentId meta = emptyMetadata @@ -108,109 +114,22 @@ template AggregateLock [ TransferLegSide with transferLegId side = SenderSide - otherside = authorizer + otherside = withdrawTo.allocation.authorizer amount instrumentId meta = emptyMetadata ] - nextIterationFunding = Some $ M.singleton instrumentId newLockedAmount + nextIterationFunding = Some $ TM.singleton instrumentId newLockedAmount , FinalizedAllocation with allocationCid = withdrawToCid extraTransferLegSides = [ TransferLegSide with transferLegId side = SenderSide - otherside = authorizer - amount - instrumentId - meta = emptyMetadata - ] - nextIterationFunding = Some $ M.singleton instrumentId newLockedAmount - ] -{- - nonconsuming choice AggregateLock_Substitute : SettlementFactory_SettleBatchResult -- AggregateLock_SubstituteResult - with - factoryCid : ContractId SettlementFactory - unlockingCid : ContractId Allocation - fundsToLock : ContractId Allocation - lockingCid : ContractId Allocation - authorizers : [ Party ] - amount : Decimal - where - controller authorizers - do - unlocking <- fetch lockedCid - withdrawTo <- fetch withdrawToCid - let ownerParty = fromSomeNote "Account must be basic" $ unlocking.allocation.authorizer.owner - let aggLockedAmulet = aggLockedAmuletFromMeta unlocking.meta ownerParty - require "Substitute controller must be one of the options" $ any (\conj -> all (`elem` authorizers) conj) substituteControllerSets - require "Not allowed to unlock to a different party than locked the funds" $ unlocking.allocation.authorizer == withdrawTo.allocation.authorizer - if not allowImmediateUnlock then require "Unlock without vesting is only allowed when specifically enabled" else pure () - require "Must be a valid goverance-locked allocation" $ isValidAggregateLockedAllocation unlocking - require "Must be a valid empty vesting unlock allocation" $ isValidVestingLockedDestination withdrawToCid - let - info = SettlementInfo with - executors = [ dso ] - id = "AggregateLock_substitute" - cid = Some self - meta = emptyMetadata - transferLegId = "substitute" - - exercise factoryCid SettlementFactory_SettleBatch with - settlement = info - actors = [dso] - extraArgs = ExtraArgs emptyChoiceContext emptyMetadata - transferLegs = - [ TransferLeg with - transferLegId - sender = unlocking.authorizer - receiver = unlocking.authorizer - amount - instrumentId - meta = emptyMetadata - , TransferLeg with - transferLegId - sender = unlocking.authorizer - receiver = unlocking.authorizer - amount - instrumentId - meta = emptyMetadata - ] - allocations = - [ FinalizedAllocation with - allocationCid = unlockingCid - extraTransferLegSides = - [ TransferLegSide with - transferLegId - side = SenderSide - otherside = authorizer - amount - instrumentId - meta = emptyMetadata - ] - -- TODO: what's newLockedAmount - nextIterationFunding = Some $ M.singleton instrumentId newLockedAmount - , FinalizedAllocation with - allocationCid = unlockingCid - extraTransferLegSides = - [ TransferLegSide with - transferLegId - side = SenderSide - otherside = authorizer + otherside = locked.allocation.authorizer amount instrumentId meta = emptyMetadata ] - -- TODO: what's newLockedAmount - nextIterationFunding = Some $ M.singleton instrumentId newLockedAmount + nextIterationFunding = Some $ TM.singleton instrumentId newLockedAmount ] --} - {- data AggregateLock_UnlockResult - = AggregateLock_UnlockResult_Vesting with - vesting : ContractId VestedAmulet - locked : ContractId AggregateLockedAmulet - | AggregateLock_UnlockResult_Immediate with - unlocked : ContractId Amulet - locked : ContractId AggregateLockedAmulet - deriving (Show) --} From 101c58b5011aa1f07c5da581cc206f2b823e2059 Mon Sep 17 00:00:00 2001 From: Deepak Birdi Date: Wed, 15 Jul 2026 21:47:15 +0000 Subject: [PATCH 06/31] WIP: Converting Vesting state to use the V2 Allocations --- .../daml/Splice/AggregateLock.daml | 48 +++++++++++++++++-- .../TestTokenV2/TestAllocationHappyV2.daml | 27 +++++++++++ .../Utils/Internal/Conversions.daml | 3 ++ 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/daml/splice-amulet/daml/Splice/AggregateLock.daml b/daml/splice-amulet/daml/Splice/AggregateLock.daml index 292f85372a..967ba2014e 100644 --- a/daml/splice-amulet/daml/Splice/AggregateLock.daml +++ b/daml/splice-amulet/daml/Splice/AggregateLock.daml @@ -2,12 +2,13 @@ module Splice.AggregateLock where import Splice.Api.Token.AllocationV2 as V2 import Splice.Api.Token.MetadataV1 +import Splice.TokenStandard.Utils.Internal.Conversions (decodeTime) import Splice.Util import DA.Optional import DA.Text import qualified DA.TextMap as TM - +import DA.Time isValidAggregateLockedAllocation : Party -> V2.AllocationView -> Bool isValidAggregateLockedAllocation dso alloc = @@ -23,9 +24,13 @@ isValidVestingLockedDestination dso alloc = and [ alloc.allocation.admin == dso , alloc.allocation.committed , null alloc.allocation.transferLegSides - , TM.lookup "cip-105/type" alloc.allocation.meta.values == Some "aggregateLock" + , TM.lookup "cip-105/type" alloc.allocation.meta.values == Some "vestingLock" , alloc.expiresAt == Some maxBound + , TM.member "cip-105/vestingLock.startDate" metaValues + , TM.member "cip-105/vestingLock.endDate" metaValues + -- TODO: Check the period is valid, check startDate < endDate ] + where metaValues = alloc.allocation.meta.values type ControllerSets = [[Party]] @@ -46,6 +51,26 @@ governanceLockedControllersFromMeta meta acctParty = GovernanceLockedControllers where controllerSetFromMetaMap key = fromOptional [[acctParty]] $ controllerSetFromMeta =<< key `TM.lookup` meta.values +calculateAvailableWithdrawAmount : Time -> V2.AllocationView -> Decimal -> Decimal +calculateAvailableWithdrawAmount currentDateTime alloc nextIterationFundAmount = max 0.0 availAmount + where + availAmount = if currentDateTime >= endDate + then nextIterationFundAmount + else 0.0 + -- (initialAmount * totalVestedPercentageElapsed) - withdrawnAmount + meta = alloc.allocation.meta.values + -- The initial amount represents the total that was locked initially when thrown into vesting state + initialAmount = fromSome $ TM.lookup "cip-105/vestingLock.initialAmount" meta + startDate = decodeTime . fromSome $ TM.lookup "cip-105/vestingLock.startDate" meta + endDate = decodeTime . fromSome $ TM.lookup "cip-105/vestingLock.endDate" meta + --totalUnlockPeriod = intToDecimal . convertRelTimeToMicroseconds $ subTime endDate startDate + --unlockPeriodElapsed = intToDecimal . convertRelTimeToMicroseconds $ subTime currentDateTime startDate + --totalVestedPercentageElapsed : Decimal = unlockPeriodElapsed / totalUnlockPeriod + --withdrawnAmount = initialAmount - nextIterationFundAmount + + + + -- | The intention is to treat the overall aggregate lock for a SV as if it is -- "the settlement" for the allocations locked to it, and use iterated @@ -131,5 +156,22 @@ template AggregateLock instrumentId meta = emptyMetadata ] - nextIterationFunding = Some $ TM.singleton instrumentId newLockedAmount + nextIterationFunding = Some $ TM.singleton instrumentId amount ] + -- Choice temporarily lives here and it will be moved, testing/draft purposes atm + nonconsuming choice VestingLock_Withdraw : () -- AllocationResult + with + authorizers : [Party] + vestingLockCid : ContractId Allocation + where + controller authorizers + do + -- remainingLocked stays in nextIterationFunding + vestingLock <- view <$> fetch vestingLockCid + + require "There are no remaining funds to withdraw" + $ isSome vestingLock.allocation.nextIterationFunding + require "There must be an initialAmount set for the vestingLock" + $ TM.member "cip-105/vestingLock.initialAmount" vestingLock.allocation.meta.values + + pure () diff --git a/token-standard/examples/splice-test-token-v2-test/daml/Splice/Testing/Tokens/TestTokenV2/TestAllocationHappyV2.daml b/token-standard/examples/splice-test-token-v2-test/daml/Splice/Testing/Tokens/TestTokenV2/TestAllocationHappyV2.daml index 43cb979150..9fc600beaa 100644 --- a/token-standard/examples/splice-test-token-v2-test/daml/Splice/Testing/Tokens/TestTokenV2/TestAllocationHappyV2.daml +++ b/token-standard/examples/splice-test-token-v2-test/daml/Splice/Testing/Tokens/TestTokenV2/TestAllocationHappyV2.daml @@ -10,6 +10,7 @@ import DA.Assert import DA.List (sort) import DA.Map qualified as Map import DA.TextMap qualified as TextMap +import DA.Time (addRelTime, days, hours) import Daml.Script @@ -24,6 +25,7 @@ import Splice.Testing.Tokens.TestTokenV2 qualified as TestTokenV2 import Splice.Testing.Utils import Splice.Testing.Registries.TestTokenV2_RegistryV2 qualified as TestTokenRegistryV2 +import Splice.TokenStandard.Utils.Internal.Conversions (encodeTime) import Splice.Testing.TokenStandard.RegistryApiV2 qualified as V2 import Splice.Testing.TokenStandard.WalletClientV2 qualified as WalletClientV2 import Splice.Testing.TokenStandard.MultiRegistry qualified as MultiRegistry @@ -45,6 +47,31 @@ test_allocation_WithdrawV2 = test_allocation_withdrawCancel_generic $ \TestEnv { meta = emptyMetadata pure () +-- This is a temporary test script +test_vesting_lock_WithdrawV2 : Script () +test_vesting_lock_WithdrawV2 = do + TestEnv {..} <- setupTest + now <- getTime + let endDate : Time = addRelTime now $ (days 365) + (hours 6) + vestingInitialAmount : Decimal = 3652.5 + + let allocationV2 = V2.AllocationSpecification with + admin = instrId.admin + authorizer = TSU.basicAccount alice + transferLegSides = [] + committed = False + nextIterationFunding = Some $ TextMap.fromList [(instrId.id, vestingInitialAmount)] + settlementDeadline = None + meta = Metadata with + values = TextMap.fromList + [ ("cip-105/type", "vestingLock") + , ("cip-105/vestingLock.initialAmount", show vestingInitialAmount) + , ("cip-105/vestingLock.startDate", encodeTime now) + , ("cip-105/vestingLock.endDate", encodeTime endDate) + ] + + pure () + -- TODO(tech-debt): add support for dealing with stale references to locked tokens -- -- Handle them analogous to how Amulet handles them and copy the relevant tests from Amulet. diff --git a/token-standard/splice-token-standard-utils/daml/Splice/TokenStandard/Utils/Internal/Conversions.daml b/token-standard/splice-token-standard-utils/daml/Splice/TokenStandard/Utils/Internal/Conversions.daml index 5fbcdd253a..1e70ba6c5c 100644 --- a/token-standard/splice-token-standard-utils/daml/Splice/TokenStandard/Utils/Internal/Conversions.daml +++ b/token-standard/splice-token-standard-utils/daml/Splice/TokenStandard/Utils/Internal/Conversions.daml @@ -27,6 +27,9 @@ module Splice.TokenStandard.Utils.Internal.Conversions ( partiesToMeta, dropMeta, validateNoMeta, + -- TODO: Is exporting the below cool? + encodeTime, + decodeTime, -- * Transfer utils reasonMetaKey, From cbf22de75aae8769bebacb94b06351e92ee35df7 Mon Sep 17 00:00:00 2001 From: Deepak Birdi Date: Thu, 16 Jul 2026 20:05:06 +0000 Subject: [PATCH 07/31] WIP Fixing withdrawing from VestingLock choice --- .../daml/Splice/AggregateLock.daml | 87 +++++++++++++++---- 1 file changed, 71 insertions(+), 16 deletions(-) diff --git a/daml/splice-amulet/daml/Splice/AggregateLock.daml b/daml/splice-amulet/daml/Splice/AggregateLock.daml index 967ba2014e..3d6d014f68 100644 --- a/daml/splice-amulet/daml/Splice/AggregateLock.daml +++ b/daml/splice-amulet/daml/Splice/AggregateLock.daml @@ -51,25 +51,24 @@ governanceLockedControllersFromMeta meta acctParty = GovernanceLockedControllers where controllerSetFromMetaMap key = fromOptional [[acctParty]] $ controllerSetFromMeta =<< key `TM.lookup` meta.values -calculateAvailableWithdrawAmount : Time -> V2.AllocationView -> Decimal -> Decimal -calculateAvailableWithdrawAmount currentDateTime alloc nextIterationFundAmount = max 0.0 availAmount +-- TODO: Potentially make return type, it's own data type? +calculateAvailableWithdrawAmount : Time -> V2.AllocationView -> Decimal -> (Decimal, Decimal) +calculateAvailableWithdrawAmount currentDateTime alloc nextIterationFundAmount = max (0.0, 0.0) availAmount where availAmount = if currentDateTime >= endDate - then nextIterationFundAmount - else 0.0 - -- (initialAmount * totalVestedPercentageElapsed) - withdrawnAmount + then (nextIterationFundAmount, 0.0) + else (availableWithdrawAmount, remainingVestingAmount) + availableWithdrawAmount = (initialAmount * totalVestedRatioElapsed) - withdrawnAmount + remainingVestingAmount = nextIterationFundAmount - availableWithdrawAmount meta = alloc.allocation.meta.values -- The initial amount represents the total that was locked initially when thrown into vesting state - initialAmount = fromSome $ TM.lookup "cip-105/vestingLock.initialAmount" meta - startDate = decodeTime . fromSome $ TM.lookup "cip-105/vestingLock.startDate" meta - endDate = decodeTime . fromSome $ TM.lookup "cip-105/vestingLock.endDate" meta - --totalUnlockPeriod = intToDecimal . convertRelTimeToMicroseconds $ subTime endDate startDate - --unlockPeriodElapsed = intToDecimal . convertRelTimeToMicroseconds $ subTime currentDateTime startDate - --totalVestedPercentageElapsed : Decimal = unlockPeriodElapsed / totalUnlockPeriod - --withdrawnAmount = initialAmount - nextIterationFundAmount - - - + initialAmount : Decimal = fromSome . parseDecimal . fromSome $ TM.lookup "cip-105/vestingLock.initialAmount" meta + startDate : Time = decodeTime . fromSome $ TM.lookup "cip-105/vestingLock.startDate" meta + endDate : Time = decodeTime . fromSome $ TM.lookup "cip-105/vestingLock.endDate" meta + totalUnlockPeriod : Decimal = intToDecimal . convertRelTimeToMicroseconds $ subTime endDate startDate + unlockPeriodElapsed : Decimal = intToDecimal . convertRelTimeToMicroseconds $ subTime currentDateTime startDate + totalVestedRatioElapsed : Decimal = unlockPeriodElapsed / totalUnlockPeriod + withdrawnAmount = initialAmount - nextIterationFundAmount -- | The intention is to treat the overall aggregate lock for a SV as if it is @@ -159,8 +158,10 @@ template AggregateLock nextIterationFunding = Some $ TM.singleton instrumentId amount ] -- Choice temporarily lives here and it will be moved, testing/draft purposes atm - nonconsuming choice VestingLock_Withdraw : () -- AllocationResult + -- Note: this whole choice is WIP + nonconsuming choice VestingLock_Withdraw : () -- TODO: Return type possibly AllocationResult with + factoryCid : ContractId SettlementFactory authorizers : [Party] vestingLockCid : ContractId Allocation where @@ -168,10 +169,64 @@ template AggregateLock do -- remainingLocked stays in nextIterationFunding vestingLock <- view <$> fetch vestingLockCid + now <- getTime + -- TODO: Probably add valid unlockController set check? + -- TODO: Potentially do case statement with nextIterationFunding optionals require "There are no remaining funds to withdraw" $ isSome vestingLock.allocation.nextIterationFunding + + let mNextIterationFunding = TM.lookup instrumentId $ fromSome vestingLock.allocation.nextIterationFunding + require ("The instrumentId " <> show instrumentId <> ", does not exist under the nextIterationFunding") + $ isSome mNextIterationFunding require "There must be an initialAmount set for the vestingLock" $ TM.member "cip-105/vestingLock.initialAmount" vestingLock.allocation.meta.values + let (availableWithdrawAmount, remainingVestingAmount) : (Decimal, Decimal) = calculateAvailableWithdrawAmount now vestingLock + $ fromSome mNextIterationFunding + + require ("Current eligible withdraw amount for vesting lock is not greater than 0.0. " + <> "currentEligibleWithdrawableAmount came back = '" + <> show availableWithdrawAmount + <> "'." + ) + $ availableWithdrawAmount > 0.0 + + let + info = SettlementInfo with + executors = [ dso ] + id = "VestingLock_Withdraw" + cid = Some $ coerceContractId vestingLockCid + meta = emptyMetadata + transferLegId = "withdraw" + + exercise factoryCid $ SettlementFactory_SettleBatch with + settlement = info + actors = [ dso ] + extraArgs = ExtraArgs emptyChoiceContext emptyMetadata + transferLegs = + [ TransferLeg with + transferLegId + sender = vestingLock.allocation.authorizer + receiver = vestingLock.allocation.authorizer + amount = availableWithdrawAmount + instrumentId + meta = emptyMetadata + ] + allocations = + [ FinalizedAllocation with + allocationCid = vestingLockCid + extraTransferLegSides = + [ TransferLegSide with + transferLegId + side = SenderSide + otherside = vestingLock.allocation.authorizer + amount = remainingVestingAmount + instrumentId + meta = emptyMetadata + ] + nextIterationFunding = Some $ TM.singleton instrumentId remainingVestingAmount + ] + + pure () From fd4c8a75a6e17e8794fae8d6bfeaf854eaa974a9 Mon Sep 17 00:00:00 2001 From: "Jonathan D.K. Gibbons" Date: Mon, 20 Jul 2026 14:34:19 +0000 Subject: [PATCH 08/31] WIP: Test for lock-to-aggregate and unlock, and bug fixes. --- .../Splice/Scripts/TestAggregateLocks.daml | 109 ++++++++++++++++++ .../daml/Splice/AggregateLock.daml | 24 ++-- .../daml/Splice/AmuletAllocationV2.daml | 2 + 3 files changed, 128 insertions(+), 7 deletions(-) create mode 100644 daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.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..090f340218 --- /dev/null +++ b/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml @@ -0,0 +1,109 @@ +{-# 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 Splice.TokenStandard.Utils qualified as TSU +import Splice.Api.Token.AllocationV2 qualified as V2 +import Splice.Testing.TokenStandard.RegistryApiV2 +import Splice.Testing.TokenStandard.WalletClientV2 qualified as WalletClientV2 +import Splice.Testing.Registries.AmuletRegistryV2 +import Splice.Api.Token.MetadataV1 +import Splice.Api.Token.AllocationV2 +import Splice.Api.Token.AllocationInstructionV2 +import Splice.AmuletAllocationV2 +import Splice.Testing.Utils + +import Splice.ExternalPartyAmuletRules + +import DA.Assert +import DA.Optional +import DA.Functor +import DA.Foldable (mapA_, sequence_) +import qualified DA.TextMap as TM + +import Splice.Scripts.TokenStandard.TestAmuletTokenStandardTestEnv +import Splice.Testing.Registries.AmuletRegistryV2 qualified as AmuletRegistryV2 + + +lockForGovernance : TestEnv -> TM.TextMap Decimal -> Metadata -> Party -> ContractId AggregateLock -> Script AllocationInstructionResult +lockForGovernance (TestEnv {..}) amounts meta party aggCid = do + WalletClientV2.allocateV2 registries bob lockSettlementInfo lockAllocation + where + lockSettlementInfo = V2.SettlementInfo with + executors = [ instrId.admin ] + id = "AggregateLock" + cid = Some $ coerceContractId aggCid + meta = emptyMetadata + lockAllocation = V2.AllocationSpecification with + admin = instrId.admin + authorizer = TSU.basicAccount bob + transferLegSides = [] + committed = True + nextIterationFunding = Some $ amounts -- TM.fromList [(instrId.id, amount)] + settlementDeadline = Some maxBound + meta + +lockForAggregate : TestEnv -> Decimal -> Party -> ContractId AggregateLock -> Script AllocationInstructionResult +lockForAggregate te amount = lockForGovernance te (TM.fromList [(te.instrId.id, amount)]) $ Metadata $ TM.fromList + [ ("cip-105/type", "aggregateLock") ] + +lockForVesting : TestEnv -> Party -> ContractId AggregateLock -> Script AllocationInstructionResult +lockForVesting te = lockForGovernance te TM.empty $ Metadata $ TM.fromList + [ ("cip-105/type", "vestingLock") + , ("cip-105/vestingPeriod", "365") ] + +testAggregateLockHappyPath : Script () +testAggregateLockHappyPath = do + env@TestEnv{..} <- setupTest + now <- getTime + let dso = env.instrId.admin + + AmuletRegistryV2.tapFaucet registriesEnv.amuletV2 bob 1800.0 + + aggLock <- submit (actAs [alice, dso]) $ createCmd AggregateLock with + dso + instrumentId = env.instrId.id + vestingSchedule = VestingSchedule_SV + Some aggLockDisclosure <- queryDisclosure dso aggLock + + -- Lock some funds for the aggregate + AllocationInstructionResult { output = AllocationInstructionResult_Completed locked } <- lockForAggregate env 1000.0 bob aggLock + + -- Check total for the aggregate; needs to be implemented. + + AllocationInstructionResult { output = AllocationInstructionResult_Completed vestingInput } <- lockForVesting env bob aggLock + + -- use getSettlementFactory to get the extraArgs and disclosures to call AggregateLock_Unlock + enriched <- getSettlementFactory registriesEnv.amuletV2 $ SettlementFactory_SettleBatch with + settlement = SettlementInfo with + executors = [ dso ] + id = "AggregateLock" + cid = None + meta = emptyMetadata + actors = [ dso ] + extraArgs = emptyExtraArgs + transferLegs = [] + allocations = [] + + submit (actAs bob <> disclose aggLockDisclosure <> discloseMany' enriched.disclosures ) $ exerciseCmd aggLock $ AggregateLock_Unlock with + factoryCid = enriched.factoryCid + lockedCid = locked + withdrawToCid = vestingInput + authorizers = [ bob ] + amount = 100.0 + extraArgs = enriched.arg.extraArgs + + allocations <- query @AmuletAllocationV2 bob + + debug allocations + + assert $ length allocations == 2 + + -- Need to check other than via inspection that the two allocations actually exist with correct values. + diff --git a/daml/splice-amulet/daml/Splice/AggregateLock.daml b/daml/splice-amulet/daml/Splice/AggregateLock.daml index 292f85372a..a3dec7a4f2 100644 --- a/daml/splice-amulet/daml/Splice/AggregateLock.daml +++ b/daml/splice-amulet/daml/Splice/AggregateLock.daml @@ -23,7 +23,7 @@ isValidVestingLockedDestination dso alloc = and [ alloc.allocation.admin == dso , alloc.allocation.committed , null alloc.allocation.transferLegSides - , TM.lookup "cip-105/type" alloc.allocation.meta.values == Some "aggregateLock" + , TM.lookup "cip-105/type" alloc.allocation.meta.values == Some "vestingLock" , alloc.expiresAt == Some maxBound ] @@ -46,6 +46,15 @@ governanceLockedControllersFromMeta meta acctParty = GovernanceLockedControllers where controllerSetFromMetaMap key = fromOptional [[acctParty]] $ controllerSetFromMeta =<< key `TM.lookup` meta.values +data VestingSchedule + = VestingSchedule_SV + | VestingSchedule_Immediate + deriving (Show, Eq, Ord) + +validateVestingSchedule : VestingSchedule -> Metadata -> Bool +validateVestingSchedule VestingSchedule_SV meta = + TM.lookup "cip-105/vestingPeriod" meta.values == Some "365" -- Placeholder check, depends on vesting impl. +validateVestingSchedule VestingSchedule_Immediate _ = True -- | The intention is to treat the overall aggregate lock for a SV as if it is -- "the settlement" for the allocations locked to it, and use iterated @@ -58,7 +67,7 @@ template AggregateLock with dso: Party instrumentId : Text - allowImmediateUnlock : Bool + vestingSchedule : VestingSchedule where signatory dso nonconsuming choice AggregateLock_Unlock : SettlementFactory_SettleBatchResult @@ -68,6 +77,7 @@ template AggregateLock withdrawToCid : ContractId Allocation authorizers : [ Party ] amount : Decimal + extraArgs : ExtraArgs where controller authorizers do @@ -78,14 +88,14 @@ template AggregateLock let GovernanceLockedControllers{..} = governanceLockedControllersFromMeta locked.meta ownerParty require "Unlock controller must be one of the options" $ any (\conj -> all (`elem` authorizers) conj) unlockControllerSets require "Not allowed to unlock to a different party than locked the funds" $ locked.allocation.authorizer == withdrawTo.allocation.authorizer - if not allowImmediateUnlock then require "Unlock without vesting is only allowed when specifically enabled" False else pure () require "Must be a valid governance-locked allocation" $ isValidAggregateLockedAllocation dso locked require "Must be a valid empty vesting unlock allocation" $ isValidVestingLockedDestination dso withdrawTo require "Must have amounts reserved within the next settlement iteration" $ isSome locked.allocation.nextIterationFunding + require "Vesting allocation must have a correct schedule configuration" $ validateVestingSchedule vestingSchedule withdrawTo.allocation.meta let info = SettlementInfo with executors = [ dso ] - id = "AggregateLock_Unlock" + id = "AggregateLock" cid = Some $ coerceContractId self meta = emptyMetadata transferLegId = "unlock" @@ -97,7 +107,7 @@ template AggregateLock exercise factoryCid $ SettlementFactory_SettleBatch with settlement = info actors = [ dso ] - extraArgs = ExtraArgs emptyChoiceContext emptyMetadata + extraArgs transferLegs = [ TransferLeg with transferLegId @@ -125,11 +135,11 @@ template AggregateLock extraTransferLegSides = [ TransferLegSide with transferLegId - side = SenderSide + side = ReceiverSide otherside = locked.allocation.authorizer amount instrumentId meta = emptyMetadata ] - nextIterationFunding = Some $ TM.singleton instrumentId newLockedAmount + nextIterationFunding = Some $ TM.singleton instrumentId amount ] diff --git a/daml/splice-amulet/daml/Splice/AmuletAllocationV2.daml b/daml/splice-amulet/daml/Splice/AmuletAllocationV2.daml index 469ede68f2..088455e03f 100644 --- a/daml/splice-amulet/daml/Splice/AmuletAllocationV2.daml +++ b/daml/splice-amulet/daml/Splice/AmuletAllocationV2.daml @@ -230,7 +230,9 @@ computeAllocationExpiryInternal transferConfig oldExpiresAt settlementDeadline = | maxTime `subTime` oldExpiresAt <= maxTTL = maxTime | otherwise = oldExpiresAt `addRelTime` maxTTL +-- FIXME: the exception to allow Some maxBound is for infinite-duration governance locks, and should probably be limited more explicitly to only those cases. computeAllocationExpiry : TransferConfigV2 Amulet -> Time -> Optional Time -> Update Time +computeAllocationExpiry transferConfig oldExpiresAt settlementDeadline | settlementDeadline == Some maxBound = pure maxBound computeAllocationExpiry transferConfig oldExpiresAt settlementDeadline = do let expiresAt = computeAllocationExpiryInternal transferConfig oldExpiresAt settlementDeadline assertWithinDeadline "allocation.expiresAt" expiresAt From 29e569e5d0a6ff707e64073502e7feef43c38800 Mon Sep 17 00:00:00 2001 From: "Jonathan D.K. Gibbons" Date: Mon, 20 Jul 2026 17:47:37 +0000 Subject: [PATCH 09/31] fix tests --- .../daml/Splice/Scripts/TestAggregateLocks.daml | 12 +++++++----- daml/splice-amulet/daml/Splice/AggregateLock.daml | 15 ++++++++++----- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml b/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml index 090f340218..8a7968e8c2 100644 --- a/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml +++ b/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml @@ -29,6 +29,7 @@ import qualified DA.TextMap as TM import Splice.Scripts.TokenStandard.TestAmuletTokenStandardTestEnv import Splice.Testing.Registries.AmuletRegistryV2 qualified as AmuletRegistryV2 +import Splice.TokenStandard.Utils.Internal.Conversions (encodeTime) lockForGovernance : TestEnv -> TM.TextMap Decimal -> Metadata -> Party -> ContractId AggregateLock -> Script AllocationInstructionResult @@ -53,15 +54,15 @@ lockForAggregate : TestEnv -> Decimal -> Party -> ContractId AggregateLock -> Sc lockForAggregate te amount = lockForGovernance te (TM.fromList [(te.instrId.id, amount)]) $ Metadata $ TM.fromList [ ("cip-105/type", "aggregateLock") ] -lockForVesting : TestEnv -> Party -> ContractId AggregateLock -> Script AllocationInstructionResult -lockForVesting te = lockForGovernance te TM.empty $ Metadata $ TM.fromList +lockForVesting : Time -> TestEnv -> Party -> ContractId AggregateLock -> Script AllocationInstructionResult +lockForVesting now te = lockForGovernance te TM.empty $ Metadata $ TM.fromList [ ("cip-105/type", "vestingLock") - , ("cip-105/vestingPeriod", "365") ] + , ("cip-105/vestingLock.startDate", encodeTime now) + , ("cip-105/vestingLock.endDate", encodeTime $ addRelTime now $ days 365 + hours 6) ] testAggregateLockHappyPath : Script () testAggregateLockHappyPath = do env@TestEnv{..} <- setupTest - now <- getTime let dso = env.instrId.admin AmuletRegistryV2.tapFaucet registriesEnv.amuletV2 bob 1800.0 @@ -77,7 +78,8 @@ testAggregateLockHappyPath = do -- Check total for the aggregate; needs to be implemented. - AllocationInstructionResult { output = AllocationInstructionResult_Completed vestingInput } <- lockForVesting env bob aggLock + now <- getTime + AllocationInstructionResult { output = AllocationInstructionResult_Completed vestingInput } <- lockForVesting (addRelTime now $ minutes 5) env bob aggLock -- use getSettlementFactory to get the extraArgs and disclosures to call AggregateLock_Unlock enriched <- getSettlementFactory registriesEnv.amuletV2 $ SettlementFactory_SettleBatch with diff --git a/daml/splice-amulet/daml/Splice/AggregateLock.daml b/daml/splice-amulet/daml/Splice/AggregateLock.daml index 201b3934b1..fc703823fe 100644 --- a/daml/splice-amulet/daml/Splice/AggregateLock.daml +++ b/daml/splice-amulet/daml/Splice/AggregateLock.daml @@ -56,10 +56,14 @@ data VestingSchedule | VestingSchedule_Immediate deriving (Show, Eq, Ord) -validateVestingSchedule : VestingSchedule -> Metadata -> Bool -validateVestingSchedule VestingSchedule_SV meta = - TM.lookup "cip-105/vestingPeriod" meta.values == Some "365" -- Placeholder check, depends on vesting impl. -validateVestingSchedule VestingSchedule_Immediate _ = True +validateVestingSchedule : VestingSchedule -> Time -> Metadata -> Bool +validateVestingSchedule VestingSchedule_SV now meta = + let + start = decodeTime $ fromSomeNote "start must exist" (TM.lookup "cip-105/vestingLock.startDate" meta.values) + end = decodeTime $ fromSomeNote "end must exist" (TM.lookup "cip-105/vestingLock.endDate" meta.values) + in + now < start && end `subTime` start == days 365 + hours 6 +validateVestingSchedule VestingSchedule_Immediate _ _ = True -- TODO: Potentially make return type, it's own data type? calculateAvailableWithdrawAmount : Time -> V2.AllocationView -> Decimal -> (Decimal, Decimal) @@ -115,7 +119,8 @@ template AggregateLock require "Must be a valid governance-locked allocation" $ isValidAggregateLockedAllocation dso locked require "Must be a valid empty vesting unlock allocation" $ isValidVestingLockedDestination dso withdrawTo require "Must have amounts reserved within the next settlement iteration" $ isSome locked.allocation.nextIterationFunding - require "Vesting allocation must have a correct schedule configuration" $ validateVestingSchedule vestingSchedule withdrawTo.allocation.meta + now <- getTime + require "Vesting allocation must have a correct schedule configuration" $ validateVestingSchedule vestingSchedule now withdrawTo.allocation.meta let info = SettlementInfo with executors = [ dso ] From 7e1be84170775c3606e496b9d97a7b10f0a27c2d Mon Sep 17 00:00:00 2001 From: Deepak Birdi Date: Mon, 20 Jul 2026 21:21:09 +0000 Subject: [PATCH 10/31] WIP Start adding VestingLock Testing --- .../Splice/Scripts/TestAggregateLocks.daml | 34 ++++++++++++++++--- .../daml/Splice/AggregateLock.daml | 19 ++++++++--- .../TestTokenV2/TestAllocationHappyV2.daml | 26 -------------- 3 files changed, 43 insertions(+), 36 deletions(-) diff --git a/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml b/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml index 8a7968e8c2..46ab6785ca 100644 --- a/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml +++ b/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml @@ -25,6 +25,7 @@ import DA.Assert import DA.Optional import DA.Functor import DA.Foldable (mapA_, sequence_) +import DA.List (last) import qualified DA.TextMap as TM import Splice.Scripts.TokenStandard.TestAmuletTokenStandardTestEnv @@ -54,11 +55,13 @@ lockForAggregate : TestEnv -> Decimal -> Party -> ContractId AggregateLock -> Sc lockForAggregate te amount = lockForGovernance te (TM.fromList [(te.instrId.id, amount)]) $ Metadata $ TM.fromList [ ("cip-105/type", "aggregateLock") ] -lockForVesting : Time -> TestEnv -> Party -> ContractId AggregateLock -> Script AllocationInstructionResult -lockForVesting now te = lockForGovernance te TM.empty $ Metadata $ TM.fromList +lockForVesting : Decimal -> Time -> TestEnv -> Party -> ContractId AggregateLock -> Script AllocationInstructionResult +lockForVesting initialAmount now te = lockForGovernance te TM.empty $ 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.endDate", encodeTime $ addRelTime now $ days 365 + hours 6) + , ("cip-105/vestingLock.initialAmount", show initialAmount) + ] testAggregateLockHappyPath : Script () testAggregateLockHappyPath = do @@ -79,7 +82,8 @@ testAggregateLockHappyPath = do -- Check total for the aggregate; needs to be implemented. now <- getTime - AllocationInstructionResult { output = AllocationInstructionResult_Completed vestingInput } <- lockForVesting (addRelTime now $ minutes 5) env bob aggLock + let initialAmountToUnlock = 100.0 + AllocationInstructionResult { output = AllocationInstructionResult_Completed vestingInput } <- lockForVesting initialAmountToUnlock (addRelTime now $ minutes 5) env bob aggLock -- use getSettlementFactory to get the extraArgs and disclosures to call AggregateLock_Unlock enriched <- getSettlementFactory registriesEnv.amuletV2 $ SettlementFactory_SettleBatch with @@ -98,7 +102,7 @@ testAggregateLockHappyPath = do lockedCid = locked withdrawToCid = vestingInput authorizers = [ bob ] - amount = 100.0 + amount = initialAmountToUnlock extraArgs = enriched.arg.extraArgs allocations <- query @AmuletAllocationV2 bob @@ -109,3 +113,23 @@ testAggregateLockHappyPath = do -- Need to check other than via inspection that the two allocations actually exist with correct values. + -- TODO: Temporary, bad to do this + let (vestingLockAllocationCid, _) = last allocations + + -- 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 <> disclose aggLockDisclosure <> discloseMany' enriched.disclosures ) $ exerciseCmd aggLock $ VestingLock_Withdraw with + factoryCid = enriched.factoryCid + authorizers = [ bob ] + vestingLockCid = toInterfaceContractId vestingLockAllocationCid + + -- Time needs to pass to be able to withdraw fron the VestedAmulet + passTime (days 10) + + submit (actAs bob <> disclose aggLockDisclosure <> discloseMany' enriched.disclosures ) $ exerciseCmd aggLock $ VestingLock_Withdraw with + factoryCid = enriched.factoryCid + authorizers = [ bob ] + vestingLockCid = toInterfaceContractId vestingLockAllocationCid + +-- assertEq False True + pure () diff --git a/daml/splice-amulet/daml/Splice/AggregateLock.daml b/daml/splice-amulet/daml/Splice/AggregateLock.daml index fc703823fe..a4fe168c12 100644 --- a/daml/splice-amulet/daml/Splice/AggregateLock.daml +++ b/daml/splice-amulet/daml/Splice/AggregateLock.daml @@ -174,7 +174,7 @@ template AggregateLock ] -- Choice temporarily lives here and it will be moved, testing/draft purposes atm -- Note: this whole choice is WIP - nonconsuming choice VestingLock_Withdraw : () -- TODO: Return type possibly AllocationResult + nonconsuming choice VestingLock_Withdraw : AllocationResult with factoryCid : ContractId SettlementFactory authorizers : [Party] @@ -210,12 +210,12 @@ template AggregateLock let info = SettlementInfo with executors = [ dso ] - id = "VestingLock_Withdraw" - cid = Some $ coerceContractId vestingLockCid + id = "AggregateLock" + cid = Some $ coerceContractId self meta = emptyMetadata transferLegId = "withdraw" - exercise factoryCid $ SettlementFactory_SettleBatch with + batchResults <- exercise factoryCid $ SettlementFactory_SettleBatch with settlement = info actors = [ dso ] extraArgs = ExtraArgs emptyChoiceContext emptyMetadata @@ -239,9 +239,18 @@ template AggregateLock amount = remainingVestingAmount instrumentId meta = emptyMetadata + , TransferLegSide with + transferLegId + side = ReceiverSide + otherside = vestingLock.allocation.authorizer + amount = remainingVestingAmount + instrumentId + meta = emptyMetadata ] nextIterationFunding = Some $ TM.singleton instrumentId remainingVestingAmount ] + -- TODO: Fix hacky way potentially + let (allocationResult::_) = batchResults.allocationSettleResults - pure () + pure allocationResult diff --git a/token-standard/examples/splice-test-token-v2-test/daml/Splice/Testing/Tokens/TestTokenV2/TestAllocationHappyV2.daml b/token-standard/examples/splice-test-token-v2-test/daml/Splice/Testing/Tokens/TestTokenV2/TestAllocationHappyV2.daml index 9fc600beaa..c106baad90 100644 --- a/token-standard/examples/splice-test-token-v2-test/daml/Splice/Testing/Tokens/TestTokenV2/TestAllocationHappyV2.daml +++ b/token-standard/examples/splice-test-token-v2-test/daml/Splice/Testing/Tokens/TestTokenV2/TestAllocationHappyV2.daml @@ -25,7 +25,6 @@ import Splice.Testing.Tokens.TestTokenV2 qualified as TestTokenV2 import Splice.Testing.Utils import Splice.Testing.Registries.TestTokenV2_RegistryV2 qualified as TestTokenRegistryV2 -import Splice.TokenStandard.Utils.Internal.Conversions (encodeTime) import Splice.Testing.TokenStandard.RegistryApiV2 qualified as V2 import Splice.Testing.TokenStandard.WalletClientV2 qualified as WalletClientV2 import Splice.Testing.TokenStandard.MultiRegistry qualified as MultiRegistry @@ -47,31 +46,6 @@ test_allocation_WithdrawV2 = test_allocation_withdrawCancel_generic $ \TestEnv { meta = emptyMetadata pure () --- This is a temporary test script -test_vesting_lock_WithdrawV2 : Script () -test_vesting_lock_WithdrawV2 = do - TestEnv {..} <- setupTest - now <- getTime - let endDate : Time = addRelTime now $ (days 365) + (hours 6) - vestingInitialAmount : Decimal = 3652.5 - - let allocationV2 = V2.AllocationSpecification with - admin = instrId.admin - authorizer = TSU.basicAccount alice - transferLegSides = [] - committed = False - nextIterationFunding = Some $ TextMap.fromList [(instrId.id, vestingInitialAmount)] - settlementDeadline = None - meta = Metadata with - values = TextMap.fromList - [ ("cip-105/type", "vestingLock") - , ("cip-105/vestingLock.initialAmount", show vestingInitialAmount) - , ("cip-105/vestingLock.startDate", encodeTime now) - , ("cip-105/vestingLock.endDate", encodeTime endDate) - ] - - pure () - -- TODO(tech-debt): add support for dealing with stale references to locked tokens -- -- Handle them analogous to how Amulet handles them and copy the relevant tests from Amulet. From 661fbe57841dadb0bc431b5b7da3e607698ba522 Mon Sep 17 00:00:00 2001 From: Deepak Birdi Date: Tue, 21 Jul 2026 18:25:21 +0000 Subject: [PATCH 11/31] WIP Flush out vestingUnlock tests more --- .../Splice/Scripts/TestAggregateLocks.daml | 32 ++++++++++++++++--- .../daml/Splice/AggregateLock.daml | 10 +++--- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml b/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml index 46ab6785ca..6d0faab17e 100644 --- a/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml +++ b/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml @@ -16,6 +16,7 @@ import Splice.Testing.Registries.AmuletRegistryV2 import Splice.Api.Token.MetadataV1 import Splice.Api.Token.AllocationV2 import Splice.Api.Token.AllocationInstructionV2 +import Splice.Api.Token.HoldingV2 import Splice.AmuletAllocationV2 import Splice.Testing.Utils @@ -25,7 +26,7 @@ import DA.Assert import DA.Optional import DA.Functor import DA.Foldable (mapA_, sequence_) -import DA.List (last) +import DA.List (head) import qualified DA.TextMap as TM import Splice.Scripts.TokenStandard.TestAmuletTokenStandardTestEnv @@ -82,7 +83,7 @@ testAggregateLockHappyPath = do -- Check total for the aggregate; needs to be implemented. now <- getTime - let initialAmountToUnlock = 100.0 + let initialAmountToUnlock = 365.25 AllocationInstructionResult { output = AllocationInstructionResult_Completed vestingInput } <- lockForVesting initialAmountToUnlock (addRelTime now $ minutes 5) env bob aggLock -- use getSettlementFactory to get the extraArgs and disclosures to call AggregateLock_Unlock @@ -114,7 +115,7 @@ testAggregateLockHappyPath = do -- Need to check other than via inspection that the two allocations actually exist with correct values. -- TODO: Temporary, bad to do this - let (vestingLockAllocationCid, _) = last allocations + let vestingLockAllocationCid = head [cid | (cid, a) <- allocations, TM.member "cip-105/vestingLock.initialAmount" a.allocation.meta.values] -- Immediate withdraw attempt causes transaction to fail due to the current -- eligible withdraw amount being zero (no time has passed since locking yet) @@ -122,14 +123,37 @@ testAggregateLockHappyPath = do factoryCid = enriched.factoryCid authorizers = [ bob ] vestingLockCid = toInterfaceContractId vestingLockAllocationCid + extraArgs = enriched.arg.extraArgs -- Time needs to pass to be able to withdraw fron the VestedAmulet passTime (days 10) - submit (actAs bob <> disclose aggLockDisclosure <> discloseMany' enriched.disclosures ) $ exerciseCmd aggLock $ VestingLock_Withdraw with + (AllocationResult vestingAllocResult tmHoldingCids _) <- submit (actAs bob <> disclose aggLockDisclosure <> discloseMany' enriched.disclosures ) $ exerciseCmd aggLock $ VestingLock_Withdraw with factoryCid = enriched.factoryCid authorizers = [ bob ] vestingLockCid = toInterfaceContractId vestingLockAllocationCid + extraArgs = enriched.arg.extraArgs + + -- Time needs to pass to be able to withdraw fron the VestedAmulet + passTime (days 365) + + let holdingCids = fromSome $ TM.lookup env.instrId.id tmHoldingCids + debug holdingCids + + holdings <- queryInterface @Holding bob + debug holdings + let [(_, (Some unlockedHolding))] = filter (\(hcid, (Some h)) -> isNone h.lock && hcid `elem` holdingCids) holdings + debug unlockedHolding + + let tolerance = 0.01 + assert $ (abs $ unlockedHolding.amount - 10.0) < tolerance + +-- submit (actAs bob <> disclose aggLockDisclosure <> discloseMany' enriched.disclosures ) $ exerciseCmd aggLock $ VestingLock_Withdraw with +-- factoryCid = enriched.factoryCid +-- authorizers = [ bob ] +-- vestingLockCid = toInterfaceContractId vestingLockAllocationCid +-- extraArgs = enriched.arg.extraArgs +-- -- assertEq False True pure () diff --git a/daml/splice-amulet/daml/Splice/AggregateLock.daml b/daml/splice-amulet/daml/Splice/AggregateLock.daml index a4fe168c12..c8e7104b22 100644 --- a/daml/splice-amulet/daml/Splice/AggregateLock.daml +++ b/daml/splice-amulet/daml/Splice/AggregateLock.daml @@ -179,6 +179,7 @@ template AggregateLock factoryCid : ContractId SettlementFactory authorizers : [Party] vestingLockCid : ContractId Allocation + extraArgs : ExtraArgs where controller authorizers do @@ -194,6 +195,7 @@ template AggregateLock let mNextIterationFunding = TM.lookup instrumentId $ fromSome vestingLock.allocation.nextIterationFunding require ("The instrumentId " <> show instrumentId <> ", does not exist under the nextIterationFunding") $ isSome mNextIterationFunding + -- TODO: DOUBLE we get the right type in meta values require "There must be an initialAmount set for the vestingLock" $ TM.member "cip-105/vestingLock.initialAmount" vestingLock.allocation.meta.values @@ -218,7 +220,7 @@ template AggregateLock batchResults <- exercise factoryCid $ SettlementFactory_SettleBatch with settlement = info actors = [ dso ] - extraArgs = ExtraArgs emptyChoiceContext emptyMetadata + extraArgs transferLegs = [ TransferLeg with transferLegId @@ -236,14 +238,14 @@ template AggregateLock transferLegId side = SenderSide otherside = vestingLock.allocation.authorizer - amount = remainingVestingAmount + amount = availableWithdrawAmount instrumentId meta = emptyMetadata , TransferLegSide with transferLegId side = ReceiverSide otherside = vestingLock.allocation.authorizer - amount = remainingVestingAmount + amount = availableWithdrawAmount instrumentId meta = emptyMetadata ] @@ -251,6 +253,6 @@ template AggregateLock ] -- TODO: Fix hacky way potentially - let (allocationResult::_) = batchResults.allocationSettleResults + let [allocationResult] = batchResults.allocationSettleResults pure allocationResult From f722c80e62b6fed02d7d22785faf1665aff4f571 Mon Sep 17 00:00:00 2001 From: Deepak Birdi Date: Tue, 21 Jul 2026 20:56:05 +0000 Subject: [PATCH 12/31] Finish basic happy test for vestingUnlock --- .../Splice/Scripts/TestAggregateLocks.daml | 29 ++++++++++++------- .../daml/Splice/AggregateLock.daml | 5 +++- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml b/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml index 6d0faab17e..83ac4e4849 100644 --- a/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml +++ b/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml @@ -133,27 +133,34 @@ testAggregateLockHappyPath = do authorizers = [ bob ] vestingLockCid = toInterfaceContractId vestingLockAllocationCid extraArgs = enriched.arg.extraArgs + debug vestingAllocResult -- Time needs to pass to be able to withdraw fron the VestedAmulet passTime (days 365) let holdingCids = fromSome $ TM.lookup env.instrId.id tmHoldingCids - debug holdingCids - holdings <- queryInterface @Holding bob - debug holdings + -- The holdings would include both locked/unlocked, so we filter let [(_, (Some unlockedHolding))] = filter (\(hcid, (Some h)) -> isNone h.lock && hcid `elem` holdingCids) holdings debug unlockedHolding let tolerance = 0.01 assert $ (abs $ unlockedHolding.amount - 10.0) < tolerance --- submit (actAs bob <> disclose aggLockDisclosure <> discloseMany' enriched.disclosures ) $ exerciseCmd aggLock $ VestingLock_Withdraw with --- factoryCid = enriched.factoryCid --- authorizers = [ bob ] --- vestingLockCid = toInterfaceContractId vestingLockAllocationCid --- extraArgs = enriched.arg.extraArgs + (AllocationResult vestingAllocResult' tmHoldingCids' _) <- submit (actAs bob <> disclose aggLockDisclosure <> discloseMany' enriched.disclosures ) $ exerciseCmd aggLock $ VestingLock_Withdraw with + factoryCid = enriched.factoryCid + authorizers = [ bob ] + vestingLockCid = fromSome $ vestingAllocResult.nextIterationAllocationCid + extraArgs = enriched.arg.extraArgs + + debug vestingAllocResult' + -- We should have no more remaining amount vesting, once the full period has passed + assertEq None $ vestingAllocResult'.nextIterationAllocationCid + + let holdingCids' = fromSome $ TM.lookup env.instrId.id tmHoldingCids' + holdings' <- queryInterface @Holding bob + -- The holdings would include both locked/unlocked, so we filter + let [(_, (Some unlockedHolding'))] = filter (\(hcid, (Some h)) -> isNone h.lock && hcid `elem` holdingCids') holdings' + debug unlockedHolding' --- --- assertEq False True - pure () + assertEq initialAmountToUnlock $ unlockedHolding.amount + unlockedHolding'.amount diff --git a/daml/splice-amulet/daml/Splice/AggregateLock.daml b/daml/splice-amulet/daml/Splice/AggregateLock.daml index c8e7104b22..b1198a2bfc 100644 --- a/daml/splice-amulet/daml/Splice/AggregateLock.daml +++ b/daml/splice-amulet/daml/Splice/AggregateLock.daml @@ -216,6 +216,9 @@ template AggregateLock cid = Some $ coerceContractId self meta = emptyMetadata transferLegId = "withdraw" + nextIterationFunding + | remainingVestingAmount <= 0.0 = None + | otherwise = Some $ TM.singleton instrumentId remainingVestingAmount batchResults <- exercise factoryCid $ SettlementFactory_SettleBatch with settlement = info @@ -249,7 +252,7 @@ template AggregateLock instrumentId meta = emptyMetadata ] - nextIterationFunding = Some $ TM.singleton instrumentId remainingVestingAmount + nextIterationFunding = nextIterationFunding ] -- TODO: Fix hacky way potentially From 457febe936b23b8a302fbd8b88a053f5c576ebc9 Mon Sep 17 00:00:00 2001 From: Deepak Birdi Date: Tue, 21 Jul 2026 22:01:14 +0000 Subject: [PATCH 13/31] Move passTime within vesting happy path test --- .../daml/Splice/Scripts/TestAggregateLocks.daml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml b/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml index 83ac4e4849..c1ebd483d7 100644 --- a/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml +++ b/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml @@ -135,9 +135,6 @@ testAggregateLockHappyPath = do extraArgs = enriched.arg.extraArgs debug vestingAllocResult - -- Time needs to pass to be able to withdraw fron the VestedAmulet - passTime (days 365) - let holdingCids = fromSome $ TM.lookup env.instrId.id tmHoldingCids holdings <- queryInterface @Holding bob -- The holdings would include both locked/unlocked, so we filter @@ -147,6 +144,9 @@ testAggregateLockHappyPath = do let tolerance = 0.01 assert $ (abs $ unlockedHolding.amount - 10.0) < tolerance + -- Let's try to withdraw the rest (now we should be past endDate) + passTime (days 365) + (AllocationResult vestingAllocResult' tmHoldingCids' _) <- submit (actAs bob <> disclose aggLockDisclosure <> discloseMany' enriched.disclosures ) $ exerciseCmd aggLock $ VestingLock_Withdraw with factoryCid = enriched.factoryCid authorizers = [ bob ] From 15dfa701bdbf30093e66fc2c205883b0990ecdf7 Mon Sep 17 00:00:00 2001 From: Cale Gibbard Date: Wed, 22 Jul 2026 16:28:28 -0400 Subject: [PATCH 14/31] Add a script which constructs a fake single package for speeding up development cycles by allowing the LSP to work cross-package when working on code. --- .gitignore | 2 ++ daml-ide-mono/README.md | 15 +++++++++++++++ daml-ide-mono/daml.yaml | 17 +++++++++++++++++ scripts/setup-mono-package.sh | 29 +++++++++++++++++++++++++++++ 4 files changed, 63 insertions(+) create mode 100644 daml-ide-mono/README.md create mode 100644 daml-ide-mono/daml.yaml create mode 100755 scripts/setup-mono-package.sh diff --git a/.gitignore b/.gitignore index 2c56be64b4..230671f341 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,8 @@ _build/ **/metals.sbt **/.scala-build/* +daml-ide-mono/daml/ +daml-ide-mono/.vscode/ .vscode/* !.vscode/settings.json # Make sure test files are checkedin diff --git a/daml-ide-mono/README.md b/daml-ide-mono/README.md new file mode 100644 index 0000000000..4a24340200 --- /dev/null +++ b/daml-ide-mono/README.md @@ -0,0 +1,15 @@ +# What is this? + +This directory, along with its fake `daml.yaml` can be populated with symlinks by +`./scripts/setup-mono-package.sh` to serve as a fake single-dar package containing all the daml in +Splice such that cross-package changes can be worked on with a tighter feedback loop using the +Daml Language Server (LSP) in VS Code or other editors, e.g. by running `dpm studio` in this directory +after having run the script. + +You'll also have to remember to re-run the script if you add new `.daml` files to any package. + +You should still verify that everything builds/tests for real once you're done of course, but this mode +of interaction can be very useful to eliminate 12-20 second build cycles and instead get immediate +feedback from the VS Code extension when working on tests and making changes that would otherwise be in +their upstream DAR dependencies, for example. + diff --git a/daml-ide-mono/daml.yaml b/daml-ide-mono/daml.yaml new file mode 100644 index 0000000000..e998fdd9a9 --- /dev/null +++ b/daml-ide-mono/daml.yaml @@ -0,0 +1,17 @@ +sdk-version: 3.5.2 +name: splice-mono-ide +source: daml +version: 0.0.1 +dependencies: + - daml-prim + - daml-stdlib + - daml-script +build-options: + - --ghc-option=-Wunused-binds + - --ghc-option=-Wunused-matches + - --target=2.1 + - -Wno-upgrade-exceptions + - -Wno-deprecated-exceptions + - -Wno-template-interface-depends-on-daml-script + - -Wno-upgrade-interfaces + - --force-utility-package=no diff --git a/scripts/setup-mono-package.sh b/scripts/setup-mono-package.sh new file mode 100755 index 0000000000..d12b164336 --- /dev/null +++ b/scripts/setup-mono-package.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Populate daml-ide-mono/daml/ with per-file symlinks into every workspace +# Daml package's source tree. The synthesised daml-ide-mono/daml.yaml is +# checked in and static - this script only regenerates the source tree +# so that VS Code can work on the union as a single package. +# +# Re-run this any time you add a new .daml file to the workspace or add +# a new workspace package. Symlinks are relative, so `mv`-ing the repo +# leaves them working. +set -euo pipefail + +repo=$(cd "$(dirname "$0")/.." && pwd) +dest="$repo/daml-ide-mono/daml" + +rm -rf "$dest" +mkdir -p "$dest" + +for pkg in "$repo"/daml/*/daml.yaml \ + "$repo"/token-standard/*/daml.yaml \ + "$repo"/token-standard/examples/*/daml.yaml; do + src=$(dirname "$pkg")/daml + [ -d "$src" ] || continue + ( cd "$src" && find . -name '*.daml' -printf '%P\n' ) | + while IFS= read -r rel; do + link="$dest/$rel" + mkdir -p "$(dirname "$link")" + ln -sfn "$src/$rel" "$link" + done +done From e4e35f7fbee57de82fb1c53bd5a95356f1100215 Mon Sep 17 00:00:00 2001 From: Deepak Birdi Date: Wed, 22 Jul 2026 20:47:35 +0000 Subject: [PATCH 15/31] Fix some issues related to PR comments for VestingLock --- .../Splice/Scripts/TestAggregateLocks.daml | 4 +- .../daml/Splice/AggregateLock.daml | 42 +++++++++++-------- .../TestTokenV2/TestAllocationHappyV2.daml | 1 - 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml b/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml index c1ebd483d7..acd13f9c5c 100644 --- a/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml +++ b/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml @@ -71,7 +71,7 @@ testAggregateLockHappyPath = do AmuletRegistryV2.tapFaucet registriesEnv.amuletV2 bob 1800.0 - aggLock <- submit (actAs [alice, dso]) $ createCmd AggregateLock with + aggLock <- submit (actAs dso) $ createCmd AggregateLock with dso instrumentId = env.instrId.id vestingSchedule = VestingSchedule_SV @@ -125,7 +125,7 @@ testAggregateLockHappyPath = do vestingLockCid = toInterfaceContractId vestingLockAllocationCid extraArgs = enriched.arg.extraArgs - -- Time needs to pass to be able to withdraw fron the VestedAmulet + -- Time needs to pass to be able to withdraw from the VestingLock passTime (days 10) (AllocationResult vestingAllocResult tmHoldingCids _) <- submit (actAs bob <> disclose aggLockDisclosure <> discloseMany' enriched.disclosures ) $ exerciseCmd aggLock $ VestingLock_Withdraw with diff --git a/daml/splice-amulet/daml/Splice/AggregateLock.daml b/daml/splice-amulet/daml/Splice/AggregateLock.daml index b1198a2bfc..f817d1b041 100644 --- a/daml/splice-amulet/daml/Splice/AggregateLock.daml +++ b/daml/splice-amulet/daml/Splice/AggregateLock.daml @@ -3,10 +3,12 @@ module Splice.AggregateLock where import Splice.Api.Token.AllocationV2 as V2 import Splice.Api.Token.MetadataV1 import Splice.TokenStandard.Utils.Internal.Conversions (decodeTime) +import Splice.Types (ForDso(..)) import Splice.Util import DA.Optional import DA.Text +import qualified DA.List as L import qualified DA.TextMap as TM import DA.Time @@ -28,7 +30,6 @@ isValidVestingLockedDestination dso alloc = , alloc.expiresAt == Some maxBound , TM.member "cip-105/vestingLock.startDate" metaValues , TM.member "cip-105/vestingLock.endDate" metaValues - -- TODO: Check the period is valid, check startDate < endDate ] where metaValues = alloc.allocation.meta.values @@ -109,8 +110,8 @@ template AggregateLock where controller authorizers do - locked <- view <$> fetch lockedCid - withdrawTo <- view <$> fetch withdrawToCid + locked <- view <$> fetchCheckedInterface (ForDso with dso) lockedCid + withdrawTo <- view <$> fetchCheckedInterface (ForDso with dso) withdrawToCid ownerParty <- whenNone locked.allocation.authorizer.owner $ assertFail "The requirement 'governance locked allocations must be owned by basic accounts' was not met" let GovernanceLockedControllers{..} = governanceLockedControllersFromMeta locked.meta ownerParty @@ -183,26 +184,29 @@ template AggregateLock where controller authorizers do - -- remainingLocked stays in nextIterationFunding - vestingLock <- view <$> fetch vestingLockCid + vestingLock <- view <$> fetchCheckedInterface (ForDso with dso) vestingLockCid now <- getTime + ownerParty <- whenNone vestingLock.allocation.authorizer.owner $ + assertFail "The requirement 'governance locked allocations must be owned by basic accounts' was not met" - -- TODO: Probably add valid unlockController set check? - -- TODO: Potentially do case statement with nextIterationFunding optionals - require "There are no remaining funds to withdraw" - $ isSome vestingLock.allocation.nextIterationFunding - - let mNextIterationFunding = TM.lookup instrumentId $ fromSome vestingLock.allocation.nextIterationFunding - require ("The instrumentId " <> show instrumentId <> ", does not exist under the nextIterationFunding") - $ isSome mNextIterationFunding - -- TODO: DOUBLE we get the right type in meta values + require "Must be a valid vesting unlock allocation" $ isValidVestingLockedDestination dso vestingLock require "There must be an initialAmount set for the vestingLock" $ TM.member "cip-105/vestingLock.initialAmount" vestingLock.allocation.meta.values + let GovernanceLockedControllers{..} = governanceLockedControllersFromMeta vestingLock.meta ownerParty + require "Unlock controller must be one of the options" $ any (\conj -> all (`elem` authorizers) conj) unlockControllerSets + + let mNextIterationFunding = case vestingLock.allocation.nextIterationFunding of + None -> None + Some nextIterationFundingTM -> + TM.lookup instrumentId nextIterationFundingTM + require ("VestingLock's nextIterationFunding should have remaining funds to withdraw for instrumentId " <> show instrumentId) + $ isSome mNextIterationFunding + let nextIterationFundingAmount = fromSome mNextIterationFunding - let (availableWithdrawAmount, remainingVestingAmount) : (Decimal, Decimal) = calculateAvailableWithdrawAmount now vestingLock - $ fromSome mNextIterationFunding + let (availableWithdrawAmount, remainingVestingAmount) : (Decimal, Decimal) = + calculateAvailableWithdrawAmount now vestingLock nextIterationFundingAmount - require ("Current eligible withdraw amount for vesting lock is not greater than 0.0. " + require ("Current eligible withdraw amount for vesting lock should be greater than 0.0. " <> "currentEligibleWithdrawableAmount came back = '" <> show availableWithdrawAmount <> "'." @@ -255,7 +259,9 @@ template AggregateLock nextIterationFunding = nextIterationFunding ] - -- TODO: Fix hacky way potentially + -- We should have one result from settleBatch + require "VestingUnlock_Withdraw should result in one allocation result. " $ + L.length batchResults.allocationSettleResults == 1 let [allocationResult] = batchResults.allocationSettleResults pure allocationResult diff --git a/token-standard/examples/splice-test-token-v2-test/daml/Splice/Testing/Tokens/TestTokenV2/TestAllocationHappyV2.daml b/token-standard/examples/splice-test-token-v2-test/daml/Splice/Testing/Tokens/TestTokenV2/TestAllocationHappyV2.daml index c106baad90..43cb979150 100644 --- a/token-standard/examples/splice-test-token-v2-test/daml/Splice/Testing/Tokens/TestTokenV2/TestAllocationHappyV2.daml +++ b/token-standard/examples/splice-test-token-v2-test/daml/Splice/Testing/Tokens/TestTokenV2/TestAllocationHappyV2.daml @@ -10,7 +10,6 @@ import DA.Assert import DA.List (sort) import DA.Map qualified as Map import DA.TextMap qualified as TextMap -import DA.Time (addRelTime, days, hours) import Daml.Script From 4b29e95040d709d432e3ac4a3c7936fa1c214833 Mon Sep 17 00:00:00 2001 From: Deepak Birdi Date: Fri, 24 Jul 2026 19:08:10 +0000 Subject: [PATCH 16/31] Address some CIP-0105 PR comments --- .../Splice/Scripts/TestAggregateLocks.daml | 10 ++---- .../daml/Splice/AggregateLock.daml | 34 +++++++++---------- .../Utils/Internal/Conversions.daml | 1 - 3 files changed, 19 insertions(+), 26 deletions(-) diff --git a/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml b/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml index acd13f9c5c..c3c69b5919 100644 --- a/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml +++ b/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml @@ -84,7 +84,7 @@ testAggregateLockHappyPath = do now <- getTime let initialAmountToUnlock = 365.25 - AllocationInstructionResult { output = AllocationInstructionResult_Completed vestingInput } <- lockForVesting initialAmountToUnlock (addRelTime now $ minutes 5) env bob aggLock + AllocationInstructionResult { output = AllocationInstructionResult_Completed vestingInput } <- lockForVesting initialAmountToUnlock now env bob aggLock -- use getSettlementFactory to get the extraArgs and disclosures to call AggregateLock_Unlock enriched <- getSettlementFactory registriesEnv.amuletV2 $ SettlementFactory_SettleBatch with @@ -108,7 +108,6 @@ testAggregateLockHappyPath = do allocations <- query @AmuletAllocationV2 bob - debug allocations assert $ length allocations == 2 @@ -133,16 +132,13 @@ testAggregateLockHappyPath = do authorizers = [ bob ] vestingLockCid = toInterfaceContractId vestingLockAllocationCid extraArgs = enriched.arg.extraArgs - debug vestingAllocResult let holdingCids = fromSome $ TM.lookup env.instrId.id tmHoldingCids holdings <- queryInterface @Holding bob -- The holdings would include both locked/unlocked, so we filter let [(_, (Some unlockedHolding))] = filter (\(hcid, (Some h)) -> isNone h.lock && hcid `elem` holdingCids) holdings - debug unlockedHolding - let tolerance = 0.01 - assert $ (abs $ unlockedHolding.amount - 10.0) < tolerance + assertEq unlockedHolding.amount 10.0000000105 -- Let's try to withdraw the rest (now we should be past endDate) passTime (days 365) @@ -153,7 +149,6 @@ testAggregateLockHappyPath = do vestingLockCid = fromSome $ vestingAllocResult.nextIterationAllocationCid extraArgs = enriched.arg.extraArgs - debug vestingAllocResult' -- We should have no more remaining amount vesting, once the full period has passed assertEq None $ vestingAllocResult'.nextIterationAllocationCid @@ -161,6 +156,5 @@ testAggregateLockHappyPath = do holdings' <- queryInterface @Holding bob -- The holdings would include both locked/unlocked, so we filter let [(_, (Some unlockedHolding'))] = filter (\(hcid, (Some h)) -> isNone h.lock && hcid `elem` holdingCids') holdings' - debug unlockedHolding' assertEq initialAmountToUnlock $ unlockedHolding.amount + unlockedHolding'.amount diff --git a/daml/splice-amulet/daml/Splice/AggregateLock.daml b/daml/splice-amulet/daml/Splice/AggregateLock.daml index f817d1b041..76a08fc7f4 100644 --- a/daml/splice-amulet/daml/Splice/AggregateLock.daml +++ b/daml/splice-amulet/daml/Splice/AggregateLock.daml @@ -1,11 +1,13 @@ module Splice.AggregateLock where +import Splice.Amulet.TokenApiUtils import Splice.Api.Token.AllocationV2 as V2 import Splice.Api.Token.MetadataV1 import Splice.TokenStandard.Utils.Internal.Conversions (decodeTime) import Splice.Types (ForDso(..)) import Splice.Util +import DA.Either import DA.Optional import DA.Text import qualified DA.List as L @@ -36,7 +38,7 @@ isValidVestingLockedDestination dso alloc = type ControllerSets = [[Party]] partiesFromText : Text -> Optional [Party] -partiesFromText = mapA partyFromText . splitOn "," +partiesFromText = eitherToOptional . parseCommaSeparated "Parties" partyFromText controllerSetFromMeta : Text -> Optional ControllerSets controllerSetFromMeta = mapA partiesFromText . splitOn ";" @@ -63,7 +65,8 @@ validateVestingSchedule VestingSchedule_SV now meta = start = decodeTime $ fromSomeNote "start must exist" (TM.lookup "cip-105/vestingLock.startDate" meta.values) end = decodeTime $ fromSomeNote "end must exist" (TM.lookup "cip-105/vestingLock.endDate" meta.values) in - now < start && end `subTime` start == days 365 + hours 6 + -- CIP-0105 states the SV vesting period be 365.25 days + now <= start && end `subTime` start == days 365 + hours 6 validateVestingSchedule VestingSchedule_Immediate _ _ = True -- TODO: Potentially make return type, it's own data type? @@ -173,8 +176,7 @@ template AggregateLock ] nextIterationFunding = Some $ TM.singleton instrumentId amount ] - -- Choice temporarily lives here and it will be moved, testing/draft purposes atm - -- Note: this whole choice is WIP + -- Note: Choice temporarily lives here and it will be moved, testing/draft purposes atm nonconsuming choice VestingLock_Withdraw : AllocationResult with factoryCid : ContractId SettlementFactory @@ -195,13 +197,13 @@ template AggregateLock let GovernanceLockedControllers{..} = governanceLockedControllersFromMeta vestingLock.meta ownerParty require "Unlock controller must be one of the options" $ any (\conj -> all (`elem` authorizers) conj) unlockControllerSets - let mNextIterationFunding = case vestingLock.allocation.nextIterationFunding of - None -> None - Some nextIterationFundingTM -> - TM.lookup instrumentId nextIterationFundingTM - require ("VestingLock's nextIterationFunding should have remaining funds to withdraw for instrumentId " <> show instrumentId) - $ isSome mNextIterationFunding - let nextIterationFundingAmount = fromSome mNextIterationFunding + let mNextIterationFunding = do + fundingMap <- vestingLock.allocation.nextIterationFunding + TM.lookup instrumentId fundingMap + + nextIterationFundingAmount <- case mNextIterationFunding of + None -> abort ("VestingLock's nextIterationFunding should have remaining funds to withdraw for instrumentId " <> show instrumentId) + Some amount -> pure amount let (availableWithdrawAmount, remainingVestingAmount) : (Decimal, Decimal) = calculateAvailableWithdrawAmount now vestingLock nextIterationFundingAmount @@ -259,9 +261,7 @@ template AggregateLock nextIterationFunding = nextIterationFunding ] - -- We should have one result from settleBatch - require "VestingUnlock_Withdraw should result in one allocation result. " $ - L.length batchResults.allocationSettleResults == 1 - let [allocationResult] = batchResults.allocationSettleResults - - pure allocationResult + -- We should have one result from settleBatch + case batchResults.allocationSettleResults of + [allocationResult] -> pure allocationResult + _ -> abort "VestingUnlock_Withdraw should result in one allocation result. " diff --git a/token-standard/splice-token-standard-utils/daml/Splice/TokenStandard/Utils/Internal/Conversions.daml b/token-standard/splice-token-standard-utils/daml/Splice/TokenStandard/Utils/Internal/Conversions.daml index 1e70ba6c5c..60c19fb477 100644 --- a/token-standard/splice-token-standard-utils/daml/Splice/TokenStandard/Utils/Internal/Conversions.daml +++ b/token-standard/splice-token-standard-utils/daml/Splice/TokenStandard/Utils/Internal/Conversions.daml @@ -27,7 +27,6 @@ module Splice.TokenStandard.Utils.Internal.Conversions ( partiesToMeta, dropMeta, validateNoMeta, - -- TODO: Is exporting the below cool? encodeTime, decodeTime, From ab158105d07f64a0cecda02fa4c5018db4b4cff9 Mon Sep 17 00:00:00 2001 From: Nicu Reut Date: Mon, 27 Jul 2026 08:37:37 +0200 Subject: [PATCH 17/31] Move LSU cancellation endpoint behind operator auth (#6555) No need for admin auth as you can schedule a LSU without it so it doesn't really make sense [ci] Signed-off-by: Nicu Reut --- .../splice/console/SvAppReference.scala | 2 +- apps/sv/src/main/openapi/sv-internal.yaml | 2 +- .../splice/sv/SvApp.scala | 1 + .../commands/HttpSvAdminAppClient.scala | 18 ------------------ .../commands/HttpSvOperatorAppClient.scala | 19 +++++++++++++++++++ .../sv/admin/http/HttpSvAdminHandler.scala | 15 --------------- .../sv/admin/http/HttpSvOperatorHandler.scala | 16 ++++++++++++++++ 7 files changed, 38 insertions(+), 35 deletions(-) diff --git a/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/console/SvAppReference.scala b/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/console/SvAppReference.scala index 37516b4b28..9f9083697c 100644 --- a/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/console/SvAppReference.scala +++ b/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/console/SvAppReference.scala @@ -134,7 +134,7 @@ abstract class SvAppReference( @Help.Summary("Cancel a running logical synchronizer upgrade by removing its LSU announcement") def cancelLogicalSynchronizerUpgrade(): Unit = consoleEnvironment.run { - httpCommand(HttpSvAdminAppClient.CancelLogicalSynchronizerUpgrade()) + httpCommand(HttpSvOperatorAppClient.CancelLogicalSynchronizerUpgrade()) } @Help.Summary("Get identities of all domain node components") diff --git a/apps/sv/src/main/openapi/sv-internal.yaml b/apps/sv/src/main/openapi/sv-internal.yaml index 3ac9285a06..f20abd07ef 100644 --- a/apps/sv/src/main/openapi/sv-internal.yaml +++ b/apps/sv/src/main/openapi/sv-internal.yaml @@ -176,7 +176,7 @@ paths: /v0/admin/synchronizer/lsu/cancel: post: tags: [sv] - x-jvm-package: sv_admin + x-jvm-package: sv_operator operationId: "cancelLogicalSynchronizerUpgrade" responses: "200": diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/SvApp.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/SvApp.scala index b7fcb81919..63e562ce9e 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/SvApp.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/SvApp.scala @@ -498,6 +498,7 @@ class SvApp( timeouts, loggerFactory, amuletAppParameters.upgradesConfig, + participantAdminConnection, ) adminHandler = new HttpSvAdminHandler( diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/api/client/commands/HttpSvAdminAppClient.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/api/client/commands/HttpSvAdminAppClient.scala index 6e9060523e..0f6cafa261 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/api/client/commands/HttpSvAdminAppClient.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/api/client/commands/HttpSvAdminAppClient.scala @@ -40,22 +40,4 @@ object HttpSvAdminAppClient { } } - case class CancelLogicalSynchronizerUpgrade() - extends BaseCommand[http.CancelLogicalSynchronizerUpgradeResponse, Unit] { - - override def submitRequest( - client: Client, - headers: List[HttpHeader], - ): EitherT[Future, Either[ - Throwable, - HttpResponse, - ], http.CancelLogicalSynchronizerUpgradeResponse] = - client.cancelLogicalSynchronizerUpgrade(headers = headers) - - override def handleOk()(implicit - decoder: TemplateJsonDecoder - ) = { case http.CancelLogicalSynchronizerUpgradeResponse.OK => - Right(()) - } - } } diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/api/client/commands/HttpSvOperatorAppClient.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/api/client/commands/HttpSvOperatorAppClient.scala index 0a998cfacc..2744a82274 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/api/client/commands/HttpSvOperatorAppClient.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/api/client/commands/HttpSvOperatorAppClient.scala @@ -468,4 +468,23 @@ object HttpSvOperatorAppClient { Left(response.error) } } + + case class CancelLogicalSynchronizerUpgrade() + extends BaseCommand[http.CancelLogicalSynchronizerUpgradeResponse, Unit] { + + override def submitRequest( + client: Client, + headers: List[HttpHeader], + ): EitherT[Future, Either[ + Throwable, + HttpResponse, + ], http.CancelLogicalSynchronizerUpgradeResponse] = + client.cancelLogicalSynchronizerUpgrade(headers = headers) + + override def handleOk()(implicit + decoder: TemplateJsonDecoder + ) = { case http.CancelLogicalSynchronizerUpgradeResponse.OK => + Right(()) + } + } } diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/http/HttpSvAdminHandler.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/http/HttpSvAdminHandler.scala index a39f4e38b0..8ff0a70ae8 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/http/HttpSvAdminHandler.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/http/HttpSvAdminHandler.scala @@ -37,21 +37,6 @@ class HttpSvAdminHandler( protected val workflowId: String = this.getClass.getSimpleName private val dsoStore = dsoStoreWithIngestion.store - override def cancelLogicalSynchronizerUpgrade( - respond: r0.CancelLogicalSynchronizerUpgradeResponse.type - )()( - extracted: AdminUserRequest - ): Future[r0.CancelLogicalSynchronizerUpgradeResponse] = { - implicit val AdminUserRequest(traceContext) = extracted - withSpan(s"$workflowId.cancelLogicalSynchronizerUpgrade") { _ => _ => - for { - decentralizedSynchronizer <- dsoStore.getDsoRules().map(_.domain) - _ <- participantAdminConnection - .removeLsuAnnouncement(decentralizedSynchronizer) - } yield r0.CancelLogicalSynchronizerUpgradeResponseOK - } - } - override def getSynchronizerNodeIdentitiesDump( respond: r0.GetSynchronizerNodeIdentitiesDumpResponse.type )()( diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/http/HttpSvOperatorHandler.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/http/HttpSvOperatorHandler.scala index f9937ca058..5cf43e04a7 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/http/HttpSvOperatorHandler.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/http/HttpSvOperatorHandler.scala @@ -62,6 +62,7 @@ class HttpSvOperatorHandler( override protected val timeouts: ProcessingTimeout, protected val loggerFactory: NamedLoggerFactory, upgradesConfig: UpgradesConfig, + participantAdminConnection: ParticipantAdminConnection, )(implicit ec: ExecutionContextExecutor, protected val tracer: Tracer, @@ -597,6 +598,21 @@ class HttpSvOperatorHandler( } } + override def cancelLogicalSynchronizerUpgrade( + respond: r0.CancelLogicalSynchronizerUpgradeResponse.type + )()( + extracted: ActAsKnownUserRequest + ): Future[r0.CancelLogicalSynchronizerUpgradeResponse] = { + implicit val ActAsKnownUserRequest(traceContext) = extracted + withSpan(s"$workflowId.cancelLogicalSynchronizerUpgrade") { _ => _ => + for { + decentralizedSynchronizer <- dsoStore.getDsoRules().map(_.domain) + _ <- participantAdminConnection + .removeLsuAnnouncement(decentralizedSynchronizer) + } yield r0.CancelLogicalSynchronizerUpgradeResponseOK + } + } + private def withClientOrNotFound[T]( notFound: definitions.ErrorResponse => T )(call: CometBftClient => Future[T])(implicit tc: TraceContext) = From 0d4accfd893e8aac2099f04b2c7afe69f5ad8fbc Mon Sep 17 00:00:00 2001 From: moritzkiefer-da <45630097+moritzkiefer-da@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:14:35 +0200 Subject: [PATCH 18/31] Remove scan transactions endpoint (#6561) * Remove scan transactions endpoint Already deprecated, we removed /activities already, metrics show basically no usage (< 1 request per day) and next release is 0.7.0 so perfect time for removing a deprecated endpoint. [ci] Signed-off-by: moritz.kiefer@digitalasset.com * fix rate limits [ci] Signed-off-by: moritz.kiefer@digitalasset.com --------- Signed-off-by: moritz.kiefer@digitalasset.com Co-authored-by: moritz.kiefer@digitalasset.com --- .../splice/console/ScanAppReference.scala | 14 -- .../tests/LsuIntegrationTest.scala | 14 +- ...HostValidatorOperatorIntegrationTest.scala | 15 +-- .../tests/RollForwardLsuIntegrationTest.scala | 14 +- ...canHistoryBackfillingIntegrationTest.scala | 74 +++++++--- .../tests/ScanIntegrationTest.scala | 107 --------------- ...TimeBasedRewardCouponIntegrationTest.scala | 34 +---- .../splice/util/UpdateHistoryTestUtil.scala | 17 --- apps/scan/src/main/openapi/scan.yaml | 121 ----------------- .../client/commands/HttpScanAppClient.scala | 27 ---- .../scan/admin/http/HttpScanHandler.scala | 28 ---- .../splice/scan/store/TxLogEntry.scala | 127 ------------------ .../configs/shared/rate-limits/unlimited.yaml | 4 - .../scratchneta/config.resolved.yaml | 3 - .../scratchnetb/config.resolved.yaml | 3 - .../scratchnetc/config.resolved.yaml | 3 - .../scratchnetd/config.resolved.yaml | 3 - .../scratchnete/config.resolved.yaml | 3 - cluster/expected/canton-network/expected.json | 8 -- cluster/expected/sv-runbook/expected.json | 4 - docs/src/release_notes_upcoming.rst | 4 + 21 files changed, 60 insertions(+), 567 deletions(-) diff --git a/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/console/ScanAppReference.scala b/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/console/ScanAppReference.scala index e510e5b33a..8de8c4fe8e 100644 --- a/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/console/ScanAppReference.scala +++ b/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/console/ScanAppReference.scala @@ -368,20 +368,6 @@ abstract class ScanAppReference( httpCommand(HttpScanAppClient.GetRewardAccountingBatch(roundNumber, batchHash)) } - import org.lfdecentralizedtrust.splice.http.v0.definitions.TransactionHistoryResponseItem - import org.lfdecentralizedtrust.splice.http.v0.definitions.TransactionHistoryRequest.SortOrder - - def listTransactions( - pageEndEventId: Option[String], - sortOrder: SortOrder, - pageSize: Int, - ): Seq[TransactionHistoryResponseItem] = - consoleEnvironment.run { - httpCommand( - HttpScanAppClient.ListTransactions(pageEndEventId, sortOrder, pageSize) - ) - } - def getAcsSnapshot(party: PartyId, recordTime: Option[Instant]): ByteString = consoleEnvironment.run { httpCommand( diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/LsuIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/LsuIntegrationTest.scala index 0026533aa8..559baf4898 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/LsuIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/LsuIntegrationTest.scala @@ -29,7 +29,6 @@ import org.lfdecentralizedtrust.splice.environment.{ SequencerAdminConnection, } import org.lfdecentralizedtrust.splice.http.v0.definitions -import org.lfdecentralizedtrust.splice.http.v0.definitions.TransactionHistoryRequest import org.lfdecentralizedtrust.splice.integration.EnvironmentDefinition import org.lfdecentralizedtrust.splice.integration.tests.SpliceTests.{ IntegrationTest, @@ -373,14 +372,13 @@ class LsuIntegrationTest initDso(includeLocal = false) startAllSync(aliceValidatorBackend, splitwellValidatorBackend) actAndCheck("Create some transaction history", sv1WalletClient.tap(1337))( - "Scan transaction history is recorded and wallet balance is updated", + "Wallet balance is updated", _ => { // buffer to account for domain fee payments assertInRange( sv1WalletClient.balance().unlockedQty, (walletUsdToAmulet(1000), walletUsdToAmulet(2000)), ) - countTapsFromScan(sv1ScanBackend, walletUsdToAmulet(1337)) shouldBe 1 }, ) @@ -962,16 +960,6 @@ class LsuIntegrationTest retryProvider, ) - private def countTapsFromScan(scan: ScanAppBackendReference, tapAmount: BigDecimal) = { - listTransactionsFromScan(scan).count( - _.tap.map(a => BigDecimal(a.amuletAmount)).contains(tapAmount) - ) - } - - private def listTransactionsFromScan(scan: ScanAppBackendReference) = { - scan.listTransactions(None, TransactionHistoryRequest.SortOrder.Asc, 100) - } - private def getSequencerUrlsConfiguredForTheSync( participantConnection: ParticipantClientReference, synchronizerAlias: SynchronizerAlias, diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/MultiHostValidatorOperatorIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/MultiHostValidatorOperatorIntegrationTest.scala index 163e7a43d4..277c7d057f 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/MultiHostValidatorOperatorIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/MultiHostValidatorOperatorIntegrationTest.scala @@ -1,11 +1,9 @@ package org.lfdecentralizedtrust.splice.integration.tests import com.digitalasset.canton.topology.transaction.* -import org.lfdecentralizedtrust.splice.http.v0.definitions.TransactionHistoryRequest import org.lfdecentralizedtrust.splice.integration.EnvironmentDefinition import org.lfdecentralizedtrust.splice.integration.tests.SpliceTests.IntegrationTest import org.lfdecentralizedtrust.splice.util.WalletTestUtil -import org.lfdecentralizedtrust.splice.store.Limit import java.nio.file.Files import java.util.UUID @@ -212,19 +210,8 @@ class MultiHostValidatorOperatorIntegrationTest extends IntegrationTest with Wal )( "The send succeeds despite alice's validator being disconnected and stopped", _ => { - // Fees eat up quite a bit splitwellWalletClient.balance().unlockedQty should be(60) - // Alice's wallet is stopped, so we confirm the transaction via scan - sv1ScanBackend - .listTransactions( - None, - TransactionHistoryRequest.SortOrder.Desc, - Limit.DefaultMaxPageSize, - ) - .flatMap(_.transfer) - .filter(tf => - tf.description == transferDescription - ) should not be empty withClue "transfers splitwell to alice" + // don't check on alice's wallet as it's stopped. the transaction going through on splitwell is enough signal. }, ) } diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/RollForwardLsuIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/RollForwardLsuIntegrationTest.scala index 8788c3338d..f1ed840381 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/RollForwardLsuIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/RollForwardLsuIntegrationTest.scala @@ -18,7 +18,6 @@ import org.lfdecentralizedtrust.splice.environment.{ MediatorAdminConnection, SequencerAdminConnection, } -import org.lfdecentralizedtrust.splice.http.v0.definitions.TransactionHistoryRequest import monocle.macros.syntax.lens.* import org.lfdecentralizedtrust.splice.integration.EnvironmentDefinition import org.lfdecentralizedtrust.splice.integration.tests.SpliceTests.IntegrationTest @@ -164,14 +163,13 @@ class RollForwardLsuIntegrationTest startAllSync(allNodes*) actAndCheck("Create some transaction history", sv1WalletClient.tap(1337))( - "Scan transaction history is recorded and wallet balance is updated", + "Wallet balance is updated", _ => { // buffer to account for domain fee payments assertInRange( sv1WalletClient.balance().unlockedQty, (walletUsdToAmulet(1000), walletUsdToAmulet(2000)), ) - countTapsFromScan(sv1ScanBackend, walletUsdToAmulet(1337)) shouldBe 1 }, ) @@ -429,16 +427,6 @@ class RollForwardLsuIntegrationTest retryProvider, ) - private def countTapsFromScan(scan: ScanAppBackendReference, tapAmount: BigDecimal) = { - listTransactionsFromScan(scan).count( - _.tap.map(a => BigDecimal(a.amuletAmount)).contains(tapAmount) - ) - } - - private def listTransactionsFromScan(scan: ScanAppBackendReference) = { - scan.listTransactions(None, TransactionHistoryRequest.SortOrder.Asc, 100) - } - private def getSequencerUrlSet( participantConnection: ParticipantClientReference, synchronizerAlias: SynchronizerAlias, diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ScanHistoryBackfillingIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ScanHistoryBackfillingIntegrationTest.scala index 7eb0263a7e..619432c55e 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ScanHistoryBackfillingIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ScanHistoryBackfillingIntegrationTest.scala @@ -1,6 +1,9 @@ package org.lfdecentralizedtrust.splice.integration.tests import com.daml.ledger.javaapi.data.Transaction +import org.lfdecentralizedtrust.splice.codegen.java.splice.dsorules.* +import org.lfdecentralizedtrust.splice.codegen.java.splice.dsorules.actionrequiringconfirmation.* +import org.lfdecentralizedtrust.splice.codegen.java.splice.dsorules.dsorules_actionrequiringconfirmation.* import org.lfdecentralizedtrust.splice.config.ConfigTransforms import org.lfdecentralizedtrust.splice.config.ConfigTransforms.{ ConfigurableApp, @@ -20,7 +23,11 @@ import org.lfdecentralizedtrust.splice.scan.automation.{ DeleteCorruptAcsSnapshotTrigger, ScanHistoryBackfillingTrigger, } -import org.lfdecentralizedtrust.splice.store.{PageLimit, TreeUpdateWithMigrationId} +import org.lfdecentralizedtrust.splice.store.{ + PageLimit, + TreeUpdateWithMigrationId, + VoteResultsFilters, +} import org.lfdecentralizedtrust.splice.sv.automation.delegatebased.AdvanceOpenMiningRoundTrigger import org.lfdecentralizedtrust.splice.util.{EventId, UpdateHistoryTestUtil, WalletTestUtil} import com.digitalasset.canton.config.NonNegativeFiniteDuration @@ -29,12 +36,12 @@ import com.digitalasset.canton.data.CantonTimestamp import scala.math.BigDecimal.javaBigDecimal2bigDecimal import com.digitalasset.canton.{HasActorSystem, HasExecutionContext} import org.lfdecentralizedtrust.splice.automation.TxLogBackfillingTrigger -import org.lfdecentralizedtrust.splice.http.v0.definitions.TransactionHistoryRequest.SortOrder import org.lfdecentralizedtrust.splice.scan.store.TxLogEntry import org.lfdecentralizedtrust.splice.store.MultiDomainAcsStore.TxLogBackfillingState import org.lfdecentralizedtrust.splice.store.UpdateHistory.BackfillingState import org.scalactic.source.Position +import java.util.Optional import scala.annotation.nowarn import scala.jdk.CollectionConverters.* import scala.jdk.OptionConverters.* @@ -129,6 +136,39 @@ class ScanHistoryBackfillingIntegrationTest }, ) + // we create votes as they produce txlog entries that can be backfilled + actAndCheck( + "Create vote", { + val action: ActionRequiringConfirmation = + new ARC_DsoRules( + new SRARC_SetConfig( + new DsoRules_SetConfig( + sv1Backend + .getDsoInfo() + .dsoRules + .payload + .config, + Optional.empty(), + ) + ) + ) + + sv1Backend.createVoteRequest( + sv1Backend.getDsoInfo().svParty.toProtoPrimitive, + action, + "url", + "description", + sv1Backend.getDsoInfo().dsoRules.payload.config.voteRequestTimeout, + None, + ) + }, + )( + "Vote has been executed", + _ => { + sv1ScanBackend.listVoteRequestResults(VoteResultsFilters(), 100)._1 should have size (1) + }, + ) + // The current round, as seen by the given scan service (reflects the state of the scan app store) def currentRoundInScan(backend: ScanAppBackendReference): Long = backend.getLatestOpenMiningRound(CantonTimestamp.now()).contract.payload.round.number @@ -466,18 +506,9 @@ class ScanHistoryBackfillingIntegrationTest .loneElement shouldBe a[TxLogBackfillingTrigger.InitializeBackfillingTask] } - clue("TxLog based historical queries differ") { - val sv1Transactions = - sv1ScanBackend.listTransactions(None, SortOrder.Asc, 1000).map(shortDebugDescription) - val sv2Transactions = - sv2ScanBackend.listTransactions(None, SortOrder.Asc, 1000).map(shortDebugDescription) - - // We tapped 4 times before SV2 joined, and once after - sv1Transactions.size should be >= 5 withClue "SV1 txns" - sv2Transactions.size should be >= 1 withClue "SV2 txns" - sv1Transactions.size should be > sv2Transactions.size withClue "SV1 txns" - sv1Transactions should contain allElementsOf sv2Transactions withClue "sv1 transactions" - sv2Transactions should not contain sv1Transactions.headOption.value withClue "sv2 transactions" + clue("TxLog based vote result result differ") { + sv1ScanBackend.listVoteRequestResults(VoteResultsFilters(), 100)._1 should have size (1) + sv2ScanBackend.listVoteRequestResults(VoteResultsFilters(), 100)._1 should be(empty) } actAndCheck( @@ -524,14 +555,13 @@ class ScanHistoryBackfillingIntegrationTest }, ) - clue("TxLog based historical queries return same results") { - val sv1Transactions = - sv1ScanBackend.listTransactions(None, SortOrder.Asc, 1000).map(shortDebugDescription) - val sv2Transactions = - sv2ScanBackend.listTransactions(None, SortOrder.Asc, 1000).map(shortDebugDescription) - - // TODO(#666): switch to theSameElementsInOrderAs once the endpoint sorts by record time instead of row id. - sv1Transactions should contain theSameElementsAs sv2Transactions withClue "SV1/2 txns" + clue("TxLog based vote result queries return same results") { + // Not quite sure why we need the eventually given that we sync on backfilling completing but without that sv2ScanBackend can still return an empty list. + eventually() { + sv1ScanBackend.listVoteRequestResults(VoteResultsFilters(), 100)._1 shouldBe sv2ScanBackend + .listVoteRequestResults(VoteResultsFilters(), 100) + ._1 + } } } diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ScanIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ScanIntegrationTest.scala index e478ca2721..147e312ff0 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ScanIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ScanIntegrationTest.scala @@ -3,9 +3,7 @@ package org.lfdecentralizedtrust.splice.integration.tests import com.digitalasset.canton.concurrent.Threading import com.digitalasset.canton.config.NonNegativeFiniteDuration import com.digitalasset.canton.config.RequireTypes.NonNegativeInt -import com.digitalasset.canton.data.CantonTimestamp import com.digitalasset.canton.discard.Implicits.DiscardOps -import com.digitalasset.canton.topology.PartyId import org.apache.pekko.http.scaladsl.Http import org.apache.pekko.http.scaladsl.client.RequestBuilding.{Get, Post} import org.apache.pekko.http.scaladsl.model.{ContentTypes, HttpEntity, StatusCodes} @@ -19,10 +17,6 @@ import org.lfdecentralizedtrust.splice.config.ConfigTransforms.{ updateAutomationConfig, ConfigurableApp, } -import org.lfdecentralizedtrust.splice.http.v0.definitions.{ - TransactionHistoryRequest, - TransactionHistoryResponseItem, -} import org.lfdecentralizedtrust.splice.integration.EnvironmentDefinition import org.lfdecentralizedtrust.splice.integration.tests.SpliceTests.{ IntegrationTestWithIsolatedEnvironment, @@ -166,107 +160,6 @@ class ScanIntegrationTest spliceInstanceNames.nameServiceNameAcronym should be("ANS") } - "list transaction pages in ascending and descending order" in { implicit env => - val aliceWalletUser = onboardWalletUser(aliceWalletClient, aliceValidatorBackend) - def tapsForAlice = (t: TransactionHistoryResponseItem) => - t.tap.exists { tap => - PartyId.tryFromProtoPrimitive(tap.amuletOwner) == aliceWalletUser - } - - val nrTaps = 10 - val amuletAmounts = (1 to nrTaps).map(walletUsdToAmulet(_)) - val pageSize = nrTaps / 2 - // filtering for Alice to avoid interference by the top up taps - def collectAllTapPagesForAlice(sortOrder: TransactionHistoryRequest.SortOrder) = { - LazyList - .iterate(sv1ScanBackend.listTransactions(None, sortOrder, pageSize)) { page => - sv1ScanBackend.listTransactions(page.lastOption.map(_.eventId), sortOrder, pageSize) - } - .takeWhile(_.nonEmpty) - .foldLeft(Seq.empty[TransactionHistoryResponseItem])(_ ++ _) - .filter(tapsForAlice) - } - - def toAmuletAmounts(page: Seq[TransactionHistoryResponseItem]) = - page.flatMap(_.tap.map(t => BigDecimal(t.amuletAmount))) - - actAndCheck( - "Tap amulets for Alice", { - (1 to nrTaps).foreach { i => - aliceWalletClient.tap(BigDecimal(i)) - } - }, - )( - "Amulets should appear in Alice's wallet", - _ => { - aliceWalletClient.list().amulets should have length nrTaps.toLong - }, - ) - - eventually() { - val latestRound = - sv1ScanBackend.getLatestOpenMiningRound(CantonTimestamp.now()).contract.payload.round.number - val asc = TransactionHistoryRequest.SortOrder.Asc - val desc = TransactionHistoryRequest.SortOrder.Desc - val allPagesAsc = collectAllTapPagesForAlice(asc) - val allPagesDesc = collectAllTapPagesForAlice(desc) - allPagesAsc.map(_.round) should contain only Some( - latestRound - ) withClue "alice tap pages' rounds" - - val tapsFirstPageAscending = allPagesAsc.take(pageSize) - - toAmuletAmounts(tapsFirstPageAscending) should be( - amuletAmounts.take(pageSize) - ) - - val firstPageEndEventId = tapsFirstPageAscending.last.eventId - val tapsSecondPageAscending = allPagesAsc.slice(pageSize, pageSize + pageSize) - sv1ScanBackend - .listTransactions( - Some(firstPageEndEventId), - TransactionHistoryRequest.SortOrder.Asc, - pageSize.toInt, - ) - .filter(tapsForAlice) - - toAmuletAmounts(tapsSecondPageAscending) should be( - amuletAmounts.slice(pageSize, pageSize + pageSize) - ) - - sv1ScanBackend - .listTransactions( - Some(tapsSecondPageAscending.last.eventId), - asc, - pageSize.toInt, - ) - .filter(tapsForAlice) should be(empty) - - val tapsFirstPageDescending = allPagesDesc.take(pageSize) - toAmuletAmounts(tapsFirstPageDescending) should be( - amuletAmounts.reverse.take(pageSize) - ) - - val tapsSecondPageDescending = - allPagesDesc.slice(pageSize, pageSize + pageSize) - - sv1ScanBackend - .listTransactions( - Some(tapsSecondPageDescending.last.eventId), - TransactionHistoryRequest.SortOrder.Desc, - pageSize.toInt, - ) - .filter(tapsForAlice) should be(empty) - - toAmuletAmounts(tapsSecondPageDescending) should be( - amuletAmounts.reverse.slice(pageSize, pageSize + pageSize) - ) - toAmuletAmounts( - tapsFirstPageAscending ++ tapsSecondPageAscending - ) should be(toAmuletAmounts((tapsFirstPageDescending ++ tapsSecondPageDescending).reverse)) - } - } - "getUpdateHistory should return 400 for invalid after timestamp" in { implicit env => import env.{actorSystem, executionContext} registerHttpConnectionPoolsCleanup(env) diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvTimeBasedRewardCouponIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvTimeBasedRewardCouponIntegrationTest.scala index 71d16928a4..170b08b3fc 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvTimeBasedRewardCouponIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvTimeBasedRewardCouponIntegrationTest.scala @@ -6,7 +6,6 @@ import org.lfdecentralizedtrust.splice.config.ConfigTransforms.{ ConfigurableApp, updateAutomationConfig, } -import org.lfdecentralizedtrust.splice.http.v0.definitions.TransactionHistoryRequest import org.lfdecentralizedtrust.splice.integration.EnvironmentDefinition import org.lfdecentralizedtrust.splice.integration.tests.SpliceTests.{ IntegrationTest, @@ -221,38 +220,7 @@ class SvTimeBasedRewardCouponIntegrationTest ) } - clue("The claims appear in the scan history") { - eventually() { - val txs = sv1ScanBackend - .listTransactions( - None, - TransactionHistoryRequest.SortOrder.Desc, - Limit.DefaultMaxPageSize, - ) - .flatMap(_.transfer) - .filter(tf => - tf.sender.inputSvRewardAmount.nonEmpty && - Seq(sv1Party.toProtoPrimitive, aliceValidatorParty.toProtoPrimitive) - .contains(tf.sender.party) - ) - .map(tf => tf.sender.party -> tf.sender.inputSvRewardAmount.value) - .toMap - BigDecimal(txs(sv1Party.toProtoPrimitive)) should beWithin( - // The expected SV reward calculated here does not match exactly the reward calculated in daml, - // presumably because of rounding differences in the reward calculation. - BigDecimal(eachSvGetInRound0) - 0.001, - BigDecimal(eachSvGetInRound0) + 0.001, - ) - BigDecimal(txs(aliceValidatorParty.toProtoPrimitive)) should beWithin( - // The expected SV reward calculated here does not match exactly the reward calculated in daml, - // presumably because of rounding differences in the reward calculation. - BigDecimal(expectedAliceAmount) - 0.001, - BigDecimal(expectedAliceAmount) + 0.001, - ) - } - } - - clue("The claims appear in the wallet history") { + clue("The claims appear in the SV wallet history") { eventually() { val txs = withoutDevNetTopups( sv1WalletClient diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/util/UpdateHistoryTestUtil.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/util/UpdateHistoryTestUtil.scala index db3215507a..cd1d00c8af 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/util/UpdateHistoryTestUtil.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/util/UpdateHistoryTestUtil.scala @@ -45,7 +45,6 @@ import com.digitalasset.canton.config.RequireTypes.PositiveInt import com.digitalasset.canton.console.LocalInstanceReference import com.digitalasset.canton.metrics.MetricValue import com.digitalasset.canton.topology.{PartyId, SynchronizerId} -import org.lfdecentralizedtrust.splice.http.v0.definitions.TransactionHistoryResponseItem import org.scalatest.Assertion import scala.jdk.CollectionConverters.* @@ -443,22 +442,6 @@ trait UpdateHistoryTestUtil extends TestCommon { def shortDebugDescription(u: Seq[definitions.UpdateHistoryItem]): String = { u.map(shortDebugDescription).mkString("[\n", ",\n", "\n]") } - def shortDebugDescription(u: TransactionHistoryResponseItem): String = { - // Minimal, human-readable description. - // Only contains data that is consistent across SVs (in particular, no offset). - u.transactionType match { - case TransactionHistoryResponseItem.TransactionType.members.Transfer => - s"Transfer(${u.date}, ${u.transfer.value.sender}, ${u.transfer.value.receivers - .map(r => s"${r.party} -> ${r.amount}") - .mkString(", ")})" - case TransactionHistoryResponseItem.TransactionType.members.Mint => - s"Mint(${u.date}, ${u.mint.value.amuletOwner}, ${u.mint.value.amuletAmount})" - case TransactionHistoryResponseItem.TransactionType.members.DevnetTap => - s"DevnetTap(${u.date}, ${u.tap.value.amuletOwner}, ${u.tap.value.amuletAmount})" - case TransactionHistoryResponseItem.TransactionType.members.AbortTransferInstruction => - s"AbortTransferInstruction(${u.date}, ${u.abortTransferInstruction.value.transferInstructionCid})" - } - } def dropTrailingNones(u: UpdateHistoryResponse): UpdateHistoryResponse = u.copy(update = dropTrailingNones(u.update)) diff --git a/apps/scan/src/main/openapi/scan.yaml b/apps/scan/src/main/openapi/scan.yaml index 527fe5fceb..2087189444 100644 --- a/apps/scan/src/main/openapi/scan.yaml +++ b/apps/scan/src/main/openapi/scan.yaml @@ -1448,36 +1448,6 @@ paths: "404": $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/404" - /v0/transactions: - post: - deprecated: true - tags: [deprecated] - x-jvm-package: scan - operationId: "listTransactionHistory" - description: | - **Deprecated with known bugs that will not be fixed, use /v2/updates instead**. - - Lists transactions, by default in ascending order, paged, from ledger begin or optionally starting after a provided event id. - requestBody: - required: true - content: - application/json: - schema: - "$ref": "#/components/schemas/TransactionHistoryRequest" - responses: - "200": - description: ok - content: - application/json: - schema: - $ref: "#/components/schemas/TransactionHistoryResponse" - "400": - $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/400" - "404": - $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/404" - "500": - $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/500" - /v0/updates: post: deprecated: true @@ -2205,97 +2175,6 @@ components: svName: description: The sequencer's operating SV name. type: string - TransactionHistoryRequest: - type: object - required: - - page_size - properties: - page_end_event_id: - type: string - description: | - Note that all transactions carry some monotonically-increasing event_id. - Omit this page_end_event_id to start reading the first page, from the beginning or the end of the ledger, depending on the sort_order column. - A subsequent request can fill the page_end_event_id with the last event_id of the TransactionHistoryResponse to continue reading in the same sort_order. - The transaction with event_id == page_end_event_id will be skipped in the next response, making it possible to continuously read pages in the same sort_order. - sort_order: - description: | - Sort order for the transactions. For ascending order, from beginning to the end of the ledger, use "asc". - For descending order, from end to beginning of the ledger, use "desc". - "asc" is used if the sort_order is omitted. - type: string - enum: - - "asc" - - "desc" - page_size: - description: | - The maximum number of transactions returned for this request. - type: integer - format: int64 - TransactionHistoryResponse: - type: object - required: - - transactions - properties: - transactions: - type: array - items: - $ref: "#/components/schemas/TransactionHistoryResponseItem" - TransactionHistoryResponseItem: - type: object - required: - - transaction_type - - event_id - - date - - domain_id - properties: - transaction_type: - description: | - Describes the type of activity that occurred. - Determines if the data for the transaction should be read - from the `transfer`, `mint`, or `tap` property. - type: string - enum: - - "transfer" - - "mint" - - "devnet_tap" - - "abort_transfer_instruction" - event_id: - description: | - The event id. - type: string - offset: - description: | - The ledger offset of the event. - Note that this field may not be the same across nodes, and therefore should not be compared between SVs. - type: string - date: - description: | - The effective date of the event. - type: string - format: date-time - domain_id: - description: | - The id of the domain through which this transaction was sequenced. - type: string - round: - description: | - The round for which this transaction was registered. - type: integer - format: int64 - transfer: - description: | - A (batch) transfer from sender to receivers. - $ref: "#/components/schemas/Transfer" - mint: - description: | - The DSO mints amulet for the cases where the DSO rules allow for that. - $ref: "#/components/schemas/AmuletAmount" - tap: - description: | - A tap creates a Amulet, only used for development purposes, and enabled only on DevNet. - $ref: "#/components/schemas/AmuletAmount" - abort_transfer_instruction: - $ref: "#/components/schemas/AbortTransferInstruction" UpdateHistoryRequestAfter: type: object required: diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/commands/HttpScanAppClient.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/commands/HttpScanAppClient.scala index 28a6bf83d0..e8211bcc08 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/commands/HttpScanAppClient.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/commands/HttpScanAppClient.scala @@ -894,33 +894,6 @@ object HttpScanAppClient { final case class DsoScan(publicUrl: Uri, svName: String) - case class ListTransactions( - pageEndEventId: Option[String], - sortOrder: definitions.TransactionHistoryRequest.SortOrder, - pageSize: Int, - ) extends InternalBaseCommand[http.ListTransactionHistoryResponse, Seq[ - definitions.TransactionHistoryResponseItem - ]] { - override def submitRequest( - client: http.ScanClient, - headers: List[HttpHeader], - ): EitherT[Future, Either[ - Throwable, - HttpResponse, - ], http.ListTransactionHistoryResponse] = { - client.listTransactionHistory( - definitions - .TransactionHistoryRequest(pageEndEventId, Some(sortOrder), pageSize.toLong), - headers, - ) - } - - override def handleOk()(implicit decoder: TemplateJsonDecoder) = { - case http.ListTransactionHistoryResponse.OK(response) => - Right(response.transactions) - } - } - case class GetAcsSnapshot( party: PartyId, recordTime: Option[Instant], diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/http/HttpScanHandler.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/http/HttpScanHandler.scala index 20767f7b21..14e2066ec2 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/http/HttpScanHandler.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/http/HttpScanHandler.scala @@ -107,7 +107,6 @@ import org.lfdecentralizedtrust.splice.store.{ AppStore, AppStoreWithIngestion, PageLimit, - SortOrder, VoteResultsFilters, VotesStore, } @@ -694,33 +693,6 @@ class HttpScanHandler( } } - override def listTransactionHistory( - respond: v0.ScanResource.ListTransactionHistoryResponse.type - )( - request: definitions.TransactionHistoryRequest - )(extracted: TraceContext): Future[v0.ScanResource.ListTransactionHistoryResponse] = { - implicit val tc = extracted - withSpan(s"$workflowId.listTransactions") { _ => _ => - val pageEndEventId = - if (request.pageEndEventId.exists(_.isEmpty)) None else request.pageEndEventId - val sortOrder = request.sortOrder - .fold[SortOrder](SortOrder.Ascending) { - case definitions.TransactionHistoryRequest.SortOrder.members.Asc => SortOrder.Ascending - case definitions.TransactionHistoryRequest.SortOrder.members.Desc => SortOrder.Descending - } - - for { - txs <- store.listTransactions( - pageEndEventId, - sortOrder, - PageLimit.tryCreate(request.pageSize.intValue()), - ) - } yield definitions.TransactionHistoryResponse( - txs.map(TxLogEntry.Http.toResponseItem).toVector - ) - } - } - def getUpdateHistory( after: Option[definitions.UpdateHistoryRequestAfter] = None, pageSize: Int, diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/TxLogEntry.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/TxLogEntry.scala index 2b17fdf90b..b105aad3a2 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/TxLogEntry.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/TxLogEntry.scala @@ -5,18 +5,15 @@ package org.lfdecentralizedtrust.splice.scan.store import org.lfdecentralizedtrust.splice.codegen.java.splice import org.lfdecentralizedtrust.splice.store.StoreErrors -import org.lfdecentralizedtrust.splice.util.Codec import scala.collection.immutable import scala.jdk.CollectionConverters.* import scala.jdk.OptionConverters.* import java.time.Instant import org.lfdecentralizedtrust.splice.http.v0.definitions as httpDef -import org.lfdecentralizedtrust.splice.http.v0.definitions.TransactionHistoryResponseItem.TransactionType as HttpTransactionType import com.digitalasset.canton.config.CantonRequireTypes.String3 import com.digitalasset.canton.topology.PartyId -import java.time.ZoneOffset import scala.math.BigDecimal.RoundingMode trait TxLogEntry extends Product with Serializable { @@ -118,130 +115,6 @@ object TxLogEntry extends StoreErrors { val Failed = "failed" } - private def toResponse(data: SenderAmount) = httpDef.SenderAmount( - party = data.party.toProtoPrimitive, - inputAmuletAmount = Some(Codec.encode(data.inputAmuletAmount)), - inputAppRewardAmount = Some(Codec.encode(data.inputAppRewardAmount)), - inputValidatorRewardAmount = Some(Codec.encode(data.inputValidatorRewardAmount)), - inputSvRewardAmount = Some(Codec.encode(data.inputSvRewardAmount.getOrElse(BigDecimal(0)))), - inputValidatorFaucetAmount = data.inputValidatorFaucetAmount.map(fa => Codec.encode(fa)), - senderChangeAmount = Codec.encode(data.senderChangeAmount), - senderChangeFee = Codec.encode(data.senderChangeFee), - senderFee = Codec.encode(data.senderFee), - holdingFees = Codec.encode(data.holdingFees), - ) - - private def toResponse(data: ReceiverAmount) = httpDef.ReceiverAmount( - party = data.party.toProtoPrimitive, - amount = Codec.encode(data.amount), - receiverFee = Codec.encode(data.receiverFee), - ) - - private def toResponse(data: BalanceChange) = httpDef.BalanceChange( - party = data.party.toProtoPrimitive, - changeToInitialAmountAsOfRoundZero = Codec.encode(data.changeToInitialAmountAsOfRoundZero), - changeToHoldingFeesRate = Codec.encode(data.changeToHoldingFeesRate), - ) - - private def toTransferResponseItem(entry: TransferTxLogEntry) = - httpDef.TransactionHistoryResponseItem( - transactionType = HttpTransactionType.Transfer, - eventId = entry.eventId, - offset = Some(entry.offset), - domainId = entry.domainId.toProtoPrimitive, - date = java.time.OffsetDateTime - .ofInstant(entry.date.getOrElse(throw txMissingField()), ZoneOffset.UTC), - transfer = Some( - httpDef.Transfer( - sender = toResponse(entry.sender.getOrElse(throw txMissingField())), - receivers = entry.receivers.map(toResponse).toVector, - balanceChanges = entry.balanceChanges.map(toResponse).toVector, - description = Some(entry.description).filter(_.nonEmpty), - transferInstructionReceiver = - Some(entry.transferInstructionReceiver).filter(_.nonEmpty), - transferInstructionAmount = entry.transferInstructionAmount.map(Codec.encode(_)), - transferInstructionCid = Some(entry.transferInstructionCid).filter(_.nonEmpty), - transferKind = entry.transferKind match { - case TransferKind.Unrecognized(_) => None - case TransferKind.TRANSFER_KIND_OTHER => None - case TransferKind.TRANSFER_KIND_CREATE_TRANSFER_INSTRUCTION => - Some(httpDef.Transfer.TransferKind.members.CreateTransferInstruction) - case TransferKind.TRANSFER_KIND_TRANSFER_INSTRUCTION_ACCEPT => - Some(httpDef.Transfer.TransferKind.members.TransferInstructionAccept) - case TransferKind.TRANSFER_KIND_PREAPPROVAL_SEND => - Some(httpDef.Transfer.TransferKind.members.PreapprovalSend) - }, - ) - ), - round = Some(entry.round), - ) - - private def toTapResponseItem(entry: TapTxLogEntry) = httpDef.TransactionHistoryResponseItem( - transactionType = HttpTransactionType.DevnetTap, - eventId = entry.eventId, - offset = Some(entry.offset), - domainId = entry.domainId.toProtoPrimitive, - date = java.time.OffsetDateTime - .ofInstant(entry.date.getOrElse(throw txMissingField()), ZoneOffset.UTC), - tap = Some( - httpDef.AmuletAmount( - amuletOwner = entry.amuletOwner.toProtoPrimitive, - amuletAmount = Codec.encode(entry.amuletAmount), - ) - ), - round = Some(entry.round), - ) - - private def toMintResponseItem(entry: MintTxLogEntry) = httpDef.TransactionHistoryResponseItem( - transactionType = HttpTransactionType.Mint, - eventId = entry.eventId, - offset = Some(entry.offset), - domainId = entry.domainId.toProtoPrimitive, - date = java.time.OffsetDateTime - .ofInstant(entry.date.getOrElse(throw txMissingField()), ZoneOffset.UTC), - mint = Some( - httpDef.AmuletAmount( - amuletOwner = entry.amuletOwner.toProtoPrimitive, - amuletAmount = Codec.encode(entry.amuletAmount), - ) - ), - ) - - private def toAbortTransferInstructionResponseItem(entry: AbortTransferInstructionTxLogEntry) = - httpDef.TransactionHistoryResponseItem( - transactionType = HttpTransactionType.AbortTransferInstruction, - eventId = entry.eventId, - offset = Some(entry.offset), - domainId = entry.domainId.toProtoPrimitive, - date = java.time.OffsetDateTime - .ofInstant(entry.date.getOrElse(throw txMissingField()), ZoneOffset.UTC), - abortTransferInstruction = Some( - httpDef.AbortTransferInstruction( - abortKind = entry.transferAbortKind match { - case TransferAbortKind.Unrecognized(_) => - sys.error(s"Unexpected transfer abort kind: ${entry.transferAbortKind}") - case TransferAbortKind.TRANSFER_ABORT_KIND_RESERVED => - sys.error(s"Unexpected transfer abort kind: ${entry.transferAbortKind}") - case TransferAbortKind.TRANSFER_ABORT_KIND_REJECT => - httpDef.AbortTransferInstruction.AbortKind.members.Reject - case TransferAbortKind.TRANSFER_ABORT_KIND_WITHDRAW => - httpDef.AbortTransferInstruction.AbortKind.members.Withdraw - }, - transferInstructionCid = entry.transferInstructionCid, - ) - ), - ) - - def toResponseItem(entry: TransactionTxLogEntry): httpDef.TransactionHistoryResponseItem = - entry match { - case entry: TransferTxLogEntry => toTransferResponseItem(entry) - case entry: TapTxLogEntry => toTapResponseItem(entry) - case entry: MintTxLogEntry => toMintResponseItem(entry) - case entry: AbortTransferInstructionTxLogEntry => - toAbortTransferInstructionResponseItem(entry) - case _ => throw txLogIsOfWrongType(entry.getClass.getSimpleName) - } - def toResponse( status: TransferCommandTxLogEntry.Status ): httpDef.TransferCommandContractStatus = diff --git a/cluster/configs/shared/rate-limits/unlimited.yaml b/cluster/configs/shared/rate-limits/unlimited.yaml index 65b069f8b8..9ea479e8fa 100644 --- a/cluster/configs/shared/rate-limits/unlimited.yaml +++ b/cluster/configs/shared/rate-limits/unlimited.yaml @@ -85,10 +85,6 @@ rateLimits: name: dso-party-id type: unlimited - /api/scan/v0/transactions: - name: transactions - type: unlimited - /api/scan/v0/backfilling: name: backfilling type: unlimited diff --git a/cluster/deployment/scratchneta/config.resolved.yaml b/cluster/deployment/scratchneta/config.resolved.yaml index 44115509bb..b1a109d6c3 100644 --- a/cluster/deployment/scratchneta/config.resolved.yaml +++ b/cluster/deployment/scratchneta/config.resolved.yaml @@ -326,9 +326,6 @@ sv: /api/scan/v0/synchronizer-identities: name: 'synchronizer-identities' type: 'unlimited' - /api/scan/v0/transactions: - name: 'transactions' - type: 'unlimited' /api/scan/v0/transfer-command: name: 'transfer-command-status' type: 'unlimited' diff --git a/cluster/deployment/scratchnetb/config.resolved.yaml b/cluster/deployment/scratchnetb/config.resolved.yaml index 44115509bb..b1a109d6c3 100644 --- a/cluster/deployment/scratchnetb/config.resolved.yaml +++ b/cluster/deployment/scratchnetb/config.resolved.yaml @@ -326,9 +326,6 @@ sv: /api/scan/v0/synchronizer-identities: name: 'synchronizer-identities' type: 'unlimited' - /api/scan/v0/transactions: - name: 'transactions' - type: 'unlimited' /api/scan/v0/transfer-command: name: 'transfer-command-status' type: 'unlimited' diff --git a/cluster/deployment/scratchnetc/config.resolved.yaml b/cluster/deployment/scratchnetc/config.resolved.yaml index 44115509bb..b1a109d6c3 100644 --- a/cluster/deployment/scratchnetc/config.resolved.yaml +++ b/cluster/deployment/scratchnetc/config.resolved.yaml @@ -326,9 +326,6 @@ sv: /api/scan/v0/synchronizer-identities: name: 'synchronizer-identities' type: 'unlimited' - /api/scan/v0/transactions: - name: 'transactions' - type: 'unlimited' /api/scan/v0/transfer-command: name: 'transfer-command-status' type: 'unlimited' diff --git a/cluster/deployment/scratchnetd/config.resolved.yaml b/cluster/deployment/scratchnetd/config.resolved.yaml index 44115509bb..b1a109d6c3 100644 --- a/cluster/deployment/scratchnetd/config.resolved.yaml +++ b/cluster/deployment/scratchnetd/config.resolved.yaml @@ -326,9 +326,6 @@ sv: /api/scan/v0/synchronizer-identities: name: 'synchronizer-identities' type: 'unlimited' - /api/scan/v0/transactions: - name: 'transactions' - type: 'unlimited' /api/scan/v0/transfer-command: name: 'transfer-command-status' type: 'unlimited' diff --git a/cluster/deployment/scratchnete/config.resolved.yaml b/cluster/deployment/scratchnete/config.resolved.yaml index 44115509bb..b1a109d6c3 100644 --- a/cluster/deployment/scratchnete/config.resolved.yaml +++ b/cluster/deployment/scratchnete/config.resolved.yaml @@ -326,9 +326,6 @@ sv: /api/scan/v0/synchronizer-identities: name: 'synchronizer-identities' type: 'unlimited' - /api/scan/v0/transactions: - name: 'transactions' - type: 'unlimited' /api/scan/v0/transfer-command: name: 'transfer-command-status' type: 'unlimited' diff --git a/cluster/expected/canton-network/expected.json b/cluster/expected/canton-network/expected.json index ce454feac0..8abfa07fd2 100644 --- a/cluster/expected/canton-network/expected.json +++ b/cluster/expected/canton-network/expected.json @@ -1772,10 +1772,6 @@ "name": "synchronizer-identities", "type": "unlimited" }, - "/api/scan/v0/transactions": { - "name": "transactions", - "type": "unlimited" - }, "/api/scan/v0/transfer-command": { "name": "transfer-command-status", "type": "unlimited" @@ -2155,10 +2151,6 @@ "name": "synchronizer-identities", "type": "unlimited" }, - "/api/scan/v0/transactions": { - "name": "transactions", - "type": "unlimited" - }, "/api/scan/v0/transfer-command": { "name": "transfer-command-status", "type": "unlimited" diff --git a/cluster/expected/sv-runbook/expected.json b/cluster/expected/sv-runbook/expected.json index a149bccb94..4cac4780b2 100644 --- a/cluster/expected/sv-runbook/expected.json +++ b/cluster/expected/sv-runbook/expected.json @@ -997,10 +997,6 @@ "name": "synchronizer-identities", "type": "unlimited" }, - "/api/scan/v0/transactions": { - "name": "transactions", - "type": "unlimited" - }, "/api/scan/v0/transfer-command": { "name": "transfer-command-status", "type": "unlimited" diff --git a/docs/src/release_notes_upcoming.rst b/docs/src/release_notes_upcoming.rst index 5f5ee78c44..e70317f3c6 100644 --- a/docs/src/release_notes_upcoming.rst +++ b/docs/src/release_notes_upcoming.rst @@ -6,3 +6,7 @@ .. NOTE: add your upcoming release notes below this line. They are included in the `release_notes.rst`. .. release-notes:: Upcoming + + - Scan app + + - Remove deprecated ``/transactions`` endpoint. From f1a3d4e9c712cbe84dce949e26e07ee20161066a Mon Sep 17 00:00:00 2001 From: Julien Tinguely Date: Mon, 27 Jul 2026 10:43:32 +0200 Subject: [PATCH 19/31] Add accept-language in pekko ignore-illegal-header (#6566) Signed-off-by: Julien Tinguely --- apps/app/src/main/resources/application.conf | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/app/src/main/resources/application.conf b/apps/app/src/main/resources/application.conf index ef8ce7ae4b..52931b943c 100644 --- a/apps/app/src/main/resources/application.conf +++ b/apps/app/src/main/resources/application.conf @@ -2,7 +2,8 @@ pekko.http.server.request-timeout = 38 seconds pekko.http.server.parsing.error-handler="org.lfdecentralizedtrust.splice.http.PekkoHttpParsingErrorHandler$" pekko.http.server.parsing.ignore-illegal-header-for = - [ "authorization" + [ "accept-language" + "authorization" "cookie" "origin" "proxy-authorization" From b5936f28256c1a7d67ad1913f438daf7c1892b37 Mon Sep 17 00:00:00 2001 From: Cale Gibbard Date: Mon, 27 Jul 2026 06:48:28 -0400 Subject: [PATCH 20/31] Add a script which constructs a fake single package for speeding up dev cycles (#6523) --------- Signed-off-by: Cale Gibbard --- .gitignore | 2 ++ daml/daml-ide-mono/README.md | 15 +++++++++++++++ daml/daml-ide-mono/daml.yaml | 17 +++++++++++++++++ docs/gen-daml-docs.sh | 1 + scripts/rename.sh | 2 +- scripts/setup-mono-package.sh | 34 ++++++++++++++++++++++++++++++++++ 6 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 daml/daml-ide-mono/README.md create mode 100644 daml/daml-ide-mono/daml.yaml create mode 100755 scripts/setup-mono-package.sh diff --git a/.gitignore b/.gitignore index 2c56be64b4..fae017aaa8 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,8 @@ _build/ **/metals.sbt **/.scala-build/* +daml/daml-ide-mono/daml/ +daml/daml-ide-mono/.vscode/ .vscode/* !.vscode/settings.json # Make sure test files are checkedin diff --git a/daml/daml-ide-mono/README.md b/daml/daml-ide-mono/README.md new file mode 100644 index 0000000000..4a24340200 --- /dev/null +++ b/daml/daml-ide-mono/README.md @@ -0,0 +1,15 @@ +# What is this? + +This directory, along with its fake `daml.yaml` can be populated with symlinks by +`./scripts/setup-mono-package.sh` to serve as a fake single-dar package containing all the daml in +Splice such that cross-package changes can be worked on with a tighter feedback loop using the +Daml Language Server (LSP) in VS Code or other editors, e.g. by running `dpm studio` in this directory +after having run the script. + +You'll also have to remember to re-run the script if you add new `.daml` files to any package. + +You should still verify that everything builds/tests for real once you're done of course, but this mode +of interaction can be very useful to eliminate 12-20 second build cycles and instead get immediate +feedback from the VS Code extension when working on tests and making changes that would otherwise be in +their upstream DAR dependencies, for example. + diff --git a/daml/daml-ide-mono/daml.yaml b/daml/daml-ide-mono/daml.yaml new file mode 100644 index 0000000000..e998fdd9a9 --- /dev/null +++ b/daml/daml-ide-mono/daml.yaml @@ -0,0 +1,17 @@ +sdk-version: 3.5.2 +name: splice-mono-ide +source: daml +version: 0.0.1 +dependencies: + - daml-prim + - daml-stdlib + - daml-script +build-options: + - --ghc-option=-Wunused-binds + - --ghc-option=-Wunused-matches + - --target=2.1 + - -Wno-upgrade-exceptions + - -Wno-deprecated-exceptions + - -Wno-template-interface-depends-on-daml-script + - -Wno-upgrade-interfaces + - --force-utility-package=no diff --git a/docs/gen-daml-docs.sh b/docs/gen-daml-docs.sh index ac5e208f7f..1fd1c1818e 100755 --- a/docs/gen-daml-docs.sh +++ b/docs/gen-daml-docs.sh @@ -47,6 +47,7 @@ DAML_PROJECT_FILES="\ -not -ipath '*splitwell*' \ -not -ipath '*app-manager*' \ -not -ipath '*dummy-holding*' \ + -not -ipath '*daml-ide-mono*' \ -print)" DAML_PROJECT_FILES=$(printf "%s\n" "$DAML_PROJECT_FILES" | grep -vf <(printf "%s\n" "${NON_COMPILED_DAML_PROJECTS[@]}" | xargs -n1 basename)) diff --git a/scripts/rename.sh b/scripts/rename.sh index 92ff142232..8fc9397d5e 100755 --- a/scripts/rename.sh +++ b/scripts/rename.sh @@ -1216,7 +1216,7 @@ function subcmd_no_illegal_daml_references() { ) for pattern in "${illegal_patterns[@]}"; do echo "Checking for occurences of '$pattern' (case sensitive, in code other than splitwell)" - if rg -P "$pattern" daml/ token-standard/ -g '!*/splitwell/*' -g '!*/splitwell-test/*' -g '!daml/dars.lock' -g '!token-standard/README.md' -g '!token-standard/V2_VALIDATION.md' -g '!token-standard/TOKEN_STANDARD_V2_DEVNET.md' -g '!*.json' -g '!token-standard/dependencies/*' -g '!**/target/'; then + if rg -P "$pattern" daml/ token-standard/ -g '!*/splitwell/*' -g '!*/splitwell-test/*' -g '!daml/dars.lock' -g '!token-standard/README.md' -g '!token-standard/V2_VALIDATION.md' -g '!token-standard/TOKEN_STANDARD_V2_DEVNET.md' -g'!daml/daml-ide-mono/README.md' -g '!*.json' -g '!token-standard/dependencies/*' -g '!**/target/'; then echo "$pattern occurs in Daml code (other than splitwell), remove all references" exit 1 fi diff --git a/scripts/setup-mono-package.sh b/scripts/setup-mono-package.sh new file mode 100755 index 0000000000..d7e8cc34f8 --- /dev/null +++ b/scripts/setup-mono-package.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash + +# Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Populate daml/daml-ide-mono/daml/ with per-file symlinks into every +# workspace Daml package's source tree. daml/daml-ide-mono/daml.yaml is +# checked in and static - this script only regenerates the source tree +# so that VS Code can work on the union as a single package. +# +# Re-run this any time a .daml file is added or removed anywhere in the +# workspace. + +set -euo pipefail + +repo=$(cd "$(dirname "$0")/.." && pwd) +dest="$repo/daml/daml-ide-mono/daml" + +rm -rf "$dest" +mkdir -p "$dest" + +for pkg in "$repo"/daml/*/daml.yaml \ + "$repo"/token-standard/*/daml.yaml \ + "$repo"/token-standard/examples/*/daml.yaml; do + src=$(dirname "$pkg")/daml + [ -d "$src" ] || continue + ( cd "$src" && find . -name '*.daml' -printf '%P\n' ) | + while IFS= read -r rel; do + link="$dest/$rel" + mkdir -p "$(dirname "$link")" + target=$(realpath --relative-to="$(dirname "$link")" "$src/$rel") + ln -sfn "$target" "$link" + done +done From 91bdc7af4a407d14f6c7f59e2acaff4a3b5d09df Mon Sep 17 00:00:00 2001 From: moritzkiefer-da <45630097+moritzkiefer-da@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:31:15 +0200 Subject: [PATCH 21/31] Remove CC txlog parsing from Scan (#6551) --------- Signed-off-by: moritz.kiefer@digitalasset.com Co-authored-by: moritz.kiefer@digitalasset.com --- .github/workflows/build.deployment_test.yml | 2 +- .github/workflows/build.docs.yml | 4 +- .github/workflows/build.scala_test.yml | 2 +- .../build.scala_test_for_compose.yml | 2 +- .../build.scala_test_with_cometbft.yml | 2 +- .github/workflows/build.static_tests.yml | 4 +- .github/workflows/build.ts_cli_tests.yml | 2 +- .github/workflows/build.ui_tests.yml | 4 +- .github/workflows/bump_gha_runner_version.yml | 2 +- .github/workflows/performance_tests.yml | 4 +- .github/workflows/pr_check_github_scripts.yml | 2 +- .../SvOnboardingAddlIntegrationTest.scala | 3 +- .../tests/WalletTxLogIntegrationTest.scala | 16 +- apps/scan/src/main/protobuf/scan_tx_log.proto | 169 ---- .../splice/scan/store/CachingScanStore.scala | 13 - .../splice/scan/store/ScanStore.scala | 10 - .../splice/scan/store/ScanTxLogParser.scala | 886 +----------------- .../splice/scan/store/TxLogEntry.scala | 149 +-- .../splice/scan/store/db/DbScanStore.scala | 60 -- .../splice/scan/store/db/ScanTables.scala | 61 -- .../splice/store/db/ScanStoreTest.scala | 153 --- 21 files changed, 34 insertions(+), 1516 deletions(-) diff --git a/.github/workflows/build.deployment_test.yml b/.github/workflows/build.deployment_test.yml index 2652c6b445..e7705bb0ed 100644 --- a/.github/workflows/build.deployment_test.yml +++ b/.github/workflows/build.deployment_test.yml @@ -25,7 +25,7 @@ jobs: - name: Setup uses: ./.github/actions/tests/common_test_setup with: - cache_version: 8 + cache_version: 9 test_name: deployment_test with_sbt: false diff --git a/.github/workflows/build.docs.yml b/.github/workflows/build.docs.yml index 11311ee26e..362a4cf039 100644 --- a/.github/workflows/build.docs.yml +++ b/.github/workflows/build.docs.yml @@ -25,7 +25,7 @@ jobs: id: setup uses: ./.github/actions/tests/common_test_setup with: - cache_version: 8 + cache_version: 9 test_name: docs save_nix_cache: true save_nix_cache_to_gcp: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }} @@ -48,7 +48,7 @@ jobs: - name: Post-SBT job uses: ./.github/actions/sbt/post_sbt with: - cache_version: 8 + cache_version: 9 setup_sbt_cache_hits: ${{ steps.setup.outputs.sbt_cache_hits }} - name: Report Failures on Slack & Github diff --git a/.github/workflows/build.scala_test.yml b/.github/workflows/build.scala_test.yml index 855939556c..5f0434d110 100644 --- a/.github/workflows/build.scala_test.yml +++ b/.github/workflows/build.scala_test.yml @@ -137,7 +137,7 @@ jobs: - name: Run Tests uses: ./.github/actions/tests/scala_test with: - cache_version: 8 + cache_version: 9 with_canton: ${{ inputs.with_canton }} start_canton_options: ${{ inputs.start_canton_options }} test_suite_name: ${{ inputs.test_name }} diff --git a/.github/workflows/build.scala_test_for_compose.yml b/.github/workflows/build.scala_test_for_compose.yml index 1c8d0d21ae..b348d7723c 100644 --- a/.github/workflows/build.scala_test_for_compose.yml +++ b/.github/workflows/build.scala_test_for_compose.yml @@ -104,7 +104,7 @@ jobs: - name: Run Tests uses: ./.github/actions/tests/scala_test with: - cache_version: 8 + cache_version: 9 start_canton_options: ${{ inputs.start_canton_options }} test_suite_name: ${{ inputs.test_name }} test_names: ${{ needs.split_tests.outputs.test_names }} diff --git a/.github/workflows/build.scala_test_with_cometbft.yml b/.github/workflows/build.scala_test_with_cometbft.yml index 76a5512c1e..6434e79ee7 100644 --- a/.github/workflows/build.scala_test_with_cometbft.yml +++ b/.github/workflows/build.scala_test_with_cometbft.yml @@ -120,7 +120,7 @@ jobs: - name: Run Tests uses: ./.github/actions/tests/scala_test with: - cache_version: 8 + cache_version: 9 start_canton_options: -F -w test_suite_name: ${{ inputs.test_name }} test_names: ${{ needs.split_tests.outputs.test_names }} diff --git a/.github/workflows/build.static_tests.yml b/.github/workflows/build.static_tests.yml index 4820b0ad5e..6cfa6d7584 100644 --- a/.github/workflows/build.static_tests.yml +++ b/.github/workflows/build.static_tests.yml @@ -31,7 +31,7 @@ jobs: - name: Setup Nix uses: ./.github/actions/tests/common_test_setup with: - cache_version: 8 + cache_version: 9 test_name: canton_consistency target: 'static_tests' @@ -84,7 +84,7 @@ jobs: id: setup uses: ./.github/actions/tests/common_test_setup with: - cache_version: 8 + cache_version: 9 test_name: static_tests target: 'static_tests' diff --git a/.github/workflows/build.ts_cli_tests.yml b/.github/workflows/build.ts_cli_tests.yml index c746a821ff..1ddf0f3bf9 100644 --- a/.github/workflows/build.ts_cli_tests.yml +++ b/.github/workflows/build.ts_cli_tests.yml @@ -41,7 +41,7 @@ jobs: if: steps.skip.outputs.skip != 'true' uses: ./.github/actions/tests/common_test_setup with: - cache_version: 8 + cache_version: 9 test_name: ts_cli - name: Run Token Standard CLI tests diff --git a/.github/workflows/build.ui_tests.yml b/.github/workflows/build.ui_tests.yml index 27742c3134..aa1b5b5855 100644 --- a/.github/workflows/build.ui_tests.yml +++ b/.github/workflows/build.ui_tests.yml @@ -41,7 +41,7 @@ jobs: if: steps.skip.outputs.skip != 'true' uses: ./.github/actions/tests/common_test_setup with: - cache_version: 8 + cache_version: 9 test_name: ui_tests - name: Run UI tests @@ -62,7 +62,7 @@ jobs: if: steps.skip.outputs.skip != 'true' uses: ./.github/actions/sbt/post_sbt with: - cache_version: 8 + cache_version: 9 setup_sbt_cache_hits: ${{ steps.setup.outputs.sbt_cache_hits }} - name: Upload logs diff --git a/.github/workflows/bump_gha_runner_version.yml b/.github/workflows/bump_gha_runner_version.yml index 8556944a43..b6e3838256 100644 --- a/.github/workflows/bump_gha_runner_version.yml +++ b/.github/workflows/bump_gha_runner_version.yml @@ -27,7 +27,7 @@ jobs: - name: Set up Nix (Self hosted) uses: ./.github/actions/nix/setup_nix with: - cache_version: 8 + cache_version: 9 target: default - name: Check for the latest version and create a PR to splice diff --git a/.github/workflows/performance_tests.yml b/.github/workflows/performance_tests.yml index 12895fe0b8..caf66cf18d 100644 --- a/.github/workflows/performance_tests.yml +++ b/.github/workflows/performance_tests.yml @@ -34,7 +34,7 @@ jobs: id: setup uses: ./.github/actions/tests/common_test_setup with: - cache_version: 8 + cache_version: 9 test_name: ingestion_performance_tests # Authenticate to GCP for read access to GCS @@ -137,7 +137,7 @@ jobs: id: setup uses: ./.github/actions/tests/common_test_setup with: - cache_version: 8 + cache_version: 9 test_name: read_performance_tests # Authenticate to GCP for read access to GCS diff --git a/.github/workflows/pr_check_github_scripts.yml b/.github/workflows/pr_check_github_scripts.yml index 491bbacd5c..23f6125797 100644 --- a/.github/workflows/pr_check_github_scripts.yml +++ b/.github/workflows/pr_check_github_scripts.yml @@ -15,7 +15,7 @@ jobs: - name: Set up Nix uses: ./.github/actions/nix/setup_nix with: - cache_version: 8 + cache_version: 9 - name: Check github scripts uses: ./.github/actions/nix/run_bash_command_in_nix with: diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvOnboardingAddlIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvOnboardingAddlIntegrationTest.scala index cfccc75f9c..25be93015f 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvOnboardingAddlIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvOnboardingAddlIntegrationTest.scala @@ -319,8 +319,7 @@ class SvOnboardingAddlIntegrationTest forAll(lines)(line => line.message should include("Unexpected amulet create event")) // Error emitted by every ScanTxLogParser plus the one UserWalletTxLogParser // associated with the owner of the coin. - lines should have size 2 withClue "ScanTxLogParser + UserWalletTxLogParser error" - forExactly(1, lines)(line => line.loggerName should include("sv1Scan")) + lines should have size 1 withClue "UserWalletTxLogParser error" forExactly(1, lines)(line => line.loggerName should include("sv1Validator")) }, ) diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletTxLogIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletTxLogIntegrationTest.scala index f88e98f41e..f7b02ba426 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletTxLogIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletTxLogIntegrationTest.scala @@ -1388,10 +1388,10 @@ class WalletTxLogIntegrationTest logs => inside(logs) { case logLines if logLines.nonEmpty => - logLines - .filter(_.errorMessage contains ("RuntimeException")) - .foreach(_.errorMessage should include("Unexpected amulet create event")) - logLines should have size (env.scans.local.size.toLong + 1) // + 1 for UserWalletTxLog + forExactly(1, logLines) { line => + line.errorMessage should include("Unexpected amulet create event") + line.loggerName should include("DbMultiDomainAcsStore") + } }, ) @@ -1407,10 +1407,10 @@ class WalletTxLogIntegrationTest logs => inside(logs) { case logLines if logLines.nonEmpty => - logLines - .filter(_.errorMessage contains ("RuntimeException")) - .foreach(_.errorMessage should include("Unexpected amulet archive event")) - logLines should have size (env.scans.local.size.toLong + 1) // + 1 for UserWalletTxLog + forExactly(1, logLines) { line => + line.errorMessage should include("Unexpected amulet archive event") + line.loggerName should include("DbMultiDomainAcsStore") + } }, ) diff --git a/apps/scan/src/main/protobuf/scan_tx_log.proto b/apps/scan/src/main/protobuf/scan_tx_log.proto index 167d0b1361..34b3215631 100644 --- a/apps/scan/src/main/protobuf/scan_tx_log.proto +++ b/apps/scan/src/main/protobuf/scan_tx_log.proto @@ -9,13 +9,6 @@ import "google/protobuf/timestamp.proto"; import "google/protobuf/struct.proto"; import "scalapb/scalapb.proto"; -message PartyBalanceChange { - option (scalapb.message).companion_extends = "org.lfdecentralizedtrust.splice.store.TxLogStore.TxLogEntryTypeMappers"; - - string change_to_initial_amount_as_of_round_zero = 1 [(scalapb.field).type = "scala.math.BigDecimal"]; - string change_to_holding_fees_rate = 2 [(scalapb.field).type = "scala.math.BigDecimal"]; -} - message SteppedRate { option (scalapb.message).companion_extends = "org.lfdecentralizedtrust.splice.store.TxLogStore.TxLogEntryTypeMappers"; @@ -30,38 +23,6 @@ message SteppedRate { repeated Step steps = 2; } -message SenderAmount { - option (scalapb.message).companion_extends = "org.lfdecentralizedtrust.splice.store.TxLogStore.TxLogEntryTypeMappers"; - - string party = 1 [(scalapb.field).type = "com.digitalasset.canton.topology.PartyId"]; - string input_amulet_amount = 2 [(scalapb.field).type = "scala.math.BigDecimal"]; - string input_app_reward_amount = 3 [(scalapb.field).type = "scala.math.BigDecimal"]; - string input_validator_reward_amount = 4 [(scalapb.field).type = "scala.math.BigDecimal"]; - string sender_change_amount = 5 [(scalapb.field).type = "scala.math.BigDecimal"]; - string sender_change_fee = 6 [(scalapb.field).type = "scala.math.BigDecimal"]; - string sender_fee = 7 [(scalapb.field).type = "scala.math.BigDecimal"]; - string holding_fees = 8 [(scalapb.field).type = "scala.math.BigDecimal"]; - // Added after initial release, so needs to be mapped to an Option in scala - string input_sv_reward_amount = 9 [(scalapb.field).type = "Option[scala.math.BigDecimal]"]; - string input_validator_faucet_amount = 10 [(scalapb.field).type = "Option[scala.math.BigDecimal]"]; -} - -message ReceiverAmount { - option (scalapb.message).companion_extends = "org.lfdecentralizedtrust.splice.store.TxLogStore.TxLogEntryTypeMappers"; - - string party = 1 [(scalapb.field).type = "com.digitalasset.canton.topology.PartyId"]; - string amount = 2 [(scalapb.field).type = "scala.math.BigDecimal"]; - string receiver_fee = 3 [(scalapb.field).type = "scala.math.BigDecimal"]; -} - -message BalanceChange { - option (scalapb.message).companion_extends = "org.lfdecentralizedtrust.splice.store.TxLogStore.TxLogEntryTypeMappers"; - - string party = 1 [(scalapb.field).type = "com.digitalasset.canton.topology.PartyId"]; - string change_to_initial_amount_as_of_round_zero = 2 [(scalapb.field).type = "scala.math.BigDecimal"]; - string change_to_holding_fees_rate = 3 [(scalapb.field).type = "scala.math.BigDecimal"]; -} - message ErrorTxLogEntry { option (scalapb.message).extends = "org.lfdecentralizedtrust.splice.scan.store.TxLogEntry"; option (scalapb.message).companion_extends = "org.lfdecentralizedtrust.splice.store.TxLogStore.TxLogEntryTypeMappers"; @@ -69,30 +30,6 @@ message ErrorTxLogEntry { string event_id = 1; } -message BalanceChangeTxLogEntry { - option (scalapb.message).extends = "org.lfdecentralizedtrust.splice.scan.store.TxLogEntry"; - option (scalapb.message).companion_extends = "org.lfdecentralizedtrust.splice.store.TxLogStore.TxLogEntryTypeMappers"; - - string event_id = 1; - string domain_id = 2 [(scalapb.field).type = "com.digitalasset.canton.topology.SynchronizerId"]; - int64 round = 3; - string change_to_initial_amount_as_of_round_zero = 4 [(scalapb.field).type = "scala.math.BigDecimal"]; - string change_to_holding_fees_rate = 5 [(scalapb.field).type = "scala.math.BigDecimal"]; - map party_balance_changes = 6 [(scalapb.field).key_type = "com.digitalasset.canton.topology.PartyId"];; -} - -message ExtraTrafficPurchaseTxLogEntry { - option (scalapb.message).extends = "org.lfdecentralizedtrust.splice.scan.store.TxLogEntry"; - option (scalapb.message).companion_extends = "org.lfdecentralizedtrust.splice.store.TxLogStore.TxLogEntryTypeMappers"; - - string event_id = 1; - string domain_id = 2 [(scalapb.field).type = "com.digitalasset.canton.topology.SynchronizerId"]; - int64 round = 3; - string validator = 4 [(scalapb.field).type = "com.digitalasset.canton.topology.PartyId"]; - int64 traffic_purchased = 5; - string cc_spent = 6 [(scalapb.field).type = "scala.math.BigDecimal"]; -} - message OpenMiningRoundTxLogEntry { option (scalapb.message).extends = "org.lfdecentralizedtrust.splice.scan.store.TxLogEntry"; option (scalapb.message).companion_extends = "org.lfdecentralizedtrust.splice.store.TxLogStore.TxLogEntryTypeMappers"; @@ -116,98 +53,6 @@ message ClosedMiningRoundTxLogEntry { google.protobuf.Timestamp effective_at = 4 [(scalapb.field).type = "java.time.Instant"]; } -enum TransferKind { - TRANSFER_KIND_OTHER = 0; - TRANSFER_KIND_CREATE_TRANSFER_INSTRUCTION = 1; - TRANSFER_KIND_TRANSFER_INSTRUCTION_ACCEPT = 2; - TRANSFER_KIND_PREAPPROVAL_SEND = 3; -} - -message TransferTxLogEntry { - option (scalapb.message).extends = "org.lfdecentralizedtrust.splice.scan.store.TxLogEntry.TransactionTxLogEntry"; - option (scalapb.message).companion_extends = "org.lfdecentralizedtrust.splice.store.TxLogStore.TxLogEntryTypeMappers"; - - string event_id = 1; - string offset = 2; - string domain_id = 3 [(scalapb.field).type = "com.digitalasset.canton.topology.SynchronizerId"]; - google.protobuf.Timestamp date = 4 [(scalapb.field).type = "java.time.Instant"]; - string provider = 5; // unused: reserved fields don't work well with the json en/decoding so we keep it here. - SenderAmount sender = 6; - repeated ReceiverAmount receivers = 7; - repeated BalanceChange balance_changes = 8; - int64 round = 9; - string amulet_price = 10; // Unused but our decoding infrastructure doesn't like reserved fields. - - string description = 11; - - string transfer_instruction_receiver = 12; - string transfer_instruction_amount = 13 [(scalapb.field).type = "Option[scala.math.BigDecimal]"]; - string transfer_instruction_cid = 14; - - TransferKind transfer_kind = 15; -} - -message TapTxLogEntry { - option (scalapb.message).extends = "org.lfdecentralizedtrust.splice.scan.store.TxLogEntry.TransactionTxLogEntry"; - option (scalapb.message).companion_extends = "org.lfdecentralizedtrust.splice.store.TxLogStore.TxLogEntryTypeMappers"; - - string event_id = 1; - string offset = 2; - string domain_id = 3 [(scalapb.field).type = "com.digitalasset.canton.topology.SynchronizerId"]; - google.protobuf.Timestamp date = 4 [(scalapb.field).type = "java.time.Instant"]; - string amulet_owner = 5 [(scalapb.field).type = "com.digitalasset.canton.topology.PartyId"]; - string amulet_amount = 6 [(scalapb.field).type = "scala.math.BigDecimal"]; - int64 round = 7; - string amulet_price = 8; // Unused but our decoding infrastructure doesn't like reserved fields. -} - -message MintTxLogEntry { - option (scalapb.message).extends = "org.lfdecentralizedtrust.splice.scan.store.TxLogEntry.TransactionTxLogEntry"; - option (scalapb.message).companion_extends = "org.lfdecentralizedtrust.splice.store.TxLogStore.TxLogEntryTypeMappers"; - - string event_id = 1; - string offset = 2; - string domain_id = 3 [(scalapb.field).type = "com.digitalasset.canton.topology.SynchronizerId"]; - google.protobuf.Timestamp date = 4 [(scalapb.field).type = "java.time.Instant"]; - string amulet_owner = 5 [(scalapb.field).type = "com.digitalasset.canton.topology.PartyId"]; - string amulet_amount = 6 [(scalapb.field).type = "scala.math.BigDecimal"]; - int64 round = 7; - string amulet_price = 8; // Unused but our decoding infrastructure doesn't like reserved fields. -} - -message SvRewardTxLogEntry { - option (scalapb.message).extends = "org.lfdecentralizedtrust.splice.scan.store.TxLogEntry.RewardTxLogEntry"; - option (scalapb.message).companion_extends = "org.lfdecentralizedtrust.splice.store.TxLogStore.TxLogEntryTypeMappers"; - - string event_id = 1; - string domain_id = 2 [(scalapb.field).type = "com.digitalasset.canton.topology.SynchronizerId"]; - int64 round = 3; - string party = 4 [(scalapb.field).type = "com.digitalasset.canton.topology.PartyId"]; - string amount = 5 [(scalapb.field).type = "scala.math.BigDecimal"]; -} - -message AppRewardTxLogEntry { - option (scalapb.message).extends = "org.lfdecentralizedtrust.splice.scan.store.TxLogEntry.RewardTxLogEntry"; - option (scalapb.message).companion_extends = "org.lfdecentralizedtrust.splice.store.TxLogStore.TxLogEntryTypeMappers"; - - string event_id = 1; - string domain_id = 2 [(scalapb.field).type = "com.digitalasset.canton.topology.SynchronizerId"]; - int64 round = 3; - string party = 4 [(scalapb.field).type = "com.digitalasset.canton.topology.PartyId"]; - string amount = 5 [(scalapb.field).type = "scala.math.BigDecimal"]; -} - -message ValidatorRewardTxLogEntry { - option (scalapb.message).extends = "org.lfdecentralizedtrust.splice.scan.store.TxLogEntry.RewardTxLogEntry"; - option (scalapb.message).companion_extends = "org.lfdecentralizedtrust.splice.store.TxLogStore.TxLogEntryTypeMappers"; - - string event_id = 1; - string domain_id = 2 [(scalapb.field).type = "com.digitalasset.canton.topology.SynchronizerId"]; - int64 round = 3; - string party = 4 [(scalapb.field).type = "com.digitalasset.canton.topology.PartyId"]; - string amount = 5 [(scalapb.field).type = "scala.math.BigDecimal"]; -} - message VoteRequestTxLogEntry { option (scalapb.message).extends = "org.lfdecentralizedtrust.splice.scan.store.TxLogEntry"; option (scalapb.message).companion_extends = "org.lfdecentralizedtrust.splice.store.TxLogStore.TxLogEntryTypeMappers"; @@ -253,17 +98,3 @@ enum TransferAbortKind { TRANSFER_ABORT_KIND_WITHDRAW = 1; TRANSFER_ABORT_KIND_REJECT = 2; } - -message AbortTransferInstructionTxLogEntry { - option (scalapb.message).extends = "org.lfdecentralizedtrust.splice.scan.store.TxLogEntry.TransactionTxLogEntry"; - option (scalapb.message).companion_extends = "org.lfdecentralizedtrust.splice.store.TxLogStore.TxLogEntryTypeMappers"; - - string event_id = 1; - string offset = 2; - string domain_id = 3 [(scalapb.field).type = "com.digitalasset.canton.topology.SynchronizerId"]; - google.protobuf.Timestamp date = 4 [(scalapb.field).type = "java.time.Instant"]; - - string transfer_instruction_cid = 14; - - TransferAbortKind transfer_abort_kind = 15; -} diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/CachingScanStore.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/CachingScanStore.scala index c3f054b2cc..dec98aef00 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/CachingScanStore.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/CachingScanStore.scala @@ -35,9 +35,7 @@ import org.lfdecentralizedtrust.splice.store.{ Limit, MiningRoundsStore, MultiDomainAcsStore, - PageLimit, ResultsPage, - SortOrder, SynchronizerStore, TxLogStore, UpdateHistory, @@ -192,17 +190,6 @@ class CachingScanStore( store.lookupTransferCommandCounterByParty, ).get(partyId) - override def listTransactions( - pageEndEventId: Option[String], - sortOrder: SortOrder, - limit: PageLimit, - )(implicit tc: TraceContext): Future[Seq[TxLogEntry.TransactionTxLogEntry]] = - store.listTransactions( - pageEndEventId, - sortOrder, - limit, - ) - override def lookupLatestTransferCommandEvents(sender: PartyId, nonce: Long, limit: Int)(implicit tc: TraceContext ): Future[Map[TransferCommand.ContractId, TransferCommandTxLogEntry]] = diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/ScanStore.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/ScanStore.scala index fbe1500723..60c9f7e649 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/ScanStore.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/ScanStore.scala @@ -30,8 +30,6 @@ import org.lfdecentralizedtrust.splice.store.{ ExternalPartyConfigStateStore, MiningRoundsStore, MultiDomainAcsStore, - PageLimit, - SortOrder, TxLogAppStore, UpdateHistory, VotesStore, @@ -215,14 +213,6 @@ trait ScanStore ]] ] - def listTransactions( - pageEndEventId: Option[String], - sortOrder: SortOrder, - limit: PageLimit, - )(implicit - tc: TraceContext - ): Future[Seq[TxLogEntry.TransactionTxLogEntry]] - def lookupLatestTransferCommandEvents( sender: PartyId, nonce: Long, diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/ScanTxLogParser.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/ScanTxLogParser.scala index a33c651e98..47a6e1f913 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/ScanTxLogParser.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/ScanTxLogParser.scala @@ -9,10 +9,6 @@ import com.daml.ledger.javaapi.data.* import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} import com.digitalasset.canton.topology.{PartyId, SynchronizerId} import com.digitalasset.canton.tracing.TraceContext -import io.grpc.Status -import org.lfdecentralizedtrust.splice.codegen.java.splice -import org.lfdecentralizedtrust.splice.codegen.java.splice.amulet.AmuletCreateSummary -import org.lfdecentralizedtrust.splice.codegen.java.splice.amuletrules.TransferResult import org.lfdecentralizedtrust.splice.codegen.java.splice.dsorules.{ DsoRules_CloseVoteRequest, DsoRules_CloseVoteRequestResult, @@ -21,23 +17,13 @@ import org.lfdecentralizedtrust.splice.codegen.java.splice.externalpartyamuletru TransferCommandResultFailure, TransferCommandResultSuccess, } -import org.lfdecentralizedtrust.splice.codegen.java.splice.wallet.subscriptions as sws import org.lfdecentralizedtrust.splice.history.* -import org.lfdecentralizedtrust.splice.scan.store.TxLogEntry.* import org.lfdecentralizedtrust.splice.store.TxLogStore import org.lfdecentralizedtrust.splice.store.events.DsoRulesCloseVoteRequest import org.lfdecentralizedtrust.splice.util.SpliceUtil.dollarsToCC -import org.lfdecentralizedtrust.splice.util.TransactionTreeExtensions.* -import org.lfdecentralizedtrust.splice.util.{ - Codec, - EventId, - ExerciseNode, - LegacyOffset, - TokenStandardMetadata, -} +import org.lfdecentralizedtrust.splice.util.{Codec, EventId, ExerciseNode} import scala.collection.immutable -import scala.jdk.OptionConverters.* import scala.jdk.CollectionConverters.* import scala.math.BigDecimal.javaBigDecimal2bigDecimal @@ -81,387 +67,6 @@ class ScanTxLogParser( exercised.getNodeId, ) exercised match { - case Transfer(node) => - State.fromTransfer(tree, exercised, synchronizerId, node) - case TransferPreapproval_Send(node) => - val state = parseTrees( - tree, - synchronizerId, - tree.getChildNodeIds(exercised).asScala.toList, - ignoreUnexpectedAmuletCreateArchive, - ) - state.setTransferPreapprovalSendFields(tree, exercised, node.argument.value.description) - case TransferPreapproval_SendV2(node) => - val receiver = (node.result.value.result.summary.balanceChanges.asScala.keySet - .diff(Set(node.argument.value.sender))) - .headOption - .getOrElse(node.argument.value.sender) - val output = new splice.amuletrules.TransferOutput( - receiver, - BigDecimal(0).bigDecimal, // receiver fee ratio is irrelevant, there are no fees - node.argument.value.amount, - java.util.Optional.empty(), // lock - java.util.Optional.empty(), // meta - ) - val state = State.fromTransferResult( - tree, - exercised, - synchronizerId, - sender = node.argument.value.sender, - outputs = Seq(output), - result = node.result.value.result, - ) - val description = - node.result.value.meta.values.asScala.get(TokenStandardMetadata.reasonMetaKey) - state.setTransferPreapprovalSendFields(tree, exercised, description.toJava) - case CreateTokenStandardTransferInstruction(node) => - // TODO(tech-debt): remove this duplication with CreateTokenStandardTransferInstructionV2 - val cid: String = node.result.value.output match { - case output: splice.api.token.transferinstructionv1.transferinstructionresult_output.TransferInstructionResult_Pending => - output.transferInstructionCid.contractId - case output => - // CreateTokenStandardTransferInstruction only matches on two-step transfers resulting in pending status. - // Single-step transfers are just parsed as the underlying transfer. - logger.warn( - s"Unexpected transfer instruction result output, expected pending but got: $output" - ) - "" - } - val state = parseTrees( - tree, - synchronizerId, - tree.getChildNodeIds(exercised).asScala.toList, - ignoreUnexpectedAmuletCreateArchive = true, - ) - val stateWithTransfer = if (state.hasTransfer) { - // We hit this for transfers before the 24h signing change that call AmuletRules_Transfer internally. - state - } else { - val txLogEntry = new TransferTxLogEntry( - offset = LegacyOffset.Api.fromLong(tree.getOffset), - domainId = synchronizerId, - date = Some(tree.getEffectiveAt), - sender = Some( - senderAmountNoFees( - node.argument.value.transfer.sender, - 0.0, // Note: Because Scan tracks the sum of locked and unlocked input and output is 0. - ) - ), - // receiver is set to the sender as the amulet is locked to them - receivers = Seq( - receiverAmountNoFees( - node.argument.value.transfer.sender, - node.argument.value.transfer.amount, - ) - ), - balanceChanges = Seq.empty, - ) - State( - txLogEntry - ) - } - stateWithTransfer.copy( - entries = stateWithTransfer.entries.map { - case e: TransferTxLogEntry => - e.copy( - description = node.argument.value.transfer.meta.values - .getOrDefault(TokenStandardMetadata.reasonMetaKey, ""), - transferInstructionReceiver = node.argument.value.transfer.receiver, - transferInstructionAmount = Some(node.argument.value.transfer.amount), - transferInstructionCid = cid, - eventId = - EventId.prefixedFromUpdateIdAndNodeId(tree.getUpdateId, exercised.getNodeId), - transferKind = TransferKind.TRANSFER_KIND_CREATE_TRANSFER_INSTRUCTION, - ) - case e => e - } - ) - case DirectTokenStandardTransfer(node) => - // TODO(tech-debt): remove this duplication with DirectTokenStandardTransferV2 - val sender = node.argument.value.transfer.sender - val receiver = node.argument.value.transfer.receiver - val amount = node.argument.value.transfer.amount - - val senderAmount = senderAmountNoFees(sender, amount) - val state = parseTrees( - tree, - synchronizerId, - tree.getChildNodeIds(exercised).asScala.toList, - ignoreUnexpectedAmuletCreateArchive = true, - ) - if (state.hasTransfer) { - // We hit this for transfers before the 24h signing delay change that call AmuletRules_Transfer or TransferPreapproval_Send internally - // or transfers with the 24h signing delay change where sender != receiver which call into TransferPreapproval_SendV2 - state - } else { - // We hit this only when sender = receiver and the 24h signing delay change is active as then there is no TransferPreapproval_SendV2 child. - // We just parse this as a plain transfer matching the behavior before the 24h signing delay change. - val txLogEntry = new TransferTxLogEntry( - offset = LegacyOffset.Api.fromLong(tree.getOffset), - eventId = - EventId.prefixedFromUpdateIdAndNodeId(tree.getUpdateId, exercised.getNodeId), - domainId = synchronizerId, - date = Some(tree.getEffectiveAt), - sender = Some(senderAmount), - receivers = Seq(receiverAmountNoFees(receiver, amount)), - balanceChanges = Seq.empty, - description = node.argument.value.transfer.meta.values - .getOrDefault(TokenStandardMetadata.reasonMetaKey, ""), - ) - State(txLogEntry) - } - case CreateTokenStandardTransferInstructionV2(node) => - // TODO(tech-debt): remove this duplication with CreateTokenStandardTransferInstruction - val admin = node.argument.value.transfer.instrumentId.admin - val cid: String = node.result.value.output match { - case output: splice.api.token.transferinstructionv2.transferinstructionresult_output.TransferInstructionResult_Pending => - output.transferInstructionCid.contractId - case output => - // CreateTokenStandardTransferInstruction only matches on two-step transfers resulting in pending status. - // Single-step transfers are just parsed as the underlying transfer. - logger.warn( - s"Unexpected transfer instruction result output, expected pending but got: $output" - ) - "" - } - val state = parseTrees( - tree, - synchronizerId, - tree.getChildNodeIds(exercised).asScala.toList, - ignoreUnexpectedAmuletCreateArchive = true, - ) - val stateWithTransfer = if (state.hasTransfer) { - // We hit this for transfers before the 24h signing change that call AmuletRules_Transfer internally. - state - } else { - val txLogEntry = new TransferTxLogEntry( - offset = LegacyOffset.Api.fromLong(tree.getOffset), - domainId = synchronizerId, - date = Some(tree.getEffectiveAt), - sender = Some( - senderAmountNoFees( - node.argument.value.transfer.sender.owner.toScala.getOrElse(admin), - 0.0, // Note: Because Scan tracks the sum of locked and unlocked input and output is 0. - ) - ), - // receiver is set to the sender as the amulet is locked to them - receivers = Seq( - receiverAmountNoFees( - node.argument.value.transfer.sender.owner.toScala.getOrElse(admin), - node.argument.value.transfer.amount, - ) - ), - balanceChanges = Seq.empty, - ) - State( - txLogEntry - ) - } - stateWithTransfer.copy( - entries = stateWithTransfer.entries.map { - case e: TransferTxLogEntry => - e.copy( - description = node.argument.value.transfer.meta.values - .getOrDefault(TokenStandardMetadata.reasonMetaKey, ""), - transferInstructionReceiver = - node.argument.value.transfer.receiver.owner.toScala.getOrElse(admin), - transferInstructionAmount = Some(node.argument.value.transfer.amount), - transferInstructionCid = cid, - eventId = - EventId.prefixedFromUpdateIdAndNodeId(tree.getUpdateId, exercised.getNodeId), - transferKind = TransferKind.TRANSFER_KIND_CREATE_TRANSFER_INSTRUCTION, - ) - case e => e - } - ) - case DirectTokenStandardTransferV2(node) => - // TODO(tech-debt): remove this duplication with DirectTokenStandardTransfer - val admin = node.argument.value.transfer.instrumentId.admin - val sender = node.argument.value.transfer.sender.owner.toScala.getOrElse(admin) - val receiver = node.argument.value.transfer.receiver.owner.toScala.getOrElse(admin) - val amount = node.argument.value.transfer.amount - - val senderAmount = senderAmountNoFees(sender, amount) - val state = parseTrees( - tree, - synchronizerId, - tree.getChildNodeIds(exercised).asScala.toList, - ignoreUnexpectedAmuletCreateArchive = true, - ) - if (state.hasTransfer) { - // We hit this for transfers before the 24h signing delay change that call AmuletRules_Transfer or TransferPreapproval_Send internally - // or transfers with the 24h signing delay change where sender != receiver which call into TransferPreapproval_SendV2 - state - } else { - // We hit this only when sender = receiver and the 24h signing delay change is active as then there is no TransferPreapproval_SendV2 child. - // We just parse this as a plain transfer matching the behavior before the 24h signing delay change. - val txLogEntry = new TransferTxLogEntry( - offset = LegacyOffset.Api.fromLong(tree.getOffset), - eventId = - EventId.prefixedFromUpdateIdAndNodeId(tree.getUpdateId, exercised.getNodeId), - domainId = synchronizerId, - date = Some(tree.getEffectiveAt), - sender = Some(senderAmount), - receivers = Seq(receiverAmountNoFees(receiver, amount)), - balanceChanges = Seq.empty, - description = node.argument.value.transfer.meta.values - .getOrDefault(TokenStandardMetadata.reasonMetaKey, ""), - ) - State(txLogEntry) - } - case TransferInstruction_Accept(node) => - val state = parseTrees( - tree, - synchronizerId, - tree.getChildNodeIds(exercised).asScala.toList, - ignoreUnexpectedAmuletCreateArchive = true, - ) - val stateWithTransfer = if (state.hasTransfer) { - // We hit this for transfers before the 24h signing change that call AmuletRules_Transfer internally. - state - } else { - val coinCid = node.result.value.output match { - case output: splice.api.token.transferinstructionv1.transferinstructionresult_output.TransferInstructionResult_Completed => - assert(output.receiverHoldingCids.size == 1) - output.receiverHoldingCids.get(0) - case output => - throw new RuntimeException( - s"Unexpected transfer instruction result output, expected completed but got: $output" - ) - } - val coin = - tree - .findCreation( - splice.amulet.Amulet.COMPANION, - new splice.amulet.Amulet.ContractId(coinCid.contractId), - ) - .getOrElse( - throw new RuntimeException( - s"The amulet contract ${coinCid} was not found in transaction ${tree.getUpdateId}" - ) - ) - val sender = node.result.value.meta.values.get(TokenStandardMetadata.senderMetaKey) - val receiver = coin.payload.owner - val amount = coin.payload.amount.initialAmount - - val txLogEntry = new TransferTxLogEntry( - offset = LegacyOffset.Api.fromLong(tree.getOffset), - eventId = - EventId.prefixedFromUpdateIdAndNodeId(tree.getUpdateId, exercised.getNodeId), - domainId = synchronizerId, - date = Some(tree.getEffectiveAt), - sender = Some(senderAmountNoFees(sender, amount)), - receivers = Seq(receiverAmountNoFees(receiver, amount)), - balanceChanges = Seq.empty, - ) - State(txLogEntry) - } - stateWithTransfer.copy( - entries = stateWithTransfer.entries.map { - case e: TransferTxLogEntry => - e.copy( - transferInstructionCid = exercised.getContractId, - transferKind = TransferKind.TRANSFER_KIND_TRANSFER_INSTRUCTION_ACCEPT, - ) - case e => e - } - ) - case TransferInstruction_Withdraw(_) => - // Contrary to the wallet which tracks only unlocked amulet balance, - // scan tracks the sum of locked and unlocked balance so - // this does not actually create a change in balance. - State( - AbortTransferInstructionTxLogEntry( - offset = LegacyOffset.Api.fromLong(tree.getOffset), - eventId = eventId, - domainId = synchronizerId, - date = Some(tree.getEffectiveAt), - transferInstructionCid = exercised.getContractId, - transferAbortKind = TransferAbortKind.TRANSFER_ABORT_KIND_WITHDRAW, - ) - ) - case TransferInstruction_Reject(_) => - // Contrary to the wallet which tracks only unlocked amulet balance, - // scan tracks the sum of locked and unlocked balance so - // this does not actually create a change in balance. - State( - AbortTransferInstructionTxLogEntry( - offset = LegacyOffset.Api.fromLong(tree.getOffset), - eventId = eventId, - domainId = synchronizerId, - date = Some(tree.getEffectiveAt), - transferInstructionCid = exercised.getContractId, - transferAbortKind = TransferAbortKind.TRANSFER_ABORT_KIND_REJECT, - ) - ) - case Tap(node) => - State.fromAmuletCreateSummary( - tree, - exercised, - synchronizerId, - node.result.value.amuletSum, - TransactionType.Tap, - ) - case Mint(node) => - State.fromAmuletCreateSummary( - tree, - exercised, - synchronizerId, - node.result.value.amuletSum, - TransactionType.Mint, - ) - case AmuletRules_BuyMemberTraffic(node) => - State.fromBuyMemberTraffic(eventId, synchronizerId, node) - case AmuletRules_CreateExternalPartySetupProposal(node) => - State.fromCreateExternalPartySetupProposal(eventId, synchronizerId, node) - case AmuletRules_CreateTransferPreapproval(node) => - State.fromCreateTransferPreapproval(eventId, synchronizerId, node) - case TransferPreapproval_Renew(node) => - State.fromRenewTransferPreapproval(eventId, synchronizerId, node) - case AmuletExpire(node) => - State.empty - case AmuletExpireV2(node) => - State.empty - case LockedAmuletExpireAmulet(node) => - State.empty - case LockedAmuletExpireAmuletV2(node) => - State.empty - // We track the sum of locked/unlocked so this is a noop. - case LockedAmuletUnlock(_) => - State.empty - // We track the sum of locked/unlocked so this is a noop. - case LockedAmuletUnlockV2(_) => - State.empty - // We track the sum of locked/unlocked so this is a noop. - case LockedAmuletOwnerExpireLock(_) => - State.empty - // We track the sum of locked/unlocked so this is a noop. - case LockedAmuletOwnerExpireLockV2(_) => - State.empty - case AnsRules_CollectInitialEntryPayment(_) => - fromAnsEntryPaymentCollection( - tree, - exercised, - synchronizerId, - sws.SubscriptionInitialPayment.COMPANION, - sws.SubscriptionInitialPayment.CHOICE_SubscriptionInitialPayment_Collect, - )(_.amulet) - case AnsRules_CollectEntryRenewalPayment(_) => - fromAnsEntryPaymentCollection( - tree, - exercised, - synchronizerId, - sws.SubscriptionPayment.COMPANION, - sws.SubscriptionPayment.CHOICE_SubscriptionPayment_Collect, - )(_.amulet) - case AmuletArchive(_) => - if (!ignoreUnexpectedAmuletCreateArchive) { - throw new RuntimeException( - s"Unexpected amulet archive event for amulet ${exercised.getContractId} in transaction ${tree.getUpdateId}" - ) - } else { - State.empty - } case DsoRulesCloseVoteRequest(node) => State.fromCloseVoteRequest(eventId, node) case ExternalPartyAmuletRules_CreateTransferCommand(node) => @@ -479,78 +84,6 @@ class ScanTxLogParser( State.fromTransferCommand_Withdraw(eventId, exercised, node) case TransferCommand_Expire(node) => State.fromTransferCommand_Expire(eventId, exercised, node) - case AllocationFactoryAllocate(node) => - val state = parseTrees( - tree, - synchronizerId, - tree.getChildNodeIds(exercised).asScala.toList, - ignoreUnexpectedAmuletCreateArchive = true, - ) - if (state.hasTransfer) { - // We hit this for allocations before the 24h signing change that call AmuletRules_Transfer internally. - state - } else { - - val sender = node.argument.value.allocation.transferLeg.sender - val amount = node.argument.value.allocation.transferLeg.amount - - val txLogEntry = new TransferTxLogEntry( - offset = LegacyOffset.Api.fromLong(tree.getOffset), - eventId = - EventId.prefixedFromUpdateIdAndNodeId(tree.getUpdateId, exercised.getNodeId), - domainId = synchronizerId, - date = Some(tree.getEffectiveAt), - sender = Some(senderAmountNoFees(sender, amount)), - receivers = Seq( - receiverAmountNoFees(sender, amount) - ), // This step locks to the sender which scan displays as a transfer to yourself. - balanceChanges = Seq.empty, - ) - State(txLogEntry) - } - case AllocationExecuteTransfer(node) => - val state = parseTrees( - tree, - synchronizerId, - tree.getChildNodeIds(exercised).asScala.toList, - ignoreUnexpectedAmuletCreateArchive = true, - ) - if (state.hasTransfer) { - // We hit this for allocations before the 24h signing change that call AmuletRules_Transfer internally. - state - } else { - assert(node.result.value.receiverHoldingCids.size == 1) - val coinCid = node.result.value.receiverHoldingCids.get(0) - val coin = - tree - .findCreation( - splice.amulet.Amulet.COMPANION, - new splice.amulet.Amulet.ContractId(coinCid.contractId), - ) - .getOrElse( - throw new RuntimeException( - s"The amulet contract ${coinCid} was not found in transaction ${tree.getUpdateId}" - ) - ) - val sender = node.result.value.meta.values.get(TokenStandardMetadata.senderMetaKey) - val receiver = coin.payload.owner - val amount = coin.payload.amount.initialAmount - - val txLogEntry = new TransferTxLogEntry( - offset = LegacyOffset.Api.fromLong(tree.getOffset), - eventId = - EventId.prefixedFromUpdateIdAndNodeId(tree.getUpdateId, exercised.getNodeId), - domainId = synchronizerId, - date = Some(tree.getEffectiveAt), - sender = Some(senderAmountNoFees(sender, amount)), - receivers = Seq(receiverAmountNoFees(receiver, amount)), - balanceChanges = Seq.empty, - ) - State(txLogEntry) - } - // Token Standard V2: the Scan txlog will go away so we don't bother parsing it - case AllocationFactoryV2Allocate(_) | AllocationV2Settle(_) => - State.empty case _ => parseTrees( tree, @@ -573,22 +106,6 @@ class ScanTxLogParser( ) case ClosedMiningRoundCreate(round) => State.fromClosedMiningRoundCreate(tree, root, synchronizerId, round) - case AmuletCreate(_) => - if (!ignoreUnexpectedAmuletCreateArchive) { - throw new RuntimeException( - s"Unexpected amulet create event for amulet ${created.getContractId} in transaction ${tree.getUpdateId}" - ) - } else { - State.empty - } - case LockedAmuletCreate(_) => - if (!ignoreUnexpectedAmuletCreateArchive) { - throw new RuntimeException( - s"Unexpected locked amulet create event for amulet ${created.getContractId} in transaction ${tree.getUpdateId}" - ) - } else { - State.empty - } case _ => State.empty } @@ -597,35 +114,6 @@ class ScanTxLogParser( } } - private def fromAnsEntryPaymentCollection[Marker, Res]( - tree: Transaction, - exercised: ExercisedEvent, - synchronizerId: SynchronizerId, - paymentCollectionTemplate: codegen.ContractCompanion[?, ?, Marker], - paymentCollectionChoice: codegen.Choice[Marker, ?, Res], - )( - collectionProducedAmulet: Res => AmuletCreate.TCid - )(implicit tc: TraceContext) = { - // first child event is the initial subscription payment collected by DSO - val (paymentCollectionEvent, _) = - tree - .firstDescendantExercise(exercised, paymentCollectionTemplate, paymentCollectionChoice) - .map { case (e, pr) => (e, collectionProducedAmulet(pr)) } - .getOrElse { - sys.error( - s"Unable to find ${paymentCollectionChoice.name} in ${exercised.getChoice}" - ) - } - - val stateFromPaymentCollection = parseTree( - tree, - synchronizerId, - paymentCollectionEvent, - ignoreUnexpectedAmuletCreateArchive = false, - ) - State.empty.appended(stateFromPaymentCollection) - } - private def parseTrees( tree: Transaction, synchronizerId: SynchronizerId, @@ -670,29 +158,6 @@ object ScanTxLogParser { def appended(other: State): State = State( entries = entries.appendedAll(other.entries) ) - def hasTransfer: Boolean = - entries.exists { - case _: TransferTxLogEntry => true - case _ => false - } - - def setTransferPreapprovalSendFields( - tree: Transaction, - exercised: ExercisedEvent, - description: java.util.Optional[String], - ): State = - copy( - entries = entries.map { - case e: TransferTxLogEntry => - e.copy( - description = description.orElse(""), - eventId = - EventId.prefixedFromUpdateIdAndNodeId(tree.getUpdateId, exercised.getNodeId), - transferKind = TransferKind.TRANSFER_KIND_PREAPPROVAL_SEND, - ) - case e => e - } - ) } private object State { @@ -709,334 +174,6 @@ object ScanTxLogParser { a.appended(b) } - private def getAmuletFromSummary( - tx: Transaction, - ccsum: AmuletCreateSummary[? <: codegen.ContractId[AmuletCreate.T]], - ) = { - val amuletCid = ccsum.amulet - tx.findCreation(AmuletCreate.companion, amuletCid) - .map(_.payload) - .getOrElse { - throw new RuntimeException( - s"The amulet contract $amuletCid referenced by AmuletCreateSummary was not found in transaction ${tx.getUpdateId}" - ) - } - } - - def fromAmuletCreateSummary( - tx: Transaction, - event: Event, - synchronizerId: SynchronizerId, - acsum: AmuletCreateSummary[? <: codegen.ContractId[AmuletCreate.T]], - activityType: TransactionType, - ): State = { - val amulet = getAmuletFromSummary(tx, acsum) - val eventId = EventId.prefixedFromUpdateIdAndNodeId(tx.getUpdateId, event.getNodeId) - val activityEntry: TransactionTxLogEntry = activityType match { - case TransactionType.Tap => - TapTxLogEntry( - offset = LegacyOffset.Api.fromLong(tx.getOffset), - eventId = eventId, - domainId = synchronizerId, - date = Some(tx.getEffectiveAt), - amuletOwner = PartyId.tryFromProtoPrimitive(amulet.owner), - amuletAmount = amulet.amount.initialAmount, - round = acsum.round.number, - ) - case TransactionType.Mint => - MintTxLogEntry( - offset = LegacyOffset.Api.fromLong(tx.getOffset), - eventId = eventId, - domainId = synchronizerId, - date = Some(tx.getEffectiveAt), - amuletOwner = PartyId.tryFromProtoPrimitive(amulet.owner), - amuletAmount = amulet.amount.initialAmount, - round = acsum.round.number, - ) - case unexpected => - throw new Exception( - s"unexpected activityType: $unexpected in fromAmuletCreateSummary" - ) - } - - State(activityEntry) - } - - private def rewardsEntriesFromTransferSummary( - sender: PartyId, - summary: splice.amuletrules.TransferSummary, - round: Long, - synchronizerId: SynchronizerId, - rootEventId: String, - ): State = { - val appRewards = summary.inputAppRewardAmount - val validatorRewards = summary.inputValidatorRewardAmount - val svRewards = summary.inputSvRewardAmount - - val appRewardEntry = - if (appRewards.compareTo(BigDecimal(0.0)) > 0) { - val entry = - AppRewardTxLogEntry( - eventId = rootEventId, - domainId = synchronizerId, - round = round, - party = sender, - amount = appRewards, - ) - State(entry) - } else { - State.empty - } - - val validatorRewardEntry = - if (validatorRewards.compareTo(BigDecimal(0.0)) > 0) { - val entry = - ValidatorRewardTxLogEntry( - eventId = rootEventId, - domainId = synchronizerId, - round = round, - party = sender, - amount = validatorRewards, - ) - State(entry) - } else { - State.empty - } - - val svRewardEntry = - if (svRewards.compareTo(BigDecimal(0.0)) > 0) { - val entry = - SvRewardTxLogEntry( - eventId = rootEventId, - domainId = synchronizerId, - round = round, - party = sender, - amount = svRewards, - ) - State(entry) - } else { - State.empty - } - - appRewardEntry.appended(validatorRewardEntry).appended(svRewardEntry) - } - - def fromTransfer( - tx: Transaction, - event: ExercisedEvent, - synchronizerId: SynchronizerId, - node: ExerciseNode[Transfer.Arg, Transfer.Res], - rootEventId: Option[String] = None, - ): State = { - State.fromTransferResult( - tx, - event, - synchronizerId, - sender = node.argument.value.transfer.sender, - outputs = node.argument.value.transfer.outputs.asScala.toSeq, - result = node.result.value, - rootEventId = rootEventId, - ) - } - - def fromTransferResult( - tx: Transaction, - event: ExercisedEvent, - synchronizerId: SynchronizerId, - sender: String, - outputs: Seq[splice.amuletrules.TransferOutput], - result: splice.amuletrules.TransferResult, - rootEventId: Option[String] = None, - ): State = { - val senderParty = Codec - .decode(Codec.Party)(sender) - .getOrElse( - throw Status.INTERNAL - .withDescription(s"Cannot decode party ID ${sender}") - .asRuntimeException() - ) - val eventId = EventId.prefixedFromUpdateIdAndNodeId(tx.getUpdateId, event.getNodeId) - val rewardEntries = - rewardsEntriesFromTransferSummary( - senderParty, - result.summary, - result.round.number, - synchronizerId, - rootEventId.getOrElse(eventId), - ) - - val activityEntry = State( - transferTxLogEntry( - tx, - event, - synchronizerId, - sender = sender, - outputs = outputs, - result = result, - ) - ) - - rewardEntries - .appended(activityEntry) - } - - private def transferTxLogEntry( - tx: Transaction, - event: Event, - synchronizerId: SynchronizerId, - sender: String, - outputs: Seq[splice.amuletrules.TransferOutput], - result: splice.amuletrules.TransferResult, - ): TransferTxLogEntry = { - val senderAmount = parseSenderAmount(sender, outputs, result) - val receiverAmounts = parseReceiverAmounts(outputs, result) - - new TransferTxLogEntry( - offset = LegacyOffset.Api.fromLong(tx.getOffset), - eventId = EventId.prefixedFromUpdateIdAndNodeId(tx.getUpdateId, event.getNodeId), - domainId = synchronizerId, - date = Some(tx.getEffectiveAt), - sender = Some(senderAmount), - receivers = receiverAmounts, - round = result.round.number, - ) - } - - def fromBuyMemberTraffic( - eventId: String, - synchronizerId: SynchronizerId, - node: ExerciseNode[AmuletRules_BuyMemberTraffic.Arg, AmuletRules_BuyMemberTraffic.Res], - ): State = { - val validatorParty = Codec - .decode(Codec.Party)(node.argument.value.provider) - .getOrElse( - throw Status.INTERNAL - .withDescription( - s"Cannot decode party ID ${node.argument.value.provider}" - ) - .asRuntimeException() - ) - val round = node.result.value.round - val trafficPurchased = node.argument.value.trafficAmount - val ccSpent = node.result.value.amuletPaid - val buyExtraTrafficEntry = ExtraTrafficPurchaseTxLogEntry( - eventId = eventId, - domainId = synchronizerId, - round = round.number, - validator = validatorParty, - trafficPurchased = trafficPurchased, - ccSpent = ccSpent, - ) - - val rewardEntries = rewardsEntriesFromTransferSummary( - validatorParty, - node.result.value.summary, - round.number, - synchronizerId, - eventId, - ) - - State(buyExtraTrafficEntry) - .appended(rewardEntries) - } - - def fromCreateExternalPartySetupProposal( - eventId: String, - synchronizerId: SynchronizerId, - node: ExerciseNode[ - AmuletRules_CreateExternalPartySetupProposal.Arg, - AmuletRules_CreateExternalPartySetupProposal.Res, - ], - ): State = { - val validatorParty = Codec - .decode(Codec.Party)(node.result.value.validator) - .getOrElse( - throw Status.INTERNAL - .withDescription( - s"Cannot decode party ID ${node.argument.value.validator}" - ) - .asRuntimeException() - ) - val transferResult = node.result.value.transferResult - fromTransferPreapprovalPurchase( - eventId, - synchronizerId, - validatorParty, - transferResult, - ) - } - - def fromCreateTransferPreapproval( - eventId: String, - synchronizerId: SynchronizerId, - node: ExerciseNode[ - AmuletRules_CreateTransferPreapproval.Arg, - AmuletRules_CreateTransferPreapproval.Res, - ], - ): State = { - val validatorParty = Codec - .decode(Codec.Party)(node.argument.value.provider) - .getOrElse( - throw Status.INTERNAL - .withDescription( - s"Cannot decode party ID ${node.argument.value.provider}" - ) - .asRuntimeException() - ) - val transferResult = node.result.value.transferResult - fromTransferPreapprovalPurchase( - eventId, - synchronizerId, - validatorParty, - transferResult, - ) - } - - def fromRenewTransferPreapproval( - eventId: String, - synchronizerId: SynchronizerId, - node: ExerciseNode[ - TransferPreapproval_Renew.Arg, - TransferPreapproval_Renew.Res, - ], - ): State = { - val validatorParty = Codec - .decode(Codec.Party)(node.result.value.provider) - .getOrElse( - throw Status.INTERNAL - .withDescription( - s"Cannot decode party ID ${node.result.value.provider}" - ) - .asRuntimeException() - ) - val transferResult = node.result.value.transferResult - fromTransferPreapprovalPurchase( - eventId, - synchronizerId, - validatorParty, - transferResult, - ) - } - - private def fromTransferPreapprovalPurchase( - eventId: String, - synchronizerId: SynchronizerId, - validatorParty: PartyId, - transferResult: TransferResult, - ) = { - val round = transferResult.round - - val rewardEntries = rewardsEntriesFromTransferSummary( - validatorParty, - transferResult.summary, - round.number, - synchronizerId, - eventId, - ) - - State.empty.appended(rewardEntries) - } - def fromOpenMiningRoundCreate( eventId: String, synchronizerId: SynchronizerId, @@ -1181,25 +318,4 @@ object ScanTxLogParser { ) } } - - private def senderAmountNoFees(party: String, amount: BigDecimal) = - SenderAmount( - party = PartyId.tryFromProtoPrimitive(party), - inputAmuletAmount = amount, - inputAppRewardAmount = BigDecimal(0.0), - inputValidatorRewardAmount = BigDecimal(0.0), - senderChangeAmount = BigDecimal(0.0), - senderChangeFee = BigDecimal(0.0), - senderFee = BigDecimal(0.0), - holdingFees = BigDecimal(0.0), - inputSvRewardAmount = None, - inputValidatorFaucetAmount = None, - ) - - private def receiverAmountNoFees(party: String, amount: BigDecimal) = - ReceiverAmount( - party = PartyId.tryFromProtoPrimitive(party), - amount = amount, - receiverFee = BigDecimal(0.0), - ) } diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/TxLogEntry.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/TxLogEntry.scala index b105aad3a2..d51aa84ee9 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/TxLogEntry.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/TxLogEntry.scala @@ -3,18 +3,11 @@ package org.lfdecentralizedtrust.splice.scan.store -import org.lfdecentralizedtrust.splice.codegen.java.splice import org.lfdecentralizedtrust.splice.store.StoreErrors -import scala.collection.immutable -import scala.jdk.CollectionConverters.* -import scala.jdk.OptionConverters.* import java.time.Instant import org.lfdecentralizedtrust.splice.http.v0.definitions as httpDef import com.digitalasset.canton.config.CantonRequireTypes.String3 -import com.digitalasset.canton.topology.PartyId - -import scala.math.BigDecimal.RoundingMode trait TxLogEntry extends Product with Serializable { // Scan store uses the eventId for pagination @@ -25,42 +18,33 @@ object TxLogEntry extends StoreErrors { object EntryType { val ErrorTxLogEntry = String3.tryCreate("err") - val BalanceChangeTxLogEntry = String3.tryCreate("bac") val ClosedMiningRoundTxLogEntry = String3.tryCreate("cmr") - val ExtraTrafficPurchaseTxLogEntry = String3.tryCreate("etp") val OpenMiningRoundTxLogEntry = String3.tryCreate("omr") - val AppRewardTxLogEntry = String3.tryCreate("are") - val MintTxLogEntry = String3.tryCreate("min") - val TapTxLogEntry = String3.tryCreate("tap") - val TransferTxLogEntry = String3.tryCreate("tra") - val ValidatorRewardTxLogEntry = String3.tryCreate("vre") - val SvRewardTxLogEntry = String3.tryCreate("sre") val VoteRequestTxLogEntry = String3.tryCreate("vot") val TransferCommandTxLogEntry = String3.tryCreate("trc") - val AbortTransferInstructionTxLogEntry = String3.tryCreate("ati") // The following entry types correspond to entries that were removed from `scan_tx_log.proto` // Those entries might still exist in databases, but we don't produce new ones and we don't read them. // The values are only kept for documentation purposes. val Unused_SvRewardCollectedTxLogEntry = String3.tryCreate("src") + val Unused_BalanceChangeTxLogEntry = String3.tryCreate("bac") + val Unused_ExtraTrafficPurchaseTxLogEntry = String3.tryCreate("etp") + val Unused_AppRewardTxLogEntry = String3.tryCreate("are") + val Unused_MintTxLogEntry = String3.tryCreate("min") + val Unused_TapTxLogEntry = String3.tryCreate("tap") + val Unused_TransferTxLogEntry = String3.tryCreate("tra") + val Unused_ValidatorRewardTxLogEntry = String3.tryCreate("vre") + val Unused_SvRewardTxLogEntry = String3.tryCreate("sre") + val Unused_AbortTransferInstructionTxLogEntry = String3.tryCreate("ati") } def encode(entry: TxLogEntry): (String3, String) = { import scalapb.json4s.JsonFormat val entryType = entry match { case _: ErrorTxLogEntry => EntryType.ErrorTxLogEntry - case _: BalanceChangeTxLogEntry => EntryType.BalanceChangeTxLogEntry case _: ClosedMiningRoundTxLogEntry => EntryType.ClosedMiningRoundTxLogEntry - case _: ExtraTrafficPurchaseTxLogEntry => EntryType.ExtraTrafficPurchaseTxLogEntry case _: OpenMiningRoundTxLogEntry => EntryType.OpenMiningRoundTxLogEntry - case _: AppRewardTxLogEntry => EntryType.AppRewardTxLogEntry - case _: MintTxLogEntry => EntryType.MintTxLogEntry - case _: TapTxLogEntry => EntryType.TapTxLogEntry - case _: TransferTxLogEntry => EntryType.TransferTxLogEntry - case _: ValidatorRewardTxLogEntry => EntryType.ValidatorRewardTxLogEntry - case _: SvRewardTxLogEntry => EntryType.SvRewardTxLogEntry case _: VoteRequestTxLogEntry => EntryType.VoteRequestTxLogEntry case _: TransferCommandTxLogEntry => EntryType.TransferCommandTxLogEntry - case _: AbortTransferInstructionTxLogEntry => EntryType.AbortTransferInstructionTxLogEntry case _ => throw txEncodingFailed() } val jsonValue = entry match { @@ -74,20 +58,10 @@ object TxLogEntry extends StoreErrors { try { entryType match { case EntryType.ErrorTxLogEntry => from[ErrorTxLogEntry](json) - case EntryType.BalanceChangeTxLogEntry => from[BalanceChangeTxLogEntry](json) case EntryType.ClosedMiningRoundTxLogEntry => from[ClosedMiningRoundTxLogEntry](json) - case EntryType.ExtraTrafficPurchaseTxLogEntry => from[ExtraTrafficPurchaseTxLogEntry](json) case EntryType.OpenMiningRoundTxLogEntry => from[OpenMiningRoundTxLogEntry](json) - case EntryType.AppRewardTxLogEntry => from[AppRewardTxLogEntry](json) - case EntryType.MintTxLogEntry => from[MintTxLogEntry](json) - case EntryType.TapTxLogEntry => from[TapTxLogEntry](json) - case EntryType.TransferTxLogEntry => from[TransferTxLogEntry](json) - case EntryType.ValidatorRewardTxLogEntry => from[ValidatorRewardTxLogEntry](json) - case EntryType.SvRewardTxLogEntry => from[ValidatorRewardTxLogEntry](json) case EntryType.VoteRequestTxLogEntry => from[VoteRequestTxLogEntry](json) case EntryType.TransferCommandTxLogEntry => from[TransferCommandTxLogEntry](json) - case EntryType.AbortTransferInstructionTxLogEntry => - from[AbortTransferInstructionTxLogEntry](json) case _ => throw txLogIsOfWrongType(entryType.str) } } catch { @@ -95,14 +69,6 @@ object TxLogEntry extends StoreErrors { } } - trait RewardTxLogEntry extends TxLogEntry { - def party: PartyId - - def amount: BigDecimal - - def round: Long - } - trait TransactionTxLogEntry extends TxLogEntry { def date: Option[Instant] } @@ -148,101 +114,4 @@ object TxLogEntry extends StoreErrors { ) } } - - sealed trait TransactionType - object TransactionType { - case object Transfer extends TransactionType - case object Mint extends TransactionType - case object Tap extends TransactionType - } - - def parseSenderAmount( - sender: String, - outputs: Seq[splice.amuletrules.TransferOutput], - res: splice.amuletrules.TransferResult, - ): SenderAmount = { - val senderFee = parseOutputAmounts(outputs, res) - .map(_.senderFee) - .sum - - SenderAmount( - party = PartyId.tryFromProtoPrimitive(sender), - inputAmuletAmount = res.summary.inputAmuletAmount, - inputAppRewardAmount = res.summary.inputAppRewardAmount, - inputValidatorRewardAmount = res.summary.inputValidatorRewardAmount, - inputSvRewardAmount = Some(res.summary.inputSvRewardAmount), - inputValidatorFaucetAmount = - res.summary.inputValidatorFaucetAmount.toScala.map(BigDecimal(_)), - senderChangeAmount = res.summary.senderChangeAmount, - senderChangeFee = res.summary.senderChangeFee, - senderFee = senderFee, - holdingFees = res.summary.holdingFees, - ) - } - - def parseReceiverAmounts( - outputs: Seq[splice.amuletrules.TransferOutput], - res: splice.amuletrules.TransferResult, - ): Seq[ReceiverAmount] = { - - // Note: the same receiver party can appear multiple times in the transfer result - // The code below merges amounts and fees for the same receiver, while preserving - // the order of receivers. - parseOutputAmounts(outputs, res) - .map(o => - new ReceiverAmount( - party = PartyId.tryFromProtoPrimitive(o.output.receiver), - amount = o.output.amount, - receiverFee = o.receiverFee, - ) - ) - .foldLeft(immutable.ListMap.empty[PartyId, ReceiverAmount])((acc, receiverAmount) => - acc.updatedWith(receiverAmount.party)(prev => - Some(prev.fold(receiverAmount) { r => - r.copy( - amount = r.amount + receiverAmount.amount, - receiverFee = r.receiverFee + receiverAmount.receiverFee, - ) - }) - ) - ) - .values - .toList - } - - /** A requested output of a transfer, together with the actual fees paid for the transfer. - * - * @param output Contains the receiver and the gross amount received (before deducting fees). - * @param senderFee Actual amount of fees paid by the sender. - * @param receiverFee Actual amount of fees paid by the receiver. - */ - private final case class OutputWithFees( - output: splice.amuletrules.TransferOutput, - senderFee: BigDecimal, - receiverFee: BigDecimal, - ) - - private def parseOutputAmounts( - outputs: Seq[splice.amuletrules.TransferOutput], - res: splice.amuletrules.TransferResult, - ): Seq[OutputWithFees] = { - assert( - outputs.size == res.summary.outputFees.size(), - "Each output should have a corresponding fee", - ) - val outputsWithFees = outputs.zip(res.summary.outputFees.asScala) - - outputsWithFees - .map { case (out, fee) => - OutputWithFees( - output = out, - senderFee = setDamlDecimalScale(BigDecimal(fee) * (BigDecimal(1) - out.receiverFeeRatio)), - receiverFee = setDamlDecimalScale(BigDecimal(fee) * out.receiverFeeRatio), - ) - } - } - - /** Returns the input number modified such that it has the same number of decimal places as a daml decimal */ - private def setDamlDecimalScale(x: BigDecimal): BigDecimal = - x.setScale(10, RoundingMode.HALF_EVEN) } diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbScanStore.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbScanStore.scala index 92fbc43bf0..871c2633b6 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbScanStore.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbScanStore.scala @@ -60,9 +60,7 @@ import org.lfdecentralizedtrust.splice.store.{ DbVotesTxLogStoreQueryBuilder, Limit, VoteResultsFilters, - PageLimit, ResultsPage, - SortOrder, TxLogStore, UpdateHistory, } @@ -79,7 +77,6 @@ import org.lfdecentralizedtrust.splice.config.IngestionConfig import org.lfdecentralizedtrust.splice.store.UpdateHistoryQueries.UpdateHistoryQueries import org.lfdecentralizedtrust.splice.store.db.AcsQueries.AcsStoreId import org.lfdecentralizedtrust.splice.store.db.TxLogQueries.TxLogStoreId -import slick.jdbc.canton.SQLActionBuilder import scala.concurrent.{ExecutionContext, Future} import scala.jdk.CollectionConverters.* @@ -391,63 +388,6 @@ class DbScanStore( } yield contractWithStateFromRow(TransferCommandCounter.COMPANION)(row)).value } - override def listTransactions( - pageEndEventId: Option[String], - sortOrder: SortOrder, - limit: PageLimit, - )(implicit - tc: TraceContext - ): Future[Seq[TxLogEntry.TransactionTxLogEntry]] = - waitUntilAcsIngested { - val entryTypeCondition: SQLActionBuilder = inClause( - "entry_type", - List( - EntryType.TransferTxLogEntry, - EntryType.TapTxLogEntry, - EntryType.MintTxLogEntry, - EntryType.AbortTransferInstructionTxLogEntry, - ), - ) - // Literal sort order since Postgres complains when trying to bind it to a parameter - val (compareEntryNumber, orderLimit) = sortOrder match { - case SortOrder.Ascending => - (sql" > ", sql""" order by entry_number asc limit ${sqlLimit(limit)};""") - case SortOrder.Descending => - (sql" < ", sql""" order by entry_number desc limit ${sqlLimit(limit)};""") - } - - // TODO (#960): don't use the event id for pagination, use the entry number - for { - rows <- storage.query( - pageEndEventId.fold( - selectFromTxLogTable( - txLogTableName, - txLogStoreId, - where = entryTypeCondition, - orderLimit = orderLimit, - ) - )(pageEndEventId => - selectFromTxLogTable( - txLogTableName, - txLogStoreId, - where = (entryTypeCondition ++ sql" and entry_number " ++ compareEntryNumber ++ - sql"""( - select entry_number - from scan_txlog_store - where store_id = $txLogStoreId - and event_id = ${lengthLimited(pageEndEventId)} - and """ ++ entryTypeCondition ++ sql""" - )""").toActionBuilder, - orderLimit = orderLimit, - ) - ), - "listTransactions", - ) - entries = rows.map(txLogEntryFromRow[TxLogEntry.TransactionTxLogEntry](txLogConfig)) - } yield entries - - } - override def lookupFeaturedAppRight( providerPartyId: PartyId )(implicit diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/ScanTables.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/ScanTables.scala index bba1683131..de109b94bf 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/ScanTables.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/ScanTables.scala @@ -15,19 +15,10 @@ import org.lfdecentralizedtrust.splice.codegen.java.splice.dsorules.actionrequir } import org.lfdecentralizedtrust.splice.codegen.java.splice.externalpartyamuletrules.TransferCommand import org.lfdecentralizedtrust.splice.scan.store.{ - AbortTransferInstructionTxLogEntry, - AppRewardTxLogEntry, - BalanceChangeTxLogEntry, ClosedMiningRoundTxLogEntry, ErrorTxLogEntry, - ExtraTrafficPurchaseTxLogEntry, - MintTxLogEntry, OpenMiningRoundTxLogEntry, - SvRewardTxLogEntry, - TapTxLogEntry, - TransferTxLogEntry, TxLogEntry, - ValidatorRewardTxLogEntry, VoteRequestTxLogEntry, TransferCommandTxLogEntry, } @@ -193,50 +184,6 @@ object ScanTables extends AcsTables { round = Some(cmr.round), closedRoundEffectiveAt = cmr.effectiveAt.map(CantonTimestamp.assertFromInstant), ) - case are: AppRewardTxLogEntry => - ScanTxLogRowData( - entry = are, - round = Some(are.round), - rewardAmount = Some(are.amount), - rewardedParty = Some(are.party), - ) - case vre: ValidatorRewardTxLogEntry => - ScanTxLogRowData( - entry = vre, - round = Some(vre.round), - rewardAmount = Some(vre.amount), - rewardedParty = Some(vre.party), - ) - case sre: SvRewardTxLogEntry => - ScanTxLogRowData( - entry = sre, - round = Some(sre.round), - rewardAmount = Some(sre.amount), - rewardedParty = Some(sre.party), - ) - case etp: ExtraTrafficPurchaseTxLogEntry => - ScanTxLogRowData( - entry = etp, - round = Some(etp.round), - extraTrafficValidator = Some(etp.validator), - extraTrafficPurchaseTrafficPurchase = Some(etp.trafficPurchased), - extraTrafficPurchaseCcSpent = Some(etp.ccSpent), - ) - case rar: TransferTxLogEntry => - ScanTxLogRowData( - entry = rar, - round = Some(rar.round), - ) - case entry: TapTxLogEntry => - ScanTxLogRowData( - entry = entry, - round = Some(entry.round), - ) - case entry: MintTxLogEntry => - ScanTxLogRowData( - entry = entry, - round = Some(entry.round), - ) case vr: VoteRequestTxLogEntry => val result = vr.result.getOrElse(throw txMissingField()) val parsedOutcome = VoteRequestOutcome.parse(result.outcome) @@ -268,20 +215,12 @@ object ScanTables extends AcsTables { entry.nonce ), ) - case entry: AbortTransferInstructionTxLogEntry => - ScanTxLogRowData( - entry = entry - ) case _ => throw txEncodingFailed() } } record match { - case _: BalanceChangeTxLogEntry => - // the balance changes are no longer indexed, or written, to the tx log table, - // See https://github.com/canton-network/splice/pull/3734 - None case entry => Some(fromEntry(entry)) } } diff --git a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/store/db/ScanStoreTest.scala b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/store/db/ScanStoreTest.scala index b84bda45f0..3d2271e786 100644 --- a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/store/db/ScanStoreTest.scala +++ b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/store/db/ScanStoreTest.scala @@ -57,7 +57,6 @@ import scala.concurrent.Future import scala.jdk.CollectionConverters.* import scala.jdk.OptionConverters.* import scala.math.BigDecimal.javaBigDecimal2bigDecimal -import scala.reflect.ClassTag import org.lfdecentralizedtrust.splice.config.IngestionConfig import org.lfdecentralizedtrust.splice.store.MultiDomainAcsStore.IngestionSink.IngestionStart.{ InitializeAcsAtLatestOffset, @@ -332,144 +331,6 @@ abstract class ScanStoreTest } } - "listTransactions" should { - "return the most recent txs in pages" in { - val limit = 10 - val nrTransfers = 20 - val round = 1L - val now = java.time.Instant.EPOCH - val zero = BigDecimal(0) - val fakeOffset = "0" - val txs: List[TransferTxLogEntry] = (1 to nrTransfers).map { i => - TransferTxLogEntry( - offset = fakeOffset, - eventId = s"$i", - domainId = dummyDomain, - date = Some(now), - sender = Some( - SenderAmount( - user1, - BigDecimal(i), - zero, - zero, - zero, - zero, - zero, - zero, - Some(zero), - None, - ) - ), - balanceChanges = Seq(), - receivers = Seq(ReceiverAmount(user2, BigDecimal(i), zero)), - round = round, - ) - }.toList - def stripEventIdAndOffset(tx: TransferTxLogEntry) = - tx.copy(eventId = "", offset = fakeOffset) - val expectedFirstPage = txs.reverse.take(limit).toList - val expectedSecondPage = txs.reverse.drop(limit).take(limit).toList - - def transferFromTransaction( - store: ScanStore, - amuletRulesContract: Contract[ - splice.amuletrules.AmuletRules.ContractId, - splice.amuletrules.AmuletRules, - ], - tx: TransferTxLogEntry, - ) = { - val sender = tx.sender.getOrElse(throw txMissingField()) - val senderParty = sender.party - val senderAmount = sender.inputAmuletAmount - val receiverParty = tx.receivers(0).party - val receiverAmount = tx.receivers(0).amount - dummyDomain - .exercise( - contract = amuletRulesContract, - interfaceId = Some(splice.amuletrules.AmuletRules.TEMPLATE_ID_WITH_PACKAGE_ID), - choiceName = Transfer.choice.name, - choiceArgument = mkAmuletRules_Transfer( - mkTransferInputOutput( - senderParty, - senderParty, - List(mkInputAmulet()), - List(mkTransferOutput(receiverParty, receiverAmount)), - ) - ), - exerciseResult = mkTransferResultRecord( - round = round, - inputAppRewardAmount = sender.inputAppRewardAmount.toDouble, - inputAmuletAmount = senderAmount.toDouble, - inputValidatorRewardAmount = sender.inputValidatorRewardAmount.toDouble, - inputSvRewardAmount = sender.inputSvRewardAmount.fold(0.0)(_.toDouble), - balanceChanges = Map(), - amuletPrice = 1.0, - ), - )( - store.multiDomainAcsStore - ) - .map(_ => ()) - } - - for { - store <- mkStore() - amuletRulesContract = amuletRules() - _ <- txs.foldLeft(Future.successful(())) { (f, tx) => - f.flatMap { _ => - transferFromTransaction( - store, - amuletRulesContract, - tx, - ) - } - } - } yield { - val firstPageDescending = store - .listByType[TransferTxLogEntry](None, SortOrder.Descending, limit) - .futureValue - .toList - - firstPageDescending - .map(stripEventIdAndOffset) should be( - expectedFirstPage - .map(stripEventIdAndOffset) - ) - val nextPageDescending = store - .listByType[TransferTxLogEntry]( - Some(firstPageDescending.last.eventId), - SortOrder.Descending, - limit, - ) - .futureValue - .toList - - nextPageDescending - .map(stripEventIdAndOffset) should be( - expectedSecondPage - .map(stripEventIdAndOffset) - ) - - val firstPageAscending = store - .listByType[TransferTxLogEntry](None, SortOrder.Ascending, limit) - .futureValue - .toList - - firstPageAscending should be(nextPageDescending.reverse) - - val nextPageAscending = store - .listByType[TransferTxLogEntry]( - Some(firstPageAscending.last.eventId), - SortOrder.Ascending, - limit, - ) - .futureValue - .toList - - nextPageAscending should be(firstPageDescending.reverse) - } - } - } - "votes" should { "listVoteRequestResults" should { @@ -1401,20 +1262,6 @@ abstract class ScanStoreTest ): Future[UpdateHistory] private lazy val user1 = userParty(1) - private lazy val user2 = userParty(2) - - implicit class ScanStoreExt(store: ScanStore) { - @SuppressWarnings(Array("org.wartremover.warts.AsInstanceOf")) - def listByType[T](beginAfterEventId: Option[String], sortOrder: SortOrder, limit: Int)(implicit - tag: ClassTag[T] - ): Future[Seq[T]] = { - store - .listTransactions(beginAfterEventId, sortOrder, PageLimit.tryCreate(limit)) - .map(_.collect { - case c if tag.runtimeClass.isInstance(c) => c.asInstanceOf[T] - }.toSeq) - } - } } trait AmuletTransferUtil { self: StoreTestBase => def mkInputAmulet() = { From 3daaaa3c8323b6eec433cdf11063d0407f4e66f6 Mon Sep 17 00:00:00 2001 From: moritzkiefer-da <45630097+moritzkiefer-da@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:26:09 +0200 Subject: [PATCH 22/31] Disable transfer command support by default (#6567) fixes #6553 [ci] --------- Signed-off-by: moritz.kiefer@digitalasset.com Co-authored-by: moritz.kiefer@digitalasset.com --- .../integration/EnvironmentDefinition.scala | 7 + ...nalPartySetupProposalIntegrationTest.scala | 1 + .../tests/ExternallySignedTxTest.scala | 2 +- ...llySignedTxsTimeBasedIntegrationTest.scala | 1 + .../tests/LsuIntegrationTest.scala | 1 + .../RecoverExternalPartyIntegrationTest.scala | 2 +- .../splice/validator/ValidatorApp.scala | 1 + .../http/HttpValidatorAdminHandler.scala | 188 ++++++++++-------- .../ValidatorAutomationService.scala | 19 +- .../validator/config/ValidatorAppConfig.scala | 2 + docs/src/release_notes_upcoming.rst | 18 ++ 11 files changed, 145 insertions(+), 97 deletions(-) diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/EnvironmentDefinition.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/EnvironmentDefinition.scala index b28ff145d3..7ff16ddb2b 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/EnvironmentDefinition.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/EnvironmentDefinition.scala @@ -504,6 +504,13 @@ case class EnvironmentDefinition( ) } + def withTransferCommandSupport: EnvironmentDefinition = + this.addConfigTransform((_, conf) => + ConfigTransforms.updateAllValidatorAppConfigs_( + _.copy(enableDeprecatedTransferCommandSupport = true) + )(conf) + ) + def clearConfigTransforms(): EnvironmentDefinition = copy(configTransformsWithContext = _ => Seq()) diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ExternalPartySetupProposalIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ExternalPartySetupProposalIntegrationTest.scala index a6b4b8fab9..6ab887197c 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ExternalPartySetupProposalIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ExternalPartySetupProposalIntegrationTest.scala @@ -103,6 +103,7 @@ class ExternalPartySetupProposalIntegrationTest NonNegativeFiniteDuration.ofMillis(500) )(config), ) + .withTransferCommandSupport } diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ExternallySignedTxTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ExternallySignedTxTest.scala index 1517b15167..cddcb116a5 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ExternallySignedTxTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ExternallySignedTxTest.scala @@ -21,7 +21,7 @@ trait ExternallySignedTxTest override def environmentDefinition: SpliceEnvironmentDefinition = { EnvironmentDefinition.simpleTopology1Sv(this.getClass.getSimpleName) - } + }.withTransferCommandSupport def prepareAndSubmitTransfer(keyName: String, sender: PartyId, receiver: PartyId)(implicit env: SpliceTestConsoleEnvironment diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ExternallySignedTxsTimeBasedIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ExternallySignedTxsTimeBasedIntegrationTest.scala index f279098757..afda9edb61 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ExternallySignedTxsTimeBasedIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ExternallySignedTxsTimeBasedIntegrationTest.scala @@ -26,6 +26,7 @@ class ExternallySignedTxsTimeBasedIntegrationTest override def environmentDefinition: SpliceEnvironmentDefinition = EnvironmentDefinition .simpleTopology1SvWithSimTime(this.getClass.getSimpleName) + .withTransferCommandSupport "Externally signed transactions can tolerate a preparation/submission skew larger than ledgerTimeRecordTimeTolerance" in { implicit env => diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/LsuIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/LsuIntegrationTest.scala index 559baf4898..5191fc8be3 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/LsuIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/LsuIntegrationTest.scala @@ -203,6 +203,7 @@ class LsuIntegrationTest .withSvBftSequencerConnectionDisabled() .withAmuletPrice(walletAmuletPrice) .withManualStart + .withTransferCommandSupport override def walletAmuletPrice: java.math.BigDecimal = SpliceUtil.damlDecimal(1.0) diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/RecoverExternalPartyIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/RecoverExternalPartyIntegrationTest.scala index d08ded7642..fbacabfd1a 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/RecoverExternalPartyIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/RecoverExternalPartyIntegrationTest.scala @@ -31,7 +31,7 @@ class RecoverExternalPartyIntegrationTest with WalletTestUtil { override def environmentDefinition: EnvironmentDefinition = - EnvironmentDefinition.simpleTopology1Sv(this.getClass.getSimpleName) + EnvironmentDefinition.simpleTopology1Sv(this.getClass.getSimpleName).withTransferCommandSupport override protected lazy val sanityChecksIgnoredRootCreates = Seq( ValidatorRewardCoupon.TEMPLATE_ID_WITH_PACKAGE_ID diff --git a/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/ValidatorApp.scala b/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/ValidatorApp.scala index d9b8dfb543..f9b9173550 100644 --- a/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/ValidatorApp.scala +++ b/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/ValidatorApp.scala @@ -782,6 +782,7 @@ class ValidatorApp( config.parameters.enabledFeatures, config.additionalPackagesToUnvet, config.domains.global.alias, + config.enableDeprecatedTransferCommandSupport, loggerFactory, packageVersionSupport, ) diff --git a/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/admin/http/HttpValidatorAdminHandler.scala b/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/admin/http/HttpValidatorAdminHandler.scala index 650f7dbd32..4dc6ad2b79 100644 --- a/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/admin/http/HttpValidatorAdminHandler.scala +++ b/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/admin/http/HttpValidatorAdminHandler.scala @@ -96,6 +96,16 @@ class HttpValidatorAdminHandler( )(handleRequest) } + private def requireTransferCommandSupport[T](handleRequest: => T): T = { + if (config.enableDeprecatedTransferCommandSupport) { + handleRequest + } else { + throw HttpErrorHandler.notImplemented( + "Transfer command support is disabled by default and will be removed in 0.8.0. You can temporarily enable in on 0.7.x by setting enable-deprecated-transfer-command-support=true in your validator config." + ) + } + } + def onboardUser( respond: v0.ValidatorAdminResource.OnboardUserResponse.type )( @@ -704,89 +714,91 @@ class HttpValidatorAdminHandler( ): Future[v0.ValidatorAdminResource.PrepareTransferPreapprovalSendResponse] = { implicit val AdminUserRequest(tracedContext) = tuser requireWalletEnabled { _ => - val senderParty = PartyId.tryFromProtoPrimitive(body.senderPartyId) - val receiverParty = PartyId.tryFromProtoPrimitive(body.receiverPartyId) - for { - synchronizerId <- getAmuletRulesDomain()(tracedContext) - // This check is just to make it fail early. The actual preapproval is fixed when the automation - // executes the transfer but we want the user to get feedback during the prepare step already. - _ <- scanConnection.lookupTransferPreapprovalByParty(receiverParty).map { preapprovalO => - if (preapprovalO.isEmpty) { - throw Status.INVALID_ARGUMENT - .withDescription(s"Receiver $receiverParty does not have a TransferPreapproval") - .asRuntimeException + requireTransferCommandSupport { + val senderParty = PartyId.tryFromProtoPrimitive(body.senderPartyId) + val receiverParty = PartyId.tryFromProtoPrimitive(body.receiverPartyId) + for { + synchronizerId <- getAmuletRulesDomain()(tracedContext) + // This check is just to make it fail early. The actual preapproval is fixed when the automation + // executes the transfer but we want the user to get feedback during the prepare step already. + _ <- scanConnection.lookupTransferPreapprovalByParty(receiverParty).map { preapprovalO => + if (preapprovalO.isEmpty) { + throw Status.INVALID_ARGUMENT + .withDescription(s"Receiver $receiverParty does not have a TransferPreapproval") + .asRuntimeException + } } - } - externalPartyAmuletRules <- scanConnection.getExternalPartyAmuletRules() - supportsDescription <- packageVersionSupport - .supportsDescriptionInTransferPreapprovals( - Seq(receiverParty, senderParty, store.key.dsoParty), - clock.now, - ) - .map(_.supported) - commands = externalPartyAmuletRules.toAssignedContract - .getOrElse( - throw Status.Code.FAILED_PRECONDITION.toStatus - .withDescription( - s"ExternalPartyAmuletRules is currently inflight between synchronizers, retry until it is assigned to a synchronizer" + externalPartyAmuletRules <- scanConnection.getExternalPartyAmuletRules() + supportsDescription <- packageVersionSupport + .supportsDescriptionInTransferPreapprovals( + Seq(receiverParty, senderParty, store.key.dsoParty), + clock.now, + ) + .map(_.supported) + commands = externalPartyAmuletRules.toAssignedContract + .getOrElse( + throw Status.Code.FAILED_PRECONDITION.toStatus + .withDescription( + s"ExternalPartyAmuletRules is currently inflight between synchronizers, retry until it is assigned to a synchronizer" + ) + .asRuntimeException() + ) + .exercise( + _.exerciseExternalPartyAmuletRules_CreateTransferCommand( + senderParty.toProtoPrimitive, + receiverParty.toProtoPrimitive, + store.key.validatorParty.toProtoPrimitive, + body.amount.bigDecimal, + body.expiresAt.toInstant, + body.nonce, + Option.when(supportsDescription)(body.description).flatten.toJava, + java.util.Optional.of(store.key.dsoParty.toProtoPrimitive), ) - .asRuntimeException() - ) - .exercise( - _.exerciseExternalPartyAmuletRules_CreateTransferCommand( - senderParty.toProtoPrimitive, - receiverParty.toProtoPrimitive, - store.key.validatorParty.toProtoPrimitive, - body.amount.bigDecimal, - body.expiresAt.toInstant, - body.nonce, - Option.when(supportsDescription)(body.description).flatten.toJava, - java.util.Optional.of(store.key.dsoParty.toProtoPrimitive), + ) + .update + .commands() + .asScala + .toSeq + r <- storeWithIngestion + .connection(SpliceLedgerConnectionPriority.Medium) + .prepareSubmission( + Some(synchronizerId), + Seq(senderParty), + Seq(senderParty), + commands, + storeWithIngestion + .connection(SpliceLedgerConnectionPriority.Medium) + .disclosedContracts(externalPartyAmuletRules), + body.verboseHashing.getOrElse(false), + ) + transferCommandCid = r.preparedTransaction + .flatMap(_.transaction) + .toList + .flatMap(_.nodes) + .flatMap(n => + n.getV1.nodeType match { + case interactive.transaction.v1.interactive_submission_data.Node.NodeType + .Create(create) => + Seq(create.contractId) + case _ => Seq.empty + } + ) + .headOption + .getOrElse( + throw Status.INTERNAL + .withDescription("Failed to obtain transferCommandCid from prepared transaction") + .asRuntimeException() + ) + } yield { + v0.ValidatorAdminResource.PrepareTransferPreapprovalSendResponse.OK( + definitions.PrepareTransferPreapprovalSendResponse( + Base64.getEncoder.encodeToString(r.getPreparedTransaction.toByteArray), + HexString.toHexString(r.preparedTransactionHash), + transferCommandCid, + r.hashingDetails, ) ) - .update - .commands() - .asScala - .toSeq - r <- storeWithIngestion - .connection(SpliceLedgerConnectionPriority.Medium) - .prepareSubmission( - Some(synchronizerId), - Seq(senderParty), - Seq(senderParty), - commands, - storeWithIngestion - .connection(SpliceLedgerConnectionPriority.Medium) - .disclosedContracts(externalPartyAmuletRules), - body.verboseHashing.getOrElse(false), - ) - transferCommandCid = r.preparedTransaction - .flatMap(_.transaction) - .toList - .flatMap(_.nodes) - .flatMap(n => - n.getV1.nodeType match { - case interactive.transaction.v1.interactive_submission_data.Node.NodeType - .Create(create) => - Seq(create.contractId) - case _ => Seq.empty - } - ) - .headOption - .getOrElse( - throw Status.INTERNAL - .withDescription("Failed to obtain transferCommandCid from prepared transaction") - .asRuntimeException() - ) - } yield { - v0.ValidatorAdminResource.PrepareTransferPreapprovalSendResponse.OK( - definitions.PrepareTransferPreapprovalSendResponse( - Base64.getEncoder.encodeToString(r.getPreparedTransaction.toByteArray), - HexString.toHexString(r.preparedTransactionHash), - transferCommandCid, - r.hashingDetails, - ) - ) + } } } } @@ -798,15 +810,17 @@ class HttpValidatorAdminHandler( ): Future[v0.ValidatorAdminResource.SubmitTransferPreapprovalSendResponse] = { implicit val AdminUserRequest(tracedContext) = tuser requireWalletEnabled { _ => - for { - updateId <- ValidatorUtil.submitAsExternalParty( - storeWithIngestion.connection(SpliceLedgerConnectionPriority.Medium), - body.submission, - waitForOffset = false, + requireTransferCommandSupport { + for { + updateId <- ValidatorUtil.submitAsExternalParty( + storeWithIngestion.connection(SpliceLedgerConnectionPriority.Medium), + body.submission, + waitForOffset = false, + ) + } yield v0.ValidatorAdminResource.SubmitTransferPreapprovalSendResponseOK( + definitions.SubmitTransferPreapprovalSendResponse(updateId) ) - } yield v0.ValidatorAdminResource.SubmitTransferPreapprovalSendResponseOK( - definitions.SubmitTransferPreapprovalSendResponse(updateId) - ) + } } } diff --git a/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/automation/ValidatorAutomationService.scala b/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/automation/ValidatorAutomationService.scala index a5ea0ab1bb..fb4b1e42d0 100644 --- a/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/automation/ValidatorAutomationService.scala +++ b/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/automation/ValidatorAutomationService.scala @@ -76,6 +76,7 @@ class ValidatorAutomationService( enabledFeatures: EnabledFeaturesConfig, additionalPackagesToUnvet: Map[PackageName, Set[PackageVersion]], globalSynchronizerAlias: SynchronizerAlias, + enableDeprecatedTransferCommandSupport: Boolean, override protected val loggerFactory: NamedLoggerFactory, packageVersionSupport: PackageVersionSupport, )(implicit @@ -196,15 +197,17 @@ class ValidatorAutomationService( ) ) - registerTrigger( - new TransferCommandSendTrigger( - triggerContext, - scanConnection, - store, - walletManager.externalPartyWalletManager, - connection(SpliceLedgerConnectionPriority.Medium), + if (enableDeprecatedTransferCommandSupport) { + registerTrigger( + new TransferCommandSendTrigger( + triggerContext, + scanConnection, + store, + walletManager.externalPartyWalletManager, + connection(SpliceLedgerConnectionPriority.Medium), + ) ) - ) + } } backupDumpConfig.foreach(config => diff --git a/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/config/ValidatorAppConfig.scala b/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/config/ValidatorAppConfig.scala index 9dde4c84da..2973fca980 100644 --- a/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/config/ValidatorAppConfig.scala +++ b/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/config/ValidatorAppConfig.scala @@ -224,6 +224,8 @@ case class ValidatorAppBackendConfig( // from running concurrently against the same database. Only disable for migration scenarios // where intentional overlap is required. instanceLockEnabled: Boolean = true, + // Enable the deprecated transfer command support, will be fully removed in 0.8.0. + enableDeprecatedTransferCommandSupport: Boolean = false, ) extends SpliceBackendConfig // TODO(DACH-NY/canton-network-node#736): fork or generalize this trait. { override val nodeTypeName: String = "validator" diff --git a/docs/src/release_notes_upcoming.rst b/docs/src/release_notes_upcoming.rst index e70317f3c6..77ab2efaa2 100644 --- a/docs/src/release_notes_upcoming.rst +++ b/docs/src/release_notes_upcoming.rst @@ -10,3 +10,21 @@ - Scan app - Remove deprecated ``/transactions`` endpoint. + + - Validator app + + - The deprecated ``TransferCommand`` functionality consisting + of the endpoints + ``/v0/admin/external-party/transfer-preapproval/prepare-send``, + ``/v0/admin/external-party/transfer-preapproval/submit-send`` + and the automation to execute transfer commands is now + disabled by default. If you were still using those switch to + token standard transfers which also support 24h submission + delays since `cip 107 + `_. + If you need some time to migrate, you can temporarily + reenable it by setting + ``canton.validator-apps.validator_backend.enable-deprecated-transfer-command-support=true``. The + functionality is expected to be fully removed in 0.8.0 so + this only provides a bit more time to migrate but you must + complete the migration. From cb4f83bc939460f23a8ce2e719b781cd5d8df332 Mon Sep 17 00:00:00 2001 From: Matt Dziuban Date: Mon, 27 Jul 2026 10:46:28 -0400 Subject: [PATCH 23/31] Update sbt to 1.12.14. (#6474) Prior to v1.11.1, sbt had a memory leak (https://github.com/sbt/sbt/issues/8142) in its `update` task that caused memory usage to grow linearly with the number of `update`s performed. This affects regular development as `update` is called as part of the `compile` task dependency graph. After upgrading to a version that includes the fix (https://github.com/coursier/sbt-coursier/pull/563, https://github.com/coursier/sbt-coursier/pull/564), I've found development with sbt, especially with long-running sbt shell sessions, to be a more pleasant experience; there's less sluggishness and a lot fewer warnings that say "X seconds of the last 10 seconds were spent in garbage collection." Signed-off-by: Matt Dziuban --- build.sbt | 2 +- project/build.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/build.sbt b/build.sbt index cfb3f09da6..c6724d4e89 100644 --- a/build.sbt +++ b/build.sbt @@ -166,7 +166,7 @@ lazy val root: Project = (project in file(".")) BuildCommon.sharedSettings, scalacOptions ++= Seq("-Wconf:src=src_managed/.*:silent"), // Needed to be able to resolve scalafmt snapshot versions - resolvers ++= Resolver.sonatypeOssRepos("snapshots"), + resolvers += Resolver.sonatypeCentralSnapshots, damlDarsLockCheckerFileArg := { val darFiles: Seq[File] = damlBuild.all(allDarsFilter).value.flatten val basePath = baseDirectory.value.toPath diff --git a/project/build.properties b/project/build.properties index cc68b53f1a..e544c4d115 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.10.11 +sbt.version=1.12.14 From 63904cf247c817984dd4fbca465a1d3fc8006fed Mon Sep 17 00:00:00 2001 From: Matt Dziuban Date: Mon, 27 Jul 2026 10:47:27 -0400 Subject: [PATCH 24/31] Specify set of valid response content types (#6535) * Specify set of valid response content types. Fixes #6483. This updates `HttpClient.createHttpFn` to consider a response invalid if its content type is not `application/json`, `application/octet-stream`, or `text/plain`. This results in `httpClientWithErrors` calling `getApiErrorFromResponse`, which produces an `HttpCommandException` and includes the raw response body in its error message. These three content types are the only ones used across all the OpenAPI specs in the repo. `text-plain` is used by [`devNetOnboardValidatorPrepare`](https://github.com/canton-network/splice/blob/main/apps/sv/src/main/openapi/sv-internal.yaml#L506-L508), and `application/octet-stream` is used by [`bulkStorageDownload`](https://github.com/canton-network/splice/blob/main/apps/scan/src/main/openapi/scan-stream-server.yaml#L46-L50). Signed-off-by: Matt Dziuban * Ensure responses with `ContentTypes.NoContentType` are considered valid. Signed-off-by: Matt Dziuban * Run scalafmt. Signed-off-by: Matt Dziuban --------- Signed-off-by: Matt Dziuban --- .../splice/http/HttpClient.scala | 42 +++++++-- .../http/InvalidResponseContentTest.scala | 89 +++++++++++++++++++ test-full-class-names-non-integration.log | 1 + 3 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 apps/common/src/test/scala/org/lfdecentralizedtrust/splice/http/InvalidResponseContentTest.scala diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/http/HttpClient.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/http/HttpClient.scala index c8eff10a11..a09cfb7fa5 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/http/HttpClient.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/http/HttpClient.scala @@ -17,6 +17,8 @@ import org.apache.pekko.http.scaladsl.model.{ HttpHeader, HttpRequest, HttpResponse, + MediaType, + MediaTypes, StatusCode, StatusCodes, } @@ -50,6 +52,37 @@ trait HttpClient { } object HttpClient { + private object ResponseErrorByStatus { + def unapply(resp: HttpResponse): Option[StatusCode] = + resp.status match { + case code @ (StatusCodes.ServerError(_) | StatusCodes.ClientError(_)) => Some(code) + case _ => None + } + } + + private object ResponseErrorByContentType { + private val validContentTypes: Set[MediaType] = Set( + MediaTypes.`application/json`, + MediaTypes.`application/octet-stream`, + MediaTypes.`text/plain`, + ) + + def unapply(resp: HttpResponse): Boolean = + resp.entity.contentType match { + // Responses with `NoContentType` are always considered valid + case ContentTypes.NoContentType => false + // Otherwise a response is valid if its content type is contained in `validContentTypes` + case contentType => !validContentTypes.contains(contentType.mediaType) + } + } + + private def httpFnErrors( + nonErrorStatusCode: Set[StatusCode] + ): PartialFunction[HttpResponse, Unit] = { + case ResponseErrorByStatus(code) if !nonErrorStatusCode.contains(code) => + case ResponseErrorByContentType() => + } + def createHttpFn( clientName: String, operationName: String, @@ -61,10 +94,7 @@ object HttpClient { ): HttpRequest => Future[HttpResponse] = { httpClientWithErrors( httpClient.executeRequest(clientName, operationName), - { - case code @ (StatusCodes.ServerError(_) | StatusCodes.ClientError(_)) - if !nonErrorStatusCode.contains(code) => - }, + httpFnErrors(nonErrorStatusCode), ) } @@ -91,7 +121,7 @@ object HttpClient { private def httpClientWithErrors( nextClient: HttpRequest => Future[HttpResponse], - errors: PartialFunction[StatusCode, Unit], + errors: PartialFunction[HttpResponse, Unit], )( req: HttpRequest )(implicit ec: ExecutionContext, mat: Materializer) = { @@ -102,7 +132,7 @@ object HttpClient { Future.failed[HttpResponse](error) } ) - .applyOrElse(_resp.status, (_: StatusCode) => Future.successful(_resp)) + .applyOrElse(_resp, Future.successful(_: HttpResponse)) } } diff --git a/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/http/InvalidResponseContentTest.scala b/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/http/InvalidResponseContentTest.scala new file mode 100644 index 0000000000..2de16000da --- /dev/null +++ b/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/http/InvalidResponseContentTest.scala @@ -0,0 +1,89 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package org.lfdecentralizedtrust.splice.http + +import cats.data.EitherT +import com.digitalasset.canton.{BaseTest, HasActorSystem, HasExecutionContext} +import com.digitalasset.canton.config.NonNegativeDuration +import io.circe.syntax.* +import org.lfdecentralizedtrust.splice.auth.AuthToken +import org.lfdecentralizedtrust.splice.config.AuthTokenSourceConfig +import org.lfdecentralizedtrust.splice.http.v0.definitions.Version +import org.apache.pekko.http.scaladsl.model.* +import org.apache.pekko.stream.Materializer +import org.lfdecentralizedtrust.splice.http.v0.external.common_admin.{ + CommonAdminClient, + GetVersionResponse, + IsReadyResponse, +} +import org.scalatest.compatible.Assertion +import org.scalatest.wordspec.AnyWordSpec + +import java.time.{OffsetDateTime, ZoneOffset} +import scala.concurrent.Future +import scala.concurrent.duration.Duration + +class InvalidResponseContentTest + extends AnyWordSpec + with BaseTest + with HasActorSystem + with HasExecutionContext { + + private implicit val mat: Materializer = Materializer(actorSystem) + + private def runEndpoint[A]( + endpoint: CommonAdminClient => EitherT[Future, Either[Throwable, HttpResponse], A], + resp: ResponseEntity, + )( + expectation: Either[Either[Throwable, HttpResponse], A] => Assertion + ): Assertion = { + implicit val httpClient: HttpClient = new HttpClient { + val requestParameters = HttpClient.HttpRequestParameters(NonNegativeDuration(Duration.Zero)) + def withOverrideParameters(newParameters: HttpClient.HttpRequestParameters): HttpClient = this + def executeRequest(client: String, operation: String)( + request: HttpRequest + ): Future[HttpResponse] = Future.successful(HttpResponse(StatusCodes.OK, entity = resp)) + def getToken(authConfig: AuthTokenSourceConfig): Future[Option[AuthToken]] = + Future.successful(None) + } + + val client = CommonAdminClient.httpClient(HttpClient.createHttpFn("", ""), "http://localhost") + expectation(endpoint(client).value.futureValue) + } + + "CommonAdminClient.getVersion" should { + "include the response body in the error when the content type is invalid" in { + val testBody = "test" + runEndpoint(_.getVersion(), HttpEntity(ContentTypes.`text/html(UTF-8)`, testBody)) { + case Left(Left(err)) => err.getMessage should endWith(testBody) + case other => fail(s"expected Left(Left(throwable)), got: $other") + } + } + + "decode a well-formed application/json response" in { + runEndpoint( + _.getVersion(), + HttpEntity( + ContentTypes.`application/json`, + Version( + "1.2.3", + OffsetDateTime.of(2026, 7, 23, 0, 0, 0, 0, ZoneOffset.UTC), + ).asJson.noSpaces, + ), + ) { + case Right(_: GetVersionResponse.OK) => succeed + case other => fail(s"expected GetVersionResponse.OK, got: $other") + } + } + } + + "CommonAdminClient.isReady" should { + "handle an empty response with no content type" in { + runEndpoint(_.isReady(), HttpEntity.Empty) { + case Right(IsReadyResponse.OK) => succeed + case other => fail(s"expected IsReadyResponse.OK, got: $other") + } + } + } +} diff --git a/test-full-class-names-non-integration.log b/test-full-class-names-non-integration.log index 6e5afedcd2..fc29c49e92 100644 --- a/test-full-class-names-non-integration.log +++ b/test-full-class-names-non-integration.log @@ -11,6 +11,7 @@ org.lfdecentralizedtrust.splice.environment.ActiveContractsRestartTest org.lfdecentralizedtrust.splice.environment.CommandCircuitBreakerTest org.lfdecentralizedtrust.splice.environment.CommandIdDedupTest org.lfdecentralizedtrust.splice.environment.TopologyAwarePackageVersionSupportTest +org.lfdecentralizedtrust.splice.http.InvalidResponseContentTest org.lfdecentralizedtrust.splice.http.NonProxyHostsTest org.lfdecentralizedtrust.splice.http.UrlValidatorTest org.lfdecentralizedtrust.splice.scan.admin.api.client.BftScanConnectionTest From c0da78d5e45aff7118a64bca9c3653c426b0dbe2 Mon Sep 17 00:00:00 2001 From: Cale Gibbard Date: Mon, 27 Jul 2026 17:14:21 +0000 Subject: [PATCH 25/31] Revert "Add a script which constructs a fake single package for speeding up development cycles by allowing the LSP to work cross-package when working on code." This reverts commit 15dfa701bdbf30093e66fc2c205883b0990ecdf7. --- .gitignore | 2 -- daml-ide-mono/README.md | 15 --------------- daml-ide-mono/daml.yaml | 17 ----------------- scripts/setup-mono-package.sh | 29 ----------------------------- 4 files changed, 63 deletions(-) delete mode 100644 daml-ide-mono/README.md delete mode 100644 daml-ide-mono/daml.yaml delete mode 100755 scripts/setup-mono-package.sh diff --git a/.gitignore b/.gitignore index 230671f341..2c56be64b4 100644 --- a/.gitignore +++ b/.gitignore @@ -22,8 +22,6 @@ _build/ **/metals.sbt **/.scala-build/* -daml-ide-mono/daml/ -daml-ide-mono/.vscode/ .vscode/* !.vscode/settings.json # Make sure test files are checkedin diff --git a/daml-ide-mono/README.md b/daml-ide-mono/README.md deleted file mode 100644 index 4a24340200..0000000000 --- a/daml-ide-mono/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# What is this? - -This directory, along with its fake `daml.yaml` can be populated with symlinks by -`./scripts/setup-mono-package.sh` to serve as a fake single-dar package containing all the daml in -Splice such that cross-package changes can be worked on with a tighter feedback loop using the -Daml Language Server (LSP) in VS Code or other editors, e.g. by running `dpm studio` in this directory -after having run the script. - -You'll also have to remember to re-run the script if you add new `.daml` files to any package. - -You should still verify that everything builds/tests for real once you're done of course, but this mode -of interaction can be very useful to eliminate 12-20 second build cycles and instead get immediate -feedback from the VS Code extension when working on tests and making changes that would otherwise be in -their upstream DAR dependencies, for example. - diff --git a/daml-ide-mono/daml.yaml b/daml-ide-mono/daml.yaml deleted file mode 100644 index e998fdd9a9..0000000000 --- a/daml-ide-mono/daml.yaml +++ /dev/null @@ -1,17 +0,0 @@ -sdk-version: 3.5.2 -name: splice-mono-ide -source: daml -version: 0.0.1 -dependencies: - - daml-prim - - daml-stdlib - - daml-script -build-options: - - --ghc-option=-Wunused-binds - - --ghc-option=-Wunused-matches - - --target=2.1 - - -Wno-upgrade-exceptions - - -Wno-deprecated-exceptions - - -Wno-template-interface-depends-on-daml-script - - -Wno-upgrade-interfaces - - --force-utility-package=no diff --git a/scripts/setup-mono-package.sh b/scripts/setup-mono-package.sh deleted file mode 100755 index d12b164336..0000000000 --- a/scripts/setup-mono-package.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash -# Populate daml-ide-mono/daml/ with per-file symlinks into every workspace -# Daml package's source tree. The synthesised daml-ide-mono/daml.yaml is -# checked in and static - this script only regenerates the source tree -# so that VS Code can work on the union as a single package. -# -# Re-run this any time you add a new .daml file to the workspace or add -# a new workspace package. Symlinks are relative, so `mv`-ing the repo -# leaves them working. -set -euo pipefail - -repo=$(cd "$(dirname "$0")/.." && pwd) -dest="$repo/daml-ide-mono/daml" - -rm -rf "$dest" -mkdir -p "$dest" - -for pkg in "$repo"/daml/*/daml.yaml \ - "$repo"/token-standard/*/daml.yaml \ - "$repo"/token-standard/examples/*/daml.yaml; do - src=$(dirname "$pkg")/daml - [ -d "$src" ] || continue - ( cd "$src" && find . -name '*.daml' -printf '%P\n' ) | - while IFS= read -r rel; do - link="$dest/$rel" - mkdir -p "$(dirname "$link")" - ln -sfn "$src/$rel" "$link" - done -done From bde618e6a70ff89b17b07224a075bfcfa415b3e5 Mon Sep 17 00:00:00 2001 From: "Jonathan D.K. Gibbons" Date: Tue, 28 Jul 2026 12:19:28 +0000 Subject: [PATCH 26/31] WIP: hook the withdraw-like operations to withdraw. --- .../Splice/Scripts/TestAggregateLocks.daml | 82 +++- .../daml/Splice/AggregateLock.daml | 362 ++++++++++-------- .../daml/Splice/AmuletAllocationV2.daml | 15 +- .../Utils/Internal/Allocations.daml | 14 +- .../Testing/Registries/AmuletRegistryV2.daml | 4 + 5 files changed, 301 insertions(+), 176 deletions(-) diff --git a/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml b/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml index c3c69b5919..717a9e6a02 100644 --- a/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml +++ b/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml @@ -1,4 +1,5 @@ {-# LANGUAGE ApplicativeDo #-} +{-# LANGUAGE GeneralizedNewtypeDeriving #-} module Splice.Scripts.TestAggregateLocks where import DA.Time @@ -10,6 +11,7 @@ import Splice.Expiry import Splice.Scripts.Util import Splice.TokenStandard.Utils qualified as TSU import Splice.Api.Token.AllocationV2 qualified as V2 +import Splice.Api.Token.HoldingV2 qualified as V2 import Splice.Testing.TokenStandard.RegistryApiV2 import Splice.Testing.TokenStandard.WalletClientV2 qualified as WalletClientV2 import Splice.Testing.Registries.AmuletRegistryV2 @@ -28,20 +30,34 @@ import DA.Functor import DA.Foldable (mapA_, sequence_) import DA.List (head) import qualified DA.TextMap as TM +import qualified DA.Map as M import Splice.Scripts.TokenStandard.TestAmuletTokenStandardTestEnv import Splice.Testing.Registries.AmuletRegistryV2 qualified as AmuletRegistryV2 import Splice.TokenStandard.Utils.Internal.Conversions (encodeTime) - -lockForGovernance : TestEnv -> TM.TextMap Decimal -> Metadata -> Party -> ContractId AggregateLock -> Script AllocationInstructionResult -lockForGovernance (TestEnv {..}) amounts meta party aggCid = do - WalletClientV2.allocateV2 registries bob lockSettlementInfo lockAllocation +newtype AmuletRegistryExtraWithdrawContext = AmuletRegistryExtraWithdrawContext { unExtra : AmuletRegistry } + +instance RegistryApi AmuletRegistryExtraWithdrawContext where + getTransferFactory = getTransferFactory . unExtra + getAllocationFactory = getAllocationFactory . unExtra + getSettlementFactory = getSettlementFactory . unExtra + getAllocation_WithdrawContext = getAllocation_WithdrawContext . unExtra + getAllocation_CancelContext = getAllocation_CancelContext . unExtra + getAllocationInstruction_WithdrawContext = getAllocationInstruction_WithdrawContext . unExtra + getAllocationInstruction_AcceptContext = getAllocationInstruction_AcceptContext . unExtra + getTransferInstruction_AcceptContext = getTransferInstruction_AcceptContext . unExtra + getTransferInstruction_RejectContext = getTransferInstruction_RejectContext . unExtra + getTransferInstruction_WithdrawContext = getTransferInstruction_WithdrawContext . unExtra + +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 = Some $ coerceContractId aggCid + cid = None -- Some $ coerceContractId aggCid meta = emptyMetadata lockAllocation = V2.AllocationSpecification with admin = instrId.admin @@ -49,14 +65,17 @@ lockForGovernance (TestEnv {..}) amounts meta party aggCid = do transferLegSides = [] committed = True nextIterationFunding = Some $ amounts -- TM.fromList [(instrId.id, amount)] - settlementDeadline = Some maxBound + settlementDeadline = Some maxComparableTime meta -lockForAggregate : TestEnv -> Decimal -> Party -> ContractId AggregateLock -> Script AllocationInstructionResult -lockForAggregate te amount = lockForGovernance te (TM.fromList [(te.instrId.id, amount)]) $ Metadata $ TM.fromList - [ ("cip-105/type", "aggregateLock") ] +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") + ] -lockForVesting : Decimal -> Time -> TestEnv -> Party -> ContractId AggregateLock -> Script AllocationInstructionResult +lockForVesting : Decimal -> Time -> TestEnv -> Party -> Script AllocationInstructionResult lockForVesting initialAmount now te = lockForGovernance te TM.empty $ Metadata $ TM.fromList [ ("cip-105/type", "vestingLock") , ("cip-105/vestingLock.startDate", encodeTime now) @@ -69,18 +88,52 @@ testAggregateLockHappyPath = do env@TestEnv{..} <- setupTest let dso = env.instrId.admin + let + addAggregateWithdrawContext : Script OpenApiChoiceContext -> Script OpenApiChoiceContext + addAggregateWithdrawContext a = do + baseCtxt <- a + extraContext <- getExternalPartyConfigStateContext registriesEnv.amuletV2 + pure $ baseCtxt <> extraContext + updateApi api = api { ggetAllocation_WithdrawContext = \a -> addAggregateWithdrawContext . api.ggetAllocation_WithdrawContext a } + newRegistries = flip fmap registries $ \reg -> + reg { v2Api = fmap updateApi reg.v2Api } + AmuletRegistryV2.tapFaucet registriesEnv.amuletV2 bob 1800.0 - aggLock <- submit (actAs dso) $ createCmd AggregateLock with + {- + aggLock <- submit (actAs [alice, dso]) $ createCmd AggregateLock with dso instrumentId = env.instrId.id vestingSchedule = VestingSchedule_SV - Some aggLockDisclosure <- queryDisclosure dso aggLock + -- Some aggLockDisclosure <- queryDisclosure dso aggLock + -} -- Lock some funds for the aggregate - AllocationInstructionResult { output = AllocationInstructionResult_Completed locked } <- lockForAggregate env 1000.0 bob aggLock + AllocationInstructionResult { output = AllocationInstructionResult_Completed locked } <- lockForAggregate env 1000.0 "alice-supervalidator" bob + + Some lockedView <- queryInterfaceContractId bob locked + + allocs <- queryInterface @V2.Allocation dso + let filtered = filter (isValidAggregateLockedAllocation dso) $ fromSome . snd <$> allocs + toKeyAndAmount 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.fromListWith (+) $ toKeyAndAmount <$> filtered + + assertEq (Some 1000.0) (Some "alice-supervalidator" `M.lookup` totals) + + WalletClientV2.withdrawAllocationV2 newRegistries bob (locked, lockedView) + + let bobAcct = V2.Account with + owner = Some bob + provider = Some dso + id = "" + + allocs <- WalletClientV2.listAllocationsV2 bobAcct bob + + -- unlockResult <- WalletClientV2.withdrawAllocationV2 newRegistries bob $ head allocs + + pure () - -- Check total for the aggregate; needs to be implemented. + {- now <- getTime let initialAmountToUnlock = 365.25 @@ -112,6 +165,7 @@ testAggregateLockHappyPath = do assert $ length allocations == 2 -- Need to check other than via inspection that the two allocations actually exist with correct values. + -} -- TODO: Temporary, bad to do this let vestingLockAllocationCid = head [cid | (cid, a) <- allocations, TM.member "cip-105/vestingLock.initialAmount" a.allocation.meta.values] diff --git a/daml/splice-amulet/daml/Splice/AggregateLock.daml b/daml/splice-amulet/daml/Splice/AggregateLock.daml index 76a08fc7f4..8943cac5af 100644 --- a/daml/splice-amulet/daml/Splice/AggregateLock.daml +++ b/daml/splice-amulet/daml/Splice/AggregateLock.daml @@ -2,25 +2,31 @@ module Splice.AggregateLock where import Splice.Amulet.TokenApiUtils import Splice.Api.Token.AllocationV2 as V2 +import qualified Splice.Api.Token.AllocationInstructionV2 as V2 import Splice.Api.Token.MetadataV1 -import Splice.TokenStandard.Utils.Internal.Conversions (decodeTime) import Splice.Types (ForDso(..)) +import Splice.TokenStandard.Utils (fromAnyContractId, maxTime) +import Splice.TokenStandard.Utils.Internal.Allocations (settlementFactoryV2_settleBatchDefaultImplNoSelf) +import Splice.TokenStandard.Utils.Internal.Conversions (decodeTime, encodeTime) import Splice.Util import DA.Either import DA.Optional import DA.Text -import qualified DA.List as L +import DA.List import qualified DA.TextMap as TM import DA.Time +maxComparableTime : Time +maxComparableTime = addRelTime maxTime $ microseconds (-1) + isValidAggregateLockedAllocation : Party -> V2.AllocationView -> Bool isValidAggregateLockedAllocation dso alloc = and [ alloc.allocation.admin == dso , alloc.allocation.committed , null alloc.allocation.transferLegSides , TM.lookup "cip-105/type" alloc.allocation.meta.values == Some "aggregateLock" - , alloc.expiresAt == Some maxBound + , alloc.expiresAt == Some maxComparableTime ] isValidVestingLockedDestination : Party -> V2.AllocationView -> Bool @@ -29,7 +35,7 @@ isValidVestingLockedDestination dso alloc = , alloc.allocation.committed , null alloc.allocation.transferLegSides , TM.lookup "cip-105/type" alloc.allocation.meta.values == Some "vestingLock" - , alloc.expiresAt == Some maxBound + , alloc.expiresAt == Some maxTime , TM.member "cip-105/vestingLock.startDate" metaValues , TM.member "cip-105/vestingLock.endDate" metaValues ] @@ -46,14 +52,37 @@ controllerSetFromMeta = mapA partiesFromText . splitOn ";" data GovernanceLockedControllers = GovernanceLockedControllers with unlockControllerSets : ControllerSets substituteControllerSets : ControllerSets + withdrawControllerSets : ControllerSets governanceLockedControllersFromMeta : Metadata -> Party -> GovernanceLockedControllers governanceLockedControllersFromMeta meta acctParty = GovernanceLockedControllers with unlockControllerSets = controllerSetFromMetaMap "cip-105/unlockControllerSets" substituteControllerSets = controllerSetFromMetaMap "cip-105/substituteControllerSets" + withdrawControllerSets = controllerSetFromMetaMap "cip-105/withdrawControllerSets" where controllerSetFromMetaMap key = fromOptional [[acctParty]] $ controllerSetFromMeta =<< key `TM.lookup` meta.values +checkControllerSet : [Party] -> ControllerSets -> Update () +checkControllerSet parties set = + require "Controllers must be one of the listed options" $ any (\conj -> all (`elem` parties) conj && all (`elem` conj) parties) set + +governanceLockedWithdrawImpl + : HasToInterface a V2.Allocation + => (a -> Time -> Metadata -> Update (ContractId V2.Allocation)) + -> a + -> ContractId a + -> V2.Allocation_Withdraw + -> Update V2.AllocationResult +governanceLockedWithdrawImpl newEmptyAllocation a acid arg@(V2.Allocation_Withdraw{..}) = + case TM.lookup "cip-105/type" allocView.allocation.meta.values of + Some "aggregateLock" -> do + aggregateLockUnlock newEmptyAllocation a acid arg + Some "vestingLock" -> + vestingLockWithdraw a acid arg + _ -> assertFail "Invalid cip-105/type field." + where + allocView = view $ toInterface @V2.Allocation a + data VestingSchedule = VestingSchedule_SV | VestingSchedule_Immediate @@ -95,173 +124,188 @@ calculateAvailableWithdrawAmount currentDateTime alloc nextIterationFundAmount = -- vesting to be in a separate dar from amulet only needed for SVs and -- interested observers. -template AggregateLock +{-template AggregateLock with dso: Party instrumentId : Text vestingSchedule : VestingSchedule where signatory dso - nonconsuming choice AggregateLock_Unlock : SettlementFactory_SettleBatchResult - with - factoryCid : ContractId SettlementFactory - lockedCid : ContractId Allocation - withdrawToCid : ContractId Allocation - authorizers : [ Party ] - amount : Decimal - extraArgs : ExtraArgs - where - controller authorizers - do - locked <- view <$> fetchCheckedInterface (ForDso with dso) lockedCid - withdrawTo <- view <$> fetchCheckedInterface (ForDso with dso) withdrawToCid - ownerParty <- whenNone locked.allocation.authorizer.owner $ - assertFail "The requirement 'governance locked allocations must be owned by basic accounts' was not met" - let GovernanceLockedControllers{..} = governanceLockedControllersFromMeta locked.meta ownerParty - require "Unlock controller must be one of the options" $ any (\conj -> all (`elem` authorizers) conj) unlockControllerSets - require "Not allowed to unlock to a different party than locked the funds" $ locked.allocation.authorizer == withdrawTo.allocation.authorizer - require "Must be a valid governance-locked allocation" $ isValidAggregateLockedAllocation dso locked - require "Must be a valid empty vesting unlock allocation" $ isValidVestingLockedDestination dso withdrawTo - require "Must have amounts reserved within the next settlement iteration" $ isSome locked.allocation.nextIterationFunding - now <- getTime - require "Vesting allocation must have a correct schedule configuration" $ validateVestingSchedule vestingSchedule now withdrawTo.allocation.meta - let - info = SettlementInfo with - executors = [ dso ] - id = "AggregateLock" - cid = Some $ coerceContractId self - meta = emptyMetadata - transferLegId = "unlock" - - currentLockedAmount <- whenNone (locked.allocation.nextIterationFunding >>= TM.lookup instrumentId) $ - assertFail "The requirement 'Must have amounts reserved within the next settlement iteration' was not met" - let newLockedAmount = currentLockedAmount - amount - - exercise factoryCid $ SettlementFactory_SettleBatch with - settlement = info - actors = [ dso ] - extraArgs - transferLegs = - [ TransferLeg with +-} + +aggregateLockUnlock : (HasToInterface a V2.Allocation) => (a -> Time -> Metadata -> Update (ContractId V2.Allocation)) -> a -> ContractId a -> Allocation_Withdraw -> Update V2.AllocationResult +aggregateLockUnlock newEmptyAllocation a aCid arg = do + let alloc = view $ toInterface @V2.Allocation a + allocCid = toInterfaceContractId @V2.Allocation aCid + + (instrumentId, currentLockedAmount) <- case TM.toList <$> alloc.allocation.nextIterationFunding of + Some [a] -> pure a + _ -> assertFail "The requirement 'Must reserve a single amount of a single token' was not met" + + vestingSchedule <- decodeVestingSchedule arg.extraArgs.meta alloc.allocation.meta + + let dso = alloc.allocation.admin + + ownerParty <- whenNone alloc.allocation.authorizer.owner $ + assertFail "The requirement 'governance locked allocations must be owned by basic accounts' was not met" + + let GovernanceLockedControllers{..} = governanceLockedControllersFromMeta alloc.meta ownerParty + checkControllerSet arg.actors unlockControllerSets + + require "Must be a valid governance-locked allocation" $ isValidAggregateLockedAllocation dso alloc + + now <- getTime + let endTime = getEndTimeFromVestingSchedule vestingSchedule now + transferLegId = "unlock" + + let alternateAmount = TM.lookup "cip-105/withdraw-amount" arg.extraArgs.meta.values >>= parseDecimal + amount = fromOptional currentLockedAmount alternateAmount + newLockedAmount = currentLockedAmount - amount + + -- Not easy to call directly into amulet_allocationFactoryV2_allocateImpl or the like here as that would make a circular dependency. + withdrawTo <- newEmptyAllocation a now $ Metadata $ TM.fromList + [ ("cip-105/type", "vestingLock") + , ("cip-105/vestingLock.startDate", encodeTime $ now) + , ("cip-105/vestingLock.endDate", encodeTime $ endTime) + ] + + -- settlementFactoryV2_settleBatchDefaultImpl does not use it's third argument, so we pass undefined rather than require a contract ID for ExternalPartyAmuletRules. + settleBatchResult <- settlementFactoryV2_settleBatchDefaultImplNoSelf (\_ _ -> pure arg.extraArgs) dso $ SettlementFactory_SettleBatch with + settlement = alloc.settlement + actors = [ dso ] + extraArgs = arg.extraArgs + transferLegs = + [ TransferLeg with + transferLegId + sender = alloc.allocation.authorizer + receiver = alloc.allocation.authorizer + amount + instrumentId + meta = emptyMetadata + ] + allocations = + [ FinalizedAllocation with + allocationCid = allocCid + extraTransferLegSides = + [ TransferLegSide with + transferLegId + side = SenderSide + otherside = alloc.allocation.authorizer + amount + instrumentId + meta = emptyMetadata + ] + nextIterationFunding = Some $ TM.singleton instrumentId newLockedAmount + , FinalizedAllocation with + allocationCid = withdrawTo + extraTransferLegSides = + [ TransferLegSide with + transferLegId + side = ReceiverSide + otherside = alloc.allocation.authorizer + amount + instrumentId + meta = emptyMetadata + ] + nextIterationFunding = Some $ TM.singleton instrumentId amount + ] + pure $ head settleBatchResult.allocationSettleResults + where + getEndTimeFromVestingSchedule VestingSchedule_SV now = addRelTime now $ days 365 + hours 6 + getEndTimeFromVestingSchedule VestingSchedule_Immediate now = now + +-- vestingLockWithdraw : V2.AllocationView -> ContractId V2.Allocation -> Allocation_Withdraw -> Update V2.AllocationResult +vestingLockWithdraw : (HasToInterface a V2.Allocation) => a -> ContractId a -> Allocation_Withdraw -> Update V2.AllocationResult +vestingLockWithdraw a aCid arg = do + let vestingLock = view $ toInterface @V2.Allocation a + vestingLockCid = toInterfaceContractId @V2.Allocation aCid + now <- getTime + + require "Must be a valid vesting unlock allocation" $ isValidVestingLockedDestination dso vestingLock + require "There must be an initialAmount set for the vestingLock" + $ TM.member "cip-105/vestingLock.initialAmount" vestingLock.allocation.meta.values + + ownerParty <- whenNone vestingLock.allocation.authorizer.owner $ + assertFail "The requirement 'governance locked allocations must be owned by basic accounts' was not met" + let GovernanceLockedControllers{..} = governanceLockedControllersFromMeta vestingLock.meta ownerParty + + checkControllerSet arg.actors withdrawControllerSets + + -- TODO: Potentially do case statement with nextIterationFunding optionals + require "There are remaining funds to withdraw" + $ isSome vestingLock.allocation.nextIterationFunding + + (instrumentId, nextIterationFunding) <- case TM.toList <$> vestingLock.allocation.nextIterationFunding of + Some [a] -> pure a + _ -> assertFail "The requirement 'Must reserve a single amount of a single token' was not met" + require "There must be an initialAmount set for the vestingLock" + $ TM.member "cip-105/vestingLock.initialAmount" vestingLock.allocation.meta.values + + let (availableWithdrawAmount, remainingVestingAmount) : (Decimal, Decimal) = + calculateAvailableWithdrawAmount now vestingLock nextIterationFunding + + require ("Current eligible withdraw amount for vesting lock is not greater than 0.0. " + <> "currentEligibleWithdrawableAmount came back = '" + <> show availableWithdrawAmount + <> "'." + ) + $ availableWithdrawAmount > 0.0 + + let + info = SettlementInfo with + executors = [ vestingLock.allocation.admin ] + id = "VestingLock_Withdraw" + cid = Some $ coerceContractId vestingLockCid + meta = emptyMetadata + transferLegId = "withdraw" + nextIterationFunding + | remainingVestingAmount <= 0.0 = None + | otherwise = Some $ TM.singleton instrumentId remainingVestingAmount + + settleResult <- settlementFactoryV2_settleBatchDefaultImplNoSelf (\_ _ -> pure arg.extraArgs) vestingLock.allocation.admin $ SettlementFactory_SettleBatch with + settlement = info + actors = [ vestingLock.allocation.admin ] + extraArgs = ExtraArgs emptyChoiceContext emptyMetadata + transferLegs = + [ TransferLeg with + transferLegId + sender = vestingLock.allocation.authorizer + receiver = vestingLock.allocation.authorizer + amount = availableWithdrawAmount + instrumentId + meta = emptyMetadata + ] + allocations = + [ FinalizedAllocation with + allocationCid = vestingLockCid + extraTransferLegSides = + [ TransferLegSide with transferLegId - sender = locked.allocation.authorizer - receiver = withdrawTo.allocation.authorizer - amount + side = SenderSide + otherside = vestingLock.allocation.authorizer + amount = remainingVestingAmount instrumentId meta = emptyMetadata - ] - allocations = - [ FinalizedAllocation with - allocationCid = lockedCid - extraTransferLegSides = - [ TransferLegSide with - transferLegId - side = SenderSide - otherside = withdrawTo.allocation.authorizer - amount - instrumentId - meta = emptyMetadata - ] - nextIterationFunding = Some $ TM.singleton instrumentId newLockedAmount - , FinalizedAllocation with - allocationCid = withdrawToCid - extraTransferLegSides = - [ TransferLegSide with - transferLegId - side = ReceiverSide - otherside = locked.allocation.authorizer - amount - instrumentId - meta = emptyMetadata - ] - nextIterationFunding = Some $ TM.singleton instrumentId amount - ] - -- Note: Choice temporarily lives here and it will be moved, testing/draft purposes atm - nonconsuming choice VestingLock_Withdraw : AllocationResult - with - factoryCid : ContractId SettlementFactory - authorizers : [Party] - vestingLockCid : ContractId Allocation - extraArgs : ExtraArgs - where - controller authorizers - do - vestingLock <- view <$> fetchCheckedInterface (ForDso with dso) vestingLockCid - now <- getTime - ownerParty <- whenNone vestingLock.allocation.authorizer.owner $ - assertFail "The requirement 'governance locked allocations must be owned by basic accounts' was not met" - - require "Must be a valid vesting unlock allocation" $ isValidVestingLockedDestination dso vestingLock - require "There must be an initialAmount set for the vestingLock" - $ TM.member "cip-105/vestingLock.initialAmount" vestingLock.allocation.meta.values - let GovernanceLockedControllers{..} = governanceLockedControllersFromMeta vestingLock.meta ownerParty - require "Unlock controller must be one of the options" $ any (\conj -> all (`elem` authorizers) conj) unlockControllerSets - - let mNextIterationFunding = do - fundingMap <- vestingLock.allocation.nextIterationFunding - TM.lookup instrumentId fundingMap - - nextIterationFundingAmount <- case mNextIterationFunding of - None -> abort ("VestingLock's nextIterationFunding should have remaining funds to withdraw for instrumentId " <> show instrumentId) - Some amount -> pure amount - - let (availableWithdrawAmount, remainingVestingAmount) : (Decimal, Decimal) = - calculateAvailableWithdrawAmount now vestingLock nextIterationFundingAmount - - require ("Current eligible withdraw amount for vesting lock should be greater than 0.0. " - <> "currentEligibleWithdrawableAmount came back = '" - <> show availableWithdrawAmount - <> "'." - ) - $ availableWithdrawAmount > 0.0 - - let - info = SettlementInfo with - executors = [ dso ] - id = "AggregateLock" - cid = Some $ coerceContractId self - meta = emptyMetadata - transferLegId = "withdraw" - nextIterationFunding - | remainingVestingAmount <= 0.0 = None - | otherwise = Some $ TM.singleton instrumentId remainingVestingAmount - - batchResults <- exercise factoryCid $ SettlementFactory_SettleBatch with - settlement = info - actors = [ dso ] - extraArgs - transferLegs = - [ TransferLeg with + , TransferLegSide with transferLegId - sender = vestingLock.allocation.authorizer - receiver = vestingLock.allocation.authorizer + side = ReceiverSide + otherside = vestingLock.allocation.authorizer amount = availableWithdrawAmount instrumentId meta = emptyMetadata ] - allocations = - [ FinalizedAllocation with - allocationCid = vestingLockCid - extraTransferLegSides = - [ TransferLegSide with - transferLegId - side = SenderSide - otherside = vestingLock.allocation.authorizer - amount = availableWithdrawAmount - instrumentId - meta = emptyMetadata - , TransferLegSide with - transferLegId - side = ReceiverSide - otherside = vestingLock.allocation.authorizer - amount = availableWithdrawAmount - instrumentId - meta = emptyMetadata - ] - nextIterationFunding = nextIterationFunding - ] + nextIterationFunding + ] + + -- We should have one result from settleBatch + case batchResults.allocationSettleResults of + [allocationResult] -> pure allocationResult + _ -> abort "VestingUnlock_Withdraw should result in one allocation result. " + +-- Takes the choice metadata as well to enable override to allow quick unlock +decodeVestingSchedule : Metadata -> Metadata -> Update VestingSchedule +decodeVestingSchedule _withdrawMeta lockMeta = do + case TM.lookup "cip-105/vestingSchedule" lockMeta.values of + Some "SV" -> pure VestingSchedule_SV + _ -> assertFail "Must have a valid vesting schedule" - -- We should have one result from settleBatch - case batchResults.allocationSettleResults of - [allocationResult] -> pure allocationResult - _ -> abort "VestingUnlock_Withdraw should result in one allocation result. " diff --git a/daml/splice-amulet/daml/Splice/AmuletAllocationV2.daml b/daml/splice-amulet/daml/Splice/AmuletAllocationV2.daml index 088455e03f..806dbd054b 100644 --- a/daml/splice-amulet/daml/Splice/AmuletAllocationV2.daml +++ b/daml/splice-amulet/daml/Splice/AmuletAllocationV2.daml @@ -23,6 +23,7 @@ import Splice.Api.Token.HoldingV2 qualified as V2 import Splice.Api.Token.AllocationV2 qualified as V2 import Splice.TokenStandard.Utils hiding (require) +import Splice.AggregateLock import Splice.Amulet import Splice.AmuletConfig (TransferConfigV2, getTokenStandardMaxTTL) import Splice.AmuletRules @@ -76,6 +77,8 @@ template AmuletAllocationV2 allocation_cancelExtraObservers _arg = observer this allocation_withdrawExtraObservers _arg = observer this + allocation_withdrawImpl self arg | isGovernanceLocked this = + governanceLockedWithdrawImpl emptyCloneWithMeta this (fromInterfaceContractId self) arg allocation_withdrawImpl self arg@(V2.Allocation_Withdraw{..}) = do archiveAndCheckActors self arg.actors [[accountPrincipal allocation.admin allocation.authorizer]] ensureWithdrawIsAllowed allocation @@ -230,14 +233,22 @@ computeAllocationExpiryInternal transferConfig oldExpiresAt settlementDeadline = | maxTime `subTime` oldExpiresAt <= maxTTL = maxTime | otherwise = oldExpiresAt `addRelTime` maxTTL --- FIXME: the exception to allow Some maxBound is for infinite-duration governance locks, and should probably be limited more explicitly to only those cases. +-- FIXME: the exception to allow Some maxTime is for infinite-duration governance locks, and should probably be limited more explicitly to only those cases. computeAllocationExpiry : TransferConfigV2 Amulet -> Time -> Optional Time -> Update Time -computeAllocationExpiry transferConfig oldExpiresAt settlementDeadline | settlementDeadline == Some maxBound = pure maxBound +computeAllocationExpiry transferConfig oldExpiresAt settlementDeadline | settlementDeadline == Some maxComparableTime = pure maxComparableTime computeAllocationExpiry transferConfig oldExpiresAt settlementDeadline = do let expiresAt = computeAllocationExpiryInternal transferConfig oldExpiresAt settlementDeadline assertWithinDeadline "allocation.expiresAt" expiresAt pure expiresAt +isGovernanceLocked alloc = TextMap.member "cip-105/type" alloc.allocation.meta.values + +emptyCloneWithMeta : AmuletAllocationV2 -> Time -> Metadata -> Update (ContractId V2.Allocation) +emptyCloneWithMeta src now meta = toInterfaceContractId <$> create src with + lockedAmulet = None + createdAt = now + allocation = src.allocation with + meta -- instances ------------ diff --git a/token-standard/splice-token-standard-utils/daml/Splice/TokenStandard/Utils/Internal/Allocations.daml b/token-standard/splice-token-standard-utils/daml/Splice/TokenStandard/Utils/Internal/Allocations.daml index 9ce6ef72f6..2085a3ae4e 100644 --- a/token-standard/splice-token-standard-utils/daml/Splice/TokenStandard/Utils/Internal/Allocations.daml +++ b/token-standard/splice-token-standard-utils/daml/Splice/TokenStandard/Utils/Internal/Allocations.daml @@ -53,6 +53,7 @@ module Splice.TokenStandard.Utils.Internal.Allocations ( -- ** SettlementFactory template implementations settlementFactoryV2_settleBatchDefaultImpl, + settlementFactoryV2_settleBatchDefaultImplNoSelf, fetchAndValidateAllocations, validateNextIterationArgs, @@ -374,7 +375,18 @@ settlementFactoryV2_settleBatchDefaultImpl -> ContractId AllocationV2.SettlementFactory -> AllocationV2.SettlementFactory_SettleBatch -> Update AllocationV2.SettlementFactory_SettleBatchResult -settlementFactoryV2_settleBatchDefaultImpl getFilteredExtraArgs admin self arg = do +settlementFactoryV2_settleBatchDefaultImpl getFilteredExtraArgs admin self arg = + settlementFactoryV2_settleBatchDefaultImplNoSelf getFilteredExtraArgs admin arg + +settlementFactoryV2_settleBatchDefaultImplNoSelf + : (AllocationV2.AllocationView -> AllocationV2.Allocation_Settle -> Update ExtraArgs) + -- ^ Function to compute the actual extra argument to use for settling an allocation. + -- Use this for example to redact unrelated choice-context data from the extra arguments. + -> Party + -- ^ Admin of the allocations and the settlement factory + -> AllocationV2.SettlementFactory_SettleBatch + -> Update AllocationV2.SettlementFactory_SettleBatchResult +settlementFactoryV2_settleBatchDefaultImplNoSelf getFilteredExtraArgs admin arg = do let AllocationV2.SettlementFactory_SettleBatch {..} = arg checkActors actors [settlement.executors] diff --git a/token-standard/splice-token-standard-v2-test/daml/Splice/Testing/Registries/AmuletRegistryV2.daml b/token-standard/splice-token-standard-v2-test/daml/Splice/Testing/Registries/AmuletRegistryV2.daml index 04a7817fff..92492103db 100644 --- a/token-standard/splice-token-standard-v2-test/daml/Splice/Testing/Registries/AmuletRegistryV2.daml +++ b/token-standard/splice-token-standard-v2-test/daml/Splice/Testing/Registries/AmuletRegistryV2.daml @@ -30,6 +30,10 @@ module Splice.Testing.Registries.AmuletRegistryV2 , getActiveOpenRoundsSorted , advanceToNextRoundChange , convertAllFeaturedAppActivityMarkers + + -- + , getExtAmuletRulesWithDisclosures + , getExternalPartyConfigStateContext ) where import DA.Action (unless, when) From 0f752f121e56b459dfef327f0a2a9767004f3912 Mon Sep 17 00:00:00 2001 From: Cale Gibbard Date: Tue, 28 Jul 2026 16:18:06 +0000 Subject: [PATCH 27/31] Fixes to rebase and update tests --- .../Splice/Scripts/TestAggregateLocks.daml | 335 ++++++++++-------- .../daml/Splice/AggregateLock.daml | 20 +- .../daml/Splice/AmuletAllocationV2.daml | 11 +- 3 files changed, 194 insertions(+), 172 deletions(-) diff --git a/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml b/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml index 717a9e6a02..e46429c7d5 100644 --- a/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml +++ b/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml @@ -1,54 +1,31 @@ {-# LANGUAGE ApplicativeDo #-} -{-# LANGUAGE GeneralizedNewtypeDeriving #-} 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.Amulet + import Splice.AggregateLock -import Splice.AmuletRules -import Splice.Expiry -import Splice.Scripts.Util -import Splice.TokenStandard.Utils qualified as TSU +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.Testing.TokenStandard.RegistryApiV2 -import Splice.Testing.TokenStandard.WalletClientV2 qualified as WalletClientV2 -import Splice.Testing.Registries.AmuletRegistryV2 import Splice.Api.Token.MetadataV1 -import Splice.Api.Token.AllocationV2 -import Splice.Api.Token.AllocationInstructionV2 -import Splice.Api.Token.HoldingV2 -import Splice.AmuletAllocationV2 -import Splice.Testing.Utils - -import Splice.ExternalPartyAmuletRules - -import DA.Assert -import DA.Optional -import DA.Functor -import DA.Foldable (mapA_, sequence_) -import DA.List (head) -import qualified DA.TextMap as TM -import qualified DA.Map as M - 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.Util.Token.Wallet.BatchingUtilityV2 qualified as BatchingUtilityV2 +import Splice.Testing.Utils +import Splice.TokenStandard.Utils qualified as TSU import Splice.TokenStandard.Utils.Internal.Conversions (encodeTime) -newtype AmuletRegistryExtraWithdrawContext = AmuletRegistryExtraWithdrawContext { unExtra : AmuletRegistry } - -instance RegistryApi AmuletRegistryExtraWithdrawContext where - getTransferFactory = getTransferFactory . unExtra - getAllocationFactory = getAllocationFactory . unExtra - getSettlementFactory = getSettlementFactory . unExtra - getAllocation_WithdrawContext = getAllocation_WithdrawContext . unExtra - getAllocation_CancelContext = getAllocation_CancelContext . unExtra - getAllocationInstruction_WithdrawContext = getAllocationInstruction_WithdrawContext . unExtra - getAllocationInstruction_AcceptContext = getAllocationInstruction_AcceptContext . unExtra - getTransferInstruction_AcceptContext = getTransferInstruction_AcceptContext . unExtra - getTransferInstruction_RejectContext = getTransferInstruction_RejectContext . unExtra - getTransferInstruction_WithdrawContext = getTransferInstruction_WithdrawContext . unExtra lockForGovernance : TestEnv -> TM.TextMap Decimal -> Metadata -> Party -> Script AllocationInstructionResult lockForGovernance (TestEnv {..}) amounts meta party = do @@ -57,14 +34,14 @@ lockForGovernance (TestEnv {..}) amounts meta party = do lockSettlementInfo = V2.SettlementInfo with executors = [ instrId.admin ] id = "AggregateLock" - cid = None -- Some $ coerceContractId aggCid + cid = None meta = emptyMetadata lockAllocation = V2.AllocationSpecification with admin = instrId.admin - authorizer = TSU.basicAccount bob + authorizer = TSU.basicAccount party transferLegSides = [] committed = True - nextIterationFunding = Some $ amounts -- TM.fromList [(instrId.id, amount)] + nextIterationFunding = Some amounts settlementDeadline = Some maxComparableTime meta @@ -75,140 +52,184 @@ lockForAggregate te amount lockName = lockForGovernance te (TM.fromList [(te.ins , ("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.empty $ 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) - ] +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 } -testAggregateLockHappyPath : Script () -testAggregateLockHappyPath = do +-- | 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 - let - addAggregateWithdrawContext : Script OpenApiChoiceContext -> Script OpenApiChoiceContext - addAggregateWithdrawContext a = do - baseCtxt <- a - extraContext <- getExternalPartyConfigStateContext registriesEnv.amuletV2 - pure $ baseCtxt <> extraContext - updateApi api = api { ggetAllocation_WithdrawContext = \a -> addAggregateWithdrawContext . api.ggetAllocation_WithdrawContext a } - newRegistries = flip fmap registries $ \reg -> - reg { v2Api = fmap updateApi reg.v2Api } - AmuletRegistryV2.tapFaucet registriesEnv.amuletV2 bob 1800.0 - - {- - aggLock <- submit (actAs [alice, dso]) $ createCmd AggregateLock with - dso - instrumentId = env.instrId.id - vestingSchedule = VestingSchedule_SV - -- Some aggLockDisclosure <- queryDisclosure dso aggLock - -} - - -- Lock some funds for the aggregate - AllocationInstructionResult { output = AllocationInstructionResult_Completed locked } <- lockForAggregate env 1000.0 "alice-supervalidator" bob - Some lockedView <- queryInterfaceContractId bob locked + _ <- 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 filtered = filter (isValidAggregateLockedAllocation dso) $ fromSome . snd <$> allocs - toKeyAndAmount 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.fromListWith (+) $ toKeyAndAmount <$> filtered + 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 1000.0) (Some "alice-supervalidator" `M.lookup` totals) - - WalletClientV2.withdrawAllocationV2 newRegistries bob (locked, lockedView) - - let bobAcct = V2.Account with - owner = Some bob - provider = Some dso - id = "" - - allocs <- WalletClientV2.listAllocationsV2 bobAcct bob - - -- unlockResult <- WalletClientV2.withdrawAllocationV2 newRegistries bob $ head allocs - - pure () - - {- - - now <- getTime - let initialAmountToUnlock = 365.25 - AllocationInstructionResult { output = AllocationInstructionResult_Completed vestingInput } <- lockForVesting initialAmountToUnlock now env bob aggLock - - -- use getSettlementFactory to get the extraArgs and disclosures to call AggregateLock_Unlock - enriched <- getSettlementFactory registriesEnv.amuletV2 $ SettlementFactory_SettleBatch with - settlement = SettlementInfo with - executors = [ dso ] - id = "AggregateLock" - cid = None - meta = emptyMetadata - actors = [ dso ] - extraArgs = emptyExtraArgs - transferLegs = [] - allocations = [] + assertEq (Some 300.0) (Some "charlie-supervalidator" `M.lookup` totals) - submit (actAs bob <> disclose aggLockDisclosure <> discloseMany' enriched.disclosures ) $ exerciseCmd aggLock $ AggregateLock_Unlock with - factoryCid = enriched.factoryCid - lockedCid = locked - withdrawToCid = vestingInput - authorizers = [ bob ] - amount = initialAmountToUnlock - extraArgs = enriched.arg.extraArgs +-- | 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 - allocations <- query @AmuletAllocationV2 bob + AmuletRegistryV2.tapFaucet registriesEnv.amuletV2 bob 1800.0 + AllocationInstructionResult { output = AllocationInstructionResult_Completed locked } <- + lockForAggregate env 1000.0 "alice-supervalidator" bob + Some lockedView <- queryInterfaceContractId bob locked - assert $ length allocations == 2 + _ <- WalletClientV2.withdrawAllocationV2 newRegistries bob (locked, lockedView) + + 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) === 0.0 + fromOptional 0.0 (vesting.allocation.nextIterationFunding >>= TM.lookup instrId.id) === 1000.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 - -- Need to check other than via inspection that the two allocations actually exist with correct values. - -} + AmuletRegistryV2.tapFaucet registriesEnv.amuletV2 bob 1800.0 - -- TODO: Temporary, bad to do this - let vestingLockAllocationCid = head [cid | (cid, a) <- allocations, TM.member "cip-105/vestingLock.initialAmount" a.allocation.meta.values] + vestStart <- getTime + AllocationInstructionResult { output = AllocationInstructionResult_Completed vestingLockCid } <- + lockForVesting initialAmount vestStart env bob + Some vestingLockView <- queryInterfaceContractId bob vestingLockCid - -- 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 <> disclose aggLockDisclosure <> discloseMany' enriched.disclosures ) $ exerciseCmd aggLock $ VestingLock_Withdraw with - factoryCid = enriched.factoryCid - authorizers = [ bob ] - vestingLockCid = toInterfaceContractId vestingLockAllocationCid - extraArgs = enriched.arg.extraArgs + -- Withdrawing immediately fails: nothing has vested yet. + vestingLockView.allocation.admin `submitMustFailWithdraw` (vestingLockCid, vestingLockView) $ newRegistries - -- Time needs to pass to be able to withdraw from the VestingLock + -- After some time passes a proportional amount can be withdrawn. passTime (days 10) + vestingResult1 <- 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 <- 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 + +-- | 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 + +-- | Extract the underlying @AllocationResult@ from a wallet-client batch result. +extractAllocationResult : BatchingUtilityV2.TokenStandardActionResult -> V2.AllocationResult +extractAllocationResult (BatchingUtilityV2.TSAR_AllocationResultV2 r) = r +extractAllocationResult other = + error $ "expected TSAR_AllocationResultV2, got: " <> show other + +-- | 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) - (AllocationResult vestingAllocResult tmHoldingCids _) <- submit (actAs bob <> disclose aggLockDisclosure <> discloseMany' enriched.disclosures ) $ exerciseCmd aggLock $ VestingLock_Withdraw with - factoryCid = enriched.factoryCid - authorizers = [ bob ] - vestingLockCid = toInterfaceContractId vestingLockAllocationCid - extraArgs = enriched.arg.extraArgs - - let holdingCids = fromSome $ TM.lookup env.instrId.id tmHoldingCids - holdings <- queryInterface @Holding bob - -- The holdings would include both locked/unlocked, so we filter - let [(_, (Some unlockedHolding))] = filter (\(hcid, (Some h)) -> isNone h.lock && hcid `elem` holdingCids) holdings - - assertEq unlockedHolding.amount 10.0000000105 - - -- Let's try to withdraw the rest (now we should be past endDate) - passTime (days 365) - - (AllocationResult vestingAllocResult' tmHoldingCids' _) <- submit (actAs bob <> disclose aggLockDisclosure <> discloseMany' enriched.disclosures ) $ exerciseCmd aggLock $ VestingLock_Withdraw with - factoryCid = enriched.factoryCid - authorizers = [ bob ] - vestingLockCid = fromSome $ vestingAllocResult.nextIterationAllocationCid - extraArgs = enriched.arg.extraArgs - - -- We should have no more remaining amount vesting, once the full period has passed - assertEq None $ vestingAllocResult'.nextIterationAllocationCid - - let holdingCids' = fromSome $ TM.lookup env.instrId.id tmHoldingCids' - holdings' <- queryInterface @Holding bob - -- The holdings would include both locked/unlocked, so we filter - let [(_, (Some unlockedHolding'))] = filter (\(hcid, (Some h)) -> isNone h.lock && hcid `elem` holdingCids') holdings' - - assertEq initialAmountToUnlock $ unlockedHolding.amount + unlockedHolding'.amount +testAggregateLockHappyPath : Script () +testAggregateLockHappyPath = do + testAggregateLockTotals + testAggregateLockUnlockCreatesVestingLock + testVestingLockWithdraw diff --git a/daml/splice-amulet/daml/Splice/AggregateLock.daml b/daml/splice-amulet/daml/Splice/AggregateLock.daml index 8943cac5af..e779c3fa08 100644 --- a/daml/splice-amulet/daml/Splice/AggregateLock.daml +++ b/daml/splice-amulet/daml/Splice/AggregateLock.daml @@ -35,7 +35,7 @@ isValidVestingLockedDestination dso alloc = , alloc.allocation.committed , null alloc.allocation.transferLegSides , TM.lookup "cip-105/type" alloc.allocation.meta.values == Some "vestingLock" - , alloc.expiresAt == Some maxTime + , alloc.expiresAt == Some maxComparableTime , TM.member "cip-105/vestingLock.startDate" metaValues , TM.member "cip-105/vestingLock.endDate" metaValues ] @@ -137,7 +137,7 @@ aggregateLockUnlock : (HasToInterface a V2.Allocation) => (a -> Time -> Metadata aggregateLockUnlock newEmptyAllocation a aCid arg = do let alloc = view $ toInterface @V2.Allocation a allocCid = toInterfaceContractId @V2.Allocation aCid - + (instrumentId, currentLockedAmount) <- case TM.toList <$> alloc.allocation.nextIterationFunding of Some [a] -> pure a _ -> assertFail "The requirement 'Must reserve a single amount of a single token' was not met" @@ -151,24 +151,24 @@ aggregateLockUnlock newEmptyAllocation a aCid arg = do let GovernanceLockedControllers{..} = governanceLockedControllersFromMeta alloc.meta ownerParty checkControllerSet arg.actors unlockControllerSets - + require "Must be a valid governance-locked allocation" $ isValidAggregateLockedAllocation dso alloc now <- getTime let endTime = getEndTimeFromVestingSchedule vestingSchedule now transferLegId = "unlock" - + let alternateAmount = TM.lookup "cip-105/withdraw-amount" arg.extraArgs.meta.values >>= parseDecimal amount = fromOptional currentLockedAmount alternateAmount newLockedAmount = currentLockedAmount - amount - + -- Not easy to call directly into amulet_allocationFactoryV2_allocateImpl or the like here as that would make a circular dependency. withdrawTo <- newEmptyAllocation a now $ Metadata $ TM.fromList [ ("cip-105/type", "vestingLock") , ("cip-105/vestingLock.startDate", encodeTime $ now) , ("cip-105/vestingLock.endDate", encodeTime $ endTime) ] - + -- settlementFactoryV2_settleBatchDefaultImpl does not use it's third argument, so we pass undefined rather than require a contract ID for ExternalPartyAmuletRules. settleBatchResult <- settlementFactoryV2_settleBatchDefaultImplNoSelf (\_ _ -> pure arg.extraArgs) dso $ SettlementFactory_SettleBatch with settlement = alloc.settlement @@ -220,8 +220,8 @@ vestingLockWithdraw a aCid arg = do let vestingLock = view $ toInterface @V2.Allocation a vestingLockCid = toInterfaceContractId @V2.Allocation aCid now <- getTime - - require "Must be a valid vesting unlock allocation" $ isValidVestingLockedDestination dso vestingLock + + require "Must be a valid vesting unlock allocation" $ isValidVestingLockedDestination vestingLock.allocation.admin vestingLock require "There must be an initialAmount set for the vestingLock" $ TM.member "cip-105/vestingLock.initialAmount" vestingLock.allocation.meta.values @@ -297,8 +297,8 @@ vestingLockWithdraw a aCid arg = do nextIterationFunding ] - -- We should have one result from settleBatch - case batchResults.allocationSettleResults of + -- We should have one result from settleBatch + case settleResult.allocationSettleResults of [allocationResult] -> pure allocationResult _ -> abort "VestingUnlock_Withdraw should result in one allocation result. " diff --git a/daml/splice-amulet/daml/Splice/AmuletAllocationV2.daml b/daml/splice-amulet/daml/Splice/AmuletAllocationV2.daml index 806dbd054b..aefff68139 100644 --- a/daml/splice-amulet/daml/Splice/AmuletAllocationV2.daml +++ b/daml/splice-amulet/daml/Splice/AmuletAllocationV2.daml @@ -235,11 +235,12 @@ computeAllocationExpiryInternal transferConfig oldExpiresAt settlementDeadline = -- FIXME: the exception to allow Some maxTime is for infinite-duration governance locks, and should probably be limited more explicitly to only those cases. computeAllocationExpiry : TransferConfigV2 Amulet -> Time -> Optional Time -> Update Time -computeAllocationExpiry transferConfig oldExpiresAt settlementDeadline | settlementDeadline == Some maxComparableTime = pure maxComparableTime -computeAllocationExpiry transferConfig oldExpiresAt settlementDeadline = do - let expiresAt = computeAllocationExpiryInternal transferConfig oldExpiresAt settlementDeadline - assertWithinDeadline "allocation.expiresAt" expiresAt - pure expiresAt +computeAllocationExpiry transferConfig oldExpiresAt settlementDeadline + | settlementDeadline == Some maxComparableTime = pure maxComparableTime + | otherwise = do + let expiresAt = computeAllocationExpiryInternal transferConfig oldExpiresAt settlementDeadline + assertWithinDeadline "allocation.expiresAt" expiresAt + pure expiresAt isGovernanceLocked alloc = TextMap.member "cip-105/type" alloc.allocation.meta.values From f312c8a8d3a98796e6ac22ca5b49762847730750 Mon Sep 17 00:00:00 2001 From: "Jonathan D.K. Gibbons" Date: Tue, 28 Jul 2026 17:35:57 +0000 Subject: [PATCH 28/31] Fix tests and a couple bugs in vesting withdraw. --- .../daml/Splice/Scripts/TestAggregateLocks.daml | 17 ++--------------- .../daml/Splice/AggregateLock.daml | 9 ++------- .../Testing/TokenStandard/WalletClientV2.daml | 7 +++++++ 3 files changed, 11 insertions(+), 22 deletions(-) diff --git a/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml b/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml index e46429c7d5..effb4c0831 100644 --- a/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml +++ b/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml @@ -21,7 +21,6 @@ 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.Util.Token.Wallet.BatchingUtilityV2 qualified as BatchingUtilityV2 import Splice.Testing.Utils import Splice.TokenStandard.Utils qualified as TSU import Splice.TokenStandard.Utils.Internal.Conversions (encodeTime) @@ -168,7 +167,7 @@ testVestingLockWithdraw = do -- After some time passes a proportional amount can be withdrawn. passTime (days 10) - vestingResult1 <- extractAllocationResult <$> + vestingResult1 <- WalletClientV2.extractAllocationResult <$> WalletClientV2.withdrawAllocationV2 newRegistries bob (vestingLockCid, vestingLockView) let holdingCids1 = fromSome $ TM.lookup instrId.id vestingResult1.authorizerHoldingCids @@ -182,7 +181,7 @@ testVestingLockWithdraw = do -- After the full period has elapsed the remainder becomes withdrawable. passTime (days 365) Some nextView <- queryInterfaceContractId bob nextCid - vestingResult2 <- extractAllocationResult <$> + vestingResult2 <- WalletClientV2.extractAllocationResult <$> WalletClientV2.withdrawAllocationV2 newRegistries bob (nextCid, nextView) let holdingCids2 = fromSome $ TM.lookup instrId.id vestingResult2.authorizerHoldingCids @@ -210,12 +209,6 @@ submitMustFailWithdraw admin (allocCid, _allocView) registries = do context = context.choiceContext meta = emptyMetadata --- | Extract the underlying @AllocationResult@ from a wallet-client batch result. -extractAllocationResult : BatchingUtilityV2.TokenStandardActionResult -> V2.AllocationResult -extractAllocationResult (BatchingUtilityV2.TSAR_AllocationResultV2 r) = r -extractAllocationResult other = - error $ "expected TSAR_AllocationResultV2, got: " <> show other - -- | Sum the amounts of the given (unlocked) holding contracts owned by @p@. unlockedAmountOf : Party -> [ContractId V2.Holding] -> Script Decimal unlockedAmountOf p wantedCids = do @@ -227,9 +220,3 @@ unlockedAmountOf p wantedCids = do , isNone h.lock ] pure $ sum (fmap (.amount) matching) - -testAggregateLockHappyPath : Script () -testAggregateLockHappyPath = do - testAggregateLockTotals - testAggregateLockUnlockCreatesVestingLock - testVestingLockWithdraw diff --git a/daml/splice-amulet/daml/Splice/AggregateLock.daml b/daml/splice-amulet/daml/Splice/AggregateLock.daml index e779c3fa08..43f2807b5f 100644 --- a/daml/splice-amulet/daml/Splice/AggregateLock.daml +++ b/daml/splice-amulet/daml/Splice/AggregateLock.daml @@ -252,18 +252,13 @@ vestingLockWithdraw a aCid arg = do $ availableWithdrawAmount > 0.0 let - info = SettlementInfo with - executors = [ vestingLock.allocation.admin ] - id = "VestingLock_Withdraw" - cid = Some $ coerceContractId vestingLockCid - meta = emptyMetadata transferLegId = "withdraw" nextIterationFunding | remainingVestingAmount <= 0.0 = None | otherwise = Some $ TM.singleton instrumentId remainingVestingAmount settleResult <- settlementFactoryV2_settleBatchDefaultImplNoSelf (\_ _ -> pure arg.extraArgs) vestingLock.allocation.admin $ SettlementFactory_SettleBatch with - settlement = info + settlement = vestingLock.settlement actors = [ vestingLock.allocation.admin ] extraArgs = ExtraArgs emptyChoiceContext emptyMetadata transferLegs = @@ -283,7 +278,7 @@ vestingLockWithdraw a aCid arg = do transferLegId side = SenderSide otherside = vestingLock.allocation.authorizer - amount = remainingVestingAmount + amount = availableWithdrawAmount instrumentId meta = emptyMetadata , TransferLegSide with diff --git a/token-standard/splice-token-standard-v2-test/daml/Splice/Testing/TokenStandard/WalletClientV2.daml b/token-standard/splice-token-standard-v2-test/daml/Splice/Testing/TokenStandard/WalletClientV2.daml index 1327fc43e7..811786435e 100644 --- a/token-standard/splice-token-standard-v2-test/daml/Splice/Testing/TokenStandard/WalletClientV2.daml +++ b/token-standard/splice-token-standard-v2-test/daml/Splice/Testing/TokenStandard/WalletClientV2.daml @@ -73,6 +73,7 @@ module Splice.Testing.TokenStandard.WalletClientV2 -- ** Allocations extractNextIterationAllocationCid, + extractAllocationResult, mkAllocationFactory_AllocateV2, mkAllocationInstruction_AcceptV2, @@ -469,6 +470,12 @@ withdrawAllocationV2 registries actor alloc = do batch <- mkAllocation_WithdrawV2 registries actor alloc executeSingletonTSABatch registries actor batch +-- | Extract the underlying @AllocationResult@ from a wallet-client batch result. +extractAllocationResult : BatchingUtilityV2.TokenStandardActionResult -> V2.AllocationResult +extractAllocationResult (BatchingUtilityV2.TSAR_AllocationResultV2 r) = r +extractAllocationResult other = + error $ "expected TSAR_AllocationResultV2, got: " <> show other + -- | Simulate a V2 wallet withdrawing an allocation from a V1 app. withdrawAllocationV1 : MultiRegistry.MultiRegistry -> Party -> (ContractId V1.Allocation, V1.AllocationView) From d601e0ebd6c25a4a52b6cdd922c4f363945074f0 Mon Sep 17 00:00:00 2001 From: "Jonathan D.K. Gibbons" Date: Tue, 28 Jul 2026 20:21:53 +0000 Subject: [PATCH 29/31] Fixes and test updates. --- .../Splice/Scripts/TestAggregateLocks.daml | 73 ++++++++++++++++++- .../daml/Splice/AggregateLock.daml | 15 ++-- .../Testing/TokenStandard/WalletClientV2.daml | 16 +++- 3 files changed, 90 insertions(+), 14 deletions(-) diff --git a/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml b/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml index effb4c0831..43fc641f00 100644 --- a/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml +++ b/daml/splice-amulet-test/daml/Splice/Scripts/TestAggregateLocks.daml @@ -85,8 +85,10 @@ 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 @@ -100,7 +102,7 @@ testAggregateLockTotals = do ) totals = M.fromListWithR (+) $ benefitOfAmount <$> aggregateLocks - assertEq (Some 1000.0) (Some "alice-supervalidator" `M.lookup` totals) + 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 @@ -119,19 +121,55 @@ testAggregateLockUnlockCreatesVestingLock = do _ <- 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) === 0.0 - fromOptional 0.0 (vesting.allocation.nextIterationFunding >>= TM.lookup instrId.id) === 1000.0 + 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 @@ -190,6 +228,35 @@ testVestingLockWithdraw = do 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 diff --git a/daml/splice-amulet/daml/Splice/AggregateLock.daml b/daml/splice-amulet/daml/Splice/AggregateLock.daml index 43f2807b5f..c9e47ee31e 100644 --- a/daml/splice-amulet/daml/Splice/AggregateLock.daml +++ b/daml/splice-amulet/daml/Splice/AggregateLock.daml @@ -146,7 +146,7 @@ aggregateLockUnlock newEmptyAllocation a aCid arg = do let dso = alloc.allocation.admin - ownerParty <- whenNone alloc.allocation.authorizer.owner $ + ownerParty <- whenNone alloc.allocation.authorizer.owner $ -- Mostly to safely get ownerParty assertFail "The requirement 'governance locked allocations must be owned by basic accounts' was not met" let GovernanceLockedControllers{..} = governanceLockedControllersFromMeta alloc.meta ownerParty @@ -161,6 +161,9 @@ aggregateLockUnlock newEmptyAllocation a aCid arg = do let alternateAmount = TM.lookup "cip-105/withdraw-amount" arg.extraArgs.meta.values >>= parseDecimal amount = fromOptional currentLockedAmount alternateAmount newLockedAmount = currentLockedAmount - amount + newLockedNextIterationFunding + | newLockedAmount == 0.0 = None + | otherwise = Some $ TM.singleton instrumentId newLockedAmount -- Not easy to call directly into amulet_allocationFactoryV2_allocateImpl or the like here as that would make a circular dependency. withdrawTo <- newEmptyAllocation a now $ Metadata $ TM.fromList @@ -169,7 +172,6 @@ aggregateLockUnlock newEmptyAllocation a aCid arg = do , ("cip-105/vestingLock.endDate", encodeTime $ endTime) ] - -- settlementFactoryV2_settleBatchDefaultImpl does not use it's third argument, so we pass undefined rather than require a contract ID for ExternalPartyAmuletRules. settleBatchResult <- settlementFactoryV2_settleBatchDefaultImplNoSelf (\_ _ -> pure arg.extraArgs) dso $ SettlementFactory_SettleBatch with settlement = alloc.settlement actors = [ dso ] @@ -195,7 +197,7 @@ aggregateLockUnlock newEmptyAllocation a aCid arg = do instrumentId meta = emptyMetadata ] - nextIterationFunding = Some $ TM.singleton instrumentId newLockedAmount + nextIterationFunding = newLockedNextIterationFunding , FinalizedAllocation with allocationCid = withdrawTo extraTransferLegSides = @@ -214,7 +216,6 @@ aggregateLockUnlock newEmptyAllocation a aCid arg = do getEndTimeFromVestingSchedule VestingSchedule_SV now = addRelTime now $ days 365 + hours 6 getEndTimeFromVestingSchedule VestingSchedule_Immediate now = now --- vestingLockWithdraw : V2.AllocationView -> ContractId V2.Allocation -> Allocation_Withdraw -> Update V2.AllocationResult vestingLockWithdraw : (HasToInterface a V2.Allocation) => a -> ContractId a -> Allocation_Withdraw -> Update V2.AllocationResult vestingLockWithdraw a aCid arg = do let vestingLock = view $ toInterface @V2.Allocation a @@ -225,16 +226,12 @@ vestingLockWithdraw a aCid arg = do require "There must be an initialAmount set for the vestingLock" $ TM.member "cip-105/vestingLock.initialAmount" vestingLock.allocation.meta.values - ownerParty <- whenNone vestingLock.allocation.authorizer.owner $ + ownerParty <- whenNone vestingLock.allocation.authorizer.owner $ -- Mostly to safely get ownerParty assertFail "The requirement 'governance locked allocations must be owned by basic accounts' was not met" let GovernanceLockedControllers{..} = governanceLockedControllersFromMeta vestingLock.meta ownerParty checkControllerSet arg.actors withdrawControllerSets - -- TODO: Potentially do case statement with nextIterationFunding optionals - require "There are remaining funds to withdraw" - $ isSome vestingLock.allocation.nextIterationFunding - (instrumentId, nextIterationFunding) <- case TM.toList <$> vestingLock.allocation.nextIterationFunding of Some [a] -> pure a _ -> assertFail "The requirement 'Must reserve a single amount of a single token' was not met" diff --git a/token-standard/splice-token-standard-v2-test/daml/Splice/Testing/TokenStandard/WalletClientV2.daml b/token-standard/splice-token-standard-v2-test/daml/Splice/Testing/TokenStandard/WalletClientV2.daml index 811786435e..d15c0d0010 100644 --- a/token-standard/splice-token-standard-v2-test/daml/Splice/Testing/TokenStandard/WalletClientV2.daml +++ b/token-standard/splice-token-standard-v2-test/daml/Splice/Testing/TokenStandard/WalletClientV2.daml @@ -55,6 +55,7 @@ module Splice.Testing.TokenStandard.WalletClientV2 acceptAllocationInstructionV2, withdrawAllocationInstructionV2, withdrawAllocationV2, + withdrawAllocationV2Meta, allocateV1, withdrawAllocationInstructionV1, @@ -470,6 +471,14 @@ withdrawAllocationV2 registries actor alloc = do batch <- mkAllocation_WithdrawV2 registries actor alloc executeSingletonTSABatch registries actor batch +-- | Simulate a V2 wallet withdrawing an allocation from a V2 app, with extra metadata +withdrawAllocationV2Meta + : MultiRegistry.MultiRegistry -> Party -> (ContractId V2.Allocation, V2.AllocationView) -> Metadata + -> Script BatchingUtilityV2.TokenStandardActionResult +withdrawAllocationV2Meta registries actor alloc meta = do + batch <- mkAllocation_WithdrawV2Meta registries actor alloc meta + executeSingletonTSABatch registries actor batch + -- | Extract the underlying @AllocationResult@ from a wallet-client batch result. extractAllocationResult : BatchingUtilityV2.TokenStandardActionResult -> V2.AllocationResult extractAllocationResult (BatchingUtilityV2.TSAR_AllocationResultV2 r) = r @@ -819,7 +828,10 @@ mkAllocationFactory_AllocateV2 registryV2 actor settlement allocation = do disclosures = enrichedChoice.disclosures mkAllocation_WithdrawV2 : MultiRegistry.MultiRegistry -> Party -> (ContractId V2.Allocation, V2.AllocationView) -> Script TSABatch -mkAllocation_WithdrawV2 registries actor (allocCid, allocView) = do +mkAllocation_WithdrawV2 registries actor (allocCid, allocView) = mkAllocation_WithdrawV2Meta registries actor (allocCid, allocView) emptyMetadata + +mkAllocation_WithdrawV2Meta : MultiRegistry.MultiRegistry -> Party -> (ContractId V2.Allocation, V2.AllocationView) -> Metadata -> Script TSABatch +mkAllocation_WithdrawV2Meta registries actor (allocCid, allocView) meta = do registry <- MultiRegistry.getRegistryApiV2 registries allocView.allocation.admin context <- V2.getAllocation_WithdrawContext registry allocCid emptyMetadata let withdrawRequest = BatchingUtilityV2.TSA_Allocation_WithdrawV2 BatchingUtilityV2.ChoiceCall with @@ -827,7 +839,7 @@ mkAllocation_WithdrawV2 registries actor (allocCid, allocView) = do arg = V2.Allocation_Withdraw with extraArgs = ExtraArgs with context = context.choiceContext - meta = emptyMetadata + meta actors = [actor] pure TSABatch with actions = [withdrawRequest] From 6dd81b319fc1dc2c71a89e0381e432bc5c94af71 Mon Sep 17 00:00:00 2001 From: "Jonathan D.K. Gibbons" Date: Wed, 29 Jul 2026 13:29:27 +0000 Subject: [PATCH 30/31] Check executors in isValid checks. --- daml/splice-amulet/daml/Splice/AggregateLock.daml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/daml/splice-amulet/daml/Splice/AggregateLock.daml b/daml/splice-amulet/daml/Splice/AggregateLock.daml index c9e47ee31e..daae8931c4 100644 --- a/daml/splice-amulet/daml/Splice/AggregateLock.daml +++ b/daml/splice-amulet/daml/Splice/AggregateLock.daml @@ -23,6 +23,7 @@ maxComparableTime = addRelTime maxTime $ microseconds (-1) isValidAggregateLockedAllocation : Party -> V2.AllocationView -> Bool isValidAggregateLockedAllocation dso alloc = and [ alloc.allocation.admin == dso + , alloc.settlement.executors == [dso] , alloc.allocation.committed , null alloc.allocation.transferLegSides , TM.lookup "cip-105/type" alloc.allocation.meta.values == Some "aggregateLock" @@ -32,6 +33,7 @@ isValidAggregateLockedAllocation dso alloc = isValidVestingLockedDestination : Party -> V2.AllocationView -> Bool isValidVestingLockedDestination dso alloc = and [ alloc.allocation.admin == dso + , alloc.settlement.executors == [dso] , alloc.allocation.committed , null alloc.allocation.transferLegSides , TM.lookup "cip-105/type" alloc.allocation.meta.values == Some "vestingLock" From 7d5a0eaa1d8dbbce7d396e498ceb99cc8411a7b0 Mon Sep 17 00:00:00 2001 From: "Jonathan D.K. Gibbons" Date: Wed, 29 Jul 2026 13:34:15 +0000 Subject: [PATCH 31/31] Remove commented out AggregateLock template. --- daml/splice-amulet/daml/Splice/AggregateLock.daml | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/daml/splice-amulet/daml/Splice/AggregateLock.daml b/daml/splice-amulet/daml/Splice/AggregateLock.daml index daae8931c4..b6bf85fd49 100644 --- a/daml/splice-amulet/daml/Splice/AggregateLock.daml +++ b/daml/splice-amulet/daml/Splice/AggregateLock.daml @@ -119,21 +119,6 @@ calculateAvailableWithdrawAmount currentDateTime alloc nextIterationFundAmount = totalVestedRatioElapsed : Decimal = unlockPeriodElapsed / totalUnlockPeriod withdrawnAmount = initialAmount - nextIterationFundAmount --- | The intention is to treat the overall aggregate lock for a SV as if it is --- "the settlement" for the allocations locked to it, and use iterated --- settlement to execute updates on the committed allocations as needed. --- This approach would permit most of the specific governance locking and --- vesting to be in a separate dar from amulet only needed for SVs and --- interested observers. - -{-template AggregateLock - with - dso: Party - instrumentId : Text - vestingSchedule : VestingSchedule - where - signatory dso --} aggregateLockUnlock : (HasToInterface a V2.Allocation) => (a -> Time -> Metadata -> Update (ContractId V2.Allocation)) -> a -> ContractId a -> Allocation_Withdraw -> Update V2.AllocationResult aggregateLockUnlock newEmptyAllocation a aCid arg = do