Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
234 changes: 106 additions & 128 deletions canton/README.md

Large diffs are not rendered by default.

45 changes: 19 additions & 26 deletions canton/core/daml/Wormhole/Core/Bytes.daml
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,8 @@ sliceFrom : Int -> Bytes -> Bytes
sliceFrom start b = T.substring (start * 2) (T.length b - start * 2) b

-- | Value of a single lowercase hex digit, given as a Unicode code point.
-- Code-point arithmetic rather than an assoc-list lookup: under Daml's strict
-- evaluation a @where@-bound 16-entry table would be rebuilt — and scanned
-- linearly — once per nibble.
-- Code-point arithmetic, not an assoc-list lookup: under Daml's strict
-- evaluation a @where@-bound table would be rebuilt and scanned once per nibble.
hexDigitValue : Int -> Int
hexDigitValue cp
| cp >= cp0 && cp <= cp9 = cp - cp0
Expand Down Expand Up @@ -109,38 +108,34 @@ leftPadTo numBytes b =
else mconcat (replicate (numBytes - bl) "00") <> normalizeHex b

-- | A length-prefixed byte segment: a 4-byte big-endian length followed by the
-- bytes themselves. A distinct type from raw 'Bytes' (Daml has no @newtype@, so
-- it is a single-constructor @data@) so a self-delimiting segment can't be
-- mistaken for, or concatenated as, unprefixed bytes. Concatenating two
-- variable-length fields bare is ambiguous ("ab"‖"c" vs "a"‖"bc"); the length
-- prefix makes the address preimage injective. Use 'unLenPrefixed' to get the
-- raw bytes back for hashing.
-- bytes. A distinct type from raw 'Bytes' (a single-constructor @data@, Daml
-- having no @newtype@) so it can't be concatenated as unprefixed bytes:
-- concatenating variable-length fields bare is ambiguous ("ab"‖"c" vs "a"‖"bc"),
-- and the length prefix makes the address preimage injective. 'unLenPrefixed'
-- recovers the raw bytes for hashing.
data LenPrefixed = LenPrefixed { unLenPrefixed : Bytes }
deriving (Eq, Show)

lenPrefixed : Bytes -> LenPrefixed
lenPrefixed b = LenPrefixed (intToBytesN (byteLength b) 4 <> b)

-- | Typed inputs to the address preimage. Daml has no @newtype@, so these are
-- single-constructor @data@ types. Distinct types for the three text fields (and
-- the id) mean 'derivedAddressFromText' can't have its arguments transposed —
-- swapping @registrar@ and @owner@ would otherwise silently derive a different
-- but perfectly valid-looking address, a security-relevant mistake.
-- | Typed inputs to the address preimage (single-constructor @data@, Daml having
-- no @newtype@). Distinct types stop 'derivedAddressFromText' from having its
-- arguments transposed — swapping @registrar@ and @owner@ would otherwise
-- silently derive a different but valid-looking address, a security bug.
data DomainTag = DomainTag Text deriving (Eq, Show)
data RegistrarText = RegistrarText Text deriving (Eq, Show)
data OwnerText = OwnerText Text deriving (Eq, Show)
data RegistryId = RegistryId Int deriving (Eq, Show)

-- | Canonical 32-byte registry address, derived from the components of a
-- registry-allocated identity:
-- | Canonical 32-byte registry address of a registry-allocated identity:
--
-- > keccak256( utf8(tag) ‖ lp(utf8(registrar)) ‖ lp(utf8(owner)) ‖ uint64be(id) )
--
-- where @lp@ is the 4-byte big-endian length prefix ('lenPrefixed'). The Text
-- form is split out from 'derivedAddress' so tests can pin the encoding against
-- fixed party strings (allocated 'Party' ids carry nondeterministic
-- fingerprints). The Go watcher recomputes the same value from the
-- 'WormholeMessage' fields; the encoding is pinned across both languages by a
-- where @lp@ is the 4-byte big-endian length prefix ('lenPrefixed'). Split out
-- from 'derivedAddress' so tests can pin the encoding against fixed party strings
-- (allocated 'Party' ids carry nondeterministic fingerprints). The Go watcher
-- recomputes the same value; the encoding is pinned across both languages by a
-- shared test vector.
derivedAddressFromText : DomainTag -> RegistrarText -> OwnerText -> RegistryId -> Bytes32
derivedAddressFromText (DomainTag domainTag) (RegistrarText registrarText) (OwnerText ownerText) (RegistryId id) =
Expand All @@ -150,11 +145,9 @@ derivedAddressFromText (DomainTag domainTag) (RegistrarText registrarText) (Owne
<> intToBytesN id 8))

