Abstract. Daml takes a different approach to smart contracts. Instead of a general-purpose language bolted onto a blockchain, it is a purpose-built language where signatories must consent before contracts exist, visibility is confined to stakeholders, and every state change means destroying one contract and creating another. These are not optional features – they are baked into the type system.
Introduction
Most smart contract platforms center on assets – tokens, balances, ownership records. Daml starts from a different place. It cares about rights: who can see a piece of data, who is allowed to act on it, and what authorization has to be in place before anything happens. That framing changes how you think about contract design from the ground up.
Daml is a functional smart contract language created by Digital Asset, derived from Haskell. Its type system goes beyond data structures: it encodes the conditions under which parties can act and the guarantees the runtime upholds when they do. The language was built for multi-party workflows where auditability and data privacy are woven into the core abstractions, not bolted on afterward.
The crucial thing to understand about Canton is that the ledger is synchronized, but data is not globally visible. Everyone agrees that a transaction happened, but only the parties directly involved can see what it contained. Contracts are immutable, and changing state means archiving one contract and creating another. That model has direct security consequences. Get signatories, observers, and controllers right, and workflows keep sensitive data contained while remaining verifiable. Get them wrong, and you end up with authorization gaps and silent data exposure – the kind of bugs that are hard to spot in code review and expensive to fix once contracts are live.
This article covers the key patterns and recurring design approaches commonly encountered in Daml development: Propose-Accept, Multiple Party Agreement, Delegation, Authorization, Locking, and the UTXO-based contract lifecycle that underpins them all. For each one, we look at what problem it solves, how the mechanics work, and where the security risks hide.
Note: Code examples in this article target Daml SDK 3.4.11 on the Canton Network. The language evolves across major versions; consult the official documentation for the latest syntax and semantics.
Daml Execution Model
Contract Lifecycle
Daml programs are organized around templates. A template describes the data a contract holds and which parties are involved. Instantiate a template and you get a contract – a live record on the ledger. Think of the template as the schema and the contract as an instance. There is no in-place mutation. If you need to change a field, you archive the existing contract and create a fresh one with the updated values. This is how every state transition on a Canton ledger works – not a workaround for a missing feature, but the intended execution model.
A contract exists from the moment it is created until it is archived. The signatory keyword marks parties whose consent is needed to create or remove a contract – analogous to signatures on a paper agreement. Every contract needs at least one signatory, and the ledger guarantees that signatories are notified whenever their authority is used. If a contract you signed gets created or consumed, you find out.
Templates can also declare observers – parties who can see the contract and its data but cannot create or archive it on their own. Then there are controllers: the parties allowed to exercise choices on a contract. A controller has to be able to see a contract before they can act on it, so in practice, they are almost always a signatory or an observer too.
How Visibility Works On Canton
Canton’s privacy model works on a need-to-know basis, and that need-to-know granularity goes all the way down to individual sub-transactions. A party learns only the parts of a transaction that touch contracts they have a stake in, plus the consequences of those actions. The hierarchical structure of Daml’s transaction tree is what makes these fine-grained projections possible.
The runtime tracks exactly why a party can see a given contract. Daml tooling surfaces this through four annotations:
- S (Signatory) – the party sees the contract because they are a signatory on it.
- O (Observer) – the party sees the contract because they are declared as an observer.
- W (Witness) – the party sees the contract because they witnessed its creation, for example, because they are an actor on the exercise that created it.
- D (Divulgence) – the party sees the contract because it was divulged to them, for example, because they witnessed an exercise that resulted in a fetch of this contract.
These are not just diagnostic labels – they carry different rights. A signatory (S) has full authority over the contract’s lifecycle. An observer (O) can see the contract but cannot act on it unless they are separately given controller rights. A witness (W) gets transient visibility: they see the contract because the transaction structure demanded it, but that does not give them ongoing access or the ability to reference the contract later. Divulgence (D) is the weakest form – the party learns that the contract exists and what it contains, but this knowledge does not automatically carry over to subsequent transactions. If the party needs to use the contract again, explicit disclosure is required.
Who is informed about which actions depends on the action type. The Daml ledger model defines the following informee rules:
There is a deliberate design decision behind this table: contract observers are not informed about non-consuming Exercise and Fetch actions unless they also happen to be actors or choice observers. The reasoning is that these actions do not change the contract’s state – a non-consuming exercise leaves the contract active, and a fetch merely reads it. Only consuming exercises and contract creation/archival count as state-changing events that stakeholders need to know about. So if someone fetches a contract where a third party is listed as an observer, that observer will not learn about the fetch unless they are also the actor performing it.
Divulgence deserves separate attention from a security standpoint. When a party witnesses a transaction that fetches a contract, the contents of that fetched contract are divulged to the witnessing party. Contract data can therefore flow to parties who were never explicitly listed as signatories or observers. This is by design – the witnessing party needs the data to validate the transaction – but it can cause unintended information disclosure if developers are not careful about which contracts get fetched inside which choice bodies.
Core Daml Security Patterns
These patterns solve real workflow problems. They also define where authority and privacy leak when the implementation gets sloppy.
Propose-Accept
One of the first things newcomers to Daml run into is this: you cannot create a contract unless every signatory authorizes it. You cannot put someone else’s name on an agreement and push it through. If a contract needs two signatures, both parties have to actively participate. Propose-Accept is the standard pattern for orchestrating that.
It works in two steps. One party creates a proposal – a lightweight contract where they are the only signatory. The other party is listed as an observer so they can see the proposal and its terms. That proposal exposes a choice the second party can exercise, and when they do, the runtime combines their authority with the authority already embedded in the proposal (from the original signatory) to produce a new contract signed by both.
Take a lending scenario. A bank wants to establish a lending agreement with a borrower. The bank cannot just create a LendingAgreement with the borrower’s name on it – the borrower has to agree. So the bank creates a proposal:
template LendingProposal
with
bank : Party
borrower : Party
limit : Decimal
rate : Decimal
where
signatory bank
observer borrower
choice LendingProposal_Accept : ContractId LendingAgreement
controller borrower
do
create LendingAgreement with
bank
borrower
limit
rate
choice LendingProposal_Reject : ()
controller borrower
do
pure ()
choice LendingProposal_Withdraw : ()
controller bank
do
pure ()
The bank signs alone. The borrower can see the terms – the credit limit, the interest rate – and decide whether to accept. If they exercise LendingProposal_Accept, the proposal gets consumed, and a LendingAgreement appears on the ledger with both parties as signatories:
template LendingAgreement
with
bank : Party
borrower : Party
limit : Decimal
rate : Decimal
where
signatory bank, borrower
Why does the create inside LendingProposal_Accept succeed even though LendingAgreement demands two signatories? Because exercising a choice produces authority from two sources: the actor (the borrower, who submitted the exercise) and the signatories of the contract being exercised (the bank, who signed the proposal). The bank never has to submit a second transaction – their authority entered the picture when they created the proposal.
Security Pitfalls
- Open proposals as free options. Without Withdraw and an expiry, the counterparty holds a free option until they choose to accept.
- Result contracts with a single signatory. If the result lists only one party, they can archive it unilaterally – defeating the whole pattern.
- Unrestricted proposal generation. A nonconsuming parent choice spawns unbounded duplicate proposals, requiring consuming or explicit deduplication.
- Authority does not chain automatically. Authority inside a nested choice comes only from that contract’s signatories and actors, not from the calling chain. Although it is a designed security control, if mistreated may lead to unexpected unauthorised execution.
- Proposal templates that leak data. Every field is visible to the observer; keep proposals minimal and move internal data to separate contracts.
Multiple Party Agreement
Propose-Accept works fine when two parties need to reach consensus. But plenty of real workflows involve more than two. Staying with the lending example: before a bank can issue a loan, it often needs a third party – a KYC (Know Your Customer) provider – to verify the borrower’s identity, run anti-money-laundering checks, and certify that the borrower meets regulatory requirements. The bank cannot just take the borrower’s word for it, and the borrower cannot self-certify. A bilateral agreement between bank and borrower is not enough because it lacks the KYC provider’s attestation. And chaining Propose-Accept across three parties forces an artificial ordering on who signs when, while also requiring a separate intermediate template for every step.
The Multiple Party Agreement pattern sidesteps this by introducing a single PendingKycLending contract that wraps the eventual multi-signatory KycLendingAgreement. Any party can kick off the process by creating a PendingKycLending contract with only their own signature. The other parties then sign one by one, in whatever order they like. Once all signatures are collected, any of them can finalize, and the actual KycLendingAgreement contract is created on the ledger.
The final agreement carries a list of signatories – the bank, the borrower, and the KYC provider – along with the loan terms:
template KycLendingAgreement
with
signatories : [Party]
borrower : Party
limit : Decimal
rate : Decimal
where
signatory signatories
ensure unique signatories && length signatories >= 3
The PendingKycLending contract holds the proposed KycLendingAgreement as a field, along with a list of parties who have already signed. Every signatory of the future agreement is listed as an observer, so they can see the pending contract and know what they are being asked to agree to:
toSign : PendingKycLending -> [Party]
toSign PendingKycLending { alreadySigned, finalContract } =
filter (`notElem` alreadySigned) finalContract.signatories
template PendingKycLending
with
finalContract : KycLendingAgreement
alreadySigned : [Party]
where
signatory alreadySigned
observer finalContract.signatories
ensure unique alreadySigned
choice Sign : ContractId PendingKycLending
with
signer : Party
controller signer
do
assert (signer `elem` toSign this)
create this with
alreadySigned = signer :: alreadySigned
choice Finalize : ContractId KycLendingAgreement
with
signer : Party
controller signer
do
assert (sort alreadySigned == sort finalContract.signatories)
create finalContract
The workflow in practice looks like this: the bank creates the PendingKycLending contract, listing themselves as the sole entry in alreadySigned. The borrower and the KYC provider are part of finalContract.signatories, so they appear as observers and can see the contract. Each exercises the Sign choice, which consumes the current PendingKycLending and creates a new one with the signer added to alreadySigned. Once all three parties have signed, any one of them can exercise Finalize, which verifies that the alreadySigned list matches the finalContract.signatories and creates the KycLendingAgreement.
The interesting part is how authority accumulates. When the bank creates the initial PendingKycLending, they are the only signatory. When the borrower exercises Sign, the old PendingKycLending is archived (requiring the bank’s authority, which they provided by signing), and a new one is created with both the bank and the borrower as signatories. When the KYC provider signs next, authority from all three parties is embedded in the contract. By the time Finalize runs, the PendingKycLending is signed by everyone, and that combined authority is enough to create the final KycLendingAgreement.
The toSign helper computes the difference between the full signatory list and the already-signed list. The Sign choice checks that the exercising party is actually in that remaining list – you cannot sign on behalf of someone else, and you cannot sign twice. The unique ensure clause on alreadySigned prevents duplicate entries, while the assertion on toSign prevents unauthorized signers.
Security Pitfalls
- No rejection or withdrawal mechanism. Without Reject, Withdraw, and an expiry, a stalled PendingKycLending lives on the ledger forever.
- Signature ordering creates intermediate exposure. All terms in finalContract are visible to every listed signatory from the first PendingKycLending, long before anyone commits.
- Liveness depends on universal participation. A single absent party blocks finalization; a timeout or quorum rule trades unanimity for liveness.
- Authority accumulation is authority concentration. Once everyone has signed, every choice on PendingKycLending needs unanimous authority – keep the template minimal.
- Duplicate or conflicting pending contracts. Nothing prevents parallel PendingKycLendings with different terms; enforce uniqueness via contract keys or application logic.
Delegation In Daml
The patterns covered so far deal with getting a contract created with the right signatories. But once a contract exists on the ledger, a different question comes up: who actually exercises its choices? In the simple case, it is one of the original parties – the bank or the borrower acts directly. Real workflows are rarely that clean. Banks outsource loan servicing to specialized firms. Asset managers act on behalf of pension funds. Custodian banks settle trades for their clients. In each case, one party grants another the right to exercise specific actions on their behalf, without having to participate in every individual transaction.
The Delegation pattern is how you model this. The principal (the party who has the right to exercise a choice) signs a separate contract – call it a power of attorney – naming an attorney (the party who will exercise the choice on the principal’s behalf). The attorney does not gain ownership or signatory rights on the underlying contract. They get a narrow, controlled ability to invoke specific choices.
Continuing the lending example, suppose the bank wants to delegate loan servicing to a third-party servicer. The servicer should be able to issue periodic statements to the borrower without the bank having to submit each statement transaction. The servicer is not a party to the loan, does not co-sign the agreement, and cannot modify its terms. They get a single, scoped capability: issue statements on the bank’s behalf.
For delegation to work, the underlying contract must have a choice whose controller is the principal alone. The lending agreement from earlier sections needs a small extension – a bank-controlled IssueStatement choice, plus a way to disclose the agreement to the servicer:
template LoanStatement
with
bank : Party
borrower : Party
issuedBy : Party
period : Text
amountDue : Decimal
where
signatory bank
observer borrower
template LendingAgreement
with
bank : Party
borrower : Party
limit : Decimal
rate : Decimal
delegates : [Party]
where
signatory bank, borrower
observer delegates
choice IssueStatement : ContractId LoanStatement
with
period : Text
amountDue : Decimal
issuedBy : Party
controller bank
do
create LoanStatement with
bank = bank
borrower = borrower
issuedBy = issuedBy
period = period
amountDue = amountDue
choice DiscloseTo : ContractId LendingAgreement
with
newDelegate : Party
controller bank
do
create this with
delegates = newDelegate :: delegates
The IssueStatement choice is controlled by the bank – only the bank’s authority can invoke it. The delegates field, plus the observer clause, are how the servicer is allowed to see the contract; without disclosure, the servicer cannot reference the agreement in any transaction.
The power of attorney is its own contract:
template ServicingPoA
with
bank : Party
servicer : Party
where
signatory bank
observer servicer
choice WithdrawPoA : ()
controller bank
do
pure ()
nonconsuming choice ServiceIssueStatement : ContractId LoanStatement
with
agreementId : ContractId LendingAgreement
period : Text
amountDue : Decimal
controller servicer
do
exercise agreementId IssueStatement with
period = period
amountDue = amountDue
issuedBy = servicer
The bank is the sole signatory of the ServicingPoA. The servicer is an observer, so they can see the contract and exercise its choices. The ServiceIssueStatement choice is nonconsuming – the servicer can use it many times without the PoA being archived. The bank also keeps a WithdrawPoA choice so they can revoke the delegation when the relationship ends.
The interesting mechanics are inside ServiceIssueStatement. The body exercises IssueStatement on the lending agreement. That choice requires the bank’s authority. Where does it come from? Not from the servicer – they are just the actor and have no authority over the bank. It comes from the signatories of the contract being exercised: the ServicingPoA. The bank signed the PoA, so the bank’s authority is available throughout any choice exercised on it. When the body invokes IssueStatement, the runtime combines the actor’s authority (servicer) with the signatories’ authority (bank), and the bank’s authority satisfies what IssueStatement needs.
Disclosure is the other half of the picture. The bank has to add the servicer to a given agreement’s delegates list before the servicer can reference that agreement’s contract ID in ServiceIssueStatement. Without observer status, the servicer would not know the contract ID exists, and even if they somehow obtained it, the runtime would reject the exercise because the servicer cannot see the contract. The DiscloseTo choice handles this.
Security Pitfalls
- The PoA is broader than it looks. It applies to any agreement ID the attorney can see; scope it by embedding the target ID or binding via contract keys.
- Disclosure is a one-way valve. Removing an observer requires archiving and recreating the agreement with all signatories’ authority; revoking the PoA does not undo prior disclosure.
- Revocation has a race window. The PoA is exercisable until WithdrawPoA commits; embed an expiry checked via getTime for high-stakes delegations.
- Authority does not extend beyond the principal. Delegation only works for choices the principal alone can exercise.
- Divulgence through choice exercise. Results of nested choices – including fetched data – are silently disclosed to the attorney.
- Attorneys can become de facto signatories. Wide PoAs hide the real actor behind the principal’s signature; log the attorney (e.g., issuedBy) and keep each PoA narrowly scoped.
Explicit Authorization In Daml
The controller keyword answers a narrow question: which party submits the transaction that exercises a choice? But real workflows often need something different: did a specific third party give permission for this particular action to happen? The controller is the actor; authorization is about whether the actor is qualified, vetted, or licensed to do what they are about to do. Daml does not have a built-in authorization primitive beyond signatories and controllers – but the type system makes it straightforward to model authorization as a contract.
The Explicit Authorization pattern uses a dedicated contract whose existence on the ledger is the authorization. The party who issues the authorization signs it; the recipient is an observer. When a choice somewhere else needs to verify that authorization is in place, it accepts a ContractId of the authorization contract as a parameter, fetches it, and asserts that its fields match the action being performed. No authorization contract on the ledger, no successful exercise.
Continuing the lending example, suppose a borrower wants to transfer their loan to a different person – a younger relative assuming the mortgage, a business partner taking over a commercial loan, or a buyer in a secondary loan sale. The bank cannot allow this unconditionally. The new borrower has to be vetted: credit checks, KYC, income verification. The pattern is to issue a BorrowerAuthorization contract for each party the bank has approved as a qualified borrower. The transfer flow then requires the new borrower to present their authorization, and the TransferLoanProposal_Accept choice verifies it before completing the transfer.
The authorization contract is small. Its sole purpose is to exist on the ledger as proof that the bank has approved a specific party:
template BorrowerAuthorization
with
bank : Party
party : Party
where
signatory bank
observer party
choice BorrowerAuthorization_Revoke : ()
controller bank
do
pure ()
The bank is the signatory, so only the bank can issue or revoke this contract. The named party is an observer, which lets them see the authorization and reference it later. The lending agreement gets a new field that names a single prospective transferee, plus a choice that initiates the transfer:
template LendingAgreement
with
bank : Party
borrower : Party
limit : Decimal
rate : Decimal
pendingTransferTo : Optional Party
where
signatory bank, borrower
observer pendingTransferTo
choice LendingAgreement_ProposeTransfer : ContractId TransferLoanProposal
with
newBorrower : Party
controller borrower
do
newAgreementId <- create this with
pendingTransferTo = Some newBorrower
create TransferLoanProposal with
bank = bank
currentBorrower = borrower
newBorrower = newBorrower
agreementId = newAgreementId
limit = limit
rate = rate
ProposeTransfer is consuming. The body archives the current agreement and creates a new version that names the new borrower in pendingTransferTo. Through the observer pendingTransferTo clause, the new borrower automatically becomes an observer on the new agreement, so they can see the loan terms and reference the contract ID inside the Accept choice. From the outside, the agreement is still alive – same bank, same current borrower as signatories, same terms – but it now has exactly one prospective transferee attached. Modeling this as Optional Party rather than a list is intentional: it structurally bounds the observer set to at most one transferee at a time, which simplifies the cleanup story we are about to introduce.
The transfer proposal carries the loan terms and a contract ID pointing to this newly recreated version of the agreement. It is signed by both the bank and the current borrower – the bank’s authority comes through because the proposal is created from inside a choice on the lending agreement, where the bank is already a signatory:
template TransferLoanProposal
with
bank : Party
currentBorrower : Party
newBorrower : Party
agreementId : ContractId LendingAgreement
limit : Decimal
rate : Decimal
where
signatory bank, currentBorrower
observer newBorrower
choice TransferLoanProposal_Accept : ContractId LendingAgreement
with
auth : ContractId BorrowerAuthorization
controller newBorrower
do
authorization <- fetch auth
assert (authorization.bank == bank)
assert (authorization.party == newBorrower)
archive agreementId
create LendingAgreement with
bank = bank
borrower = newBorrower
limit = limit
rate = rate
pendingTransferTo = None
choice TransferLoanProposal_Reject : ContractId LendingAgreement
controller newBorrower
do
archive agreementId
create LendingAgreement with
bank = bank
borrower = currentBorrower
limit = limit
rate = rate
pendingTransferTo = None
choice TransferLoanProposal_Withdraw : ContractId LendingAgreement
controller currentBorrower
do
archive agreementId
create LendingAgreement with
bank = bank
borrower = currentBorrower
limit = limit
rate = rate
pendingTransferTo = None
All three choices on the proposal – Accept, Reject, and Withdraw – archive the current agreement and recreate it. The differences are who ends up as borrower and whether pendingTransferTo is reset. Accept produces an agreement owned by the new borrower with pendingTransferTo = None. Reject and Withdraw produce an agreement that keeps the original borrower with pendingTransferTo = None.
The cleanup on the negative paths is mandatory: without it, the new borrower would remain an observer of the loan after a rejection that was supposed to mean “this transfer is off.” The runtime would not catch this – it would leave the agreement on the ledger with a stale observer – and a casual reader of the proposal template would not see anything wrong with Reject returning pure ().
The Accept choice is where the authorization is enforced. The new borrower has to pass in the contract ID of their BorrowerAuthorization. The choice body fetches it, asserts that the authorization was issued by the same bank involved in this loan, and asserts that the named party in the authorization matches the new borrower. If either assertion fails, the transaction aborts – no archive, no new agreement. If both pass, the original agreement is archived and a new one is created with the new borrower as signatory.
The authority flow on Accept is worth tracing. The signatories of the proposal are the bank and the current borrower. The new borrower is the actor. That gives the choice body authority from all three parties. Archiving the old agreement requires the bank plus current borrower – both present. Creating the new agreement requires the bank plus new borrower – both present. The fetch auth call requires the new borrower to be able to see the authorization, which they can because they are an observer. The two assertions are pure data checks; they do not consume authority, but they are the entire point of the pattern.
Security Pitfalls
- Forgetting the assertions. Passing an authorization ID without checking its bank and party fields type-checks, but lets any authorization unlock any transfer.
- Failing to clean up observers leaves a privacy trail. Every negative path must archive-and-recreate with the rejected party removed; a list-based variant turns missed cleanups into a permanent leak.
- Authorizations that outlive their usefulness. Without an expiry or revocation discipline, a stale authorization can unlock a transfer the bank would no longer approve.
- Reusable authorizations create transfer loops. A non-consuming authorization can be reused; archive it on use or expose a consuming Use choice if single-use is required.
- Authorization issuance leaks identity. Each authorization discloses the bank’s approval; issue from a private bank-only contract when the approval list is sensitive.
- Asymmetric trust between authorization and proposal. The authorization only binds the bank – the current borrower must still agree to transfer.
- Revocation is forward-looking only. Revoking an authorization does not unwind transfers already completed with it.
Asset Locking Patterns In Daml
In many financial workflows, an asset needs to be frozen for a period of time – held in place so that no party can move, split, or transfer it while a related process completes. During clearing and settlement, the seller’s securities are locked until the buyer’s payment arrives. In a collateral arrangement, pledged tokens must remain untouched until the obligation they secure is discharged. In all of these cases, “locking” means selectively disabling certain choices on a contract while keeping it on the ledger and visible to its stakeholders.
Daml has no built-in lock primitive. What it gives you is immutable contracts, consuming choices, and party-based control – and from those building blocks, developers construct locking by hand. The Daml documentation describes three approaches: locking by archiving, locking by state, and locking by safekeeping. Each trades off simplicity, flexibility, and trust assumptions differently. Of the three, locking by state is the most practical when you need selective freeze semantics without introducing extra templates or custody transfers.
The idea is simple: encode the lock as a field on the asset contract itself. In its unlocked state, the contract looks and behaves normally – all choices are available. When the owner exercises a Pause choice, the contract is consumed and recreated with an isLocked flag set to True. Every state-changing choice on the template checks that flag before proceeding:
template LockableCoin
with
issuer : Party
owner : Party
amount : Decimal
isLocked : Bool
where
signatory issuer, owner
ensure amount > 0.0
choice LockableCoin_Transfer : ContractId LockableCoin
with
newOwner : Party
controller owner, newOwner
do
assert (not isLocked)
create this with
owner = newOwner
isLocked = False
choice LockableCoin_Pause : ContractId LockableCoin
controller owner
do
assert (not isLocked)
create this with
isLocked = True
choice LockableCoin_Unpause : ContractId LockableCoin
controller owner
do
assert isLocked
create this with
isLocked = False
When isLocked is False, the coin is active and every choice is available. When it is True, the coin is paused and any choice carrying the assert (not isLocked) guard will fail. Pause itself is gated on the unlocked state so a coin cannot be double-paused, and only the owner can toggle the flag in either direction. Unpause reverses the process – consumes the paused contract, recreates it with the flag cleared.
This keeps the schema minimal – one template, one extra field – and lets you gate choices selectively. A read-only query choice that does not move value can skip the lock guard entirely and remain callable while the coin is frozen. A Split choice can carry the guard; a QueryBalance choice can skip it. That selective control is the main advantage over the archiving approach, where locking replaces the entire contract with a different template and disables all choices unconditionally.
The two other approaches to locking are worth knowing about.
Lock by Archiving adds a consuming Lock choice to the original asset template. When exercised, it archives the asset and creates a separate LockedAsset contract that holds the original data plus locking terms. Because the original contract no longer exists, every choice on it is unreachable – locking is total and enforced by the UTXO model itself. The locker holds an Unlock choice on the LockedAsset that recreates the original. The upside is conceptual simplicity and a clean on-ledger state (either the asset is active or the locked version is, never both). The downsides are significant: the original template must be amended to add the Lock choice – which requires agreement from all signatories if the template is shared – and any choice that should remain available during the lock must be duplicated on the LockedAsset template, leading to code duplication. The contract ID also changes after every lock/unlock cycle, breaking any out-of-band reference to the original.
Lock by Safekeeping leaves the original asset template untouched and models locking as a custody transfer. A LockRequest contract (signed by the locker) proposes the arrangement; the asset owner accepts it, transferring on-ledger ownership to the locker. A LockedAsset contract records the arrangement and provides Unlock (for the locker) and Clawback (for the owner, gated on maturity). This mirrors real-world escrow: the asset changes hands, and the locker holds it until conditions are met. The advantage is that the original template needs no modification. The disadvantage is trust: the locker becomes the on-ledger owner with full owner privileges, including the ability to transfer the asset to a colluding party. A rogue locker can drain the asset before maturity, and the original owner’s clawback fails because the stored contract ID has already been archived by the rogue transfer. This is the digital equivalent of a dishonest escrow agent disappearing with the funds – the pattern is only as safe as the locker.
Security Pitfalls
- Missing lock guards on choices. The compiler cannot verify that every state-changing choice checks isLocked. A single choice without the assert (not isLocked) guard is silently exercisable while the asset is supposedly frozen – and the bug is invisible from code review unless the reviewer audits every choice body.
- Lock without expiry is a griefing vector. If the owner locks the coin and then loses their keys or refuses to unlock, the asset is stranded. Consider adding a maturity-based clawback or a time-bound automatic release.
UTXO Model
What the UTXO (Unspent Transaction Output) Model Is
The unspent transaction output model, popularized by Bitcoin, treats balances the way a wallet treats physical bills. Your wallet does not store a single number labelled “fifty euros.” It holds a 20€ note, another 20€ note, and a 10€ note. Each note exists on its own and can be spent independently. When you pay 30€ for something, you hand over two 20€ notes and get a new 10€ note as change. The originals are destroyed; the change note is issued fresh. You cannot decrement the value printed on a note – notes are immutable. The only operation is “destroy some notes, create new ones.”
A UTXO ledger works the same way. Every coin lives in an output identified by the transaction that created it. An output is immutable: its amount, its owner, and its denomination are fixed for life. A spending transaction consumes one or more existing outputs as inputs and creates new outputs that together account for the same total value, minus any fee. The active state of the ledger at any moment is the set of outputs that have not yet been consumed, and a user’s balance is the sum of the outputs they own. There is no central account record, no row to update – just an ever-growing set of immutable notes, some still in circulation, some already torn up.
Spending Is Sequential Per Coin
This per-coin granularity has a direct effect on throughput. Two transactions targeting different coins are independent and can be processed in parallel – no shared object to contend on. Two transactions targeting the same coin must be sequential: the first has to finalize before the second can reference whatever change it produced.
Here is a concrete example. Alice owns a single coin worth 50 tokens and wants to make two payments – 40 to one merchant and 10 to another. She cannot submit both at once. The first transaction consumes the 50-token coin, sends 40 to the merchant, and produces a new 10-token coin as change. Only after that transaction is finalized does the 10-token coin exist on the ledger; only then can Alice reference it as the input to the second payment. Had she pre-split her holdings into a 40-token coin and a 10-token coin earlier, both payments could go through in parallel, because each would target a different output. The model rewards owners who keep their balance fragmented: parallelism is bought at the cost of holding more coins.
Privacy Through Selective Disclosure
The UTXO model also limits how much a transaction has to reveal. In an account-based system, a transfer references the sender’s account, and the account’s full balance is part of the global state that anyone can read – the transaction implicitly discloses how much the sender holds. In a UTXO system, a transaction only references the specific outputs being consumed. If Alice has ten coins of various sizes and pays for a 30-token purchase using one 30-token coin, the transaction discloses one input worth 30 tokens. Her remaining nine coins are not referenced anywhere in the transaction, and on a privacy-preserving ledger, they remain invisible to the counterparty and to outside observers. The user discloses the amount needed for the trade, not the full balance.
On Canton, this property is reinforced by the network’s stakeholder-based privacy: each coin is a contract whose visibility is restricted to its issuer and its owner. A merchant who receives a 30-token payment learns about the 30-token coin and the resulting transfer; they do not learn how many other coins the payer holds. The merchant is not a stakeholder on those other contracts and does not see them on their participant node.
A Coin Template
The mechanics of split, merge, and per-coin authority are easier to see in code. Here is a minimal Daml model of a transferable token – a single coin with an issuer, an owner, and an amount, plus the two choices that handle value movement in the UTXO style.
template Coin
with
issuer : Party
owner : Party
amount : Decimal
where
signatory issuer, owner
ensure amount > 0.0
choice Split : (ContractId Coin, ContractId Coin)
with
splitAmount : Decimal
controller owner
do
assert (splitAmount > 0.0 && splitAmount < amount)
firstCid <- create this with
amount = splitAmount
secondCid <- create this with
amount = amount - splitAmount
pure (firstCid, secondCid)
choice Merge : ContractId Coin
with
otherCid : ContractId Coin
controller owner
do
other <- fetch otherCid
assert (other.issuer == issuer)
assert (other.owner == owner)
archive otherCid
create this with
amount = amount + other.amount
Both Split and Merge are consuming choices, so the original Coin is archived the moment the choice runs. Split produces two new coins whose amounts sum to the original – the assertion enforces that splitAmount is strictly between zero and the full amount. Merge takes the second coin’s contract ID as an argument, fetches it to confirm it has the same issuer and owner, archives it, and creates a replacement coin with the combined amount. Those assertions on issuer and owner are doing critical work that the type system cannot do on its own: without them, an owner could merge coins from different issuers (effectively counterfeiting a currency) or absorb coins they do not actually own.
Spending a coin in this model means splitting it into “the part being sent” and “the change,” then transferring the first part to the recipient (a transfer choice, omitted here for brevity, would archive the coin and recreate it with a new owner). The 50-into-40-and-10 scenario from earlier maps directly: the owner exercises Split on the 50-token coin with splitAmount = 40.0, the choice archives the original and produces two new contract IDs – one for 40, one for 10 – and only then can either be used as input to a subsequent payment.
Security Pitfalls
- Contention on shared coins. Concurrent consuming exercises on the same contract race; shared objects become a DoS surface. Split workflows across independent contracts and pre-split holdings.
- Archive-and-create amplifies field-copy bugs. Recreating a coin requires copying every unchanged field by hand; a missed field silently issues coins with wrong values.
- Missing assertions on Merge. Without issuer and owner checks, an owner can counterfeit currencies or absorb coins they do not own.
- Contract IDs become stale immediately. Every exercise invalidates prior IDs; cached off-chain IDs lead to transactions on already-consumed coins.
- Visibility of consumption is asymmetric. Observers at creation may never learn of a later archival if they are not stakeholders on the consuming exercise.
- The active set grows without bound. Dust coins accumulate; the runtime never compacts – wallet software must call Merge.
- Privacy depends on disclosure discipline. A choice that fetches unrelated coins divulges them to the actor; fetch only what is needed and keep auxiliary data on separate contracts.
How This Differs From Solidity
Solidity contracts on Ethereum work the other way around. An ERC-20 token contract holds a single mapping from address to balance. A transfer is a function call that decrements one balance and increments another, mutating storage slots values in place. There is no notion of an individual coin – the value an address controls is a number on a ledger row, not a collection of separately identifiable objects.
That basic difference cascades into several others:
Concurrency. Every transfer from the same sender in ERC-20 contends on the same storage slot for the sender’s balance, even if the recipients are different. Two transactions from the same address are inherently sequenced by the account’s nonce, not by which value they want to move. The UTXO model only sequences transactions that touch the same output; transactions on different outputs are free to execute in any order.
Privacy. An ERC-20 balance is a single number stored in a public mapping; anyone can query the contract and read every holder’s balance. A UTXO balance is split across many independent objects, and on a privacy-preserving network, those objects can be visible only to the parties involved with each one.
Authorization. In Solidity, a function decides who can call it by reading msg.sender and consulting the contract’s own logic – require(msg.sender == owner) and similar checks. In Daml’s UTXO model, authorization is structural: a choice has a controller, the controller is a field of the contract being exercised, and the runtime enforces who can submit the exercise without the choice body having to check. The check is part of the type, not part of the body.
Contract identity. A Solidity token contract has a single, stable address; users hold balances against that one contract for as long as it is deployed. A Daml Coin contract is identified by a ContractId that names one specific revision of one specific coin. The moment that a coin is split, merged, or transferred, the original contract ID is gone, and new contract IDs replace it. Off-chain code that holds a contract ID is holding a reference to a particular state of the ledger, not to a long-lived object. Code that needs to refer to a coin across a sequence of operations either passes contract IDs forward through each step or uses a contract key to fetch the current revision by a stable, business-meaningful name.
Conclusion
Every primitive in Daml – signatories, observers, controllers, consuming choices – exists to express who can do what, under which conditions, with visibility restricted to exactly the parties that need it. The patterns covered in this article are not convenience abstractions. They are the building blocks from which real financial infrastructure gets assembled, and each one encodes specific trust relationships and information boundaries directly into the ledger.
If there is a single recurring theme across all of them, it is that correctness in Daml means getting three things right:
- Liveness. Every workflow must be able to reach completion: proposals must be finalizable, agreements must be executable, and delegated actions must carry the right authority end-to-end. There should be no dead-end states where a contract sits on the ledger without a viable path forward.
- State hygiene. Stale state should not be reachable. Archived contracts must not leave behind dangling observers, outdated references, or zombie proposals that can be exercised long after their business context has expired.
- Privacy containment. Each actor should learn only what they need to – nothing more. Divulgence, observer lists, and fetch patterns all determine what leaks beyond the intended audience. A single misplaced fetch or a forgotten observer cleanup can turn a privacy-preserving workflow into one that silently exposes sensitive data.
These are not edge cases. They sit at the center of Daml security, and the compiler will not catch most of them. Getting the authorization model right is table stakes; getting the information flow right is what separates a Daml application that works from one that meets its functional requirements while quietly violating its privacy guarantees.
Next step: if you are designing on Daml, audit every workflow for three things before mainnet: who can act, who can still see the data after the workflow ends, and which stale contracts remain reachable.



