Skip to content

Commit 827d7dd

Browse files
committed
canton: make core-bridge contracts publicly readable via a public observer party
Add a read-only 'public' party as an observer to CoreState, EmitterRequest, and Emitter so the full records (config, emitter registrations, sequences, and published messages) are readable by anyone the participant grants read-as rights to that party. The public party carries no authority; operator remains the sole signatory. It is chosen at setup, stored on CoreState, supplied on each EmitterRequest, and copied into the Emitter on approval. - Thread public through mkInitialCoreState and the test setup/registerEmitter. - Add testPublicVisibility (public reads CoreState and the sequence-advanced Emitter; a non-stakeholder sees nothing). - Surface the public party id in the devnet bootstrap. - Document the visibility model and the required topology read-grant (README 4.6).
1 parent 5e5a0b8 commit 827d7dd

5 files changed

Lines changed: 152 additions & 33 deletions

File tree

canton/README.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ Daml package name: `wormhole-core` (the `core` package). Module layout under
152152
template CoreState
153153
with
154154
operator : Party -- holds/advances the singleton; the "deployer"
155+
public : Party -- read-only visibility party (§4.6)
155156
chainId : Int -- 72
156157
governanceChainId : Int -- 1 (Solana)
157158
governanceContract : Bytes32 -- 0x..0004 (the governance emitter)
@@ -368,6 +369,33 @@ Matches EVM (`Setters.sol`): when a new set is installed, the previous set's
368369
non-current set only while `effectiveTime < expirationTime`. The current set
369370
never expires.
370371

372+
### 4.6 Public readability
373+
374+
Canton contracts are private to their stakeholders (signatories + observers), so
375+
by default only the `operator` and an emitter's `owner` could read the bridge
376+
state. To make the full record world-readable — config, emitter registrations,
377+
per-emitter sequences, and every published message — each template carries a
378+
`public` party and lists it as an `observer`. `CoreState`, `EmitterRequest`, and
379+
`Emitter` are therefore all readable by `public`.
380+
381+
`public` is an **ordinary party with no choices and no authority**; observing it
382+
only widens visibility. It is chosen at `setup`, stored on `CoreState`, supplied
383+
on each `EmitterRequest`, and copied into the `Emitter` on approval (and carried
384+
forward unchanged on every recreate). The `operator`, as the trusted bookkeeper
385+
that already enforces emitter-address uniqueness, approves only requests carrying
386+
the canonical `public` party.
387+
388+
**The Daml change is necessary but not sufficient.** Listing `public` as an
389+
observer makes the contracts *visible to that party*; letting arbitrary readers
390+
actually **read as** `public` is a Canton topology/participant concern — grant
391+
read-as rights for the `public` party on each participant that should serve
392+
reads, or stand up a read-only front-end (the JSON Ledger API or a Participant
393+
Query Store instance) configured to read as `public`, so consumers query an
394+
endpoint and never need the party id. A party id is `hint::fingerprint`, where
395+
the fingerprint is a hash of the allocating namespace's key — deployment-specific
396+
and not guessable — so the `public` party id must be published or fronted by an
397+
API; it cannot be inferred.
398+
371399
---
372400

373401
## 5. On-chain signature verification (the crux)
@@ -766,6 +794,32 @@ Canton equivalent of EVM's `parseAndVerifyVM(bytes)`.
766794

