This commit is contained in:
Kukks
2024-01-08 16:02:37 +01:00
parent 9d47ac08eb
commit 62c6054927
5 changed files with 295 additions and 274 deletions

View File

@@ -11,6 +11,7 @@ using WalletWasabi.Blockchain.TransactionOutputs;
using WalletWasabi.Crypto.Randomness;
using WalletWasabi.Extensions;
using WalletWasabi.Helpers;
using WalletWasabi.WabiSabi;
using WalletWasabi.WabiSabi.Backend.Rounds;
using WalletWasabi.WabiSabi.Client;
using WalletWasabi.Wallets;
@@ -20,12 +21,10 @@ namespace BTCPayServer.Plugins.Wabisabi;
public class BTCPayCoinjoinCoinSelector : IRoundCoinSelector
{
private readonly BTCPayWallet _wallet;
private readonly ILogger _logger;
public BTCPayCoinjoinCoinSelector(BTCPayWallet wallet, ILogger logger)
public BTCPayCoinjoinCoinSelector(BTCPayWallet wallet)
{
_wallet = wallet;
_logger = logger;
}
public async Task<ImmutableList<SmartCoin>> SelectCoinsAsync(IEnumerable<SmartCoin> coinCandidates,
@@ -98,12 +97,13 @@ public class BTCPayCoinjoinCoinSelector : IRoundCoinSelector
ConsolidationModeType.WhenLowFeeAndManyUTXO => isLowFee && coinCandidates.Count() > BTCPayWallet.HighAmountOfCoins,
_ => throw new ArgumentOutOfRangeException()
};
Dictionary<AnonsetType, int> idealMinimumPerType = new Dictionary<AnonsetType, int>()
{{AnonsetType.Red, 1}, {AnonsetType.Orange, 1}, {AnonsetType.Green, 1}};
var solution = await SelectCoinsInternal(utxoSelectionParameters, coinCandidates, payments,
Random.Shared.Next(10, 31),
maxPerType,
new Dictionary<AnonsetType, int>() {{AnonsetType.Red, 1}, {AnonsetType.Orange, 1}, {AnonsetType.Green, 1}},
idealMinimumPerType,
consolidationMode, liquidityClue, secureRandom);
if (attemptingTobeParanoid && !solution.HandledPayments.Any())
@@ -119,7 +119,7 @@ public class BTCPayCoinjoinCoinSelector : IRoundCoinSelector
attemptingToMixToOtherWallet = false;
goto selectCoins;
}
_logger.LogTrace(solution.ToString());
_wallet.LogTrace(solution.ToString());
return solution.Coins.ToImmutableList();
}
@@ -140,19 +140,26 @@ public class BTCPayCoinjoinCoinSelector : IRoundCoinSelector
var solution = new SubsetSolution(remainingPendingPayments.Count, _wallet.AnonScoreTarget,
utxoSelectionParameters);
var cv = new CoinsView(remainingCoins);
var percentage = await _wallet.GetPrivacyPercentageAsync(cv);
var fullyPrivate = await _wallet.IsWalletPrivateAsync(new CoinsView(remainingCoins));
var coinjoiningOnlyForPayments = fullyPrivate && remainingPendingPayments.Any();
if (!consolidationMode && percentage < 1 && _wallet.ConsolidationMode != ConsolidationModeType.Never)
{
consolidationMode = true;
}
solution.ConsolidationMode = consolidationMode;
if (fullyPrivate && !coinjoiningOnlyForPayments )
{
var rand = Random.Shared.Next(1, 1001);
if (rand > _wallet.WabisabiStoreSettings.ExtraJoinProbability)
{
_logger.LogTrace($"All coins are private and we have no pending payments. Skipping join.");
_wallet.LogTrace($"All coins are private and we have no pending payments. Skipping join.");
return solution;
}
_logger.LogTrace(
_wallet.LogTrace(
"All coins are private and we have no pending payments but will join just to reduce timing analysis");
}
@@ -234,6 +241,8 @@ public class BTCPayCoinjoinCoinSelector : IRoundCoinSelector
{
continue;
}
//if we have less than the max suggested output registration, we should add more coins to reach that number to avoid breaking up into too many coins?
var isLessThanMaxOutputRegistration = solution.Coins.Count < Math.Max(solution.HandledPayments.Count +1, 8);
var rand = Random.Shared.Next(1, 101);
@@ -253,10 +262,17 @@ public class BTCPayCoinjoinCoinSelector : IRoundCoinSelector
chance -= maxCoinCapacityPercentage;
}
_logger.LogDebug($"coin selection: no payments left but at {solution.Coins.Count()} coins. random chance to add another coin if: {chance} > {rand} (random 0-100) continue: {chance > rand}");
if (chance <= rand)
{
if (_wallet.MinimumDenominationAmount is not null &&
Money.Coins(solution.LeftoverValue).Satoshi < _wallet.MinimumDenominationAmount)
{
_wallet.LogDebug(
$"coin selection: leftover value {solution.LeftoverValue} is less than minimum denomination amount {_wallet.MinimumDenominationAmount} so we will try to add more coins");
continue;
}
_wallet.LogDebug($"coin selection: no payments left but at {solution.Coins.Count()} coins. random chance to add another coin if: {chance} > {rand} (random 0-100) continue: {chance > rand}");
break;
}
@@ -265,7 +281,7 @@ public class BTCPayCoinjoinCoinSelector : IRoundCoinSelector
if (coinjoiningOnlyForPayments && solution.HandledPayments?.Any() is not true)
{
_logger.LogInformation(
_wallet.LogInfo(
"Attempted to coinjoin only to fulfill payments but the coin selection results yielded no handled payment.");
return new SubsetSolution(remainingPendingPayments.Count, _wallet.AnonScoreTarget,
utxoSelectionParameters);
@@ -326,8 +342,6 @@ public static class SmartCoinExtensions
public static AnonsetType CoinColor(this SmartCoin coin, int anonsetTarget)
{
return coin.IsPrivate(anonsetTarget)? AnonsetType.Green: coin.IsSemiPrivate(anonsetTarget)? AnonsetType.Orange: AnonsetType.Red;
return coin.AnonymitySet <= 1 ? AnonsetType.Red :
coin.AnonymitySet >= anonsetTarget ? AnonsetType.Green : AnonsetType.Orange;
}
}
@@ -365,10 +379,10 @@ public class SubsetSolution
payment.ToTxOut().EffectiveCost(_utxoSelectionParameters.MiningFeeRate).ToDecimal(MoneyUnit.BTC));
public decimal LeftoverValue => TotalValue - TotalPaymentCost;
public bool ConsolidationMode { get; set; }
public override string ToString()
{
var sb = new StringBuilder();
if (!Coins.Any())
{
return "Solution yielded no selection of coins";
@@ -378,20 +392,8 @@ public class SubsetSolution
sc.TryGetValue(AnonsetType.Green, out var gcoins);
sc.TryGetValue(AnonsetType.Orange, out var ocoins);
sc.TryGetValue(AnonsetType.Red, out var rcoins);
sb.AppendLine(
$"Solution total coins:{Coins.Count} R:{rcoins?.Length ?? 0} O:{ocoins?.Length ?? 0} G:{gcoins?.Length ?? 0} AL:{GetAnonLoss(Coins)} total value: {TotalValue} total payments:{TotalPaymentCost}/{TotalPaymentsGross} leftover: {LeftoverValue}");
if (HandledPayments.Any())
sb.AppendLine($"handled payments: {string.Join(", ", HandledPayments.Select(p => p.Value))} ");
return sb.ToString();
}
private static decimal GetAnonLoss<TCoin>(IEnumerable<TCoin> coins)
where TCoin : SmartCoin
{
double minimumAnonScore = coins.Min(x => x.AnonymitySet);
var rawSum = coins.Sum(x => x.Amount);
return coins.Sum(x =>
((decimal)x.AnonymitySet - (decimal)minimumAnonScore) * x.Amount.ToDecimal(MoneyUnit.BTC)) / rawSum;
return $"Selected {Coins.Count} ({TotalValue} BTC) ({ocoins?.Length + rcoins?.Length} not private, {gcoins?.Length ?? 0} private) coins to pay {TotalPaymentsGross} payments ({TotalPaymentCost} BTC) with {LeftoverValue} BTC leftover\n Consolidation mode:{ConsolidationMode}";
}
}

