React to Off-Ledger Events: Daml Script¶
Daml Script provides a simple way of testing Daml models and getting quick feedback in Daml studio. In addition to running it in a virtual ledger in Daml Studio, you can also point it against an actual ledger. This means that you can use it for application scripting, to test automation logic and also for ledger initialization.
You can also use Daml Script interactively using Experiment: Daml REPL (Deprecated).
Hint
Remember that you can access all the example code by running daml new script-example --template script-example
Usage¶
Our example for this tutorial consists of 2 templates.
First, we have a template called Coin
:
template Coin
with
issuer : Party
owner : Party
where
signatory issuer, owner
This template represents a coin issued to owner
by issuer
.
Coin
has both the owner
and the issuer
as signatories.
Second, we have a template called CoinProposal
:
template CoinProposal
with
coin : Coin
where
signatory coin.issuer
observer coin.owner
choice Accept : ContractId Coin
controller coin.owner
do create coin
CoinProposal
is only signed by the issuer
and it provides a
single Accept
choice which, when exercised by the controller will
create the corresponding Coin
.
Having defined the templates, we can now move on to write Daml scripts
that operate on these templates. To get access to the API used to implement Daml scripts, you need to add the daml-script
library to the dependencies
field in daml.yaml
.
dependencies:
- daml-prim
- daml-stdlib
- daml-script
We also enable the ApplicativeDo
extension. We will
see below why this is useful.
{-# LANGUAGE ApplicativeDo #-}
module ScriptExample where
import DA.Time
import Daml.Script
Since on an actual ledger parties cannot be arbitrary strings, we define a record containing all the parties that we will use in our script so that we can easily swap them out.
data LedgerParties = LedgerParties with
bank : Party
alice : Party
bob : Party
Let us now write a function to initialize the ledger with 3
CoinProposal
contracts and accept 2 of them. This function takes the
LedgerParties
as an argument and returns a value of type Script
()
which is Daml script’s equivalent of Scenario ()
.
initialize : LedgerParties -> Script ()
initialize parties = do
First we create the proposals. To do so, we use the submit
function to submit a transaction. The first argument is the party
submitting the transaction. In our case, we want all proposals to be
created by the bank so we use parties.bank
. The second argument
must be of type Commands a
so in our case Commands (ContractId
CoinProposal, ContractId CoinProposal, ContractId CoinProposal)
corresponding to the 3 proposals that we create. However, Commands
requires that the individual commands
do not depend on each other. This matches the restriction on the
Ledger API where a transaction consists of a list of commands. Using
ApplicativeDo
we can still use do
-notation as long as we
respect this and the last statement in the do
-block is of the form
return expr
or pure expr
.
In Commands
we use createCmd
instead of
create
and exerciseCmd
instead of exercise
.
(coinProposalAlice, coinProposalBob, coinProposalBank) <- submit parties.bank $ do
coinProposalAlice <- createCmd (CoinProposal (Coin parties.bank parties.alice))
coinProposalBob <- createCmd (CoinProposal (Coin parties.bank parties.bob))
coinProposalBank <- createCmd (CoinProposal (Coin parties.bank parties.bank))
pure (coinProposalAlice, coinProposalBob, coinProposalBank)
Now that we have created the CoinProposal
s, we want Alice
and
Bob
to accept the proposal while the Bank
will ignore the
proposal that it has created for itself. To do so we use separate
submit
statements for Alice
and Bob
and call
exerciseCmd
.
coinAlice <- submit parties.alice $ exerciseCmd coinProposalAlice Accept
coinBob <- submit parties.bob $ exerciseCmd coinProposalBob Accept
Finally, we call pure ()
on the last line of our script to match
the type Script ()
.
pure ()
Party Management¶
We have now defined a way to initialize the ledger so we can write a test that checks that the contracts that we expect exist afterwards.
First, we define the signature of our test. We will create the parties used here in the test, so it does not take any arguments.
test : Script ()
test = do
Now, we create the parties using the allocateParty
function. This
uses the party management service to create new parties with the given
display name. Note that the display name does not identify a party
uniquely. If you call allocateParty
twice with the same display
name, it will create 2 different parties. This is very convenient for
testing since a new party cannot see any old contracts on the ledger
so using new parties for each test removes the need to reset the
ledger. We factor out party allocation into a function so we can reuse it in later sections.
allocateParties : Script LedgerParties
allocateParties = do
alice <- allocateParty "alice"
bob <- allocateParty "bob"
bank <- allocateParty "Bank"
pure (LedgerParties bank alice bob)
We now call the initialize
function that we defined before on the
parties that we have just allocated.
initialize parties
Queries¶
To verify the contracts on the ledger, we use the query
function. We pass it the type of the template and a party. It will
then give us all active contracts of the given type visible to the
party. In our example, we expect to see one active CoinProposal
for bank
and one Coin
contract for each of Alice
and
Bob
. We get back list of (ContractId t, t)
pairs from
query
. In our tests, we do not need the contract ids, so we throw
them away using map snd
.
proposals <- query @CoinProposal bank
assertEq [CoinProposal (Coin bank bank)] (map snd proposals)
aliceCoins <- query @Coin alice
assertEq [Coin bank alice] (map snd aliceCoins)
bobCoins <- query @Coin bob
assertEq [Coin bank bob] (map snd bobCoins)
Interfaces¶
To use interfaces within Daml code, the target language version must be at least 1.15
.
build-options:
- --target=1.15
Now we can define an Asset
interface which can be implemented by the Coin
template. We also define AssetInfo
for use as the viewtype.
data AssetInfo = AssetInfo { info : Text } deriving (Eq,Show)
interface Asset where
viewtype AssetInfo
interface instance Asset for Coin where
view = AssetInfo { info = "A Coin" }
Now we use the queryInterface
function. We pass it the type of the interface and a party. It will return a list of active contract views for the given interface type. As before we throw away the contract ids using map snd
.
aliceAssets <- queryInterface @Asset alice
assertEq [Some $ AssetInfo "A Coin"] (map snd aliceAssets)
Run a Script¶
To run our script, we first build it with daml build
and then run
it by pointing to the DAR, the name of our script, and the host and
port our ledger is running on.
daml script --dar .daml/dist/script-example-0.0.1.dar --script-name ScriptExample:test --ledger-host localhost --ledger-port 6865
Up to now, we have worked with a script (test
) that is entirely
self-contained. This is fine for running unit-test type script in the IDE,
but for more complex use-cases you may want to vary the inputs of a script and
inspect its outputs, ideally without having to recompile it. To that end, the
daml script
command supports the flags --input-file
and
--output-file
. Both flags take a filename, and said file will be
read/written as JSON, following the Daml-LF JSON Encoding.
The --output-file
option instructs daml script
to write the
result of the given --script-name
to the given filename (creating
the file if it does not exist; overwriting it otherwise). This is most
useful if the given program has a type Script b
, where b
is a
meaningful value. In our example, we can use this to write out the
party ids that have been allocated by allocateParties
:
daml script --dar .daml/dist/script-example-0.0.1.dar --script-name ScriptExample:allocateParties --ledger-host localhost --ledger-port 6865 --output-file ledger-parties.json
The resulting file will look similar to the following but the actual party IDs will be different each time you run it:
{
"bank": "party-93affbfe-8717-4996-990c-9f4c5a889663::12201d00faa0968d7ab81e63ad6ad4ee0d31b08a3581b1d8596e68a1356f27519ccb",
"alice": "party-99595f45-75e3-4373-997c-fbdf899439f7::12201d00faa0968d7ab81e63ad6ad4ee0d31b08a3581b1d8596e68a1356f27519ccb",
"bob": "party-6e38e1ed-c070-4ded-ba20-073e0dbdb13c::12201d00faa0968d7ab81e63ad6ad4ee0d31b08a3581b1d8596e68a1356f27519ccb"
}
Next, we want to call the initialize
function with those parties
using the --input-file
flag. If the --input-file
flag is
specified, the --script-name
flag must point to a function of one
argument returning a Script
, and the function will be called with
the result of parsing the input file as its argument. For example, we
can initialize our ledger using the initialize
function defined
above.
Using the previously created -ledger-parties.json
file, we can
initialize our ledger as follows:
daml script --dar .daml/dist/script-example-0.0.1.dar --script-name ScriptExample:initialize --ledger-host localhost --ledger-port 6865 --input-file ledger-parties.json
Use Daml Script for Ledger Initialization¶
You can use Daml script to initialize a ledger on startup. To do so,
specify an init-script: ScriptExample:initializeUser
field in your
daml.yaml
. This will automatically be picked up by daml start
and used to initialize sandbox. During development not being able to
control party ids can often be inconvenient. Here, we rely on
users which do put us in control of
their id. User ids can be used in Navigator, triggers & other tools
instead of party ids.
initializeUser : Script ()
initializeUser = do
parties <- allocateParties
bank <- validateUserId "bank"
alice <- validateUserId "alice"
bob <- validateUserId "bob"
_ <- createUser (User bank (Some parties.bank)) [CanActAs parties.bank]
_ <- createUser (User alice (Some parties.alice)) [CanActAs parties.alice]
_ <- createUser (User bob (Some parties.bob)) [CanActAs parties.bob]
initialize parties
Migrate From Scenarios¶
Existing scenarios that you used for ledger initialization can be translated to Daml script but there are a few things to keep in mind:
- You need to add
daml-script
to the list of dependencies in yourdaml.yaml
. - You need to import the
Daml.Script
module. - Calls to
create
,exercise
,exerciseByKey
andcreateAndExercise
need to be suffixed withCmd
, e.g.,createCmd
. - Instead of specifying a
scenario
field in yourdaml.yaml
, you need to specify aninit-script
field. The initialization script is specified viaModule:identifier
for both fields. - In Daml script,
submit
andsubmitMustFail
are limited to the functionality provided by the ledger API: A list of independent commands consisting ofcreateCmd
,exerciseCmd
,createAndExerciseCmd
andexerciseByKeyCmd
. There are two issues you might run into when migrating an existing scenario:- Your commands depend on each other, e.g., you use the result of
a
create
within a following command in the samesubmit
. In this case, you have two options: If it is not important that they are part of a single transaction, split them into multiple calls tosubmit
. If you do need them to be within the same transaction, you can move the logic to a choice and call that usingcreateAndExerciseCmd
. - You use something that is not part of the 4 ledger API command
types, e.g.,
fetch
. Forfetch
andfetchByKey
, you can instead usequeryContractId
andqueryContractKey
with the caveat that they do not run within the same transaction. Other types ofUpdate
statements can be moved to a choice that you call viacreateAndExerciseCmd
.
- Your commands depend on each other, e.g., you use the result of
a
- Instead of Scenario’s
getParty
, Daml Script provides you withallocateParty
andallocatePartyWithHint
. There are a few important differences:- Allocating a party always gives you back a new party (or
fails). If you have multiple calls to
getParty
with the same string and expect to get back the same party, you should instead allocate the party once at the beginning and pass it along to the rest of the code. - If you want to allocate a party with a specific party id, you
can use
allocatePartyWithHint x (PartyIdHint x)
as a replacement for getParty x. Note that while this is supported in Daml Studio, some ledgers can behave differently and ignore the party id hint or interpret it another way. Try to not rely on any specific party id.
- Allocating a party always gives you back a new party (or
fails). If you have multiple calls to
- Instead of
pass
andpassToDate
, Daml Script providespassTime
andsetTime
.
Use Daml Script with the IDE Ledger¶
Similarly to running daml test
or when running a script in VSCode itself via the provided buttons, you can use
daml script
to run the scripts in a given DAR file within the IDE Ledger.
This is a fully in-memory child process of daml script
, allowing you to quickly
invoke a script without having to spin up a ledger in the background.
To run daml script
in this mode, you should provide the --ide-ledger
flag.
This flag is not compatible with --ledger-host
, --ledger-port
,
--participant-config
(described more in the next section), and --json-api
.
Note that since this uses an in-memory ledger, no state will be preserved once
the script finishes. You will only receive a success flag and, optionally, the script result
if you use --output-file
.
Use Daml Script in Canton¶
So far, we have run Daml script against a single participant node. It
is also possible to run it in a setting where different parties
are hosted on different participant nodes. To do so, pass the
--participant-config participant-config.json
file to daml script
instead of --ledger-host
and ledger-port
.
You can generate this file by calling
utils.generate_daml_script_participants_conf(defaultParticipant = Some(one))
in the canton console or in the bootstrap scripts.
The generated file will look similar to the one shown below:
{
"default_participant": {"host": "localhost", "port": 6866},
"participants": {
"one": {"host": "localhost", "port": 6866},
"two": {"host": "localhost", "port": 6865}
},
"party_participants": {"alice": "one", "bob": "two"}
}
This will define a participant called one
, declare one
as the default participant and it defines that the party alice
is hosted on participant
one
. Whenever you submit something as party, we will use the
participant for that party or if none is specified
default_participant
.
If you use utils.generate_daml_script_participants_conf()
without a default participant, the default_participant won’t be defined and therefore
using a party with an unspecified participant is an error.
allocateParty
will also use the default_participant
. If you
want to allocate a party on a specific participant, you can use
allocatePartyOn
which accepts the participant name as an extra
argument.
Hints for synchronizing contracts on multiple-participant Canton¶
When you create a contract on participant1
and try to use it on
participant2
, you can run into synchronization issues where
participant2
doesn’t see the contract yet. One option to workaround
this limitation
is to poll until the contract is visible. In the example below, the bank
and alice
parties are allocated on two different participants and to avoid
synchronization issues, we wait until the contract is visible on alice
participant.
tries : Int
tries = 60
waitForCid : (Template t, HasAgreement t) => Int -> Party -> ContractId t -> Script ()
waitForCid tries p cid
| tries <= 0 = abort $ "Cid " <> show cid <> " did not appear"
| otherwise = do
r <- queryContractId p cid
case r of
None -> do
sleep delay
waitForCid (tries - 1) p cid
Some _ -> pure ()
where delay = seconds 1
testWithSync: LedgerParties -> Script ()
testWithSync parties = do
coinProposalAlice <- submit parties.bank $ createCmd (CoinProposal (Coin parties.bank parties.alice))
waitForCid tries parties.alice coinProposalAlice
coinAlice <- submit parties.alice $ exerciseCmd coinProposalAlice Accept
pure ()
Run Daml Script Against Ledgers with Authorization¶
To run Daml Script against a ledger that verifies authorization, you need to specify an access token. There are two ways of doing that:
- Specify a single access token via
--access-token-file path/to/jwt
. This token will then be used for all requests so it must provide claims for all parties that you use in your script. - If you need multiple tokens, e.g., because you only have
single-party tokens you can define the
access_token
field in the participant config specified via--participant-config
. Note that you can specify the same participant twice if you want different auth tokens. The file should be of the format
{
"default_participant": {"host": "localhost", "port": 6866, "access_token": "default_jwt", "application_id": "myapp"},
"participants": {
"one": {"host": "localhost", "port": 6866, "access_token": "jwt_for_alice", "application_id": "myapp"},
"two": {"host": "localhost", "port": 6865, "access_token": "jwt_for_bob", "application_id": "myapp"}
},
"party_participants": {"alice": "one", "bob": "two"}
}
If you specify both --access-token-file
and
--participant-config
, the participant config takes precedence and
the token from the file will be used for any participant that does not
have a token specified in the config.
Run Daml Script Against the HTTP JSON API¶
In some cases, you only have access to the HTTP JSON API but not to the gRPC of a ledger, e.g., on Daml Hub. For this use case, Daml script can be run against the JSON API. Note that if you do have access to the gRPC Ledger API, running Daml script against the JSON API does not have any advantages.
To run Daml script against the JSON API you have to pass the --json-api
parameter to daml script
. There are a few differences and limitations compared to running Daml Script against the gRPC Ledger API:
- When running against the JSON API, the
--host
argument has to contain anhttp://
orhttps://
prefix, e.g.,daml script --host http://localhost --port 7575 --json-api
. - The JSON API only supports single-command submissions. This means
that within a single call to
submit
you can only execute one ledger API command, e.g., onecreateCmd
or oneexerciseCmd
. - The JSON API requires authorization tokens even when it is run against a ledger that doesn’t verify authorization. The section on authorization describes how to specify the tokens.
- The parties used for command submissions and queries must match the
parties specified in the token exactly. For command submissions
that means
actAs
andreadAs
must match exactly what you specified whereas for queries the union ofactAs
andreadAs
must match the parties specified in the query. - If you use multiple parties within your Daml Script, you need to specify one token per party or every submission and query must specify all parties of the multi-party token.
getTime
will always return the Unix epoch in static time mode since the time service is not exposed via the JSON API.setTime
is not supported and will throw a runtime error.