767795
These are tracked in [Open Questions](#11-open-questions--validation-items).
768796

797+
### Worked example: token-bridge send path
798+
799+
`examples/token-bridge` is a runnable example of the lock-and-attest send path on
800+
the Canton Network Token Standard (CIP-0056). A `TokenBridge.LockAndPublish` choice
801+
atomically (1) transfers a user's holding into the bridge's custody via the token
802+
standard's `TransferFactory_Transfer` (`receiver = bridge`) and (2) publishes a
803+
Wormhole Transfer message from the bridge's `Emitter` — in a single transaction the
804+
user submits.
805+
806+
Key points it demonstrates:
807+
808+
- **Custody = transfer to the bridge party**, not the allocation API (allocation is
809+
DvP and needs a counter-leg, which a one-sided lock does not have).
810+
- **Atomicity via authority composition**: the bridge-signed orchestrator supplies
811+
the publish authority while the registry factory supplies the authority to move
812+
the asset, so no core operator is involved — matching EVM's permissionless
813+
lock+publish.
814+
- **Explicit disclosure**: the user receives the bridge, factory, and emitter
815+
contracts as disclosed contracts (it is not a stakeholder of them), the standard
816+
pattern for an app handing its factory to a user.
817+
818+
The example vendors the standard's interface DARs (pinned; see
819+
`examples/token-bridge/.lib/THIRD_PARTY.md`) and ships a minimal admin-custodied
820+
registry so it is self-issuing and runnable without Amulet. Build and test with
821+
`dpm build --all` then `cd examples/token-bridge && dpm test --all`.
822+
769823
---
770824

771825
## 11. Open Questions & Validation Items

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,15 @@ defaultGovernanceContract = "000000000000000000000000000000000000000000000000000
2525
-- the supplied governance configuration. The caller @create@s it as @operator@.
2626
mkInitialCoreState :
2727
Party -- ^ operator
28+
-> Party -- ^ public (read-only visibility party)
2829
-> Int -- ^ governance chain id
2930
-> Bytes32 -- ^ governance contract
3031
-> [Bytes20] -- ^ initial guardian addresses (set 0)
3132
-> CoreState
32-
mkInitialCoreState operator govChain govContract initialGuardians =
33+
mkInitialCoreState operator public govChain govContract initialGuardians =
3334
CoreState with
3435
operator
36+
public
3537
chainId = cantonChainId
3638
governanceChainId = govChain
3739
governanceContract = normalizeHex govContract

canton/core/daml/Wormhole/Core/State.daml

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@
2222
-- sequence without the operator's authority, matching EVM where anyone may call
2323
-- @publishMessage@. 'CoreState' holds only the shared config and governance
2424
-- state.
25+
--
26+
-- PUBLIC READABILITY: every template carries a @public@ party and lists it as an
27+
-- 'observer', so the full record (config, emitter registrations, sequences, and
28+
-- published messages) is readable by anyone the participant grants read-as
29+
-- rights to that party. @public@ is an ordinary party with no choices and no
30+
-- authority — it only widens visibility. Granting arbitrary readers the right to
31+
-- read as @public@ is a Canton topology/participant concern, not enforced here;
32+
-- see canton/README.md. The @public@ party is chosen at setup, stored on
33+
-- 'CoreState', supplied on each 'EmitterRequest', and copied into the 'Emitter'
34+
-- on approval (and carried forward on every recreate).
2535
module Wormhole.Core.State where
2636

2737
import DA.Map (Map)
@@ -70,6 +80,7 @@ maxPayloadBytes = 750
7080
template CoreState
7181
with
7282
operator : Party -- owns/advances the singleton; no power over outcomes
83+
public : Party -- read-only visibility party; see module header
7384
chainId : Int -- 72 (Canton)
7485
governanceChainId : Int -- 1 (Solana)
7586
governanceContract : Bytes32 -- governance emitter address
@@ -79,6 +90,9 @@ template CoreState
7990
consumedGovernance : Set Bytes32 -- replay protection, keyed by digest
8091
where
8192
signatory operator
93+
-- @public@ is an ordinary party that the participant grants broad read-as
94+
-- rights to; observing it makes the full record publicly readable.
95+
observer public
8296
-- No contract key: Canton 3.x has no unique keys (see module header).
8397
-- Uniqueness is operator-enforced; the current cid is tracked off-ledger.
8498

@@ -186,19 +200,24 @@ template EmitterRequest
186200
with
187201
requester : Party
188202
operator : Party
203+
public : Party -- read-only visibility party, carried into the Emitter
189204
where
190205
signatory requester
191-
observer operator
206+
observer operator, public
192207

