Compare commits
2 Commits
ef60ec0330
...
9aecdb1aaa
| Author | SHA1 | Date | |
|---|---|---|---|
| 9aecdb1aaa | |||
| 2be43d3177 |
@@ -49,7 +49,7 @@ public partial class MainWindowViewModel
|
|||||||
|
|
||||||
_pendingDonate = new TransactionFactory(_account).Build(
|
_pendingDonate = new TransactionFactory(_account).Build(
|
||||||
_doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate,
|
_doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate,
|
||||||
_doc.Cache.NextChangeIndex, sendAll: false);
|
_doc.Cache.NextChangeIndex, _doc.Cache.TipHeight, sendAll: false);
|
||||||
|
|
||||||
DonatePreview = $"txid {_pendingDonate.Txid[..16]}… · " +
|
DonatePreview = $"txid {_pendingDonate.Txid[..16]}… · " +
|
||||||
$"fee {Fmt(_pendingDonate.Fee.Satoshi)} " +
|
$"fee {Fmt(_pendingDonate.Fee.Satoshi)} " +
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ public partial class MainWindowViewModel
|
|||||||
|
|
||||||
_pendingSend = new TransactionFactory(_account).Build(
|
_pendingSend = new TransactionFactory(_account).Build(
|
||||||
_doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate,
|
_doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate,
|
||||||
_doc.Cache.NextChangeIndex, SendAll);
|
_doc.Cache.NextChangeIndex, _doc.Cache.TipHeight, SendAll);
|
||||||
|
|
||||||
SendPreview = $"txid {_pendingSend.Txid[..16]}… · " +
|
SendPreview = $"txid {_pendingSend.Txid[..16]}… · " +
|
||||||
$"fee {Fmt(_pendingSend.Fee.Satoshi)} " +
|
$"fee {Fmt(_pendingSend.Fee.Satoshi)} " +
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
@@ -55,6 +56,8 @@ public partial class MainWindowViewModel
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool isSyncing;
|
private bool isSyncing;
|
||||||
|
|
||||||
|
private CancellationTokenSource _syncCts = new();
|
||||||
|
|
||||||
public ObservableCollection<KnownServer> KnownServers { get; } = [];
|
public ObservableCollection<KnownServer> KnownServers { get; } = [];
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
@@ -165,11 +168,23 @@ public partial class MainWindowViewModel
|
|||||||
{
|
{
|
||||||
if (IsSyncing)
|
if (IsSyncing)
|
||||||
{
|
{
|
||||||
_resyncRequested = true;
|
var (newHost, newPort) = ParseServer();
|
||||||
|
bool serverChanged = _client is null
|
||||||
|
|| _client.Host != newHost
|
||||||
|
|| _client.Port != newPort
|
||||||
|
|| _client.UseSsl != UseSsl;
|
||||||
|
if (serverChanged)
|
||||||
|
_syncCts.Cancel();
|
||||||
|
else
|
||||||
|
_resyncRequested = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_syncCts = new CancellationTokenSource();
|
||||||
|
var ct = _syncCts.Token;
|
||||||
IsSyncing = true;
|
IsSyncing = true;
|
||||||
StatusMessage = "";
|
StatusMessage = "";
|
||||||
|
bool cancelled = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var (host, port) = ParseServer();
|
var (host, port) = ParseServer();
|
||||||
@@ -194,12 +209,13 @@ public partial class MainWindowViewModel
|
|||||||
Exception? lastError = null;
|
Exception? lastError = null;
|
||||||
foreach (var (h, p) in candidates)
|
foreach (var (h, p) in candidates)
|
||||||
{
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
ConnectionStatus = $"{Loc.Tr("conn.connectingto")} {h}:{p}…";
|
ConnectionStatus = $"{Loc.Tr("conn.connectingto")} {h}:{p}…";
|
||||||
ConnectionStatusShort = Loc.Tr("conn.connectingto") + "…";
|
ConnectionStatusShort = Loc.Tr("conn.connectingto") + "…";
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net));
|
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net));
|
||||||
_client = await ElectrumClient.ConnectAsync(h, p, UseSsl, pins);
|
_client = await ElectrumClient.ConnectAsync(h, p, UseSsl, pins, ct);
|
||||||
_client.NotificationReceived += OnServerNotification;
|
_client.NotificationReceived += OnServerNotification;
|
||||||
_client.Disconnected += _ => Dispatcher.UIThread.Post(() =>
|
_client.Disconnected += _ => Dispatcher.UIThread.Post(() =>
|
||||||
{
|
{
|
||||||
@@ -226,6 +242,10 @@ public partial class MainWindowViewModel
|
|||||||
lastError = null;
|
lastError = null;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
lastError = ex;
|
lastError = ex;
|
||||||
@@ -258,7 +278,7 @@ public partial class MainWindowViewModel
|
|||||||
do
|
do
|
||||||
{
|
{
|
||||||
_resyncRequested = false;
|
_resyncRequested = false;
|
||||||
var result = await _synchronizer.SyncOnceAsync();
|
var result = await _synchronizer.SyncOnceAsync(ct);
|
||||||
_lastTransactions = result.Transactions;
|
_lastTransactions = result.Transactions;
|
||||||
|
|
||||||
var (rawHex, verifiedAt, blockHeaders) = _synchronizer.ExportCaches(
|
var (rawHex, verifiedAt, blockHeaders) = _synchronizer.ExportCaches(
|
||||||
@@ -282,7 +302,16 @@ public partial class MainWindowViewModel
|
|||||||
_syncFailed = false;
|
_syncFailed = false;
|
||||||
StatusMessage = $"{Loc.Tr("msg.synced")}: {Loc.Tr("msg.height")} {result.TipHeight}, " +
|
StatusMessage = $"{Loc.Tr("msg.synced")}: {Loc.Tr("msg.height")} {result.TipHeight}, " +
|
||||||
$"{result.History.Count} {Loc.Tr("msg.synced.detail")}";
|
$"{result.History.Count} {Loc.Tr("msg.synced.detail")}";
|
||||||
} while (_resyncRequested);
|
} while (_resyncRequested && !ct.IsCancellationRequested);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Intentional cancellation due to server change request — not an error.
|
||||||
|
cancelled = true;
|
||||||
|
IsConnected = false;
|
||||||
|
ConnectionStatus = Loc.Tr("conn.none");
|
||||||
|
ConnectionStatusShort = Loc.Tr("conn.none");
|
||||||
|
StatusMessage = "";
|
||||||
}
|
}
|
||||||
catch (CertificatePinMismatchException ex)
|
catch (CertificatePinMismatchException ex)
|
||||||
{
|
{
|
||||||
@@ -309,6 +338,10 @@ public partial class MainWindowViewModel
|
|||||||
{
|
{
|
||||||
IsSyncing = false;
|
IsSyncing = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If cancelled due to a server change, restart immediately with the new server.
|
||||||
|
if (cancelled)
|
||||||
|
_ = ConnectAndSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
|
|||||||
@@ -1233,14 +1233,14 @@
|
|||||||
<!-- Actions — Desktop: in a row -->
|
<!-- Actions — Desktop: in a row -->
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8" IsVisible="{Binding IsDesktop}">
|
<StackPanel Orientation="Horizontal" Spacing="8" IsVisible="{Binding IsDesktop}">
|
||||||
<Button Content="{Binding Loc[wallet.connect]}" Classes="accent"
|
<Button Content="{Binding Loc[wallet.connect]}" Classes="accent"
|
||||||
Command="{Binding ConnectAndSyncCommand}" IsEnabled="{Binding !IsSyncing}"/>
|
Command="{Binding ConnectAndSyncCommand}"/>
|
||||||
<Button Content="{Binding Loc[wallet.resetcert]}"
|
<Button Content="{Binding Loc[wallet.resetcert]}"
|
||||||
Command="{Binding ResetCertificatesCommand}"/>
|
Command="{Binding ResetCertificatesCommand}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<!-- Actions — Mobile: stacked -->
|
<!-- Actions — Mobile: stacked -->
|
||||||
<StackPanel Spacing="6" IsVisible="{Binding IsMobile}">
|
<StackPanel Spacing="6" IsVisible="{Binding IsMobile}">
|
||||||
<Button Content="{Binding Loc[wallet.connect]}" Classes="accent"
|
<Button Content="{Binding Loc[wallet.connect]}" Classes="accent"
|
||||||
Command="{Binding ConnectAndSyncCommand}" IsEnabled="{Binding !IsSyncing}"
|
Command="{Binding ConnectAndSyncCommand}"
|
||||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
|
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
|
||||||
<Button Content="{Binding Loc[wallet.resetcert]}"
|
<Button Content="{Binding Loc[wallet.resetcert]}"
|
||||||
Command="{Binding ResetCertificatesCommand}"
|
Command="{Binding ResetCertificatesCommand}"
|
||||||
|
|||||||
+1
-1
@@ -193,7 +193,7 @@ static async Task<int> Send(string[] o)
|
|||||||
|
|
||||||
var built = new TransactionFactory(account).Build(
|
var built = new TransactionFactory(account).Build(
|
||||||
doc.Cache.Utxos, transactions, destination, amount, feeRate,
|
doc.Cache.Utxos, transactions, destination, amount, feeRate,
|
||||||
doc.Cache.NextChangeIndex, sendAll);
|
doc.Cache.NextChangeIndex, doc.Cache.TipHeight, sendAll);
|
||||||
|
|
||||||
Console.WriteLine($"txid: {built.Txid}");
|
Console.WriteLine($"txid: {built.Txid}");
|
||||||
Console.WriteLine($"fee: {CoinAmount.Format(built.Fee.Satoshi, account.Profile.CoinUnit)} " +
|
Console.WriteLine($"fee: {CoinAmount.Format(built.Fee.Satoshi, account.Profile.CoinUnit)} " +
|
||||||
|
|||||||
@@ -126,6 +126,12 @@ public sealed record ChainProfile
|
|||||||
/// <summary>Target block time in seconds (LWMA v2: 120s).</summary>
|
/// <summary>Target block time in seconds (LWMA v2: 120s).</summary>
|
||||||
public required int BlockTimeSeconds { get; init; }
|
public required int BlockTimeSeconds { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Blocks a coinbase output must wait before it can be spent.</summary>
|
||||||
|
public required int CoinbaseMaturity { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Minimum confirmations required for a regular UTXO to be spendable.</summary>
|
||||||
|
public required int MinConfirmations { get; init; }
|
||||||
|
|
||||||
/// <summary>BIP32/SLIP-132 headers for each script type.</summary>
|
/// <summary>BIP32/SLIP-132 headers for each script type.</summary>
|
||||||
public required IReadOnlyDictionary<ScriptKind, ExtKeyHeaders> ExtKeyHeaders { get; init; }
|
public required IReadOnlyDictionary<ScriptKind, ExtKeyHeaders> ExtKeyHeaders { get; init; }
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ public static class ChainProfiles
|
|||||||
ExplorerUrl = "https://explorer.palladium-coin.com/",
|
ExplorerUrl = "https://explorer.palladium-coin.com/",
|
||||||
SkipPowValidation = true,
|
SkipPowValidation = true,
|
||||||
BlockTimeSeconds = 120,
|
BlockTimeSeconds = 120,
|
||||||
|
CoinbaseMaturity = 120,
|
||||||
|
MinConfirmations = 6,
|
||||||
ExtKeyHeaders = new Dictionary<ScriptKind, ExtKeyHeaders>
|
ExtKeyHeaders = new Dictionary<ScriptKind, ExtKeyHeaders>
|
||||||
{
|
{
|
||||||
[ScriptKind.Legacy] = new(0x0488ade4, 0x0488b21e), // xprv / xpub
|
[ScriptKind.Legacy] = new(0x0488ade4, 0x0488b21e), // xprv / xpub
|
||||||
@@ -53,6 +55,7 @@ public static class ChainProfiles
|
|||||||
{
|
{
|
||||||
Kind = NetKind.Testnet,
|
Kind = NetKind.Testnet,
|
||||||
NetName = "testnet",
|
NetName = "testnet",
|
||||||
|
MinConfirmations = 1,
|
||||||
WifPrefix = 0xff,
|
WifPrefix = 0xff,
|
||||||
AddrP2pkh = 127,
|
AddrP2pkh = 127,
|
||||||
AddrP2sh = 115,
|
AddrP2sh = 115,
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ public static class PalladiumNetworks
|
|||||||
MajorityRejectBlockOutdated = 950,
|
MajorityRejectBlockOutdated = 950,
|
||||||
MajorityWindow = 1000,
|
MajorityWindow = 1000,
|
||||||
MinimumChainWork = uint256.Zero,
|
MinimumChainWork = uint256.Zero,
|
||||||
CoinbaseMaturity = 100,
|
CoinbaseMaturity = 120,
|
||||||
SupportSegwit = true,
|
SupportSegwit = true,
|
||||||
SupportTaproot = true,
|
SupportTaproot = true,
|
||||||
ConsensusFactory = new ConsensusFactory(),
|
ConsensusFactory = new ConsensusFactory(),
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
IsChange = addr.IsChange,
|
IsChange = addr.IsChange,
|
||||||
AddressIndex = addr.Index,
|
AddressIndex = addr.Index,
|
||||||
Height = txHeights[txid],
|
Height = txHeights[txid],
|
||||||
|
IsCoinbase = tx.IsCoinBase,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,5 +150,6 @@ public sealed class CachedUtxo
|
|||||||
public bool IsChange { get; set; }
|
public bool IsChange { get; set; }
|
||||||
public int AddressIndex { get; set; }
|
public int AddressIndex { get; set; }
|
||||||
public int Height { get; set; }
|
public int Height { get; set; }
|
||||||
|
public bool IsCoinbase { get; set; }
|
||||||
public bool Frozen { get; set; }
|
public bool Frozen { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ public sealed class TransactionFactory(IWalletAccount account)
|
|||||||
/// <param name="amountSats">Amount; ignored when <paramref name="sendAll"/> is true.</param>
|
/// <param name="amountSats">Amount; ignored when <paramref name="sendAll"/> is true.</param>
|
||||||
/// <param name="feeRateSatPerVByte">Fixed fee rate in sat/vByte (§6.4).</param>
|
/// <param name="feeRateSatPerVByte">Fixed fee rate in sat/vByte (§6.4).</param>
|
||||||
/// <param name="changeIndex">Index of the next change address (internal chain).</param>
|
/// <param name="changeIndex">Index of the next change address (internal chain).</param>
|
||||||
|
/// <param name="tipHeight">Current chain tip height, used to enforce confirmation thresholds.</param>
|
||||||
/// <param name="sendAll">Send all: fee subtracted from the amount (§6.1).</param>
|
/// <param name="sendAll">Send all: fee subtracted from the amount (§6.1).</param>
|
||||||
public BuiltTransaction Build(
|
public BuiltTransaction Build(
|
||||||
IReadOnlyList<CachedUtxo> utxos,
|
IReadOnlyList<CachedUtxo> utxos,
|
||||||
@@ -48,16 +49,52 @@ public sealed class TransactionFactory(IWalletAccount account)
|
|||||||
long amountSats,
|
long amountSats,
|
||||||
decimal feeRateSatPerVByte,
|
decimal feeRateSatPerVByte,
|
||||||
int changeIndex,
|
int changeIndex,
|
||||||
|
int tipHeight,
|
||||||
bool sendAll = false)
|
bool sendAll = false)
|
||||||
{
|
{
|
||||||
// Only confirmed UTXOs are spent: mempool funds appear in the "pending"
|
var coinbaseMaturity = account.Profile.CoinbaseMaturity;
|
||||||
// balance but are not spendable until confirmed.
|
var minConf = account.Profile.MinConfirmations;
|
||||||
var spendable = utxos.Where(u => !u.Frozen && u.Height > 0).ToList();
|
|
||||||
|
// A UTXO is spendable when it has enough confirmations:
|
||||||
|
// - coinbase: COINBASE_MATURITY + 1 confirmations (matches the Qt wallet: the consensus
|
||||||
|
// rule is nSpendHeight - nHeight >= 120, so at the current tip a TX can be mined in
|
||||||
|
// the next block when tipHeight - height + 1 >= 121)
|
||||||
|
// - regular: MinConfirmations (wallet policy, no consensus rule)
|
||||||
|
int coinbaseThreshold = coinbaseMaturity + 1;
|
||||||
|
var spendable = utxos.Where(u =>
|
||||||
|
!u.Frozen &&
|
||||||
|
u.Height > 0 &&
|
||||||
|
(tipHeight - u.Height + 1) >= (u.IsCoinbase ? coinbaseThreshold : minConf)
|
||||||
|
).ToList();
|
||||||
|
|
||||||
if (spendable.Count == 0)
|
if (spendable.Count == 0)
|
||||||
{
|
{
|
||||||
var pending = utxos.Where(u => !u.Frozen && u.Height <= 0).Sum(u => u.ValueSats);
|
var reasons = new System.Text.StringBuilder();
|
||||||
throw new WalletSpendException(pending > 0
|
|
||||||
? $"No confirmed funds: {CoinAmount.Format(pending)} pending confirmation (not spendable)."
|
var mempool = utxos.Where(u => !u.Frozen && u.Height <= 0).ToList();
|
||||||
|
if (mempool.Count > 0)
|
||||||
|
reasons.Append($"{mempool.Count} output(s) unconfirmed ({CoinAmount.Format(mempool.Sum(u => u.ValueSats))} in mempool). ");
|
||||||
|
|
||||||
|
var immature = utxos.Where(u =>
|
||||||
|
!u.Frozen && u.Height > 0 && u.IsCoinbase &&
|
||||||
|
(tipHeight - u.Height + 1) < coinbaseThreshold).ToList();
|
||||||
|
if (immature.Count > 0)
|
||||||
|
{
|
||||||
|
var best = immature.Max(u => tipHeight - u.Height + 1);
|
||||||
|
reasons.Append($"{immature.Count} coinbase output(s) not yet mature ({best}/{coinbaseThreshold} confirmations). ");
|
||||||
|
}
|
||||||
|
|
||||||
|
var underConf = utxos.Where(u =>
|
||||||
|
!u.Frozen && u.Height > 0 && !u.IsCoinbase &&
|
||||||
|
(tipHeight - u.Height + 1) < minConf).ToList();
|
||||||
|
if (underConf.Count > 0)
|
||||||
|
{
|
||||||
|
var best = underConf.Max(u => tipHeight - u.Height + 1);
|
||||||
|
reasons.Append($"{underConf.Count} output(s) need {minConf} confirmations ({best} so far). ");
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new WalletSpendException(reasons.Length > 0
|
||||||
|
? $"No spendable UTXOs: {reasons.ToString().TrimEnd()}"
|
||||||
: "No spendable UTXOs selected.");
|
: "No spendable UTXOs selected.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ public class TransactionFactoryTests
|
|||||||
|
|
||||||
var built = new TransactionFactory(account).Build(
|
var built = new TransactionFactory(account).Build(
|
||||||
utxos, txs, destination, amountSats: 400_000,
|
utxos, txs, destination, amountSats: 400_000,
|
||||||
feeRateSatPerVByte: 2, changeIndex: 0);
|
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100);
|
||||||
|
|
||||||
Assert.True(built.Signed);
|
Assert.True(built.Signed);
|
||||||
// Output: recipient + change.
|
// Output: recipient + change.
|
||||||
@@ -74,7 +74,7 @@ public class TransactionFactoryTests
|
|||||||
|
|
||||||
var built = new TransactionFactory(account).Build(
|
var built = new TransactionFactory(account).Build(
|
||||||
utxos, txs, destination, amountSats: 0,
|
utxos, txs, destination, amountSats: 0,
|
||||||
feeRateSatPerVByte: 1, changeIndex: 0, sendAll: true);
|
feeRateSatPerVByte: 1, changeIndex: 0, tipHeight: 100, sendAll: true);
|
||||||
|
|
||||||
var output = Assert.Single(built.Transaction.Outputs);
|
var output = Assert.Single(built.Transaction.Outputs);
|
||||||
Assert.Equal(500_000, output.Value.Satoshi + built.Fee.Satoshi);
|
Assert.Equal(500_000, output.Value.Satoshi + built.Fee.Satoshi);
|
||||||
@@ -88,7 +88,7 @@ public class TransactionFactoryTests
|
|||||||
|
|
||||||
Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
||||||
utxos, txs, account.GetReceiveAddress(1), amountSats: 900_000,
|
utxos, txs, account.GetReceiveAddress(1), amountSats: 900_000,
|
||||||
feeRateSatPerVByte: 2, changeIndex: 0));
|
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -100,8 +100,8 @@ public class TransactionFactoryTests
|
|||||||
|
|
||||||
var ex = Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
var ex = Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
||||||
utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000,
|
utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000,
|
||||||
feeRateSatPerVByte: 2, changeIndex: 0));
|
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100));
|
||||||
Assert.Contains("pending confirmation", ex.Message);
|
Assert.Contains("unconfirmed", ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -113,7 +113,62 @@ public class TransactionFactoryTests
|
|||||||
|
|
||||||
Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
||||||
utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000,
|
utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000,
|
||||||
feeRateSatPerVByte: 2, changeIndex: 0));
|
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gli_utxo_coinbase_immaturi_non_sono_spendibili()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var (utxos, txs) = Fund(account, 1_000_000);
|
||||||
|
utxos[0].IsCoinbase = true;
|
||||||
|
utxos[0].Height = 100;
|
||||||
|
// Threshold = COINBASE_MATURITY + 1 = 121 (mirrors the Qt wallet: consensus rule is
|
||||||
|
// nSpendHeight - nHeight >= 120, plus one block of safety margin).
|
||||||
|
// At height=100, tip=219 → confs = 219-100+1 = 120 < 121 → immature.
|
||||||
|
var ex = Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
||||||
|
utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000,
|
||||||
|
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 219));
|
||||||
|
Assert.Contains("coinbase", ex.Message);
|
||||||
|
Assert.Contains("120/121", ex.Message);
|
||||||
|
|
||||||
|
// tip=220 → confs = 121 ≥ 121 → mature and spendable.
|
||||||
|
var built = new TransactionFactory(account).Build(
|
||||||
|
utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000,
|
||||||
|
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 220);
|
||||||
|
Assert.True(built.Signed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gli_utxo_con_meno_di_minconf_non_sono_spendibili_su_mainnet()
|
||||||
|
{
|
||||||
|
var mainnetAccount = HdAccount.FromMnemonic(
|
||||||
|
Bip39.TryParse("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", out var m) ? m! : throw new Exception(),
|
||||||
|
null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
|
||||||
|
|
||||||
|
var funding = PalladiumNetworks.Mainnet.CreateTransaction();
|
||||||
|
funding.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0)));
|
||||||
|
funding.Outputs.Add(Money.Satoshis(1_000_000), mainnetAccount.GetReceiveAddress(0));
|
||||||
|
var txid = funding.GetHash().ToString();
|
||||||
|
var utxos = new List<CachedUtxo>
|
||||||
|
{
|
||||||
|
new() { Txid = txid, Vout = 0, ValueSats = 1_000_000,
|
||||||
|
Address = mainnetAccount.GetReceiveAddress(0).ToString(),
|
||||||
|
IsChange = false, AddressIndex = 0, Height = 100, IsCoinbase = false },
|
||||||
|
};
|
||||||
|
var txs = new Dictionary<string, Transaction> { [txid] = funding };
|
||||||
|
|
||||||
|
// 5 confirmations (tipHeight=104): below the mainnet minimum of 6.
|
||||||
|
var ex = Assert.Throws<WalletSpendException>(() => new TransactionFactory(mainnetAccount).Build(
|
||||||
|
utxos, txs, mainnetAccount.GetReceiveAddress(1), amountSats: 100_000,
|
||||||
|
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 104));
|
||||||
|
Assert.Contains("5 so far", ex.Message);
|
||||||
|
|
||||||
|
// 6 confirmations (tipHeight=105): exactly at the threshold → spendable.
|
||||||
|
var built = new TransactionFactory(mainnetAccount).Build(
|
||||||
|
utxos, txs, mainnetAccount.GetReceiveAddress(1), amountSats: 100_000,
|
||||||
|
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 105);
|
||||||
|
Assert.True(built.Signed);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -127,7 +182,7 @@ public class TransactionFactoryTests
|
|||||||
|
|
||||||
var built = new TransactionFactory(watchOnly).Build(
|
var built = new TransactionFactory(watchOnly).Build(
|
||||||
utxos, txs, full.GetReceiveAddress(5), amountSats: 400_000,
|
utxos, txs, full.GetReceiveAddress(5), amountSats: 400_000,
|
||||||
feeRateSatPerVByte: 2, changeIndex: 0);
|
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100);
|
||||||
|
|
||||||
Assert.False(built.Signed);
|
Assert.False(built.Signed);
|
||||||
// Air-gapped flow (§6.5): the online-machine PSBT is signed
|
// Air-gapped flow (§6.5): the online-machine PSBT is signed
|
||||||
|
|||||||
Reference in New Issue
Block a user