Multi-participant Daml Scripts

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.

image

I do this with:

  • the --participant-config parameter available to daml 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 on participant1.
  • When doing a submit bob do exerciseCmd ..., exercise the choice on participant2.

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:

  1. 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
    }
  }
}
  1. 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
  1. 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.

6 Likes