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.