diff --git a/src/cmd/turnkey/pkg/wallets.go b/src/cmd/turnkey/pkg/wallets.go new file mode 100644 index 0000000..0a8e0cc --- /dev/null +++ b/src/cmd/turnkey/pkg/wallets.go @@ -0,0 +1,350 @@ +package pkg + +import ( + "fmt" + "github.com/rotisserie/eris" + "github.com/spf13/cobra" + + "github.com/tkhq/go-sdk/pkg/api/client/wallets" + "github.com/tkhq/go-sdk/pkg/api/models" + "github.com/tkhq/go-sdk/pkg/util" +) + +var ( + walletNameOrID string + walletAccountAddressFormat string + walletAccountCurve string + walletAccountPathFormat string + walletAccountPath string +) + +func init() { + walletCreateCmd.Flags().StringVar(&walletNameOrID, "name", "", "name to be applied to the wallet") + + walletAccountsListCmd.Flags().StringVar(&walletNameOrID, "wallet", "", "name or ID of wallet used to fetch accounts") + + walletAccountCreateCmd.Flags().StringVar(&walletNameOrID, "wallet", "", "name or ID of wallet used for account creation") + walletAccountCreateCmd.Flags().StringVar(&walletAccountAddressFormat, "address-format", "", "address format for account. For a list of formats, use 'turnkey address-formats list'.") + walletAccountCreateCmd.Flags().StringVar(&walletAccountCurve, "curve", "", "curve for account. For a list of curves, use 'turnkey curves list'. If unset, will predict based on address format.") + walletAccountCreateCmd.Flags().StringVar(&walletAccountPathFormat, "path-format", string(models.PathFormatBip32), "the derivation path format for account.") + walletAccountCreateCmd.Flags().StringVar(&walletAccountPath, "path", "", "the derivation path for account. If unset, will predict next path.") + + walletAccountsCmd.AddCommand(walletAccountsListCmd) + walletAccountsCmd.AddCommand(walletAccountCreateCmd) + walletsCmd.AddCommand(walletCreateCmd) + walletsCmd.AddCommand(walletsListCmd) + walletsCmd.AddCommand(walletAccountsCmd) + + rootCmd.AddCommand(walletsCmd) +} + +var walletsCmd = &cobra.Command{ + Use: "wallets", + Short: "Interact with wallets", + PersistentPreRun: func(cmd *cobra.Command, args []string) { + basicSetup(cmd) + LoadKeypair("") + LoadClient() + }, + Aliases: []string{}, +} + +var walletCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new wallet", + PreRun: func(cmd *cobra.Command, args []string) { + if walletNameOrID == "" { + OutputError(eris.New("name for wallet must be specified")) + } + }, + Run: func(cmd *cobra.Command, args []string) { + activity := string(models.ActivityTypeCreateWallet) + + params := wallets.NewCreateWalletParams() + params.SetBody(&models.CreateWalletRequest{ + OrganizationID: &Organization, + Parameters: &models.CreateWalletIntent{ + WalletName: &walletNameOrID, + Accounts: []*models.WalletAccountParams{}, + }, + TimestampMs: util.RequestTimestamp(), + Type: &activity, + }) + + if err := params.Body.Validate(nil); err != nil { + OutputError(eris.Wrap(err, "request validation failed")) + } + + resp, err := APIClient.V0().Wallets.CreateWallet(params, APIClient.Authenticator) + if err != nil { + OutputError(eris.Wrap(err, "request failed")) + } + + if !resp.IsSuccess() { + OutputError(eris.Errorf("failed to create wallet: %s", resp.Error())) + } + + Output(resp.Payload) + }, +} + +var walletsListCmd = &cobra.Command{ + Use: "list", + Short: "Return wallets for the organization", + Run: func(cmd *cobra.Command, args []string) { + params := wallets.NewGetWalletsParams() + + params.SetBody(&models.GetWalletsRequest{ + OrganizationID: &Organization, + }) + + if err := params.Body.Validate(nil); err != nil { + OutputError(eris.Wrap(err, "request validation failed")) + } + + resp, err := APIClient.V0().Wallets.GetWallets(params, APIClient.Authenticator) + if err != nil { + OutputError(eris.Wrap(err, "request failed")) + } + + if !resp.IsSuccess() { + OutputError(eris.Errorf("failed to list wallets: %s", resp.Error())) + } + + Output(resp.Payload) + }, +} + +var walletAccountsCmd = &cobra.Command{ + Use: "accounts", + Short: "Interact with wallet accounts", + Aliases: []string{"acc"}, +} + +var walletAccountCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new account for a wallet", + PreRun: func(cmd *cobra.Command, args []string) { + if walletNameOrID == "" { + OutputError(eris.New("name or id for wallet must be specified")) + } + + if walletAccountAddressFormat == "" { + OutputError(eris.New("address format cannot be empty")) + } + }, + Run: func(cmd *cobra.Command, args []string) { + wallet, err := lookupWallet(walletNameOrID) + if err != nil { + OutputError(eris.Wrap(err, "failed to lookup wallet")) + } + + addressFormat := models.AddressFormat(walletAccountAddressFormat) + curve := models.Curve(walletAccountCurve) + pathFormat := models.PathFormat(walletAccountPathFormat) + path := walletAccountPath + + // set standard curve, if we can, if no override + if curve == "" { + if standardCurve := getCurveForAddressFormat(addressFormat); standardCurve != nil { + curve = *standardCurve + } + } + + // set standard path, if we can, if no override + if path == "" { + accounts, err := listAccountsForWallet(wallet.WalletID) + if err != nil { + OutputError(eris.Wrap(err, "failed to lookup wallet accounts")) + } + + // build path map to avoid conflicts + paths := make(map[string]struct{}) + for _, account := range accounts { + // we only need to care about accounts w/ this same address format + if *account.AddressFormat != addressFormat { + continue + } + + paths[*account.Path] = struct{}{} + } + + // find the next unused standard path + for i := 0; i < len(paths)+1; i++ { + if standardPath := getStandardPath(pathFormat, addressFormat, i); standardPath != nil { + // we've found an unused path! + if _, ok := paths[*standardPath]; !ok { + path = *standardPath + break + } + } + } + } + + activity := string(models.ActivityTypeCreateWalletAccounts) + + params := wallets.NewCreateWalletAccountsParams() + params.SetBody(&models.CreateWalletAccountsRequest{ + OrganizationID: &Organization, + Parameters: &models.CreateWalletAccountsIntent{ + WalletID: wallet.WalletID, + Accounts: []*models.WalletAccountParams{ + { + AddressFormat: &addressFormat, + Curve: &curve, + PathFormat: &pathFormat, + Path: &path, + }, + }, + }, + TimestampMs: util.RequestTimestamp(), + Type: &activity, + }) + + if err := params.Body.Validate(nil); err != nil { + OutputError(eris.Wrap(err, "request validation failed")) + } + + resp, err := APIClient.V0().Wallets.CreateWalletAccounts(params, APIClient.Authenticator) + if err != nil { + OutputError(eris.Wrap(err, "request failed")) + } + + if !resp.IsSuccess() { + OutputError(eris.Errorf("failed to create wallet account: %s", resp.Error())) + } + + Output(resp.Payload) + }, +} + +var walletAccountsListCmd = &cobra.Command{ + Use: "list", + Short: "Return accounts for the wallet", + PreRun: func(cmd *cobra.Command, args []string) { + if walletNameOrID == "" { + OutputError(eris.New("name or ID for wallet must be specified")) + } + }, + Run: func(cmd *cobra.Command, args []string) { + wallet, err := lookupWallet(walletNameOrID) + if err != nil { + OutputError(eris.Wrap(err, "failed to lookup wallet")) + } + + params := wallets.NewGetWalletAccountsParams() + + params.SetBody(&models.GetWalletAccountsRequest{ + OrganizationID: &Organization, + WalletID: wallet.WalletID, + }) + + if err := params.Body.Validate(nil); err != nil { + OutputError(eris.Wrap(err, "request validation failed")) + } + + resp, err := APIClient.V0().Wallets.GetWalletAccounts(params, APIClient.Authenticator) + if err != nil { + OutputError(eris.Wrap(err, "request failed")) + } + + if !resp.IsSuccess() { + OutputError(eris.Errorf("failed to list wallets: %s", resp.Error())) + } + + Output(resp.Payload) + }, +} + +func lookupWallet(nameOrID string) (*models.Wallet, error) { + params := wallets.NewGetWalletsParams() + + params.SetBody(&models.GetWalletsRequest{ + OrganizationID: &Organization, + }) + + if err := params.Body.Validate(nil); err != nil { + OutputError(eris.Wrap(err, "request validation failed")) + } + + resp, err := APIClient.V0().Wallets.GetWallets(params, APIClient.Authenticator) + if err != nil { + OutputError(eris.Wrap(err, "request failed")) + } + + if !resp.IsSuccess() { + OutputError(eris.Errorf("failed to list wallets: %s", resp.Error())) + } + + for _, wallet := range resp.Payload.Wallets { + if *wallet.WalletName == nameOrID || *wallet.WalletID == nameOrID { + return wallet, nil + } + } + + return nil, eris.Errorf("wallet %q not found in list of wallets", nameOrID) +} + +func listAccountsForWallet(walletID *string) ([]*models.WalletAccount, error) { + params := wallets.NewGetWalletAccountsParams() + + params.SetBody(&models.GetWalletAccountsRequest{ + OrganizationID: &Organization, + WalletID: walletID, + }) + + if err := params.Body.Validate(nil); err != nil { + OutputError(eris.Wrap(err, "request validation failed")) + } + + resp, err := APIClient.V0().Wallets.GetWalletAccounts(params, APIClient.Authenticator) + if err != nil { + OutputError(eris.Wrap(err, "request failed")) + } + + if !resp.IsSuccess() { + OutputError(eris.Errorf("failed to list wallets: %s", resp.Error())) + } + + return resp.Payload.Accounts, nil +} + +func getCurveForAddressFormat(addressFormat models.AddressFormat) *models.Curve { + switch addressFormat { + case models.AddressFormatEthereum, models.AddressFormatCosmos, models.AddressFormatUncompressed: + return models.NewCurve(models.CurveSecp256k1) + case models.AddressFormatSolana: + return models.NewCurve(models.CurveEd25519) + default: + // we're here because either we haven't updated this switch statement after adding new + // address formats OR we've hit an address format that supports multiple curves so we'll + // make no assumptions on the expected curve + return nil + } +} + +func getStandardPath(pathFormat models.PathFormat, addressFormat models.AddressFormat, accountIndex int) *string { + // we currently only support BIP-32 so we'll make no assumptions about the path if given a different path format + if pathFormat != models.PathFormatBip32 { + return nil + } + + var path string + + switch addressFormat { + case models.AddressFormatEthereum: + path = fmt.Sprintf(`m/44'/60'/%d'/0/0`, accountIndex) + case models.AddressFormatCosmos: + path = fmt.Sprintf(`m/44'/118'/%d'/0/0`, accountIndex) + case models.AddressFormatSolana: + path = fmt.Sprintf(`m/44'/501'/%d'/0'`, accountIndex) + case models.AddressFormatUncompressed, models.AddressFormatCompressed: + path = fmt.Sprintf(`m/%d'`, accountIndex) + default: + // we're here because we haven't updated this switch statement after adding new + // address formats so we'll make no assumptions on the expected path + return nil + } + + return &path +}