From a90ad0ae29c45b0a6e5406e56f2a2a8b7b33f261 Mon Sep 17 00:00:00 2001 From: Bengt Lofgren Date: Tue, 7 Jul 2026 12:44:37 -0700 Subject: [PATCH 1/3] canton: make ParseAndVerifyVAA's controller the caller, not operator ParseAndVerifyVAA is documented as being "for the token bridge / external verifiers" -- but was controller operator, meaning every external protocol needed operator to actively co-sign its own read-only verification call. This is nonconsuming and does no create/archive at all -- unlike SubmitGovernanceVAA (which IS consuming and creates a successor CoreState genuinely needing guardianGovernance's co-signature), nothing in this choice's body needs operator's authority for any structural reason. It's pure computation: parse, look up the guardian set, verify, return. Added `verifier : Party` as a choice argument and made it the sole controller. Any external party can now verify a VAA as itself, with operator never an active party. The one real requirement is visibility, not authorization: a non-stakeholder needs CoreState explicitly disclosed to reference it -- a data attachment servable by any party with read access, not a discretionary approval. Same pattern as Wormhole.Ntt.Manager.Transfer's controller-user fix on the canton-keyed-ntt branch (PR #30), which is where this was first verified empirically; this is the same fix applied to already-merged core code, hence its own standalone change against integration/canton rather than being bundled into that NTT-specific PR. testParseAndVerifyVAAByExternalVerifier proves it end-to-end: an external party with no relationship to CoreState (not operator, guardianGovernance, or guardianObserver) verifies a real guardian-signed VAA as itself, given only CoreState disclosed to it. Verified: dpm build --all clean; dpm test 13/13 (1 new). --- canton/README.md | 15 ++++++++++++++- canton/core/daml/Wormhole/Core/State.daml | 15 ++++++++++++++- canton/test/daml/Test/TestCore.daml | 21 +++++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/canton/README.md b/canton/README.md index a1896f9ab9..73022c897c 100644 --- a/canton/README.md +++ b/canton/README.md @@ -359,9 +359,10 @@ governance protocol. -- on CoreState; pure verification, does not mutate state nonconsuming choice ParseAndVerifyVAA : VerifiedVAA with + verifier : Party -- any party; the sole controller encodedVAA : Bytes pubKeys : [(Int, Bytes)] -- guardianIndex -> 65-byte uncompressed pubkey - controller operator + controller verifier do vaa <- parseVAA encodedVAA verifyVAA self vaa pubKeys -- §5 @@ -372,6 +373,18 @@ nonconsuming choice ParseAndVerifyVAA : VerifiedVAA not by recovery (§5). They are _untrusted hints_; verification fails unless each provided key hashes to the guardian address stored in the set. +**This is genuinely permissionless, not merely operator-delegated.** `verifier` +is the sole controller — `operator` never needs to be an active party. The +choice is `nonconsuming` and does no create/archive at all, so nothing in its +body needs `operator`'s authority for any structural reason (contrast +`SubmitGovernanceVAA` below, which *is* consuming and creates a successor +`CoreState` genuinely needing `guardianGovernance`'s co-signature). Any external +protocol — the token bridge, NTT, anything else — can verify a VAA as itself. +The one real requirement is visibility, not authorization: a non-stakeholder +needs `CoreState` explicitly disclosed to reference it at all — a data +attachment servable by any party with read access, not a discretionary +approval. + ### 4.4 Governance Governance follows [0002](../whitepapers/0002_governance_messaging.md). A single diff --git a/canton/core/daml/Wormhole/Core/State.daml b/canton/core/daml/Wormhole/Core/State.daml index d90b96ceac..e10d27ddd4 100644 --- a/canton/core/daml/Wormhole/Core/State.daml +++ b/canton/core/daml/Wormhole/Core/State.daml @@ -112,11 +112,24 @@ template CoreState -- | Read-only VAA verification, for the token bridge / external verifiers. -- Verifies against the CURRENT guardian set. @pubKeys@ are untrusted hints -- bound to guardian addresses inside 'verifyVAA'. + -- + -- @verifier@ (any party, supplied by the caller) is the sole controller -- + -- @operator@ does not need to be an active party. This choice is + -- @nonconsuming@ and does no create/archive at all, so nothing in its body + -- needs @operator@'s authority for any structural reason (contrast + -- 'SubmitGovernanceVAA', which IS consuming and creates a successor + -- 'CoreState' genuinely needing @guardianGovernance@'s co-signature). Any + -- external protocol can verify a VAA as itself. The one real requirement is + -- visibility, not authorization: a non-stakeholder needs @CoreState@ + -- explicitly disclosed to reference it at all -- a data attachment servable + -- by any party with read access, not a discretionary approval (same pattern + -- as 'Wormhole.Ntt.Manager.Transfer'). nonconsuming choice ParseAndVerifyVAA : VAA with + verifier : Party encodedVAA : Bytes pubKeys : [(Int, Bytes)] - controller operator + controller verifier do now <- getTime let vaa = parseVAA encodedVAA diff --git a/canton/test/daml/Test/TestCore.daml b/canton/test/daml/Test/TestCore.daml index d963b5f1eb..b49fd757c3 100644 --- a/canton/test/daml/Test/TestCore.daml +++ b/canton/test/daml/Test/TestCore.daml @@ -181,6 +181,27 @@ testParseAndVerifyVAA = do (operator, csId) <- setup vaa <- submit operator do exerciseCmd csId ParseAndVerifyVAA with + verifier = operator + encodedVAA = govSetFeeVAA + pubKeys = [(0, devnetGuardianPubKey)] + vaa.guardianSetIndex === 0 + vaa.emitterChain === 1 + vaa.sequence === 1 + +-- | ParseAndVerifyVAA is genuinely permissionless: an external party with no +-- relationship to CoreState at all (not operator, guardianGovernance, or +-- guardianObserver) can verify a VAA as itself, with no involvement from +-- operator, given only CoreState disclosed to it -- a data attachment, not an +-- approval. This is what the "for the token bridge / external verifiers" +-- doc comment on the choice promises. +testParseAndVerifyVAAByExternalVerifier : Script () +testParseAndVerifyVAAByExternalVerifier = do + (operator, csId) <- setup + tokenBridge <- allocateParty "ExternalTokenBridge" + Some dCs <- queryDisclosure operator csId + vaa <- submit (actAs tokenBridge <> discloseMany [dCs]) do + exerciseCmd csId ParseAndVerifyVAA with + verifier = tokenBridge encodedVAA = govSetFeeVAA pubKeys = [(0, devnetGuardianPubKey)] vaa.guardianSetIndex === 0 From eaed51fcff0f44a86dac8963e296e2a9986cb7f2 Mon Sep 17 00:00:00 2001 From: Bengt Lofgren Date: Tue, 7 Jul 2026 12:51:35 -0700 Subject: [PATCH 2/3] canton: add a live integration test for the external-verifier fix Proves ParseAndVerifyVAA is callable by an external "verifier" party that is NOT a stakeholder of CoreState (CoreState's only stakeholders are operator and guardianGovernance) against a real Canton sandbox/participant, not just the Script interpreter. Test.TestCore:integrationParseAndVerifyVAAByExternalVerifier allocates a fresh external party, has CoreState explicitly disclosed to it, and verifies a real guardian-signed VAA as that party alone -- operator is never active in the submission. node/pkg/watchers/canton/core_verify_vaa_integration_test.go boots a live sandbox, runs it via `dpm script`, and confirms the returned party is a real, live-hosted party id. Also fixed a copy-paste inaccuracy in testParseAndVerifyVAAByExternalVerifier's docstring from the previous commit: it named guardianObserver as one of CoreState's stakeholders, but that party doesn't exist on this branch (integration/canton) -- CoreState's only stakeholders here are operator and guardianGovernance. Verified live: go test -tags integration -run TestCantonParseAndVerifyVAAExternalVerifierIntegration ./pkg/watchers/canton -v PASS (23.4s), clean teardown, no stray processes. dpm build --all / dpm test also clean (14 scripts, 1 new). --- canton/README.md | 8 +- canton/test/daml/Test/TestCore.daml | 32 +++++- .../core_verify_vaa_integration_test.go | 99 +++++++++++++++++++ 3 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 node/pkg/watchers/canton/core_verify_vaa_integration_test.go diff --git a/canton/README.md b/canton/README.md index 73022c897c..69cc2d6542 100644 --- a/canton/README.md +++ b/canton/README.md @@ -383,7 +383,12 @@ protocol — the token bridge, NTT, anything else — can verify a VAA as itself The one real requirement is visibility, not authorization: a non-stakeholder needs `CoreState` explicitly disclosed to reference it at all — a data attachment servable by any party with read access, not a discretionary -approval. +approval. Proven both in-memory +(`test/daml/Test/TestCore.daml:testParseAndVerifyVAAByExternalVerifier`) and +against a live sandbox/participant +([`node/pkg/watchers/canton/core_verify_vaa_integration_test.go`](../node/pkg/watchers/canton/core_verify_vaa_integration_test.go)): +a party with no relationship to `CoreState` at all — not `operator`, not +`guardianGovernance` — verifies a real guardian-signed VAA as itself. ### 4.4 Governance @@ -653,6 +658,7 @@ node/pkg/cantonclient/ ← Ledger API v2 gRPC client vectorgen_test.go ← regenerates the signed-VAA test vector (GEN_CANTON_VECTORS=1) node/pkg/watchers/canton/ ← the watcher (config.go, watcher.go) watcher_integration_test.go ← dpm-driven end-to-end test (integration tag) + core_verify_vaa_integration_test.go ← ParseAndVerifyVAA by a non-stakeholder external party (integration tag) sdk/vaa/structs.go ← ChainIDCanton = 72 devnet/canton-devnet.yaml ← Tilt k8s manifest (sandbox + bootstrap) Tiltfile ← `canton` component (opt-in) diff --git a/canton/test/daml/Test/TestCore.daml b/canton/test/daml/Test/TestCore.daml index b49fd757c3..6860ed0afb 100644 --- a/canton/test/daml/Test/TestCore.daml +++ b/canton/test/daml/Test/TestCore.daml @@ -189,11 +189,11 @@ testParseAndVerifyVAA = do vaa.sequence === 1 -- | ParseAndVerifyVAA is genuinely permissionless: an external party with no --- relationship to CoreState at all (not operator, guardianGovernance, or --- guardianObserver) can verify a VAA as itself, with no involvement from --- operator, given only CoreState disclosed to it -- a data attachment, not an --- approval. This is what the "for the token bridge / external verifiers" --- doc comment on the choice promises. +-- relationship to CoreState at all (not operator or guardianGovernance -- +-- CoreState's only two stakeholders) can verify a VAA as itself, with no +-- involvement from operator, given only CoreState disclosed to it -- a data +-- attachment, not an approval. This is what the "for the token bridge / +-- external verifiers" doc comment on the choice promises. testParseAndVerifyVAAByExternalVerifier : Script () testParseAndVerifyVAAByExternalVerifier = do (operator, csId) <- setup @@ -208,6 +208,28 @@ testParseAndVerifyVAAByExternalVerifier = do vaa.emitterChain === 1 vaa.sequence === 1 +-- | Integration entrypoint, invoked by the Go integration test via `dpm +-- script` against a live sandbox/participant -- not just the Script +-- interpreter. Same scenario as 'testParseAndVerifyVAAByExternalVerifier': +-- an external party with no relationship to CoreState verifies a real +-- guardian-signed VAA as itself, given only CoreState disclosed to it. +-- Returns the external verifier's party id so the Go test can sanity-check +-- it (a real, hosted party, not something forged). +integrationParseAndVerifyVAAByExternalVerifier : Script Party +integrationParseAndVerifyVAAByExternalVerifier = do + (operator, csId) <- setup + tokenBridge <- allocateParty "IntegrationExternalVerifier" + Some dCs <- queryDisclosure operator csId + vaa <- submit (actAs tokenBridge <> discloseMany [dCs]) do + exerciseCmd csId ParseAndVerifyVAA with + verifier = tokenBridge + encodedVAA = govSetFeeVAA + pubKeys = [(0, devnetGuardianPubKey)] + vaa.guardianSetIndex === 0 + vaa.emitterChain === 1 + vaa.sequence === 1 + pure tokenBridge + -- | A SetMessageFee governance VAA verifies and updates the message fee. Looks up -- the stable governance anchor once, then exercises by the 'CoreState' key -- (ExerciseByKeyCommand) — so governance needs no contract-id tracking, and the diff --git a/node/pkg/watchers/canton/core_verify_vaa_integration_test.go b/node/pkg/watchers/canton/core_verify_vaa_integration_test.go new file mode 100644 index 0000000000..b9bebdee63 --- /dev/null +++ b/node/pkg/watchers/canton/core_verify_vaa_integration_test.go @@ -0,0 +1,99 @@ +//go:build integration + +// Integration test proving ParseAndVerifyVAA is callable by an external +// "verifier" party that is NOT a stakeholder of CoreState (CoreState's only +// stakeholders are operator and guardianGovernance), against a live Canton +// sandbox/participant -- not just the Script interpreter. +// +// It runs Test.TestCore:integrationParseAndVerifyVAAByExternalVerifier, which +// allocates a fresh external party, has CoreState explicitly disclosed to it +// (a data attachment, not a signature), and verifies a real guardian-signed +// VAA as that party alone -- operator is never an active party in that +// submission. The script's own assertions (guardianSetIndex/emitterChain/ +// sequence checks) make `dpm script` exit non-zero if verification failed; +// this test additionally confirms the returned party is a real, live-hosted +// party id, not something forged. +// +// go test -tags integration -run TestCantonParseAndVerifyVAAExternalVerifierIntegration ./pkg/watchers/canton -v +// +// Requires `dpm` (PATH or ~/.dpm/bin) + a JDK; skipped otherwise. Slow (boots +// a JVM sandbox), so it is excluded from the default build. findDpm/runCmd +// are shared with watcher_integration_test.go. +package canton + +import ( + "context" + "encoding/json" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestCantonParseAndVerifyVAAExternalVerifierIntegration(t *testing.T) { + dpm := findDpm(t) + + cantonDir, err := filepath.Abs("../../../../canton") + require.NoError(t, err) + dar := filepath.Join(cantonDir, "test", ".daml", "dist", "wormhole-core-test-0.1.0.dar") + + port := os.Getenv("CANTON_SANDBOX_PORT") + if port == "" { + port = "6865" + } + addr := "localhost:" + port + + runCmd(t, cantonDir, dpm, "build", "--all") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + sandboxDir := t.TempDir() + logPath := filepath.Join(sandboxDir, "sandbox.log") + logFile, err := os.Create(logPath) + require.NoError(t, err) + defer logFile.Close() + + sandbox := exec.CommandContext(ctx, dpm, "sandbox", "--no-tty") + sandbox.Dir = sandboxDir + sandbox.Stdout = logFile + sandbox.Stderr = logFile + sandbox.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + require.NoError(t, sandbox.Start()) + t.Cleanup(func() { + if sandbox.Process != nil { + _ = syscall.Kill(-sandbox.Process.Pid, syscall.SIGKILL) + } + }) + + require.Eventually(t, func() bool { + b, _ := os.ReadFile(logPath) + return strings.Contains(string(b), "Canton sandbox is ready") + }, 180*time.Second, 2*time.Second, "sandbox never became ready") + conn, err := net.DialTimeout("tcp", addr, 5*time.Second) + require.NoError(t, err) + _ = conn.Close() + + // Upload+vet the DAR and run the scenario. All the real assertions (the + // verification succeeding, the decoded VAA fields matching) live inside the + // script; a non-zero exit here means one of them failed. + partyFile := filepath.Join(sandboxDir, "verifier.json") + out, err := exec.Command(dpm, "script", "--dar", dar, "--upload-dar", "yes", + "--script-name", "Test.TestCore:integrationParseAndVerifyVAAByExternalVerifier", + "--ledger-host", "localhost", "--ledger-port", port, + "--output-file", partyFile).CombinedOutput() + require.NoErrorf(t, err, "dpm script failed: %s", out) + + raw, err := os.ReadFile(partyFile) + require.NoError(t, err) + var verifier string + require.NoError(t, json.Unmarshal(raw, &verifier)) + require.NotEmpty(t, verifier) + require.Contains(t, verifier, "::", "verifier should be a full, live-hosted party id") + t.Logf("external, non-stakeholder verifier party successfully called ParseAndVerifyVAA: %s", verifier) +} From a3b24651f374763283243d4161dc1d361ad41ff2 Mon Sep 17 00:00:00 2001 From: Bengt Lofgren Date: Tue, 7 Jul 2026 13:33:35 -0700 Subject: [PATCH 3/3] canton: use submitWithDisclosures for readability, add docs pointer submit (actAs tokenBridge <> discloseMany [dCs]) is the general SubmitOptions-builder form, needed when multiple actAs parties are required alongside disclosure. Neither call site here needs more than one actAs party, so this was just habit -- submitWithDisclosures is strictly equivalent (it's literally defined as `submitWithDisclosures p ds cmds = submit (actAs p <> discloseMany ds) cmds` in Daml.Script.Internal.Questions.Submit) and reads more directly: the function name says "this uses disclosure" instead of requiring the reader to notice `<> discloseMany` tacked onto a builder. Also added a direct link to Daml's Explicit Contract Disclosure docs at the point ParseAndVerifyVAA's comment first introduces the mechanism, and named submitWithDisclosures as the Script-side idiom, so a future reader can look up the primary source instead of just trusting the comment. Verified: dpm build --all clean; dpm test passes (3/3 ParseAndVerifyVAA scripts); live integration test re-run and still passes (TestCantonParseAndVerifyVAAExternalVerifierIntegration, PASS, 24.3s). --- canton/README.md | 9 ++++++--- canton/core/daml/Wormhole/Core/State.daml | 10 +++++++--- canton/test/daml/Test/TestCore.daml | 4 ++-- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/canton/README.md b/canton/README.md index 69cc2d6542..8be599d3f1 100644 --- a/canton/README.md +++ b/canton/README.md @@ -381,9 +381,12 @@ body needs `operator`'s authority for any structural reason (contrast `CoreState` genuinely needing `guardianGovernance`'s co-signature). Any external protocol — the token bridge, NTT, anything else — can verify a VAA as itself. The one real requirement is visibility, not authorization: a non-stakeholder -needs `CoreState` explicitly disclosed to reference it at all — a data -attachment servable by any party with read access, not a discretionary -approval. Proven both in-memory +needs `CoreState` explicitly disclosed to reference it at all, via Daml's +[Explicit Contract +Disclosure](https://docs.digitalasset.com/build/3.4/sdlc-howtos/applications/develop/explicit-contract-disclosure.html) +feature — a data attachment, not a signature — servable by any party with read +access. In Daml Script, `submitWithDisclosures` is the idiom. Proven both +in-memory (`test/daml/Test/TestCore.daml:testParseAndVerifyVAAByExternalVerifier`) and against a live sandbox/participant ([`node/pkg/watchers/canton/core_verify_vaa_integration_test.go`](../node/pkg/watchers/canton/core_verify_vaa_integration_test.go)): diff --git a/canton/core/daml/Wormhole/Core/State.daml b/canton/core/daml/Wormhole/Core/State.daml index e10d27ddd4..da4c2c385d 100644 --- a/canton/core/daml/Wormhole/Core/State.daml +++ b/canton/core/daml/Wormhole/Core/State.daml @@ -121,9 +121,13 @@ template CoreState -- 'CoreState' genuinely needing @guardianGovernance@'s co-signature). Any -- external protocol can verify a VAA as itself. The one real requirement is -- visibility, not authorization: a non-stakeholder needs @CoreState@ - -- explicitly disclosed to reference it at all -- a data attachment servable - -- by any party with read access, not a discretionary approval (same pattern - -- as 'Wormhole.Ntt.Manager.Transfer'). + -- explicitly disclosed to reference it at all, via Daml's Explicit Contract + -- Disclosure feature -- a data attachment, not a signature, servable by any + -- party with read access: + -- . + -- In Daml Script, @'submitWithDisclosures'@ is the idiom (see + -- @testParseAndVerifyVAAByExternalVerifier@); same pattern as + -- 'Wormhole.Ntt.Manager.Transfer'. nonconsuming choice ParseAndVerifyVAA : VAA with verifier : Party diff --git a/canton/test/daml/Test/TestCore.daml b/canton/test/daml/Test/TestCore.daml index 6860ed0afb..b7ba981380 100644 --- a/canton/test/daml/Test/TestCore.daml +++ b/canton/test/daml/Test/TestCore.daml @@ -199,7 +199,7 @@ testParseAndVerifyVAAByExternalVerifier = do (operator, csId) <- setup tokenBridge <- allocateParty "ExternalTokenBridge" Some dCs <- queryDisclosure operator csId - vaa <- submit (actAs tokenBridge <> discloseMany [dCs]) do + vaa <- submitWithDisclosures tokenBridge [dCs] do exerciseCmd csId ParseAndVerifyVAA with verifier = tokenBridge encodedVAA = govSetFeeVAA @@ -220,7 +220,7 @@ integrationParseAndVerifyVAAByExternalVerifier = do (operator, csId) <- setup tokenBridge <- allocateParty "IntegrationExternalVerifier" Some dCs <- queryDisclosure operator csId - vaa <- submit (actAs tokenBridge <> discloseMany [dCs]) do + vaa <- submitWithDisclosures tokenBridge [dCs] do exerciseCmd csId ParseAndVerifyVAA with verifier = tokenBridge encodedVAA = govSetFeeVAA