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
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.
5860module 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,52 +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.
151168template 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 (mirroring 'EmitterRequest'). The consumer's
168- -- signature on this request is what authorizes its co-signature on the root;
169- -- the operator accepts against its registry, which enforces
170- -- one-root-per-consumer.
171- template ReplayRootRequest
172- with
173- consumer : Party
174- operator : Party
175- splitThreshold : Int -- normally 'defaultSplitThreshold'; tiny in tests
176- where
177- signatory consumer
178- observer operator
179-
180- choice ApproveReplayRoot : (ContractId ReplayRootRegistry, ContractId ReplayNode)
174+ nonconsuming choice ClaimReplayRoot : ContractId ReplayNode
181175 with
182- registryCid : ContractId ReplayRootRegistry -- passed by cid: no keys
183- controller operator
176+ consumer : Party
177+ splitThreshold : Int -- normally 'defaultSplitThreshold'; tiny in tests
178+ controller consumer
184179 do
185- reg <- fetch registryCid
186- assertMsg "registry belongs to a different operator" (reg.operator == operator)
187- regCid' <- exercise registryCid GrantReplayRoot with consumer
188- rootCid <- create ReplayNode with
180+ create ReplayNode with
189181 consumer
190182 operator
191183 prefix = ""
192184 splitThreshold
193185 consumed = Set.empty
194- pure (regCid', rootCid)
0 commit comments