-- | Address of the registry-allocated identity @(registrar, owner, id)@ under
-- @domainTag@. The @owner@ is part of the preimage: reproducing an existing
-- address requires the owner's signature at create time (the owner is a
-- signatory of the identity contract), so a compromised registrar cannot
-- impersonate an existing identity — it can only mint a duplicate under a
-- different owner, which derives a different, untrusted address.
-- @domainTag@. The @owner@ is in the preimage and co-signs the identity contract,
-- so a compromised registrar cannot impersonate an existing identity — only mint
-- a duplicate under a different owner, deriving a different, untrusted address.
derivedAddress : DomainTag -> Party -> Party -> RegistryId -> Bytes32
derivedAddress domainTag registrar owner id =
derivedAddressFromText domainTag (RegistrarText (partyToText registrar)) (OwnerText (partyToText owner)) id
Expand Down
62 changes: 19 additions & 43 deletions canton/core/daml/Wormhole/Core/Fees.daml
Original file line number Diff line number Diff line change
@@ -1,50 +1,26 @@
-- | Fee collection via the Canton Network Token Standard (CIP-0056, V1).
-- | Fee collection via the Canton Network Token Standard (CIP-0056 V1).
--
-- The bridge charges fees by CONSUMING token-standard 'Allocation's: the payer
-- locks funds against our settlement via the token registry's
-- @AllocationFactory@ (off-ledger, its wallet/CLI plus the registry's CIP-0056
-- OpenAPI, which supplies the 'ExtraArgs' choice context and any registry
-- disclosures), and the charging choice exercises
-- 'Allocation_ExecuteTransfer' — payment and effect in ONE transaction. In
-- every bridge flow the payer IS the submitter, so the bridge never implements
-- the wallet-facing @AllocationRequest@ interface (future UX work); it only
-- validates and executes allocations handed to it.
-- Fees are charged by consuming a payer-supplied 'Allocation' via
-- 'Allocation_ExecuteTransfer' (payment + effect in one transaction); the
-- bridge only validates/executes allocations, never issuing the
-- wallet-facing @AllocationRequest@ side.
--
-- AUTHORITY: 'Allocation_ExecuteTransfer' requires the JOINT authority of the
-- leg's sender, the leg's receiver, and the settlement executor. Bridge
-- convention: @executor = sender = payer@, @receiver = feeRecipient@ (the
-- guardianGovernance party — see 'Wormhole.Core.State.CoreState'). The
-- receiver's authority exists in exactly one place: inherited inside a
-- 'CoreState' choice (it is a signatory), which is why ALL charging routes
-- through 'ChargeMessageFee' / 'ChargeFee' there. This also blocks theft
-- structurally: a foreign allocation's sender is a required controller the
-- transaction never has.
-- 'Allocation_ExecuteTransfer' needs the sender/receiver/executor's joint
-- authority: @executor = sender = payer@, @receiver = feeRecipient@
-- (guardianGovernance). The receiver's authority is inherited from
-- 'CoreState' (a signatory), so every charge routes through
-- 'ChargeMessageFee'/'ChargeFee' there, and a foreign sender can never be a
-- controller — blocking theft structurally, PROVIDED 'CoreState' is
-- authentic. Callers pin 'GetGuardianGovernance' against their own committed
-- anchor before charging, so a compromised operator can't redirect fees via
-- a shadow CoreState under a gg it controls.
--
-- The "blocks theft structurally" guarantee presupposes an AUTHENTIC
-- 'CoreState' — a rogue one routes @receiver = feeRecipient@ to an
-- attacker-controlled party. Authenticity is now pinned by the caller before
-- every charge: each call site ('PublishMessage', 'RegisterEmitter',
-- 'ClaimReplayRoot') exercises 'Wormhole.Core.State.GetGuardianGovernance' and
-- checks it against the @guardianGovernance@ committed on the
-- emitter/registry. Pinning @guardianGovernance@ (not just the operator)
-- resists a compromised operator: the operator alone does not determine
-- @feeRecipient@, so a shadow 'CoreState' the operator co-signs under a
-- throwaway gg is rejected before any funds move.
-- Allocation views are forgeable; authenticity is the instrument admin's
-- signature on the allocation, checked in 'chargeFee'.
--
-- AUTHENTICITY: allocation views are template-computed and forgeable — a
-- hostile template can claim any instrument while moving nothing. The pin is
-- the instrument admin's SIGNATURE on the allocation contract (amulet
-- allocations are DSO-co-signed), checked in 'chargeFee': payloads are
-- authenticated, never resolution paths.
--
-- DENOMINATION: fees are 'Int's in units of 10^-10 tokens — exactly the last
-- digit of Daml's @Decimal@ (Numeric 10), so every fee is exactly
-- representable and 'feeToDecimal' is exact for all inputs. The
-- governance-set @messageFee@ (frozen 32-byte wire format) shares this unit.
--
-- Token Standard V2 (CIP-0112) is approved but not yet on mainnet; it ships
-- as a parallel @-v2@ interface family, and this module is the single seam
-- where support would be added.
-- Fees are 'Int's in 10^-10 token units (Decimal's last digit — exact by
-- construction); @messageFee@ shares this unit. Token Standard V2 (CIP-0112)
-- isn't on mainnet yet; this module is the seam for it.
module Wormhole.Core.Fees where

