Skip to content

Commit 2fb6104

Browse files
committed
canton: free replay roots; single-trie discipline is the consumer's own
1 parent f1bcb01 commit 2fb6104

4 files changed

Lines changed: 120 additions & 93 deletions

File tree

canton/README.md

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -529,23 +529,36 @@ the digest — verify + consume + act in one transaction.
529529

530530
Properties:
531531

532-
- **No archive-and-replay, no shadow roots.** Archival alone only bricks
533-
(fail-closed); the replay vector is node *re-creation*. Nodes are co-signed,
534-
so neither the consumer nor the operator can archive or create one alone;
535-
splits inherit both signatures from the archived parent (the operator is not
536-
involved per consume). Roots are granted exactly once per consumer through
537-
the operator's `ReplayRootRegistry` — a *positive*, on-ledger
538-
one-root-per-consumer check (the registry carries the granted set and every
539-
grant consumes it), via a one-time propose-accept ceremony
540-
(`ReplayRootRequest`), mirroring emitter registration. The irreducible
541-
remainder: consumer and operator *colluding* can rebuild a trie —
542-
signatories can always unmake their own contracts.
532+
- **Nobody can touch someone else's scope; your own scope is your own
533+
problem.** Archival alone only bricks (fail-closed); the replay vector is
534+
node *re-creation*. Nodes are co-signed, so no third party can archive or
535+
re-create them (splits inherit both signatures from the archived parent —
536+
the operator is not involved per consume), and roots come only from the
537+
operator's `ReplayRootRegistry` — a stateless, never-churning anchor whose
538+
nonconsuming `ClaimReplayRoot` lends the operator's *inherited* signature
539+
to a consumer-submitted claim (the `RegisterEmitter` pattern: no crank; the
540+
operator multisig signs nothing after genesis). Claiming a root for a
541+
consumer requires **that consumer's authority**, so nobody can fork another
542+
app's replay scope. There is deliberately *no* one-root-per-consumer
543+
enforcement: maintaining a single trie is the consumer's own responsibility
544+
(its disclosure service defines which trie its consumes resolve into, and
545+
detects forks — more than one covering node — for free). This is EVM
546+
parity: nobody grants replay storage there either; every integrator owns
547+
its mapping. A consumer minting parallel roots, like a consumer whose key
548+
is stolen, only harms itself — and a stolen consumer key defeats the app
549+
more directly anyway. (If key-compromise resilience is ever wanted, the
550+
hardening is trie-identity pinning: thread the original root's cid through
551+
successors via `self` and pin it in the app contract — documented, not
552+
built.) Spam claims cost the spammer synchronizer traffic per transaction
553+
and the operator ~300 B of storage per worthless root; no shared contract
554+
grows, nothing bricks.
543555
- **Scoping and contention.** Tries are per consumer party: different apps
544556
consume the same VAA independently. Consumes racing on the *same node*
545557
conflict — exactly one commits (validator-checked, regardless of submitter);
546558
the loser re-resolves the covering node and retries. A blind retry of a
547-
consume that actually committed fails on membership ("VAA already consumed")
548-
— a clean idempotency signal. Until the first split, all of one app's
559+
consume that actually committed fails on membership ("digest already
560+
consumed") — a clean idempotency signal. Until the first split, all of one
561+
app's
549562
consumes serialize through its root; 16-way fan-out begins at
550563
`splitThreshold` consumes.
551564
- **Cost.** `defaultSplitThreshold = 128`: suffixes are ≤64-char hex (~66 B
@@ -607,7 +620,7 @@ it must serve:
607620
| `CoreState` | the one true instance | integrators/users exercising §4.3 verify or §4.6 consume | yes — every non-stakeholder submitter | per governance action (rare) |
608621
| `ReplayNode` | (consumer, prefix covering digest) | every `VerifyAndConsumeVAA` / integrator redeem | yes for non-stakeholders (e.g. end users); the consumer itself only needs the cid | every consume under that node |
609622
| `EmitterRegistry` | operator | `RegisterEmitter` | yes — the requester submits | per emitter registration |
610-
| `ReplayRootRegistry` | operator | `ApproveReplayRoot` | nooperator submits, own ACS | per root grant |
623+
| `ReplayRootRegistry` | operator | `ClaimReplayRoot` | yesthe consumer submits (static blob, cacheable) | never (stateless anchor) |
611624
| `Emitter` | (operator, owner, emitterId) | `PublishMessage` | no — owner submits, own ACS | per publish |
612625
| app contracts (e.g. `ExampleIntegrator`) | app-defined | the app's own choices | app's concern (its users are observers) | app-defined |
613626

@@ -1096,10 +1109,16 @@ the quorum and turn guardian downtime into a chain-halt).
10961109
> access to the operator party and its namespace key. Prefer the dedicated
10971110
> `guardianObserver`; use operator replica-hosting only as a migration stopgap.
10981111
1112+
Note the operator party's ACTIVE signing surface after genesis is empty: every
1113+
day-to-day flow (`RegisterEmitter`, `ClaimReplayRoot`, splits, governance
1114+
transitions) reaches its signature by inheritance from contracts it signed at
1115+
setup. If the operator is stood up as a k-of-n external threshold party (the
1116+
§9 machinery, `GG_THRESHOLD=k`), the interactive-submission signing ceremony
1117+
is needed for genesis only.
1118+
10991119
**What a compromised operator can and cannot do.** It can degrade **liveness**
1100-
(stop serving disclosures, stop granting replay roots, evict observers — all
1101-
fail-safe: transactions stop constructing, guardians simply stop seeing and
1102-
stop signing). It **cannot** produce
1120+
(stop serving disclosures, evict observers — all fail-safe: transactions stop
1121+
constructing, guardians simply stop seeing and stop signing). It **cannot** produce
11031122
anything guardians would wrongly attest: emitting under an existing address
11041123
requires that owner's signature (§4.2), so an outbound forge is either an invalid
11051124
transaction (rejected in replay, never observed) or a valid contract with a

canton/core/daml/Wormhole/Core/Replay.daml

Lines changed: 39 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -35,16 +35,18 @@
3535
-- its subspace — no covering node, nothing verifies — fail-closed, the
3636
-- opposite of the key model.
3737
--
38-
-- The replay vector is therefore node RE-CREATION (archive-and-recreate, or a
39-
-- parallel shadow root), which the signatory design closes: the @operator@
40-
-- co-signs every node, so the consumer alone can neither archive nor create
41-
-- one, and roots are granted exactly once per consumer through the
42-
-- 'ReplayRootRegistry' — a positive, on-ledger one-root-per-consumer check
43-
-- linearized by consuming the registry (the 'EmitterRegistry' pattern, minus
44-
-- the key). Splits inherit both signatures from the archived parent, so the
45-
-- operator is not involved per consume. The irreducible remainder: consumer
46-
-- and operator COLLUDING can archive and rebuild a trie — signatories can
47-
-- always unmake their own contracts.
38+
-- The replay vector is therefore node RE-CREATION. Against THIRD PARTIES the
39+
-- signatory design closes it: the @operator@ co-signs every node (inherited
40+
-- from the 'ReplayRootRegistry' anchor at the root, from the archived parent
41+
-- at splits — the operator never signs per consume), and claiming a root for
42+
-- a consumer requires that consumer's authority, so nobody can archive,
43+
-- re-create, or fork someone ELSE'S replay scope. Against the consumer
44+
-- ITSELF there is deliberately no on-ledger defense: a consumer can mint
45+
-- parallel roots for its own scope (see 'ReplayRootRegistry'), which is
46+
-- self-harm — replay protection is the consumer's own safety property, and a
47+
-- compromised consumer key defeats the app more directly anyway. Same class
48+
-- as the irreducible signatory remainder: signatories can always unmake (or
49+
-- here, duplicate) their own state.
4850
--
4951
-- OFF-LEDGER: consumers (and their users) locate the covering node for a
5052
-- digest via a disclosure service — an ACS index over (consumer, prefix) that
@@ -53,7 +55,7 @@
5355
-- RETRY PROTOCOL: racing consumes on the same node conflict and exactly one
5456
-- commits; on rejection, re-resolve the covering node (the cid churns on every
5557
-- consume) and resubmit. A blind retry of a consume that actually committed
56-
-- hits the membership check and fails "VAA already consumed" — a clean
58+
-- hits the membership check and fails "digest already consumed" — a clean
5759
-- idempotency signal.
5860
module Wormhole.Core.Replay where
5961

@@ -123,7 +125,7 @@ template ReplayNode
123125
-- The replay check MUST precede the split branch: a split inserting a
124126
-- duplicate would be a silent Set.insert no-op — a replay that
125127
-- "succeeds".
126-
assertMsg "VAA already consumed" (not (Set.member suffix consumed))
128+
assertMsg "digest already consumed" (not (Set.member suffix consumed))
127129
if Set.size consumed < splitThreshold || T.length prefix == 63
128130
-- Depth-63 nodes never split: suffixes are single nibbles (≤16
129131
-- distinct, so the set is bounded), and children would be degenerate
@@ -143,51 +145,41 @@ template ReplayNode
143145
Some cid -> pure cid
144146
None -> abort "unreachable: split lost the covering child"
145147

146-
-- | Grants each consumer AT MOST ONE trie root — the positive, on-ledger
147-
-- uniqueness check that a contract key cannot provide. One registry per
148-
-- operator, created at setup ('mkInitialReplayRootRegistry'); every grant
149-
-- consumes it, so grants are linearized through the operator. The set grows by
150-
-- one Party per integrator app — dozens, not millions.
148+
-- | Hands out trie roots to any consumer that asks — a stateless O(1) anchor
149+
-- whose one nonconsuming choice lends the operator's INHERITED co-signature to
150+
-- consumer-submitted claims (the 'RegisterEmitter' pattern): no operator
151+
-- crank, no contention, no churn — the registry's disclosure blob is static
152+
-- and cacheable. One per operator, created at setup
153+
-- ('mkInitialReplayRootRegistry').
154+
--
155+
-- There is deliberately NO one-root-per-consumer enforcement: maintaining a
156+
-- single trie is the CONSUMER'S OWN responsibility (its disclosure service
157+
-- defines which trie its consumes resolve into). The controller is what keeps
158+
-- that sound against everyone else — a third party cannot fork someone else's
159+
-- replay scope, because claiming a root for @consumer@ requires @consumer@'s
160+
-- authority. Parallel roots can only be minted by the consumer itself (or its
161+
-- stolen key), which is self-harm of the same class as the signatory
162+
-- remainder above. Spam claims cost the spammer synchronizer traffic per
163+
-- transaction and the operator ~300 B of storage per worthless root; no
164+
-- shared contract grows. If consumer-key-compromise resilience is ever
165+
-- wanted, the hardening is trie-identity pinning: thread the original root's
166+
-- cid through successors (via @self@ in 'ConsumeDigest') and pin it in the
167+
-- app contract — documented, not built.
151168
template ReplayRootRegistry
152169
with
153170
operator : Party
154-
granted : Set Party
155171
where
156172
signatory operator
157173

