Purpose
In this post, I discuss how to run a single Daml script such that it can submit as different parties against multiple participant nodes.
I do this with:
- the
--participant-config
parameter available todaml script
- the
utils.generate_daml_script_participant_config()
Canton function - the
listKnownPartiesOn
Daml Script function
Background
Let’s work with a simple, multi-party workflow: a request → bid → accept workflow between a home owner and painter.
Daml model
module Main where
template BidRequest
with
owner : Party
painter : Party
where
signatory owner
observer painter
choice Propose : ContractId Bid
with amount : Decimal
controller painter
do create Bid with request = this, ..
template Bid
with
request : BidRequest
amount : Decimal
where
signatory request.owner, request.painter
choice Accept : ContractId PaintHouse
controller request.owner
do create PaintHouse with acceptedBid = this
template PaintHouse
with
acceptedBid : Bid
where
signatory (signatory acceptedBid)
The happy path for this workflow could be exercised with Daml Script as follows:
happy_path : Script ()
happy_path = script do
alice <- allocateParty "alice"
bob <- allocateParty "bob"
debug "Alice creating a bid request..."
cidBidRequest <- submit alice do
createCmd BidRequest with owner = alice, painter = bob
debug "Bob submitting a proposal..."
cidBid <- submit bob do
exerciseCmd cidBidRequest Propose with amount = 1000.0
debug "Alice accepting the proposal..."
cidPaintHouse <- submit alice do
exerciseCmd cidBid Accept
pure ()
In Daml Studio, you see a nice, gray “Script results” link above the function. Clicking the “Script results” link brings up the table and transaction views of the ledger in Daml Studio. In this case, the script is running against a special, single-participant ledger for the IDE.
This article addresses the question:
How does one run a script like the above against a multi-participant network with Alice hosted on one participant and Bob hosted on another?
1. Participant Config File
The daml script
command has an optional --participant-config <file name>
argument.
The participant config file lists the participants that the Daml Script can send commands to. It also maps a party to its hosting participant. Here is an example:
{
"default_participant" : {
"host" : "participant1",
"port" : 5003
},
"participants" : {
"participant1" : {
"host" : "participant1",
"port" : 5003
},
"participant2" : {
"host" : "participant2",
"port" : 5005
}
},
"party_participants" : {
"alice::1220053..." : "participant1",
"bob::1220063..." : "participant2"
}
}
Keep in mind this is not how you configure a Canton network or allocate parties. This is a file specifically for Daml Script that describes how things are already configured.
If you run something like this…
daml script --dar demo-0.0.1.dar --all --participant-config participant-config.json
… you are telling Daml Script to run all the scripts in the given dar. While executing those scripts, whenever a script performs a submit
, use the mapping in the participant-config.json file to determine against which participant node to submit that command. For example:
- When doing a
submit alice do createCmd...
, create the contract onparticipant1
. - When doing a
submit bob do exerciseCmd ...
, exercise the choice onparticipant2
.
But how does one get a participant-config.json
file for an up and running network?
2. Generate Daml Script Participant Config
Obviously, you could type out a participant config file manually. However, there is a function to generate one for an up-and-running network.
The Canton libraries include a function for generating the participant config file for daml script
. Use these steps:
- Create a
remote.conf
Canton file for your environment.
Sample remote.conf
canton {
remote-domains {
mydomain {
public-api.port = 5001
public-api.address = mydomain
admin-api.port = 5002
admin-api.address = mydomain
}
}
remote-participants {
participant1 {
ledger-api.port = 5003
ledger-api.address = participant1
admin-api.port = 5004
admin-api.address = participant1
}
participant2 {
ledger-api.port = 5005
ledger-api.address = participant2
admin-api.port = 5006
admin-api.address = participant2
}
}
}
- Open a Canton Console to one of your participant nodes.
Sample command
daml canton-console \
--host participant1 \
--port 5003 \
--admin-api-port 5004 \
--config remote.conf
- From within the Canton Console, call the
generate_daml_script_participants_conf
function.
Sample command
utils.generate_daml_script_participants_conf(
file=Some("participant-config.json"),
defaultParticipant=Some(participant1))
exit
Alternatively, you can include the call to utils.generate_daml_script_participants_config(...)
in a boostrap.canton file.
You now have a participants config file with the known participants and their parties.
However, our script will be using an existing party instead of allocating a new one. What do we do with the following line in our script?
alice <- allocateParty "alice"
See the next section.
3. The -On functions in Daml Script
You may have seen the listKnownParties function. As its name implies, it returns a list of known parties.
listKnownParties
: HasCallStack => Script [PartyDetails]
List the parties known to the default participant.
However, have you ever noticed its cousin listKnownPartiesOn? It accepts a participant name as its first argument.
listKnownPartiesOn
: HasCallStack => ParticipantName -> Script [PartyDetails]
List the parties known to the given participant.
Additionally, there is an allocatePartyOn function. Both of these -On functions use the participant config file to know how to reach the given participant.
These -On functions, likely wrapped in some helpers that meet your specific needs, are how you write Daml Scripts that execute against multiple participant nodes as different parties. Here are some examples:
Sample helper functions
module Helpers (findOrAllocatePartyOn, submitAfterPollingForCid, Retries(..)) where
import Daml.Script
import DA.Optional (mapOptional)
import DA.Time (RelTime)
data FindPartyResult
= Found Party
| NotFound
| MultipleFound
findPartyInDetails : Text -> [PartyDetails] -> FindPartyResult
findPartyInDetails displayName partyDetails = do
let rolePartyOptional partyDetails =
if partyDetails.displayName == Some (displayName)
then Some partyDetails.party
else None
case mapOptional rolePartyOptional partyDetails of
[p] -> Found p
[] -> NotFound
_ -> MultipleFound
findPartyOn : Text -> ParticipantName -> Script FindPartyResult
findPartyOn displayName participant =
findPartyInDetails displayName <$> listKnownPartiesOn participant
findOrAllocatePartyOn : Text -> ParticipantName -> Script Party
findOrAllocatePartyOn displayName participant = do
result <- findPartyOn displayName participant
case result of
Found party -> pure party
MultipleFound -> error $ "Multiple " <> displayName <> " parties found on " <> (participantName participant)
NotFound -> do
debug $ "Allocating party " <> displayName <> " on " <> (participantName participant)
allocatePartyWithHintOn displayName (PartyIdHint displayName) participant
data Retries = Retries with
remaining : Int
delay : RelTime
pollForCid : (HasAgreement t, Template t)
=> Party -> ContractId t -> Retries -> Script ()
pollForCid p cid retries = do
r <- queryContractId p cid
case r of
Some _ -> pure ()
None ->
if retries.remaining <= 0
then do
debug "The cid could not be queried."
pure ()
else do
debug $ "Waiting for a cid (" <> show retries.remaining <> " retries)..."
sleep retries.delay
pollForCid p cid $ retries with remaining = retries.remaining - 1
submitAfterPollingForCid : (HasAgreement t, Template t)
=> Party -> ContractId t -> Retries -> Commands a -> Script a
submitAfterPollingForCid p cid retries cmds = do
_ <- pollForCid p cid retries
submit p cmds
Updated happy_path script
happy_path : Script ()
happy_path = script do
let retries : Retries = Retries with remaining=10, delay=milliseconds 100
alice <- findOrAllocatePartyOn "Alice" (ParticipantName "participant1")
bob <- findOrAllocatePartyOn "Bob" (ParticipantName "participant2")
debug "Alice creating a bid request..."
cidBidRequest <- submit alice do createCmd BidRequest with owner = alice, painter = bob
debug "Bob submitting a proposal..."
cidBid <- submitAfterPollingForCid bob cidBidRequest retries do exerciseCmd cidBidRequest Propose with amount = 1000.0
debug "Alice accepting the proposal..."
cidPaintHouse <- submitAfterPollingForCid alice cidBid retries do exerciseCmd cidBid Accept
pure ()
Waiting for CIDs
Keep in mind that our script is running against two different, distributed participant nodes. They are not going to be instantaneously synchronized with each other. Consequently, the following code…
debug "Alice creating a bid request..."
cidBidRequest <- submit alice do
createCmd BidRequest with owner = alice, painter = bob
debug "Bob submitting a proposal..."
cidBid <- submit bob do
exerciseCmd cidBidRequest Propose with amount = 1000.0
… will likely fail with the dreaded CONTRACT_NOT_FOUND
exception. It will likely fail when our script tries to execute the Propose
choice on the cidBidRequest
contract as bob against participant2. The failure would be due to the fact that the bid request contract is not yet available at Bob’s Ledger API.
To avoid this, your Daml Script must wait for the contract id to be available before exercising any choices on it. That is why the sample scripts in the previous section introduce a submitAfterPollingForCid
helper to address this.
submitAfterPollingForCid p cid retries cmds = do
_ <- pollForCid p cid retries
submit p cmds
The earlier submit bob do exerciseCmd...
becomes:
debug "Bob submitting a proposal..."
cidBid <- submitAfterPollingForCid bob cidBidRequest retries do
exerciseCmd cidBidRequest Propose with amount = 1000.0
Complete Sample Code
For a complete, runnable sample using Docker, see this GitHub branch.