import DA.Optional (isNone)
Expand Down
129 changes: 55 additions & 74 deletions canton/core/daml/Wormhole/Core/Replay.daml
Original file line number Diff line number Diff line change
@@ -1,62 +1,45 @@
-- | Per-consumer replay protection for integrators (token bridge / NTT),
-- stored as a PREFIX TRIE of consumed VAA digests leaf contracts only.
-- | Per-consumer replay protection for integrators (token bridge / NTT): a
-- PREFIX TRIE of consumed VAA digests, leaf contracts only.
--
-- Governance replay protection lives in 'Wormhole.Core.State.CoreState' as a
-- @Set@ of consumed digests — fine there, because governance actions are rare
-- and already churn the singleton. App VAAs need a structure that scales and,
-- crucially, one whose absence check does NOT rely on contract keys: on
-- Daml-LF 2.3 key lookups resolve once, on the submitting participant, against
-- the submission's readers, and a negative result is a pinned validation input
-- no confirmer re-checks — negative lookups fail OPEN. This module uses no
-- keys at all.
-- @Set@ — fine there, since governance actions are rare. App VAAs need a
-- structure that scales and whose absence check does NOT rely on contract keys:
-- on Daml-LF 2.3 key lookups resolve on the submitting participant against the
-- submission's readers, and a negative result no confirmer re-checks fails OPEN.
-- This module uses no keys at all.
--
-- THE TRIE: a 'ReplayNode' holds a hex @prefix@ and the set of @consumed@
-- digest SUFFIXES under that prefix (digests are 64 lowercase hex nibbles; a
-- node at depth d stores 64−d-nibble suffixes). A consumer's trie starts as a
-- single root (empty prefix, empty set). When a node reaches @splitThreshold@
-- entries, the next consume splits it into 16 children — one per nibble,
-- including empty ones — bucketing the suffixes by first nibble (which is
-- dropped). Internal nodes are never stored.
-- THE TRIE: a 'ReplayNode' holds a hex @prefix@ and the set of consumed digest
-- SUFFIXES under it (digests are 64 lowercase hex nibbles; a node at depth d
-- stores 64−d-nibble suffixes). A trie starts as a single root (empty prefix,
-- empty set). When a node reaches @splitThreshold@ entries, the next consume
-- splits it into 16 children — one per nibble, empty ones included — bucketing
-- the suffixes by their (dropped) first nibble. Internal nodes are never stored.
-- INVARIANT: a consumer's active nodes PARTITION the digest space — every digest
-- has exactly one covering node. The root covers everything, and a split
-- replaces one node by 16 children that exactly tile its subspace, atomically.
--
-- INVARIANT: the active nodes of one consumer's trie PARTITION the digest
-- space — every digest has exactly one covering node (a node whose prefix is a
-- prefix of the digest). The vetted operations preserve it: the root covers
-- everything, and a split replaces one node by 16 children that exactly tile
-- its subspace, atomically.
--
-- WHY THIS IS SOUND WHERE KEYS WERE NOT: "digest not consumed" becomes a
-- positive statement about one NAMED contract — fetch the covering node and
-- check the suffix is absent. 'ConsumeDigest' is consuming, so every confirmer
-- validates the node's activeness: a stale cid conflicts and aborts, and the
-- current covering node provably contains or excludes the digest. Soundness is
-- SUBMITTER-INDEPENDENT: any submitter with a disclosure of the current
-- covering node gets a correct check; a wrong or stale node can only fail the
-- transaction, never replay. Archiving a node (both signatories) merely BRICKS
-- its subspace — no covering node, nothing verifies — fail-closed, the
-- opposite of the key model.
-- WHY THIS IS SOUND WHERE KEYS WERE NOT: "digest not consumed" is a positive
-- statement about one NAMED contract — fetch the covering node, check the suffix
-- is absent. 'ConsumeDigest' is consuming, so every confirmer validates the
-- node's activeness: a stale cid conflicts and aborts. Soundness is
-- submitter-independent, and archiving a node merely BRICKS its subspace
-- (fail-closed) — the opposite of the key model.
--
-- The replay vector is therefore node RE-CREATION. Against THIRD PARTIES the
-- signatory design closes it: the @operator@ co-signs every node (inherited
-- from the 'ReplayRootRegistry' anchor at the root, from the archived parent
-- at splits — the operator never signs per consume), and claiming a root for
-- a consumer requires that consumer's authority, so nobody can archive,
-- re-create, or fork someone ELSE'S replay scope. Against the consumer
-- ITSELF there is deliberately no on-ledger defense: a consumer can mint
-- parallel roots for its own scope (see 'ReplayRootRegistry'), which is
-- self-harm — replay protection is the consumer's own safety property, and a
-- compromised consumer key defeats the app more directly anyway. Same class
-- as the irreducible signatory remainder: signatories can always unmake (or
-- here, duplicate) their own state.
-- signatory design closes it: the @operator@ co-signs every node (inherited from
-- the 'ReplayRootRegistry' anchor at the root, from the archived parent at splits
-- — never per consume), and claiming a root requires the consumer's authority,
-- so nobody can archive, re-create, or fork someone ELSE'S scope. Against the
-- consumer ITSELF there is deliberately no defense: minting parallel roots is
-- self-harm (see 'ReplayRootRegistry'), the same class as any signatory being
-- able to unmake its own state.
--
-- OFF-LEDGER: consumers (and their users) locate the covering node for a
-- digest via a disclosure service — an ACS index over (consumer, prefix) that
-- serves the node's contract-id + explicit disclosure. It is untrusted for
-- safety: the worst it can do is serve a wrong or stale node, which fails.
-- RETRY PROTOCOL: racing consumes on the same node conflict and exactly one
-- commits; on rejection, re-resolve the covering node (the cid churns on every
-- consume) and resubmit. A blind retry of a consume that actually committed
-- hits the membership check and fails "digest already consumed" — a clean
-- idempotency signal.
-- OFF-LEDGER: consumers locate the covering node via a disclosure service — an
-- ACS index over (consumer, prefix), untrusted for safety (a wrong or stale node
-- only fails). RETRY: racing consumes on one node conflict and exactly one
-- commits; on rejection, re-resolve the covering node (its cid churns per
-- consume) and resubmit. A blind retry of a committed consume hits the
-- membership check — a clean idempotency signal.
module Wormhole.Core.Replay where

