mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2024-11-19 09:54:30 +01:00
641bdcff31
* Histograms: Add Lightning data and API endpoints Ported over from the mobile-working-branch. Adds histogram data for Lightning and exposes the wallet/lightning histogram data via the API. It also add a dashboard graph for the Lightning balance. Caveat: The Lightning histogram is calculated by using the current channel balance and going backwards through as much invoices and transactions as we have. The "start" of the LN graph data might not be accurate though. That's because we don't track (and not even have) the LN onchain data. It is calculated by using the current channel balance and going backwards through as much invoices and transactions as we have. So the historic graph data for LN is basically a best effort of trying to reconstruct it with what we have: The LN channel transactions. * More timeframes * Refactoring: Remove redundant WalletHistogram types * Remove store property from dashboard tile view models * JS error fixes
1500 lines
67 KiB
C#
1500 lines
67 KiB
C#
#nullable enable
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.Linq;
|
|
using System.Net.Mime;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using BTCPayServer.Abstractions.Constants;
|
|
using BTCPayServer.Abstractions.Extensions;
|
|
using BTCPayServer.Abstractions.Models;
|
|
using BTCPayServer.BIP78.Sender;
|
|
using BTCPayServer.Client;
|
|
using BTCPayServer.Client.Models;
|
|
using BTCPayServer.Data;
|
|
using BTCPayServer.HostedServices;
|
|
using BTCPayServer.ModelBinders;
|
|
using BTCPayServer.Models;
|
|
using BTCPayServer.Models.WalletViewModels;
|
|
using BTCPayServer.Payments;
|
|
using BTCPayServer.Payments.Bitcoin;
|
|
using BTCPayServer.Payments.PayJoin;
|
|
using BTCPayServer.Payouts;
|
|
using BTCPayServer.Services;
|
|
using BTCPayServer.Services.Invoices;
|
|
using BTCPayServer.Services.Labels;
|
|
using BTCPayServer.Services.Rates;
|
|
using BTCPayServer.Services.Stores;
|
|
using BTCPayServer.Services.Wallets;
|
|
using BTCPayServer.Services.Wallets.Export;
|
|
using Dapper;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.WebUtilities;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Localization;
|
|
using NBitcoin;
|
|
using NBXplorer;
|
|
using NBXplorer.DerivationStrategy;
|
|
using NBXplorer.Models;
|
|
using Newtonsoft.Json;
|
|
using StoreData = BTCPayServer.Data.StoreData;
|
|
|
|
namespace BTCPayServer.Controllers
|
|
{
|
|
[Route("wallets")]
|
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
|
[AutoValidateAntiforgeryToken]
|
|
//16mb psbts
|
|
[RequestFormLimits(ValueLengthLimit = FormReader.DefaultValueLengthLimit * 4)]
|
|
public partial class UIWalletsController : Controller
|
|
{
|
|
private StoreRepository Repository { get; }
|
|
private WalletRepository WalletRepository { get; }
|
|
private BTCPayNetworkProvider NetworkProvider { get; }
|
|
private ExplorerClientProvider ExplorerClientProvider { get; }
|
|
public IServiceProvider ServiceProvider { get; }
|
|
public RateFetcher RateFetcher { get; }
|
|
public IStringLocalizer StringLocalizer { get; }
|
|
|
|
private readonly UserManager<ApplicationUser> _userManager;
|
|
private readonly NBXplorerDashboard _dashboard;
|
|
private readonly IAuthorizationService _authorizationService;
|
|
private readonly IFeeProviderFactory _feeRateProvider;
|
|
private readonly BTCPayWalletProvider _walletProvider;
|
|
private readonly WalletReceiveService _walletReceiveService;
|
|
private readonly SettingsRepository _settingsRepository;
|
|
private readonly DelayedTransactionBroadcaster _broadcaster;
|
|
private readonly PayjoinClient _payjoinClient;
|
|
private readonly LabelService _labelService;
|
|
private readonly PaymentMethodHandlerDictionary _handlers;
|
|
private readonly DefaultRulesCollection _defaultRules;
|
|
private readonly Dictionary<PaymentMethodId, ICheckoutModelExtension> _paymentModelExtensions;
|
|
private readonly TransactionLinkProviders _transactionLinkProviders;
|
|
private readonly PullPaymentHostedService _pullPaymentHostedService;
|
|
private readonly WalletHistogramService _walletHistogramService;
|
|
|
|
readonly CurrencyNameTable _currencyTable;
|
|
|
|
public UIWalletsController(StoreRepository repo,
|
|
WalletRepository walletRepository,
|
|
CurrencyNameTable currencyTable,
|
|
BTCPayNetworkProvider networkProvider,
|
|
UserManager<ApplicationUser> userManager,
|
|
NBXplorerDashboard dashboard,
|
|
WalletHistogramService walletHistogramService,
|
|
RateFetcher rateProvider,
|
|
IAuthorizationService authorizationService,
|
|
ExplorerClientProvider explorerProvider,
|
|
IFeeProviderFactory feeRateProvider,
|
|
BTCPayWalletProvider walletProvider,
|
|
WalletReceiveService walletReceiveService,
|
|
SettingsRepository settingsRepository,
|
|
DelayedTransactionBroadcaster broadcaster,
|
|
PayjoinClient payjoinClient,
|
|
IServiceProvider serviceProvider,
|
|
PullPaymentHostedService pullPaymentHostedService,
|
|
LabelService labelService,
|
|
DefaultRulesCollection defaultRules,
|
|
PaymentMethodHandlerDictionary handlers,
|
|
Dictionary<PaymentMethodId, ICheckoutModelExtension> paymentModelExtensions,
|
|
IStringLocalizer stringLocalizer,
|
|
TransactionLinkProviders transactionLinkProviders)
|
|
{
|
|
_currencyTable = currencyTable;
|
|
_labelService = labelService;
|
|
_defaultRules = defaultRules;
|
|
_handlers = handlers;
|
|
_paymentModelExtensions = paymentModelExtensions;
|
|
_transactionLinkProviders = transactionLinkProviders;
|
|
Repository = repo;
|
|
WalletRepository = walletRepository;
|
|
RateFetcher = rateProvider;
|
|
_authorizationService = authorizationService;
|
|
NetworkProvider = networkProvider;
|
|
_userManager = userManager;
|
|
_dashboard = dashboard;
|
|
ExplorerClientProvider = explorerProvider;
|
|
_feeRateProvider = feeRateProvider;
|
|
_walletProvider = walletProvider;
|
|
_walletReceiveService = walletReceiveService;
|
|
_settingsRepository = settingsRepository;
|
|
_broadcaster = broadcaster;
|
|
_payjoinClient = payjoinClient;
|
|
_pullPaymentHostedService = pullPaymentHostedService;
|
|
ServiceProvider = serviceProvider;
|
|
_walletHistogramService = walletHistogramService;
|
|
StringLocalizer = stringLocalizer;
|
|
}
|
|
|
|
[HttpPost]
|
|
[Route("{walletId}")]
|
|
public async Task<IActionResult> ModifyTransaction(
|
|
// We need addlabel and addlabelclick. addlabel is the + button if the label does not exists,
|
|
// addlabelclick is if the user click on existing label. For some reason, reusing the same name attribute for both
|
|
// does not work
|
|
[ModelBinder(typeof(WalletIdModelBinder))]
|
|
WalletId walletId, string transactionId,
|
|
string? addlabel = null,
|
|
string? addlabelclick = null,
|
|
string? addcomment = null,
|
|
string? removelabel = null)
|
|
{
|
|
addlabel = addlabel ?? addlabelclick;
|
|
// Hack necessary when the user enter a empty comment and submit.
|
|
// For some reason asp.net consider addcomment null instead of empty string...
|
|
try
|
|
{
|
|
if (addcomment == null && Request?.Form?.TryGetValue(nameof(addcomment), out _) is true)
|
|
{
|
|
addcomment = string.Empty;
|
|
}
|
|
}
|
|
catch { }
|
|
/////////
|
|
|
|
var paymentMethod = GetDerivationSchemeSettings(walletId);
|
|
if (paymentMethod == null)
|
|
return NotFound();
|
|
|
|
var txObjId = new WalletObjectId(walletId, WalletObjectData.Types.Tx, transactionId);
|
|
if (addlabel != null)
|
|
{
|
|
await WalletRepository.AddWalletObjectLabels(txObjId, addlabel);
|
|
}
|
|
else if (removelabel != null)
|
|
{
|
|
await WalletRepository.RemoveWalletObjectLabels(txObjId, removelabel);
|
|
}
|
|
else if (addcomment != null)
|
|
{
|
|
await WalletRepository.SetWalletObjectComment(txObjId, addcomment);
|
|
}
|
|
return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() });
|
|
}
|
|
|
|
[HttpGet]
|
|
[AllowAnonymous]
|
|
public async Task<IActionResult> ListWallets()
|
|
{
|
|
if (GetUserId() == null)
|
|
{
|
|
return Challenge(AuthenticationSchemes.Cookie);
|
|
}
|
|
var wallets = new ListWalletsViewModel();
|
|
var stores = await Repository.GetStoresByUserId(GetUserId());
|
|
|
|
var onChainWallets = stores
|
|
.SelectMany(s => s.GetPaymentMethodConfigs<DerivationSchemeSettings>(_handlers)
|
|
.Select(d => (
|
|
Wallet: _walletProvider.GetWallet(((IHasNetwork)_handlers[d.Key]).Network),
|
|
DerivationStrategy: d.Value.AccountDerivation,
|
|
Network: ((IHasNetwork)_handlers[d.Key]).Network))
|
|
.Where(_ => _.Wallet != null && _.Network.WalletSupported)
|
|
.Select(_ => (Wallet: _.Wallet,
|
|
Store: s,
|
|
Balance: GetBalanceString(_.Wallet, _.DerivationStrategy),
|
|
DerivationStrategy: _.DerivationStrategy,
|
|
Network: _.Network)))
|
|
.ToList();
|
|
|
|
foreach (var wallet in onChainWallets)
|
|
{
|
|
ListWalletsViewModel.WalletViewModel walletVm = new ListWalletsViewModel.WalletViewModel();
|
|
wallets.Wallets.Add(walletVm);
|
|
walletVm.Balance = await wallet.Balance + " " + wallet.Wallet.Network.CryptoCode;
|
|
|
|
|
|
walletVm.CryptoCode = wallet.Network.CryptoCode;
|
|
walletVm.StoreId = wallet.Store.Id;
|
|
walletVm.Id = new WalletId(wallet.Store.Id, wallet.Network.CryptoCode);
|
|
walletVm.StoreName = wallet.Store.StoreName;
|
|
|
|
var money = await GetBalanceAsMoney(wallet.Wallet, wallet.DerivationStrategy);
|
|
wallets.BalanceForCryptoCode[wallet.Network] = wallets.BalanceForCryptoCode.ContainsKey(wallet.Network)
|
|
? wallets.BalanceForCryptoCode[wallet.Network].Add(money)
|
|
: money;
|
|
}
|
|
|
|
return View(wallets);
|
|
}
|
|
|
|
[HttpGet("{walletId}")]
|
|
[HttpGet("{walletId}/transactions")]
|
|
public async Task<IActionResult> WalletTransactions(
|
|
[ModelBinder(typeof(WalletIdModelBinder))]
|
|
WalletId walletId,
|
|
string? labelFilter = null,
|
|
int skip = 0,
|
|
int count = 50,
|
|
bool loadTransactions = false,
|
|
CancellationToken cancellationToken = default
|
|
)
|
|
{
|
|
var paymentMethod = GetDerivationSchemeSettings(walletId);
|
|
if (paymentMethod == null)
|
|
return NotFound();
|
|
var network = _handlers.GetBitcoinHandler(walletId.CryptoCode).Network;
|
|
var wallet = _walletProvider.GetWallet(network);
|
|
|
|
// We can't filter at the database level if we need to apply label filter
|
|
var preFiltering = string.IsNullOrEmpty(labelFilter);
|
|
var model = new ListTransactionsViewModel { Skip = skip, Count = count };
|
|
model.Labels.AddRange(
|
|
(await WalletRepository.GetWalletLabels(walletId))
|
|
.Select(c => (c.Label, c.Color, ColorPalette.Default.TextColor(c.Color))));
|
|
|
|
IList<TransactionHistoryLine>? transactions = null;
|
|
Dictionary<string, WalletTransactionInfo>? walletTransactionsInfo = null;
|
|
if (loadTransactions)
|
|
{
|
|
transactions = await wallet.FetchTransactionHistory(paymentMethod.AccountDerivation, preFiltering ? skip : null, preFiltering ? count : null, cancellationToken: cancellationToken);
|
|
walletTransactionsInfo = await WalletRepository.GetWalletTransactionsInfo(walletId, transactions.Select(t => t.TransactionId.ToString()).ToArray());
|
|
}
|
|
|
|
if (labelFilter != null)
|
|
{
|
|
model.PaginationQuery = new Dictionary<string, object> { { "labelFilter", labelFilter } };
|
|
}
|
|
if (transactions == null || walletTransactionsInfo is null)
|
|
{
|
|
model.Transactions = new List<ListTransactionsViewModel.TransactionViewModel>();
|
|
}
|
|
else
|
|
{
|
|
var pmi = PaymentTypes.CHAIN.GetPaymentMethodId(walletId.CryptoCode);
|
|
foreach (var tx in transactions)
|
|
{
|
|
var vm = new ListTransactionsViewModel.TransactionViewModel();
|
|
vm.Id = tx.TransactionId.ToString();
|
|
vm.Link = _transactionLinkProviders.GetTransactionLink(pmi, vm.Id);
|
|
vm.Timestamp = tx.SeenAt;
|
|
vm.Positive = tx.BalanceChange.GetValue(wallet.Network) >= 0;
|
|
vm.Balance = tx.BalanceChange.ShowMoney(wallet.Network);
|
|
vm.IsConfirmed = tx.Confirmations != 0;
|
|
|
|
if (walletTransactionsInfo.TryGetValue(tx.TransactionId.ToString(), out var transactionInfo))
|
|
{
|
|
var labels = _labelService.CreateTransactionTagModels(transactionInfo, Request);
|
|
vm.Tags.AddRange(labels);
|
|
vm.Comment = transactionInfo.Comment;
|
|
}
|
|
|
|
if (labelFilter == null ||
|
|
vm.Tags.Any(l => l.Text.Equals(labelFilter, StringComparison.OrdinalIgnoreCase)))
|
|
model.Transactions.Add(vm);
|
|
}
|
|
|
|
model.Total = preFiltering ? null : model.Transactions.Count;
|
|
// if we couldn't filter at the db level, we need to apply skip and count
|
|
if (!preFiltering)
|
|
{
|
|
model.Transactions = model.Transactions.Skip(skip).Take(count).ToList();
|
|
}
|
|
}
|
|
|
|
model.CryptoCode = walletId.CryptoCode;
|
|
|
|
//If ajax call then load the partial view
|
|
return Request.Headers["X-Requested-With"] == "XMLHttpRequest"
|
|
? PartialView("_WalletTransactionsList", model)
|
|
: View(model);
|
|
}
|
|
|
|
[HttpGet("{walletId}/histogram/{type}")]
|
|
public async Task<IActionResult> WalletHistogram(
|
|
[ModelBinder(typeof(WalletIdModelBinder))]
|
|
WalletId walletId, HistogramType type)
|
|
{
|
|
var store = GetCurrentStore();
|
|
var data = await _walletHistogramService.GetHistogram(store, walletId, type);
|
|
if (data == null) return NotFound();
|
|
|
|
return Json(data);
|
|
}
|
|
|
|
[HttpGet("{walletId}/receive")]
|
|
public async Task<IActionResult> WalletReceive([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
|
|
[FromQuery] string? returnUrl = null)
|
|
{
|
|
if (walletId?.StoreId == null)
|
|
return NotFound();
|
|
var paymentMethod = GetDerivationSchemeSettings(walletId);
|
|
if (paymentMethod == null)
|
|
return NotFound();
|
|
var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
|
|
if (network == null)
|
|
return NotFound();
|
|
var store = GetCurrentStore();
|
|
var address = (await _walletReceiveService.GetOrGenerate(walletId)).Address;
|
|
var allowedPayjoin = paymentMethod.IsHotWallet && store.GetStoreBlob().PayJoinEnabled;
|
|
var bip21 = network.GenerateBIP21(address?.ToString(), null);
|
|
if (allowedPayjoin)
|
|
{
|
|
bip21.QueryParams.Add(PayjoinClient.BIP21EndpointKey,
|
|
Request.GetAbsoluteUri(Url.Action(nameof(PayJoinEndpointController.Submit), "PayJoinEndpoint",
|
|
new { cryptoCode = walletId.CryptoCode })));
|
|
}
|
|
|
|
string[]? labels = null;
|
|
if (address is not null)
|
|
{
|
|
var info = await WalletRepository.GetWalletObject(new WalletObjectId(walletId, WalletObjectData.Types.Address,
|
|
address.ToString()));
|
|
labels = info?.GetNeighbours().Where(data => data.Type == WalletObjectData.Types.Label)
|
|
.Select(data => data.Id).ToArray();
|
|
}
|
|
return View(new WalletReceiveViewModel
|
|
{
|
|
CryptoCode = walletId.CryptoCode,
|
|
Address = address?.ToString(),
|
|
CryptoImage = GetImage(network),
|
|
PaymentLink = bip21.ToString(),
|
|
ReturnUrl = returnUrl,
|
|
SelectedLabels = labels ?? Array.Empty<string>()
|
|
});
|
|
}
|
|
|
|
[HttpPost("{walletId}/receive")]
|
|
public async Task<IActionResult> WalletReceive([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
|
|
WalletReceiveViewModel vm, string command)
|
|
{
|
|
if (walletId?.StoreId == null)
|
|
return NotFound();
|
|
var paymentMethod = GetDerivationSchemeSettings(walletId);
|
|
if (paymentMethod == null)
|
|
return NotFound();
|
|
var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
|
|
if (network == null)
|
|
return NotFound();
|
|
switch (command)
|
|
{
|
|
case "generate-new-address":
|
|
await _walletReceiveService.GetOrGenerate(walletId, true);
|
|
break;
|
|
case "fill-wallet":
|
|
var cheater = ServiceProvider.GetService<Cheater>();
|
|
if (cheater != null)
|
|
await SendFreeMoney(cheater, walletId, paymentMethod);
|
|
break;
|
|
}
|
|
return RedirectToAction(nameof(WalletReceive), new { walletId, returnUrl = vm.ReturnUrl });
|
|
}
|
|
|
|
private async Task SendFreeMoney(Cheater cheater, WalletId walletId, DerivationSchemeSettings paymentMethod)
|
|
{
|
|
var c = this.ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode);
|
|
var cashCow = cheater.GetCashCow(walletId.CryptoCode);
|
|
if (walletId.CryptoCode == "LBTC")
|
|
{
|
|
await cashCow.SendCommandAsync("rescanblockchain");
|
|
}
|
|
var addresses = Enumerable.Range(0, 200).Select(_ => c.GetUnusedAsync(paymentMethod.AccountDerivation, DerivationFeature.Deposit, reserve: true)).ToArray();
|
|
|
|
await Task.WhenAll(addresses);
|
|
await cashCow.GenerateAsync(addresses.Length / 8);
|
|
var b = cashCow.PrepareBatch();
|
|
Random r = new Random();
|
|
List<Task<uint256>> sending = new List<Task<uint256>>();
|
|
foreach (var a in addresses)
|
|
{
|
|
sending.Add(b.SendToAddressAsync((await a).Address, Money.Coins(0.1m) + Money.Satoshis(r.Next(0, 90_000_000))));
|
|
}
|
|
await b.SendBatchAsync();
|
|
await cashCow.GenerateAsync(1);
|
|
|
|
var factory = ServiceProvider.GetRequiredService<NBXplorerConnectionFactory>();
|
|
|
|
// Wait it sync...
|
|
await Task.Delay(1000);
|
|
await ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode).WaitServerStartedAsync();
|
|
await Task.Delay(1000);
|
|
await using var conn = await factory.OpenConnection();
|
|
|
|
var txIds = sending.Select(s => s.Result.ToString()).ToArray();
|
|
await conn.ExecuteAsync(
|
|
"UPDATE txs t SET seen_at=(NOW() - (random() * (interval '90 days'))) " +
|
|
"FROM unnest(@txIds) AS r (tx_id) WHERE r.tx_id=t.tx_id;", new { txIds });
|
|
await Task.Delay(1000);
|
|
await conn.ExecuteAsync("REFRESH MATERIALIZED VIEW wallets_history;");
|
|
}
|
|
|
|
private async Task<bool> CanUseHotWallet()
|
|
{
|
|
var policies = await _settingsRepository.GetSettingAsync<PoliciesSettings>();
|
|
return (await _authorizationService.CanUseHotWallet(policies, User)).HotWallet;
|
|
}
|
|
|
|
[HttpGet("{walletId}/send")]
|
|
public async Task<IActionResult> WalletSend(
|
|
[ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
|
|
string? defaultDestination = null, string? defaultAmount = null, string[]? bip21 = null,
|
|
[FromQuery] string? returnUrl = null)
|
|
{
|
|
if (walletId?.StoreId == null)
|
|
return NotFound();
|
|
var store = await Repository.FindStore(walletId.StoreId);
|
|
var paymentMethod = GetDerivationSchemeSettings(walletId);
|
|
if (paymentMethod == null || store is null)
|
|
return NotFound();
|
|
var network = this.NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
|
|
if (network == null || network.ReadonlyWallet)
|
|
return NotFound();
|
|
var storeData = store.GetStoreBlob();
|
|
var rateRules = store.GetStoreBlob().GetRateRules(_defaultRules);
|
|
rateRules.Spread = 0.0m;
|
|
var currencyPair = new Rating.CurrencyPair(walletId.CryptoCode, storeData.DefaultCurrency);
|
|
double.TryParse(defaultAmount, out var amount);
|
|
|
|
var model = new WalletSendModel
|
|
{
|
|
CryptoCode = walletId.CryptoCode,
|
|
ReturnUrl = returnUrl ?? HttpContext.Request.GetTypedHeaders().Referer?.AbsolutePath
|
|
};
|
|
if (bip21?.Any() is true)
|
|
{
|
|
var messagePresent = TempData.HasStatusMessage();
|
|
foreach (var link in bip21)
|
|
{
|
|
if (!string.IsNullOrEmpty(link))
|
|
{
|
|
await LoadFromBIP21(walletId, model, link, network, messagePresent);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!(model.Outputs?.Any() is true))
|
|
{
|
|
model.Outputs = new List<WalletSendModel.TransactionOutput>()
|
|
{
|
|
new WalletSendModel.TransactionOutput()
|
|
{
|
|
Amount = Convert.ToDecimal(amount), DestinationAddress = defaultDestination
|
|
}
|
|
};
|
|
}
|
|
var feeProvider = _feeRateProvider.CreateFeeProvider(network);
|
|
var recommendedFees =
|
|
new[]
|
|
{
|
|
TimeSpan.FromMinutes(10.0), TimeSpan.FromMinutes(60.0), TimeSpan.FromHours(6.0),
|
|
TimeSpan.FromHours(24.0),
|
|
}.Select(async time =>
|
|
{
|
|
try
|
|
{
|
|
var result = await feeProvider.GetFeeRateAsync(
|
|
(int)network.NBitcoinNetwork.Consensus.GetExpectedBlocksFor(time));
|
|
return new WalletSendModel.FeeRateOption()
|
|
{
|
|
Target = time,
|
|
FeeRate = result.SatoshiPerByte
|
|
};
|
|
}
|
|
catch (Exception)
|
|
{
|
|
return null;
|
|
}
|
|
})
|
|
.ToArray();
|
|
var balance = _walletProvider.GetWallet(network).GetBalance(paymentMethod.AccountDerivation);
|
|
model.NBXSeedAvailable = await GetSeed(walletId, network) != null;
|
|
var Balance = await balance;
|
|
model.CurrentBalance = (Balance.Available ?? Balance.Total).GetValue(network);
|
|
if (Balance.Immature is null)
|
|
model.ImmatureBalance = 0;
|
|
else
|
|
model.ImmatureBalance = Balance.Immature.GetValue(network);
|
|
|
|
await Task.WhenAll(recommendedFees);
|
|
model.RecommendedSatoshiPerByte =
|
|
recommendedFees.Select(tuple => tuple.GetAwaiter().GetResult()).Where(option => option != null).ToList();
|
|
|
|
model.FeeSatoshiPerByte = recommendedFees[1].GetAwaiter().GetResult()?.FeeRate;
|
|
model.CryptoDivisibility = network.Divisibility;
|
|
using (CancellationTokenSource cts = new CancellationTokenSource())
|
|
{
|
|
try
|
|
{
|
|
cts.CancelAfter(TimeSpan.FromSeconds(5));
|
|
var result = await RateFetcher.FetchRate(currencyPair, rateRules, new StoreIdRateContext(walletId.StoreId), cts.Token)
|
|
.WithCancellation(cts.Token);
|
|
if (result.BidAsk != null)
|
|
{
|
|
model.Rate = result.BidAsk.Center;
|
|
model.FiatDivisibility = _currencyTable.GetNumberFormatInfo(currencyPair.Right, true)
|
|
.CurrencyDecimalDigits;
|
|
model.Fiat = currencyPair.Right;
|
|
}
|
|
else
|
|
{
|
|
model.RateError =
|
|
$"{result.EvaluatedRule} ({string.Join(", ", result.Errors.OfType<object>().ToArray())})";
|
|
}
|
|
}
|
|
catch (Exception ex) { model.RateError = ex.Message; }
|
|
}
|
|
return View(model);
|
|
}
|
|
|
|
private async Task<string?> GetSeed(WalletId walletId, BTCPayNetwork network)
|
|
{
|
|
return await CanUseHotWallet() &&
|
|
GetDerivationSchemeSettings(walletId) is DerivationSchemeSettings s &&
|
|
s.IsHotWallet &&
|
|
ExplorerClientProvider.GetExplorerClient(network) is ExplorerClient client &&
|
|
await client.GetMetadataAsync<string>(s.AccountDerivation, WellknownMetadataKeys.MasterHDKey) is
|
|
string seed &&
|
|
!string.IsNullOrEmpty(seed)
|
|
? seed
|
|
: null;
|
|
}
|
|
|
|
[HttpPost("{walletId}/send")]
|
|
public async Task<IActionResult> WalletSend(
|
|
[ModelBinder(typeof(WalletIdModelBinder))]
|
|
WalletId walletId, WalletSendModel vm, string command = "", CancellationToken cancellation = default,
|
|
string? bip21 = "")
|
|
{
|
|
if (walletId?.StoreId == null)
|
|
return NotFound();
|
|
var store = await Repository.FindStore(walletId.StoreId);
|
|
if (store == null)
|
|
return NotFound();
|
|
var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
|
|
if (network == null || network.ReadonlyWallet)
|
|
return NotFound();
|
|
|
|
vm.NBXSeedAvailable = await GetSeed(walletId, network) != null;
|
|
if (!string.IsNullOrEmpty(bip21))
|
|
{
|
|
vm.Outputs?.Clear();
|
|
await LoadFromBIP21(walletId, vm, bip21, network, TempData.HasStatusMessage());
|
|
}
|
|
|
|
decimal transactionAmountSum = 0;
|
|
if (command == "toggle-input-selection")
|
|
{
|
|
vm.InputSelection = !vm.InputSelection;
|
|
}
|
|
if (vm.InputSelection)
|
|
{
|
|
var schemeSettings = GetDerivationSchemeSettings(walletId);
|
|
if (schemeSettings is null)
|
|
return NotFound();
|
|
|
|
var utxos = await _walletProvider.GetWallet(network)
|
|
.GetUnspentCoins(schemeSettings.AccountDerivation, false, cancellation);
|
|
var pmi = PaymentTypes.CHAIN.GetPaymentMethodId(vm.CryptoCode);
|
|
var walletTransactionsInfoAsync = await this.WalletRepository.GetWalletTransactionsInfo(walletId,
|
|
utxos.SelectMany(GetWalletObjectsQuery.Get).Distinct().ToArray());
|
|
vm.InputsAvailable = utxos.Select(coin =>
|
|
{
|
|
walletTransactionsInfoAsync.TryGetValue(coin.OutPoint.Hash.ToString(), out var info1);
|
|
walletTransactionsInfoAsync.TryGetValue(coin.Address.ToString(), out var info2);
|
|
walletTransactionsInfoAsync.TryGetValue(coin.OutPoint.ToString(), out var info3);
|
|
var info = WalletRepository.Merge(info1, info2, info3);
|
|
return new WalletSendModel.InputSelectionOption()
|
|
{
|
|
Outpoint = coin.OutPoint.ToString(),
|
|
Amount = coin.Value.GetValue(network),
|
|
Comment = info?.Comment,
|
|
Labels = _labelService.CreateTransactionTagModels(info, Request),
|
|
Link = _transactionLinkProviders.GetTransactionLink(pmi, coin.OutPoint.ToString()),
|
|
Confirmations = coin.Confirmations
|
|
};
|
|
}).ToArray();
|
|
}
|
|
|
|
if (command == "toggle-input-selection")
|
|
{
|
|
ModelState.Clear();
|
|
return View(vm);
|
|
}
|
|
vm.Outputs ??= new();
|
|
if (!string.IsNullOrEmpty(bip21))
|
|
{
|
|
if (!vm.Outputs.Any())
|
|
{
|
|
vm.Outputs.Add(new WalletSendModel.TransactionOutput());
|
|
}
|
|
return View(vm);
|
|
}
|
|
if (command == "add-output")
|
|
{
|
|
ModelState.Clear();
|
|
vm.Outputs.Add(new WalletSendModel.TransactionOutput());
|
|
return View(vm);
|
|
}
|
|
if (command.StartsWith("remove-output", StringComparison.InvariantCultureIgnoreCase))
|
|
{
|
|
ModelState.Clear();
|
|
var index = int.Parse(
|
|
command.Substring(command.IndexOf(":", StringComparison.InvariantCultureIgnoreCase) + 1),
|
|
CultureInfo.InvariantCulture);
|
|
vm.Outputs.RemoveAt(index);
|
|
return View(vm);
|
|
}
|
|
|
|
if (!vm.Outputs.Any())
|
|
{
|
|
ModelState.AddModelError(string.Empty,
|
|
"Please add at least one transaction output");
|
|
return View(vm);
|
|
}
|
|
|
|
var bypassBalanceChecks = command == "schedule";
|
|
|
|
var subtractFeesOutputsCount = new List<int>();
|
|
var substractFees = vm.Outputs.Any(o => o.SubtractFeesFromOutput);
|
|
for (var i = 0; i < vm.Outputs.Count; i++)
|
|
{
|
|
var transactionOutput = vm.Outputs[i];
|
|
if (transactionOutput.SubtractFeesFromOutput)
|
|
{
|
|
subtractFeesOutputsCount.Add(i);
|
|
}
|
|
transactionOutput.DestinationAddress = transactionOutput.DestinationAddress?.Trim() ?? string.Empty;
|
|
|
|
var inputName =
|
|
string.Format(CultureInfo.InvariantCulture, "Outputs[{0}].",
|
|
i.ToString(CultureInfo.InvariantCulture)) +
|
|
nameof(transactionOutput.DestinationAddress);
|
|
try
|
|
{
|
|
var address = BitcoinAddress.Create(transactionOutput.DestinationAddress, network.NBitcoinNetwork);
|
|
if (address is TaprootAddress)
|
|
{
|
|
var supportTaproot = _dashboard.Get(network.CryptoCode)?.Status?.BitcoinStatus?.Capabilities
|
|
?.CanSupportTaproot;
|
|
if (!(supportTaproot is true))
|
|
{
|
|
ModelState.AddModelError(inputName,
|
|
"You need to update your full node, and/or NBXplorer (Version >= 2.1.56) to be able to send to a taproot address.");
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
ModelState.AddModelError(inputName, "Invalid address");
|
|
}
|
|
|
|
if (!bypassBalanceChecks && transactionOutput.Amount.HasValue)
|
|
{
|
|
transactionAmountSum += transactionOutput.Amount.Value;
|
|
|
|
if (vm.CurrentBalance == transactionOutput.Amount.Value &&
|
|
!transactionOutput.SubtractFeesFromOutput)
|
|
vm.AddModelError(model => model.Outputs[i].SubtractFeesFromOutput,
|
|
"You are sending your entire balance to the same destination, you should subtract the fees",
|
|
this);
|
|
}
|
|
}
|
|
|
|
if (!bypassBalanceChecks)
|
|
{
|
|
if (subtractFeesOutputsCount.Count > 1)
|
|
{
|
|
foreach (var subtractFeesOutput in subtractFeesOutputsCount)
|
|
{
|
|
vm.AddModelError(model => model.Outputs[subtractFeesOutput].SubtractFeesFromOutput,
|
|
"You can only subtract fees from one output", this);
|
|
}
|
|
}
|
|
else if (vm.CurrentBalance == transactionAmountSum && !substractFees)
|
|
{
|
|
ModelState.AddModelError(string.Empty,
|
|
"You are sending your entire balance, you should subtract the fees from an output");
|
|
}
|
|
|
|
if (vm.CurrentBalance < transactionAmountSum)
|
|
{
|
|
for (var i = 0; i < vm.Outputs.Count; i++)
|
|
{
|
|
vm.AddModelError(model => model.Outputs[i].Amount,
|
|
"You are sending more than what you own", this);
|
|
}
|
|
}
|
|
|
|
if (vm.FeeSatoshiPerByte is decimal fee)
|
|
{
|
|
if (fee < 0)
|
|
{
|
|
vm.AddModelError(model => model.FeeSatoshiPerByte,
|
|
"The fee rate should be above 0", this);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!ModelState.IsValid)
|
|
return View(vm);
|
|
|
|
foreach (var transactionOutput in vm.Outputs.Where(output => output.Labels?.Any() is true))
|
|
{
|
|
var labels = transactionOutput.Labels.Where(s => !string.IsNullOrWhiteSpace(s)).ToArray();
|
|
var walletObjectAddress = new WalletObjectId(walletId, WalletObjectData.Types.Address, transactionOutput.DestinationAddress);
|
|
var obj = await WalletRepository.GetWalletObject(walletObjectAddress);
|
|
if (obj is null)
|
|
{
|
|
await WalletRepository.EnsureWalletObject(walletObjectAddress);
|
|
}
|
|
await WalletRepository.AddWalletObjectLabels(walletObjectAddress, labels);
|
|
}
|
|
|
|
var derivationScheme = GetDerivationSchemeSettings(walletId);
|
|
if (derivationScheme is null)
|
|
return NotFound();
|
|
CreatePSBTResponse psbtResponse;
|
|
if (command == "schedule")
|
|
{
|
|
var pmi = PayoutTypes.CHAIN.GetPayoutMethodId(walletId.CryptoCode);
|
|
var claims =
|
|
vm.Outputs.Where(output => string.IsNullOrEmpty(output.PayoutId)).Select(output => new ClaimRequest()
|
|
{
|
|
Destination = new AddressClaimDestination(
|
|
BitcoinAddress.Create(output.DestinationAddress, network.NBitcoinNetwork)),
|
|
ClaimedAmount = output.Amount,
|
|
PayoutMethodId = pmi,
|
|
StoreId = walletId.StoreId,
|
|
PreApprove = true,
|
|
}).ToArray();
|
|
var someFailed = false;
|
|
string? message = null;
|
|
string? errorMessage = null;
|
|
var result = new Dictionary<ClaimRequest, ClaimRequest.ClaimResult>();
|
|
foreach (ClaimRequest claimRequest in claims)
|
|
{
|
|
var response = await _pullPaymentHostedService.Claim(claimRequest);
|
|
result.Add(claimRequest, response.Result);
|
|
if (response.Result == ClaimRequest.ClaimResult.Ok)
|
|
{
|
|
if (message is null)
|
|
{
|
|
message = "Payouts scheduled:<br/>";
|
|
}
|
|
|
|
message += $"{claimRequest.ClaimedAmount} to {claimRequest.Destination.ToString()}<br/>";
|
|
|
|
}
|
|
else
|
|
{
|
|
someFailed = true;
|
|
if (errorMessage is null)
|
|
{
|
|
errorMessage = "Payouts failed to be scheduled:<br/>";
|
|
}
|
|
|
|
switch (response.Result)
|
|
{
|
|
case ClaimRequest.ClaimResult.Duplicate:
|
|
errorMessage += $"{claimRequest.ClaimedAmount} to {claimRequest.Destination.ToString()} - address reuse<br/>";
|
|
break;
|
|
case ClaimRequest.ClaimResult.AmountTooLow:
|
|
errorMessage += $"{claimRequest.ClaimedAmount} to {claimRequest.Destination.ToString()} - amount too low<br/>";
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (message is not null && errorMessage is not null)
|
|
{
|
|
message += $"<br/><br/>{errorMessage}";
|
|
}
|
|
else if (message is null && errorMessage is not null)
|
|
{
|
|
message = errorMessage;
|
|
}
|
|
TempData.SetStatusMessageModel(new StatusMessageModel()
|
|
{
|
|
Severity = someFailed ? StatusMessageModel.StatusSeverity.Warning :
|
|
StatusMessageModel.StatusSeverity.Success,
|
|
Html = message
|
|
});
|
|
return RedirectToAction("Payouts", "UIStorePullPayments",
|
|
new
|
|
{
|
|
storeId = walletId.StoreId,
|
|
PaymentMethodId = pmi.ToString(),
|
|
payoutState = PayoutState.AwaitingPayment,
|
|
});
|
|
}
|
|
|
|
try
|
|
{
|
|
psbtResponse = await CreatePSBT(network, derivationScheme, vm, cancellation);
|
|
}
|
|
catch (NBXplorerException ex)
|
|
{
|
|
ModelState.AddModelError(string.Empty, ex.Error.Message);
|
|
return View(vm);
|
|
}
|
|
catch (NotSupportedException)
|
|
{
|
|
ModelState.AddModelError(string.Empty, "You need to update your version of NBXplorer");
|
|
return View(vm);
|
|
}
|
|
|
|
var psbt = psbtResponse.PSBT;
|
|
derivationScheme.RebaseKeyPaths(psbt);
|
|
|
|
var signingContext = new SigningContextModel
|
|
{
|
|
PayJoinBIP21 = vm.PayJoinBIP21,
|
|
EnforceLowR = psbtResponse.Suggestions?.ShouldEnforceLowR,
|
|
ChangeAddress = psbtResponse.ChangeAddress?.ToString(),
|
|
PSBT = psbt.ToHex()
|
|
};
|
|
switch (command)
|
|
{
|
|
case "sign":
|
|
return await WalletSign(walletId, new WalletPSBTViewModel
|
|
{
|
|
SigningContext = signingContext,
|
|
ReturnUrl = vm.ReturnUrl,
|
|
BackUrl = vm.BackUrl
|
|
});
|
|
case "analyze-psbt":
|
|
var name =
|
|
$"Send-{string.Join('_', vm.Outputs.Select(output => $"{output.Amount}->{output.DestinationAddress}{(output.SubtractFeesFromOutput ? "-Fees" : string.Empty)}"))}.psbt";
|
|
return RedirectToWalletPSBT(new WalletPSBTViewModel { PSBT = psbt.ToBase64(), FileName = name });
|
|
default:
|
|
return View(vm);
|
|
}
|
|
}
|
|
|
|
|
|
private async Task LoadFromBIP21(WalletId walletId, WalletSendModel vm, string bip21,
|
|
BTCPayNetwork network, bool statusMessagePresent)
|
|
{
|
|
BitcoinAddress? address = null;
|
|
vm.Outputs ??= new();
|
|
try
|
|
{
|
|
var uriBuilder = new NBitcoin.Payment.BitcoinUrlBuilder(bip21, network.NBitcoinNetwork);
|
|
var output = new WalletSendModel.TransactionOutput
|
|
{
|
|
Amount = uriBuilder.Amount?.ToDecimal(MoneyUnit.BTC),
|
|
DestinationAddress = uriBuilder.Address?.ToString(),
|
|
SubtractFeesFromOutput = false,
|
|
PayoutId = uriBuilder.UnknownParameters.ContainsKey("payout")
|
|
? uriBuilder.UnknownParameters["payout"]
|
|
: null
|
|
};
|
|
if (!string.IsNullOrEmpty(uriBuilder.Label))
|
|
{
|
|
output.Labels = output.Labels.Append(uriBuilder.Label).ToArray();
|
|
}
|
|
vm.Outputs.Add(output);
|
|
address = uriBuilder.Address;
|
|
// only set SetStatusMessageModel if there is not message already or there is label / message in uri builder
|
|
if (!statusMessagePresent)
|
|
{
|
|
if (!string.IsNullOrEmpty(uriBuilder.Label) || !string.IsNullOrEmpty(uriBuilder.Message))
|
|
{
|
|
TempData.SetStatusMessageModel(new StatusMessageModel
|
|
{
|
|
Severity = StatusMessageModel.StatusSeverity.Info,
|
|
Html =
|
|
$"Payment {(string.IsNullOrEmpty(uriBuilder.Label) ? string.Empty : $" to <strong>{uriBuilder.Label}</strong>")} {(string.IsNullOrEmpty(uriBuilder.Message) ? string.Empty : $" for <strong>{uriBuilder.Message}</strong>")}"
|
|
});
|
|
}
|
|
}
|
|
|
|
if (uriBuilder.TryGetPayjoinEndpoint(out _))
|
|
vm.PayJoinBIP21 = uriBuilder.ToString();
|
|
}
|
|
catch
|
|
{
|
|
try
|
|
{
|
|
address = BitcoinAddress.Create(bip21, network.NBitcoinNetwork);
|
|
vm.Outputs.Add(new WalletSendModel.TransactionOutput
|
|
{
|
|
DestinationAddress = address.ToString()
|
|
});
|
|
}
|
|
catch
|
|
{
|
|
TempData.SetStatusMessageModel(new StatusMessageModel
|
|
{
|
|
Severity = StatusMessageModel.StatusSeverity.Error,
|
|
Message = StringLocalizer["The provided BIP21 payment URI was malformed"].Value
|
|
});
|
|
}
|
|
}
|
|
|
|
ModelState.Clear();
|
|
if (address is not null)
|
|
{
|
|
var addressLabels = await WalletRepository.GetWalletLabels(new WalletObjectId(walletId, WalletObjectData.Types.Address, address.ToString()));
|
|
vm.Outputs.Last().Labels = vm.Outputs.Last().Labels.Concat(addressLabels.Select(tuple => tuple.Label)).ToArray();
|
|
}
|
|
}
|
|
|
|
private IActionResult ViewVault(WalletId walletId, WalletPSBTViewModel vm)
|
|
{
|
|
return View(nameof(WalletSendVault),
|
|
new WalletSendVaultModel
|
|
{
|
|
SigningContext = vm.SigningContext,
|
|
WalletId = walletId.ToString(),
|
|
WebsocketPath = Url.Action(nameof(UIVaultController.VaultBridgeConnection), "UIVault",
|
|
new { walletId = walletId.ToString() }),
|
|
ReturnUrl = vm.ReturnUrl,
|
|
BackUrl = vm.BackUrl
|
|
});
|
|
}
|
|
|
|
[HttpPost("{walletId}/vault")]
|
|
public IActionResult WalletSendVault([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
|
|
WalletSendVaultModel model)
|
|
{
|
|
return RedirectToWalletPSBTReady(new WalletPSBTReadyViewModel
|
|
{
|
|
SigningContext = model.SigningContext,
|
|
ReturnUrl = model.ReturnUrl,
|
|
BackUrl = model.BackUrl
|
|
});
|
|
}
|
|
|
|
private IActionResult RedirectToWalletPSBTReady(WalletPSBTReadyViewModel vm)
|
|
{
|
|
var redirectVm = new PostRedirectViewModel
|
|
{
|
|
AspController = "UIWallets",
|
|
AspAction = nameof(WalletPSBTReady),
|
|
RouteParameters = { { "walletId", this.RouteData?.Values["walletId"]?.ToString() } },
|
|
FormParameters =
|
|
{
|
|
{ "SigningKey", vm.SigningKey },
|
|
{ "SigningKeyPath", vm.SigningKeyPath },
|
|
{ "command", "decode" }
|
|
}
|
|
};
|
|
AddSigningContext(redirectVm, vm.SigningContext);
|
|
if (!string.IsNullOrEmpty(vm.SigningContext.OriginalPSBT) &&
|
|
!string.IsNullOrEmpty(vm.SigningContext.PSBT))
|
|
{
|
|
//if a hw device signed a payjoin, we want it broadcast instantly
|
|
redirectVm.FormParameters.Remove("command");
|
|
redirectVm.FormParameters.Add("command", "broadcast");
|
|
}
|
|
if (vm.ReturnUrl != null)
|
|
{
|
|
redirectVm.FormParameters.Add("returnUrl", vm.ReturnUrl);
|
|
}
|
|
if (vm.BackUrl != null)
|
|
{
|
|
redirectVm.FormParameters.Add("backUrl", vm.BackUrl);
|
|
}
|
|
return View("PostRedirect", redirectVm);
|
|
}
|
|
|
|
private void AddSigningContext(PostRedirectViewModel redirectVm, SigningContextModel signingContext)
|
|
{
|
|
if (signingContext is null)
|
|
return;
|
|
redirectVm.FormParameters.Add("SigningContext.PSBT", signingContext.PSBT);
|
|
redirectVm.FormParameters.Add("SigningContext.OriginalPSBT", signingContext.OriginalPSBT);
|
|
redirectVm.FormParameters.Add("SigningContext.PayJoinBIP21", signingContext.PayJoinBIP21);
|
|
redirectVm.FormParameters.Add("SigningContext.EnforceLowR",
|
|
signingContext.EnforceLowR?.ToString(CultureInfo.InvariantCulture));
|
|
redirectVm.FormParameters.Add("SigningContext.ChangeAddress", signingContext.ChangeAddress);
|
|
}
|
|
|
|
private IActionResult RedirectToWalletPSBT(WalletPSBTViewModel vm)
|
|
{
|
|
var redirectVm = new PostRedirectViewModel
|
|
{
|
|
AspController = "UIWallets",
|
|
AspAction = nameof(WalletPSBT),
|
|
RouteParameters = { { "walletId", RouteData.Values["walletId"]?.ToString() } },
|
|
FormParameters =
|
|
{
|
|
{ "psbt", vm.PSBT },
|
|
{ "fileName", vm.FileName },
|
|
{ "backUrl", vm.BackUrl },
|
|
{ "returnUrl", vm.ReturnUrl },
|
|
{ "command", "decode" }
|
|
}
|
|
};
|
|
return View("PostRedirect", redirectVm);
|
|
}
|
|
|
|
[HttpGet("{walletId}/psbt/seed")]
|
|
public IActionResult SignWithSeed([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
|
|
SigningContextModel signingContext, string returnUrl, string backUrl)
|
|
{
|
|
return View(nameof(SignWithSeed), new SignWithSeedViewModel
|
|
{
|
|
SigningContext = signingContext,
|
|
ReturnUrl = returnUrl,
|
|
BackUrl = backUrl
|
|
});
|
|
}
|
|
|
|
[HttpPost("{walletId}/psbt/seed")]
|
|
public async Task<IActionResult> SignWithSeed([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
|
|
SignWithSeedViewModel viewModel)
|
|
{
|
|
if (!ModelState.IsValid)
|
|
{
|
|
return View("SignWithSeed", viewModel);
|
|
}
|
|
var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
|
|
if (network == null)
|
|
throw new FormatException("Invalid value for crypto code");
|
|
ExtKey extKey = viewModel.GetExtKey(network.NBitcoinNetwork);
|
|
|
|
if (extKey is null)
|
|
{
|
|
ModelState.AddModelError(nameof(viewModel.SeedOrKey),
|
|
"Seed or Key was not in a valid format. It is either the 12/24 words or starts with xprv");
|
|
}
|
|
|
|
var psbt = PSBT.Parse(viewModel.SigningContext.PSBT, network.NBitcoinNetwork);
|
|
|
|
if (!psbt.IsReadyToSign())
|
|
{
|
|
ModelState.AddModelError(nameof(viewModel.SigningContext.PSBT), "PSBT is not ready to be signed");
|
|
}
|
|
|
|
if (!ModelState.IsValid)
|
|
{
|
|
return View("SignWithSeed", viewModel);
|
|
}
|
|
// It will never throw, this make nullable check below happy
|
|
ArgumentNullException.ThrowIfNull(extKey);
|
|
|
|
ExtKey? signingKey = null;
|
|
var settings = GetDerivationSchemeSettings(walletId);
|
|
if (settings is null)
|
|
return NotFound();
|
|
var signingKeySettings = settings.GetSigningAccountKeySettings();
|
|
if (signingKeySettings.RootFingerprint is null)
|
|
signingKeySettings.RootFingerprint = extKey.GetPublicKey().GetHDFingerPrint();
|
|
|
|
RootedKeyPath rootedKeyPath = signingKeySettings.GetRootedKeyPath();
|
|
if (rootedKeyPath == null)
|
|
{
|
|
ModelState.AddModelError(nameof(viewModel.SeedOrKey),
|
|
"The master fingerprint and/or account key path of your seed are not set in the wallet settings.");
|
|
return View(nameof(SignWithSeed), viewModel);
|
|
}
|
|
// The user gave the root key, let's try to rebase the PSBT, and derive the account private key
|
|
if (rootedKeyPath.MasterFingerprint == extKey.GetPublicKey().GetHDFingerPrint())
|
|
{
|
|
psbt.RebaseKeyPaths(signingKeySettings.AccountKey, rootedKeyPath);
|
|
signingKey = extKey.Derive(rootedKeyPath.KeyPath);
|
|
}
|
|
else
|
|
{
|
|
ModelState.AddModelError(nameof(viewModel.SeedOrKey),
|
|
"The master fingerprint does not match the one set in your wallet settings. Probable causes are: wrong seed, wrong passphrase or wrong fingerprint in your wallet settings.");
|
|
return View(nameof(SignWithSeed), viewModel);
|
|
}
|
|
|
|
psbt.Settings.SigningOptions = new SigningOptions()
|
|
{
|
|
EnforceLowR = !(viewModel.SigningContext?.EnforceLowR is false)
|
|
};
|
|
var changed = psbt.PSBTChanged(() => psbt.SignAll(settings.AccountDerivation, signingKey, rootedKeyPath));
|
|
if (!changed)
|
|
{
|
|
var update = new UpdatePSBTRequest() { PSBT = psbt, DerivationScheme = settings.AccountDerivation };
|
|
update.RebaseKeyPaths = settings.GetPSBTRebaseKeyRules().ToList();
|
|
psbt = (await ExplorerClientProvider.GetExplorerClient(network).UpdatePSBTAsync(update))?.PSBT;
|
|
changed = psbt is not null && psbt.PSBTChanged(() =>
|
|
psbt.SignAll(settings.AccountDerivation, signingKey, rootedKeyPath));
|
|
if (!changed)
|
|
{
|
|
ModelState.AddModelError(nameof(viewModel.SeedOrKey),
|
|
"Impossible to sign the transaction. Probable causes: Incorrect account key path in wallet settings or PSBT already signed.");
|
|
return View(nameof(SignWithSeed), viewModel);
|
|
}
|
|
}
|
|
ModelState.Remove(nameof(viewModel.SigningContext.PSBT));
|
|
viewModel.SigningContext ??= new();
|
|
viewModel.SigningContext.PSBT = psbt?.ToBase64();
|
|
return RedirectToWalletPSBTReady(new WalletPSBTReadyViewModel
|
|
{
|
|
SigningKey = signingKey.GetWif(network.NBitcoinNetwork).ToString(),
|
|
SigningKeyPath = rootedKeyPath?.ToString(),
|
|
SigningContext = viewModel.SigningContext,
|
|
ReturnUrl = viewModel.ReturnUrl,
|
|
BackUrl = viewModel.BackUrl
|
|
});
|
|
}
|
|
|
|
private string ValueToString(Money v, BTCPayNetworkBase network)
|
|
{
|
|
return v.ToString() + " " + network.CryptoCode;
|
|
}
|
|
|
|
[HttpGet("{walletId}/rescan")]
|
|
public async Task<IActionResult> WalletRescan(
|
|
[ModelBinder(typeof(WalletIdModelBinder))]
|
|
WalletId walletId)
|
|
{
|
|
if (walletId?.StoreId == null)
|
|
return NotFound();
|
|
var paymentMethod = GetDerivationSchemeSettings(walletId);
|
|
if (paymentMethod == null)
|
|
return NotFound();
|
|
|
|
var vm = new RescanWalletModel();
|
|
vm.IsFullySync = _dashboard.IsFullySynched(walletId.CryptoCode, out var unused);
|
|
vm.IsServerAdmin = (await _authorizationService.AuthorizeAsync(User, Policies.CanModifyServerSettings))
|
|
.Succeeded;
|
|
vm.IsSupportedByCurrency =
|
|
_dashboard.Get(walletId.CryptoCode)?.Status?.BitcoinStatus?.Capabilities?.CanScanTxoutSet == true;
|
|
var explorer = ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode);
|
|
var scanProgress = await explorer.GetScanUTXOSetInformationAsync(paymentMethod.AccountDerivation);
|
|
if (scanProgress != null)
|
|
{
|
|
vm.PreviousError = scanProgress.Error;
|
|
if (scanProgress.Status == ScanUTXOStatus.Queued || scanProgress.Status == ScanUTXOStatus.Pending)
|
|
{
|
|
if (scanProgress.Progress == null)
|
|
{
|
|
vm.Progress = 0;
|
|
}
|
|
else
|
|
{
|
|
vm.Progress = scanProgress.Progress.OverallProgress;
|
|
vm.RemainingTime = TimeSpan.FromSeconds(scanProgress.Progress.RemainingSeconds).PrettyPrint();
|
|
}
|
|
}
|
|
|
|
if (scanProgress.Status == ScanUTXOStatus.Complete)
|
|
{
|
|
vm.LastSuccess = scanProgress.Progress;
|
|
vm.TimeOfScan = (scanProgress.Progress!.CompletedAt!.Value - scanProgress.Progress.StartedAt)
|
|
.PrettyPrint();
|
|
}
|
|
}
|
|
|
|
return View(vm);
|
|
}
|
|
|
|
[HttpPost("{walletId}/rescan")]
|
|
[Authorize(Policy = Policies.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
|
public async Task<IActionResult> WalletRescan(
|
|
[ModelBinder(typeof(WalletIdModelBinder))]
|
|
WalletId walletId, RescanWalletModel vm)
|
|
{
|
|
if (walletId?.StoreId == null)
|
|
return NotFound();
|
|
var paymentMethod = GetDerivationSchemeSettings(walletId);
|
|
if (paymentMethod == null)
|
|
return NotFound();
|
|
var explorer = ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode);
|
|
try
|
|
{
|
|
await explorer.ScanUTXOSetAsync(paymentMethod.AccountDerivation, vm.BatchSize, vm.GapLimit,
|
|
vm.StartingIndex);
|
|
_walletProvider.GetWallet(walletId.CryptoCode).InvalidateCache(paymentMethod.AccountDerivation);
|
|
}
|
|
catch (NBXplorerException ex) when (ex.Error.Code == "scanutxoset-in-progress")
|
|
{
|
|
}
|
|
|
|
return RedirectToAction();
|
|
}
|
|
|
|
internal DerivationSchemeSettings? GetDerivationSchemeSettings(WalletId walletId)
|
|
{
|
|
return GetCurrentStore().GetDerivationSchemeSettings(_handlers, walletId.CryptoCode);
|
|
}
|
|
|
|
private static async Task<IMoney> GetBalanceAsMoney(BTCPayWallet wallet,
|
|
DerivationStrategyBase derivationStrategy)
|
|
{
|
|
using CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
|
try
|
|
{
|
|
var b = await wallet.GetBalance(derivationStrategy, cts.Token);
|
|
return b.Available ?? b.Total;
|
|
}
|
|
catch
|
|
{
|
|
return Money.Zero;
|
|
}
|
|
}
|
|
|
|
internal async Task<string> GetBalanceString(BTCPayWallet wallet, DerivationStrategyBase? derivationStrategy)
|
|
{
|
|
if (derivationStrategy is null)
|
|
return "--";
|
|
try
|
|
{
|
|
return (await GetBalanceAsMoney(wallet, derivationStrategy)).ShowMoney(wallet.Network);
|
|
}
|
|
catch
|
|
{
|
|
return "--";
|
|
}
|
|
}
|
|
|
|
[HttpPost("{walletId}/actions")]
|
|
public async Task<IActionResult> WalletActions(
|
|
[ModelBinder(typeof(WalletIdModelBinder))]
|
|
WalletId walletId, string command,
|
|
string[] selectedTransactions,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var derivationScheme = GetDerivationSchemeSettings(walletId);
|
|
var network = _handlers.GetBitcoinHandler(walletId.CryptoCode).Network;
|
|
if (derivationScheme == null || network.ReadonlyWallet)
|
|
return NotFound();
|
|
|
|
switch (command)
|
|
{
|
|
case "cpfp":
|
|
{
|
|
selectedTransactions ??= Array.Empty<string>();
|
|
if (selectedTransactions.Length == 0)
|
|
{
|
|
TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["No transaction selected"].Value;
|
|
return RedirectToAction(nameof(WalletTransactions), new { walletId });
|
|
}
|
|
|
|
var parameters = new MultiValueDictionary<string, string>();
|
|
parameters.Add("walletId", walletId.ToString());
|
|
int i = 0;
|
|
foreach (var tx in selectedTransactions)
|
|
{
|
|
parameters.Add($"transactionHashes[{i}]", tx);
|
|
i++;
|
|
}
|
|
|
|
var backUrl = Url.Action(nameof(WalletTransactions), new { walletId })!;
|
|
parameters.Add("returnUrl", backUrl);
|
|
parameters.Add("backUrl", backUrl);
|
|
return View("PostRedirect",
|
|
new PostRedirectViewModel
|
|
{
|
|
AspController = "UIWallets",
|
|
AspAction = nameof(WalletCPFP),
|
|
RouteParameters = { { "walletId", walletId.ToString() } },
|
|
FormParameters = parameters
|
|
});
|
|
}
|
|
case "prune":
|
|
{
|
|
var result = await ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode)
|
|
.PruneAsync(derivationScheme.AccountDerivation, new PruneRequest(), cancellationToken);
|
|
if (result.TotalPruned == 0)
|
|
{
|
|
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["The wallet is already pruned"].Value;
|
|
}
|
|
else
|
|
{
|
|
TempData[WellKnownTempData.SuccessMessage] =
|
|
StringLocalizer["The wallet has been successfully pruned ({0} transactions have been removed from the history)", result.TotalPruned].Value;
|
|
}
|
|
|
|
return RedirectToAction(nameof(WalletTransactions), new { walletId });
|
|
}
|
|
case "clear" when User.IsInRole(Roles.ServerAdmin):
|
|
{
|
|
if (Version.TryParse(_dashboard.Get(walletId.CryptoCode)?.Status?.Version ?? "0.0.0.0",
|
|
out var v) &&
|
|
v < new Version(2, 2, 4))
|
|
{
|
|
TempData[WellKnownTempData.ErrorMessage] =
|
|
"This version of NBXplorer doesn't support this operation, please upgrade to 2.2.4 or above";
|
|
}
|
|
else
|
|
{
|
|
await ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode)
|
|
.WipeAsync(derivationScheme.AccountDerivation, cancellationToken);
|
|
TempData[WellKnownTempData.SuccessMessage] =
|
|
"The transactions have been wiped out, to restore your balance, rescan the wallet.";
|
|
}
|
|
|
|
return RedirectToAction(nameof(WalletTransactions), new { walletId });
|
|
}
|
|
default:
|
|
return NotFound();
|
|
}
|
|
}
|
|
|
|
[HttpGet("{walletId}/export")]
|
|
public async Task<IActionResult> Export(
|
|
[ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
|
|
string format, string? labelFilter = null, CancellationToken cancellationToken = default)
|
|
{
|
|
var paymentMethod = GetDerivationSchemeSettings(walletId);
|
|
if (paymentMethod == null)
|
|
return NotFound();
|
|
|
|
var network = _handlers.GetBitcoinHandler(walletId.CryptoCode).Network;
|
|
var wallet = _walletProvider.GetWallet(network);
|
|
var walletTransactionsInfoAsync = WalletRepository.GetWalletTransactionsInfo(walletId, (string[]?)null);
|
|
var input = await wallet.FetchTransactionHistory(paymentMethod.AccountDerivation, cancellationToken: cancellationToken);
|
|
var walletTransactionsInfo = await walletTransactionsInfoAsync;
|
|
var export = new TransactionsExport(wallet, walletTransactionsInfo);
|
|
var res = export.Process(input, format);
|
|
var fileType = format switch
|
|
{
|
|
"csv" => "csv",
|
|
"json" => "json",
|
|
"bip329" => "jsonl",
|
|
_ => throw new ArgumentOutOfRangeException(nameof(format), format, null)
|
|
};
|
|
var mimeType = format switch
|
|
{
|
|
"csv" => "text/csv",
|
|
"json" => "application/json",
|
|
"bip329" => "application/jsonl", // Ongoing discussion: https://github.com/wardi/jsonlines/issues/19
|
|
_ => throw new ArgumentOutOfRangeException(nameof(format), format, null)
|
|
};
|
|
var cd = new ContentDisposition
|
|
{
|
|
FileName = $"btcpay-{walletId}-{DateTime.UtcNow.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture)}.{fileType}",
|
|
Inline = true
|
|
};
|
|
Response.Headers.Add("Content-Disposition", cd.ToString());
|
|
Response.Headers.Add("X-Content-Type-Options", "nosniff");
|
|
return Content(res, mimeType);
|
|
}
|
|
|
|
public class UpdateLabelsRequest
|
|
{
|
|
public string? Id { get; set; }
|
|
public string? Type { get; set; }
|
|
public string[]? Labels { get; set; }
|
|
}
|
|
|
|
[HttpPost("{walletId}/update-labels")]
|
|
[IgnoreAntiforgeryToken]
|
|
public async Task<IActionResult> UpdateLabels(
|
|
[ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
|
|
[FromBody] UpdateLabelsRequest request)
|
|
{
|
|
if (string.IsNullOrEmpty(request.Type) || string.IsNullOrEmpty(request.Id) || request.Labels is null)
|
|
return BadRequest();
|
|
|
|
var objid = new WalletObjectId(walletId, request.Type, request.Id);
|
|
var obj = await WalletRepository.GetWalletObject(objid);
|
|
if (obj is null)
|
|
{
|
|
await WalletRepository.EnsureWalletObject(objid);
|
|
}
|
|
else
|
|
{
|
|
var currentLabels = obj.GetNeighbours().Where(data => data.Type == WalletObjectData.Types.Label).ToArray();
|
|
var toRemove = currentLabels.Where(data => !request.Labels.Contains(data.Id)).Select(data => data.Id).ToArray();
|
|
await WalletRepository.RemoveWalletObjectLabels(objid, toRemove);
|
|
}
|
|
await WalletRepository.AddWalletObjectLabels(objid, request.Labels);
|
|
return Ok();
|
|
}
|
|
|
|
[HttpGet("{walletId}/labels.json")]
|
|
[IgnoreAntiforgeryToken]
|
|
public async Task<IActionResult> LabelsJson(
|
|
[ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
|
|
bool excludeTypes,
|
|
string? type = null,
|
|
string? id = null)
|
|
{
|
|
var walletObjectId = !string.IsNullOrEmpty(type) && !string.IsNullOrEmpty(id)
|
|
? new WalletObjectId(walletId, type, id)
|
|
: null;
|
|
var labels = walletObjectId == null
|
|
? await WalletRepository.GetWalletLabels(walletId)
|
|
: await WalletRepository.GetWalletLabels(walletObjectId);
|
|
return Ok(labels
|
|
.Where(l => !excludeTypes || !WalletObjectData.Types.AllTypes.Contains(l.Label))
|
|
.Select(tuple => new WalletLabelModel
|
|
{
|
|
Label = tuple.Label,
|
|
Color = tuple.Color,
|
|
TextColor = ColorPalette.Default.TextColor(tuple.Color)
|
|
}));
|
|
}
|
|
|
|
[HttpGet("{walletId}/labels")]
|
|
public async Task<IActionResult> WalletLabels(
|
|
[ModelBinder(typeof(WalletIdModelBinder))]
|
|
WalletId walletId)
|
|
{
|
|
if (walletId.StoreId == null)
|
|
return NotFound();
|
|
|
|
var labels = await WalletRepository.GetWalletLabels(walletId);
|
|
|
|
var vm = new WalletLabelsModel
|
|
{
|
|
WalletId = walletId,
|
|
Labels = labels
|
|
.Where(l => !WalletObjectData.Types.AllTypes.Contains(l.Label))
|
|
.Select(tuple => new WalletLabelModel
|
|
{
|
|
Label = tuple.Label,
|
|
Color = tuple.Color,
|
|
TextColor = ColorPalette.Default.TextColor(tuple.Color)
|
|
})
|
|
};
|
|
|
|
return View(vm);
|
|
}
|
|
|
|
[HttpPost("{walletId}/labels/{id}/remove")]
|
|
public async Task<IActionResult> RemoveWalletLabel(
|
|
[ModelBinder(typeof(WalletIdModelBinder))]
|
|
WalletId walletId, string id)
|
|
{
|
|
if (walletId.StoreId == null)
|
|
return NotFound();
|
|
|
|
var labels = new[] { id };
|
|
;
|
|
if (await WalletRepository.RemoveWalletLabels(walletId, labels))
|
|
{
|
|
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["The label has been successfully removed."].Value;
|
|
}
|
|
else
|
|
{
|
|
TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["The label could not be removed."].Value;
|
|
}
|
|
|
|
return RedirectToAction(nameof(WalletLabels), new { walletId });
|
|
}
|
|
|
|
private string? GetImage(BTCPayNetwork network)
|
|
{
|
|
var pmi = PaymentTypes.CHAIN.GetPaymentMethodId(network.CryptoCode);
|
|
if (_paymentModelExtensions.TryGetValue(pmi, out var extension))
|
|
{
|
|
return Request.GetRelativePathOrAbsolute(Url.Content(extension.Image));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private string GetUserId() => _userManager.GetUserId(User)!;
|
|
|
|
private StoreData GetCurrentStore() => HttpContext.GetStoreData();
|
|
}
|
|
|
|
public class WalletReceiveViewModel
|
|
{
|
|
public string? CryptoImage { get; set; }
|
|
public string? CryptoCode { get; set; }
|
|
public string? Address { get; set; }
|
|
public string? PaymentLink { get; set; }
|
|
public string? ReturnUrl { get; set; }
|
|
public string[]? SelectedLabels { get; set; }
|
|
}
|
|
|
|
public class SendToAddressResult
|
|
{
|
|
[JsonProperty("psbt")] public string? PSBT { get; set; }
|
|
}
|
|
}
|