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:
1 parent
06d1f59c2b
commit
eca4d4ac0c
5 files changed
+117
-5
No files matched your search
@@ -17,6 +17,10 @@ release and covers the full history from the initial commit.
|
|||||||
|
|
||||||
### Fixed
|
### 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
|
- 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
|
`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
|
Bitcoin's genesis), the header at the highest checkpoint not above the server's tip must match
|
||||||
|
|||||||
+5
-3
@@ -508,9 +508,11 @@ them is normal.
|
|||||||
|
|
||||||
A simple per-wallet address book, on its own tab:
|
A simple per-wallet address book, on its own tab:
|
||||||
|
|
||||||
- **Add**: name + address, both required (whitespace is trimmed). Note: the address format
|
- **Add**: name + address, both required. The address is **validated when saving** against
|
||||||
is **not validated when saving** — it is only validated when you actually send to it. Copy
|
the wallet's network: an invalid or wrong-network address, or one already in the list, is
|
||||||
addresses carefully.
|
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
|
- **Remove selected**: deletes the highlighted contact. There is no edit function — remove
|
||||||
and re-add.
|
and re-add.
|
||||||
- Contacts are used by the **From contacts** dropdown on the Send tab.
|
- Contacts are used by the **From contacts** dropdown on the Send tab.
|
||||||
|
|||||||
@@ -477,6 +477,22 @@ public sealed class Loc
|
|||||||
"服务器 {0} 不在 Palladium 链上(创世块或检查点不同)。请选择其他服务器。"],
|
"服务器 {0} 不在 Palladium 链上(创世块或检查点不同)。请选择其他服务器。"],
|
||||||
|
|
||||||
// Contacts
|
// 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.name"] = ["Nome", "Name", "Nombre", "Nom", "Nome", "Name", "姓名"],
|
||||||
["contacts.address"] = ["Indirizzo", "Address", "Dirección", "Adresse", "Endereço", "Adresse", "地址"],
|
["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", "联系人姓名"],
|
["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 System.Linq;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using NBitcoin;
|
||||||
|
using PalladiumWallet.App.Localization;
|
||||||
|
using PalladiumWallet.Core.Chain;
|
||||||
using PalladiumWallet.Core.Storage;
|
using PalladiumWallet.Core.Storage;
|
||||||
|
|
||||||
namespace PalladiumWallet.App.ViewModels;
|
namespace PalladiumWallet.App.ViewModels;
|
||||||
@@ -33,8 +36,23 @@ public partial class MainWindowViewModel
|
|||||||
private void AddContact()
|
private void AddContact()
|
||||||
{
|
{
|
||||||
var name = NewContactName.Trim();
|
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;
|
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));
|
Contacts.Add(new ContactEntry(name, addr));
|
||||||
NewContactName = NewContactAddress = "";
|
NewContactName = NewContactAddress = "";
|
||||||
PersistContacts();
|
PersistContacts();
|
||||||
|
|||||||
@@ -291,7 +291,8 @@ public sealed class MainWindowViewModelTests : VmTestBase
|
|||||||
var vm = new MainWindowViewModel();
|
var vm = new MainWindowViewModel();
|
||||||
RestoreFlow(vm, Mnemonic, null, "c");
|
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.AddContactCommand.Execute(null);
|
||||||
vm.NewContactName = ""; vm.NewContactAddress = "ignored";
|
vm.NewContactName = ""; vm.NewContactAddress = "ignored";
|
||||||
vm.AddContactCommand.Execute(null); // blank name → 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);
|
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 ----
|
// ---- sync + send against the fake server ----
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in new issue
Block a user