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.
This commit is contained in:
davide committed 2026-11-03 14:15:03 +01:00
1 parent 06d1f59c2b
commit eca4d4ac0c
5 files changed
+117 -5

No files matched your search

+4
View File
@@ -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
+5 -3
View File
@@ -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.
+16
View File
@@ -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", "联系人姓名"],
@@ -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();
@@ -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]