193208
choice ApproveEmitter : ContractId Emitter
194209
controller operator
195210
do
196211
-- The identity cid is known within this transaction; the 'Emitter' stores
197212
-- it (not a derived address) and the watcher hashes it off-ledger.
198-
identityCid <- create EmitterIdentity with operator, owner = requester
213+
identityCid <- create EmitterIdentity with operator, owner = requester, public
199214
create Emitter with
200215
operator
201216
owner = requester
217+
-- Visibility party is copied from the request; the operator (the
218+
-- trusted bookkeeper, see module header) approves only requests that
219+
-- carry the canonical @public@ party.
220+
public
202221
identity = identityCid
203222
sequence = 0
204223

@@ -214,9 +233,10 @@ template EmitterIdentity
214233
with
215234
operator : Party
216235
owner : Party
236+
public : Party -- read-only visibility party; see module header
217237
where
218238
signatory operator
219-
observer owner
239+
observer owner, public
220240
-- No choices: immutability is the point. Re-minting an 'Emitter' from a fresh
221241
-- identity would reset the sequence to 0, so identities are strictly one-shot.
222242

@@ -228,11 +248,15 @@ template Emitter
228248
with
229249
operator : Party
230250
owner : Party
231-
identity : ContractId EmitterIdentity -- permanent identity; address = keccak256(this cid)
232-
sequence : Int -- next sequence to assign
251+
public : Party -- read-only visibility party; see module header
252+
identity : ContractId EmitterIdentity -- permanent identity; address = keccak256(this cid)
253+
sequence : Int -- next sequence to assign
233254
where
234255
signatory operator
235-
observer owner
256+
-- @owner@ controls publishing; @public@ makes the record (and every
257+
-- published message) world-readable. @create this@ on each publish carries
258+
-- @public@ forward unchanged.
259+
observer owner, public
236260
-- No contract key (Canton 3.x); the owner tracks the current cid off-ledger.
237261

238262
-- | Publish a Wormhole message. The choice result is the 'WormholeMessage'

canton/devnet/bootstrap.sh

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
#!/usr/bin/env bash
22
#
33
# Bootstrap the Wormhole core bridge on the Canton sandbox: allocate the Operator
4-
# party and create the CoreState (with the devnet guardian) by running the
5-
# Test.TestCore:setup Daml Script.
4+
# and Public parties and create the CoreState (with the devnet guardian) by
5+
# running the Test.TestCore:setup Daml Script.
66
#
77
# The guardian does NOT need the Operator party id — the watcher uses a wildcard
88
# "any party" filter (see canton/README.md §7.1). The party is surfaced below for
@@ -35,11 +35,18 @@ dpm script \
3535
--ledger-port "${PORT}" \
3636
--output-file "${RESULT}"
3737

38-
# The setup script returns a tuple {"_1": <Party>, "_2": <ContractId>} as JSON.
39-
# The party id is the quoted value containing "::" (the namespace separator); the
40-
# contract id has none. Informational only (the watcher needs no party id).
41-
OPERATOR_PARTY="$(grep -oE '"[^"]*::[^"]*"' "${RESULT}" | head -1 | tr -d '"')"
38+
# The setup script returns a tuple {"_1": <Operator>, "_2": <Public>, "_3":
39+
# <ContractId>} as JSON. Party ids are the quoted values containing "::" (the
40+
# namespace separator) in tuple order — operator first, then public; the contract
41+
# id has none. The Operator party is informational (the watcher uses a wildcard
42+
# filter). The Public party is the read-only visibility party every contract
43+
# observes (canton/README.md §4.6); surface it so operators can grant read-as
44+
# rights or point a JSON Ledger API / PQS reader at it.
45+
PARTIES="$(grep -oE '"[^"]*::[^"]*"' "${RESULT}" | tr -d '"')"
46+
OPERATOR_PARTY="$(echo "${PARTIES}" | sed -n '1p')"
47+
PUBLIC_PARTY="$(echo "${PARTIES}" | sed -n '2p')"
4248
echo "[canton] setup complete; Operator party: ${OPERATOR_PARTY}"
49+
echo "[canton] public (read-only) party: ${PUBLIC_PARTY}"
4350

