From 0de13865008e69f7a79c14b46a8ae18e4857a0ba Mon Sep 17 00:00:00 2001 From: webwarrior Date: Tue, 16 Jul 2024 14:41:34 +0200 Subject: [PATCH] Backend,Frontend: import wallet using BIP39 seed Added option to import wallet using BIP39 seed phrase (mnemonic). All funds from imported wallet (currently only first receiving address is used) are sent to BTC account of choice. After that, imported account is converted to archived account so geewallet will warn the user if more funds arrive to it in the future. --- src/GWallet.Backend/Account.fs | 39 ++++++++ src/GWallet.Backend/AccountTypes.fs | 5 +- src/GWallet.Backend/Config.fs | 2 + .../UtxoCoin/UtxoCoinAccount.fs | 9 ++ src/GWallet.Frontend.Console/Program.fs | 98 +++++++++++++++++++ .../UserInteraction.fs | 4 +- 6 files changed, 154 insertions(+), 3 deletions(-) diff --git a/src/GWallet.Backend/Account.fs b/src/GWallet.Backend/Account.fs index 9fad24398..80ebbb60b 100644 --- a/src/GWallet.Backend/Account.fs +++ b/src/GWallet.Backend/Account.fs @@ -7,6 +7,8 @@ open System.Threading.Tasks open GWallet.Backend.FSharpUtil.UwpHacks +open NBitcoin + // this exception, if it happens, it would cause a crash because we don't handle it yet type UnhandledCurrencyServerException(currency: Currency, innerException: Exception) = @@ -394,6 +396,43 @@ module Account = |> ignore Config.RemoveNormalAccount account + let CreateEphemeralAccountFromSeedMenmonic (mnemonic: string) : UtxoCoin.EphemeralUtxoAccount = + let standardBip84DerivationPath = KeyPath("m/84'/0'/0'") + let rootKey = Mnemonic(mnemonic).DeriveExtKey().Derive(standardBip84DerivationPath) + let firstReceivingAddressKey = rootKey.Derive(0u).Derive(0u) + + let currency = Currency.BTC + let network = UtxoCoin.Account.GetNetwork currency + let privateKeyString = + firstReceivingAddressKey.PrivateKey.GetWif(network).ToWif() + + let fromPublicKeyToPublicAddress (publicKey: PubKey) = + publicKey.GetAddress(ScriptPubKeyType.Segwit, network).ToString() + + let fromAccountFileToPrivateKey (accountConfigFile: FileRepresentation) = + Key.Parse(accountConfigFile.Content(), network) + + let fromAccountFileToPublicAddress (accountConfigFile: FileRepresentation) = + fromPublicKeyToPublicAddress(fromAccountFileToPrivateKey(accountConfigFile).PubKey) + + let fromAccountFileToPublicKey (accountConfigFile: FileRepresentation) = + fromAccountFileToPrivateKey(accountConfigFile).PubKey + + let fileName = fromPublicKeyToPublicAddress(firstReceivingAddressKey.GetPublicKey()) + let accountFileRepresentation = { Name = fileName; Content = fun _ -> privateKeyString } + + UtxoCoin.EphemeralUtxoAccount( + currency, + accountFileRepresentation, + fromAccountFileToPublicAddress, + fromAccountFileToPublicKey + ) + + let ConvertEphemeralAccountToArchivedAccount (ephemeralAccount: UtxoCoin.EphemeralUtxoAccount) (currency: Currency) : unit = + // no need for removing account since we don't create any file to begin with (see CreateEphemeralAccountFromSeedMenmonic) + let privateKeyAsString = ephemeralAccount.GetUnencryptedPrivateKey() + CreateArchivedAccount currency privateKeyAsString |> ignore + let SweepArchivedFunds (account: ArchivedAccount) (balance: decimal) (destination: IAccount) diff --git a/src/GWallet.Backend/AccountTypes.fs b/src/GWallet.Backend/AccountTypes.fs index bd2aec6ba..f025930ae 100644 --- a/src/GWallet.Backend/AccountTypes.fs +++ b/src/GWallet.Backend/AccountTypes.fs @@ -2,9 +2,11 @@ namespace GWallet.Backend open System.IO +type UtxoPublicKey = string + type WatchWalletInfo = { - UtxoCoinPublicKey: string + UtxoCoinPublicKey: UtxoPublicKey EtherPublicAddress: string } @@ -30,6 +32,7 @@ type AccountKind = | Normal | ReadOnly | Archived + | Ephemeral static member All() = seq { yield Normal diff --git a/src/GWallet.Backend/Config.fs b/src/GWallet.Backend/Config.fs index a0acf9d70..d04c5c79f 100644 --- a/src/GWallet.Backend/Config.fs +++ b/src/GWallet.Backend/Config.fs @@ -110,6 +110,8 @@ module Config = Path.Combine(accountConfigDir, "readonly") | AccountKind.Archived -> Path.Combine(accountConfigDir, "archived") + | AccountKind.Ephemeral -> + failwith "Ephemeral accounts are not supposed to be stored in file" let configDir = Path.Combine(baseConfigDir, currency.ToString()) |> DirectoryInfo if not configDir.Exists then diff --git a/src/GWallet.Backend/UtxoCoin/UtxoCoinAccount.fs b/src/GWallet.Backend/UtxoCoin/UtxoCoinAccount.fs index ccaba5d12..de0c11c47 100644 --- a/src/GWallet.Backend/UtxoCoin/UtxoCoinAccount.fs +++ b/src/GWallet.Backend/UtxoCoin/UtxoCoinAccount.fs @@ -52,6 +52,15 @@ type ArchivedUtxoAccount(currency: Currency, accountFile: FileRepresentation, interface IUtxoAccount with member val PublicKey = fromAccountFileToPublicKey accountFile with get +/// Inherits from ArchivedUtxoAccount because SweepArchivedFunds expects ArchivedUtxoAccount instance +/// and sweep funds functionality is needed for this kind of account. +type EphemeralUtxoAccount(currency: Currency, accountFile: FileRepresentation, + fromAccountFileToPublicAddress: FileRepresentation -> string, + fromAccountFileToPublicKey: FileRepresentation -> PubKey) = + inherit ArchivedUtxoAccount(currency, accountFile, fromAccountFileToPublicAddress, fromAccountFileToPublicKey) + + override self.Kind = AccountKind.Ephemeral + module Account = let internal GetNetwork (currency: Currency) = diff --git a/src/GWallet.Frontend.Console/Program.fs b/src/GWallet.Frontend.Console/Program.fs index a337961f6..1344e89c4 100644 --- a/src/GWallet.Frontend.Console/Program.fs +++ b/src/GWallet.Frontend.Console/Program.fs @@ -326,6 +326,7 @@ module Program = | TestPaymentPassword | TestSeedPassphrase | WipeWallet + | TransferFundsFromWalletUsingMenmonic let rec TestPaymentPassword () = let password = UserInteraction.AskPassword false @@ -348,6 +349,99 @@ module Program = Account.WipeAll() else () + + let TransferFundsFromWalletUsingMenmonic() = + let rec askForMnemonic() : UtxoCoin.EphemeralUtxoAccount = + Console.WriteLine "Enter mnemonic seed phrase (12, 15, 18, 21 or 24 words):" + let mnemonic = Console.ReadLine() + try + Account.CreateEphemeralAccountFromSeedMenmonic mnemonic + with + | :? FormatException as exn -> + printfn "Error reading mnemonic seed phrase: %s" exn.Message + askForMnemonic() + + let importedAccount = askForMnemonic() + let currency = BTC + + let maybeTotalBalance, maybeUsdValue = UserInteraction.GetAccountBalance importedAccount |> Async.RunSynchronously + match maybeTotalBalance with + | NotFresh _ -> + Console.WriteLine "Could not retrieve balance." + UserInteraction.PressAnyKeyToContinue() + | Fresh 0.0m -> + Console.WriteLine "Balance on imported account is zero. No funds to transfer." + UserInteraction.PressAnyKeyToContinue() + | Fresh balance -> + printfn + "Balance on imported account: %s BTC (%s)" + (balance.ToString()) + (UserInteraction.BalanceInUsdString balance maybeUsdValue) + + let rec chooseAccount() = + Console.WriteLine "Choose account to send funds to:" + Console.WriteLine() + let allAccounts = Account.GetAllActiveAccounts() |> Seq.toList + let btcAccounts = allAccounts |> List.filter (fun acc -> acc.Currency = currency) + + match btcAccounts with + | [ singleAccount ] -> Some singleAccount + | [] -> + printfn "No BTC accounts found." + None + | _ -> + allAccounts |> Seq.iteri (fun i account -> + if account.Currency = currency then + let balance, maybeUsdValue = + UserInteraction.GetAccountBalance account + |> Async.RunSynchronously + UserInteraction.DisplayAccountStatus (i + 1) account balance maybeUsdValue + |> Seq.iter Console.WriteLine + ) + + Console.Write "Write the account number (or 0 to cancel): " + let accountNumber = Console.ReadLine() + match Int32.TryParse accountNumber with + | false, _ -> chooseAccount() + | true, 0 -> None + | true, accountParsed -> + let theAccountChosen = + try + let selectedAccount = allAccounts.[accountParsed - 1] + if selectedAccount.Currency = BTC then + Some selectedAccount + else + chooseAccount() + with + | _ -> chooseAccount() + theAccountChosen + + match chooseAccount() with + | Some targetAccount -> + let destination = targetAccount.PublicAddress + let transferAmount = TransferAmount(balance, balance, currency) // send all funds + let maybeFee = UserInteraction.AskFee importedAccount transferAmount destination + match maybeFee with + | None -> () + | Some fee -> + let txId = + Account.SweepArchivedFunds + importedAccount + balance + targetAccount + fee + false + |> Async.RunSynchronously + let uri = BlockExplorer.GetTransaction currency txId + printfn "Transaction successful:" + printfn "%s" (uri.ToString()) + Console.WriteLine() + printf "Archiving imported account..." + Account.ConvertEphemeralAccountToArchivedAccount importedAccount currency + printfn " done." + UserInteraction.PressAnyKeyToContinue() + | None -> + UserInteraction.PressAnyKeyToContinue() let WalletOptions(): unit = let rec AskWalletOption(): GenericWalletOption = @@ -355,6 +449,7 @@ module Program = Console.WriteLine "1. Check you still remember your payment password" Console.WriteLine "2. Check you still remember your secret recovery phrase" Console.WriteLine "3. Wipe your current wallet, in order to start from scratch" + Console.WriteLine "4. Transfer all funds from another wallet (given mnemonic code)" Console.Write "Choose an option from the ones above: " let optIntroduced = Console.ReadLine () match UInt32.TryParse optIntroduced with @@ -365,6 +460,7 @@ module Program = | 1u -> GenericWalletOption.TestPaymentPassword | 2u -> GenericWalletOption.TestSeedPassphrase | 3u -> GenericWalletOption.WipeWallet + | 4u -> GenericWalletOption.TransferFundsFromWalletUsingMenmonic | _ -> AskWalletOption() let walletOption = AskWalletOption() @@ -377,6 +473,8 @@ module Program = Console.WriteLine "Success!" | GenericWalletOption.WipeWallet -> WipeWallet() + | GenericWalletOption.TransferFundsFromWalletUsingMenmonic -> + TransferFundsFromWalletUsingMenmonic() | _ -> () let rec PerformOperation (numActiveAccounts: uint32) (numHotAccounts: uint32) = diff --git a/src/GWallet.Frontend.Console/UserInteraction.fs b/src/GWallet.Frontend.Console/UserInteraction.fs index c39b6db4a..625f2db1d 100644 --- a/src/GWallet.Frontend.Console/UserInteraction.fs +++ b/src/GWallet.Frontend.Console/UserInteraction.fs @@ -178,7 +178,7 @@ module UserInteraction = password // FIXME: share code between Frontend.Console and Frontend.XF - let private BalanceInUsdString balance maybeUsdValue = + let internal BalanceInUsdString balance maybeUsdValue = match maybeUsdValue with | NotFresh(NotAvailable) -> Presentation.ExchangeRateUnreachableMsg | Fresh(usdValue) -> @@ -260,7 +260,7 @@ module UserInteraction = return (account,balance,usdValue) } - let private GetAccountBalance (account: IAccount): Async*MaybeCached> = + let internal GetAccountBalance (account: IAccount): Async*MaybeCached> = async { let! (_, balance, maybeUsdValue) = GetAccountBalanceInner account false return (balance, maybeUsdValue)