diff --git a/canton/README.md b/canton/README.md index a1896f9ab9..8be599d3f1 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,26 @@ 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, 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)): +a party with no relationship to `CoreState` at all — not `operator`, not +`guardianGovernance` — verifies a real guardian-signed VAA as itself. + ### 4.4 Governance Governance follows [0002](../whitepapers/0002_governance_messaging.md). A single @@ -640,6 +661,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/core/daml/Wormhole/Core/State.daml b/canton/core/daml/Wormhole/Core/State.daml index d90b96ceac..da4c2c385d 100644 --- a/canton/core/daml/Wormhole/Core/State.daml +++ b/canton/core/daml/Wormhole/Core/State.daml @@ -112,11 +112,28 @@ 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, 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 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..b7ba981380 100644 --- a/canton/test/daml/Test/TestCore.daml +++ b/canton/test/daml/Test/TestCore.daml @@ -181,12 +181,55 @@ 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 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 + tokenBridge <- allocateParty "ExternalTokenBridge" + Some dCs <- queryDisclosure operator csId + vaa <- submitWithDisclosures tokenBridge [dCs] do + exerciseCmd csId ParseAndVerifyVAA with + verifier = tokenBridge + encodedVAA = govSetFeeVAA + pubKeys = [(0, devnetGuardianPubKey)] + vaa.guardianSetIndex === 0 + 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 <- submitWithDisclosures tokenBridge [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) +}