4451
touch /canton/success
4552
echo "[canton] bootstrap done; sleeping"

canton/test/daml/Test/TestCore.daml

Lines changed: 52 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -29,19 +29,22 @@ devnetGuardianPubKey = "04d4a4629979f0c9fa0f0bb54edf33f87c8c5a1f42c0350a30d68f7e
2929
govSetFeeVAA : Bytes
3030
govSetFeeVAA = "01000000000100710ab5dcf6885f62016990540e090400f26f41286382dab479a78ed5f85017ff4a562a46379753e94abba1e69b9fec5d1994ac7d9fb145820727df0759bf4f80016553f100000000000001000000000000000000000000000000000000000000000000000000000000000400000000000000010000000000000000000000000000000000000000000000000000000000436f726503004800000000000000000000000000000000000000000000000000000000000003e8"
3131

32-
-- | Bootstrap a devnet 'CoreState' as a fresh operator party.
33-
setup : Script (Party, ContractId CoreState)
32+
-- | Bootstrap a devnet 'CoreState' as a fresh operator party, allocating the
33+
-- read-only @public@ visibility party that every template observes.
34+
setup : Script (Party, Party, ContractId CoreState)
3435
setup = do
3536
operator <- allocateParty "Operator"
37+
public <- allocateParty "Public"
3638
csId <- submit operator do
37-
createCmd (mkInitialCoreState operator solanaGovernanceChainId defaultGovernanceContract [devnetGuardian])
38-
pure (operator, csId)
39+
createCmd (mkInitialCoreState operator public solanaGovernanceChainId defaultGovernanceContract [devnetGuardian])
40+
pure (operator, public, csId)
3941

4042
-- | Register an emitter for @owner@: mints its immutable 'EmitterIdentity' and
41-
-- the 'Emitter' bound to it, and returns the 'Emitter' cid.
42-
registerEmitter : Party -> Party -> Script (ContractId Emitter)
43-
registerEmitter operator owner = do
44-
reqId <- submit owner do createCmd EmitterRequest with requester = owner, operator
43+
-- the 'Emitter' bound to it, carrying the @public@ visibility party. Returns the
44+
-- 'Emitter' cid.
45+
registerEmitter : Party -> Party -> Party -> Script (ContractId Emitter)
46+
registerEmitter operator public owner = do
47+
reqId <- submit owner do createCmd EmitterRequest with requester = owner, operator, public
4548
submit operator do exerciseCmd reqId ApproveEmitter
4649

4750
-- | Publishing increments the per-emitter sequence and yields the expected
@@ -50,9 +53,9 @@ registerEmitter operator owner = do
5053
-- address itself is not stored on-ledger, so there is nothing to assert here.
5154
testPublishMessage : Script ()
5255
testPublishMessage = do
53-
(operator, _) <- setup
56+
(operator, public, _) <- setup
5457
alice <- allocateParty "Alice"
55-
emitterId <- registerEmitter operator alice
58+
emitterId <- registerEmitter operator public alice
5659
Some em <- queryContractId alice emitterId
5760

5861
msg0 <- submit alice do
@@ -73,12 +76,41 @@ testPublishMessage = do
7376
msg1.identity === em.identity
7477
pure ()
7578

