Canton Asset Servicing: implementation draft
Coupons, redemptions and corrections on a tokenized security, including one that is pledged, across applications built by different vendors. Companion to the Development Fund proposal. 2026-10-27, PixelPlex.
Written against the token standard as shipped in Splice 0.8.3 (token-standard/, daml/splice-api-token-burn-mint-v1/) and CIP-0112. The splice-api-token-* and burn-mint packages are unchanged through Splice 0.9.4, so every reference below holds there too.
Build target. All 24 token-standard daml.yaml files (23 under token-standard/ plus daml/splice-api-token-burn-mint-v1/) are sdk-version: 3.5.2 with --target=2.1. Nineteen of the 24 also carry --explicit-serializable=yes; the five without it are allocation-instruction-v1, allocation-v1, holding-v1, metadata-v1 and transfer-instruction-v1; allocation-request-v1, also V1-generation, carries it. This package takes all three. The constraint decides the arithmetic in §5.2, and Serializable as a deriving clause does not exist before SDK 3.5.2.
Code blocks below compile at that target, in two packages: the interfaces in one, and the registry-side template of §4.3 with the §2 helper and the §5.2 expression in another (§4.3 explains why they cannot share a package). Both packages exist as PixelPlex's working prototype, with scripts asserting the §5.4 rounding examples, every rounding direction, the decimals range and the 10^18 overflow. The prototype compiles the interfaces and checks the arithmetic; no template implements the interfaces yet. It becomes the starting point of Milestone 2's public repository.
1. Goal and non-goals
Goal. A registry announces that an instrument pays or redeems, derives who is owed what as of a record time, and pays it. A custodian, a collateral application and a wallet built by three other vendors read the entitlements and redirects from their own participants, and the event terms from the entitlement or the registry's API, without a bilateral integration. The case it must handle is a pledged bond, where the recorded holder is not the party the coupon is owed to.
Version 1 covers cash distributions (coupon, dividend, interest), full redemption, redirection of income to a pledgee, payment with a record of what has been paid, and corrections and cancellations before and after payment.
Extensions beyond version 1. Each is a separate package on top of this one, proposed separately (§13): holder elections, unit changes (splits, consolidations), partial redemption, allocating a rounding residual against a funded total, securities-lending equivalent payments, and cross-instrument corporate actions (mergers, spin-offs, rights issues). An extension reuses version 1's keys and leaves its interfaces as they are. Anything it needs beyond them, such as a funded total, a unit result or an option set, it carries in interfaces of its own, implemented alongside version 1's on the same contracts.
Non-goals. A new payment rail (distributions use the token standard's own allocations and transfers). Tax logic (withholding is a declared input). Valuation and risk parameters (#665). Liquidation mechanics (#232). Legal enforceability of any agreement a redirect points at.
2. Actors
| Actor | Is | Does |
|---|
| Instrument admin | InstrumentId.admin (the registry) | announces events, derives entitlements, pays, corrects |
| Holder | account principal of an Account holding the instrument | owed the income, unless part of it is redirected |
| Account provider | Account.provider, where set (for example a custodian) | already sees its clients' holdings; sees their entitlements and every redirect on their positions |
| Beneficiary | the pledgee | receives redirected income |
| Paying agent | the admin, or a party the admin delegates to | pays and records the payment (§6); a stakeholder of every entitlement it settles |
Roles are technical. Each role names what a party does on the ledger, not a licensed function. An account provider is the party in Account.provider; a paying agent is the party that exercises LifecycleEvent_SettleBatch or Entitlement_Settle. Whether a party in either role needs a license is a matter for its jurisdiction, which this standard neither requires nor addresses. Where the text says custodian, it means an account provider acting for its clients.
Account parties, throughout, means what the standard's own helper means:
accountParties : Party -> HoldingV2.Account -> [Party]
accountParties admin account = case account.provider of
Some provider | provider /= principal -> [principal, provider]
_ -> [principal]
where principal = fromOptional admin account.owner
The principal (the owner, or the admin for a special account with owner = None), plus the provider only where one is set and differs. For a basic account (basicAccount, no provider) it is one party. Nothing here may assume a provider exists.
Source: token-standard/splice-token-standard-utils/daml/Splice/TokenStandard/Utils/Internal/Conversions.daml:147-170.
3. What the standard gives us, and where this attaches
| We reuse | From | Why it matters here |
|---|
Account { owner : Optional Party, provider : Optional Party, id : Text } | HoldingV2.daml | an entitlement is owed to an account, so the custody relationship is explicit |
InstrumentId { admin, id } | HoldingV2.daml | the admin is the only party that may announce an event |
HoldingView { account, instrumentId, amount, lock, meta } | HoldingV2.daml | the derivation input |
accountParties | TokenStandard/Utils | the stakeholder rule we adopt for entitlements |
cip-112/mint, cip-112/burn accounts | CIP-0112 §4.3.2.1 | redemption is an allocation leg to the burn account; BurnMintFactory is not used |
SettlementFactory_SettleBatch, Allocation, TransferFactory_Transfer | AllocationV2.daml, TransferInstructionV2.daml | the movements a settlement performs: batched over allocations, or a single transfer (§6) |
Receipt-only delegation, checked by ensureIsReceiptAllocation | TradingAppV2.daml (TradeSettlementAgreement_CreateReceiptAllocation), TokenStandard/Utils | the paying agent creates receipt allocations for the payable accounts (§6) |
ExtraArgs { context : ChoiceContext, meta : Metadata } | MetadataV1.daml | registry-provided context fetched over the off-ledger API |
Factory + _PublicFetch + actors : [Party] + availableActions | TransferInstructionV2.daml, AllocationV2.daml | the V2 shape wallets already render |
Metadata keys with a prefix and a name (CIP-0056 key syntax); V2's own keys use the prefix cip-112/ | CIP-0056, MetadataV1.daml:57-61, Conversions.daml (tokenStandardV2Namespace) | our references use cip-<nr>/ the same way (§6, §12.1) |
What is missing:
Lock has four fields: holders : [Party], expiresAt, expiresAfter and context : Optional Text, the last documented as wallet display and possibly visible more widely than the contracts it describes. It cannot say which obligation it secures, or who gets the coupon.
- No event, no record time, no entitlement in CIP-0056 or CIP-0112.
Why the event and its entitlements are not a V2 settlement and its legs. The shapes are alike: an event names a payment run, entitlements look like its legs, and partial payments and corrections look like settlement iterations. They differ on every point servicing depends on:
- An allocation is an authorization, not a record.
Allocation_Settle archives it, and the next-iteration allocation, if any, carries funding, not history. After settlement no V2 contract states what was owed and what was paid. The entitlement stays live as that statement, with outstanding positive, zero or negative.
- V2 does not say who is entitled. The executor specifies the legs;
SettlementInfo is executors, id, an optional cid and meta, with no terms. The snapshot at recordTime, the per-unit rate and the rounding (§5) have no V2 counterpart.
- A receipt allocation cannot be iterated. Iteration has to be enabled when the allocation is created, by setting
nextIterationFunding, and ensureIsReceiptAllocation requires it to be None. Each partial payment is therefore its own settlement, and paidAmount is what links them.
- A correction is not an iteration. An iteration changes legs before money moves. A correction arrives after the money moved and the allocation was archived; it needs the supersession link,
paidAmount carried over, and a negative outstanding when the payment was too large (§7), which no leg can express.
- A redirect has no counterpart at all.
Each settlement batch is still a V2 settlement: its SettlementInfo.id is built from the event key and the batch number, and its meta carries the three reference keys (§6).
Source: AllocationV2.daml (SettlementInfo, AllocationSpecification.nextIterationFunding, Allocation_Settle); Utils/Internal/Allocations.daml (ensureIsReceiptAllocation).
V1 and V2 conventions differ by design, and we follow V2. AllocationV2's module header states that V2 "removes the need to specify the expectedAdmin party, as its value is already specified in other choice arguments or not relevant (for _PublicFetch choices)". V2 packages take actors : [Party] and no expectedAdmin; V1-generation packages, including burn-mint-v1, take expectedAdmin and one actor. A V2 token carries interface instance V1.Holding and interface instance V2.Holding on the same template, so one registry serves both generations.
Source: splice-api-token-allocation-v2/daml/Splice/Api/Token/AllocationV2.daml:18-19; examples/splice-test-token-v2/daml/Splice/Testing/Tokens/TestTokenV2/Holding.daml:31-35.
_PublicFetch belongs on factories only. All six declarations in 0.8.3 are on factories (TransferFactory V1/V2, AllocationFactory V1/V2, SettlementFactory, BurnMintFactory), and EventLogView's doc comment states the rule for data contracts: "EventLog contracts do not support public fetching, as they are intended to be used by the instrument admin or app provider only." Our factory carries it; LifecycleEvent, Entitlement and EntitlementRedirect do not. A V2 _PublicFetch validates no admin: the caller compares the returned view against its own trusted source of the admin, which V1 had the caller pass in as expectedAdmin.
Source: git grep -n "choice .*_PublicFetch" 0.8.3 -- 'token-standard/splice-api-token-*' 'daml/splice-api-token-burn-mint-v1'; TransferEventsV2.daml:109-112; BurnMintV1.daml:58-70.
3.1 Daml Finance and the shipped reference applications
A registry that models its bond in Daml Finance already lifecycles it there: a lifecycle rule evaluates the instrument and produces an effect stating, per unit of the target instrument, what is consumed and what is produced, with a settlement time. That effect is internal to the registry's own application. The servicing interfaces are its cross-application projection, and the table maps one onto the other:
| Daml Finance, inside the registry | Servicing, across applications |
|---|
| the effect's target instrument | EventKey.instrumentId |
| a cash quantity produced per unit | ratePerUnit, payInInstrument |
| the effect's settlement time | paymentTime |
| the target instrument consumed, nothing else produced but cash | EK_Redemption |
| — | recordTime: Daml Finance settles per holding against an instrument version and has no record instant, so the registry has to fix one |
| — | entitlements, redirects, paidAmount: no counterpart; the registry derives and records them |
Daml Finance has no release for Daml-LF 2.x, and Digital Asset no longer develops it. Its main branch builds with SDK 2.10.0 at --target=1.17, the finance-lifecycling template shipped with SDK 3.5.2 also targets 1.17, and the upgrade to SDK 3.x has stayed an unmerged draft. Since September 2026 the repository's README states that it is not actively maintained and gives no guarantee of compatibility with newer SDK versions. LF 1.x packages do not run on Canton 3.x, so a registry on the Canton Network cannot run Daml Finance. The table above is therefore a guide for teams that know Daml Finance's model, not a deliverable, and no adapter is planned.
Source: digital-asset/daml-finance daml.yaml and README.md on main (README warning added in PR #1305, 2026-09-30); draft PR #1283 (SDK 3.4.8 upgrade); dpm new --template finance-lifecycling with SDK 3.5.2.
Digital Asset's ex-asset-servicing reference application, now archived and built on SDK 1.18, is the precedent for these workflows (coupons, redemptions, dividends and splits). On one point it takes the opposite approach. There the issuer publishes per-unit lifecycle effects and bumps the instrument's version; each holder, or its bank, then applies the effects to its own position, archiving the old version and creating the new one with the cash beside it. The issuer never learns who holds, and that privacy is the application's stated aim. There is no record instant: entitlement is holding the old version when it is converted, as in Daml Finance after it.
We derive entitlements at the registry for three reasons:
- Payment authority. In the reference token, the holding is signed by the admin and the account parties (§13). The holder cannot create the payment from the registry's cash holdings. The coupon must be transferred by the paying agent.
- Privacy. The instrument admin already sees every holding. Deriving entitlements at the registry therefore does not reveal it any additional information about positions.
- Pledged holdings. If the holder processes its own holding, it receives the coupon unless it forwards the payment to the pledgee. The registry can derive the entitlement using both the position and the redirect, and pay the pledgee directly.
The Entitlement template in ex-asset-servicing serves a different purpose: it represents a right to receive an asset at a settlement date, rather than a record of who is owed how much at a given record time. Its templates are also confined to that application and are not intended for other applications to read.
3.2 Relation to ISO 20022
Custodians and paying agents exchange corporate-action data as ISO 20022 seev messages, or their ISO 15022 equivalents. The servicing objects map onto them at message level, so a custodian can translate in both directions without changing its back office:
| Servicing | ISO 20022 (ISO 15022) |
|---|
a LifecycleEvent announced | seev.031 Corporate Action Notification (MT564) |
a corrected event (supersedes) | seev.031 replacing the earlier notification |
EK_CashDistribution | event type INTR (Interest Payment) or DVCA (Cash Dividend) |
EK_Redemption | event type REDM (Final Maturity) or MCAL (Full Call/Early Redemption) |
recordTime | record date RDTE; ours is an instant, theirs a date, and the registry publishes the date beside it (§4.1) |
paymentTime | payment date PAYD |
| an entitlement once derived: units, gross, deductions, net | seev.035 Movement Preliminary Advice, per account |
a settlement, paidAmount increased | seev.036 Movement Confirmation (MT566) |
| an event cancelled | seev.039 Cancellation Advice |
| a correction after payment | no one-to-one counterpart: seev.037 Movement Reversal Advice reverses a booked movement, which this standard never does. Whether a translator emits a reversal and a new seev.036, or a seev.036 for the difference only, is fixed in the Milestone 1 mapping |
| a redirect | no counterpart; a pledge is handled outside these messages |
EventKind stays a two-value enum, because settlement logic depends on it. The ISO event type goes in the event's meta under the cip-<nr>/caev key, as an optional four-letter code, so a translation to seev.031 needs no lookup table of its own. The field-level mapping, element by element and for a stated message version, is part of the CIP in Milestone 1.
Source: Clearstream, "ISO 20022 and ISO 15022 equivalent messages"; ECB AMI-SeCo SCoRE corporate-actions standards (event codes); SWIFT MT564 user handbook, field 98a (RDTE, PAYD).
4. The objects
Three objects, five interfaces: LifecycleEvent; Entitlement; EntitlementRedirect with RedirectInstruction and RedirectFactory.
Factory naming follows the shipped convention (TransferFactory / TransferFactory_Transfer / TransferFactoryView), so the redirect factory is RedirectFactory / RedirectFactory_Propose / RedirectFactoryView.
4.0 Identity: business keys, not contract ids
The token standard is built for Daml-LF 2.1, which has no contract keys, and none of its packages uses them. Canton 3.5 adds contract keys from Daml-LF 2.3, but they are not unique: several active contracts may share a key, so a key would not enforce the one-register-per-position rule of §4.3 either. A standard on the token standard's target cannot rely on keys. Every object here also changes state at least once. Changing a field archives the contract and creates a new one, so a stored ContractId to one of these objects dangles as soon as the correction flow runs.
data EventKey = EventKey with
admin : Party
instrumentId : InstrumentId
eventId : Text
deriving (Eq, Ord, Show, Serializable)
data RedirectKey = RedirectKey with
admin : Party
instrumentId : InstrumentId
holderAccount : Account
redirectId : Text
deriving (Eq, Ord, Show, Serializable)
data EntitlementKey = EntitlementKey with
event : EventKey
account : Account -- ^ the account that held at record time
redirect : Optional RedirectKey -- ^ None = the holder's own share; Some r =
-- the slice allocated under redirect r (4.3)
deriving (Eq, Ord, Show, Serializable)
An entitlement is identified by the slice it pays, not by where it pays. Two redirects from one account to the same beneficiary account produce two entitlements with different keys, each naming its redirect; the beneficiary account is a field of the view (payableTo), and two slices may share it.
References between objects are keys. A ContractId may accompany a key as a resolution hint, never as the reference itself.
The key is authoritative. Where a view carries both a key and a field the key already contains, the key wins; implementations MUST populate them consistently and the conformance suite asserts it.
Registry obligations. These are not ledger invariants: the suite can observe a registry violating one, but nothing prevents it.
eventId is unique within (admin, instrumentId); redirectId within a RedirectKey's other fields.
ref (§4.2) is unique within an event.
- All entitlements of one event carry
terms equal under the derived Eq. No single party can check this, because the event's observers are narrow and a holder sees only its own copy; the suite sees every party and asserts it across them.
4.1 Lifecycle event
No event factory: only the admin may announce, so there is no non-admin initiator for a factory to serve.
data EventKind
= EK_CashDistribution -- coupon, dividend, interest
| EK_Redemption -- full redemption at maturity or on a call
deriving (Eq, Ord, Show, Serializable)
data EventStatus
= ES_Announced
| ES_Confirmed -- ^ derivation complete: every entitlement of the event exists
| ES_Corrected with supersededBy : EventKey
| ES_Cancelled with replacedBy : Optional EventKey -- ^ set when the cancellation
-- replaces the event (7)
deriving (Eq, Show, Serializable)
data RoundingDirection
= RD_HalfEven | RD_HalfUp | RD_Down | RD_Floor | RD_Ceiling
deriving (Eq, Ord, Show, Serializable)
data RoundingRule = RoundingRule with
decimals : Int -- ^ scale of the payment instrument, 0..10 (5.2)
direction : RoundingDirection
deriving (Eq, Show, Serializable)
data EventTerms = EventTerms with
kind : EventKind
recordTime : Time -- ^ 5.1
paymentTime : Time
ratePerUnit : Decimal -- ^ coupon or redemption price per unit
payInInstrument : InstrumentId
rounding : RoundingRule
deriving (Eq, Show, Serializable)
data LifecycleEventView = LifecycleEventView with
key : EventKey
terms : EventTerms
announcedAt : Time
synchronizerId : Text -- ^ the synchronizer recordTime refers to (5.1)
status : EventStatus
supersedes : Optional EventKey
meta : Metadata
deriving (Eq, Show, Serializable)
data EntitlementPayment = EntitlementPayment with
entitlementCid : ContractId Entitlement
amount : Decimal
transferLegId : Text -- ^ the cash leg in `settlements` that pays it
deriving (Eq, Show, Serializable)
data SettlementCall = SettlementCall with
factoryCid : ContractId SettlementFactory
settleArg : SettlementFactory_SettleBatch
deriving (Eq, Show, Serializable)
data SettleBatchResult = SettleBatchResult with
paidAmounts : [Decimal] -- ^ the successors' paidAmount, in the order of `payments`
meta : Metadata
deriving (Eq, Show, Serializable)
interface LifecycleEvent where
viewtype LifecycleEventView
lifecycleEvent_settleBatchImpl
: ContractId LifecycleEvent -> LifecycleEvent_SettleBatch -> Update SettleBatchResult
lifecycleEvent_settleBatchExtraObservers : LifecycleEvent_SettleBatch -> [Party]
-- | Pays a batch of this event's entitlements and records each payment, in one transaction.
-- Implementations MUST: require the event live (`ES_Announced` or `ES_Confirmed`);
-- check every listed entitlement as `Entitlement_Settle` does (`actors == [payingAgent]`,
-- `status == ETS_Live`, `key.event` equal to this event's key, `0 < amount <= outstanding`,
-- with equality for `EK_Redemption`), each listed at most once; match each payment to
-- exactly one cash leg in `settlements`, with receiver `payableTo`, the leg's instrument
-- (with its factory's admin) equal to `payInInstrument`, the payment's amount, and the
-- three reference keys in the leg's `meta` (6); exercise every `SettlementCall` and
-- require every allocation result to be `AllocationResult_Settled`; archive each listed
-- entitlement and create a successor with `paidAmount` increased by its amount.
-- Either all of it happens or none of it does (6).
nonconsuming choice LifecycleEvent_SettleBatch : SettleBatchResult
with
actors : [Party]
payments : [EntitlementPayment]
settlements : [SettlementCall] -- ^ one per instrument admin: the cash registry's,
-- and the serviced registry's for burn legs (6)
extraArgs : ExtraArgs
observer lifecycleEvent_settleBatchExtraObservers this arg
controller actors
do lifecycleEvent_settleBatchImpl this self arg
RoundingDirection is our own enum because DA.Numeric.RoundingMode has no deriving clause, so it is not serializable and cannot appear in a view. The standard fixes five of the stdlib's eight modes, mapping onto RoundingHalfEven, RoundingHalfUp, RoundingDown (toward zero), RoundingFloor and RoundingCeiling. RoundingUp and RoundingHalfDown are not offered: no settlement convention we found uses them, and each extra mode is another way two implementations can differ. RoundingUnnecessary is excluded for a different reason: it is not a rounding convention. It asserts that no rounding is needed and raises a runtime error when that is false.
Stakeholders. Signatory: key.admin. Observers are implementation-defined and narrow: the admin's own operating parties and any party the registry chooses to disclose to. Normatively, the holder set is not among them.
A contract's stakeholders are visible to its stakeholders, so an event observed by every holder's account parties would tell every holder and every provider who else holds that instrument. That amounts to the complete holder register, a larger disclosure than anything in §9. The rule would also be unsatisfiable at announcement, because the entitled set is only fixed at recordTime: a party buying between announcement and record time would not be an observer of the event that entitles it.
An event therefore reaches its audience in two ways, neither through a wide observer set:
- Before the record time, as data over the off-ledger announcement endpoint (§8), for display. A wallet showing "XS123 pays on 31 Dec" needs only the terms.
- After derivation, as
EventTerms denormalized onto each holder's own entitlement, which discloses that holder's line and nothing about any other holder.
Which copy is authoritative. The copy on the entitlement: it is signed by the admin, it is on the holder's own participant, and the payment is checked against it. Version 1 has no elections, so a holder acts on nothing it reads from the announcement endpoint, and an endpoint that served wrong terms could mislead a display but not a payment. A correction is a new event with its own EventKey linked by supersedes / supersededBy (§7). The event key therefore identifies the version, and an entitlement is bound to a single version of the terms, by its key and by its copy.
A registry whose holder set is already public MAY widen the observers; that is its choice and its disclosure.
The paying agent exercises LifecycleEvent_SettleBatch on the event, so the registry either lists it as an observer or discloses the event to it with the choice context. The choice runs with the admin's authority as the event's signatory, which is what lets it re-create the admin-signed entitlements.
recordTime is an instant, not a date. A record date is a calendar convention over a timezone; the ledger has neither. The registry publishes the date in its documentation and the instant on the contract (the date is what ISO 20022 carries as RDTE, §3.2), and the suite asserts they correspond. §5.1 defines what the instant means operationally.
Status. ES_Announced from announcement until derivation; ES_Confirmed once every entitlement of the event exists; after that the terms change only by a correction (§7). ES_Corrected and ES_Cancelled are §7's.
Clocks. announcedAt and paymentTime are ledger times as recorded by the registry, and are informational. recordTime alone is normative and is defined against record time at the synchronizer (§5.1).
4.2 Entitlement
One per EntitlementKey.
data DeductionKind = DK_Withholding | DK_Fee | DK_Custom with id : Text
deriving (Eq, Ord, Show, Serializable)
data DeductionBasis
= DB_Gross -- ^ the entitlement's grossAmount
| DB_Remaining -- ^ gross less the deductions earlier in the list
| DB_Declared -- ^ an amount the registry declares; `source` says why
deriving (Eq, Ord, Show, Serializable)
data Deduction = Deduction with
kind : DeductionKind
basisKind : DeductionBasis
basis : Decimal -- ^ the amount the rate was applied to
rate : Decimal
amount : Decimal -- ^ round(basis * rate) per the event's rule
source : Text -- ^ a credential, a standing instruction, a default.
-- Declared, not derived. See 9.
deriving (Eq, Show, Serializable)
data EntitlementStatus
= ETS_Live
| ETS_Superseded with supersededBy : EntitlementKey
| ETS_Cancelled with replacedBy : Optional EntitlementKey
deriving (Eq, Show, Serializable)
data EntitlementView = EntitlementView with
key : EntitlementKey
ref : Text -- ^ opaque, registry-assigned, unique within the event
payableTo : Account -- ^ key.account, or the redirect's beneficiary
terms : EventTerms -- ^ denormalized from the event; 4.1
payingAgent : Party
unitsAtRecord : Decimal -- ^ units attributed to THIS entitlement; 5.1
grossAmount : Decimal -- ^ round(units * rate); 5.2
deductions : [Deduction]
netAmount : Decimal -- ^ = grossAmount - sum(deductions.amount)
paidAmount : Decimal -- ^ sum settled through LifecycleEvent_SettleBatch
-- or Entitlement_Settle, carried across
-- supersession (6, 7)
derivedFrom : [ContractId Holding] -- ^ hint only
supersedes : Optional EntitlementKey
status : EntitlementStatus
meta : Metadata
deriving (Eq, Show, Serializable)
data EntitlementSettleResult = EntitlementSettleResult with
paidAmount : Decimal -- ^ the successor's paidAmount
meta : Metadata
deriving (Eq, Show, Serializable)
interface Entitlement where
viewtype EntitlementView
entitlement_settleImpl
: ContractId Entitlement -> Entitlement_Settle -> Update EntitlementSettleResult
entitlement_settleExtraObservers : Entitlement_Settle -> [Party]
-- | Pays `amount` towards this entitlement and records it, in one transaction.
-- The single-payment path, for a `payableTo` that receives a transfer directly;
-- `LifecycleEvent_SettleBatch` is the batched path over allocations (6).
-- Implementations MUST: require `actors == [payingAgent]`, `status == ETS_Live`,
-- the event live (`ES_Announced` or `ES_Confirmed`, fetched from the context,
-- with its key equal to `key.event`)
-- and `0 < amount <= netAmount - paidAmount`, with equality for `EK_Redemption`
-- (no partial redemption payment); move `amount` of `payInInstrument`
-- to `payableTo` inside this choice, by a movement that completes in this
-- transaction; archive this contract and create a successor with `paidAmount`
-- increased by `amount`. Either both happen or neither does (6).
nonconsuming choice Entitlement_Settle : EntitlementSettleResult
with
actors : [Party]
amount : Decimal
extraArgs : ExtraArgs -- ^ the servicing registry's context, with the cash
-- registry's transfer-factory context nested (6)
observer entitlement_settleExtraObservers this arg
controller actors
do entitlement_settleImpl this self arg
What is owed, at any moment, is outstanding = netAmount - paidAmount, which is computed and never stored. Positive, the entitlement is open for that much; zero, settled; negative, overpaid, and the difference is a claim of the registry on payableTo (§7). Storing a settlement state beside the two amounts would be a third field that could disagree with them.
Stakeholders, normative. Signatory: key.event.admin. Observers: accountParties admin key.account ∪ accountParties admin payableTo ∪ [payingAgent]. The paying agent is included because it settles the entitlement and otherwise could not read it.
Storage cost. One persistent contract per entitlement on every stakeholder's participant, re-created once per payment and once per correction. An instrument with 50,000 holders paying quarterly derives 200,000 entitlements a year and creates at least 400,000 contracts for them (the derivation and at least one payment each), each replicated to the holder's and any provider's participant. Settled entitlements stay active as the record of what was paid, so the live set grows by about 200,000 a year until a registry archives closed events under its own retention policy.
Eager register creation (§4.3) adds one more per (account, instrument): 50,000 on that instrument, observed by the holder's account parties, whether or not a redirect is ever proposed. That is a quarter as many contracts as the 200,000 entitlements a year, but it is a one-off cost, while the entitlements recur with every event. It is the price of serializing the first completed redirect on a position as well as later ones. §12.3 puts the entitlement alternative to the SIG; the register has no alternative to put.
Traffic cost. Each of these contracts is created by a transaction, and on the Global Synchronizer the submitting participant pays traffic for it. A confirmation request costs its size in bytes, with a small surcharge per recipient participant (readVsWriteScalingFactor is in parts per 10,000, so 0.04% each at the Global Synchronizer's value of 4), times extraTrafficPrice; since CIP-0104, confirmation responses are free. Both are governance parameters. At the time of writing MainNet sets extraTrafficPrice to 60 USD per MB, which is 0.06 USD per kilobyte. The registry pays for derivation, corrections and register creation, the paying agent for settlement, and each approving party for its own redirect command.
What drives the size of a confirmation request is mainly the number of views: a new view starts wherever a sub-action has informees its parent does not (CIP-0112 §4.3.8). The servicing transactions are therefore designed by views, per batch of n entitlements:
| Transaction | Transactions | Views | Why |
|---|
| Derivation | 1 per batch | 1 + n | each entitlement has its own stakeholders (the holder's account parties, and the beneficiary's for a slice), so each create is a view; unavoidable while entitlements are per account |
| Payment, cash instrument with privacy between holders | 1 per batch | about 3n + 2 | per account, one view creating its receipt allocation and one updating its entitlement; per allocation, one sub-view of SettlementFactory_SettleBatch (CIP-0112 §4.3.8.1, rule 4); plus the root and the paying agent's cash allocation |
| Payment in Canton Coin | 1 per batch | about 2n + 3 | as above, but Amulet settles the batch in one view (CIP-0112 §4.3.8.3) |
| Correction | 1 per batch | 1 + n | as derivation, for the entitlements it touches |
| Redirect, each command | 1 | a few | not per holder; does not grow with the instrument |
The single-payment path, one Entitlement_Settle per entitlement, costs n transactions instead of one. To stay at these figures an implementation SHOULD set lifecycleEvent_settleBatchExtraObservers and the registry's own …ExtraObservers so that the informees inside a choice only decrease, and SHOULD archive and re-create an entitlement inside one consuming exercise so the two share a view. CIP-0112 §4.3.8.4's extraReceiptAuthorizers, which would let a settlement create missing receipt allocations itself, is not in the shipped SettlementFactory_SettleBatch (removed per V2_VALIDATION.md); creating the receipt allocations in the same command as the batch (§6) gives the same transaction count. A batched entitlement per provider (§12.6) would cut derivation to 1 plus the number of providers, at the price of showing a provider's clients to each other's observers.
These are design targets, not measurements. Milestone 3 measures the views and bytes of each transaction type on the reference registry and publishes them with the suite. Under CIP-0104 as amended (approved 2026-10-27), a transaction submitted by a featured application counts its whole traffic as that application's activity, and a transaction submitted by a user splits it among the featured applications that confirm it; a registry or paying agent holding a FeaturedAppRight therefore earns app rewards on the servicing transactions it submits. CIP-0129 (approved 2026-10-23) sets the framework for charging traffic on other production synchronizers at the Global Synchronizer's list price, with the details left to a later CIP, so the same view counts apply to a registry on its own synchronizer.
This rule is ours. EventLog_HoldingsChange's "observers MUST include at least the account parties" constrains the observers of a nonconsuming choice, which is a transient disclosure on a transaction, and says nothing about the stakeholders of a persistent contract.
Source: TransferEventsV2.daml:92-95.
Changed only by payment, and no _PublicFetch. An entitlement is a statement of what is owed and what has been paid. A settlement, Entitlement_Settle on the entitlement or LifecycleEvent_SettleBatch on its event, is the only way the second figure changes; supersession and cancellation are the registry's own operations on its own contract (§7). Its readers are its observers, who read it off their own participant; a party that is not an observer has no disclosure source for it.
derivedFrom is a hint. By payment time those holdings are archived and cannot be fetched. Reproducing the figure needs the ACS at the record offset or the update history. derivedFrom lets a party that has the history see which contracts the registry used; it does not let a party without the history verify anything. §5.5 states what verification requires.
4.3 Entitlement redirect
data RedirectStatus
= RS_Live
| RS_Revoked with revokedAt : Time
| RS_Expired
deriving (Eq, Show, Serializable)
data RedirectScope = RedirectScope with
instrumentId : InstrumentId
unitsCap : Optional Decimal -- ^ None = the whole position; then no other
-- redirect may be live, and toTime MUST be set
kinds : [EventKind] -- ^ [] = all kinds
fromTime : Time -- ^ compared against the event's recordTime
toTime : Optional Time -- ^ likewise
deriving (Eq, Show, Serializable)
data EntitlementRedirectView = EntitlementRedirectView with
key : RedirectKey
beneficiary : Account
scope : RedirectScope
priority : Int -- ^ handed out by the register at completion
status : RedirectStatus
revokeRequestedBy : [Party] -- ^ parties other than the beneficiary's owner
-- that asked to revoke
agreementRef : Optional AnyContractId -- ^ id only; see 9
meta : Metadata
deriving (Eq, Show, Serializable)
interface EntitlementRedirect where
viewtype EntitlementRedirectView
entitlementRedirect_revokeImpl
: ContractId EntitlementRedirect -> EntitlementRedirect_Revoke -> Update RedirectResult
entitlementRedirect_revokeExtraObservers : EntitlementRedirect_Revoke -> [Party]
entitlementRedirect_expireImpl
: ContractId EntitlementRedirect -> EntitlementRedirect_Expire -> Update RedirectResult
entitlementRedirect_expireExtraObservers : EntitlementRedirect_Expire -> [Party]
-- | One party per call, from either side. Any other call records a request;
-- a call by the beneficiary account's owner completes the revocation (4.3).
nonconsuming choice EntitlementRedirect_Revoke : RedirectResult
with
actors : [Party]
extraArgs : ExtraArgs
observer entitlementRedirect_revokeExtraObservers this arg
controller actors
do entitlementRedirect_revokeImpl this self arg
nonconsuming choice EntitlementRedirect_Expire : RedirectResult
with
actors : [Party]
extraArgs : ExtraArgs
observer entitlementRedirect_expireExtraObservers this arg
controller actors
do entitlementRedirect_expireImpl this self arg
-- | What a pending instruction proposes; the redirect's priority and status
-- exist only once it completes.
data RedirectProposal = RedirectProposal with
key : RedirectKey
beneficiary : Account
scope : RedirectScope
agreementRef : Optional AnyContractId
meta : Metadata
deriving (Eq, Show, Serializable)
data RedirectInstructionView = RedirectInstructionView with
admin : Party
proposal : RedirectProposal
approvedBy : [Party] -- ^ approvals collected so far, proposer included
availableActions : Map.Map RedirectAction [[Party]]
-- ^ every group under RA_Approve is one party
expiresAt : Optional Time
meta : Metadata
deriving (Eq, Show, Serializable)
interface RedirectInstruction where
viewtype RedirectInstructionView
redirectInstruction_approveImpl
: ContractId RedirectInstruction -> RedirectInstruction_Approve -> Update RedirectResult
redirectInstruction_approveExtraObservers : RedirectInstruction_Approve -> [Party]
-- | Records the approval of `actors`, which MUST be one of the single-party
-- groups listed under `RA_Approve`. Returns Pending with the successor instruction while approvals
-- are missing; the approval that completes the set creates the redirect.
nonconsuming choice RedirectInstruction_Approve : RedirectResult
with
actors : [Party]
extraArgs : ExtraArgs
observer redirectInstruction_approveExtraObservers this arg
controller actors
do redirectInstruction_approveImpl this self arg
-- _Reject and _Withdraw are identical in shape — their methods, observer
-- methods and choices are elided here — callable by any beneficiary-side and
-- any holder-side account party respectively.
data RedirectAction = RA_Approve | RA_Reject | RA_Withdraw
deriving (Eq, Ord, Show, Serializable)
data RedirectResult_Output
= RedirectResult_Pending with instructionCid : ContractId RedirectInstruction
| RedirectResult_Completed with redirect : RedirectKey
| RedirectResult_Failed
deriving (Eq, Show, Serializable)
data RedirectResult = RedirectResult with
output : RedirectResult_Output
meta : Metadata
deriving (Eq, Show, Serializable)
interface RedirectFactory where
viewtype RedirectFactoryView
redirectFactory_proposeImpl
: ContractId RedirectFactory -> RedirectFactory_Propose -> Update RedirectResult
redirectFactory_publicFetchImpl
: ContractId RedirectFactory -> RedirectFactory_PublicFetch -> Update RedirectFactoryView
redirectFactory_proposeExtraObservers : RedirectFactory_Propose -> [Party]
nonconsuming choice RedirectFactory_Propose : RedirectResult
with
holderAccount : Account
beneficiary : Account
scope : RedirectScope
agreementRef : Optional AnyContractId
actors : [Party]
extraArgs : ExtraArgs
observer redirectFactory_proposeExtraObservers this arg
controller actors
do redirectFactory_proposeImpl this self arg
nonconsuming choice RedirectFactory_PublicFetch : RedirectFactoryView
with actors : [Party]
controller actors
do redirectFactory_publicFetchImpl this self arg
data RedirectFactoryView = RedirectFactoryView with
admin : Party
meta : Metadata
deriving (Eq, Show, Serializable)
Every state-changing choice here is nonconsuming and returns a result, as are all fifteen state-changing choices in the shipped V2 interfaces; the fourteen outside EventLog also carry an …ExtraObservers method. _Revoke and _Expire MUST archive the redirect and create a successor carrying RS_Revoked or RS_Expired (or, for a recorded revocation request, the same status with revokeRequestedBy extended). A consumed contract has no view, so a revocation that only archived would be indistinguishable from absence. _Expire's actors MUST be the admin, and the implementation MUST reject unless scope.toTime is set and has passed; without that check the admin could archive any live redirect at will. A redirect with toTime = None is never expirable.
What a redirect is. An instruction, approved by the holder's side and the beneficiary's side, that income derived from the units held in holderAccount at an event's record time is paid to beneficiary, up to unitsCap. Its only source is the account's position: if the account holds nothing at the record time, the redirect yields nothing. A pledge does not by itself decide who receives income. The pledge agreement says what the parties agreed and the lock says the units cannot move; only a completed redirect tells the registry to pay someone other than the holder. An obligation to pay a counterparty an amount regardless of what the account holds (the equivalent payment a securities borrower owes a lender after selling the borrowed bond) is not a redirect, and version 1 does not model it (§13).
Authorization: one party per command. The holder's account parties, the beneficiary's account parties and the registry are, in the case this standard exists for, on different participants, and a command can only act as parties its participant hosts. No choice here therefore needs two account parties in one command. The flow follows the shipped transfer instruction, where TransferFactory_Transfer "SHOULD … allow all the sender's account parties to call this choice, so they can all initiate a transfer on their own", and further approvals arrive as steps on a pending instruction:
- Propose. Any one holder-side account party exercises
RedirectFactory_Propose alone. The holder side is accountParties admin holderAccount: the principal and, where one is set and differs, the provider. The registry's authority comes from its own factory, so the registry is never a separate step. The result is a pending RedirectInstruction whose approvedBy holds the proposer.
- Approve. Every further approval is one
RedirectInstruction_Approve by one party, from its own participant. availableActions lists, under RA_Approve, each party whose approval is still missing, one party per group. Which further account parties must approve is the registry's account policy, as it is for transfers; the standard fixes the minimum: the principal of the holder account and the principal of the beneficiary account both approve. A provider therefore cannot redirect its client's income on its own authority, even to an account it owns itself as in the Milestone 1 fallback (§9), where the provider is the whole beneficiary side but the holder still approves. Each approval archives the instruction and creates a successor with approvedBy extended, until the approval that completes the set creates the redirect.
- Reject and withdraw. Any beneficiary-side party may reject and any holder-side party may withdraw, alone, archiving the instruction.
- Revoke.
EntitlementRedirect_Revoke is likewise called by one party. A call by the owner of the beneficiary account (the party the redirect benefits, releasing its own claim) completes the revocation, whether or not a request was recorded. A call by any other account party, on the holder's side or the beneficiary's provider, records a request in revokeRequestedBy and changes nothing else. Creation needs both owners, and so does every way of ending a redirect early: the holder's side cannot take the income back alone, and a pledgee's custodian cannot give up its client's claim. Under the fallback design of §9 the beneficiary account is owned by the provider, which is then the owner that completes.
CIP-0112 mandates provider visibility on asset movements and holdings, and a redirect is neither; whether a provider's approval is required is therefore left to the registry's policy. On a basic account there is no provider and the principal alone represents the holder's side.
Stakeholders. EntitlementRedirect: signatory key.admin, the same party that signs the register, which makes the exclusivity check enforceable in one transaction; observers accountParties admin key.holderAccount ∪ accountParties admin beneficiary. RedirectInstruction: signatory admin; observers the same two sets, so every party that may approve can see what it approves from its own participant. §9's rows for the registry, the two providers and the beneficiary are claims about these sets. The holder's provider is therefore an observer of every redirect on its client's position, which is why it is the counterparty that can recompute the whole split (§5.5).
Scope is by account and instrument with an optional cap, never by holding id. Holdings are archived and re-created on every transfer, split, merge, lock and unlock, so a redirect pinned to a ContractId Holding stops matching the moment the position is touched, including by the lock the pledge itself creates.
Which events a redirect catches is decided by the snapshot as well as the scope (§5.1). The redirect must be live in the ACS at the record offset, and its scope must then match: the event's kind is in kinds (or kinds is empty), fromTime <= recordTime, and toTime is None or recordTime <= toTime. So fromTime can narrow a redirect but never extend it backwards: a redirect completed after a record time does not catch that event, whatever fromTime it carries.
Allocation under competing redirects. Priority is the Int the register hands out when a redirect completes, which is monotone and therefore a total order with no tie-break needed. It is fixed then and never re-evaluated, so a redirect completed later can never take units from one already live. fromTime plays no part: it is a scope filter, holder-supplied, so ranking on it would let a proposer jump the queue.
Derivation allocates in priority order among the redirects that catch the event: each takes up to its cap until the position is exhausted, and the holder keeps the remainder. If the position is below the committed caps, the holder's own share absorbs the shortfall first, down to zero, and only then does the lowest-priority redirect receive less than its cap. Each entitlement names the redirect it was allocated under in its key.
Uncapped redirects. A redirect with unitsCap = None claims the whole position. It may complete only if nothing else is live on that position, it blocks every later completion while it lives, and it MUST carry a toTime; without one, a beneficiary that never revokes would encumber the position permanently.
Capped redirects may coexist without limit, and their caps are not checked against the position, for the reason below. One with no toTime is never expirable, so its slice can be encumbered the same way; the exposure is the cap in units. We accept that and do not force a deadline on every redirect, because a pledge of a fixed number of units with no stated end is an ordinary arrangement.
Exclusivity is enforced per position, not per scope. An uncapped redirect over coupons only still blocks a capped one over redemptions only, and one whose window has not started still blocks one whose window has ended. The reason is that the check must be answerable from the register, which carries aggregates and no scopes, and a register that carried scopes would disclose them to every approving beneficiary (§9). Parties that want disjoint uncapped redirects on one position cap them instead.
Serializing redirect completion. Daml cannot check non-existence, so "no conflicting redirect exists" is unenforceable against concurrent commands. Completion must therefore be serialized per position, through a contract the registry consumes and re-creates. Only the completing approval touches it; earlier approvals change the instruction alone.
That contract is not in this package. An interface package should not declare a template: damlc rejects it by default (the error can be downgraded to a warning with -Wupgrade-interfaces) with "This package defines both interfaces and templates. This may make this package and its dependents not upgradeable", and none of the thirteen shipped splice-api-token-* packages contains a template. The standard therefore specifies the obligation and the aggregate the completing approval's choice context must supply, and the register itself is a registry-side template. The reference registry's version:
template RedirectRegister with
admin : Party
holderAccount : Account
instrumentId : InstrumentId
committedUnits : Decimal -- ^ sum of live caps
liveCount : Int
uncappedLive : Bool -- ^ an aggregate cannot otherwise distinguish a
-- live uncapped redirect from a live zero-cap one
nextPriority : Int -- ^ handed out and incremented at completion
where signatory admin
observer accountParties admin holderAccount
Normative for any registry, whatever it calls its own register:
- Exactly one live register per
(holderAccount, instrumentId), signed by the admin and observed by the holder's account parties, so a holder can always see its own committed total.
- First created only by commands the registry itself submits, never inside another party's transaction, such as the receiving leg of a transfer, a settlement or a mint, whose commands carry someone else's submitter and command id. The registry creates it eagerly: at the account's setup, for an instrument with explicit accounts; by its own automation on observing the first holding in the account, for one with implicit accounts (CIP-0112 §4.3.2.2: "Some assets like Canton Coin will use implicit accounts only"); and in any case before it serves a choice context for the account, including a
_Propose context, which covers arranging a pledge before the position arrives.
- Creation must not race, and the ledger does not prevent it: two creates of the same register are two valid commands. Because every creation is the registry's own command, the registry can serialize them: it submits them all as one user from one participant, with a command id derived deterministically from
(admin, holderAccount, instrumentId), so the Ledger API's command deduplication (scoped to the participant, the submitting user, the acting parties and the command id, all fixed here) rejects the second of two concurrent creations; and before submitting, the registry checks its own active contract set for a live register. Deduplication alone is bounded by its period and the check alone races; together they leave a window only if a register is created, archived without a successor, and created again, and no operation here does that.
- A redirect cannot complete without a register: the completing approval consumes it, so a position with none has no completed redirects.
- Consumed and re-created by the completing
_Approve, a completing _Revoke and _Expire; never by _Propose, by a non-completing approval, or by a revocation request.
- The register is disclosed to an approving beneficiary-side party, and a disclosed contract carries its full create argument (
DisclosedContract.createdEventBlob is "the serialized created event"). The beneficiary therefore learns the whole payload, nextPriority included, and no arrangement of fields changes that: an admin-only side contract would have to be disclosed too for the beneficiary's participant to exercise over it, and the beneficiary is a witness of its archive regardless. §9 states the disclosure.
How each choice moves the aggregates, so that any two registries agree on them:
| Choice | committedUnits | liveCount | uncappedLive | nextPriority |
|---|
completing _Approve, capped at c | + c | + 1 | unchanged | handed to the redirect, then + 1 |
completing _Approve, uncapped | unchanged | + 1 | True | handed to the redirect, then + 1 |
completing _Revoke / _Expire, capped at c | − c | − 1 | unchanged | unchanged |
completing _Revoke / _Expire, uncapped | unchanged | − 1 | False | unchanged |
Two proposals on one position both succeed, and so do any number of non-completing approvals; two completions race, and one loses.
Expiry needs _Expire because toTime passing runs no transaction: nothing would decrement committedUnits, and the capacity would stay committed. Until the registry sweeps, an expired redirect still occupies its place in the register, but not in any entitlement, because the scope rule above already excludes events after toTime.
The two outcomes are different and the suite tests both:
- Contention. The losing completion fails because a contract it used is no longer active. This is reported as contract-not-active when the command is interpreted, or as an inactive or locked contract when it is confirmed; the suite keys on that category, not on one error code. The losing party re-fetches the choice context and retries. The retry succeeds unless the business rule now rejects it.
- Business rejection. An uncapped redirect is live, or this redirect is itself uncapped and something else is live. Retrying will not help, and a wallet shows why. A retry can also end this way: when either of two racing redirects is uncapped, the loser's retry is rejected.
The completing approval is a choice on RedirectInstruction, so the register reaches that party as a disclosed contract in that instruction's choice context (§8, …/redirect-instructions/{id}/choice-contexts/approve).
Caps are not checked against the current position. Checking them on-ledger would require disclosing the holder's position to the approving beneficiary. An off-ledger check could not be verified by the Daml conformance suite.
The allocation rule already defines what happens when committed caps exceed the position: the holder's share absorbs the shortfall first, followed by the lowest-priority redirect. The same situation can arise if the position decreases after a redirect completes, so a check at completion would not prevent it. committedUnits is therefore informational, not a limit.
committedUnits tells a beneficiary how much is committed ahead of it, since every redirect live at its completion has a lower priority number. It does not tell it whether the position covers that amount plus its own cap, because the beneficiary never sees the position. Coverage is the pledge agreement's business, checked by whatever means that agreement provides; the register does not establish it.
What the choice body does check on-ledger, and the suite therefore tests, is the uncapped-exclusivity rule: an uncapped redirect may complete only when liveCount = 0, and none may complete while uncappedLive. Both conditions are answerable from the register alone. Concurrency can break this invariant, and the serialization exists to protect it.
The register carries aggregates only, never the list of redirects, their beneficiaries or their scopes, and contention on it is bounded to one holder's position in one instrument.
5. Derivation
5.1 The snapshot
What is read. The registry establishes two sets from the ACS at the ledger offset corresponding to recordTime (or by folding the update stream to that offset): the holdings of the instrument, and the redirects on those positions with their status. It does not use an on-ledger fetch, which only sees currently active contracts. Both sets come from the same offset, so holdings and redirects are never read at different moments.
The boundary. Activeness is decided by record time at the synchronizer, not by the ledger time a submission requested. A contract is in the snapshot iff it was created by a transaction whose record time is at or before recordTime, and not archived by one whose record time is at or before recordTime. So a holding created at exactly recordTime counts, and one archived at exactly recordTime does not; one archived after it does, because it was live at the instant.
What follows for redirects. A redirect counts iff the contract active at the record offset carries RS_Live and its scope matches the event (§4.3). Consequences, each asserted by the suite:
- a redirect completed after
recordTime does not catch the event, whatever its fromTime;
- a redirect revoked or expired after
recordTime still catches it, because the revocation is the successor contrac
This PR also touches 1 other file; get_proposal_file lists them.