Contract Keys in Canton¶
DAML provides a “contract key” mechanism for contracts, similar to primary keys in relational databases. Canton supports the full syntax of contract keys, but not their full semantics. That is, all valid DAML contracts using keys will run on Canton, but their behavior may deviate from the prescribed one. This document explains the deviation, as well as ways of recovering the full functionality of keys in some scenarios. It assumes a reasonable familiarity with DAML.
Keys have two main functions:
- Simplifying the modeling of mutable state in DAML.
DAML contracts are immutable and can be only created and archived.
Mutating a contract
Cis modeled by archiving
Cand creating a new contract
C'which is a modified version of
C. Other than keys, DAML offers no means to capture the relation between
C'. After archiving
C, any contract
Dthat contains the contract ID of
Cis left with a dangling reference. This makes it cumbersome to model mutable state that is split across multiple contracts. Keys provide mutable references in DAML; giving
C'the same key
Kas a reference that will start pointing to
- Checking that no active contract with a given key exists at some point in time. This mainly serves to provide uniqueness guarantees, which are useful in many cases. One is that they can serve to de-duplicate data coming from external sources. Another one is that they allow “natural” mutable references, e.g., referring to a user by their username or e-mail.
Keys in Canton provide the first, but not the second function, at least not without additional effort. In particular:
- In Canton, two (or more) active contracts with the same key may exist simultaneously.
lookupByKey @Template kmay return
Noneeven when an active instance of template
Templatewith the key
fetchByKey @Template kor or an
exerciseByKey @Template kor a positive
lookupByKey @Template k(returning
Some cid) may return any active contract of template
In the remainder of the document we:
- give more detailed examples of the differences above
- give an overview of how keys are implemented such that you can better understand their behavior
- show workarounds for recovering the uniqueness functionality in particular scenarios
- give a formal semantics of keys in Canton, in terms of the DAML ledger model.
Examples of Semantic Differences¶
Double Key Creation¶
Consider the following template:
template Keyed with sig: Party k: Int where signatory sig key (sig, k): (Party, Int) maintainer key._1
The DAML contract key semantics prescribes that no two active
Keyed contracts with the same keys should exist.
This is exemplified in the following DAML scenario:
multiple = scenario do alice <- getParty "alice" submitMustFail alice $ do create $ Keyed with sig = alice, k = 1 create $ Keyed with sig = alice, k = 1
Alice’s submission must fail, since it attempts to create two contracts with the key
In Canton, however, the submission is legal, and will succeed (if executed, for example, through DAML Script).
Thus, you cannot directly rely on keys to ensure the uniqueness of user-chosen usernames or external identifiers (e.g., order identifiers, health record identifiers, entity identifiers, etc.) in Canton.
Similarly, your code might rely on the negative case of a
lookupByKey, for example as follows.
template Initialization with sig: Party k: Int where signatory sig template Orchestrator with sig: Party where signatory sig controller sig can nonconsuming Initialize: Optional (ContractId Initialization) with k: Int do optCid <- lookupByKey @Keyed (sig, k) case optCid of None -> do create Keyed with .. time <- getTime cid <- create Initialization with sig, k pure $ Some cid Some _ -> pure None
If you run some process (represented by the
Initialization template here), you may usually use a pattern like above to ensure that it is run only once.
Initialization template does not have a key.
Nevertheless, if all processing happens through the
Orchestrator template, there will only ever be one
Initialization created for the given party and key.
For example, the following scenario creates only one
lookupNone = scenario do alice <- getParty "alice" orchestratorId <- submit alice do create Orchestrator with sig = alice submit alice do exercise orchestratorId Initialize with k = 1 submit alice do exercise orchestratorId Initialize with k = 1
In scenarios, transactions are executed sequentially.
Alice’s second submission above will always find the existing
Keyed contract, and thus execute the
Some branch of the
In real-world applications, transactions may run concurrently.
initTx2 are run concurrently, and that these are the first two transactions running the
Then, during their preparation, both of them might execute the
None branch (i.e.,
lookupByKey might return a negative result), and thus both might try to create the
lookupByKey results must be committed to the ledger, and the key consistency requirements prohibit both of them committing.
Thus, one of
initTx2 might fail, or they both might succeed (if one of them sees the effects of the other and then executes the
Some branch), but in either case, only one
Initialization contract will be created.
In Canton, however, it is possible that both
initTx2 execute the
None branch, yet both get committed.
Initialization contracts may get created.
fetchByKey and Positive
DAML also provides a
DAML commands are evaluated against some active contract set.
When DAML encounters a
fetchByKey command, it tries to find an active contract with the given key (and fails if it cannot).
Since DAML semantics prescribe that only one such contract may exist, it is clear which one to return.
For example, consider the scenario:
fetchSome = scenario do alice <- getParty "alice" keyedId1 <- submit alice do create Keyed with sig = alice, k = 1 keyedId2 <- submitMustFail alice do create Keyed with sig = alice, k = 1 (foundId, _) <- submit alice do fetchByKey @Keyed (alice, 1) assert $ foundId == keyedId1 optFoundId <- submit alice do lookupByKey @Keyed (alice, 1) assert $ optFoundId == Some keyedId1
DAML’s contract key semantics says that Alice’s second submission must fail, since a contract with the given key already exists.
Thus, her third submission will always succeed, and return
keyedId1, since this is the only
Keyed contract with the key
Similarly, her fourth submission will also successfully find a contract, which will be
As discussed earlier, Alice’s second submission in the above scenario will however succeed in Canton.
Alice’s third and fourth submissions thus may return different contract IDs, with each returning either
Whichever one is returned, a successful
lookupByKey still guarantees that the returned contract is active at the time when the transaction gets committed.
As mentioned earlier, negative
lookupByKey results may be spurious.
Canton’s Implementation of Keys¶
Internally, a Canton participant node has a component that provides the gRPC interface (the “Ledger API Server”), and another component that synchronizes participants (the “sync service”).
When a command is submitted, the Ledger API Server evaluates the command against its local view, including the resolution of key lookups (
Submitted commands are evaluated in parallel, both on a single node, as well as across different nodes.
The evaluated command is then sent to the sync service, which runs Canton’s commit protocol.
The protocol provides a linear ordering of all transactions on a single domain, and participants check all transactions for conflicts, with an earlier-transaction-wins policy.
As participants only see parts of transactions (the joint projection of the parties they host), they only check conflicts on contracts for which they host stakeholders.
During conflict detection, positive key lookups (that find a contract ID based on a key) are treated as ordinary
fetch commands on the found contract ID, and the contract ID is checked to still be active.
Negative key lookups, on the other hand, are never checked by Canton (a malicious submitter, for example, can always successfully claim that the lookup was negative).
Similarly, contract creations are not checked for duplicate keys.
Logically, both of these checks would require checking a “there is no such key” statement.
Canton does not check such statements.
While adding the check to the individual participants is straightforward, it’s hard to get meaningful guarantees from such local checks because each participant has only a limited view of the entire virtual global ledger.
For example, the check could pass locally on a participant even though there exists a contract with the given key on some domain that the participant is not connected to.
Similarly, since the processing of different domains runs in parallel, it’s unclear how to consistently handle the case where transactions on different domains create two contracts with the same key.
For integrity, the participants also re-evaluate the submitted command (or, more precisely, the subtransaction in the joint projection of the parties they host).
The commit protocol ensures that any two involved participants will evaluate the key lookups in the same way as the Ledger API Server of the submitting participant.
That is, if there are two active contracts with the key
k, the protocol insures that a
fetchByKey k will return the same contract on all participants.
Once the sync protocol commits a transaction, it informs the Ledger API server, which then atomically updates its set of active contracts. The transactions are passed to the Ledger API server in the order in which they are recorded on the ledger.
Workarounds for Recovering Uniqueness¶
Since achieving some form of uniqueness for ledger data is necessary in many cases, we list some strategies to achieve it in Canton. The strategies’ applicability depends on your contracts and the deployment setup of your application. In general, none of the strategies apply to the case where creations and deletions of contracts with keys are delegated.
Setting: Single Maintainer, Single Participant Node¶
Often, contracts may have a single maintainer (e.g., an “operator” that wants to have unique user names for its users). In the simplest case, the maintainer party will be hosted on just one participant node. This setting allows some simple options for recovering uniqueness.
Command ID Deduplication¶
The Ledger API server deduplicates commands based on their IDs.
Note, however, that the IDs are deduplicated only within a configured window of time.
This can simplify the uniqueness bookkeeping of your application as follows.
Before your application sends a command that creates a contract with the key
k, it should first check that no contract with the key
k exists in a recent ACS snapshot (obtained from the Ledger API).
Then, it should use a command ID that’s a deterministic function of
k to send the command.
This protects you from the race condition of creating the key twice concurrently, without having to keep track of commands in flight.
Caveats to keep in mind are:
- you need to know exactly which contracts with keys each of your commands will create
- your commands may only create contracts with a single key
- only the maintainer party may submit commands that create contracts with keys (i.e., do not delegate the creation to other parties).
However, these conditions are often true in simple cases (e.g., commands that create new users).
Another approach is to funnel all creations of the keyed contracts through a “generator” contract.
An example generator for the
Keyed template is shown below.
template Generator with sig: Party where signatory sig controller sig can Generate : (ContractId Generator, ContractId Keyed) with k: Int do existing <- lookupByKey @Keyed (sig, k) keyed <- case existing of Some cid -> pure cid None -> create Keyed with .. gen <- create this pure (gen, keyed)
The main difference to the
Orchestrator contract is that the
Generate choice is consuming.
Caveats to keep in mind are:
- Your application must ensure that you only ever create one
Generatorcontract (e.g., by creating one when initializing the application for the first time).
- All commands that create the
Keyedcontract must be issued by the maintainer (in particular, do not delegate choices on the
Generatorcontract to other parties).
- You must not create
Keyedcontracts by any other means other than exercising the
Generatechoice above will not abort the command if the contract with the given key already exists, it will just return the existing contract. However, this is easy to change.
- This approach relies on a particular internal behavior of Canton (as discussed below). While we don’t expect the behavior to change, we do not currently make strong guarantees that it will not change.
- If the participant is connected to multiple domains, the approach may fail in future versions of Canton. To be future proof, you should only use it in the settings when your participant is connected to a single domain.
A usage example scenario is below.
generator = scenario do alice <- getParty "Alice" -- Your application must ensure that the following command runs at most once gen <- submit alice $ create Generator with sig = alice (gen, keyed) <- submit alice $ exercise gen Generate with k = 1 (gen, keyed1) <- submit alice $ exercise gen Generate with k = 1 assert $ keyed1 == keyed submit alice $ exercise keyed Archive (gen, keyed2) <- submit alice $ exercise gen Generate with k = 1 assert $ keyed2 /= keyed
To understand why this works, first read how keys are implemented in Canton.
With this in mind, since the
Generate choice is consuming, if you issue two or more concurrent commands that use the
Generate choice, at most one of them will succeed (as the
Generator contract will be archived by the time the first transaction commits).
Thus, all accepted commands will be evaluated sequentially by the Ledger API server.
As the server writes the results of accepted commands to its database atomically, the
Keyed contract created by one command that uses
Generate will either be visible to the following command that uses
Generate, or it will have been archived by some other, unrelated command in between.
Setting: Single Maintainer, Multiple Participants¶
Ensuring uniqueness with multiple participants is more complicated, and adds more restrictions on how you operate on the contract.
The main approach is to track all “allocations” and “deallocations” of a key through a helper contract.
template KeyState with sig: Party k: Int allocated: Bool where signatory sig controller sig can Allocate : (ContractId KeyState, ContractId Keyed) do assert $ not allocated newState <- create this with allocated = True keyed <- create Keyed with .. pure (newState, keyed) Deallocate : ContractId KeyState do assert $ allocated (cid, _) <- fetchByKey @Keyed (sig, k) exercise cid Archive create this with allocated = False
- Before creating a contract with the key
kfor the first time, your application must create the matching
False. Such a contract must be created at most once. Most likely, you will want to choose a “master” participant on which you create such contracts.
- Do not delegate choices on the
Keyedcontract to parties other than the maintainers.
- You must never send a command that creates or archives the
Keyedcontract directly. Instead, you must use the
Deallocatechoices on the
KeyStatecontract. The only exception are consuming choices on the
Keyedcontract that immediately recreate a
Keyedcontract with the same key. These choices may also be delegated.
A usage example scenario is below.
state = scenario do alice <- getParty "Alice" -- Your application must ensure that the following command executes at most once state <- submit alice $ create KeyState with sig = alice, k = 1, allocated = False (state, keyed) <- submit alice $ exercise state Allocate submitMustFail alice $ exercise state Allocate -- If you archive the keyed contract without going through the -- KeyState, you must also recreate it in the same transaction. -- For example, if Keyed had consuming choices, the choices' bodies -- would have to recreate another Keyed contract with the same key submit alice $ do exercise keyed Archive create Keyed with sig = alice, k = 1 state <- submit alice $ exercise state Deallocate (state, keyed2) <- submit alice $ exercise state Allocate assert $ keyed2 /= keyed
An alternative to this approach, if you want to use a consuming choice
ch on the
Keyed template that doesn’t recreate key, is to record the contract ID of the
KeyState contract in the
You can then call
ch, but you must first modify
Deallocate to not perform a
Setting: Multiple Maintainers¶
Achieving uniqueness for contracts with multiple maintainers is more difficult, and the maintainers must trust each other.
To handle this case, follow the
KeyState approach from the previous section.
The main difference is that the
KeyState contracts must have multiple signatories.
Thus you must follow the usual DAML pattern of collecting signatories.
Beware that you must still structure this such that you only ever create one
Formal Semantics of Keys in Canton¶
In terms of the DAML ledger model, Canton’s virtual shared ledger violates key consistency.
NoSuchKey k actions may happen on the ledger even when there exists an active contract with the key
Create actions for a contract with the key
k may appear on the ledger even if another active contract with the key
In terms of DAML evaluation, i.e., the translation of DAML into the ledger model transactions, the following changes:
- When evaluated against an active contract set, a
fetchByKey kmay result in a
Fetch caction for any active contract
cwith the key
k(in Canton, there can be multiple such contracts). In the current implementation, it will favor the most recently created contract within the single transaction. However, this is not guaranteed to hold in future versions of Canton. If no contract with key
kis active, it will fail as usual.
lookupByKey kmay result in a
Fetch cfor any active contract
cwith the key
k. If no such contract exists, it results in a
NoSuchKey kas usual.
- Likewise, an
exerciseByKey kmay result in an
Exerciseon any contract
cwith the key
k. It fails if no contract with key