79+
-- | The @public@ party observes every template, so it can read the full record
80+
-- of the 'CoreState' and each 'Emitter' (including the advanced sequence). A
81+
-- party that is neither a stakeholder nor @public@ sees nothing.
82+
testPublicVisibility : Script ()
83+
testPublicVisibility = do
84+
(operator, public, csId) <- setup
85+
carol <- allocateParty "Carol"
86+
emitterId <- registerEmitter operator public carol
87+
_ <- submit carol do
88+
exerciseCmd emitterId PublishMessage with nonce = 1, payload = "abcd", consistencyLevel = 0
89+
90+
-- public reads the shared config singleton...
91+
[(csId', _)] <- query @CoreState public
92+
csId' === csId
93+
-- ...the recreated emitter with its advanced sequence...
94+
[(_, em)] <- query @Emitter public
95+
em.sequence === 1
96+
-- ...and the emitter's immutable identity.
97+
[(identId, _)] <- query @EmitterIdentity public
98+
em.identity === identId
99+
100+
-- privacy still holds for a party that is neither stakeholder nor public.
101+
outsider <- allocateParty "Outsider"
102+
emsForOutsider <- query @Emitter outsider
103+
length emsForOutsider === 0
104+
csForOutsider <- query @CoreState outsider
105+
length csForOutsider === 0
106+
pure ()
107+
76108
-- | Payloads larger than 750 bytes are rejected.
77109
testPayloadTooLarge : Script ()
78110
testPayloadTooLarge = do
79-
(operator, _) <- setup
111+
(operator, public, _) <- setup
80112
bob <- allocateParty "Bob"
81-
emitterId <- registerEmitter operator bob
113+
emitterId <- registerEmitter operator public bob
82114
-- 751 bytes = 1502 hex chars.
83115
let bigPayload = mconcat (replicate 1502 "f")
84116
submitMustFail bob do
@@ -97,7 +129,7 @@ testQuorum = do
97129
-- | The current guardian set is installed at index 0 and never expires.
98130
testInitialGuardianSet : Script ()
99131
testInitialGuardianSet = do
100-
(operator, csId) <- setup
132+
(operator, _, csId) <- setup
101133
Some cs <- queryContractId operator csId
102134
cs.guardianSetIndex === 0
103135
let gs = fromSome (Map.lookup 0 cs.guardianSets)
@@ -109,7 +141,7 @@ testInitialGuardianSet = do
109141
-- exercises the full keccak256 + DER + secp256k1WithEcdsaOnly path.
110142
testParseAndVerifyVAA : Script ()
111143
testParseAndVerifyVAA = do
112-
(operator, csId) <- setup
144+
(operator, _, csId) <- setup
113145
vaa <- submit operator do
114146
exerciseCmd csId ParseAndVerifyVAA with
115147
encodedVAA = govSetFeeVAA
@@ -121,7 +153,7 @@ testParseAndVerifyVAA = do
121153
-- | A SetMessageFee governance VAA verifies and updates the message fee.
122154
testGovernanceSetMessageFee : Script ()
123155
testGovernanceSetMessageFee = do
124-
(operator, csId) <- setup
156+
(operator, _, csId) <- setup
125157
newCsId <- submit operator do
126158
exerciseCmd csId SubmitGovernanceVAA with
127159
encodedVAA = govSetFeeVAA
@@ -137,23 +169,23 @@ testGovernanceSetMessageFee = do
137169
-- contract-id, which the Go test recomputes to check the mapping.
138170
integrationPublish : Script Party
139171
integrationPublish = do
140-
(operator, _) <- setup
172+
(operator, public, _) <- setup
141173
owner <- allocateParty "IntegrationEmitter"
142-
emitterId <- registerEmitter operator owner
174+
emitterId <- registerEmitter operator public owner
143175
_ <- submit owner do
144176
exerciseCmd emitterId PublishMessage with nonce = 42, payload = "11223344", consistencyLevel = 0
145177
pure operator
146178

147179
-- | Exercise the auto-generated Archive choice on each template (for coverage).
148180
testArchives : Script ()
149181
testArchives = do
150-
(operator, csId) <- setup
182+
(operator, public, csId) <- setup
151183
submit operator do archiveCmd csId -- Archive CoreState
152184
alice <- allocateParty "ArchAlice"
153-
reqId <- submit alice do createCmd EmitterRequest with requester = alice, operator
185+
reqId <- submit alice do createCmd EmitterRequest with requester = alice, operator, public
154186
submit alice do archiveCmd reqId -- Archive EmitterRequest
155187
bob <- allocateParty "ArchBob"
156-
emId <- registerEmitter operator bob
188+
emId <- registerEmitter operator public bob
157189
submit operator do archiveCmd emId -- Archive Emitter
158190
-- The immutable identity is normally never archived; exercise it here for
159191
-- coverage (operator is its signatory).

0 commit comments

Comments
 (0)