From bded80cce30a8a05e620e2df92e92a57e5c06674 Mon Sep 17 00:00:00 2001
From: spilin <lyoshakr@gmail.com>
Date: Wed, 11 Oct 2023 21:24:45 +0300
Subject: [PATCH] Adding cmd to generate/add/remove access keys for key
 rotation functionality

---
 README.md   |  76 +++++++++++++-
 cmd/keys.go | 280 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 go.mod      |   7 +-
 go.sum      |   4 +-
 main.go     |   4 +
 5 files changed, 364 insertions(+), 7 deletions(-)
 create mode 100644 cmd/keys.go

diff --git a/README.md b/README.md
index e4b68e4..14e859f 100644
--- a/README.md
+++ b/README.md
@@ -41,7 +41,79 @@ To be able to call `eth_sendRawTransaction`, you must have a NEAR account and
 signing key on the network you are relaying to, and said NEAR account must have
 a sufficient Ⓝ balance to be able to send transactions.
 
-To configure the signing account and private key:
+For those who prefer not to use personal accounts, a new signing key can be generated.
+
+#### Setting the Signer Key Location
+Ensure the signer key location is defined in the configuration file:
+Ex. :
+```yaml
+endpoint:
+  ...
+  engine:
+    ...
+    signerKey: config/relayer.json
+```
+
+#### Generating the Signer Key
+Execute the following command to generate a new signer key:
+
+```bash
+./relayer generate-key
+```
+
+> Note: The default configuration file used is config/testnet.yaml.
+
+Upon successful execution, a `relayer.json` file will be generated. An example of its structure is as follows:
+```json
+{
+  "account_id": "703f78e5355ae6fedf6384257a18e4cfb55bada321f6e7a35b1e21a3803b03e0",
+  "public_key": "ed25519:8ZAoFCzzj7FwADapMyRCctau295MpvKT5Y1z6cPySCko",
+  "secret_key": "ed25519:3j8Rxcnx6BcVvphxJpxKMJGWDfjTrSZTvDx7EdWm7L223dwkK8ZgebXieiadAZ3v5Xfg9AKx4XYsaPPcfmncFNo1"
+}
+```
+
+Update the configuration file's `signer` field with the `account_id` value from the generated `relayer.json` file:
+
+```yaml
+endpoint:
+  ...
+  engine:
+    ...
+    signer: 703f78e5355ae6fedf6384257a18e4cfb55bada321f6e7a35b1e21a3803b03e0
+    signerKey: config/relayer.json
+```
+
+#### Activating implicit account
+
+In the NEAR Protocol, implicit accounts require activation. This is achieved when a contract-based account or an externally-owned account transfers funds to the implicit account. This step is crucial as the signer account balance is utilized to cover the costs for `eth_sendRawTransaction`. Utilize any NEAR-compatible wallet or the [near send] command. Below is an example that transfers 0.5 NEAR to the generated account:
+
+```bash
+near send myaccount.near 703f78e5355ae6fedf6384257a18e4cfb55bada321f6e7a35b1e21a3803b03e0 0.5
+```
+
+### Handling Multiple Access Keys
+
+When dispatching multiple transactions concurrently, each signed by a different EOA, you may encounter the `ERR_INCORRECT_NONCE` error. The solution is for the relayer to use multiple access keys, signing each transaction with a distinct key.
+
+#### Generating Multiple Keys
+
+After [configuring and activating the access key](#configuring-a-signing-key), execute the following:
+
+```bash
+./relayer add-keys --config config/mainnet.yaml -n 100
+```
+
+This command generates 100 keys, storing them in the same directory as the `config/relayer.json` file. Upon restarting, the relayer will leverage these access keys for transaction signing.
+
+#### Removing Keys
+
+To delete these keys from the account, run:
+
+```bash
+./relayer delete-keys --config config/mainnet.yaml
+```
+
+You can also use ENV variables to configure the signing account and private key:
 
 #### Mainnet
 ```bash
@@ -300,6 +372,8 @@ using distribution project [Standalone Aurora Relayer and Refiner].
 [NEAR CLI]: https://docs.near.org/docs/tools/near-cli
 [Standalone Aurora Relayer and Refiner]: https://github.com/aurora-is-near/standalone-rpc
 
+[near send]: https://docs.near.org/tools/near-cli#near-send
+
 [`web3_clientVersion`]: https://docs.infura.io/infura/networks/ethereum/json-rpc-methods/web3_clientversion
 [`web3_sha3`]: https://openethereum.github.io/JSONRPC-web3-module#web3_sha3
 [`net_listening`]: https://docs.infura.io/infura/networks/ethereum/json-rpc-methods/net_listening
diff --git a/cmd/keys.go b/cmd/keys.go
new file mode 100644
index 0000000..4ce0050
--- /dev/null
+++ b/cmd/keys.go
@@ -0,0 +1,280 @@
+package cmd
+
+import (
+	"crypto/ed25519"
+	"crypto/rand"
+	"encoding/hex"
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/aurora-is-near/near-api-go"
+	"github.com/aurora-is-near/near-api-go/keystore"
+	"github.com/aurora-is-near/near-api-go/utils"
+	"github.com/btcsuite/btcutil/base58"
+	"github.com/spf13/cobra"
+	"github.com/spf13/viper"
+)
+
+var defaultConfigFile = "config/testnet.yaml"
+var numberOfKeys uint64
+var prefix = "ed25519:"
+
+type KeyFile struct {
+	AccountID string `json:"account_id"`
+	PublicKey string `json:"public_key"`
+	SecretKey string `json:"secret_key"`
+}
+
+type Config struct {
+	Signer                   string `mapstructure:"signer"`
+	SignerKey                string `mapstructure:"signerKey"`
+	FunctionKeyPrefixPattern string `mapstructure:"functionKeyPrefixPattern"`
+	NearNetworkID            string `mapstructure:"networkID"`
+	NearNodeURL              string `mapstructure:"nearNodeURL"`
+	NearArchivalNodeURL      string `mapstructure:"nearArchivalNodeURL"`
+	NearConfig               near.Config
+}
+
+// GenerateKeysCmd generates a new key pair and attemots to save it to the file
+func GenerateKeysCmd() *cobra.Command {
+	generateKey := &cobra.Command{
+		Use:   "generate-key",
+		Short: "Command to generate signer key pair",
+		PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
+			return bindConfiguration(cmd)
+		},
+
+		RunE: func(cmd *cobra.Command, args []string) error {
+			config := loadConfig()
+			key := newKey("")
+			dumpJson(config.SignerKey, key)
+			fmt.Printf("key generated: [%s]", config.SignerKey)
+			return nil
+		},
+	}
+	acceptConfigFilePath(generateKey)
+	return generateKey
+}
+
+// AddKeysCmd adds keys to the account via batch transaction
+func AddKeysCmd() *cobra.Command {
+	addKeys := &cobra.Command{
+		Use:   "add-keys",
+		Short: "Command to add keys to the account",
+		PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
+			return bindConfiguration(cmd)
+		},
+
+		RunE: func(cmd *cobra.Command, args []string) error {
+			config := loadConfig()
+
+			if numberOfKeys == 0 {
+				fmt.Println("number-of-keys is not set, please provide it using -n flag")
+				os.Exit(1)
+			}
+
+			destDir := filepath.Dir(config.SignerKey)
+			baseName := filepath.Base(config.SignerKey)
+
+			nearConn := near.NewConnection(config.NearConfig.NodeURL)
+			nearAccount, err := near.LoadAccount(nearConn, &config.NearConfig, config.Signer)
+			if err != nil {
+				fmt.Printf("failed to load Near account from path: [%s](%s)", config.SignerKey, err)
+				os.Exit(1)
+			}
+			pubKeys := make([]utils.PublicKey, 0)
+
+			for i := uint64(0); i < numberOfKeys; i++ {
+				key := newKey(config.Signer)
+				pubKeys = append(pubKeys, publicKeyFromBase58(key.PublicKey))
+				filename := fmt.Sprintf("%s/fk%v.%s", destDir, i, baseName)
+				err = dumpJson(filename, key)
+				if err != nil {
+					fmt.Printf("failed to dump key to file: [%s](%s) \n", filename, err)
+					os.Exit(1)
+				}
+			}
+			result, err := nearAccount.AddKeys(pubKeys...)
+			if err != nil {
+				fmt.Printf("failed to add key: [%s]", err)
+				os.Exit(1)
+			}
+			status := result["status"].(map[string]interface{})
+			if status["Failure"] != nil {
+				fmt.Printf("failed to add key: [%s]", status["Failure"])
+				os.Exit(1)
+			}
+
+			fmt.Printf("%v keys where added", len(pubKeys))
+
+			return nil
+		},
+	}
+	acceptConfigFilePath(addKeys)
+	addKeys.PersistentFlags().Uint64VarP(&numberOfKeys, "number-of-keys", "n", 0, "Amount of access keys to generate and add to the account")
+	return addKeys
+}
+
+// DeleteKeysCmd deletes keys from the account via batch transaction
+func DeleteKeysCmd() *cobra.Command {
+	deleteKeys := &cobra.Command{
+		Use:   "delete-keys",
+		Short: "Command to delete keys from the account",
+		PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
+			return bindConfiguration(cmd)
+		},
+
+		RunE: func(cmd *cobra.Command, args []string) error {
+			config := loadConfig()
+
+			keyPairFilePaths := getFunctionCallKeyPairFilePaths(config.SignerKey, config.FunctionKeyPrefixPattern)
+			nearConn := near.NewConnection(config.NearConfig.NodeURL)
+			nearAccount, err := near.LoadAccount(nearConn, &config.NearConfig, config.Signer)
+			if err != nil {
+				fmt.Printf("failed to load Near account from path: [%s](%s)", config.SignerKey, err)
+				os.Exit(1)
+			}
+
+			pubKeys := make([]utils.PublicKey, 0)
+			for _, keyPairFilePath := range keyPairFilePaths {
+				keyPair, err := keystore.LoadKeyPairFromPath(keyPairFilePath, config.Signer)
+				if err != nil {
+					fmt.Printf("failed to load key pair from path: [%s](%s)", keyPairFilePath, err)
+					os.Exit(1)
+				}
+				pubKeys = append(pubKeys, publicKeyFromBase58(keyPair.PublicKey))
+			}
+
+			result, err := nearAccount.DeleteKeys(pubKeys...)
+			if err != nil {
+				fmt.Printf("failed to delete key: [%s]", err)
+				os.Exit(1)
+			}
+			status := result["status"].(map[string]interface{})
+			if status["Failure"] != nil {
+				fmt.Printf("failed to delete key: [%s]", status["Failure"])
+				os.Exit(1)
+			}
+			for _, keyPairFilePath := range keyPairFilePaths {
+				err := os.Remove(keyPairFilePath)
+				if err != nil {
+					fmt.Printf("failed to remove key pair file: [%s](%s)", keyPairFilePath, err)
+					os.Exit(1)
+				}
+			}
+
+			fmt.Printf("%v keys where deleted", len(keyPairFilePaths))
+
+			os.Exit(0)
+
+			return nil
+		},
+	}
+	acceptConfigFilePath(deleteKeys)
+	return deleteKeys
+}
+
+func bindConfiguration(cmd *cobra.Command) error {
+	configFile, _ := cmd.Flags().GetString("config")
+	if configFile != "" {
+		viper.SetConfigFile(configFile)
+	} else {
+		viper.SetConfigFile(defaultConfigFile)
+	}
+
+	if err := viper.ReadInConfig(); err != nil {
+		if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
+			return err
+		}
+	}
+
+	return nil
+}
+
+// publicKeyFromBase58 converts a base58 encoded public key to a PublicKey struct
+func publicKeyFromBase58(pub string) utils.PublicKey {
+	var pubKey utils.PublicKey
+	pubKey.KeyType = utils.ED25519
+	pub = strings.TrimPrefix(pub, prefix)
+	decoded := base58.Decode(pub)
+	copy(pubKey.Data[:], decoded)
+	return pubKey
+}
+
+// newKey generates a new key pair and returns the key file
+func newKey(accountid string) *KeyFile {
+	pub, priv, err := ed25519.GenerateKey(rand.Reader)
+	if err != nil {
+		panic(err)
+	}
+	if accountid == "" {
+		accountid = hex.EncodeToString(pub)
+	}
+	kf := &KeyFile{
+		AccountID: accountid,
+		PublicKey: prefix + base58.Encode(pub),
+		SecretKey: prefix + base58.Encode(priv),
+	}
+	return kf
+}
+
+// dumpJson dumps the key file to a json file
+// if the file already exists, it returns an error
+func dumpJson(fileName string, keyFile *KeyFile) error {
+	if _, err := os.Stat(fileName); os.IsNotExist(err) {
+		file, err := os.Create(fileName)
+		if err != nil {
+			return err
+		}
+		defer file.Close()
+
+		encoder := json.NewEncoder(file)
+		encoder.SetIndent("", "  ")
+		err = encoder.Encode(keyFile)
+		if err != nil {
+			return err
+		}
+		return nil
+	} else {
+		return fmt.Errorf("file already exists: [%s]", fileName)
+	}
+}
+
+// loadConfig loads the configuration from the config file
+func loadConfig() Config {
+	var config Config
+	err := viper.UnmarshalKey("endpoint.engine", &config)
+	if err != nil {
+		panic(err)
+	}
+	if config.NearNodeURL == "" {
+		config.NearNodeURL = config.NearArchivalNodeURL
+	}
+
+	config.NearConfig = near.Config{
+		NetworkID: config.NearNetworkID,
+		NodeURL:   config.NearNodeURL,
+		KeyPath:   config.SignerKey,
+	}
+	return config
+}
+
+// getFunctionCallKeyPairFilePaths returns the file paths of the key pairs that match the pattern
+func getFunctionCallKeyPairFilePaths(path, prefixPattern string) []string {
+	dir, file := filepath.Split(path)
+	pattern := filepath.Join(dir, prefixPattern+file)
+
+	keyPairFiles := make([]string, 0)
+	files, err := filepath.Glob(pattern)
+	if err == nil && len(files) > 0 {
+		keyPairFiles = append(keyPairFiles, files...)
+	}
+	return keyPairFiles
+}
+
+func acceptConfigFilePath(cmd *cobra.Command) {
+	cmd.PersistentFlags().StringP("config", "c", "config/testnet.yaml", "Path of the configuration file")
+}
diff --git a/go.mod b/go.mod
index 5ae1132..99bbabb 100644
--- a/go.mod
+++ b/go.mod
@@ -3,16 +3,16 @@ module github.com/aurora-is-near/relayer2-public
 go 1.18
 
 //replace github.com/aurora-is-near/relayer2-base => github.com/aurora-is-near/relayer2-base v1.1.3-0.20230914105446-42f23496919e
-
-// replace github.com/aurora-is-near/near-api-go => /Users/spilin/sandbox/aurora/near-api-go
+//replace github.com/aurora-is-near/near-api-go => github.com/aurora-is-near/near-api-go
 
 // The following package had a conflicting dependency.
 // Fixed by pointing the dependency to the latest version tag.
 replace github.com/btcsuite/btcd => github.com/btcsuite/btcd v0.23.2
 
 require (
-	github.com/aurora-is-near/near-api-go v0.0.13
+	github.com/aurora-is-near/near-api-go v0.0.14
 	github.com/aurora-is-near/relayer2-base v1.1.3
+	github.com/btcsuite/btcutil v1.0.2
 	github.com/buger/jsonparser v1.1.1
 	github.com/ethereum/go-ethereum v1.10.25
 	github.com/google/uuid v1.3.0
@@ -30,7 +30,6 @@ require (
 	github.com/andybalholm/brotli v1.0.5 // indirect
 	github.com/aurora-is-near/go-jsonrpc/v3 v3.1.2 // indirect
 	github.com/aurora-is-near/stream-backup v0.0.0-20221212013533-1e06e263c3f7 // indirect
-	github.com/btcsuite/btcutil v1.0.2 // indirect
 	github.com/carlmjohnson/versioninfo v0.22.4 // indirect
 	github.com/cespare/xxhash v1.1.0 // indirect
 	github.com/dgraph-io/badger/v3 v3.2103.2 // indirect
diff --git a/go.sum b/go.sum
index e822f1e..11cafb5 100644
--- a/go.sum
+++ b/go.sum
@@ -52,8 +52,8 @@ github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHG
 github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
 github.com/aurora-is-near/go-jsonrpc/v3 v3.1.2 h1:GKX/ga2vElkJYHSIpWEGsW3Z5c0xFx6bnXkSsU30GXg=
 github.com/aurora-is-near/go-jsonrpc/v3 v3.1.2/go.mod h1:Li013EFlPu3crtlFQtWJAeE7VmdhSsxOpRoop1J0icw=
-github.com/aurora-is-near/near-api-go v0.0.13 h1:iIMlDIA/NqGS0D6j1TeP4DIdnyboifx2Y65UsJtUnBo=
-github.com/aurora-is-near/near-api-go v0.0.13/go.mod h1:k1fyeUePNSpC8VZTkbUaGbjMY8jexe4s5LffFejplyA=
+github.com/aurora-is-near/near-api-go v0.0.14 h1:6BHZ0jtUQ3frNx0h6Kucd2tOR/TZ5I7lScgN5khNhd8=
+github.com/aurora-is-near/near-api-go v0.0.14/go.mod h1:k1fyeUePNSpC8VZTkbUaGbjMY8jexe4s5LffFejplyA=
 github.com/aurora-is-near/relayer2-base v1.1.3 h1:NNPzgopDXatKjf4MPNILtx81GHvPjXwo8NkvkRrgtuE=
 github.com/aurora-is-near/relayer2-base v1.1.3/go.mod h1:kvEh47ywen00njN0XWPWj2RDGa1CXDF13EjDjLiRojo=
 github.com/aurora-is-near/stream-backup v0.0.0-20221212013533-1e06e263c3f7 h1:aHMsjwM2KJ6EO9H7f1UgFD95c+d9BTpA43NQgZ6hiaM=
diff --git a/main.go b/main.go
index cee5959..4c4f625 100644
--- a/main.go
+++ b/main.go
@@ -15,6 +15,7 @@ import (
 	"github.com/aurora-is-near/relayer2-base/indexer/tar"
 	"github.com/aurora-is-near/relayer2-base/log"
 	"github.com/aurora-is-near/relayer2-base/rpc/node"
+	publicCmd "github.com/aurora-is-near/relayer2-public/cmd"
 	"github.com/aurora-is-near/relayer2-public/endpoint"
 	"github.com/aurora-is-near/relayer2-public/indexer"
 	"github.com/aurora-is-near/relayer2-public/middleware"
@@ -28,6 +29,9 @@ import (
 func main() {
 	c := cmd.RootCmd()
 	c.AddCommand(cmd.VersionCmd())
+	c.AddCommand(publicCmd.AddKeysCmd())
+	c.AddCommand(publicCmd.DeleteKeysCmd())
+	c.AddCommand(publicCmd.GenerateKeysCmd())
 	c.AddCommand(cmd.StartCmd(func(cmd *cobra.Command, args []string) {
 		logger := log.Log()
 		bh, err := badger.NewBlockHandler()