158-
choice GrantReplayRoot : ContractId ReplayRootRegistry
159-
with
160-
consumer : Party
161-
controller operator
162-
do
163-
assertMsg "consumer already has a replay root" (not (Set.member consumer granted))
164-
create this with granted = Set.insert consumer granted
165-
166-
-- | The consumer's half of the root-creation ceremony: a one-time
167-
-- propose-accept per app. The consumer's signature on this request is what
168-
-- authorizes its co-signature on the root; the operator accepts against its
169-
-- registry, which enforces one-root-per-consumer.
170-
template ReplayRootRequest
171-
with
172-
consumer : Party
173-
operator : Party
174-
splitThreshold : Int -- normally 'defaultSplitThreshold'; tiny in tests
175-
where
176-
signatory consumer
177-
observer operator
178-
179-
choice ApproveReplayRoot : (ContractId ReplayRootRegistry, ContractId ReplayNode)
174+
nonconsuming choice ClaimReplayRoot : ContractId ReplayNode
180175
with
181-
registryCid : ContractId ReplayRootRegistry -- passed by cid: no keys
182-
controller operator
176+
consumer : Party
177+
splitThreshold : Int -- normally 'defaultSplitThreshold'; tiny in tests
178+
controller consumer
183179
do
184-
reg <- fetch registryCid
185-
assertMsg "registry belongs to a different operator" (reg.operator == operator)
186-
regCid' <- exercise registryCid GrantReplayRoot with consumer
187-
rootCid <- create ReplayNode with
180+
create ReplayNode with
188181
consumer
189182
operator
190183
prefix = ""
191184
splitThreshold
192185
consumed = Set.empty
193-
pure (regCid', rootCid)

canton/core/daml/Wormhole/Core/Setup.daml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,9 @@ mkInitialEmitterRegistry : Party -> Party -> EmitterRegistry
5555
mkInitialEmitterRegistry operator guardianObserver =
5656
EmitterRegistry with operator, guardianObserver, nextId = 0
5757

58-
-- | The replay-root registry the operator creates alongside the 'CoreState' at
59-
-- setup. Grants each integrator consumer at most one 'ReplayNode' trie root
60-
-- (see 'Wormhole.Core.Replay').
58+
-- | The replay-root registry (the stateless claim anchor) the operator
59+
-- creates alongside the 'CoreState' at setup. Hands a 'ReplayNode' trie root
60+
-- to any consumer that claims one (see 'Wormhole.Core.Replay').
6161
mkInitialReplayRootRegistry : Party -> ReplayRootRegistry
6262
mkInitialReplayRootRegistry operator =
63-
ReplayRootRegistry with operator, granted = Set.empty
63+
ReplayRootRegistry with operator

canton/test/daml/Test/TestReplay.daml

Lines changed: 41 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -29,26 +29,31 @@ pad64 t = t <> mconcat (replicate (64 - T.length t) "0")
2929
nibbles : [Text]
3030
nibbles = T.explode "0123456789abcdef"
3131

32-
-- | Propose-accept ceremony granting @consumer@ its trie root: the consumer
33-
-- requests, the operator approves against its (queried, cid-passed — no keys)
34-
-- 'ReplayRootRegistry'.
32+
-- | Direct, consumer-submitted root claim: the consumer holds the one static
33+
-- disclosure of the 'ReplayRootRegistry' anchor (served off-ledger by the
34+
-- disclosure service — simulated here by reading as the operator, and
35+
-- cacheable forever since the anchor never churns) and submits alone. No
36+
-- operator crank.
3537
registerReplayRoot : Party -> Party -> Int -> Script (ContractId ReplayNode)
3638
registerReplayRoot operator consumer threshold = do
37-
reqCid <- submit consumer do
38-
createCmd ReplayRootRequest with consumer, operator, splitThreshold = threshold
3939
[(regCid, _)] <- query @ReplayRootRegistry operator
40-
(_, rootCid) <- submit operator do
41-
exerciseCmd reqCid ApproveReplayRoot with registryCid = regCid
42-
pure rootCid
40+
regDisc <- fromSome <$> queryDisclosure operator regCid
41+
submit (actAs consumer <> disclose regDisc) do
42+
exerciseCmd regCid ClaimReplayRoot with
43+
consumer
44+
splitThreshold = threshold
4345

4446
-- | The disclosure-service lookup: the unique active node of @consumer@'s trie
45-
-- covering @digest@. Aborts if coverage is not exactly one — every call doubles
46-
-- as a check of the partition invariant.
47+
-- covering @digest@, queried as @consumer@. The filter matches the trie owner
48+
-- too, because a reader can see nodes of several tries (the operator co-signs
49+
-- all of them). Aborts if coverage is not exactly one — every call doubles as
50+
-- a check of the partition invariant, and this abort is also how the service
51+
-- detects a forked trie (a consumer that minted parallel roots).
4752
coveringNode : Party -> Text -> Script (ContractId ReplayNode, ReplayNode)
4853
coveringNode consumer digest = do
4954
nodes <- query @ReplayNode consumer
5055
let d = normalizeHex digest
51-
case filter (\(_, n) -> n.prefix `T.isPrefixOf` d) nodes of
56+
case filter (\(_, n) -> n.consumer == consumer && n.prefix `T.isPrefixOf` d) nodes of
5257
[one] -> pure one
5358
covering -> abort ("partition invariant violated: " <> show (length covering) <> " covering nodes")
5459

@@ -254,30 +259,41 @@ testNodeArchiveNeedsBoth = do
254259
submitMustFail consumer do archiveCmd rootCid
255260
submitMustFail operator do archiveCmd rootCid
256261
-- Both together can (the irreducible signatory remainder) — coverage for the
257-
-- auto-generated Archive choices, including request and registry.
262+
-- auto-generated Archive choices, including the registry anchor.
258263
submit (actAs consumer <> actAs operator) do archiveCmd rootCid
259-
reqCid <- submit consumer do
260-
createCmd ReplayRootRequest with consumer, operator, splitThreshold = 4
261-
submit consumer do archiveCmd reqCid
262264
[(regCid, _)] <- query @ReplayRootRegistry operator
263265
submit operator do archiveCmd regCid
264266

265-
-- | The root registry grants at most one root per consumer — the positive,
266-
-- on-ledger check that replaces key uniqueness — and roots cannot be minted
267-
-- outside it (the consumer alone lacks the operator's signature).
268-
testRootRegistry : Script ()
269-
testRootRegistry = do
267+
-- | Root claims are free and self-serve; what the registry pins down is WHOSE
268+
-- scope a claim can touch. Claiming a root for a consumer requires that
269+
-- consumer's authority — the load-bearing property: nobody can fork someone
270+
-- ELSE'S replay scope. A consumer duplicating its own root is deliberately
271+
-- possible (pinned below): parallel roots are self-harm, detected off-ledger
272+
-- by the disclosure service ('coveringNode' aborts on more than one covering
273+
-- node). Roots cannot be minted outside the registry — the consumer alone
274+
-- lacks the operator's signature.
275+
testRootClaims : Script ()
276+
testRootClaims = do
270277
(operator, consumer, rootCid) <- setupTrie 4
271278
Some root <- queryContractId consumer rootCid
272279
root.prefix === ""
273280
root.splitThreshold === 4
274281
Set.size root.consumed === 0
275-
-- A second grant for the same consumer is rejected.
276-
reqCid <- submit consumer do
277-
createCmd ReplayRootRequest with consumer, operator, splitThreshold = 4
278282
[(regCid, _)] <- query @ReplayRootRegistry operator
279-
submitMustFail operator do
280-
exerciseCmd reqCid ApproveReplayRoot with registryCid = regCid
283+
regDisc <- fromSome <$> queryDisclosure operator regCid
284+
-- A third party cannot claim a root for someone else's scope.
285+
mallory <- allocateParty "TrieMallory"
286+
submitMustFail (actAs mallory <> disclose regDisc) do
287+
exerciseCmd regCid ClaimReplayRoot with
288+
consumer
289+
splitThreshold = 4
290+
-- The consumer CAN duplicate its own root — by design, self-harm only.
291+
_ <- submit (actAs consumer <> disclose regDisc) do
292+
exerciseCmd regCid ClaimReplayRoot with
293+
consumer
294+
splitThreshold = 4
295+
roots <- query @ReplayNode consumer
296+
length roots === 2
281297
-- The consumer cannot create a co-signed node alone.
282298
submitMustFail consumer do
283299
createCmd ReplayNode with

0 commit comments

Comments
 (0)