From eca4d4ac0cb7a9901a60fa4a7db090511c542b35 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 3 Nov 2026 14:15:03 +0100 Subject: [PATCH] fix(app): validate contact addresses when saving Adding a contact now checks the address against the wallet's network and rejects duplicates; whitespace in a pasted address is removed. Before, any string was stored and the error only surfaced at send time. --- CHANGELOG.md | 4 + USERGUIDE.md | 8 +- src/App/Localization/Loc.cs | 16 ++++ .../MainWindowViewModel.Contacts.cs | 20 ++++- .../App/MainWindowViewModelTests.cs | 74 ++++++++++++++++++- 5 files changed, 117 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd76b7c..4a0b855 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ release and covers the full history from the initial commit. ### Fixed +- Adding a contact now validates the address for the wallet's network and rejects duplicates + (case-insensitive); whitespace and line breaks in a pasted address are removed. Before, any + string was saved and the error only appeared at send time. Contacts already stored with an + invalid address still load and display. - The wallet now verifies the chain identity of a server right after connecting: the `genesis_hash` from `server.features` must match the profile and, on mainnet (which shares Bitcoin's genesis), the header at the highest checkpoint not above the server's tip must match diff --git a/USERGUIDE.md b/USERGUIDE.md index 148268c..bb9b303 100644 --- a/USERGUIDE.md +++ b/USERGUIDE.md @@ -508,9 +508,11 @@ them is normal. A simple per-wallet address book, on its own tab: -- **Add**: name + address, both required (whitespace is trimmed). Note: the address format - is **not validated when saving** — it is only validated when you actually send to it. Copy - addresses carefully. +- **Add**: name + address, both required. The address is **validated when saving** against + the wallet's network: an invalid or wrong-network address, or one already in the list, is + refused with a message. Spaces and line breaks in a pasted address are removed. Contacts + saved by an older version without validation are still shown; the send form validates the + destination again when you use them. - **Remove selected**: deletes the highlighted contact. There is no edit function — remove and re-add. - Contacts are used by the **From contacts** dropdown on the Send tab. diff --git a/src/App/Localization/Loc.cs b/src/App/Localization/Loc.cs index 8d87769..3411dcc 100644 --- a/src/App/Localization/Loc.cs +++ b/src/App/Localization/Loc.cs @@ -477,6 +477,22 @@ public sealed class Loc "服务器 {0} 不在 Palladium 链上(创世块或检查点不同)。请选择其他服务器。"], // Contacts + ["msg.contact.address.invalid"] = [ + "Indirizzo non valido per questa rete: il contatto non è stato salvato.", + "Invalid address for this network: the contact was not saved.", + "Dirección no válida para esta red: el contacto no se guardó.", + "Adresse invalide pour ce réseau : le contact n'a pas été enregistré.", + "Endereço inválido para esta rede: o contato não foi salvo.", + "Ungültige Adresse für dieses Netzwerk: Der Kontakt wurde nicht gespeichert.", + "该网络的地址无效:联系人未保存。"], + ["msg.contact.duplicate"] = [ + "Esiste già un contatto con questo indirizzo.", + "A contact with this address already exists.", + "Ya existe un contacto con esta dirección.", + "Un contact avec cette adresse existe déjà.", + "Já existe um contato com este endereço.", + "Ein Kontakt mit dieser Adresse existiert bereits.", + "已存在相同地址的联系人。"], ["contacts.name"] = ["Nome", "Name", "Nombre", "Nom", "Nome", "Name", "姓名"], ["contacts.address"] = ["Indirizzo", "Address", "Dirección", "Adresse", "Endereço", "Adresse", "地址"], ["contacts.name.ph"] = ["Nome contatto", "Contact name", "Nombre del contacto", "Nom du contact", "Nome do contato", "Kontaktname", "联系人姓名"], diff --git a/src/App/ViewModels/MainWindowViewModel.Contacts.cs b/src/App/ViewModels/MainWindowViewModel.Contacts.cs index 0c4b0ae..7ef13c3 100644 --- a/src/App/ViewModels/MainWindowViewModel.Contacts.cs +++ b/src/App/ViewModels/MainWindowViewModel.Contacts.cs @@ -2,6 +2,9 @@ using System.Collections.ObjectModel; using System.Linq; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using NBitcoin; +using PalladiumWallet.App.Localization; +using PalladiumWallet.Core.Chain; using PalladiumWallet.Core.Storage; namespace PalladiumWallet.App.ViewModels; @@ -33,8 +36,23 @@ public partial class MainWindowViewModel private void AddContact() { var name = NewContactName.Trim(); - var addr = NewContactAddress.Trim(); + // Pasted addresses often carry spaces or line breaks anywhere: drop all whitespace. + var addr = string.Concat(NewContactAddress.Where(c => !char.IsWhiteSpace(c))); if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(addr)) return; + try + { + BitcoinAddress.Create(addr, PalladiumNetworks.For(Net)); + } + catch (System.FormatException) + { + StatusMessage = Loc.Tr("msg.contact.address.invalid"); + return; + } + if (Contacts.Any(c => string.Equals(c.Address, addr, System.StringComparison.OrdinalIgnoreCase))) + { + StatusMessage = Loc.Tr("msg.contact.duplicate"); + return; + } Contacts.Add(new ContactEntry(name, addr)); NewContactName = NewContactAddress = ""; PersistContacts(); diff --git a/tests/PalladiumWallet.Tests/App/MainWindowViewModelTests.cs b/tests/PalladiumWallet.Tests/App/MainWindowViewModelTests.cs index 461c324..62d81db 100644 --- a/tests/PalladiumWallet.Tests/App/MainWindowViewModelTests.cs +++ b/tests/PalladiumWallet.Tests/App/MainWindowViewModelTests.cs @@ -291,7 +291,8 @@ public sealed class MainWindowViewModelTests : VmTestBase var vm = new MainWindowViewModel(); RestoreFlow(vm, Mnemonic, null, "c"); - vm.NewContactName = "Alice"; vm.NewContactAddress = "plm1qexample"; + var alice = Address(1); + vm.NewContactName = "Alice"; vm.NewContactAddress = alice; vm.AddContactCommand.Execute(null); vm.NewContactName = ""; vm.NewContactAddress = "ignored"; vm.AddContactCommand.Execute(null); // blank name → ignored @@ -306,6 +307,77 @@ public sealed class MainWindowViewModelTests : VmTestBase Assert.Empty(WalletStore.Load(Assert.Single(AppPaths.WalletFiles(NetKind.Mainnet))).Contacts); }); + private static string Address(int index) => + HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet) + .GetReceiveAddress(index).ToString(); + + [Theory] + [InlineData("plm1qexample")] // garbage + [InlineData("not an address")] + [InlineData("tplm1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx")] // wrong network / bad checksum + public Task Contacts_RejectInvalidAddresses(string bad) => UiSession.Run(() => + { + var vm = new MainWindowViewModel(); + RestoreFlow(vm, Mnemonic, null, "c1"); + + vm.NewContactName = "Bob"; vm.NewContactAddress = bad; + vm.AddContactCommand.Execute(null); + + Assert.Empty(vm.Contacts); + Assert.Equal(Loc.Tr("msg.contact.address.invalid"), vm.StatusMessage); + Assert.Empty(WalletStore.Load(Assert.Single(AppPaths.WalletFiles(NetKind.Mainnet))).Contacts); + }); + + [Fact] + public Task Contacts_RejectAnAddressFromAnotherNetwork() => UiSession.Run(() => + { + var vm = new MainWindowViewModel(); + RestoreFlow(vm, Mnemonic, null, "c2"); + var regtest = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Regtest) + .GetReceiveAddress(0).ToString(); + + vm.NewContactName = "Eve"; vm.NewContactAddress = regtest; + vm.AddContactCommand.Execute(null); + + Assert.Empty(vm.Contacts); + Assert.Equal(Loc.Tr("msg.contact.address.invalid"), vm.StatusMessage); + }); + + [Fact] + public Task Contacts_RejectDuplicatesAndCleanPastedWhitespace() => UiSession.Run(() => + { + var vm = new MainWindowViewModel(); + RestoreFlow(vm, Mnemonic, null, "c3"); + var a = Address(2); + + vm.NewContactName = " Carol "; vm.NewContactAddress = $" {a[..10]}\n{a[10..]}\r\n"; + vm.AddContactCommand.Execute(null); + Assert.Equal(new ContactEntry("Carol", a), Assert.Single(vm.Contacts)); + + vm.NewContactName = "Carol again"; vm.NewContactAddress = a.ToUpperInvariant(); + vm.AddContactCommand.Execute(null); + Assert.Single(vm.Contacts); + Assert.Equal(Loc.Tr("msg.contact.duplicate"), vm.StatusMessage); + }); + + [Fact] + public Task Contacts_AlreadyStoredWithAnInvalidAddress_StillLoadAndDisplay() => UiSession.Run(() => + { + var vm = new MainWindowViewModel(); + RestoreFlow(vm, Mnemonic, null, "c4"); + var path = Assert.Single(AppPaths.WalletFiles(NetKind.Mainnet)); + var doc = WalletStore.Load(path); + doc.Contacts = [new StoredContact { Name = "Legacy", Address = "whatever" }]; + WalletStore.Save(doc, path, null); + vm.CloseWalletCommand.Execute(null); + + vm.WizardStartOpenCommand.Execute(null); + vm.OpenExistingCommand.Execute(null); + + Assert.True(vm.IsWalletOpen); + Assert.Equal(new ContactEntry("Legacy", "whatever"), Assert.Single(vm.Contacts)); + }); + // ---- sync + send against the fake server ---- [Fact]