feat(wallet): show pending mempool balance and exclude unconfirmed UTXOs from spending

TransactionFactory now selects only confirmed UTXOs (height > 0), so
mempool funds are visible as pending balance but never spendable, with
a clear error when only unconfirmed funds are available. GUI and CLI
labels updated to "in attesa di conferma (non spendibile)".
This commit is contained in:
2026-06-11 11:34:13 +02:00
parent af10482e54
commit ece634f42e
4 changed files with 27 additions and 6 deletions
+3 -2
View File
@@ -332,10 +332,11 @@ public partial class MainWindowViewModel : ViewModelBase
} }
BalanceText = CoinAmount.Format(cache.ConfirmedSats, Profile.CoinUnit); BalanceText = CoinAmount.Format(cache.ConfirmedSats, Profile.CoinUnit);
// Saldo in attesa: somma delle tx in mempool (può essere negativo per // Saldo in attesa: somma delle tx in mempool (può essere negativo per
// gli invii in uscita non ancora confermati). // gli invii in uscita non ancora confermati). Non è spendibile finché
// non conferma: la TransactionFactory usa solo UTXO confermati.
var pending = cache.History.Where(t => t.Height <= 0).Sum(t => t.DeltaSats); var pending = cache.History.Where(t => t.Height <= 0).Sum(t => t.DeltaSats);
UnconfirmedText = pending != 0 UnconfirmedText = pending != 0
? $"in mempool: {(pending > 0 ? "+" : "")}{CoinAmount.Format(pending)} (in attesa di conferma)" ? $"in attesa di conferma: {(pending > 0 ? "+" : "")}{CoinAmount.Format(pending)} — non ancora spendibile"
: ""; : "";
ReceiveAddress = _account.GetReceiveAddress(cache.NextReceiveIndex).ToString(); ReceiveAddress = _account.GetReceiveAddress(cache.NextReceiveIndex).ToString();
History.Clear(); History.Clear();
+2 -2
View File
@@ -105,7 +105,7 @@ static int Info(string[] o)
if (doc.Cache is { } cache) if (doc.Cache is { } cache)
{ {
Console.WriteLine($"saldo: {CoinAmount.Format(cache.ConfirmedSats, account.Profile.CoinUnit)} confermato" Console.WriteLine($"saldo: {CoinAmount.Format(cache.ConfirmedSats, account.Profile.CoinUnit)} confermato"
+ (cache.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(cache.UnconfirmedSats)} non conf." : "")); + (cache.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(cache.UnconfirmedSats)} in attesa (non spendibile)." : ""));
Console.WriteLine($"sync: altezza {cache.TipHeight}, {cache.History.Count} transazioni"); Console.WriteLine($"sync: altezza {cache.TipHeight}, {cache.History.Count} transazioni");
Console.WriteLine($"ricezione: {account.GetReceiveAddress(cache.NextReceiveIndex)}"); Console.WriteLine($"ricezione: {account.GetReceiveAddress(cache.NextReceiveIndex)}");
} }
@@ -147,7 +147,7 @@ static async Task<int> Sync(string[] o)
WalletStore.Save(doc, path, Opt(o, "--password")); WalletStore.Save(doc, path, Opt(o, "--password"));
Console.WriteLine($"Saldo: {CoinAmount.Format(result.ConfirmedSats, account.Profile.CoinUnit)} confermato" Console.WriteLine($"Saldo: {CoinAmount.Format(result.ConfirmedSats, account.Profile.CoinUnit)} confermato"
+ (result.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(result.UnconfirmedSats)} non confermato" : "")); + (result.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(result.UnconfirmedSats)} in attesa di conferma (non spendibile)" : ""));
Console.WriteLine($"Storico ({result.History.Count}):"); Console.WriteLine($"Storico ({result.History.Count}):");
foreach (var tx in result.History) foreach (var tx in result.History)
Console.WriteLine($" {(tx.Height > 0 ? tx.Height.ToString() : "mempool"),7} " + Console.WriteLine($" {(tx.Height > 0 ? tx.Height.ToString() : "mempool"),7} " +
+9 -2
View File
@@ -50,9 +50,16 @@ public sealed class TransactionFactory(HdAccount account)
int changeIndex, int changeIndex,
bool sendAll = false) bool sendAll = false)
{ {
var spendable = utxos.Where(u => !u.Frozen).ToList(); // Si spendono solo UTXO confermati: i fondi in mempool si vedono nel
// saldo "in attesa" ma non sono spendibili finché non confermano.
var spendable = utxos.Where(u => !u.Frozen && u.Height > 0).ToList();
if (spendable.Count == 0) if (spendable.Count == 0)
throw new WalletSpendException("Nessun UTXO spendibile selezionato."); {
var pending = utxos.Where(u => !u.Frozen && u.Height <= 0).Sum(u => u.ValueSats);
throw new WalletSpendException(pending > 0
? $"Nessun fondo confermato: {CoinAmount.Format(pending)} in attesa di conferma (non spendibile)."
: "Nessun UTXO spendibile selezionato.");
}
var coins = spendable.Select(u => new Coin( var coins = spendable.Select(u => new Coin(
new OutPoint(uint256.Parse(u.Txid), (uint)u.Vout), new OutPoint(uint256.Parse(u.Txid), (uint)u.Vout),
@@ -91,6 +91,19 @@ public class TransactionFactoryTests
feeRateSatPerVByte: 2, changeIndex: 0)); feeRateSatPerVByte: 2, changeIndex: 0));
} }
[Fact]
public void Gli_utxo_in_mempool_non_sono_spendibili()
{
var account = Account();
var (utxos, txs) = Fund(account, 1_000_000);
utxos[0].Height = 0; // in mempool: visibile nel saldo pending, non spendibile
var ex = Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000,
feeRateSatPerVByte: 2, changeIndex: 0));
Assert.Contains("in attesa di conferma", ex.Message);
}
[Fact] [Fact]
public void Gli_utxo_congelati_sono_esclusi_dalla_spesa() public void Gli_utxo_congelati_sono_esclusi_dalla_spesa()
{ {