I have not been able to find reference to this in the docs, so sorry if I missed something:
Is there a concept of something like internal templates? Where a template can only be used by another template (such as part of a choice)? If this does not exist, I am not saying it should exist, but i was considering the list of templates in the the navigator UI and in Daml Hub: where we list all templates, but not all are “public”: such as the concept of Public APIs / Interfaces vs the internal code.
Thanks
There is no such concept at the moment. It comes up occasionally and I do think something along those lines is sensible but so far it hasn’t been prioritized.
@cocreature how do users restrict the usage of a template if there is no concept of “internal”? If you had a flow such as Invite > Participant: and your invite consumed into a Participant, how do you prevent the Participant from being created directly (and only created through a choice in an invite) ?
You cannot prevent contracts from being created directly. However, you can prevent them from being created with your signature (by not providing your authority for that). Generally, I recommend to switch from “all contracts of template T” to “all contracts of template T signed by parties X”. A Daml ledger is an open system. Parties can add contracts that only affect them as they want to.
1 Like
@cocreature so based on that design, the signatory becomes a rippling change in the ledge:
An Expense Pool + Invites to join the pool. The original invites are created/signatory by a choice in the pool by the owner of the pool (the signatory of the invite). Now the owner of the pool changes, but there are still 100 invites that are active/open for that pool. From the description you provided, my understanding is: A change of the owner would have to propagate updates of the 100 invite contracts to recreate new versions of the contract with the new owner/signatory. Is that correct? Is that the recommended design?
Yes, in that design (and that’s the one I recommend) ownership changes become a bit trickier because you need to update other contracts as well. The details of how to best handle that depend on your specific design. In some cases it may make sense that as part of the ownership transfer you don’t directly modify the other contracts but you delegate authority to the new owner to change the owners of the other contracts.
But in either case, it means all 100 contracts must be updated.
If it was done with delegation it would 100 delegation wrappers + 100 invite contract updates?
No, you could create one contract with a non-consuming choice for the delegation. And you don’t need to do the invite upgrades immediately. You can often find designs where you update them lazily as they are used.
Is there a working example of this delegation?
Would this not be simpler to have secondary parties that were like “super owners” that the IAM gives actAs permissions to the actual “Pool” owner? Such as a party that represented the “pool”
I don’t have an example repo but something like this (untested)
template Invite
with
owner : Party
invitee : Party
where
signatory owner
template DelegateOwnershipChange
with
previousOwner : Party
newOwner : Party
where
signatory previousOwner, newOwner
nonconsuming choice ChangeOwner : ContractId Invite
with
cid : ContractId Invite
controller newOwner
do invite <- fetch cid
invite.owner == oldOwner
create invite with owner = newOwner
Would this not be simpler to have secondary parties that were like “super owners” that the IAM gives actAs permissions to the actual “Pool” owner? Such as a party that represented the “pool”
That’s another option. Keep in mind thought that the IAM is per participant so to make this work you need to potentially move a party across participants (there is functionality for that in Canton in early access).
You can also have a delegation design where you have one party as the owner and then you delegate via the Daml model the rights to act as that owner to another party.
I feel like i am missing a detail in your example:
Based on what you wrote, what i see is: If there were 100 invites, your delegatingOwnership would be used to generate 100 delegating invites, which then generate 100 new invites / replacing the old invites. Which would still be 200+ transactions for a ownership change, no?
You can also have a delegation design where you have one party as the owner and then you delegate via the Daml model the rights to act as that owner to another party.
Is there a working example of this? Where delegation does not mean placing the original contract in “limbo” / wrapped in the delegating contract?
I am looking at this: https://github.com/StephenOTT/daml-quick-start/blob/1206d7cd0a683c0a3f633cce318f018d7f54084a/ExpensePool/daml/ExpensePoolWithState.daml
There is nonconsuming invites, and the invite need would need to still be active after a change to the pool (such changing the name of the pool)
Even with delegation is seems to just generate more and more contract updates
Here is a detailed example with Expense Pool + Invites.
The only way i have thought of to restrict creation is using signatory (“works in the tests”/does not throw errors):
This checks if the invite being processed is from the same pool and if the invite was created by the same pool owner.
But this example would still require Invite contract updates with a new pool owner issuing new invites/replacement invites.
in either case, it seems like unless there is another functionality: the design pattern becomes: live with the potential of mass contract updates or use the IAM and “role-parties”/“group-parties” (or whatever you want to call them) which would act as a super owner that real parties could actAs for admin-like functions on a ~“master-contract”.
module ExpensePoolWithState where
import Daml.Script
import DA.Set (Set)
import DA.Set qualified as Set
type PoolKey = (Party, Text)
type InviteKey = (PoolKey, Party)
template Pool
with
poolId: Text
owner: Party
name: Text
participants: Set Party
invitedParticipants: Set Party
where
signatory owner
observer participants, invitedParticipants
ensure do
-- Cannot have overlap between the two lists:
Set.null(Set.intersection participants invitedParticipants)
key(owner, poolId): PoolKey
maintainer key._1
choice InviteNewParticipant : (ContractId Pool, ContractId PoolInvite)
with
newParticipant: Party
controller owner
do
newPool <- create this -- recreate the pool with the invited participant : This is required because of Observers being able to Accept the Invite.
with
owner
name
participants
invitedParticipants = Set.insert newParticipant invitedParticipants
invite <- create PoolInvite with
poolKey = key(this)
newParticipant
return (newPool, invite)
choice InviteAccept : ContractId Pool
with
invite: ContractId PoolInvite
controller invitedParticipants
do
-- SEE HERE:
i <- fetch @PoolInvite invite
assertMsg ("Creator of invite must be current pool owner") (signatory(i) == [owner])
assertMsg "Pool Key does not match" (i.poolKey == key(this)) -- Assumes that if the owner changes than existing invites become invalid. This line could be removed if you dont care about owner changes.
assertMsg "New Participant is not part of invited participants." (Set.member i.newParticipant invitedParticipants)
create this
with
poolId
owner
name
participants = Set.insert i.newParticipant participants
invitedParticipants = Set.delete i.newParticipant invitedParticipants
choice RemoveParticipant : ContractId Pool
with
participantToRemove: Party
controller owner
do
assertMsg "Participant you want to remove is not in the list of participants for this pool"
(Set.member participantToRemove participants)
create this with
poolId
owner
name
participants = Set.delete participantToRemove participants
invitedParticipants
choice RemoveInvitedParticipant : ContractId Pool
with
participantToRemove: Party
controller owner
do
assertMsg "Participant you want to remove is not in the list of participants for this pool"
(Set.member participantToRemove invitedParticipants)
-- Generates the invite Key + archives the invite: essentially revoking the invite
let inviteKey = (key(this), participantToRemove):InviteKey
invite <- fetchByKey @PoolInvite inviteKey
exerciseByKey @PoolInvite inviteKey Archive
-- recreates the Pool with the invitee removed
create this with
poolId
owner
name
participants
invitedParticipants = Set.delete participantToRemove invitedParticipants
template PoolInvite
with
poolKey: PoolKey -- Use the pool key to find the pool in the future (such as if changes to the pool occur)
newParticipant: Party
where
signatory poolKey._1 -- The pool owner is the creator of the invite
observer newParticipant
key(poolKey, newParticipant): InviteKey
maintainer key._1._1 -- Pool owner
postconsuming choice AcceptInvite : ContractId Pool
controller newParticipant
do
-- This works because the invitee is part of the invitedParticiants Set in the Pool, which is an observer of the pool.
exerciseByKey @Pool poolKey InviteAccept with
invite = self
normalFlow = script do
alice <- allocateParty "Alice"
bob <- allocateParty "Bob"
-- Create a pool:
emptyPool1Cid <- submit alice do
createCmd Pool with
poolId = "pool-1234"
owner = alice
name = "Pool1"
participants = Set.empty
invitedParticipants = Set.empty
-- Invite Bob to the Pool:
(updatedPool1Cid, invite1Cid) <- submit alice do
exerciseCmd emptyPool1Cid InviteNewParticipant with
newParticipant = bob
Some _updatedPool <- queryContractId alice updatedPool1Cid
assert(Set.member bob _updatedPool.invitedParticipants)
-- Revoke Bob's invite:
emptyPool2Cid <- submit alice do
exerciseCmd updatedPool1Cid RemoveInvitedParticipant with
participantToRemove = bob
Some _updatedPool <- queryContractId alice emptyPool2Cid
assert(Set.notMember bob _updatedPool.invitedParticipants)
-- Re-Invite Bob to the Pool:
(updatedPool2Cid, invite2Cid) <- submit alice do
exerciseCmd emptyPool2Cid InviteNewParticipant with
newParticipant = bob
Some _updatedPool <- queryContractId alice updatedPool2Cid
assert(Set.member bob _updatedPool.invitedParticipants)
-- Bob accepts invite:
updatedPool3Cid <- submit bob do
exerciseCmd invite2Cid AcceptInvite
Some _updatedPool <- queryContractId alice updatedPool3Cid
assert(Set.member bob _updatedPool.participants)
assert(Set.notMember bob _updatedPool.invitedParticipants)
-- Remove Bob as a participant:
emptyPool3Cid <- submit alice do
exerciseCmd updatedPool3Cid RemoveParticipant with
participantToRemove = bob
Some _updatedPool <- queryContractId alice emptyPool3Cid
assert(Set.notMember bob _updatedPool.participants)
-- Re-Invite Bob to the Pool:
(updatedPool4Cid, invite3Cid) <- submit alice do
exerciseCmd emptyPool3Cid InviteNewParticipant with
newParticipant = bob
Some _updatedPool <- queryContractId alice updatedPool4Cid
assert(Set.member bob _updatedPool.invitedParticipants)
-- Bob accepts invite:
updatedPool5Cid <- submit bob do
exerciseCmd invite3Cid AcceptInvite
-- Final pool should have bob as a participant
Some finalPool <- queryContractId bob updatedPool5Cid
assert(Set.member bob finalPool.participants)
assert(Set.notMember bob finalPool.invitedParticipants)
assert(Set.null finalPool.invitedParticipants)
Let’s take a step back and look at the different options and the tradeoffs:
- Don’t make the owner a signatory. As you noticed yourself, this allows anyone to create invites. In some cases, that may be acceptable but usually this is not a workable model.
- Make the owner a signatory and solve ownership transfer by changing the owner party. There is no way around archiving & recreating the contracts in that case if you want to promptly change the owner in all contracts. However, at least in some cases you can do something more clever: In your example, one option would be that you keep the invites untouched but on
InviteAccept
you accept invites that still have the old owners.
- Make the owner a signatory and solve ownership transfer at the IAM level. This works if you have a relatively closed system where both parties are hosted on the same participant so they share the IAM. It becomes fairly difficult to manage if parties are hosted on different participants though.
- Make the owner a signatory and solve ownership by delegation. In that model, you have a super owner that never changes. That party delegates rights to the current owner to act on their behalf, e.g., to accept invites. There you don’t need to rewrite anything and it works regardless of how parties are distributed across participants.
A simple sketch for delegating would look something like this (only modelling one choice but the other ones follow the same pattern)
template OwnerDelegation
with
poolOwner : Party
delegate : Party
where
signatory poolOwner, delegate
nonconsuming choice InviteNewParticipant : (ContractId Pool, ContractId PoolInvite)
with
cid : ContractId Pool
newParticipant : Party
controller delegate
do exercise cid (InviteNewParticipant newParticipant)
4 is the most flexible option, 2 and 3 may be a bit simpler in some cases if you’re happy to accept the limitations.
@cocreature thank you very much for the details! In you delegation draft, if the delegate is not a stakeholder of the cid, what is giving them the right to run ‘exercise cid (InviteNewParticipant newParticipant)’ ? Is it the double signatory?
(I am assuming the controller of the choice in the pool is ‘controller newParticupant’, but they would not have visibility of the contract so cannot exercise)
So there are two parts here:
Authorization & visibility.
The delegate has the authorization because within the choice body you have authorization from signatories & controllers.
Visibility is trickier. Ignoring divulgence, the reading parties (the union of actAs & readAs) must be a stakeholder on each contract used in the submision. One option would be that the owner provides readAs rights to the delegate. The other option ofc is that they are an observer although if you want to do that on each invitation as well you’re just back to having to modify all contracts. We are currently working on a new feature which would allow the owner to disclose the contracts to the delegate without requiring modification of the contracts to make this type of stuff easier.