From 214abd2892b71b6fdcff88ace9eff283211e69ec Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Sun, 19 Jul 2026 12:37:18 +0200 Subject: [PATCH 1/5] feat(core): support pure watch-only address import Adds a WalletDocument.WatchAddresses field and WalletLoader.NewFromAddresses to build an ImportedKeyAccount with no private key at all (unlike xpub/WIF imports, which can still derive/hold key material). ScriptKind is inferred from the address itself via a new DerivationPaths.KindFor helper. Signing already refuses to run for any IsWatchOnly account (TransactionFactory), so this only had to wire up construction/reload of the new account shape. --- src/Core/Crypto/DerivationPaths.cs | 13 +++ src/Core/Storage/WalletDocument.cs | 3 + src/Core/Wallet/WalletLoader.cs | 53 +++++++++++- .../Storage/StorageTests.cs | 15 ++++ .../Wallet/TransactionFactoryTests.cs | 27 ++++++ .../Wallet/WalletLoaderTests.cs | 82 +++++++++++++++++++ 6 files changed, 192 insertions(+), 1 deletion(-) diff --git a/src/Core/Crypto/DerivationPaths.cs b/src/Core/Crypto/DerivationPaths.cs index 55d6569..6126032 100644 --- a/src/Core/Crypto/DerivationPaths.cs +++ b/src/Core/Crypto/DerivationPaths.cs @@ -52,6 +52,19 @@ public static class DerivationPaths _ => throw new ArgumentOutOfRangeException(nameof(kind)), }; + /// + /// Best-effort reverse mapping from an already-known address to a ScriptKind, used to + /// label pure address imports (no derivation involved, so this is informational only). + /// + public static ScriptKind KindFor(BitcoinAddress address) => address switch + { + BitcoinWitPubKeyAddress => ScriptKind.NativeSegwit, + TaprootAddress => ScriptKind.Taproot, + BitcoinWitScriptAddress => ScriptKind.NativeSegwitMultisig, + BitcoinScriptAddress => ScriptKind.WrappedSegwit, + _ => ScriptKind.Legacy, + }; + /// /// Account path relative to the root: purpose'/coin'/account' (§4.2). /// coin_type is taken from the profile (746 mainnet, 1 testnet). diff --git a/src/Core/Storage/WalletDocument.cs b/src/Core/Storage/WalletDocument.cs index a9d8d78..9eddc85 100644 --- a/src/Core/Storage/WalletDocument.cs +++ b/src/Core/Storage/WalletDocument.cs @@ -40,6 +40,9 @@ public sealed class WalletDocument /// Imported WIF keys (in plaintext in the document — must be encrypted!). public List? WifKeys { get; set; } + /// Watch-only addresses with no associated private key (pure address import). + public List? WatchAddresses { get; set; } + /// Gap limit for address scanning (§5), configurable. public int GapLimit { get; set; } = 20; diff --git a/src/Core/Wallet/WalletLoader.cs b/src/Core/Wallet/WalletLoader.cs index 5b0690b..e8dc25a 100644 --- a/src/Core/Wallet/WalletLoader.cs +++ b/src/Core/Wallet/WalletLoader.cs @@ -51,7 +51,15 @@ public static class WalletLoader return new ImportedKeyAccount(entries, kind, profile); } - // 4. Watch-only from xpub + // 4. Watch-only imported addresses (no keys, no HD derivation) + if (doc.WatchAddresses is { Count: > 0 } watchAddresses) + { + var entries = watchAddresses.Select(a => + (BitcoinAddress.Create(a.Trim(), network), (Key?)null)).ToList(); + return new ImportedKeyAccount(entries, kind, profile); + } + + // 5. Watch-only from xpub if (doc.AccountXpub is null) throw new InvalidDataException("Wallet file has no xpub and no seed."); if (!Slip132.TryDecodePublic(doc.AccountXpub, profile, out var xpub, out _)) @@ -170,4 +178,47 @@ public static class WalletLoader }; return (doc, account); } + + /// + /// Creates the document from one or more plain addresses, with no private key at all + /// (pure watch-only import — the account can never sign, unlike xpub- or WIF-based accounts + /// which can be upgraded later by supplying the matching key). ScriptKind is informational + /// only here (no derivation happens from a fixed address list) and is auto-detected from the + /// first address unless is given. + /// + public static (WalletDocument Doc, ImportedKeyAccount Account) NewFromAddresses( + IReadOnlyList addresses, ChainProfile profile, ScriptKind? kindOverride = null) + { + if (addresses.Count == 0) + throw new InvalidDataException("At least one address is required."); + + var network = PalladiumNetworks.For(profile.Kind); + var entries = new List<(BitcoinAddress, Key?)>(); + var addressStrings = new List(); + + foreach (var raw in addresses) + { + BitcoinAddress addr; + try + { + addr = BitcoinAddress.Create(raw.Trim(), network); + } + catch (Exception ex) + { + throw new InvalidDataException($"Invalid address: {ex.Message}"); + } + entries.Add((addr, null)); + addressStrings.Add(raw.Trim()); + } + + var kind = kindOverride ?? DerivationPaths.KindFor(entries[0].Item1); + var account = new ImportedKeyAccount(entries, kind, profile); + var doc = new WalletDocument + { + Network = profile.NetName, + ScriptKind = kind.ToString(), + WatchAddresses = addressStrings, + }; + return (doc, account); + } } diff --git a/tests/PalladiumWallet.Tests/Storage/StorageTests.cs b/tests/PalladiumWallet.Tests/Storage/StorageTests.cs index fe44106..0520c81 100644 --- a/tests/PalladiumWallet.Tests/Storage/StorageTests.cs +++ b/tests/PalladiumWallet.Tests/Storage/StorageTests.cs @@ -148,6 +148,21 @@ public class StorageTests Assert.True(WalletDocument.FromJson(doc.ToJson()).IsWatchOnly); } + [Fact] + public void WatchAddresses_fa_roundtrip_json_ed_e_watch_only() + { + var doc = new WalletDocument + { + Network = "mainnet", + ScriptKind = "NativeSegwit", + WatchAddresses = ["bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"], + }; + var restored = WalletDocument.FromJson(doc.ToJson()); + + Assert.Equal(doc.WatchAddresses, restored.WatchAddresses); + Assert.True(restored.IsWatchOnly); + } + [Fact] public void Json_corrotto_lancia_eccezione() { diff --git a/tests/PalladiumWallet.Tests/Wallet/TransactionFactoryTests.cs b/tests/PalladiumWallet.Tests/Wallet/TransactionFactoryTests.cs index fa13471..3cc91ce 100644 --- a/tests/PalladiumWallet.Tests/Wallet/TransactionFactoryTests.cs +++ b/tests/PalladiumWallet.Tests/Wallet/TransactionFactoryTests.cs @@ -250,6 +250,33 @@ public class TransactionFactoryTests Assert.Contains(tx.Outputs, o => o.Value.Satoshi == 400_000); } + [Fact] + public void Un_account_di_soli_indirizzi_watch_only_produce_una_psbt_non_firmata() + { + var full = Account(); + var (utxos, txs) = Fund(full, 1_000_000); + + // Pure address import: no private key at all (unlike xpub watch-only, which + // can still derive public keys/scriptPubKeys — this account can't upgrade + // to spendable without the user separately importing the matching key). + var watchOnly = new ImportedKeyAccount( + [(full.GetReceiveAddress(0), (Key?)null)], ScriptKind.NativeSegwit, Profile); + + var built = new TransactionFactory(watchOnly).Build( + utxos, txs, full.GetReceiveAddress(5), amountSats: 400_000, + feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100); + + Assert.False(built.Signed); + Assert.True(watchOnly.IsWatchOnly); + Assert.Null(watchOnly.GetPrivateKey(false, 0)); + + var psbt = built.Psbt; + psbt.SignWithKeys(full.GetExtPrivateKey(false, 0)); + psbt.Finalize(); + var tx = psbt.ExtractTransaction(); + Assert.Contains(tx.Outputs, o => o.Value.Satoshi == 400_000); + } + [Theory] [InlineData("0.00000001", 1L)] [InlineData("1", 100_000_000L)] diff --git a/tests/PalladiumWallet.Tests/Wallet/WalletLoaderTests.cs b/tests/PalladiumWallet.Tests/Wallet/WalletLoaderTests.cs index 094e933..92846dd 100644 --- a/tests/PalladiumWallet.Tests/Wallet/WalletLoaderTests.cs +++ b/tests/PalladiumWallet.Tests/Wallet/WalletLoaderTests.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using PalladiumWallet.Core.Chain; using PalladiumWallet.Core.Crypto; using PalladiumWallet.Core.Storage; @@ -249,4 +250,85 @@ public class WalletLoaderTests Assert.Throws( () => WalletLoader.NewFromWif([], ScriptKind.NativeSegwit, ChainProfiles.Mainnet)); } + + // ---- NewFromAddresses (pure watch-only, no key at all) ---- + + private static string SampleAddress(ScriptKind kind = ScriptKind.NativeSegwit) => + WalletLoader.NewFromMnemonic(ValidMnemonic, null, kind, ChainProfiles.Mainnet) + .Account.GetReceiveAddress(0).ToString(); + + [Fact] + public void NewFromAddresses_crea_documento_watch_only_senza_chiavi() + { + var address = SampleAddress(); + var (doc, account) = WalletLoader.NewFromAddresses([address], ChainProfiles.Mainnet); + + Assert.Equal("mainnet", doc.Network); + Assert.Null(doc.Mnemonic); + Assert.Null(doc.AccountXprv); + Assert.Null(doc.AccountXpub); + Assert.Null(doc.WifKeys); + Assert.Equal([address], doc.WatchAddresses); + Assert.True(doc.IsWatchOnly); + Assert.True(account.IsWatchOnly); + Assert.Null(account.GetPrivateKey(false, 0)); + Assert.Equal(address, account.GetReceiveAddress(0).ToString()); + } + + [Fact] + public void NewFromAddresses_rileva_lo_scriptkind_dallindirizzo() + { + var legacyAddr = SampleAddress(ScriptKind.Legacy); + var (doc, _) = WalletLoader.NewFromAddresses([legacyAddr], ChainProfiles.Mainnet); + Assert.Equal("Legacy", doc.ScriptKind); + } + + [Fact] + public void NewFromAddresses_piu_indirizzi_sono_tutti_scansionabili() + { + var addr1 = SampleAddress(); + var addr2 = WalletLoader.NewFromMnemonic(ValidMnemonic24, null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet) + .Account.GetReceiveAddress(0).ToString(); + var (_, account) = WalletLoader.NewFromAddresses([addr1, addr2], ChainProfiles.Mainnet); + + Assert.NotNull(account.FixedAddresses); + Assert.Equal(2, account.FixedAddresses!.Count); + Assert.True(account.FixedAddresses!.All(e => account.GetPrivateKey(e.IsChange, e.Index) is null)); + } + + [Fact] + public void NewFromAddresses_lista_vuota_lancia_eccezione() + { + Assert.Throws( + () => WalletLoader.NewFromAddresses([], ChainProfiles.Mainnet)); + } + + [Fact] + public void NewFromAddresses_indirizzo_invalido_lancia_eccezione() + { + Assert.Throws( + () => WalletLoader.NewFromAddresses(["not-an-address"], ChainProfiles.Mainnet)); + } + + [Fact] + public void NewFromAddresses_indirizzo_di_rete_sbagliata_lancia_eccezione() + { + var testnetAddr = WalletLoader.NewFromMnemonic(ValidMnemonic, null, ScriptKind.NativeSegwit, ChainProfiles.Testnet) + .Account.GetReceiveAddress(0).ToString(); + Assert.Throws( + () => WalletLoader.NewFromAddresses([testnetAddr], ChainProfiles.Mainnet)); + } + + [Fact] + public void ToAccount_da_watch_addresses_ricostruisce_lo_stesso_account() + { + var address = SampleAddress(); + var (doc, _) = WalletLoader.NewFromAddresses([address], ChainProfiles.Mainnet); + + var account = WalletLoader.ToAccount(doc); + + Assert.True(account.IsWatchOnly); + Assert.Equal(address, account.GetReceiveAddress(0).ToString()); + Assert.Null(account.GetPrivateKey(false, 0)); + } } From 9fa5440ae5cf845dc0bd3a396d4ecbf467abf0f2 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Sun, 19 Jul 2026 12:37:47 +0200 Subject: [PATCH 2/5] feat(cli): add restore-address command for pure address watch-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parallels restore-xpub but for one or more plain addresses with no extended key and no private key — output is explicitly labelled "cannot sign" since the resulting wallet can never produce a signed tx. --- src/Cli/Program.cs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/Cli/Program.cs b/src/Cli/Program.cs index bb29161..4ffe89c 100644 --- a/src/Cli/Program.cs +++ b/src/Cli/Program.cs @@ -18,6 +18,7 @@ try ["create", .. var rest] => Create(rest), ["restore", var words, .. var rest] => Restore(words, rest), ["restore-xpub", var xpub, .. var rest] => RestoreXpub(xpub, rest), + ["restore-address", var addrs, .. var rest] => RestoreAddress(addrs, rest), ["info", .. var rest] => Info(rest), ["sync", .. var rest] => await Sync(rest), ["send", .. var rest] => await Send(rest), @@ -95,6 +96,31 @@ static int RestoreXpub(string xpubText, string[] o) return 0; } +static int RestoreAddress(string addrsText, string[] o) +{ + var profile = Profile(o); + var addresses = addrsText.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (addresses.Length == 0) + { + Console.Error.WriteLine("At least one address is required."); + return 1; + } + WalletDocument doc; + try + { + (doc, _) = WalletLoader.NewFromAddresses(addresses, profile); + } + catch (InvalidDataException ex) + { + Console.Error.WriteLine(ex.Message); + return 1; + } + var path = WalletPath(o, profile); + WalletStore.Save(doc, path, Opt(o, "--password")); + Console.WriteLine($"Watch-only wallet saved to {path} ({addresses.Length} address(es), cannot sign)"); + return 0; +} + static int Info(string[] o) { var (doc, account, path) = OpenWallet(o); @@ -352,6 +378,7 @@ static int Usage() [--passphrase W] [--password P] [--file PATH] restore "" [same options as create] [--path m/...] restore-xpub [--net ...] [--password P] [--file PATH] (watch-only) + restore-address [--net ...] [--password P] [--file PATH] (watch-only, no keys) info [--net ...] [--password P] [--file PATH] Network (indexing server; without --server the first known server is used): From f0fb5bfcc6ae3ee7bcaacaedce434b6b5de3062a Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Sun, 19 Jul 2026 12:38:54 +0200 Subject: [PATCH 3/5] feat(app): add watch-address import step to setup wizard New wizard flow mirroring the existing xpub/WIF import steps: paste one or more plain addresses (no key material at all) and go straight to the password step, since there is no derivation and no script-type ambiguity to resolve for a fixed address list. --- src/App/Localization/Loc.cs | 13 ++++ .../ViewModels/MainWindowViewModel.Wizard.cs | 74 +++++++++++++++++-- src/App/Views/MainView.axaml | 16 ++++ 3 files changed, 95 insertions(+), 8 deletions(-) diff --git a/src/App/Localization/Loc.cs b/src/App/Localization/Loc.cs index b970291..db10f49 100644 --- a/src/App/Localization/Loc.cs +++ b/src/App/Localization/Loc.cs @@ -102,6 +102,7 @@ public sealed class Loc ["wiz.restore.btn"] = ["Ripristina da seed", "Restore from seed", "Restaurar desde semilla", "Restaurer depuis la graine", "Restaurar da semente", "Aus Seed wiederherstellen", "从种子恢复"], ["wiz.importxkey.btn"] = ["Importa xpub / xprv", "Import xpub / xprv", "Importar xpub / xprv", "Importer xpub / xprv", "Importar xpub / xprv", "xpub / xprv importieren", "导入 xpub / xprv"], ["wiz.importwif.btn"] = ["Importa chiave WIF", "Import WIF key", "Importar clave WIF", "Importer clé WIF", "Importar chave WIF", "WIF-Schlüssel importieren", "导入 WIF 密钥"], + ["wiz.importaddress.btn"] = ["Importa indirizzo (sola lettura)", "Import address (watch-only)", "Importar dirección (solo lectura)", "Importer une adresse (lecture seule)", "Importar endereço (somente leitura)", "Adresse importieren (nur lesend)", "导入地址(仅观察)"], ["wiz.open.title"] = ["Apri il wallet", "Open the wallet", "Abrir el wallet", "Ouvrir le wallet", "Abrir a carteira", "Wallet öffnen", "打开钱包"], ["wiz.open.placeholder"] = ["Password del file (vuoto se non impostata)", "File password (empty if not set)", "Contraseña del archivo (vacío si no establecida)", "Mot de passe du fichier (vide si non défini)", "Senha do arquivo (vazio se não definida)", "Dateipasswort (leer lassen, wenn nicht gesetzt)", "文件密码(未设置则留空)"], ["wiz.open.ok"] = ["Apri", "Open", "Abrir", "Ouvrir", "Abrir", "Öffnen", "打开"], @@ -172,12 +173,24 @@ public sealed class Loc "Fügen Sie einen oder mehrere WIF-Schlüssel ein (einer pro Zeile). Sie können mehrere Schlüssel importieren, um mehrere Adressen mit demselben Wallet zu verwalten.", "粘贴一个或多个 WIF 密钥(每行一个)。您可以导入多个密钥,用同一个钱包控制多个地址。"], ["wiz.importwif.placeholder"] = ["K… / L… / 5… (una chiave per riga)", "K… / L… / 5… (one key per line)", "K… / L… / 5… (una clave por línea)", "K… / L… / 5… (une clé par ligne)", "K… / L… / 5… (uma chave por linha)", "K… / L… / 5… (ein Schlüssel pro Zeile)", "K… / L… / 5…(每行一个密钥)"], + ["wiz.importaddress.title"] = ["Importa indirizzi in sola lettura", "Import watch-only addresses", "Importar direcciones de solo lectura", "Importer des adresses en lecture seule", "Importar endereços somente leitura", "Nur-Lese-Adressen importieren", "导入仅观察地址"], + ["wiz.importaddress.hint"] = [ + "Incolla uno o più indirizzi (uno per riga). Nessuna chiave privata è coinvolta: potrai vedere saldo e cronologia ma non potrai mai firmare o inviare transazioni da questo wallet.", + "Paste one or more addresses (one per line). No private key is involved: you'll be able to see the balance and history but you can never sign or send transactions from this wallet.", + "Pega una o más direcciones (una por línea). No hay ninguna clave privada involucrada: podrás ver el saldo y el historial, pero nunca podrás firmar ni enviar transacciones desde este wallet.", + "Collez une ou plusieurs adresses (une par ligne). Aucune clé privée n'est impliquée : vous pourrez voir le solde et l'historique, mais vous ne pourrez jamais signer ni envoyer de transactions depuis ce wallet.", + "Cole um ou mais endereços (um por linha). Nenhuma chave privada está envolvida: você poderá ver o saldo e o histórico, mas nunca poderá assinar ou enviar transações a partir desta carteira.", + "Fügen Sie eine oder mehrere Adressen ein (eine pro Zeile). Es ist kein privater Schlüssel beteiligt: Sie können den Kontostand und den Verlauf sehen, aber nie Transaktionen aus diesem Wallet signieren oder senden.", + "粘贴一个或多个地址(每行一个)。不涉及任何私钥:您可以查看余额和历史记录,但永远无法从此钱包签名或发送交易。"], + ["wiz.importaddress.placeholder"] = ["Indirizzo… (uno per riga)", "Address… (one per line)", "Dirección… (una por línea)", "Adresse… (une par ligne)", "Endereço… (um por linha)", "Adresse… (eine pro Zeile)", "地址…(每行一个)"], // Import error messages ["msg.xkey.required"] = ["Incolla una chiave estesa (xpub/xprv o variante).", "Paste an extended key (xpub/xprv or variant).", "Pega una clave extendida (xpub/xprv o variante).", "Collez une clé étendue (xpub/xprv ou variante).", "Cole uma chave estendida (xpub/xprv ou variante).", "Fügen Sie einen erweiterten Schlüssel ein (xpub/xprv oder Variante).", "请粘贴扩展密钥(xpub/xprv 或其变体)。"], ["msg.xkey.invalid"] = ["Chiave estesa non riconosciuta per questa rete.", "Extended key not recognised for this network.", "Clave extendida no reconocida para esta red.", "Clé étendue non reconnue pour ce réseau.", "Chave estendida não reconhecida para esta rede.", "Erweiterter Schlüssel für dieses Netzwerk nicht erkannt.", "此网络无法识别该扩展密钥。"], ["msg.wif.required"] = ["Incolla almeno una chiave WIF.", "Paste at least one WIF key.", "Pega al menos una clave WIF.", "Collez au moins une clé WIF.", "Cole pelo menos uma chave WIF.", "Fügen Sie mindestens einen WIF-Schlüssel ein.", "请至少粘贴一个 WIF 密钥。"], ["msg.wif.invalid"] = ["Chiave WIF non valida per questa rete.", "WIF key not valid for this network.", "Clave WIF no válida para esta red.", "Clé WIF invalide pour ce réseau.", "Chave WIF inválida para esta rede.", "WIF-Schlüssel für dieses Netzwerk ungültig.", "该 WIF 密钥对此网络无效。"], + ["msg.address.required"] = ["Incolla almeno un indirizzo.", "Paste at least one address.", "Pega al menos una dirección.", "Collez au moins une adresse.", "Cole pelo menos um endereço.", "Fügen Sie mindestens eine Adresse ein.", "请至少粘贴一个地址。"], + ["msg.address.invalid"] = ["Indirizzo non valido per questa rete.", "Address not valid for this network.", "Dirección no válida para esta red.", "Adresse invalide pour ce réseau.", "Endereço inválido para esta rede.", "Adresse für dieses Netzwerk ungültig.", "该地址对此网络无效。"], // Wallet panel ["wallet.close"] = ["Chiudi wallet", "Close wallet", "Cerrar wallet", "Fermer le wallet", "Fechar carteira", "Wallet schließen", "关闭钱包"], diff --git a/src/App/ViewModels/MainWindowViewModel.Wizard.cs b/src/App/ViewModels/MainWindowViewModel.Wizard.cs index 927c757..acbff27 100644 --- a/src/App/ViewModels/MainWindowViewModel.Wizard.cs +++ b/src/App/ViewModels/MainWindowViewModel.Wizard.cs @@ -27,9 +27,10 @@ public partial class MainWindowViewModel public const string StepScriptType = "script-type"; public const string StepImportXkey = "import-xkey"; public const string StepImportWif = "import-wif"; + public const string StepImportAddress = "import-address"; public const string StepPassword = "password"; - private enum WizardFlowKind { New, Restore, ImportXkey, ImportWif } + private enum WizardFlowKind { New, Restore, ImportXkey, ImportWif, ImportAddress } private WizardFlowKind _wizardFlow; [ObservableProperty] @@ -44,6 +45,7 @@ public partial class MainWindowViewModel [NotifyPropertyChangedFor(nameof(IsStepScriptType))] [NotifyPropertyChangedFor(nameof(IsStepImportXkey))] [NotifyPropertyChangedFor(nameof(IsStepImportWif))] + [NotifyPropertyChangedFor(nameof(IsStepImportAddress))] [NotifyPropertyChangedFor(nameof(IsStepPassword))] private string setupStep = StepStart; @@ -56,9 +58,10 @@ public partial class MainWindowViewModel public bool IsStepWords => SetupStep == StepWords; public bool IsStepPassphrase => SetupStep == StepPassphrase; public bool IsStepScriptType => SetupStep == StepScriptType; - public bool IsStepImportXkey => SetupStep == StepImportXkey; - public bool IsStepImportWif => SetupStep == StepImportWif; - public bool IsStepPassword => SetupStep == StepPassword; + public bool IsStepImportXkey => SetupStep == StepImportXkey; + public bool IsStepImportWif => SetupStep == StepImportWif; + public bool IsStepImportAddress => SetupStep == StepImportAddress; + public bool IsStepPassword => SetupStep == StepPassword; [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsLegacySelected))] @@ -85,6 +88,9 @@ public partial class MainWindowViewModel [ObservableProperty] private string importWifInput = ""; + [ObservableProperty] + private string importAddressInput = ""; + // Script type detected during xkey decoding (to display to the user) [ObservableProperty] private string importXkeyDetectedKind = ""; @@ -214,6 +220,15 @@ public partial class MainWindowViewModel StatusMessage = ""; } + [RelayCommand] + private void WizardStartImportAddress() + { + _wizardFlow = WizardFlowKind.ImportAddress; + ImportAddressInput = ""; + SetupStep = StepImportAddress; + StatusMessage = ""; + } + [RelayCommand] private void WizardNextFromShowSeed() { @@ -312,6 +327,35 @@ public partial class MainWindowViewModel StatusMessage = ""; } + [RelayCommand] + private void WizardNextFromImportAddress() + { + var addresses = ImportAddressInput.Split('\n', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (addresses.Length == 0) + { + StatusMessage = Loc.Tr("msg.address.required"); + return; + } + var network = PalladiumNetworks.For(Net); + foreach (var a in addresses) + { + try + { + _ = NBitcoin.BitcoinAddress.Create(a, network); + } + catch + { + StatusMessage = Loc.Tr("msg.address.invalid"); + return; + } + } + PasswordInput = ConfirmPasswordInput = ""; + EncryptWallet = true; + SetupStep = StepPassword; + StatusMessage = ""; + } + [RelayCommand] private void WizardNextFromScriptType() { @@ -327,11 +371,16 @@ public partial class MainWindowViewModel SetupStep = SetupStep switch { StepOpen => WalletList.Count > 1 ? StepChooseWallet : StepStart, - StepChooseWallet or StepShowSeed or StepWords or StepImportXkey or StepImportWif => StepStart, + StepChooseWallet or StepShowSeed or StepWords or StepImportXkey or StepImportWif or StepImportAddress => StepStart, StepConfirmSeed => StepShowSeed, StepPassphrase => _wizardFlow == WizardFlowKind.Restore ? StepWords : StepConfirmSeed, StepScriptType => _wizardFlow == WizardFlowKind.ImportWif ? StepImportWif : StepPassphrase, - StepPassword => _wizardFlow == WizardFlowKind.ImportXkey ? StepImportXkey : StepScriptType, + StepPassword => _wizardFlow switch + { + WizardFlowKind.ImportXkey => StepImportXkey, + WizardFlowKind.ImportAddress => StepImportAddress, + _ => StepScriptType, + }, _ => StepStart, }; if (SetupStep == StepStart) @@ -391,6 +440,14 @@ public partial class MainWindowViewModel (doc, account) = (d, a); break; } + case WizardFlowKind.ImportAddress: + { + var addressLines = ImportAddressInput.Split('\n', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var (d, a) = WalletLoader.NewFromAddresses(addressLines, Profile); + (doc, account) = (d, a); + break; + } default: { var (d, a) = WalletLoader.NewFromMnemonic( @@ -497,13 +554,14 @@ public partial class MainWindowViewModel _walletPath = path; _password = password; MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = ConfirmPasswordInput = WalletNameInput = ""; - ImportXkeyInput = ImportWifInput = ImportXkeyDetectedKind = ""; + ImportXkeyInput = ImportWifInput = ImportAddressInput = ImportXkeyDetectedKind = ""; SetupStep = StepStart; + OnPropertyChanged(nameof(IsWatchOnlyAccount)); var walletKindTag = account switch { - ImportedKeyAccount => " · imported", { IsWatchOnly: true } => " · watch-only", + ImportedKeyAccount => " · imported", _ => "" }; var pathTag = !string.IsNullOrEmpty(doc.AccountPath) ? $" · m/{doc.AccountPath}" : ""; diff --git a/src/App/Views/MainView.axaml b/src/App/Views/MainView.axaml index 9a0cada..596faee 100644 --- a/src/App/Views/MainView.axaml +++ b/src/App/Views/MainView.axaml @@ -99,6 +99,9 @@