diff --git a/CHANGELOG.md b/CHANGELOG.md index ee51162..bec28b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ## Unreleased +### Improvements + +* [#207](https://github.com/babylonlabs-io/finality-provider/pull/207) create finality provider from JSON file + ## v0.13.1 ### Bug Fixes diff --git a/finality-provider/cmd/fpd/daemon/daemon_commands.go b/finality-provider/cmd/fpd/daemon/daemon_commands.go index a943c82..6ed9050 100644 --- a/finality-provider/cmd/fpd/daemon/daemon_commands.go +++ b/finality-provider/cmd/fpd/daemon/daemon_commands.go @@ -5,7 +5,9 @@ import ( "encoding/hex" "encoding/json" "fmt" + "os" "strconv" + "strings" "cosmossdk.io/math" "github.com/babylonlabs-io/babylon/types" @@ -74,9 +76,32 @@ func CommandCreateFP() *cobra.Command { Short: "Create a finality provider object and save it in database.", Long: "Create a new finality provider object and store it in the finality provider database. " + "It needs to have an operating EOTS manager available and running.", - Example: fmt.Sprintf(`fpd create-finality-provider --daemon-address %s ...`, defaultFpdDaemonAddress), - Args: cobra.NoArgs, - RunE: fpcmd.RunEWithClientCtx(runCommandCreateFP), + Example: strings.TrimSpace( + fmt.Sprintf(` +Either by specifying all flags manually: + +$fpd create-finality-provider --daemon-address %s ... + +Or providing the path to finality-provider.json: +$fpd create-finality-provider --daemon-address %s --from-file /path/to/finality-provider.json + +Where finality-provider.json contains: + +{ + "keyName": "The unique key name of the finality provider's Babylon account", + "chainID": "The identifier of the consumer chain", + "passphrase": "The pass phrase used to encrypt the keys", + "commissionRate": "The commission rate for the finality provider, e.g., 0.05"", + "moniker": ""A human-readable name for the finality provider", + "identity": "A optional identity signature", + "website": "Validator's (optional) website", + "securityContract": "Validator's (optional) security contact email", + "details": "Validator's (optional) details", + "eotsPK": "The hex string of the finality provider's EOTS public key" +} +`, defaultFpdDaemonAddress, defaultFpdDaemonAddress)), + Args: cobra.NoArgs, + RunE: fpcmd.RunEWithClientCtx(runCommandCreateFP), } f := cmd.Flags() @@ -92,22 +117,29 @@ func CommandCreateFP() *cobra.Command { f.String(securityContactFlag, "", "An email for security contact") f.String(detailsFlag, "", "Other optional details") f.String(fpEotsPkFlag, "", "The hex string of the finality provider's EOTS public key") - - // make flags required - if err := cmd.MarkFlagRequired(chainIDFlag); err != nil { - panic(err) - } - if err := cmd.MarkFlagRequired(keyNameFlag); err != nil { - panic(err) - } - if err := cmd.MarkFlagRequired(monikerFlag); err != nil { - panic(err) - } - if err := cmd.MarkFlagRequired(commissionRateFlag); err != nil { - panic(err) - } - if err := cmd.MarkFlagRequired(fpEotsPkFlag); err != nil { - panic(err) + f.String(fromFile, "", "Path to a json file containing finality provider data") + + cmd.PreRunE = func(cmd *cobra.Command, _ []string) error { + fromFilePath, _ := cmd.Flags().GetString(fromFile) + if fromFilePath == "" { + // Mark flags as required only if --from-file is not provided + if err := cmd.MarkFlagRequired(chainIDFlag); err != nil { + return err + } + if err := cmd.MarkFlagRequired(keyNameFlag); err != nil { + return err + } + if err := cmd.MarkFlagRequired(monikerFlag); err != nil { + return err + } + if err := cmd.MarkFlagRequired(commissionRateFlag); err != nil { + return err + } + if err := cmd.MarkFlagRequired(fpEotsPkFlag); err != nil { + return err + } + } + return nil } return cmd @@ -115,32 +147,28 @@ func CommandCreateFP() *cobra.Command { func runCommandCreateFP(ctx client.Context, cmd *cobra.Command, _ []string) error { flags := cmd.Flags() - daemonAddress, err := flags.GetString(fpdDaemonAddressFlag) - if err != nil { - return fmt.Errorf("failed to read flag %s: %w", fpdDaemonAddressFlag, err) - } - commissionRateStr, err := flags.GetString(commissionRateFlag) - if err != nil { - return fmt.Errorf("failed to read flag %s: %w", commissionRateFlag, err) - } - commissionRate, err := math.LegacyNewDecFromStr(commissionRateStr) + fpJSONPath, err := flags.GetString(fromFile) if err != nil { - return fmt.Errorf("invalid commission rate: %w", err) + return fmt.Errorf("failed to read flag %s: %w", fromFile, err) } - description, err := getDescriptionFromFlags(flags) - if err != nil { - return fmt.Errorf("invalid description: %w", err) + var fp *parsedFinalityProvider + if fpJSONPath != "" { + fp, err = parseFinalityProviderJSON(fpJSONPath, ctx.HomeDir) + if err != nil { + panic(err) + } + } else { + fp, err = parseFinalityProviderFlags(cmd, ctx.HomeDir) + if err != nil { + panic(err) + } } - keyName, err := loadKeyName(ctx.HomeDir, cmd) + daemonAddress, err := flags.GetString(fpdDaemonAddressFlag) if err != nil { - return fmt.Errorf("not able to load key name: %w", err) - } - - if keyName == "" { - return fmt.Errorf("keyname cannot be empty") + return fmt.Errorf("failed to read flag %s: %w", fpdDaemonAddressFlag, err) } client, cleanUp, err := dc.NewFinalityProviderServiceGRpcClient(daemonAddress) @@ -153,37 +181,14 @@ func runCommandCreateFP(ctx client.Context, cmd *cobra.Command, _ []string) erro } }() - chainID, err := flags.GetString(chainIDFlag) - if err != nil { - return fmt.Errorf("failed to read flag %s: %w", chainIDFlag, err) - } - - if chainID == "" { - return fmt.Errorf("chain-id cannot be empty") - } - - passphrase, err := flags.GetString(passphraseFlag) - if err != nil { - return fmt.Errorf("failed to read flag %s: %w", passphraseFlag, err) - } - - eotsPkHex, err := flags.GetString(fpEotsPkFlag) - if err != nil { - return fmt.Errorf("failed to read flag %s: %w", fpEotsPkFlag, err) - } - - if eotsPkHex == "" { - return fmt.Errorf("eots-pk cannot be empty") - } - res, err := client.CreateFinalityProvider( context.Background(), - keyName, - chainID, - eotsPkHex, - passphrase, - description, - &commissionRate, + fp.keyName, + fp.chainID, + fp.eotsPK, + fp.passphrase, + fp.description, + &fp.commissionRate, ) if err != nil { return err @@ -516,3 +521,143 @@ func loadKeyName(homeDir string, cmd *cobra.Command) (string, error) { } return keyName, nil } + +type parsedFinalityProvider struct { + keyName string + chainID string + eotsPK string + passphrase string + description stakingtypes.Description + commissionRate math.LegacyDec +} + +func parseFinalityProviderJSON(path string, homeDir string) (*parsedFinalityProvider, error) { + type internalFpJSON struct { + KeyName string `json:"keyName"` + ChainID string `json:"chainID"` + Passphrase string `json:"passphrase"` + CommissionRate string `json:"commissionRate"` + Moniker string `json:"moniker"` + Identity string `json:"identity"` + Website string `json:"website"` + SecurityContract string `json:"securityContract"` + Details string `json:"details"` + EotsPK string `json:"eotsPK"` + } + + // #nosec G304 - The log file path is provided by the user and not externally + contents, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var fp internalFpJSON + if err := json.Unmarshal(contents, &fp); err != nil { + return nil, err + } + + if fp.ChainID == "" { + return nil, fmt.Errorf("chainID is required") + } + + if fp.KeyName == "" { + cfg, err := fpcfg.LoadConfig(homeDir) + if err != nil { + return nil, fmt.Errorf("failed to load config from %s: %w", fpcfg.CfgFile(homeDir), err) + } + fp.KeyName = cfg.BabylonConfig.Key + if fp.KeyName == "" { + return nil, fmt.Errorf("the key is neither in config nor provided in the json file") + } + } + + if fp.Moniker == "" { + return nil, fmt.Errorf("moniker is required") + } + + if fp.CommissionRate == "" { + return nil, fmt.Errorf("commissionRate is required") + } + + if fp.EotsPK == "" { + return nil, fmt.Errorf("eotsPK is required") + } + + commissionRate, err := math.LegacyNewDecFromStr(fp.CommissionRate) + if err != nil { + return nil, fmt.Errorf("invalid commission rate: %w", err) + } + + description, err := stakingtypes.NewDescription(fp.Moniker, fp.Identity, fp.Website, fp.SecurityContract, fp.Details).EnsureLength() + if err != nil { + return nil, err + } + + return &parsedFinalityProvider{ + keyName: fp.KeyName, + chainID: fp.ChainID, + eotsPK: fp.EotsPK, + passphrase: fp.Passphrase, + description: description, + commissionRate: commissionRate, + }, nil +} + +func parseFinalityProviderFlags(cmd *cobra.Command, homeDir string) (*parsedFinalityProvider, error) { + flags := cmd.Flags() + + commissionRateStr, err := flags.GetString(commissionRateFlag) + if err != nil { + return nil, fmt.Errorf("failed to read flag %s: %w", commissionRateFlag, err) + } + commissionRate, err := math.LegacyNewDecFromStr(commissionRateStr) + if err != nil { + return nil, fmt.Errorf("invalid commission rate: %w", err) + } + + description, err := getDescriptionFromFlags(flags) + if err != nil { + return nil, fmt.Errorf("invalid description: %w", err) + } + + keyName, err := loadKeyName(homeDir, cmd) + if err != nil { + return nil, fmt.Errorf("not able to load key name: %w", err) + } + + if keyName == "" { + return nil, fmt.Errorf("keyname cannot be empty") + } + + chainID, err := flags.GetString(chainIDFlag) + if err != nil { + return nil, fmt.Errorf("failed to read flag %s: %w", chainIDFlag, err) + } + + if chainID == "" { + return nil, fmt.Errorf("chain-id cannot be empty") + } + + passphrase, err := flags.GetString(passphraseFlag) + if err != nil { + return nil, fmt.Errorf("failed to read flag %s: %w", passphraseFlag, err) + } + + eotsPkHex, err := flags.GetString(fpEotsPkFlag) + if err != nil { + return nil, fmt.Errorf("failed to read flag %s: %w", fpEotsPkFlag, err) + } + + if eotsPkHex == "" { + return nil, fmt.Errorf("eots-pk cannot be empty") + } + + return &parsedFinalityProvider{ + keyName: keyName, + chainID: chainID, + eotsPK: eotsPkHex, + passphrase: passphrase, + description: description, + commissionRate: commissionRate, + }, nil +} diff --git a/finality-provider/cmd/fpd/daemon/flags.go b/finality-provider/cmd/fpd/daemon/flags.go index e20a2fe..4a343a2 100644 --- a/finality-provider/cmd/fpd/daemon/flags.go +++ b/finality-provider/cmd/fpd/daemon/flags.go @@ -12,6 +12,7 @@ const ( chainIDFlag = "chain-id" signedFlag = "signed" checkDoubleSignFlag = "check-double-sign" + fromFile = "from-file" // flags for description monikerFlag = "moniker" diff --git a/itest/e2e_test.go b/itest/e2e_test.go index a7f6b66..5591a0f 100644 --- a/itest/e2e_test.go +++ b/itest/e2e_test.go @@ -1,10 +1,12 @@ -//go:build e2e -// +build e2e - package e2etest import ( + "encoding/json" + "fmt" + bbntypes "github.com/babylonlabs-io/babylon/types" + "log" "math/rand" + "os" "testing" "time" @@ -240,3 +242,65 @@ func TestFinalityProviderEditCmd(t *testing.T) { require.Equal(t, updateFpDesc.SecurityContact, oldDesc.SecurityContact) require.Equal(t, updatedFp.FinalityProvider.Commission, &rate) } + +func TestFinalityProviderCreateCmd(t *testing.T) { + tm, _ := StartManagerWithFinalityProvider(t) + defer tm.Stop(t) + + cmd := daemon.CommandCreateFP() + + eotsKeyName := "eots-key-2" + eotsPkBz, err := tm.EOTSClient.CreateKey(eotsKeyName, passphrase, hdPath) + require.NoError(t, err) + eotsPk, err := bbntypes.NewBIP340PubKey(eotsPkBz) + require.NoError(t, err) + + data := struct { + KeyName string `json:"keyName"` + ChainID string `json:"chainID"` + Passphrase string `json:"passphrase"` + CommissionRate string `json:"commissionRate"` + Moniker string `json:"moniker"` + Identity string `json:"identity"` + Website string `json:"website"` + SecurityContract string `json:"securityContract"` + Details string `json:"details"` + EotsPK string `json:"eotsPK"` + }{ + KeyName: tm.FpConfig.BabylonConfig.Key, + ChainID: testChainID, + Passphrase: passphrase, + CommissionRate: "0.10", + Moniker: "some moniker", + Identity: "F123456789ABCDEF", + Website: "https://fp.example.com", + SecurityContract: "https://fp.example.com/security", + Details: "This is a highly secure and reliable fp.", + EotsPK: eotsPk.MarshalHex(), + } + + file, err := os.Create(fmt.Sprintf("%s/%s", t.TempDir(), "finality-provider.json")) + if err != nil { + log.Fatalf("Failed to create file: %v", err) + } + t.Cleanup(func() { + _ = os.Remove(file.Name()) + }) + + if err := json.NewEncoder(file).Encode(data); err != nil { + log.Fatalf("Failed to write JSON to file: %v", err) + } + + cmd.SetArgs([]string{ + "--from-file=" + file.Name(), + "--daemon-address=" + tm.FpConfig.RPCListener, + }) + + // Run the command + err = cmd.Execute() + require.NoError(t, err) + + fp, err := tm.BBNClient.QueryFinalityProvider(eotsPk.MustToBTCPK()) + require.NoError(t, err) + require.NotNil(t, fp) +}