Browse Source

bridge: implement guardian set update submission node admin service

Tested on a live devnet via `scripts/test-injection.sh 0`.

ghstack-source-id: 92489c2455e677433414dfa66c6917a577e4c4a5
Pull Request resolved: https://github.com/certusone/wormhole/pull/104
Leo 5 years ago
parent
commit
66430cb5be

+ 81 - 0
bridge/cmd/guardiand/adminclient.go

@@ -0,0 +1,81 @@
+package guardiand
+
+import (
+	"context"
+	"fmt"
+	"io/ioutil"
+	"log"
+	"time"
+
+	"github.com/spf13/cobra"
+	"github.com/status-im/keycard-go/hexutils"
+	"google.golang.org/grpc"
+	"google.golang.org/protobuf/encoding/prototext"
+
+	nodev1 "github.com/certusone/wormhole/bridge/pkg/proto/node/v1"
+)
+
+var clientSocketPath *string
+
+func init() {
+	pf := AdminClientInjectGuardianSetUpdateCmd.Flags()
+	clientSocketPath = pf.String("socket", "", "gRPC admin server socket to connect to")
+	err := cobra.MarkFlagRequired(pf, "socket")
+	if err != nil {
+		panic(err)
+	}
+
+	AdminCmd.AddCommand(AdminClientInjectGuardianSetUpdateCmd)
+	AdminCmd.AddCommand(AdminClientGuardianSetTemplateCmd)
+	AdminCmd.AddCommand(AdminClientGuardianSetVerifyCmd)
+}
+
+var AdminCmd = &cobra.Command{
+	Use:   "admin",
+	Short: "Guardian node admin commands",
+}
+
+var AdminClientInjectGuardianSetUpdateCmd = &cobra.Command{
+	Use:   "guardian-set-update-inject",
+	Short: "Inject and sign a guardian set update from a prototxt file (see docs!)",
+	Run:   runInjectGuardianSetUpdate,
+	Args:  cobra.ExactArgs(1),
+}
+
+func getAdminClient(ctx context.Context, addr string) (*grpc.ClientConn, error, nodev1.NodePrivilegedClient) {
+	conn, err := grpc.DialContext(ctx, fmt.Sprintf("unix:///%s", addr), grpc.WithInsecure())
+
+	if err != nil {
+		log.Fatalf("failed to connect to %s: %v", addr, err)
+	}
+
+	c := nodev1.NewNodePrivilegedClient(conn)
+	return conn, err, c
+}
+
+func runInjectGuardianSetUpdate(cmd *cobra.Command, args []string) {
+	path := args[0]
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	conn, err, c := getAdminClient(ctx, *clientSocketPath)
+	defer conn.Close()
+
+	b, err := ioutil.ReadFile(path)
+	if err != nil {
+		log.Fatalf("failed to read file: %v", err)
+	}
+
+	var msg nodev1.GuardianSetUpdate
+	err = prototext.Unmarshal(b, &msg)
+	if err != nil {
+		log.Fatalf("failed to deserialize: %v", err)
+	}
+
+	resp, err := c.SubmitGuardianSetVAA(ctx, &nodev1.SubmitGuardianSetVAARequest{GuardianSet: &msg})
+	if err != nil {
+		log.Fatalf("failed to submit guardian set update: %v", err)
+	}
+
+	log.Printf("VAA successfully injected with digest %s", hexutils.BytesToHex(resp.Digest))
+}

+ 123 - 0
bridge/cmd/guardiand/adminserver.go

@@ -0,0 +1,123 @@
+package guardiand
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net"
+	"os"
+	"time"
+
+	ethcommon "github.com/ethereum/go-ethereum/common"
+	"go.uber.org/zap"
+	"google.golang.org/grpc"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
+
+	"github.com/certusone/wormhole/bridge/pkg/common"
+	nodev1 "github.com/certusone/wormhole/bridge/pkg/proto/node/v1"
+	"github.com/certusone/wormhole/bridge/pkg/supervisor"
+	"github.com/certusone/wormhole/bridge/pkg/vaa"
+)
+
+type nodePrivilegedService struct {
+	nodev1.UnimplementedNodePrivilegedServer
+	injectC chan<- *vaa.VAA
+	logger  *zap.Logger
+}
+
+// adminGuardianSetUpdateToVAA converts a nodev1.GuardianSetUpdate message to its canonical VAA representation.
+// Returns an error if the data is invalid.
+func adminGuardianSetUpdateToVAA(req *nodev1.GuardianSetUpdate) (*vaa.VAA, error) {
+	if len(req.Guardians) == 0 {
+		return nil, errors.New("empty guardian set specified")
+	}
+
+	if len(req.Guardians) > common.MaxGuardianCount {
+		return nil, fmt.Errorf("too many guardians - %d, maximum is %d", len(req.Guardians), common.MaxGuardianCount)
+	}
+
+	addrs := make([]ethcommon.Address, len(req.Guardians))
+	for i, g := range req.Guardians {
+		if !ethcommon.IsHexAddress(g.Pubkey) {
+			return nil, fmt.Errorf("invalid pubkey format at index %d (%s)", i, g.Name)
+		}
+
+		addrs[i] = ethcommon.HexToAddress(g.Pubkey)
+	}
+
+	v := &vaa.VAA{
+		Version:          vaa.SupportedVAAVersion,
+		GuardianSetIndex: req.CurrentSetIndex,
+		Timestamp:        time.Unix(int64(req.Timestamp), 0),
+		Payload: &vaa.BodyGuardianSetUpdate{
+			Keys:     addrs,
+			NewIndex: req.CurrentSetIndex + 1,
+		},
+	}
+
+	return v, nil
+}
+
+func (s *nodePrivilegedService) SubmitGuardianSetVAA(ctx context.Context, req *nodev1.SubmitGuardianSetVAARequest) (*nodev1.SubmitGuardianSetVAAResponse, error) {
+	s.logger.Info("guardian set injected via admin socket", zap.String("request", req.String()))
+
+	v, err := adminGuardianSetUpdateToVAA(req.GuardianSet)
+	if err != nil {
+		return nil, status.Error(codes.InvalidArgument, err.Error())
+	}
+
+	// Generate digest of the unsigned VAA.
+	digest, err := v.SigningMsg()
+	if err != nil {
+		panic(err)
+	}
+
+	s.logger.Info("guardian set VAA constructed",
+		zap.Any("vaa", v),
+		zap.String("digest", digest.String()),
+	)
+
+	s.injectC <- v
+
+	return &nodev1.SubmitGuardianSetVAAResponse{Digest: digest.Bytes()}, nil
+}
+
+func adminServiceRunnable(logger *zap.Logger, socketPath string, injectC chan<- *vaa.VAA) (supervisor.Runnable, error) {
+	// Delete existing UNIX socket, if present.
+	fi, err := os.Stat(socketPath)
+	if err == nil {
+		fmode := fi.Mode()
+		if fmode&os.ModeType == os.ModeSocket {
+			err = os.Remove(socketPath)
+			if err != nil {
+				return nil, fmt.Errorf("failed to remove existing socket at %s: %w", socketPath, err)
+			}
+		} else {
+			return nil, fmt.Errorf("%s is not a UNIX socket", socketPath)
+		}
+	}
+
+	// Create a new UNIX socket and listen to it.
+
+	// The socket is created with the default umask. We set a restrictive umask in setRestrictiveUmask
+	// to ensure that any files we create are only readable by the user - this is much harder to mess up.
+	// The umask avoids a race condition between file creation and chmod.
+
+	laddr, err := net.ResolveUnixAddr("unix", socketPath)
+	l, err := net.ListenUnix("unix", laddr)
+	if err != nil {
+		return nil, fmt.Errorf("failed to listen on %s: %w", socketPath, err)
+	}
+
+	logger.Info("listening on", zap.String("path", socketPath))
+
+	nodeService := &nodePrivilegedService{
+		injectC: injectC,
+		logger:  logger.Named("adminservice"),
+	}
+
+	grpcServer := grpc.NewServer()
+	nodev1.RegisterNodePrivilegedServer(grpcServer, nodeService)
+	return supervisor.GRPCServer(grpcServer, l, false), nil
+}

+ 61 - 0
bridge/cmd/guardiand/admintemplate.go

@@ -0,0 +1,61 @@
+package guardiand
+
+import (
+	"fmt"
+	"io/ioutil"
+	"log"
+
+	"github.com/ethereum/go-ethereum/crypto"
+	"github.com/spf13/cobra"
+	"google.golang.org/protobuf/encoding/prototext"
+
+	"github.com/certusone/wormhole/bridge/pkg/devnet"
+	nodev1 "github.com/certusone/wormhole/bridge/pkg/proto/node/v1"
+)
+
+var templateNumGuardians *int
+var templateGuardianIndex *int
+
+func init() {
+	templateNumGuardians = AdminClientGuardianSetTemplateCmd.Flags().Int("num", 1, "Number of devnet guardians in example file")
+	templateGuardianIndex = AdminClientGuardianSetTemplateCmd.Flags().Int("idx", 0, "Default current guardian set index")
+}
+
+var AdminClientGuardianSetTemplateCmd = &cobra.Command{
+	Use:   "guardian-set-update-template",
+	Short: "Generate an empty guardian set template at specified path (offline)",
+	Run:   runGuardianSetTemplate,
+	Args:  cobra.ExactArgs(1),
+}
+
+func runGuardianSetTemplate(cmd *cobra.Command, args []string) {
+	path := args[0]
+
+	// Use deterministic devnet addresses as examples in the template, such that this doubles as a test fixture.
+	guardians := make([]*nodev1.GuardianSetUpdate_Guardian, *templateNumGuardians)
+	for i := 0; i < *templateNumGuardians; i++ {
+		k := devnet.DeterministicEcdsaKeyByIndex(crypto.S256(), uint64(i))
+		guardians[i] = &nodev1.GuardianSetUpdate_Guardian{
+			Pubkey: crypto.PubkeyToAddress(k.PublicKey).Hex(),
+			Name:   fmt.Sprintf("Example validator %d", i),
+		}
+	}
+
+	m := &nodev1.GuardianSetUpdate{
+		CurrentSetIndex: uint32(*templateGuardianIndex),
+		// Timestamp is hardcoded to make it reproducible on different devnet nodes.
+		// In production, a real UNIX timestamp should be used (see node.proto).
+		Timestamp: 1605744545,
+		Guardians: guardians,
+	}
+
+	b, err := prototext.MarshalOptions{Multiline: true}.Marshal(m)
+	if err != nil {
+		panic(err)
+	}
+
+	err = ioutil.WriteFile(path, b, 0640)
+	if err != nil {
+		log.Fatal(err)
+	}
+}

+ 46 - 0
bridge/cmd/guardiand/adminverify.go

@@ -0,0 +1,46 @@
+package guardiand
+
+import (
+	"io/ioutil"
+	"log"
+
+	"github.com/davecgh/go-spew/spew"
+	"github.com/spf13/cobra"
+	"google.golang.org/protobuf/encoding/prototext"
+
+	nodev1 "github.com/certusone/wormhole/bridge/pkg/proto/node/v1"
+)
+
+var AdminClientGuardianSetVerifyCmd = &cobra.Command{
+	Use:   "guardian-set-update-verify",
+	Short: "Verify guardian set update in prototxt format (offline)",
+	Run:   runGuardianSetVerify,
+	Args:  cobra.ExactArgs(1),
+}
+
+func runGuardianSetVerify(cmd *cobra.Command, args []string) {
+	path := args[0]
+
+	b, err := ioutil.ReadFile(path)
+	if err != nil {
+		log.Fatalf("failed to read file: %v", err)
+	}
+
+	var msg nodev1.GuardianSetUpdate
+	err = prototext.Unmarshal(b, &msg)
+	if err != nil {
+		log.Fatalf("failed to deserialize: %v", err)
+	}
+
+	v, err := adminGuardianSetUpdateToVAA(&msg)
+	if err != nil {
+		log.Fatalf("invalid update: %v", err)
+	}
+
+	digest, err := v.SigningMsg()
+	if err != nil {
+		panic(err)
+	}
+
+	log.Printf("VAA with digest %s: %+v", digest.Hex(), spew.Sdump(v))
+}

+ 27 - 1
bridge/cmd/guardiand/bridge.go

@@ -38,6 +38,8 @@ var (
 
 
 	nodeKeyPath *string
 	nodeKeyPath *string
 
 
+	adminSocketPath *string
+
 	bridgeKeyPath *string
 	bridgeKeyPath *string
 
 
 	ethRPC           *string
 	ethRPC           *string
@@ -67,6 +69,8 @@ func init() {
 
 
 	nodeKeyPath = BridgeCmd.Flags().String("nodeKey", "", "Path to node key (will be generated if it doesn't exist)")
 	nodeKeyPath = BridgeCmd.Flags().String("nodeKey", "", "Path to node key (will be generated if it doesn't exist)")
 
 
+	adminSocketPath = BridgeCmd.Flags().String("adminSocket", "", "Admin gRPC service UNIX domain socket path")
+
 	bridgeKeyPath = BridgeCmd.Flags().String("bridgeKey", "", "Path to guardian key (required)")
 	bridgeKeyPath = BridgeCmd.Flags().String("bridgeKey", "", "Path to guardian key (required)")
 
 
 	ethRPC = BridgeCmd.Flags().String("ethRPC", "", "Ethereum RPC URL")
 	ethRPC = BridgeCmd.Flags().String("ethRPC", "", "Ethereum RPC URL")
@@ -133,6 +137,12 @@ func lockMemory() {
 	}
 	}
 }
 }
 
 
+// setRestrictiveUmask masks the group and world bits. This ensures that key material
+// and sockets we create aren't accidentally group- or world-readable.
+func setRestrictiveUmask() {
+	syscall.Umask(0077) // cannot fail
+}
+
 // BridgeCmd represents the bridge command
 // BridgeCmd represents the bridge command
 var BridgeCmd = &cobra.Command{
 var BridgeCmd = &cobra.Command{
 	Use:   "bridge",
 	Use:   "bridge",
@@ -146,6 +156,7 @@ func runBridge(cmd *cobra.Command, args []string) {
 	}
 	}
 
 
 	lockMemory()
 	lockMemory()
+	setRestrictiveUmask()
 
 
 	// Set up logging. The go-log zap wrapper that libp2p uses is compatible with our
 	// Set up logging. The go-log zap wrapper that libp2p uses is compatible with our
 	// usage of zap in supervisor, which is nice.
 	// usage of zap in supervisor, which is nice.
@@ -196,6 +207,9 @@ func runBridge(cmd *cobra.Command, args []string) {
 	if *bridgeKeyPath == "" {
 	if *bridgeKeyPath == "" {
 		logger.Fatal("Please specify -bridgeKey")
 		logger.Fatal("Please specify -bridgeKey")
 	}
 	}
+	if *adminSocketPath == "" {
+		logger.Fatal("Please specify -adminSocket")
+	}
 	if *agentRPC == "" {
 	if *agentRPC == "" {
 		logger.Fatal("Please specify -agentRPC")
 		logger.Fatal("Please specify -agentRPC")
 	}
 	}
@@ -273,6 +287,9 @@ func runBridge(cmd *cobra.Command, args []string) {
 	// VAAs to submit to Solana
 	// VAAs to submit to Solana
 	solanaVaaC := make(chan *vaa.VAA)
 	solanaVaaC := make(chan *vaa.VAA)
 
 
+	// Injected VAAs (manually generated rather than created via observation)
+	injectC := make(chan *vaa.VAA)
+
 	// Load p2p private key
 	// Load p2p private key
 	var priv crypto.PrivKey
 	var priv crypto.PrivKey
 	if *unsafeDevMode {
 	if *unsafeDevMode {
@@ -288,6 +305,11 @@ func runBridge(cmd *cobra.Command, args []string) {
 		}
 		}
 	}
 	}
 
 
+	adminService, err := adminServiceRunnable(logger, *adminSocketPath, injectC)
+	if err != nil {
+		logger.Fatal("failed to create admin service socket", zap.Error(err))
+	}
+
 	// Run supervisor.
 	// Run supervisor.
 	supervisor.New(rootCtx, logger, func(ctx context.Context) error {
 	supervisor.New(rootCtx, logger, func(ctx context.Context) error {
 		if err := supervisor.Run(ctx, "p2p", p2p.Run(
 		if err := supervisor.Run(ctx, "p2p", p2p.Run(
@@ -314,11 +336,15 @@ func runBridge(cmd *cobra.Command, args []string) {
 			return err
 			return err
 		}
 		}
 
 
-		p := processor.NewProcessor(ctx, lockC, setC, sendC, obsvC, solanaVaaC, gk, *unsafeDevMode, *devNumGuardians, *ethRPC, *terraLCD, *terraChaidID, *terraContract, *terraFeePayer)
+		p := processor.NewProcessor(ctx, lockC, setC, sendC, obsvC, solanaVaaC, injectC, gk, *unsafeDevMode, *devNumGuardians, *ethRPC, *terraLCD, *terraChaidID, *terraContract, *terraFeePayer)
 		if err := supervisor.Run(ctx, "processor", p.Run); err != nil {
 		if err := supervisor.Run(ctx, "processor", p.Run); err != nil {
 			return err
 			return err
 		}
 		}
 
 
+		if err := supervisor.Run(ctx, "admin", adminService); err != nil {
+			return err
+		}
+
 		logger.Info("Started internal services")
 		logger.Info("Started internal services")
 
 
 		select {
 		select {

+ 1 - 0
bridge/cmd/guardiand/bridgekey.go

@@ -32,6 +32,7 @@ var KeygenCmd = &cobra.Command{
 
 
 func runKeygen(cmd *cobra.Command, args []string) {
 func runKeygen(cmd *cobra.Command, args []string) {
 	lockMemory()
 	lockMemory()
+	setRestrictiveUmask()
 
 
 	log.Print("Creating new key at ", args[0])
 	log.Print("Creating new key at ", args[0])
 
 

+ 3 - 1
bridge/cmd/root.go

@@ -2,9 +2,10 @@ package cmd
 
 
 import (
 import (
 	"fmt"
 	"fmt"
-	"github.com/spf13/cobra"
 	"os"
 	"os"
 
 
+	"github.com/spf13/cobra"
+
 	homedir "github.com/mitchellh/go-homedir"
 	homedir "github.com/mitchellh/go-homedir"
 	"github.com/spf13/viper"
 	"github.com/spf13/viper"
 
 
@@ -34,6 +35,7 @@ func init() {
 	rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.guardiand.yaml)")
 	rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.guardiand.yaml)")
 	rootCmd.AddCommand(guardiand.BridgeCmd)
 	rootCmd.AddCommand(guardiand.BridgeCmd)
 	rootCmd.AddCommand(guardiand.KeygenCmd)
 	rootCmd.AddCommand(guardiand.KeygenCmd)
+	rootCmd.AddCommand(guardiand.AdminCmd)
 }
 }
 
 
 // initConfig reads in config file and ENV variables if set.
 // initConfig reads in config file and ENV variables if set.

+ 2 - 1
bridge/go.mod

@@ -7,6 +7,7 @@ require (
 	github.com/aristanetworks/goarista v0.0.0-20201012165903-2cb20defcd66 // indirect
 	github.com/aristanetworks/goarista v0.0.0-20201012165903-2cb20defcd66 // indirect
 	github.com/btcsuite/btcd v0.21.0-beta // indirect
 	github.com/btcsuite/btcd v0.21.0-beta // indirect
 	github.com/cenkalti/backoff/v4 v4.1.0
 	github.com/cenkalti/backoff/v4 v4.1.0
+	github.com/davecgh/go-spew v1.1.1
 	github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c // indirect
 	github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c // indirect
 	github.com/deckarep/golang-set v1.7.1 // indirect
 	github.com/deckarep/golang-set v1.7.1 // indirect
 	github.com/ethereum/go-ethereum v1.9.23
 	github.com/ethereum/go-ethereum v1.9.23
@@ -45,7 +46,7 @@ require (
 	github.com/shirou/gopsutil v2.20.9+incompatible // indirect
 	github.com/shirou/gopsutil v2.20.9+incompatible // indirect
 	github.com/spf13/cobra v1.0.0
 	github.com/spf13/cobra v1.0.0
 	github.com/spf13/viper v1.6.3
 	github.com/spf13/viper v1.6.3
-	github.com/status-im/keycard-go v0.0.0-20200402102358-957c09536969 // indirect
+	github.com/status-im/keycard-go v0.0.0-20200402102358-957c09536969
 	github.com/stretchr/testify v1.6.1
 	github.com/stretchr/testify v1.6.1
 	github.com/tendermint/tendermint v0.33.8 // indirect
 	github.com/tendermint/tendermint v0.33.8 // indirect
 	github.com/terra-project/terra.go v1.0.1-0.20201113170042-b3bffdc6fd06
 	github.com/terra-project/terra.go v1.0.1-0.20201113170042-b3bffdc6fd06

+ 9 - 0
bridge/pkg/common/guardianset.go

@@ -4,6 +4,15 @@ import (
 	"github.com/ethereum/go-ethereum/common"
 	"github.com/ethereum/go-ethereum/common"
 )
 )
 
 
+// TODO: this should be 20, https://github.com/certusone/wormhole/issues/86
+//
+// Matching constants:
+//  - MAX_LEN_GUARDIAN_KEYS in Solana contract
+//
+// The Eth and Terra contracts do not specify a maximum number and support more than 20,
+// but presumably, chain-specific transaction size limits will apply at some point (untested).
+const MaxGuardianCount = 19
+
 type GuardianSet struct {
 type GuardianSet struct {
 	// Guardian's public keys truncated by the ETH standard hashing mechanism (20 bytes).
 	// Guardian's public keys truncated by the ETH standard hashing mechanism (20 bytes).
 	Keys []common.Address
 	Keys []common.Address

+ 48 - 0
bridge/pkg/processor/injection.go

@@ -0,0 +1,48 @@
+package processor
+
+import (
+	"context"
+	"encoding/hex"
+
+	"github.com/ethereum/go-ethereum/crypto"
+	"go.uber.org/zap"
+
+	"github.com/certusone/wormhole/bridge/pkg/supervisor"
+	"github.com/certusone/wormhole/bridge/pkg/vaa"
+)
+
+// handleInjection processes a pre-populated VAA injected locally.
+func (p *Processor) handleInjection(ctx context.Context, v *vaa.VAA) {
+	// Check if we're in the guardian set.
+	us, ok := p.gs.KeyIndex(p.ourAddr)
+	if !ok {
+		p.logger.Error("we're not in the guardian set - refusing to sign",
+			zap.Uint32("index", p.gs.Index),
+			zap.Stringer("our_addr", p.ourAddr),
+			zap.Any("set", p.gs.KeysAsHexStrings()))
+		return
+	}
+
+	// Generate digest of the unsigned VAA.
+	digest, err := v.SigningMsg()
+	if err != nil {
+		panic(err)
+	}
+
+	// The internal originator is responsible for logging the full VAA, just log the digest here.
+	supervisor.Logger(ctx).Info("signing injected VAA",
+		zap.Stringer("digest", digest))
+
+	// Sign the digest using our node's guardian key.
+	s, err := crypto.Sign(digest.Bytes(), p.gk)
+	if err != nil {
+		panic(err)
+	}
+
+	p.logger.Info("observed and signed injected VAA",
+		zap.String("digest", hex.EncodeToString(digest.Bytes())),
+		zap.String("signature", hex.EncodeToString(s)),
+		zap.Int("our_index", us))
+
+	p.broadcastSignature(v, s)
+}

+ 30 - 23
bridge/pkg/processor/observation.go

@@ -138,43 +138,50 @@ func (p *Processor) handleObservation(ctx context.Context, m *gossipv1.LockupObs
 				panic(err)
 				panic(err)
 			}
 			}
 
 
-			if t, ok := v.Payload.(*vaa.BodyTransfer); ok {
+			// Submit every VAA to Solana for data availability.
+			p.logger.Info("submitting signed VAA to Solana",
+				zap.String("digest", hash),
+				zap.Any("vaa", signed),
+				zap.String("bytes", hex.EncodeToString(vaaBytes)))
+			p.vaaC <- signed
 
 
-				switch t.TargetChain {
-				case vaa.ChainIDEthereum,
-					vaa.ChainIDSolana,
-					vaa.ChainIDTerra:
-					// Submit to Solana if target is Solana, but also cross-submit all other targets to Solana for data availability
-					p.logger.Info("submitting signed VAA to Solana",
-						zap.String("digest", hash),
-						zap.Any("vaa", signed),
-						zap.String("bytes", hex.EncodeToString(vaaBytes)))
+			switch t := v.Payload.(type) {
+			case *vaa.BodyTransfer:
+				// Depending on the target chain, guardians submit VAAs directly to the chain.
 
 
-					// Check whether we run in devmode and submit the VAA ourselves, if so.
-					switch t.TargetChain {
-					case vaa.ChainIDEthereum:
-						p.devnetVAASubmission(ctx, signed, hash)
-					case vaa.ChainIDTerra:
-						p.terraVAASubmission(ctx, signed, hash)
-					}
-
-					p.vaaC <- signed
+				switch t.TargetChain {
+				case vaa.ChainIDSolana:
+					// No-op.
+				case vaa.ChainIDEthereum:
+					// Ethereum is special because it's expensive, and guardians cannot
+					// be expected to pay the fees. We only submit to Ethereum in devnet mode.
+					p.devnetVAASubmission(ctx, signed, hash)
+				case vaa.ChainIDTerra:
+					p.terraVAASubmission(ctx, signed, hash)
 				default:
 				default:
-					p.logger.Error("we don't know how to submit this VAA",
+					p.logger.Error("unknown target chain ID",
 						zap.String("digest", hash),
 						zap.String("digest", hash),
 						zap.Any("vaa", signed),
 						zap.Any("vaa", signed),
 						zap.String("bytes", hex.EncodeToString(vaaBytes)),
 						zap.String("bytes", hex.EncodeToString(vaaBytes)),
 						zap.Stringer("target_chain", t.TargetChain))
 						zap.Stringer("target_chain", t.TargetChain))
 				}
 				}
-
-				p.state.vaaSignatures[hash].submitted = true
-			} else {
+			case *vaa.BodyGuardianSetUpdate:
+				// A guardian set update is broadcast to every chain that we talk to.
+				p.devnetVAASubmission(ctx, signed, hash)
+				p.terraVAASubmission(ctx, signed, hash)
+			default:
 				panic(fmt.Sprintf("unknown VAA payload type: %+v", v))
 				panic(fmt.Sprintf("unknown VAA payload type: %+v", v))
 			}
 			}
+
+			p.state.vaaSignatures[hash].submitted = true
 		} else {
 		} else {
 			p.logger.Info("quorum not met or already submitted, doing nothing",
 			p.logger.Info("quorum not met or already submitted, doing nothing",
 				zap.String("digest", hash))
 				zap.String("digest", hash))
 		}
 		}
+	} else {
+		p.logger.Info("we have not yet seen this VAA - temporarily storing signature",
+			zap.String("digest", hash))
+
 	}
 	}
 }
 }
 
 

+ 7 - 0
bridge/pkg/processor/processor.go

@@ -50,6 +50,9 @@ type Processor struct {
 	// vaaC is a channel of VAAs to submit to store on Solana (either as target, or for data availability)
 	// vaaC is a channel of VAAs to submit to store on Solana (either as target, or for data availability)
 	vaaC chan *vaa.VAA
 	vaaC chan *vaa.VAA
 
 
+	// injectC is a channel of VAAs injected locally.
+	injectC chan *vaa.VAA
+
 	// gk is the node's guardian private key
 	// gk is the node's guardian private key
 	gk *ecdsa.PrivateKey
 	gk *ecdsa.PrivateKey
 
 
@@ -84,6 +87,7 @@ func NewProcessor(
 	sendC chan []byte,
 	sendC chan []byte,
 	obsvC chan *gossipv1.LockupObservation,
 	obsvC chan *gossipv1.LockupObservation,
 	vaaC chan *vaa.VAA,
 	vaaC chan *vaa.VAA,
+	injectC chan *vaa.VAA,
 	gk *ecdsa.PrivateKey,
 	gk *ecdsa.PrivateKey,
 	devnetMode bool,
 	devnetMode bool,
 	devnetNumGuardians uint,
 	devnetNumGuardians uint,
@@ -99,6 +103,7 @@ func NewProcessor(
 		sendC:              sendC,
 		sendC:              sendC,
 		obsvC:              obsvC,
 		obsvC:              obsvC,
 		vaaC:               vaaC,
 		vaaC:               vaaC,
+		injectC:            injectC,
 		gk:                 gk,
 		gk:                 gk,
 		devnetMode:         devnetMode,
 		devnetMode:         devnetMode,
 		devnetNumGuardians: devnetNumGuardians,
 		devnetNumGuardians: devnetNumGuardians,
@@ -134,6 +139,8 @@ func (p *Processor) Run(ctx context.Context) error {
 			}
 			}
 		case k := <-p.lockC:
 		case k := <-p.lockC:
 			p.handleLockup(ctx, k)
 			p.handleLockup(ctx, k)
+		case v := <-p.injectC:
+			p.handleInjection(ctx, v)
 		case m := <-p.obsvC:
 		case m := <-p.obsvC:
 			p.handleObservation(ctx, m)
 			p.handleObservation(ctx, m)
 		case <-p.cleanup.C:
 		case <-p.cleanup.C:

+ 2 - 0
proto/gossip/v1/gossip.proto

@@ -40,6 +40,8 @@ message Heartbeat {
 // guardians submitting valid signatures for a given hash, they can be assembled into a VAA.
 // guardians submitting valid signatures for a given hash, they can be assembled into a VAA.
 //
 //
 // Messages without valid signature are dropped unceremoniously.
 // Messages without valid signature are dropped unceremoniously.
+//
+// TODO: rename? we also use it for governance VAAs
 message LockupObservation {
 message LockupObservation {
   // Guardian pubkey as truncated eth address.
   // Guardian pubkey as truncated eth address.
   bytes addr = 1;
   bytes addr = 1;

+ 50 - 1
proto/node/v1/node.proto

@@ -6,9 +6,58 @@ option go_package = "proto/node/v1;nodev1";
 
 
 import "google/api/annotations.proto";
 import "google/api/annotations.proto";
 
 
-service Node {
+// NodePrivileged exposes an administrative API. It runs on a UNIX socket and is authenticated
+// using Linux filesystem permissions.
+service NodePrivileged {
+    // SubmitGuardianSetVAA injects a guardian set change VAA into the guardian node.
+    // The node will inject the VAA into the aggregator and sign/broadcast the VAA signature.
+    //
+    // A consensus majority of nodes on the network will have to inject the VAA within the
+    // VAA timeout window for it to reach consensus.
+    //
+    rpc SubmitGuardianSetVAA (SubmitGuardianSetVAARequest) returns (SubmitGuardianSetVAAResponse);
 }
 }
 
 
+// GuardianSet represents a new guardian set to be submitted to and signed by the node.
+// During the genesis procedure, this data structure will be assembled using off-chain collaborative tooling
+// like GitHub using a human-readable encoding, so readability is a concern.
+message GuardianSetUpdate {
+    // Index of the current guardian set to be replaced.
+    uint32 current_set_index = 1;
+
+    // UNIX timestamp (s) of the VAA to be created. The timestamp is informational and will be part
+    // of the VAA submitted to the chain. It's part of the VAA digest and has to be identical across nodes.
+    //
+    // For lockups, the timestamp identifies the block that the lockup belongs to. For guardian set updates,
+    // we create the VAA manually. Best practice is to pick a timestamp which roughly matches the expected
+    // genesis ceremony data.
+    //
+    // The actual on-chain guardian set creation timestamp will be set when the VAA is accepted on each chain.
+    //
+    // This is a uint32 to match the on-chain timestamp representation. This becomes a problem in 2106 (sorry).
+    uint32 timestamp = 2;
+
+    // List of guardian set members.
+    message Guardian {
+        // Guardian key pubkey. Stored as hex string with 0x prefix for human readability -
+        // this is the canonical Ethereum representation.
+        string pubkey = 1;
+        // Optional descriptive name. Not stored on any chain, purely informational.
+        string name = 2;
+    };
+    repeated Guardian guardians = 3;
+}
+
+message SubmitGuardianSetVAARequest {
+    GuardianSetUpdate guardian_set = 1;
+}
+
+message SubmitGuardianSetVAAResponse {
+    // Canonical digest of the submitted VAA.
+    bytes digest = 1;
+}
+
+// GuardianKey specifies the on-disk format for a node's guardian key.
 message GuardianKey {
 message GuardianKey {
     // description is an optional, free-form description text set by the operator.
     // description is an optional, free-form description text set by the operator.
     string description = 1;
     string description = 1;

+ 18 - 0
scripts/test-injection.sh

@@ -0,0 +1,18 @@
+#!/bin/bash
+# This script submits a guardian set update using the VAA injection admin command.
+# First argument is node to submit to. Second argument is current set index.
+set -e
+
+node=$1
+idx=$2
+path=/tmp/new-guardianset.prototxt
+sock=/tmp/admin.sock
+
+# Create a no-op update that sets the same 1-node guardian set again.
+kubectl exec guardian-${node} -c guardiand -- /guardiand admin guardian-set-update-template --num=1 --idx=${idx} $path
+
+# Verify and print resulting result. The digest incorporates the current time and is NOT deterministic.
+kubectl exec guardian-${node} -c guardiand -- /guardiand admin guardian-set-update-verify $path
+
+# Submit to node
+kubectl exec guardian-${node} -c guardiand -- /guardiand admin guardian-set-update-inject --socket $sock $path