4 Commits

Author SHA1 Message Date
davide 008e9c395a fix(wallet): reject amounts with sub-satoshi precision
Replace the silent (long) cast with an explicit fractional check:
if value * factor has a non-zero remainder, TryParseIn/TryParseCoins
return false. The caller already shows "Importo non valido." to the
user, so no UI change is needed.

Covers TryParseIn (all units) and TryParseCoins. Adds CoinAmountTests
with valid and invalid cases including the triggering example (1.9 sat).
2026-06-13 20:39:08 +02:00
davide 70cce640aa test(storage): add WalletLock unit tests
Covers acquire/release, double-acquire returning null, and
re-acquire after dispose.
2026-06-13 20:34:21 +02:00
davide 0f8a764a44 feat(storage): add WalletLock to prevent concurrent wallet access
Introduces an exclusive file lock (FileShare.None on wallet.json.lock)
held for the duration of a wallet session. A second instance trying to
open the same file receives null from TryAcquire and sees a localized
error message; UnauthorizedAccessException (OS permission issues)
propagates separately so the UI can show a distinct message.
The .lock file is deleted on Dispose (best-effort); the real guard is
the open FileStream, which the OS releases automatically on crash.
2026-06-13 20:34:06 +02:00
davide d66490b6be docs: update CLAUDE.md 2026-06-13 20:15:28 +02:00
7 changed files with 198 additions and 4 deletions
+2 -2
View File
@@ -36,8 +36,8 @@ by `MainWindow` on desktop and as the single-view root on Android.
## Commands ## Commands
.NET 10 SDK lives in `~/.dotnet`: in non-interactive shells, before any `dotnet` command run .NET 10 SDK lives in `~/.dotnet10`: in non-interactive shells, before any `dotnet` command run
`export PATH="$HOME/.dotnet:$PATH" DOTNET_ROOT="$HOME/.dotnet"`. `export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"`.
- Build: `dotnet build` - Build: `dotnet build`
- Tests (headless, the primary verification layer): `dotnet test` — single: `dotnet test --filter "FullyQualifiedName~TestName"` - Tests (headless, the primary verification layer): `dotnet test` — single: `dotnet test --filter "FullyQualifiedName~TestName"`
+2
View File
@@ -281,6 +281,8 @@ public sealed class Loc
"As duas senhas não coincidem.", "As duas senhas não coincidem.",
"Die beiden Passwörter stimmen nicht überein."], "Die beiden Passwörter stimmen nicht überein."],
["msg.wrongpassword"] = ["Password errata.", "Wrong password.", "Contraseña incorrecta.", "Mot de passe incorrect.", "Senha incorreta.", "Falsches Passwort."], ["msg.wrongpassword"] = ["Password errata.", "Wrong password.", "Contraseña incorrecta.", "Mot de passe incorrect.", "Senha incorreta.", "Falsches Passwort."],
["msg.wallet.locked"] = ["Wallet già aperto in un'altra istanza dell'applicazione.", "Wallet already open in another instance of the application.", "El wallet ya está abierto en otra instancia de la aplicación.", "Le wallet est déjà ouvert dans une autre instance de l'application.", "A carteira já está aberta em outra instância do aplicativo.", "Wallet ist bereits in einer anderen Instanz der Anwendung geöffnet."],
["msg.wallet.noaccess"] = ["Impossibile accedere al file del wallet: verificare i permessi.", "Cannot access the wallet file: check file permissions.", "No se puede acceder al archivo del wallet: verifique los permisos.", "Impossible d'accéder au fichier du wallet : vérifiez les autorisations.", "Não é possível acessar o arquivo da carteira: verifique as permissões.", "Zugriff auf die Wallet-Datei nicht möglich: Berechtigungen prüfen."],
["msg.opened"] = ["Wallet aperto: connessione al server…", "Wallet opened: connecting to server…", "Wallet abierto: conectando al servidor…", "Wallet ouvert : connexion au serveur…", "Carteira aberta: conectando ao servidor…", "Wallet geöffnet: Verbindung zum Server…"], ["msg.opened"] = ["Wallet aperto: connessione al server…", "Wallet opened: connecting to server…", "Wallet abierto: conectando al servidor…", "Wallet ouvert : connexion au serveur…", "Carteira aberta: conectando ao servidor…", "Wallet geöffnet: Verbindung zum Server…"],
["msg.synced"] = ["Sincronizzato", "Synchronized", "Sincronizado", "Synchronisé", "Sincronizado", "Synchronisiert"], ["msg.synced"] = ["Sincronizzato", "Synchronized", "Sincronizado", "Synchronisé", "Sincronizado", "Synchronisiert"],
["msg.synced.detail"] = [ ["msg.synced.detail"] = [
+27
View File
@@ -57,6 +57,7 @@ public partial class MainWindowViewModel : ViewModelBase
private HdAccount? _account; private HdAccount? _account;
private string? _walletPath; private string? _walletPath;
private string? _password; private string? _password;
private WalletLock? _walletLock;
private ElectrumClient? _client; private ElectrumClient? _client;
private WalletSynchronizer? _synchronizer; private WalletSynchronizer? _synchronizer;
private IReadOnlyDictionary<string, Transaction>? _lastTransactions; private IReadOnlyDictionary<string, Transaction>? _lastTransactions;
@@ -896,6 +897,10 @@ public partial class MainWindowViewModel : ViewModelBase
{ {
StatusMessage = Loc.Tr("msg.wrongpassword"); StatusMessage = Loc.Tr("msg.wrongpassword");
} }
catch (UnauthorizedAccessException)
{
StatusMessage = Loc.Tr("msg.wallet.noaccess");
}
catch (Exception ex) catch (Exception ex)
{ {
StatusMessage = $"Errore: {ex.Message}"; StatusMessage = $"Errore: {ex.Message}";
@@ -923,6 +928,10 @@ public partial class MainWindowViewModel : ViewModelBase
SetupStep = StepOpen; SetupStep = StepOpen;
StatusMessage = ""; StatusMessage = "";
} }
catch (UnauthorizedAccessException)
{
StatusMessage = Loc.Tr("msg.wallet.noaccess");
}
catch (Exception ex) catch (Exception ex)
{ {
StatusMessage = $"Errore: {ex.Message}"; StatusMessage = $"Errore: {ex.Message}";
@@ -939,8 +948,24 @@ public partial class MainWindowViewModel : ViewModelBase
StatusMessage = ""; StatusMessage = "";
} }
private bool TryAcquireWalletLock(string path)
{
var acquired = WalletLock.TryAcquire(path);
if (acquired is null)
{
StatusMessage = Loc.Tr("msg.wallet.locked");
return false;
}
_walletLock?.Dispose();
_walletLock = acquired;
return true;
}
private void OpenLoaded(WalletDocument doc, HdAccount account, string path, string? password) private void OpenLoaded(WalletDocument doc, HdAccount account, string path, string? password)
{ {
if (!TryAcquireWalletLock(path))
return;
// La rete del wallet comanda (registry, pin TLS, indirizzi). // La rete del wallet comanda (registry, pin TLS, indirizzi).
SelectedNetwork = doc.Network; SelectedNetwork = doc.Network;
_doc = doc; _doc = doc;
@@ -1246,6 +1271,8 @@ public partial class MainWindowViewModel : ViewModelBase
[RelayCommand] [RelayCommand]
private void CloseWallet() private void CloseWallet()
{ {
_walletLock?.Dispose();
_walletLock = null;
_ = _client?.DisposeAsync().AsTask(); _ = _client?.DisposeAsync().AsTask();
_client = null; _client = null;
_synchronizer = null; _synchronizer = null;
+48
View File
@@ -0,0 +1,48 @@
namespace PalladiumWallet.Core.Storage;
/// <summary>
/// Exclusive lock on a wallet file held for the lifetime of the session.
/// The real lock is the open FileStream with FileShare.None — the .lock file
/// is just its vessel. The OS releases it automatically on process exit or crash.
/// </summary>
public sealed class WalletLock : IDisposable
{
private readonly string _lockPath;
private FileStream? _stream;
private WalletLock(string lockPath, FileStream stream)
{
_lockPath = lockPath;
_stream = stream;
}
/// <summary>
/// Tries to acquire an exclusive lock for <paramref name="walletPath"/>.
/// Returns null if another process already holds the lock (IOException).
/// Lets UnauthorizedAccessException propagate so callers can show a distinct message.
/// </summary>
public static WalletLock? TryAcquire(string walletPath)
{
var lockPath = walletPath + ".lock";
try
{
var stream = new FileStream(
lockPath,
FileMode.OpenOrCreate,
FileAccess.ReadWrite,
FileShare.None);
return new WalletLock(lockPath, stream);
}
catch (IOException)
{
return null;
}
}
public void Dispose()
{
_stream?.Dispose();
_stream = null;
try { File.Delete(_lockPath); } catch { }
}
}
+8 -2
View File
@@ -46,7 +46,10 @@ public static class CoinAmount
return false; return false;
try try
{ {
sats = (long)(value * factor); var satsDecimal = value * factor;
if (satsDecimal % 1 != 0)
return false;
sats = (long)satsDecimal;
} }
catch (OverflowException) catch (OverflowException)
{ {
@@ -65,7 +68,10 @@ public static class CoinAmount
return false; return false;
try try
{ {
sats = (long)(coins * SatsPerCoin); var satsDecimal = coins * SatsPerCoin;
if (satsDecimal % 1 != 0)
return false;
sats = (long)satsDecimal;
} }
catch (OverflowException) catch (OverflowException)
{ {
@@ -92,4 +92,59 @@ public class StorageTests
doc.Mnemonic = null; doc.Mnemonic = null;
Assert.True(WalletDocument.FromJson(doc.ToJson()).IsWatchOnly); Assert.True(WalletDocument.FromJson(doc.ToJson()).IsWatchOnly);
} }
[Fact]
public void WalletLock_acquisisce_e_rilascia()
{
var path = Path.Combine(Path.GetTempPath(), $"plm-lock-{Guid.NewGuid()}.wallet.json");
try
{
using var lock1 = WalletLock.TryAcquire(path);
Assert.NotNull(lock1);
}
finally
{
File.Delete(path);
File.Delete(path + ".lock");
}
}
[Fact]
public void WalletLock_seconda_istanza_restituisce_null()
{
var path = Path.Combine(Path.GetTempPath(), $"plm-lock-{Guid.NewGuid()}.wallet.json");
try
{
using var lock1 = WalletLock.TryAcquire(path);
Assert.NotNull(lock1);
var lock2 = WalletLock.TryAcquire(path);
Assert.Null(lock2);
}
finally
{
File.Delete(path);
File.Delete(path + ".lock");
}
}
[Fact]
public void WalletLock_riacquisibile_dopo_rilascio()
{
var path = Path.Combine(Path.GetTempPath(), $"plm-lock-{Guid.NewGuid()}.wallet.json");
try
{
var lock1 = WalletLock.TryAcquire(path);
Assert.NotNull(lock1);
lock1!.Dispose();
using var lock2 = WalletLock.TryAcquire(path);
Assert.NotNull(lock2);
}
finally
{
File.Delete(path);
File.Delete(path + ".lock");
}
}
} }
@@ -0,0 +1,56 @@
using PalladiumWallet.Core.Wallet;
namespace PalladiumWallet.Tests.Wallet;
public class CoinAmountTests
{
// --- importi validi ---
[Theory]
[InlineData("1", "sat", 1)]
[InlineData("0", "sat", 0)]
[InlineData("1.5", "PLM", 150_000_000)]
[InlineData("0.00000001", "PLM", 1)]
[InlineData("1", "PLM", 100_000_000)]
[InlineData("1.00000", "mPLM", 100_000)]
[InlineData("1.00", "µPLM", 100)]
public void Importo_valido_viene_accettato(string input, string unit, long expectedSats)
{
Assert.True(CoinAmount.TryParseIn(input, unit, out var sats));
Assert.Equal(expectedSats, sats);
}
// --- importi con troppi decimali: devono essere rifiutati ---
[Theory]
[InlineData("1.9", "sat")] // sat non è divisibile
[InlineData("0.001", "µPLM")] // 0.001 × 100 = 0.1 sat
[InlineData("1.500000001", "PLM")] // 9 decimali, uno di troppo
[InlineData("0.000000001", "PLM")] // sotto al satoshi
[InlineData("1.1", "sat")]
public void Importo_con_troppi_decimali_viene_rifiutato(string input, string unit)
{
Assert.False(CoinAmount.TryParseIn(input, unit, out _));
}
// --- TryParseCoins ---
[Fact]
public void TryParseCoins_accetta_precisione_massima()
{
Assert.True(CoinAmount.TryParseCoins("0.00000001", out var sats));
Assert.Equal(1L, sats);
}
[Fact]
public void TryParseCoins_rifiuta_sotto_al_satoshi()
{
Assert.False(CoinAmount.TryParseCoins("0.000000001", out _));
}
[Fact]
public void TryParseCoins_rifiuta_importo_negativo()
{
Assert.False(CoinAmount.TryParseCoins("-1", out _));
}
}