Files
BTCPayServerPlugins/Plugins/BTCPayServer.Plugins.Wabisabi/Views/Shared/Wabisabi/WabisabiDashboard.cshtml
2024-09-20 12:39:02 +02:00

559 lines
30 KiB
Plaintext

@using BTCPayServer.Abstractions.Contracts
@using BTCPayServer.Abstractions.TagHelpers
@using BTCPayServer.Client
@using BTCPayServer.Common
@using BTCPayServer.Components.TruncateCenter
@using BTCPayServer.Plugins.Wabisabi
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using Microsoft.Extensions.Logging
@using NBitcoin
@using WalletWasabi.WabiSabi.Backend.Rounds
@using WalletWasabi.WabiSabi.Client
@using WalletWasabi.WabiSabi.Models
@model object
@inject IScopeProvider ScopeProvider
@inject WabisabiService WabisabiService;
@inject WalletProvider WalletProvider;
@inject WabisabiCoordinatorClientInstanceManager WabisabiCoordinatorClientInstanceManager
<script src="~/Resources/chart.js" type="text/javascript"> </script>
@{
var available = true;
@if (((dynamic) Model).CryptoCode != "BTC" || ((dynamic) Model).WalletEnabled is not true)
{
return;
}
var storeId = ScopeProvider.GetCurrentStoreId();
Context.Items["cjlite"] = true;
}
@if (available)
{
{
var settings = await WabisabiService.GetWabisabiForStore(storeId);
var enabledSettings = settings.Settings.Where(coordinatorSettings => coordinatorSettings.Enabled);
var cjHistory = (await WabisabiService.GetCoinjoinHistory(storeId)).Take(5).ToList();
@if (!enabledSettings.Any())
{
<div class="widget" permission="@Policies.CanModifyStoreSettings">
<partial name="../WabisabiStore/UpdateWabisabiStoreSettings" model="@settings"/>
</div>
}
else
{
<div class="widget store-wallet-balance">
<header>
<h3>Recent Coinjoins</h3>
@if (cjHistory.Any())
{
<a asp-controller="WabisabiStore" asp-action="ListCoinjoins" asp-route-storeId="@storeId">View All</a>
}
</header>
@if (!cjHistory.Any())
{
<p class="text-secondary mt-3 mb-0">
There are no recent transactions.
</p>
}
else
{
<partial name="Wabisabi/CoinjoinHistoryTable" model="cjHistory"/>
}
</div>
var wallet = (BTCPayWallet?) await WalletProvider.GetWalletAsync(storeId);
var coins = await wallet.GetAllCoins();
var privacy = wallet.GetPrivacyPercentage(coins, wallet.AnonScoreTarget);
var privacyPercentage = Math.Round(privacy * 100);
var data = new
{
privacyProgress = privacyPercentage,
targetScore = wallet.AnonScoreTarget,
coins = coins.Select(coin => new
{
value = coin.Amount.ToDecimal(MoneyUnit.BTC),
score = coin.AnonymitySet,
isPrivate = coin.CoinColor(wallet) == AnonsetType.Green,
confirmed = coin.Confirmed,
id = coin.Outpoint.ToString(),
coinjoinInProgress = coin.CoinJoinInProgress
}).OrderBy(coin => coin.isPrivate).ThenBy(coin => coin.score),
};
@if (coins.Any())
{
<script>
document.addEventListener("DOMContentLoaded", function () {
function getColor(isPrivate, score, maxScore) {
let normalizedScore = Math.min(Math.max(score, 0), maxScore) / maxScore;
return isPrivate ? `rgb(81, 177, 62)` : `rgb(255, ${Math.floor(128 * normalizedScore)}, 0)`;
}
function prepareDatasets(data) {
const coins = data.coins;
const confirmedCoins = coins;
const inProgressCoins = coins.filter(coin => coin.coinjoinInProgress);
return [
{
id: "coins",
label: "Coins",
coins: confirmedCoins,
data: confirmedCoins.map(coin => coin.value),
backgroundColor: confirmedCoins.map(coin => getColor(coin.isPrivate, coin.score, data.targetScore)),
borderColor: "transparent",
borderWidth: 3
},
{
id: "inprogresscoins",
label: "In Progress Coins",
data: inProgressCoins.map(coin => coin.value),
backgroundColor: inProgressCoins.map(() => "rgb(56, 151, 37)"),
alternativeBackgroundColor: inProgressCoins.map(() => "rgb(28, 113, 11)"),
borderColor: "transparent",
borderWidth: 3,
coins: inProgressCoins
}
];
}
const data = @Json.Serialize(data);
const chartDataset = {
labels: data.coins.map(coin => coin.id),
datasets: prepareDatasets(data)
};
const config = {
type: "doughnut",
data: chartDataset,
options: {
cutout: "70%",
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
labelColor: ()=>{ return undefined },
label: function (context) {
const coin = context.dataset.coins[context.dataIndex];
return [
`${coin.value.toFixed(8)} BTC`,
`${coin.score.toFixed(2)} anonset`,
`${coin.confirmed ? "confirmed" : "unconfirmed"}`,
coin.isPrivate ? "private" : "not private",
...(coin.coinjoinInProgress ? ["mixing"] : []),
...(!coin.isPrivate && coin.score >= data.targetScore? ["Coin too close to entry to deem private just yet"] : [])
];
},
title: function (context) {
const coin = context[0].dataset.coins[context[0].dataIndex];
//truncated shoudl have first few chars and then ... and then last few chars
const truncatedId = coin.id.substring(0, 8) + "..." + coin.id.substring(coin.id.length - 8);
return truncatedId;
},
footer: function (context) {}
}
}
}
},
plugins: [{
beforeDraw: function (chart) {
let ctx = chart.ctx;
ctx.save();
let width = chart.width;
let height = chart.height;
ctx.textBaseline = "middle";
ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('color');
function calculateFontSize(text, maxWidth, initialFontSize) {
let fontSize = initialFontSize;
ctx.font = fontSize + "em sans-serif";
while (ctx.measureText(text).width > maxWidth && fontSize > 0) {
fontSize -= 0.1;
ctx.font = fontSize + "em sans-serif";
}
return fontSize;
}
function getTextHeight(text, fontSize) {
ctx.font = fontSize + "em sans-serif";
let metrics = ctx.measureText(text);
return metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent +5;
}
function drawCenteredText(text, posY, maxWidth, fontSize) {
ctx.font = fontSize + "em sans-serif";
let textX = (width - ctx.measureText(text).width) / 2;
ctx.fillText(text, textX, posY);
return getTextHeight(text, fontSize);
}
let pfs = height / 114;
let textY = height / 4;
let maxWidth = width * 0.6;
let lineTexts = [];
const totalPrivateSum = data.coins.filter(coin => coin.isPrivate).length;
const totalPrivateValueSum = data.coins.filter(coin => coin.isPrivate).reduce((sum, coin) => sum + coin.value, 0);
if (totalPrivateSum > 0)
lineTexts.push(`${totalPrivateSum} coins(${totalPrivateValueSum.toFixed(8)}BTC) private`);
const totalNonPrivateSum = data.coins.filter(coin => !coin.isPrivate).length;
const totalNonPrivateValueSum = data.coins.filter(coin => !coin.isPrivate).reduce((sum, coin) => sum + coin.value, 0);
if (totalNonPrivateSum > 0)
lineTexts.push(`${totalNonPrivateSum} coins(${totalNonPrivateValueSum.toFixed(8)}BTC) semi/not private`);
const totalInProgressSum = data.coins.filter(coin => coin.coinjoinInProgress).length;
const totalInProgressValueSum = data.coins.filter(coin => coin.coinjoinInProgress).reduce((sum, coin) => sum + coin.value, 0);
if (totalInProgressSum > 0)
lineTexts.push(`${totalInProgressSum} coins(${totalInProgressValueSum.toFixed(8)}BTC) mixing`);
let commonFontSize = lineTexts.reduce(
(size, text) => Math.min(size, calculateFontSize(text, maxWidth, pfs * 0.7)),
pfs * 0.7
);
let totalTextHeight = getTextHeight(`${data.privacyProgress}%`, pfs) +
getTextHeight("Private",pfs) + 10 +
lineTexts.reduce((totalHeight, text) => totalHeight + getTextHeight(text, commonFontSize), 0);
// Adjust initial Y position for vertical centering
textY = (height - totalTextHeight) / 2;
// Draw the main text (privacy progress) and additional summary text
textY += drawCenteredText(`${data.privacyProgress}%`, textY, maxWidth, pfs);
textY += drawCenteredText("Private",textY, maxWidth, pfs) +10;
lineTexts.forEach(text => {
textY += drawCenteredText(text, textY, maxWidth, commonFontSize);
});
ctx.restore();
}
}]
};
const ctx = document.getElementById("cjchart").getContext("2d");
const myChart = new Chart(ctx, config);
function updateInProgressAnimation(chart) {
chart.data.datasets.forEach(dataset => {
if (dataset.id === "inprogresscoins") {
dataset.backgroundColor = dataset.backgroundColor.map((c, i) => {
const alt = dataset.alternativeBackgroundColor[i];
const current = dataset.backgroundColor[i];
dataset.alternativeBackgroundColor[i] = current;
return alt;
});
}
});
chart.update();
setTimeout(() => updateInProgressAnimation(chart), 1000);
}
updateInProgressAnimation(myChart);
});
</script>
}
<div class="widget store-numbers">
@if (wallet is { })
{
@if (!((BTCPayKeyChain) wallet.KeyChain).KeysAvailable)
{
<div class="alert alert-danger d-flex align-items-center" role="alert">
<vc:icon symbol="warning"/>
<span class="ms-3">This wallet is either not a hot wallet, or enabled in yout store settings and will not be able to participate in coinjoins.</span>
</div>
}
}
<header>
<h4>Coinjoin stats</h4>
<div class="d-flex gap-1">
<a asp-controller="WabisabiStore" asp-action="ToggleActive" asp-route-storeId="@storeId" asp-route-returnUrl="@Context.Request.GetCurrentUrl()" class="fw-semibold">
@(settings.Active ? "Deactivate" : "Activate")
</a>
-
<a asp-controller="WabisabiStore" asp-action="UpdateWabisabiStoreSettings" asp-route-storeId="@storeId" class="fw-semibold" permission="@Policies.CanModifyStoreSettings">
Configure
</a>
</div>
</header>
<div class="w-100">
@if (coins.Any())
{
<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">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5">Your coins</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<table class="table">
<thead>
<tr>
<th colspan="3">Coins</th>
</tr>
<tr>
<th>
Anonset
</th>
<th>
Value
</th>
<th>
Labels
</th>
</tr>
</thead>
@foreach (var coin in coins.OrderByDescending(coin => coin.AnonymitySet).ThenByDescending(coin => coin.Amount))
{
<tr>
<td>
@coin.AnonymitySet.ToString("0.##")
@if (!coin.IsSufficientlyDistancedFromExternalKeys)
{
<span>(too close to entry)</span>
}
</td>
<td>
@coin.Amount.ToDecimal(MoneyUnit.BTC) BTC
</td>
<td>
@for (var index = 0; index < coin.HdPubKey.Labels.Count; index++)
{
var label = coin.HdPubKey.Labels.ElementAt(index);
<vc:truncate-center text="@label" classes="truncate-center-id"></vc:truncate-center>
}
</td>
</tr>
}
</table>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<div class="list-group list-group-flush mb-3">
@if (enabledSettings.Count() > 1)
{
<h5 class="list-group-item-heading text-muted">Enabled coordinators</h5>
}
@{
foreach (var setting in enabledSettings)
{
if (!WabisabiCoordinatorClientInstanceManager.HostedServices.TryGetValue(setting.Coordinator, out var coordinator))
{
continue;
}
RoundState currentRound = null;
CoinJoinTracker tracker = null;
if (coordinator.CoinJoinManager.TrackedCoinJoins?.TryGetValue(wallet.WalletId, out tracker) is true &&
tracker?.CoinJoinClient?.CurrentRoundId is { } &&
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">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3" data-bs-toggle="tooltip" title="@statusMsg">
<span class="btcpay-status btcpay-status--@(coordinator.WasabiCoordinatorStatusFetcher.Connected ? "enabled" : "disabled")"></span>
<h6>@coordinator.CoordinatorDisplayName</h6>
</div>
@if (currentRound is not null)
{
<div class="timer cursor-pointer" data-bs-toggle="collapse" data-bs-target="#cj-@currentRound.Id">
<span class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden"></span>
</span>
<span class="h6">Mixing</span>
<vc:icon symbol="caret-down"/>
</div>
}
else if (coordinator.WasabiCoordinatorStatusFetcher.Connected)
{
var roundParameters = coordinator.RoundStateUpdater.RoundStates.LastOrDefault(pair => pair.Value.BlameOf == uint256.Zero).Value?.CoinjoinState.Parameters;
coordinator.CoinJoinManager.TrackedWallets.TryGetValue(wallet.WalletId, out var trackedWallet);
if(trackedWallet is {} && setting.RoundWhenEnabled is not null && roundParameters is not null && !BTCPayWallet.IsRoundOk(roundParameters, setting))
{
<a asp-controller="WabisabiStore" asp-action="UpdateWabisabiStoreSettings" asp-route-storeId="@storeId" class="h6 text-danger">
New terms detected
</a>
}else if (trackedWallet is {})
{
<span class="h6">Idle</span>
}
else
{
<span class="h6">Inactive</span>
}
}
</div>
@{
if (coordinator.CoinPrison is not null)
{
var bannedCoins = coins.Where(coin => coordinator.CoinPrison.IsBanned(coin.Outpoint)).ToArray();
@if (bannedCoins.Any())
{
<div class="text-muted">@bannedCoins.Count() banned coins(for disrupting rounds)</div>
}
}
if (currentRound is not null)
{
<div class="collapse table-responsive @(enabledSettings.Count() == 1 ? "show" : "") m-0 w-100" id="cj-@currentRound.Id">
<table class="table w-100">
<tr>
<th scope="">Status</th>
<td class="text-truncate">@currentRound.Phase.ToString().ToSentenceCase()</td>
</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>
</tr>
<tr>
<th scope="">Mining feerate</th>
<td >@currentRound.CoinjoinState.Parameters.MiningFeeRate.ToString()</td>
</tr>
<tr>
<th scope="">Coinjoin total inputs</th>
<td >@currentRound.CoinjoinState.Inputs.Count() inputs (@currentRound.CoinjoinState.Inputs.Sum(coin => coin.Amount.ToDecimal(MoneyUnit.BTC)) BTC)</td>
</tr>
@if (!tracker.CoinJoinClient.CoinsToRegister.IsEmpty)
{
var statement = $"Registered {tracker.CoinJoinClient.CoinsInCriticalPhase.Count()} inputs ({tracker.CoinJoinClient.CoinsInCriticalPhase.Sum(coin => coin.Amount.ToDecimal(MoneyUnit.BTC))} BTC)";
if (tracker.CoinJoinClient.CoinsInCriticalPhase.Count() != tracker.CoinJoinClient.CoinsToRegister.Count())
{
statement += $" / {tracker.CoinJoinClient.CoinsToRegister.Count()} inputs ({tracker.CoinJoinClient.CoinsToRegister.Sum(coin => coin.Amount.ToDecimal(MoneyUnit.BTC))} BTC)";
}
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>
@if (tracker.BannedCoins.Any())
{
<span class="w-100 text-danger">but got @tracker.BannedCoins.Count() inputs (@tracker.BannedCoins.Sum(coin => coin.Coin.Amount.ToDecimal(MoneyUnit.BTC)) BTC) banned</span>
}
</td>
</tr>
}
@if (currentRound.Phase >= Phase.OutputRegistration)
{
<tr>
<th scope="">Coinjoin total outputs</th>
<td >@currentRound.CoinjoinState.Outputs.Count() outputs (@currentRound.CoinjoinState.Outputs.Sum(coin => coin.Value.ToDecimal(MoneyUnit.BTC)) BTC)</td>
</tr>
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 data-bs-toggle="tooltip" title="@outputToolTip">
<th scope="">Your outputs</th>
<td >@statement</td>
</tr>
}
}
</table>
</div>
}
}
</div>
}
}
</div>
@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>
<div class="modal modal-lg fade" id="logs" data-bs-keyboard="false" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<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 mt-0">
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Time</th>
<th>Coordinator</th>
<th>Message</th>
</tr>
</thead>
<tbody>
@foreach (var evt in wallet.LastLogs)
{
string cssClass = evt.level <= (LogLevel) 2 ? "info" : evt.level == (LogLevel) 4 ? "warning" : "danger";
<tr class="text-@cssClass">
<td>
<small class="text-muted" data-timeago-unixms="@evt.time.ToUnixTimeMilliseconds()">@evt.time.ToTimeAgo()</small>
</td>
<td>
<pre>@evt.coordinator</pre>
</td>
<td>
<pre>@evt.message</pre>
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
</div>
}
</div>
</div>
}
}
}