Daml Triggers - Off-Ledger Automation in Daml

In addition to the actual Daml logic which is uploaded to the Ledger and the UI, Daml applications often need to automate certain interactions with the ledger. This is commonly done in the form of a ledger client that listens to the transaction stream of the ledger and when certain conditions are met, e.g., when a template of a given type has been created, the client sends commands to the ledger, e.g., it creates a template of another type.

It is possible to write these clients in a language of your choice, e.g., JavaScript, using the HTTP JSON API. However, that introduces an additional layer of friction: You now need to translate between the template and choice types in Daml and a representation of those Daml types in the language you are using for your client. Daml triggers address this problem by allowing you to write certain kinds of automation directly in Daml reusing all the Daml types and logic that you have already defined. Note that while the logic for Daml triggers is written in Daml, they act like any other ledger client: They are executed separately from the ledger, they do not need to be uploaded to the ledger and they do not allow you to do anything that any other ledger client could not do.

Usage

Our example for this tutorial consists of 3 templates.

First, we have a template called Original:

template Original
  with
    owner : Party
    name : Text
    textdata : Text
  where
    signatory owner

    key (owner, name) : (Party, Text)
    maintainer key._1

This template has an owner, a name that identifies it and some textdata that we just represent as Text to keep things simple. We have also added a contract key to ensure that each owner can only have one Original with a given name.

Second, we have a template called Subscriber:

template Subscriber
  with
    subscriber : Party
    subscribedTo : Party
  where
    signatory subscriber
    observer subscribedTo
    key (subscriber, subscribedTo) : (Party, Party)
    maintainer key._1

This template allows the subscriber to subscribe to Original s where subscribedTo is the owner. For each of these Original s, our Daml trigger should then automatically create an instance of third template called Copy:

template Copy
  with
    original : Original
    subscriber : Party
  where
    signatory (signatory original)
    observer subscriber

Our trigger should also ensure that the Copy contracts stay in sync with changes on the ledger. That means that we need to archive Copy contracts if there is more than one for the same Original, we need to archive Copy contracts if the corresponding Original has been archived and we need to archive all Copy s for a given subscriber if the corresponding Subscriber contract has been archived.

Implementing a Daml Trigger

Having defined what our Daml trigger is supposed to do, we can now move on to its implementation. A Daml trigger is a regular Daml project that you can build using daml build. To get access to the API used to build a trigger, you need to add the daml-triggers library to the dependencies field in daml.yaml.

dependencies:
  - daml-prim
  - daml-stdlib
  - daml-trigger

In addition to that you also need to import the Daml.Trigger module.

Daml triggers automatically track the active contract set (ACS), i.e., the set of contracts that have been created and have not been archived, and the commands in flight for you. In addition to that, they allow you to have user-defined state that is updated based on new transactions and command completions. For our copy trigger, the ACS is sufficient, so we will simply use () as the type of the user defined state.

To create a trigger you need to define a value of type Trigger s where s is the type of your user-defined state:

data Trigger s = Trigger
  { initialize : TriggerInitializeA s
  , updateState : Message -> TriggerUpdateA s ()
  , rule : Party -> TriggerA s ()
  , registeredTemplates : RegisteredTemplates
  , heartbeat : Optional RelTime
  }

The initialize function is called on startup and allows you to initialize your user-defined state based on querying the active contract set.

The updateState function is called on new transactions and command completions and can be used to update your user-defined state based on the ACS and the transaction or completion. Since our Daml trigger does not have any interesting user-defined state, we will not go into details here.

The rule function is the core of a Daml trigger. It defines which commands need to be sent to the ledger based on the party the trigger is executed at, the current state of the ACS, and the user defined state. The type TriggerA allows you to emit commands that are then sent to the ledger, query the ACS with query, update the user-defined state, as well as retrieve the commands in flight with getCommandsInFlight. Like Scenario or Update, you can use do notation and getTime with TriggerA.

We can specify the templates that our trigger will operate on. In our case, we will simply specify AllInDar which means that the trigger will receive events for all template types defined in the DAR. It is also possible to specify an explicit list of templates, e.g., RegisteredTemplates [registeredTemplate @Original, registeredTemplate @Subscriber, registeredTemplate @Copy]. This is mainly useful for performance reasons if your DAR contains many templates that are not relevant for your trigger.

Finally, you can specify an optional heartbeat interval at which the trigger will be sent a MHeartbeat message. This is useful if you want to ensure that the trigger is executed at a certain rate to issue timed commands.

For our Daml trigger, the definition looks as follows:

copyTrigger : Trigger ()
copyTrigger = Trigger
  { initialize = pure ()
  , updateState = \_message -> pure ()
  , rule = copyRule
  , registeredTemplates = AllInDar
  , heartbeat = None
  }

Now we can move on to the most complex part of our Daml trigger, the implementation of copyRule. First let’s take a look at the signature:

