diff --git a/tm2/pkg/crypto/keys/client/derive.go b/tm2/pkg/crypto/keys/client/derive.go new file mode 100644 index 00000000000..cf4d6c1de7c --- /dev/null +++ b/tm2/pkg/crypto/keys/client/derive.go @@ -0,0 +1,134 @@ +package client + +import ( + "context" + "errors" + "flag" + "math" + + "github.com/gnolang/gno/tm2/pkg/commands" + "github.com/gnolang/gno/tm2/pkg/crypto" + "github.com/gnolang/gno/tm2/pkg/crypto/bip39" + "github.com/gnolang/gno/tm2/pkg/crypto/hd" + "github.com/gnolang/gno/tm2/pkg/crypto/secp256k1" +) + +var ( + errInvalidMnemonic = errors.New("invalid bip39 mnemonic") + errInvalidNumAccounts = errors.New("invalid number of accounts") + errInvalidAccountIndex = errors.New("invalid account index") +) + +type deriveCfg struct { + mnemonic string + numAccounts uint64 + accountIndex uint64 +} + +// newDeriveCmd creates a new gnokey derive subcommand +func newDeriveCmd(io *commands.IO) *commands.Command { + cfg := &deriveCfg{} + + return commands.NewCommand( + commands.Metadata{ + Name: "derive", + ShortUsage: "derive [flags]", + ShortHelp: "Derives the account addresses from the specified mnemonic", + }, + cfg, + func(_ context.Context, _ []string) error { + return execDerive(cfg, io) + }, + ) +} + +func (c *deriveCfg) RegisterFlags(fs *flag.FlagSet) { + fs.StringVar( + &c.mnemonic, + "mnemonic", + "", + "the bip39 mnemonic", + ) + + fs.Uint64Var( + &c.numAccounts, + "num-accounts", + 10, + "the number of accounts to derive from the mnemonic", + ) + + fs.Uint64Var( + &c.accountIndex, + "account-index", + 0, + "the account index in the mnemonic", + ) +} + +func execDerive(cfg *deriveCfg, io *commands.IO) error { + // Make sure the number of accounts is valid + if cfg.numAccounts == 0 || !isUint32(cfg.numAccounts) { + return errInvalidNumAccounts + } + + // Make sure the account index is valid + if !isUint32(cfg.accountIndex) { + return errInvalidAccountIndex + } + + // Make sure the mnemonic is valid + if !bip39.IsMnemonicValid(cfg.mnemonic) { + return errInvalidMnemonic + } + + // Generate the accounts + accounts := generateAccounts( + cfg.mnemonic, + cfg.accountIndex, + cfg.numAccounts, + ) + + io.Printf("[Generated Accounts]\n\n") + io.Printf("Account Index: %d\n\n", cfg.accountIndex) + + // Print them out + for index, account := range accounts { + io.Printfln("%d. %s", index, account.String()) + } + + return nil +} + +// isUint32 verifies a uint64 value can be represented +// as a uint32 +func isUint32(value uint64) bool { + return value <= math.MaxUint32 +} + +// generateAccounts the accounts using the provided mnemonics +func generateAccounts(mnemonic string, accountIndex, numAccounts uint64) []crypto.Address { + addresses := make([]crypto.Address, numAccounts) + + // Generate the seed + seed := bip39.NewSeed(mnemonic, "") + + for i := uint64(0); i < numAccounts; i++ { + key := generateKeyFromSeed(seed, uint32(accountIndex), uint32(i)) + address := key.PubKey().Address() + + addresses[i] = address + } + + return addresses +} + +// generateKeyFromSeed generates a private key from +// the provided seed and index +func generateKeyFromSeed(seed []byte, account, index uint32) crypto.PrivKey { + pathParams := hd.NewFundraiserParams(account, crypto.CoinType, index) + + masterPriv, ch := hd.ComputeMastersFromSeed(seed) + derivedPriv, _ := hd.DerivePrivateKeyForPath(masterPriv, ch, pathParams.String()) + + return secp256k1.PrivKeySecp256k1(derivedPriv) +} diff --git a/tm2/pkg/crypto/keys/client/derive_test.go b/tm2/pkg/crypto/keys/client/derive_test.go new file mode 100644 index 00000000000..3ba9d525784 --- /dev/null +++ b/tm2/pkg/crypto/keys/client/derive_test.go @@ -0,0 +1,98 @@ +package client + +import ( + "bytes" + "math" + "testing" + + "github.com/gnolang/gno/tm2/pkg/commands" + "github.com/gnolang/gno/tm2/pkg/crypto/bip39" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_execDerive(t *testing.T) { + t.Parallel() + + t.Run("invalid number of accounts, no accounts requested", func(t *testing.T) { + t.Parallel() + + cfg := &deriveCfg{ + numAccounts: 0, + } + + assert.ErrorIs(t, execDerive(cfg, nil), errInvalidNumAccounts) + }) + + t.Run("invalid number of accounts, > uint32", func(t *testing.T) { + t.Parallel() + + cfg := &deriveCfg{ + numAccounts: math.MaxUint32 + 1, // > uint32 + } + + assert.ErrorIs(t, execDerive(cfg, nil), errInvalidNumAccounts) + }) + + t.Run("invalid account index", func(t *testing.T) { + t.Parallel() + + cfg := &deriveCfg{ + numAccounts: 1, + accountIndex: math.MaxUint32 + 1, // > uint32 + } + + assert.ErrorIs(t, execDerive(cfg, nil), errInvalidAccountIndex) + }) + + t.Run("invalid mnemonic", func(t *testing.T) { + t.Parallel() + + cfg := &deriveCfg{ + numAccounts: 1, + accountIndex: 0, + mnemonic: "one two", + } + + assert.ErrorIs(t, execDerive(cfg, nil), errInvalidMnemonic) + }) + + t.Run("valid accounts generated", func(t *testing.T) { + t.Parallel() + + // Generate a dummy mnemonic + entropy, entropyErr := bip39.NewEntropy(mnemonicEntropySize) + require.NoError(t, entropyErr) + + mnemonic, mnemonicErr := bip39.NewMnemonic(entropy) + require.NoError(t, mnemonicErr) + + cfg := &deriveCfg{ + numAccounts: 1, + accountIndex: 0, + mnemonic: mnemonic, + } + + // Create a test IO so we can capture output + mockOut := bytes.NewBufferString("") + + testIO := commands.NewTestIO() + testIO.SetOut(commands.WriteNopCloser(mockOut)) + + require.NoError(t, execDerive(cfg, testIO)) + + // Grab the output + deriveOutput := mockOut.String() + + // Verify the addresses are derived correctly + expectedAccounts := generateAccounts( + mnemonic, + cfg.accountIndex, + cfg.numAccounts, + ) + + for _, expectedAccount := range expectedAccounts { + assert.Contains(t, deriveOutput, expectedAccount.String()) + } + }) +} diff --git a/tm2/pkg/crypto/keys/client/root.go b/tm2/pkg/crypto/keys/client/root.go index 550dd408b77..d117d39b46c 100644 --- a/tm2/pkg/crypto/keys/client/root.go +++ b/tm2/pkg/crypto/keys/client/root.go @@ -46,6 +46,7 @@ func NewRootCmd(io *commands.IO) *commands.Command { newQueryCmd(cfg, io), newBroadcastCmd(cfg, io), newMakeTxCmd(cfg, io), + newDeriveCmd(io), ) return cmd