Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/Helpers/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public class Constants
public static readonly bool ENABLE_REMOTE_SIGNER;
public static readonly bool PUSH_NOTIFICATIONS_ONESIGNAL_ENABLED;
public static readonly bool ENABLE_HW_SUPPORT;
public static readonly bool NBXPLORER_ENABLE_CUSTOM_BACKEND = false;
public static bool NBXPLORER_ENABLE_CUSTOM_BACKEND = false; // Not readonly so we can change it in tests
/// <summary>
/// Allow simultaneous channel opening operations using the same source and destination nodes
/// </summary>
Expand Down Expand Up @@ -107,6 +107,11 @@ public class Constants
public static readonly int SESSION_TIMEOUT_MILLISECONDS = 3_600_000;
public static readonly Money BITCOIN_DUST = new Money(0.00000546m, MoneyUnit.BTC); // 546 satoshi in BTC

/// <summary>
/// UTXOs with value less than or equal to this are excluded from coin selection (dust-attack protection).
/// </summary>
public static readonly long MINIMUM_UTXO_VALUE_SATS = 546;

/// <summary>
/// Minimum swap out size in BTC for automatic liquidity management (Swap Out).
/// Can be configured via MINIMUM_SWAP_OUT_SIZE_BTC environment variable.
Expand Down Expand Up @@ -495,6 +500,9 @@ static Constants()
var minSweepTransactionAmount = Environment.GetEnvironmentVariable("MINIMUM_SWEEP_TRANSACTION_AMOUNT_SATS");
if (minSweepTransactionAmount != null) MINIMUM_SWEEP_TRANSACTION_AMOUNT_SATS = long.Parse(minSweepTransactionAmount);

var minimumUtxoValueSats = Environment.GetEnvironmentVariable("MINIMUM_UTXO_VALUE_SATS");
if (minimumUtxoValueSats != null) MINIMUM_UTXO_VALUE_SATS = long.Parse(minimumUtxoValueSats);


DEFAULT_DERIVATION_PATH = GetEnvironmentalVariableOrThrowIfNotTesting("DEFAULT_DERIVATION_PATH") ?? DEFAULT_DERIVATION_PATH;