import DA.Map qualified as Map
Expand All @@ -66,19 +49,18 @@ import DA.Set qualified as Set
import DA.Text qualified as T
import Wormhole.Core.Bytes

-- | Default node capacity. Suffixes are ≤64 hex chars (~66 B serialized), and
-- every consume rewrites the full node, so this bounds both the node payload
-- (~8.5 KB) and the per-consume write cost (~4 KB on average). The split
-- transaction redistributes the same bytes across 16 creates (~15 KB) — all
-- comfortably below Canton's request caps. Larger values thin the ACS but
-- amplify every write; the 'ensure' clause caps it at 4096 (~270 KB nodes).
-- | Default node capacity. Suffixes are ≤64 hex chars (~66 B serialized) and
-- every consume rewrites the full node, bounding the node payload (~8.5 KB), the
-- per-consume write (~4 KB), and the split's 16 creates (~15 KB) — all below
-- Canton's request caps. Larger values thin the ACS but amplify every write;
-- 'ensure' caps it at 4096 (~270 KB nodes).
defaultSplitThreshold : Int
defaultSplitThreshold = 128

-- | One trie node: the consumed digest suffixes under @prefix@. See the module
-- header for the partition invariant and the trust model. NOT observed by the
-- guardian observer party: replay state is integrator-private safety state,
-- not attestation surface — guardians never re-check target-chain consumption.
-- | One trie node: the consumed digest suffixes under @prefix@ (see the module
-- header for the partition invariant and trust model). NOT observed by the
-- guardian observer: replay state is integrator-private safety state, not
-- attestation surface — guardians never re-check target-chain consumption.
template ReplayNode
with
consumer : Party -- integrator scope party; bears the replay risk
Expand All @@ -89,23 +71,22 @@ template ReplayNode
where
signatory consumer, operator
-- Shape invariants only — 'ensure' is a bug-catcher, not a trust boundary
-- (the signatories can always sign a well-formed shadow node). Depth-64
-- nodes are unrepresentable; suffix lengths pin the arithmetic that
-- membership depends on. Deliberately NO @size consumed <= splitThreshold@:
-- a split can legitimately create an over-full child (all parent entries
-- in one bucket); its next consume splits it further.
-- (signatories can always sign a well-formed shadow node). Depth-64 nodes are
-- unrepresentable; suffix lengths pin the membership arithmetic. Deliberately
-- NO @size consumed <= splitThreshold@: a split can legitimately create an
-- over-full child (all parent entries in one bucket), split further next
-- consume.
ensure splitThreshold >= 1 && splitThreshold <= 4096
&& T.length prefix <= 63 && isLowerHex prefix
&& all (\s -> T.length s == 64 - T.length prefix && isLowerHex s)
(Set.toList consumed)

-- | Record @digest@ as consumed, or fail if it already was. Returns the
-- node that now contains the digest — the recreated node, or, on a split,
-- the child covering it (the other 15 children surface to the disclosure
-- service as CreatedEvents). Consuming: racing consumes conflict on this
-- contract and exactly one commits (see the retry protocol in the module
-- header). To consume two digests under the same node in one transaction,
-- chain the second exercise on the returned cid.
-- | Record @digest@ as consumed, or fail if it already was. Returns the node
-- now containing the digest — the recreated node, or on a split the child
-- covering it (the other 15 surface as CreatedEvents). Consuming: racing
-- consumes conflict and exactly one commits (module-header retry protocol).
-- To consume two digests under one node in a single transaction, chain the
-- second exercise on the returned cid.
choice ConsumeDigest : ContractId ReplayNode
with
digest : Bytes32
Expand Down
Loading
Loading