copyRule : Party -> TriggerA () ()
copyRule party = do

We will need the party and the ACS to get the Original contracts where we are the owner, the Subscriber contracts where we are in the subscribedTo field and the Copy contracts where we are the owner of the corresponding Original.

The commands in flight, retrievable with getCommandsInFlight, will be useful to avoid sending the same command multiple times if copyRule is run multiple times before we get the corresponding transaction. Note that Daml triggers are expected to be designed such that they can cope with this, e.g., after a restart or a crash where the commands in flight do not contain commands in flight from before the restart, so this is an optimization rather than something required for them to function correctly.

First, we get all Subscriber, Original and Copy contracts from the ACS. For that, the Daml trigger API provides a query function that will return a list of all contracts of a given template.

  subscribers : [(ContractId Subscriber, Subscriber)] <- query @Subscriber
  originals : [(ContractId Original, Original)] <- query @Original
  copies : [(ContractId Copy, Copy)] <- query @Copy

Now, we can filter those contracts to the ones where we are the owner as described before.

  let ownedSubscribers = filter (\(_, s) -> s.subscribedTo == party) subscribers
  let ownedOriginals = filter (\(_, o) -> o.owner == party) originals
  let ownedCopies = filter (\(_, c) -> c.original.owner == party) copies

We also need a list of all parties that have subscribed to us.

  let subscribingParties = map (\(_, s) -> s.subscriber) ownedSubscribers

As we have mentioned before, we only want to keep one Copy per Original and Subscriber and archive all others. Therefore, we group identical Copy contracts and keep the first of each group while archiving the others.

  let groupedCopies : [[(ContractId Copy, Copy)]]
      groupedCopies = groupOn snd $ sortOn snd $ ownedCopies
  let copiesToKeep = map head groupedCopies
  let archiveDuplicateCopies = concatMap tail groupedCopies

In addition to duplicate copies, we also need to archive copies where the corresponding Original or Subscriber no longer exists.

  let archiveMissingOriginal = filter (\(_, c) -> c.original `notElem` map snd ownedOriginals) copiesToKeep
  let archiveMissingSubscriber = filter (\(_, c) -> c.subscriber `notElem` subscribingParties) copiesToKeep
  let archiveCopies = dedup $ map fst $ archiveDuplicateCopies <> archiveMissingOriginal <> archiveMissingSubscriber

To send the corresponding archive commands to the ledger, we iterate over archiveCopies using forA and call the emitCommands function. Each call to emitCommands takes a list of commands which will be submitted as a single transaction. The actual commands can be created using exerciseCmd and createCmd. In addition to that, we also pass in a list of contract ids. Those contracts will be marked pending and not be included in the result of query until the commands have either been committed to the ledger or the command submission failed.

  forA archiveCopies $ \cid -> emitCommands [exerciseCmd cid Archive] [toAnyContractId cid]

Finally, we also need to create copies that do not already exists. We want to avoid creating copies for which there is already a command in flight. The Daml Trigger API provides a dedupCreate helper for this which only sends the commands if it is not already in flight.

  let neededCopies = [Copy m o | (_, m) <- ownedOriginals, o <- subscribingParties]
  let createCopies = filter (\c -> c `notElem` map snd copiesToKeep) neededCopies
  mapA dedupCreate createCopies

Running a Daml Trigger

To try this example out, you can replicate it using daml new copy-trigger --template copy-trigger. You first have to build the trigger like you would build a regular Daml project using daml build. Then start the sandbox and navigator using daml start.

Now we are ready to run the trigger using daml trigger:

daml trigger --dar .daml/dist/copy-trigger-0.0.1.dar --trigger-name CopyTrigger:copyTrigger --ledger-host localhost --ledger-port 6865 --ledger-party Alice

The first argument specifies the .dar file that we have just built. The second argument specifies the identifier of the trigger using the syntax ModuleName:identifier. Finally, we need to specify the ledger host, port, the party that our trigger is executed as, and the time mode of the ledger which is the sandbox default, i.e, static time.

Now open Navigator at http://localhost:7500/.

First, login as Alice and create an Original contract with party set to Alice. Now, logout and login as Bob and create a Subscriber contract with subscriber set to Bob and subscribedTo set to Alice. After a short delay you should now see a Copy contract corresponding to the Original that you have created as Alice. Once you archive the Subscriber contract, you can see that the Copy contract will also be archived.

When using Daml triggers against a Ledger with authentication, you can pass --access-token-file token.jwt to daml trigger which will read the token from the file token.jwt.

When not to use Daml triggers

Daml Triggers are not suited for automation that needs to interact with services or data outside of the ledger. For those cases, you can write a ledger client using the JavaScript bindings running against the HTTP JSON API or the Java bindings running against the gRPC Ledger API.

Daml triggers deliberately only allow you to express automation that listens for ledger events and reacts to them by sending commands to the ledger.