View File

@@ -13,7 +13,7 @@
<PropertyGroup>
<Product>Coinjoin</Product>
<Description>Allows you to integrate your btcpayserver store with coinjoins.</Description>
<Version>1.0.67</Version>
<Version>1.0.68</Version>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
</PropertyGroup>

View File

@@ -51,7 +51,7 @@ public class BTCPayWallet : IWallet, IDestinationProvider
// public readonly IBTCPayServerClientFactory BtcPayServerClientFactory;
public WabisabiStoreSettings WabisabiStoreSettings;
public readonly IUTXOLocker UtxoLocker;
public readonly ILogger Logger;
// public readonly ILogger Logger;
public static readonly BlockchainAnalyzer BlockchainAnalyzer = new();
public BTCPayWallet(
@@ -85,12 +85,12 @@ public class BTCPayWallet : IWallet, IDestinationProvider
UtxoLocker = utxoLocker;
_storeRepository = storeRepository;
_memoryCache = memoryCache;
Logger = loggerFactory.CreateLogger($"BTCPayWallet_{storeId}");
_logger = loggerFactory.CreateLogger($"BTCPayWallet_{storeId}");
}
public string StoreId { get; set; }
public List<(Microsoft.Extensions.Logging.LogLevel, string)> LastLogs { get; private set; } = new();
public List<(DateTimeOffset time, Microsoft.Extensions.Logging.LogLevel level , string message)> LastLogs { get; private set; } = new();
public void Log(LogLevel logLevel, string logMessage, string callerFilePath = "", string callerMemberName = "",
int callerLineNumber = -1)
{
@@ -104,12 +104,12 @@ public class BTCPayWallet : IWallet, IDestinationProvider
LogLevel.Critical => Microsoft.Extensions.Logging.LogLevel.Critical,
_ => throw new ArgumentOutOfRangeException(nameof(logLevel))
};
if(LastLogs.FirstOrDefault().Item2 != logMessage)
LastLogs.Insert(0, (ll, logMessage) );
if(LastLogs.FirstOrDefault().message != logMessage)
LastLogs.Insert(0, (DateTimeOffset.Now, ll, logMessage) );
if (LastLogs.Count >= 100)
LastLogs.RemoveLast();
Logger.Log(ll, logMessage, callerFilePath, callerMemberName, callerLineNumber);
_logger.Log(ll, logMessage, callerFilePath, callerMemberName, callerLineNumber);
}
public string WalletName => StoreId;
@@ -147,16 +147,16 @@ public class BTCPayWallet : IWallet, IDestinationProvider
public async Task<bool> IsWalletPrivateAsync(CoinsView coins)
{
var privacy= GetPrivacyPercentage(coins, AnonScoreTarget);
var privacy= await GetPrivacyPercentageAsync(coins);
var mixToOtherWallet = !WabisabiStoreSettings.PlebMode && !string.IsNullOrEmpty(WabisabiStoreSettings
.MixToOtherWallet);
var forceConsolidate = ConsolidationMode == ConsolidationModeType.WhenLowFeeAndManyUTXO && coins.Available().Confirmed().Count() > HighAmountOfCoins;
return !BatchPayments && privacy >= 1 && !mixToOtherWallet && !forceConsolidate;
}
public async Task<double> GetPrivacyPercentageAsync()
public async Task<double> GetPrivacyPercentageAsync(CoinsView coins)
{
return GetPrivacyPercentage(await GetAllCoins(), AnonScoreTarget);
return GetPrivacyPercentage(coins, AnonScoreTarget);
}
public async Task<CoinsView> GetAllCoins()
@@ -199,7 +199,7 @@ public class BTCPayWallet : IWallet, IDestinationProvider
public IRoundCoinSelector GetCoinSelector()
{
_coinSelector??= new BTCPayCoinjoinCoinSelector(this, Logger );
_coinSelector??= new BTCPayCoinjoinCoinSelector(this );
return _coinSelector;
}
@@ -213,9 +213,8 @@ public class BTCPayWallet : IWallet, IDestinationProvider
{
try
{
var successfulCoinJoinResult = (await finishedCoinJoin.CoinJoinTask) as SuccessfulCoinJoinResult;
if(await finishedCoinJoin.CoinJoinTask is not SuccessfulCoinJoinResult successfulCoinJoinResult)
return;
await RegisterCoinjoinTransaction(successfulCoinJoinResult,
finishedCoinJoin.CoinJoinClient.CoordinatorName);
}
@@ -410,6 +409,7 @@ public class BTCPayWallet : IWallet, IDestinationProvider
}
private Task _savingProgress = Task.CompletedTask;
private readonly ILogger _logger;
public async Task RegisterCoinjoinTransaction(SuccessfulCoinJoinResult result, string coordinatorName)
{
@@ -419,6 +419,13 @@ public class BTCPayWallet : IWallet, IDestinationProvider
}
private async Task RegisterCoinjoinTransactionInternal(SuccessfulCoinJoinResult result, string coordinatorName)
{
var attempts = 0;
while (attempts < 5)
{
//wait longer between attempts
await Task.Delay(TimeSpan.FromSeconds(attempts * 3));
attempts++;
try
{
var stopwatch = new Stopwatch();
@@ -428,7 +435,9 @@ public class BTCPayWallet : IWallet, IDestinationProvider
WellknownMetadataKeys.AccountKeyPath);
var storeIdForutxo = WabisabiStoreSettings.PlebMode ||
string.IsNullOrEmpty(WabisabiStoreSettings.MixToOtherWallet)? StoreId: WabisabiStoreSettings.MixToOtherWallet;
string.IsNullOrEmpty(WabisabiStoreSettings.MixToOtherWallet)
? StoreId
: WabisabiStoreSettings.MixToOtherWallet;
var utxoDerivationScheme = DerivationScheme;
if (storeIdForutxo != StoreId)
{
@@ -436,6 +445,7 @@ public class BTCPayWallet : IWallet, IDestinationProvider
var scheme = s.GetDerivationSchemeSettings(_btcPayNetworkProvider, "BTC");
utxoDerivationScheme = scheme.AccountDerivation;
}
List<(IndexedTxOut txout, Task<KeyPathInformation>)> scriptInfos = new();
@@ -443,7 +453,8 @@ public class BTCPayWallet : IWallet, IDestinationProvider
foreach (var script in result.Outputs)
{
var txout = result.UnsignedCoinJoin.Outputs.AsIndexedOutputs()
.Single(@out => @out.TxOut.ScriptPubKey == script.ScriptPubKey && @out.TxOut.Value == script.Value);
.Single(@out =>
@out.TxOut.ScriptPubKey == script.ScriptPubKey && @out.TxOut.Value == script.Value);
//this was not a mix to self, but rather a payment
@@ -455,12 +466,18 @@ public class BTCPayWallet : IWallet, IDestinationProvider
continue;
}
var privateEnough = result.Coins.All(c => c.AnonymitySet >= WabisabiStoreSettings.AnonymitySetTarget );
scriptInfos.Add((txout, ExplorerClient.GetKeyInformationAsync(BlockchainAnalyzer.StdDenoms.Contains(txout.TxOut.Value)&& privateEnough?utxoDerivationScheme:DerivationScheme, script.ScriptPubKey)));
var privateEnough =
result.Coins.All(c => c.AnonymitySet >= WabisabiStoreSettings.AnonymitySetTarget);
scriptInfos.Add((txout,
ExplorerClient.GetKeyInformationAsync(
BlockchainAnalyzer.StdDenoms.Contains(txout.TxOut.Value) && privateEnough
? utxoDerivationScheme
: DerivationScheme, script.ScriptPubKey)));
}
await Task.WhenAll(scriptInfos.Select(t => t.Item2));
var scriptInfos2 = scriptInfos.Where(tuple => tuple.Item2.Result is not null).ToDictionary(tuple => tuple.txout.TxOut.ScriptPubKey);
var scriptInfos2 = scriptInfos.Where(tuple => tuple.Item2.Result is not null)
.ToDictionary(tuple => tuple.txout.TxOut.ScriptPubKey);
var smartTx = new SmartTransaction(result.UnsignedCoinJoin, new Height(HeightType.Unknown));
result.Coins.ForEach(coin =>
{
@@ -472,7 +489,8 @@ public class BTCPayWallet : IWallet, IDestinationProvider
{
if (scriptInfos2.TryGetValue(s.ScriptPubKey, out var si))
{
var derivation = DerivationScheme.GetChild(si.Item2.Result.KeyPath).GetExtPubKeys().First().PubKey;
var derivation = DerivationScheme.GetChild(si.Item2.Result.KeyPath).GetExtPubKeys().First()
.PubKey;
var hdPubKey = new HdPubKey(derivation, kp.Derive(si.Item2.Result.KeyPath).KeyPath,
LabelsArray.Empty,
@@ -489,7 +507,7 @@ public class BTCPayWallet : IWallet, IDestinationProvider
}
catch (Exception e)
{
Logger.LogError($"Failed to analyze anonsets of tx {smartTx.GetHash()}");
this.LogError($"Failed to analyze anonsets of tx {smartTx.GetHash()}");
}
@@ -534,8 +552,10 @@ public class BTCPayWallet : IWallet, IDestinationProvider
if (result.HandledPayments.Any())
{
attachments.AddRange(result.HandledPayments.Select(payment => new Attachment("payout", payment.Value.Identifier)));
attachments.AddRange(result.HandledPayments.Select(payment =>
new Attachment("payout", payment.Value.Identifier)));
}
List<(WalletId wallet, string id, IEnumerable<Attachment> attachments, string type )> objects = new();
objects.Add((new WalletId(StoreId, "BTC"),
@@ -593,34 +613,21 @@ public class BTCPayWallet : IWallet, IDestinationProvider
stopwatch.Stop();
Logger.LogInformation($"Registered coinjoin result for {StoreId} in {stopwatch.Elapsed}");
this.LogInfo($"Registered coinjoin result for {StoreId} in {stopwatch.Elapsed}");
_memoryCache.Remove(WabisabiService.GetCacheKey(StoreId) + "cjhistory");
break;
}
catch (Exception e)
{
Logger.LogError(e, "Could not save coinjoin progress!");
this.LogError( "Could not save coinjoin progress! " + e.Message);
// ignored
}
}
// public async Task UnlockUTXOs()
// {
// var client = await BtcPayServerClientFactory.Create(null, StoreId);
// var utxos = await client.GetOnChainWalletUTXOs(StoreId, "BTC");
// var unlocked = new List<string>();
// foreach (OnChainWalletUTXOData utxo in utxos)
// {
//
// if (await UtxoLocker.TryUnlock(utxo.Outpoint))
// {
// unlocked.Add(utxo.Outpoint.ToString());
// }
// }
//
// Logger.LogTrace($"unlocked utxos: {string.Join(',', unlocked)}");
// }
}
}
public async Task<IEnumerable<IDestination>> GetNextDestinationsAsync(int count, bool mixedOutputs, bool privateEnough)
{

View File

@@ -13,7 +13,6 @@
@inject WabisabiCoordinatorClientInstanceManager WabisabiCoordinatorClientInstanceManager
<script src="~/Resources/chart.js" type="text/javascript"> </script>
@inject IExplorerClientProvider ExplorerClientProvider
@{
var available = true;
@@ -21,6 +20,7 @@
{
return;
}
var storeId = ScopeProvider.GetCurrentStoreId();
Context.Items["cjlite"] = true;
}
@@ -78,11 +78,9 @@
id = coin.Outpoint.ToString(),
coinjoinInProgress = coin.CoinJoinInProgress
}).OrderBy(coin => coin.isPrivate).ThenBy(coin => coin.score),
};
@if (coins.Any())
{
<script>
document.addEventListener("DOMContentLoaded", function () {
@@ -261,6 +259,7 @@ updateInProgressAnimation(myChart);
});
</script>
}
<div class="widget store-numbers">
@if (wallet is { })
@@ -289,8 +288,9 @@ updateInProgressAnimation(myChart);
@if (coins.Any())
{
<div class="d-flex justify-content-center mb-4" style="max-height: 400px; "> <canvas id="cjchart"></canvas></div>
<div class="d-flex justify-content-center mb-4" style="max-height: 400px; ">
<canvas id="cjchart"></canvas>
</div>
}
<div class="modal modal-lg fade" id="coins" data-bs-keyboard="false" tabindex="-1">
@@ -372,6 +372,7 @@ updateInProgressAnimation(myChart);
tracker?.CoinJoinClient?.RoundStatusUpdater?.RoundStates?.TryGetValue(tracker?.CoinJoinClient?.CurrentRoundId, out currentRound) is true)
{
}
var statusMsg = coordinator.WasabiCoordinatorStatusFetcher.Connected ? $"Connected to {(coordinator.Coordinator?.ToString() ?? "local")}" : $"Not connected to {(coordinator.Coordinator?.ToString() ?? "local")}";
<div class="list-group-item">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3">
@@ -422,7 +423,9 @@ updateInProgressAnimation(myChart);
</tr>
<tr>
<th scope="">Round id</th>
<td class="text-truncate"><vc:truncate-center text="@currentRound.Id.ToString()" classes="truncate-center-id"></vc:truncate-center></td>
<td class="text-truncate">
<vc:truncate-center text="@currentRound.Id.ToString()" classes="truncate-center-id"></vc:truncate-center>
</td>
</tr>
<tr>
<th scope="">Mining feerate</th>
@@ -439,7 +442,9 @@ updateInProgressAnimation(myChart);
{
statement += $" / {tracker.CoinJoinClient.CoinsToRegister.Count()} inputs ({tracker.CoinJoinClient.CoinsToRegister.Sum(coin => coin.Amount.ToDecimal(MoneyUnit.BTC))} BTC)";
}
<tr>
var inputToolTip = tracker.CoinJoinClient.CoinsToRegister.Aggregate("", (current, coin) => current + $"{coin.Amount.ToDecimal(MoneyUnit.BTC)} BTC\n");
<tr data-bs-toggle="tooltip" title="@inputToolTip">
<th scope="">Your inputs</th>
<td class="">
<span class="w-100">@statement</span>
@@ -460,8 +465,10 @@ updateInProgressAnimation(myChart);
if (tracker.CoinJoinClient.OutputTxOuts is { } outputs)
{
var statement = $"{outputs.outputTxOuts.Count()} outputs ({outputs.outputTxOuts.Sum(coin => coin.Value.ToDecimal(MoneyUnit.BTC))} BTC {(outputs.batchedPayments.Any() ? $"{outputs.batchedPayments.Count()} batched payments" : "")}";
var outputToolTip = outputs.outputTxOuts.Aggregate("", (current, output) => current + $"{output.Value.ToDecimal(MoneyUnit.BTC)} BTC\n");
<tr>
<tr data-bs-toggle="tooltip" title="@outputToolTip">
<th scope="">Your outputs</th>
<td >@statement</td>
</tr>
@@ -470,8 +477,6 @@ updateInProgressAnimation(myChart);
</table>
</div>
}
}
</div>
@@ -481,14 +486,12 @@ updateInProgressAnimation(myChart);
@if (coins.Any())
{
<button type="button" class="btn btn-text p-1" data-bs-toggle="modal" data-bs-target="#coins">
View coins
</button>
}
@if (wallet.LastLogs.Any())
{
<button type="button" class="btn btn-text p-1" data-bs-toggle="modal" data-bs-target="#logs">
Recent logs
</button>
@@ -499,19 +502,27 @@ updateInProgressAnimation(myChart);
<h3 class="mb-0">Coinjoin logs</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body table-responsive mt-0">
<div class="modal-body mt-0">
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Time</th>
<th>Message</th>
</tr>
</thead>
<tbody>
@foreach (var evt in wallet.LastLogs)
{
string cssClass = evt.Item1 <= (LogLevel) 2 ? "info" : evt.Item1 == (LogLevel) 4 ? "warning" : "danger";
string cssClass = evt.level <= (LogLevel) 2 ? "info" : evt.level == (LogLevel) 4 ? "warning" : "danger";
<tr class="text-@cssClass">
<td>@evt.Item2</td>
<td>
<small class="text-muted" data-timeago-unixms="@evt.time.ToUnixTimeMilliseconds()">@evt.time.ToTimeAgo()</small>
</td>
<td>
<pre>@evt.message</pre>
</td>
</tr>
}
</tbody>
@@ -523,6 +534,7 @@ updateInProgressAnimation(myChart);
</div>
</div>
</div>
</div>
}
</div>