Expand Down
7 changes: 6 additions & 1 deletion src/Pages/Wallets.razor
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,7 @@
{
foreach (var (_, utxo, tags, isFrozen) in _detailsUTXOs)
{
var isDust = ((Money)utxo.Value).Satoshi <= Constants.MINIMUM_UTXO_VALUE_SATS;
<TableRow>
<TableRowHeader class="utxo-tags">
<a href="@($"{Environment.GetEnvironmentVariable("MEMPOOL_ENDPOINT")}/tx/{utxo.Outpoint.Hash}#vout={utxo.Outpoint.N}")"
Expand All @@ -589,6 +590,10 @@
<TableRowCell>@utxo.Confirmations</TableRowCell>
<TableRowCell>@($"{utxo.Value} BTC")</TableRowCell>
<TableRowCell class="utxo-tags">
@if (isDust)
{
<Badge class="utxo-tag" Color="Color.Warning" Pill title="Dust UTXO, automatically excluded from coin selection">dust</Badge>
}
@if (tags.Any())
{
foreach (var data in tags.Select((tag, i) => new { tag, i }))
Expand All @@ -598,7 +603,7 @@
}
</TableRowCell>
<TableRowCell>
<Check TValue="bool" Checked="@isFrozen"
<Check TValue="bool" Checked="@isFrozen" Disabled="@isDust"
CheckedChanged="@((b) => ToggleUtxoFreeze(b, utxo))"></Check>
</TableRowCell>
</TableRow>
Expand Down
38 changes: 30 additions & 8 deletions src/Rpc/NodeGuardService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1057,17 +1057,39 @@ public override async Task<GetUtxosResponse> GetAvailableUtxos(GetAvailableUtxos
walletUtxos.Unconfirmed.UTXOs.Any(u => u.Outpoint.ToString() == utxo))
.ToList();

// Ignore dust UTXOs server-side too, so they are not counted towards the requested amount
// by any selection strategy and then stripped locally, returning a short selection
var listDust = walletUtxos.Confirmed.UTXOs
.Concat(walletUtxos.Unconfirmed.UTXOs)
.Where(utxo => ((Money)utxo.Value).Satoshi <= Constants.MINIMUM_UTXO_VALUE_SATS)
.Select(utxo => utxo.Outpoint.ToString())
.ToList();

ignoreOutpoints.AddRange(listLocked);
ignoreOutpoints.AddRange(listFrozen);
ignoreOutpoints.AddRange(listDust);

var utxos = await _nbXplorerService.GetUTXOsByLimitAsync(
derivationStrategy,
coinSelectionStrategy,
request.Limit,
request.Amount,
request.ClosestTo,
ignoreOutpoints
);
UTXOChanges utxos;
try
{
utxos = await _nbXplorerService.GetUTXOsByLimitAsync(
derivationStrategy,
coinSelectionStrategy,
request.Limit,
request.Amount,
request.ClosestTo,
ignoreOutpoints
);
}
catch (Exception e)
{
// Preserve this RPC's previous contract: a failing custom backend yields an empty
// selection instead of an error
_logger.LogWarning(e,
"UTXO selection through the custom NBXplorer backend failed for wallet {WalletId}, returning an empty selection",
request.WalletId);
utxos = new UTXOChanges();
}

var confirmedUtxos = utxos.Confirmed.UTXOs.Select(utxo => new Utxo()
{
Expand Down
53 changes: 49 additions & 4 deletions src/Services/CoinSelectionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -148,15 +148,34 @@ public async Task<List<UTXO>> GetLockedUTXOsForRequest(IBitcoinRequest bitcoinRe
return utxos.Confirmed.UTXOs.Where(utxo => lockedUTXOsList.Contains(utxo.Outpoint.ToString())).ToList();
}

private async Task<List<UTXO>> FilterLockedFrozenUTXOs(UTXOChanges? utxoChanges)
private async Task<List<string>> GetLockedFrozenOutpoints()
{
var lockedUTXOs = await _fmutxoRepository.GetLockedUTXOs();
var listLocked = lockedUTXOs.Select(utxo => $"{utxo.TxId}-{utxo.OutputIndex}").ToList();
var listLocked = lockedUTXOs.Select(utxo => $"{utxo.TxId}-{utxo.OutputIndex}").ToList();
var listFrozen = await GetFrozenUTXOs();
var frozenAndLockedOutpoints = new List<string>();
frozenAndLockedOutpoints.AddRange(listLocked);
frozenAndLockedOutpoints.AddRange(listFrozen);

return frozenAndLockedOutpoints;
}

/// <summary>
/// Outpoints that must never be offered for coin selection: locked, frozen and dust UTXOs.
/// </summary>
private async Task<List<string>> GetIgnoredOutpoints(UTXOChanges utxos)
{
var ignoredOutpoints = await GetLockedFrozenOutpoints();
ignoredOutpoints.AddRange(utxos.Confirmed.UTXOs
.Concat(utxos.Unconfirmed.UTXOs)
.Where(utxo => ((Money)utxo.Value).Satoshi <= Constants.MINIMUM_UTXO_VALUE_SATS)
.Select(utxo => utxo.Outpoint.ToString()));
return ignoredOutpoints;
}

private async Task<List<UTXO>> FilterLockedFrozenUTXOs(UTXOChanges? utxoChanges)
{
var frozenAndLockedOutpoints = await GetLockedFrozenOutpoints();

utxoChanges.RemoveDuplicateUTXOs();

var availableUTXOs = new List<UTXO>();
Expand All @@ -167,6 +186,11 @@ private async Task<List<UTXO>> FilterLockedFrozenUTXOs(UTXOChanges? utxoChanges)
{
_logger.LogInformation("Removing UTXO: {Utxo} from UTXO set as it is locked", utxo.Outpoint.ToString());
}
else if (((Money)utxo.Value).Satoshi <= Constants.MINIMUM_UTXO_VALUE_SATS)
{
_logger.LogInformation("Removing UTXO: {Utxo} from UTXO set as it is dust ({Sats} sats <= {MinSats} sats)",
utxo.Outpoint.ToString(), ((Money)utxo.Value).Satoshi, Constants.MINIMUM_UTXO_VALUE_SATS);
}
else
{
availableUTXOs.Add(utxo);
Expand Down Expand Up @@ -206,7 +230,28 @@ public async Task<List<UTXO>> GetAvailableUTXOsAsync(DerivationStrategyBase deri
UTXOChanges utxoChanges;
if (Constants.NBXPLORER_ENABLE_CUSTOM_BACKEND)
{
utxoChanges = await _nbXplorerService.GetUTXOsByLimitAsync(derivationStrategy, strategy, limit, amount, closestTo);
try
{
// Tell the backend which UTXOs to skip (locked, frozen and dust), otherwise it
// counts them towards the requested amount and the local filter below strips them
// afterwards, returning a selection that falls short of that amount
var allUtxos = await _nbXplorerService.GetUTXOsAsync(derivationStrategy);
allUtxos.RemoveDuplicateUTXOs();
var ignoreOutpoints = await GetIgnoredOutpoints(allUtxos);

utxoChanges = await _nbXplorerService.GetUTXOsByLimitAsync(derivationStrategy, strategy, limit, amount, closestTo, ignoreOutpoints);
}
catch (Exception e)
{
// Skip the custom backend entirely and degrade to the plain UTXO listing, same as
// when NBXPLORER_ENABLE_CUSTOM_BACKEND is off: the strategy/amount are no longer
// applied server-side, but the local filter below still strips locked, frozen and
// dust UTXOs, so none of them can leak through
_logger.LogWarning(e,
"UTXO selection through the custom NBXplorer backend failed for strategy {Strategy}, falling back to the plain UTXO listing",
strategy);
utxoChanges = await _nbXplorerService.GetUTXOsAsync(derivationStrategy);
}
}
else
{
Expand Down
7 changes: 4 additions & 3 deletions src/Services/NBXplorerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -179,14 +179,15 @@ public async Task<UTXOChanges> GetUTXOsByLimitAsync(DerivationStrategyBase deriv

return client.Serializer.ToObject<UTXOChanges>(await response.Content.ReadAsStringAsync().ConfigureAwait(false));
}

throw new HttpRequestException(
$"selectutxos request failed with status code {(int)response.StatusCode}: {await response.Content.ReadAsStringAsync(cancellation).ConfigureAwait(false)}");
}
catch (Exception e)
{
_logger.LogError(e.ToString());
throw e;
throw;
}

return new UTXOChanges();
}

public async Task<GetFeeRateResult> GetFeeRateAsync(int blockCount, FeeRate fallbackFeeRate,
Expand Down
Loading
Loading