From a2db4175285e1e1edfed18133df94a9b83ab615e 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 | 37 ++++++++ src/GWallet.Backend/AccountTypes.fs | 14 ++- src/GWallet.Backend/Config.fs | 2 + .../UtxoCoin/UtxoCoinAccount.fs | 8 ++ src/GWallet.Frontend.Console/Program.fs | 94 +++++++++++++++++++ .../UserInteraction.fs | 4 +- 6 files changed, 156 insertions(+), 3 deletions(-) diff --git a/src/GWallet.Backend/Account.fs b/src/GWallet.Backend/Account.fs index 9fad24398..2023843d0 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,41 @@ module Account = |> ignore Config.RemoveNormalAccount account + let CreateEphemeralAccountFromSeedMenmonic (mnemonic: string) : UtxoCoin.EphemeralUtxoAccount = + let rootKey = Mnemonic(mnemonic).DeriveExtKey().Derive(KeyPath("m/84'/0'/0'")) + 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: EphemeralAccount) (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..1076f179b 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,11 +32,13 @@ type AccountKind = | Normal | ReadOnly | Archived + | Ephemeral static member All() = seq { yield Normal yield ReadOnly yield Archived + yield Ephemeral } type IAccount = @@ -77,3 +81,11 @@ type ArchivedAccount(currency: Currency, accountFile: FileRepresentation, accountFile.Content() override __.Kind = AccountKind.Archived + +/// Inherits from ArchivedAccount because SweepArchivedFunds expects ArchivedAccount instance +/// and sweep funds functionality is needed for this kind of account. +type EphemeralAccount(currency: Currency, accountFile: FileRepresentation, + fromAccountFileToPublicAddress: FileRepresentation -> string) = + inherit ArchivedAccount(currency, accountFile, fromAccountFileToPublicAddress) + + override __.Kind = AccountKind.Ephemeral diff --git a/src/GWallet.Backend/Config.fs b/src/GWallet.Backend/Config.fs index a0acf9d70..ab15ea202 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 -> + Path.Combine(accountConfigDir, "wip") 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..e354f7627 100644 --- a/src/GWallet.Backend/UtxoCoin/UtxoCoinAccount.fs +++ b/src/GWallet.Backend/UtxoCoin/UtxoCoinAccount.fs @@ -52,6 +52,14 @@ type ArchivedUtxoAccount(currency: Currency, accountFile: FileRepresentation, interface IUtxoAccount with member val PublicKey = fromAccountFileToPublicKey accountFile with get +type EphemeralUtxoAccount(currency: Currency, accountFile: FileRepresentation, + fromAccountFileToPublicAddress: FileRepresentation -> string, + fromAccountFileToPublicKey: FileRepresentation -> PubKey) = + inherit GWallet.Backend.EphemeralAccount(currency, accountFile, fromAccountFileToPublicAddress) + + interface IUtxoAccount with + member val PublicKey = fromAccountFileToPublicKey accountFile with get + 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..546b89a85 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,95 @@ 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 + | [] -> failwith "No BTC accounts found" + | _ -> + 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:\n%s" (uri.ToString()) + Console.WriteLine() + printf "Archiving imported account..." + Account.ConvertEphemeralAccountToArchivedAccount importedAccount currency + printfn " done" + UserInteraction.PressAnyKeyToContinue() + | None -> () let WalletOptions(): unit = let rec AskWalletOption(): GenericWalletOption = @@ -355,6 +445,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 +456,7 @@ module Program = | 1u -> GenericWalletOption.TestPaymentPassword | 2u -> GenericWalletOption.TestSeedPassphrase | 3u -> GenericWalletOption.WipeWallet + | 4u -> GenericWalletOption.TransferFundsFromWalletUsingMenmonic | _ -> AskWalletOption() let walletOption = AskWalletOption() @@ -377,6 +469,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)