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
24 changes: 23 additions & 1 deletion canton/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
19 changes: 18 additions & 1 deletion canton/core/daml/Wormhole/Core/State.daml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
-- <https://docs.digitalasset.com/build/3.4/sdlc-howtos/applications/develop/explicit-contract-disclosure.html>.
-- 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
Expand Down
43 changes: 43 additions & 0 deletions canton/test/daml/Test/TestCore.daml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
99 changes: 99 additions & 0 deletions node/pkg/watchers/canton/core_verify_vaa_integration_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading