diff --git a/cmd/zetaclientd-supervisor/lib.go b/cmd/zetaclientd-supervisor/lib.go index 71f492e88b..fe62e0c07a 100644 --- a/cmd/zetaclientd-supervisor/lib.go +++ b/cmd/zetaclientd-supervisor/lib.go @@ -1,7 +1,6 @@ package main import ( - "bufio" "context" "encoding/json" "errors" @@ -10,7 +9,6 @@ import ( "os" "path" "runtime" - "strings" "sync" "syscall" "time" @@ -383,23 +381,3 @@ func (s *zetaclientdSupervisor) downloadZetaclientd(ctx context.Context, plan *u } return nil } - -func promptPasswords() (string, string, error) { - reader := bufio.NewReader(os.Stdin) - fmt.Print("HotKey Password: ") - hotKeyPass, err := reader.ReadString('\n') - if err != nil { - return "", "", err - } - fmt.Print("TSS Password: ") - tssKeyPass, err := reader.ReadString('\n') - if err != nil { - return "", "", err - } - - //trim delimiters - hotKeyPass = strings.TrimSuffix(hotKeyPass, "\n") - tssKeyPass = strings.TrimSuffix(tssKeyPass, "\n") - - return hotKeyPass, tssKeyPass, err -} diff --git a/cmd/zetaclientd-supervisor/main.go b/cmd/zetaclientd-supervisor/main.go index ee1e247be4..955a0097f1 100644 --- a/cmd/zetaclientd-supervisor/main.go +++ b/cmd/zetaclientd-supervisor/main.go @@ -7,12 +7,14 @@ import ( "os" "os/exec" "os/signal" + "strings" "syscall" "time" "golang.org/x/sync/errgroup" "github.com/zeta-chain/zetacore/app" + zetaos "github.com/zeta-chain/zetacore/pkg/os" "github.com/zeta-chain/zetacore/zetaclient/config" ) @@ -36,7 +38,9 @@ func main() { shutdownChan := make(chan os.Signal, 1) signal.Notify(shutdownChan, syscall.SIGINT, syscall.SIGTERM) - hotkeyPassword, tssPassword, err := promptPasswords() + // prompt for all necessary passwords + titles := []string{"HotKey", "TSS", "Solana Relayer Key"} + passwords, err := zetaos.PromptPasswords(titles) if err != nil { logger.Error().Err(err).Msg("unable to get passwords") os.Exit(1) @@ -65,7 +69,7 @@ func main() { cmd.Stderr = os.Stderr // must reset the passwordInputBuffer every iteration because reads are stateful (seek to end) passwordInputBuffer := bytes.Buffer{} - passwordInputBuffer.Write([]byte(hotkeyPassword + "\n" + tssPassword + "\n")) + passwordInputBuffer.Write([]byte(strings.Join(passwords, "\n") + "\n")) cmd.Stdin = &passwordInputBuffer eg, ctx := errgroup.WithContext(ctx) diff --git a/cmd/zetaclientd/import_relayer_keys.go b/cmd/zetaclientd/import_relayer_keys.go index fef20f6ed1..0f2f713336 100644 --- a/cmd/zetaclientd/import_relayer_keys.go +++ b/cmd/zetaclientd/import_relayer_keys.go @@ -1,7 +1,6 @@ package main import ( - "encoding/json" "fmt" "os" "path/filepath" @@ -10,6 +9,7 @@ import ( "github.com/rs/zerolog/log" "github.com/spf13/cobra" + "github.com/zeta-chain/zetacore/pkg/chains" "github.com/zeta-chain/zetacore/pkg/crypto" zetaos "github.com/zeta-chain/zetacore/pkg/os" "github.com/zeta-chain/zetacore/zetaclient/keys" @@ -62,13 +62,13 @@ func init() { CmdImportRelayerKey.Flags(). StringVar(&importArgs.privateKey, "private-key", "", "the relayer private key to import") CmdImportRelayerKey.Flags(). - StringVar(&importArgs.password, "password", "", "the password to encrypt the private key") + StringVar(&importArgs.password, "password", "", "the password to encrypt the relayer private key") CmdImportRelayerKey.Flags(). StringVar(&importArgs.relayerKeyPath, "relayer-key-path", defaultRelayerKeyPath, "path to relayer keys") CmdRelayerAddress.Flags().Int32Var(&addressArgs.network, "network", 7, "network id, (7:solana)") CmdRelayerAddress.Flags(). - StringVar(&addressArgs.password, "password", "", "the password to decrypt the private key") + StringVar(&addressArgs.password, "password", "", "the password to decrypt the relayer private key") CmdRelayerAddress.Flags(). StringVar(&addressArgs.relayerKeyPath, "relayer-key-path", defaultRelayerKeyPath, "path to relayer keys") } @@ -84,12 +84,13 @@ func ImportRelayerKey(_ *cobra.Command, _ []string) error { } // resolve the relayer key file path - keyPath, fileName, err := resolveRelayerKeyPath(importArgs.network, importArgs.relayerKeyPath) + fileName, err := keys.ResolveRelayerKeyFile(importArgs.relayerKeyPath, chains.Network(importArgs.network)) if err != nil { return errors.Wrap(err, "failed to resolve relayer key file path") } // create path (owner `rwx` permissions) if it does not exist + keyPath := filepath.Dir(fileName) if _, err := os.Stat(keyPath); os.IsNotExist(err) { if err := os.MkdirAll(keyPath, 0o700); err != nil { return errors.Wrapf(err, "failed to create relayer key path: %s", keyPath) @@ -110,14 +111,8 @@ func ImportRelayerKey(_ *cobra.Command, _ []string) error { return errors.Wrap(err, "private key encryption failed") } - // construct the relayer key struct and write to file as json - keyData, err := json.Marshal(keys.RelayerKey{PrivateKey: ciphertext}) - if err != nil { - return errors.Wrap(err, "failed to marshal relayer key") - } - - // create relay key file (owner `rw` permissions) - err = os.WriteFile(fileName, keyData, 0o600) + // create the relayer key file + err = keys.WriteRelayerKeyToFile(fileName, keys.RelayerKey{PrivateKey: ciphertext}) if err != nil { return errors.Wrapf(err, "failed to create relayer key file: %s", fileName) } @@ -128,27 +123,24 @@ func ImportRelayerKey(_ *cobra.Command, _ []string) error { // ShowRelayerAddress shows the relayer address func ShowRelayerAddress(_ *cobra.Command, _ []string) error { - // resolve the relayer key file path - _, fileName, err := resolveRelayerKeyPath(addressArgs.network, addressArgs.relayerKeyPath) - if err != nil { - return errors.Wrap(err, "failed to resolve relayer key file path") - } - - // read the relayer key file - relayerKey, err := keys.ReadRelayerKeyFromFile(fileName) + // try loading the relayer key if present + network := chains.Network(addressArgs.network) + relayerKey, err := keys.LoadRelayerKey(addressArgs.relayerKeyPath, network, addressArgs.password) if err != nil { - return err + return errors.Wrap(err, "failed to load relayer key") } - // decrypt the private key - privateKey, err := crypto.DecryptAES256GCMBase64(relayerKey.PrivateKey, addressArgs.password) - if err != nil { - return errors.Wrap(err, "private key decryption failed") + // relayer key does not exist, return error + if relayerKey == nil { + return fmt.Errorf( + "relayer key not found for network %d in path: %s", + addressArgs.network, + addressArgs.relayerKeyPath, + ) } - relayerKey.PrivateKey = privateKey - // resolve the address - networkName, address, err := relayerKey.ResolveAddress(addressArgs.network) + // resolve the relayer address + networkName, address, err := relayerKey.ResolveAddress(network) if err != nil { return errors.Wrap(err, "failed to resolve relayer address") } @@ -156,23 +148,3 @@ func ShowRelayerAddress(_ *cobra.Command, _ []string) error { return nil } - -// resolveRelayerKeyPath is a helper function to resolve the relayer key file path and name -func resolveRelayerKeyPath(network int32, relayerKeyPath string) (string, string, error) { - // get relayer key file name by network - name, err := keys.GetRelayerKeyFileByNetwork(network) - if err != nil { - return "", "", errors.Wrap(err, "failed to get relayer key file name") - } - - // resolve relayer key path if it contains a tilde - keyPath, err := zetaos.ExpandHomeDir(relayerKeyPath) - if err != nil { - return "", "", errors.Wrap(err, "failed to resolve relayer key path") - } - - // build file name - fileName := filepath.Join(keyPath, name) - - return keyPath, fileName, err -} diff --git a/cmd/zetaclientd/start.go b/cmd/zetaclientd/start.go index 281043cb27..6b357db38f 100644 --- a/cmd/zetaclientd/start.go +++ b/cmd/zetaclientd/start.go @@ -1,7 +1,6 @@ package main import ( - "bufio" "context" "encoding/json" "fmt" @@ -21,7 +20,9 @@ import ( "github.com/spf13/cobra" "github.com/zeta-chain/zetacore/pkg/authz" + "github.com/zeta-chain/zetacore/pkg/chains" "github.com/zeta-chain/zetacore/pkg/constant" + zetaos "github.com/zeta-chain/zetacore/pkg/os" observerTypes "github.com/zeta-chain/zetacore/x/observer/types" "github.com/zeta-chain/zetacore/zetaclient/chains/base" "github.com/zeta-chain/zetacore/zetaclient/config" @@ -49,10 +50,15 @@ func start(_ *cobra.Command, _ []string) error { SetupConfigForTest() - //Prompt for Hotkey and TSS key-share passwords - hotkeyPass, tssKeyPass, err := promptPasswords() + // Prompt for Hotkey, TSS key-share and relayer key passwords + titles := []string{"HotKey", "TSS", "Solana Relayer Key"} + passwords, err := zetaos.PromptPasswords(titles) if err != nil { - return err + return errors.Wrap(err, "unable to get passwords") + } + hotkeyPass, tssKeyPass, solanaKeyPass := passwords[0], passwords[1], passwords[2] + relayerKeyPasswords := map[chains.Network]string{ + chains.Network_solana: solanaKeyPass, } //Load Config file given path @@ -77,6 +83,7 @@ func start(_ *cobra.Command, _ []string) error { startLogger := logger.Std.With().Str("module", "startup").Logger() appContext := zctx.New(cfg, masterLogger) + appContext.SetRelayerKeyPasswords(relayerKeyPasswords) ctx := zctx.WithAppContext(context.Background(), appContext) // Wait until zetacore is up @@ -397,29 +404,6 @@ func initPreParams(path string) { } } -// promptPasswords() This function will prompt for passwords which will be used to decrypt two key files: -// 1. HotKey -// 2. TSS key-share -func promptPasswords() (string, string, error) { - reader := bufio.NewReader(os.Stdin) - fmt.Print("HotKey Password: ") - hotKeyPass, err := reader.ReadString('\n') - if err != nil { - return "", "", err - } - fmt.Print("TSS Password: ") - TSSKeyPass, err := reader.ReadString('\n') - if err != nil { - return "", "", err - } - - //trim delimiters - hotKeyPass = strings.TrimSuffix(hotKeyPass, "\n") - TSSKeyPass = strings.TrimSuffix(TSSKeyPass, "\n") - - return hotKeyPass, TSSKeyPass, err -} - // isObserverNode checks whether THIS node is an observer node. func isObserverNode(ctx context.Context, client *zetacore.Client) (bool, error) { observers, err := client.GetObserverList(ctx) diff --git a/contrib/localnet/scripts/password.file b/contrib/localnet/scripts/password.file index 96b3814661..efedb37b66 100644 --- a/contrib/localnet/scripts/password.file +++ b/contrib/localnet/scripts/password.file @@ -1,2 +1,3 @@ password pass2 +pass_relayerkey diff --git a/contrib/localnet/scripts/start-zetaclientd.sh b/contrib/localnet/scripts/start-zetaclientd.sh index 64117621d2..df61ce48f5 100755 --- a/contrib/localnet/scripts/start-zetaclientd.sh +++ b/contrib/localnet/scripts/start-zetaclientd.sh @@ -14,16 +14,13 @@ set_sepolia_endpoint() { jq '.EVMChainConfigs."11155111".Endpoint = "http://eth2:8545"' /root/.zetacored/config/zetaclient_config.json > tmp.json && mv tmp.json /root/.zetacored/config/zetaclient_config.json } -# creates a file that contains a relayer private key (e.g. Solana relayer key) -create_relayer_key_file() { +# import a relayer private key (e.g. Solana relayer key) +import_relayer_key() { local num="$1" - local file="$2" - # read observer relayer private key from config - privkey_relayer=$(yq -r ".observer_relayer_accounts.relayer_account_${num}.solana_private_key" /root/config.yml) - - # create the key file that contains the private key - jq -n --arg privkey_relayer "$privkey_relayer" '{"private_key": $privkey_relayer}' > "${file}" + # import solana (network=7) relayer private key + privkey_solana=$(yq -r ".observer_relayer_accounts.relayer_account_${num}.solana_private_key" /root/config.yml) + zetaclientd import-relayer-key --network=7 --private-key="$privkey_solana" --password=pass_relayerkey } PREPARAMS_PATH="/root/preparams/${HOSTNAME}.json" @@ -78,8 +75,8 @@ then MYIP=$(/sbin/ip -o -4 addr list eth0 | awk '{print $4}' | cut -d/ -f1) zetaclientd init --zetacore-url zetacore0 --chain-id athens_101-1 --operator "$operatorAddress" --log-format=text --public-ip "$MYIP" --keyring-backend "$BACKEND" --pre-params "$PREPARAMS_PATH" - # create relayer key file for solana - create_relayer_key_file 0 "${RELAYER_KEY_PATH}/solana.json" + # import relayer private key for zetaclient0 + import_relayer_key 0 # if eth2 is enabled, set the endpoint in the zetaclient_config.json # in this case, the additional evm is represented with the sepolia chain, we set manually the eth2 endpoint to the sepolia chain (11155111 -> http://eth2:8545) @@ -101,8 +98,8 @@ then done zetaclientd init --peer "/ip4/172.20.0.21/tcp/6668/p2p/${SEED}" --zetacore-url "$node" --chain-id athens_101-1 --operator "$operatorAddress" --log-format=text --public-ip "$MYIP" --log-level 1 --keyring-backend "$BACKEND" --pre-params "$PREPARAMS_PATH" - # create relayer key file for solana - create_relayer_key_file "${num}" "${RELAYER_KEY_PATH}/solana.json" + # import relayer private key for zetaclient{$num} + import_relayer_key "${num}" # check if the option is additional-evm # in this case, the additional evm is represented with the sepolia chain, we set manually the eth2 endpoint to the sepolia chain (11155111 -> http://eth2:8545) diff --git a/pkg/os/console.go b/pkg/os/console.go new file mode 100644 index 0000000000..28102ef1fd --- /dev/null +++ b/pkg/os/console.go @@ -0,0 +1,29 @@ +package os + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +// PromptPasswords prompts the user for passwords with the given titles +func PromptPasswords(passwordTitles []string) ([]string, error) { + reader := bufio.NewReader(os.Stdin) + passwords := make([]string, len(passwordTitles)) + + // iterate over password titles and prompt for each + for i, title := range passwordTitles { + fmt.Printf("%s Password: ", title) + password, err := reader.ReadString('\n') + if err != nil { + return nil, err + } + + // trim delimiters + password = strings.TrimSuffix(password, "\n") + passwords[i] = password + } + + return passwords, nil +} diff --git a/pkg/os/console_test.go b/pkg/os/console_test.go new file mode 100644 index 0000000000..bdacfa2a39 --- /dev/null +++ b/pkg/os/console_test.go @@ -0,0 +1,65 @@ +package os_test + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" + zetaos "github.com/zeta-chain/zetacore/pkg/os" +) + +// Test function for PromptPasswords +func Test_PromptPasswords(t *testing.T) { + tests := []struct { + name string + passwordTitles []string + input string + expected []string + }{ + { + name: "Single password prompt", + passwordTitles: []string{"HotKey"}, + input: "pass123\n", + expected: []string{"pass123"}, + }, + { + name: "Multiple password prompts", + passwordTitles: []string{"HotKey", "TSS", "RelayerKey"}, + input: "pass_hotkey\npass_tss\npass_relayer\n", + expected: []string{"pass_hotkey", "pass_tss", "pass_relayer"}, + }, + { + name: "Empty input for passwords is allowed", + passwordTitles: []string{"HotKey", "TSS", "RelayerKey"}, + input: "\n\n\n", + expected: []string{"", "", ""}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a pipe to simulate stdin + r, w, err := os.Pipe() + require.NoError(t, err) + + // Write the test input to the pipe + _, err = w.Write([]byte(tt.input)) + require.NoError(t, err) + w.Close() // Close the write end of the pipe + + // Backup the original stdin and restore it after the test + oldStdin := os.Stdin + defer func() { os.Stdin = oldStdin }() + + // Redirect stdin to the read end of the pipe + os.Stdin = r + + // Call the function with the test case data + passwords, err := zetaos.PromptPasswords(tt.passwordTitles) + + // Check the returned passwords + require.NoError(t, err) + require.Equal(t, tt.expected, passwords) + }) + } +} diff --git a/testutil/sample/crypto.go b/testutil/sample/crypto.go index a46310fb25..906b8f6ee0 100644 --- a/testutil/sample/crypto.go +++ b/testutil/sample/crypto.go @@ -57,11 +57,18 @@ func EthAddress() ethcommon.Address { return ethcommon.BytesToAddress(sdk.AccAddress(ed25519.GenPrivKey().PubKey().Address()).Bytes()) } +// SolanaPrivateKey returns a sample solana private key +func SolanaPrivateKey(t *testing.T) solana.PrivateKey { + privKey, err := solana.NewRandomPrivateKey() + require.NoError(t, err) + return privKey +} + // SolanaAddress returns a sample solana address func SolanaAddress(t *testing.T) string { - keypair, err := solana.NewRandomPrivateKey() + privKey, err := solana.NewRandomPrivateKey() require.NoError(t, err) - return keypair.PublicKey().String() + return privKey.PublicKey().String() } // SolanaSignature returns a sample solana signature diff --git a/zetaclient/chains/solana/signer/signer.go b/zetaclient/chains/solana/signer/signer.go index a250f31c69..cc9b6e14f1 100644 --- a/zetaclient/chains/solana/signer/signer.go +++ b/zetaclient/chains/solana/signer/signer.go @@ -30,7 +30,8 @@ type Signer struct { client interfaces.SolanaRPCClient // relayerKey is the private key of the relayer account for Solana chain - relayerKey solana.PrivateKey + // relayerKey is optional, the signer will not relay transactions if it is not set + relayerKey *solana.PrivateKey // gatewayID is the program ID of gateway program on Solana chain gatewayID solana.PublicKey @@ -45,7 +46,7 @@ func NewSigner( chainParams observertypes.ChainParams, solClient interfaces.SolanaRPCClient, tss interfaces.TSSSigner, - relayerKey keys.RelayerKey, + relayerKey *keys.RelayerKey, ts *metrics.TelemetryServer, logger base.Logger, ) (*Signer, error) { @@ -58,21 +59,32 @@ func NewSigner( return nil, errors.Wrapf(err, "cannot parse gateway address %s", chainParams.GatewayAddress) } - // construct Solana private key - privKey, err := solana.PrivateKeyFromBase58(relayerKey.PrivateKey) - if err != nil { - return nil, errors.Wrap(err, "unable to construct solana private key") + // create Solana signer + signer := &Signer{ + Signer: baseSigner, + client: solClient, + gatewayID: gatewayID, + pda: pda, } - logger.Std.Info().Msgf("Solana relayer address: %s", privKey.PublicKey()) - // create Solana signer - return &Signer{ - Signer: baseSigner, - client: solClient, - relayerKey: privKey, - gatewayID: gatewayID, - pda: pda, - }, nil + // construct Solana private key if present + if relayerKey != nil { + privKey, err := solana.PrivateKeyFromBase58(relayerKey.PrivateKey) + if err != nil { + return nil, errors.Wrap(err, "unable to construct solana private key") + } + signer.relayerKey = &privKey + logger.Std.Info().Msgf("Solana relayer address: %s", privKey.PublicKey()) + } else { + logger.Std.Info().Msg("Solana relayer key is not provided") + } + + return signer, nil +} + +// HasRelayerKey returns true if the signer has a relayer key +func (signer *Signer) HasRelayerKey() bool { + return signer.relayerKey != nil } // TryProcessOutbound - signer interface implementation @@ -121,6 +133,11 @@ func (signer *Signer) TryProcessOutbound( return } + // skip relaying the transaction if this signer hasn't set the relayer key + if !signer.HasRelayerKey() { + return + } + // sign the withdraw transaction by relayer key tx, err := signer.SignWithdrawTx(ctx, *msg) if err != nil { diff --git a/zetaclient/chains/solana/signer/withdraw.go b/zetaclient/chains/solana/signer/withdraw.go index 9cd11c40b7..f44dc3fc30 100644 --- a/zetaclient/chains/solana/signer/withdraw.go +++ b/zetaclient/chains/solana/signer/withdraw.go @@ -92,7 +92,7 @@ func (signer *Signer) SignWithdrawTx(ctx context.Context, msg contracts.MsgWithd // relayer signs the transaction _, err = tx.Sign(func(key solana.PublicKey) *solana.PrivateKey { if key.Equals(privkey.PublicKey()) { - return &privkey + return privkey } return nil }) diff --git a/zetaclient/context/app.go b/zetaclient/context/app.go index 032d0b759c..3bb4ab698d 100644 --- a/zetaclient/context/app.go +++ b/zetaclient/context/app.go @@ -19,14 +19,26 @@ import ( // AppContext represents application (zetaclient) context. type AppContext struct { + // config is the config of the app config config.Config + + // logger is the logger of the app logger zerolog.Logger + // chainRegistry is a registry of supported chains chainRegistry *ChainRegistry + // currentTssPubKey is the current tss pubKey currentTssPubKey string - crosschainFlags observertypes.CrosschainFlags - keygen observertypes.Keygen + + // crosschainFlags is the current crosschain flags state + crosschainFlags observertypes.CrosschainFlags + + // keygen is the current tss keygen state + keygen observertypes.Keygen + + // relayerKeyPasswords maps network id to relayer key password + relayerKeyPasswords map[chains.Network]string mu sync.RWMutex } @@ -39,9 +51,10 @@ func New(cfg config.Config, logger zerolog.Logger) *AppContext { chainRegistry: NewChainRegistry(), - crosschainFlags: observertypes.CrosschainFlags{}, - currentTssPubKey: "", - keygen: observertypes.Keygen{}, + crosschainFlags: observertypes.CrosschainFlags{}, + currentTssPubKey: "", + keygen: observertypes.Keygen{}, + relayerKeyPasswords: make(map[chains.Network]string), mu: sync.RWMutex{}, } @@ -127,6 +140,22 @@ func (a *AppContext) GetCrossChainFlags() observertypes.CrosschainFlags { return a.crosschainFlags } +// SetRelayerKeyPasswords sets the relayer key passwords for given networks +func (a *AppContext) SetRelayerKeyPasswords(relayerKeyPasswords map[chains.Network]string) { + a.mu.Lock() + defer a.mu.Unlock() + + a.relayerKeyPasswords = relayerKeyPasswords +} + +// GetRelayerKeyPassword returns the relayer key password for the given network +func (a *AppContext) GetRelayerKeyPassword(network chains.Network) string { + a.mu.RLock() + defer a.mu.RUnlock() + + return a.relayerKeyPasswords[network] +} + // Update updates AppContext and params for all chains // this must be the ONLY function that writes to AppContext func (a *AppContext) Update( diff --git a/zetaclient/keys/relayer_key.go b/zetaclient/keys/relayer_key.go new file mode 100644 index 0000000000..8de6cc9853 --- /dev/null +++ b/zetaclient/keys/relayer_key.go @@ -0,0 +1,149 @@ +package keys + +import ( + "encoding/json" + "io" + "os" + "path/filepath" + + "github.com/gagliardetto/solana-go" + "github.com/pkg/errors" + + "github.com/zeta-chain/zetacore/pkg/chains" + "github.com/zeta-chain/zetacore/pkg/crypto" + zetaos "github.com/zeta-chain/zetacore/pkg/os" +) + +// RelayerKey is the structure that holds the relayer private key +type RelayerKey struct { + PrivateKey string `json:"private_key"` +} + +// ResolveAddress returns the network name and address of the relayer key +func (rk RelayerKey) ResolveAddress(network chains.Network) (string, string, error) { + // get network name + networkName, found := chains.GetNetworkName(int32(network)) + if !found { + return "", "", errors.Errorf("network name not found for network %d", network) + } + + switch network { + case chains.Network_solana: + privKey, err := solana.PrivateKeyFromBase58(rk.PrivateKey) + if err != nil { + return "", "", errors.Wrap(err, "unable to construct solana private key") + } + return networkName, privKey.PublicKey().String(), nil + default: + return "", "", errors.Errorf("cannot derive relayer address for unsupported network %d", network) + } +} + +// LoadRelayerKey loads the relayer key for given network and password +func LoadRelayerKey(relayerKeyPath string, network chains.Network, password string) (*RelayerKey, error) { + // resolve the relayer key file name + fileName, err := ResolveRelayerKeyFile(relayerKeyPath, network) + if err != nil { + return nil, errors.Wrap(err, "failed to resolve relayer key file name") + } + + // load the relayer key if it is present + if zetaos.FileExists(fileName) { + // read the relayer key file + relayerKey, err := ReadRelayerKeyFromFile(fileName) + if err != nil { + return nil, errors.Wrapf(err, "failed to read relayer key file: %s", fileName) + } + + // decrypt the private key + privateKey, err := crypto.DecryptAES256GCMBase64(relayerKey.PrivateKey, password) + if err != nil { + return nil, errors.Wrap(err, "failed to decrypt private key") + } + + relayerKey.PrivateKey = privateKey + return relayerKey, nil + } + + // relayer key is optional, so it's okay if the relayer key is not provided + return nil, nil +} + +// ResolveRelayerKeyFile is a helper function to resolve the relayer key file with full path +func ResolveRelayerKeyFile(relayerKeyPath string, network chains.Network) (string, error) { + // resolve relayer key path if it contains a tilde + keyPath, err := zetaos.ExpandHomeDir(relayerKeyPath) + if err != nil { + return "", errors.Wrap(err, "failed to resolve relayer key path") + } + + // get relayer key file name by network + name, err := relayerKeyFileByNetwork(network) + if err != nil { + return "", errors.Wrap(err, "failed to get relayer key file name") + } + + return filepath.Join(keyPath, name), nil +} + +// WriteRelayerKeyToFile writes the relayer key to a file +func WriteRelayerKeyToFile(fileName string, relayerKey RelayerKey) error { + keyData, err := json.Marshal(relayerKey) + if err != nil { + return errors.Wrap(err, "failed to marshal relayer key") + } + + // create relay key file (owner `rw` permissions) + return os.WriteFile(fileName, keyData, 0o600) +} + +// ReadRelayerKeyFromFile reads the relayer key file and returns the key +func ReadRelayerKeyFromFile(fileName string) (*RelayerKey, error) { + // expand home directory in the file path if it exists + fileNameFull, err := zetaos.ExpandHomeDir(fileName) + if err != nil { + return nil, errors.Wrapf(err, "ExpandHome failed for file: %s", fileName) + } + + // open the file + file, err := os.Open(fileNameFull) + if err != nil { + return nil, errors.Wrapf(err, "unable to open relayer key file: %s", fileNameFull) + } + defer file.Close() + + // read the file contents + fileData, err := io.ReadAll(file) + if err != nil { + return nil, errors.Wrapf(err, "unable to read relayer key data: %s", fileNameFull) + } + + // unmarshal the JSON data into the struct + var key RelayerKey + err = json.Unmarshal(fileData, &key) + if err != nil { + return nil, errors.Wrap(err, "unable to unmarshal relayer key") + } + + return &key, nil +} + +// relayerKeyFileByNetwork returns the relayer key JSON file name based on network +func relayerKeyFileByNetwork(network chains.Network) (string, error) { + // get network name + networkName, found := chains.GetNetworkName(int32(network)) + if !found { + return "", errors.Errorf("network name not found for network %d", network) + } + + // JSONFileSuffix is the suffix for the relayer key file + const JSONFileSuffix = ".json" + + // return file name for supported networks only + switch network { + case chains.Network_solana: + return networkName + JSONFileSuffix, nil + default: + return "", errors.Errorf("network %d does not support relayer key", network) + } +} diff --git a/zetaclient/keys/relayer_key_test.go b/zetaclient/keys/relayer_key_test.go new file mode 100644 index 0000000000..3d5c8a1d1e --- /dev/null +++ b/zetaclient/keys/relayer_key_test.go @@ -0,0 +1,258 @@ +package keys_test + +import ( + "os" + "os/user" + "path" + "testing" + + "github.com/stretchr/testify/require" + "github.com/zeta-chain/zetacore/pkg/chains" + "github.com/zeta-chain/zetacore/pkg/crypto" + "github.com/zeta-chain/zetacore/testutil/sample" + "github.com/zeta-chain/zetacore/zetaclient/keys" +) + +// createRelayerKeyFile creates a relayer key file for testing +func createRelayerKeyFile(t *testing.T, fileName, privKey, password string) { + // encrypt the private key + ciphertext, err := crypto.EncryptAES256GCMBase64(privKey, password) + require.NoError(t, err) + + // create relayer key file + err = keys.WriteRelayerKeyToFile(fileName, keys.RelayerKey{PrivateKey: ciphertext}) + require.NoError(t, err) +} + +// createBadRelayerKeyFile creates a bad relayer key file for testing +func createBadRelayerKeyFile(t *testing.T, fileName string) { + err := os.WriteFile(fileName, []byte("arbitrary data"), 0o600) + require.NoError(t, err) +} + +func Test_ResolveAddress(t *testing.T) { + // sample test keys + solanaPrivKey := sample.SolanaPrivateKey(t) + + tests := []struct { + name string + network chains.Network + relayerKey keys.RelayerKey + expectedNetworkName string + expectedAddress string + expectedError string + }{ + { + name: "should resolve solana address", + network: chains.Network_solana, + relayerKey: keys.RelayerKey{ + PrivateKey: solanaPrivKey.String(), + }, + expectedNetworkName: "solana", + expectedAddress: solanaPrivKey.PublicKey().String(), + }, + { + name: "should return error if network name not found", + network: chains.Network(999), + relayerKey: keys.RelayerKey{ + PrivateKey: solanaPrivKey.String(), + }, + expectedError: "network name not found", + }, + { + name: "should return error if private key is invalid", + network: chains.Network_solana, + relayerKey: keys.RelayerKey{ + PrivateKey: "invalid", + }, + expectedError: "unable to construct solana private key", + }, + { + name: "should return error if network is unsupported", + network: chains.Network_eth, + relayerKey: keys.RelayerKey{ + PrivateKey: solanaPrivKey.String(), + }, + expectedError: "cannot derive relayer address for unsupported network", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + networkName, address, err := tt.relayerKey.ResolveAddress(tt.network) + if tt.expectedError != "" { + require.Empty(t, networkName) + require.Empty(t, address) + require.ErrorContains(t, err, tt.expectedError) + return + } + + require.NoError(t, err) + require.Equal(t, tt.expectedNetworkName, networkName) + require.Equal(t, tt.expectedAddress, address) + }) + } +} + +func Test_LoadRelayerKey(t *testing.T) { + // sample test key and temp path + solanaPrivKey := sample.SolanaPrivateKey(t) + keyPath := sample.CreateTempDir(t) + fileName := path.Join(keyPath, "solana.json") + + // create relayer key file + createRelayerKeyFile(t, fileName, solanaPrivKey.String(), "password") + + // create a bad relayer key file + keyPath2 := sample.CreateTempDir(t) + badKeyFile := path.Join(keyPath2, "solana.json") + createBadRelayerKeyFile(t, badKeyFile) + + // test cases + tests := []struct { + name string + keyPath string + network chains.Network + password string + expectedKey *keys.RelayerKey + expectError string + }{ + { + name: "should load relayer key successfully", + keyPath: keyPath, + network: chains.Network_solana, + password: "password", + expectedKey: &keys.RelayerKey{PrivateKey: solanaPrivKey.String()}, + }, + { + name: "it's okay if relayer key is not provided", + keyPath: sample.CreateTempDir(t), // create a empty directory + network: chains.Network_solana, + password: "", + expectedKey: nil, + expectError: "", + }, + { + name: "should return error if network is unsupported", + keyPath: keyPath, + network: chains.Network_eth, + password: "", + expectedKey: nil, + expectError: "failed to resolve relayer key file name", + }, + { + name: "should return error if unable to read relayer key file", + keyPath: keyPath2, + network: chains.Network_solana, + password: "", + expectedKey: nil, + expectError: "failed to read relayer key file", + }, + { + name: "should return error if password is incorrect", + keyPath: keyPath, + network: chains.Network_solana, + password: "incorrect", + expectedKey: nil, + expectError: "failed to decrypt private key", + }, + } + + // Iterate over the test cases and run them + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + relayerKey, err := keys.LoadRelayerKey(tt.keyPath, tt.network, tt.password) + + if tt.expectError != "" { + require.ErrorContains(t, err, tt.expectError) + require.Nil(t, relayerKey) + } else { + require.NoError(t, err) + if tt.expectedKey != nil { + require.Equal(t, tt.expectedKey.PrivateKey, relayerKey.PrivateKey) + } + } + }) + } +} + +func Test_ResolveRelayerKeyPath(t *testing.T) { + usr, err := user.Current() + require.NoError(t, err) + + tests := []struct { + name string + relayerKeyPath string + network chains.Network + expectedName string + errMessage string + }{ + { + name: "should resolve relayer key path", + relayerKeyPath: "~/.zetacored/relayer-keys", + network: chains.Network_solana, + expectedName: path.Join(usr.HomeDir, ".zetacored/relayer-keys/solana.json"), + }, + { + name: "should return error if network is not found", + relayerKeyPath: "~/.zetacored/relayer-keys", + network: chains.Network(999), + errMessage: "failed to get relayer key file name", + }, + { + name: "should return error if network does not support relayer key", + relayerKeyPath: "~/.zetacored/relayer-keys", + network: chains.Network_eth, + errMessage: "does not support relayer key", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + name, err := keys.ResolveRelayerKeyFile(tt.relayerKeyPath, tt.network) + if tt.errMessage != "" { + require.Empty(t, name) + require.ErrorContains(t, err, tt.errMessage) + return + } + + require.NoError(t, err) + require.Equal(t, tt.expectedName, name) + }) + } +} + +func Test_ReadWriteRelayerKeyFile(t *testing.T) { + // sample test key and temp path + solanaPrivKey := sample.SolanaPrivateKey(t) + keyPath := sample.CreateTempDir(t) + fileName := path.Join(keyPath, "solana.json") + + t.Run("should write and read relayer key file", func(t *testing.T) { + // create relayer key file + err := keys.WriteRelayerKeyToFile(fileName, keys.RelayerKey{PrivateKey: solanaPrivKey.String()}) + require.NoError(t, err) + + // read relayer key file + relayerKey, err := keys.ReadRelayerKeyFromFile(fileName) + require.NoError(t, err) + require.Equal(t, solanaPrivKey.String(), relayerKey.PrivateKey) + }) + + t.Run("should return error if relayer key file does not exist", func(t *testing.T) { + noFileName := path.Join(keyPath, "non-existing.json") + _, err := keys.ReadRelayerKeyFromFile(noFileName) + require.ErrorContains(t, err, "unable to open relayer key file") + }) + + t.Run("should return error if unmarsalling fails", func(t *testing.T) { + // create a bad key file + badKeyFile := path.Join(keyPath, "bad.json") + createBadRelayerKeyFile(t, badKeyFile) + + // try reading bad key file + key, err := keys.ReadRelayerKeyFromFile(badKeyFile) + require.ErrorContains(t, err, "unable to unmarshal relayer key") + require.Nil(t, key) + }) +} diff --git a/zetaclient/keys/relayer_keys.go b/zetaclient/keys/relayer_keys.go deleted file mode 100644 index 97dd7ca8b2..0000000000 --- a/zetaclient/keys/relayer_keys.go +++ /dev/null @@ -1,112 +0,0 @@ -package keys - -import ( - "encoding/json" - "io" - "os" - "path" - - "github.com/gagliardetto/solana-go" - "github.com/pkg/errors" - - "github.com/zeta-chain/zetacore/pkg/chains" - zetaos "github.com/zeta-chain/zetacore/pkg/os" -) - -const ( - // RelayerKeyFileSolana is the file name for the Solana relayer key - RelayerKeyFileSolana = "solana.json" -) - -// RelayerKey is the structure that holds the relayer private key -type RelayerKey struct { - PrivateKey string `json:"private_key"` -} - -// ResolveAddress returns the network name and address of the relayer key -func (rk RelayerKey) ResolveAddress(network int32) (string, string, error) { - // get network name - networkName, found := chains.GetNetworkName(network) - if !found { - return "", "", errors.Errorf("network name not found for network %d", network) - } - - switch chains.Network(network) { - case chains.Network_solana: - privKey, err := solana.PrivateKeyFromBase58(rk.PrivateKey) - if err != nil { - return "", "", errors.Wrap(err, "unable to construct solana private key") - } - return networkName, privKey.PublicKey().String(), nil - default: - return "", "", errors.Errorf("cannot derive relayer address for unsupported network %d", network) - } -} - -// LoadRelayerKey loads a relayer key from given path and chain -func LoadRelayerKey(keyPath string, chain chains.Chain) (RelayerKey, error) { - // determine relayer key file name based on chain - var fileName string - switch chain.Network { - case chains.Network_solana: - fileName = path.Join(keyPath, RelayerKeyFileSolana) - default: - return RelayerKey{}, errors.Errorf("relayer key not supported for network %s", chain.Network) - } - - // read the relayer key file - relayerKey, err := ReadRelayerKeyFromFile(fileName) - if err != nil { - return RelayerKey{}, errors.Wrap(err, "ReadRelayerKeyFile failed") - } - - return relayerKey, nil -} - -// ReadRelayerKeyFromFile reads the relayer key file and returns the key -func ReadRelayerKeyFromFile(fileName string) (RelayerKey, error) { - // expand home directory in the file path if it exists - fileNameFull, err := zetaos.ExpandHomeDir(fileName) - if err != nil { - return RelayerKey{}, errors.Wrapf(err, "ExpandHome failed for file: %s", fileName) - } - - // open the file - file, err := os.Open(fileNameFull) - if err != nil { - return RelayerKey{}, errors.Wrapf(err, "unable to open relayer key file: %s", fileNameFull) - } - defer file.Close() - - // read the file contents - fileData, err := io.ReadAll(file) - if err != nil { - return RelayerKey{}, errors.Wrapf(err, "unable to read relayer key data: %s", fileNameFull) - } - - // unmarshal the JSON data into the struct - var key RelayerKey - err = json.Unmarshal(fileData, &key) - if err != nil { - return RelayerKey{}, errors.Wrap(err, "unable to unmarshal relayer key") - } - - return key, nil -} - -// GetRelayerKeyFileByNetwork returns the relayer key file name based on network -func GetRelayerKeyFileByNetwork(network int32) (string, error) { - // get network name - networkName, found := chains.GetNetworkName(network) - if !found { - return "", errors.Errorf("network name not found for network %d", network) - } - - // return file name for supported networks only - switch chains.Network(network) { - case chains.Network_solana: - return networkName + ".json", nil - default: - return "", errors.Errorf("network %d does not support relayer key", network) - } -} diff --git a/zetaclient/orchestrator/bootstrap.go b/zetaclient/orchestrator/bootstrap.go index 2e65e157ad..2df07902a9 100644 --- a/zetaclient/orchestrator/bootstrap.go +++ b/zetaclient/orchestrator/bootstrap.go @@ -162,19 +162,16 @@ func syncSignerMap( continue } - // load the Solana private key - relayerKey, err := keys.LoadRelayerKey(app.Config().GetRelayerKeyPath(), *rawChain) + // try loading Solana relayer key if present + password := app.GetRelayerKeyPassword(rawChain.Network) + relayerKey, err := keys.LoadRelayerKey(app.Config().GetRelayerKeyPath(), rawChain.Network, password) if err != nil { logger.Std.Error().Err(err).Msg("Unable to load Solana relayer key") continue } - var ( - paramsRaw = chain.Params() - ) - // create Solana signer - signer, err := solanasigner.NewSigner(*rawChain, *paramsRaw, rpcClient, tss, relayerKey, ts, logger) + signer, err := solanasigner.NewSigner(*rawChain, *chain.Params(), rpcClient, tss, relayerKey, ts, logger) if err != nil { logger.Std.Error().Err(err).Msgf("Unable to construct signer for SOL chain %d", chainID) continue