mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2024-11-19 18:11:36 +01:00
a8fdc4798d
* Remove randomize RBF from wallet UI advanced settings * remove support RBF and allow bump fee from wallet send model * update psbt RBF
834 lines
39 KiB
C#
834 lines
39 KiB
C#
#nullable enable
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics.CodeAnalysis;
|
|
using System.Globalization;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using BTCPayServer.Abstractions.Constants;
|
|
using BTCPayServer.Abstractions.Extensions;
|
|
using BTCPayServer.BIP78.Sender;
|
|
using BTCPayServer.Client;
|
|
using BTCPayServer.Client.Models;
|
|
using BTCPayServer.Data;
|
|
using BTCPayServer.HostedServices;
|
|
using BTCPayServer.Models.WalletViewModels;
|
|
using BTCPayServer.Payments;
|
|
using BTCPayServer.Payments.PayJoin;
|
|
using BTCPayServer.Payments.PayJoin.Sender;
|
|
using BTCPayServer.Services;
|
|
using BTCPayServer.Services.Labels;
|
|
using BTCPayServer.Services.Wallets;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Cors;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using NBitcoin;
|
|
using NBitcoin.Payment;
|
|
using NBXplorer;
|
|
using NBXplorer.Models;
|
|
using Newtonsoft.Json.Linq;
|
|
using StoreData = BTCPayServer.Data.StoreData;
|
|
|
|
namespace BTCPayServer.Controllers.Greenfield
|
|
{
|
|
[ApiController]
|
|
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
|
[EnableCors(CorsPolicies.All)]
|
|
public class GreenfieldStoreOnChainWalletsController : Controller
|
|
{
|
|
private StoreData Store => HttpContext.GetStoreData();
|
|
|
|
public PoliciesSettings PoliciesSettings { get; }
|
|
|
|
private readonly IAuthorizationService _authorizationService;
|
|
private readonly BTCPayWalletProvider _btcPayWalletProvider;
|
|
private readonly BTCPayNetworkProvider _btcPayNetworkProvider;
|
|
private readonly WalletRepository _walletRepository;
|
|
private readonly ExplorerClientProvider _explorerClientProvider;
|
|
private readonly NBXplorerDashboard _nbXplorerDashboard;
|
|
private readonly UIWalletsController _walletsController;
|
|
private readonly PayjoinClient _payjoinClient;
|
|
private readonly DelayedTransactionBroadcaster _delayedTransactionBroadcaster;
|
|
private readonly EventAggregator _eventAggregator;
|
|
private readonly WalletReceiveService _walletReceiveService;
|
|
private readonly IFeeProviderFactory _feeProviderFactory;
|
|
private readonly UTXOLocker _utxoLocker;
|
|
private readonly TransactionLinkProviders _transactionLinkProviders;
|
|
|
|
public GreenfieldStoreOnChainWalletsController(
|
|
IAuthorizationService authorizationService,
|
|
BTCPayWalletProvider btcPayWalletProvider,
|
|
BTCPayNetworkProvider btcPayNetworkProvider,
|
|
WalletRepository walletRepository,
|
|
ExplorerClientProvider explorerClientProvider,
|
|
NBXplorerDashboard nbXplorerDashboard,
|
|
PoliciesSettings policiesSettings,
|
|
UIWalletsController walletsController,
|
|
PayjoinClient payjoinClient,
|
|
DelayedTransactionBroadcaster delayedTransactionBroadcaster,
|
|
EventAggregator eventAggregator,
|
|
WalletReceiveService walletReceiveService,
|
|
IFeeProviderFactory feeProviderFactory,
|
|
UTXOLocker utxoLocker,
|
|
TransactionLinkProviders transactionLinkProviders
|
|
)
|
|
{
|
|
_authorizationService = authorizationService;
|
|
_btcPayWalletProvider = btcPayWalletProvider;
|
|
_btcPayNetworkProvider = btcPayNetworkProvider;
|
|
_walletRepository = walletRepository;
|
|
_explorerClientProvider = explorerClientProvider;
|
|
PoliciesSettings = policiesSettings;
|
|
_nbXplorerDashboard = nbXplorerDashboard;
|
|
_walletsController = walletsController;
|
|
_payjoinClient = payjoinClient;
|
|
_delayedTransactionBroadcaster = delayedTransactionBroadcaster;
|
|
_eventAggregator = eventAggregator;
|
|
_walletReceiveService = walletReceiveService;
|
|
_feeProviderFactory = feeProviderFactory;
|
|
_utxoLocker = utxoLocker;
|
|
_transactionLinkProviders = transactionLinkProviders;
|
|
}
|
|
|
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
|
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet")]
|
|
public async Task<IActionResult> ShowOnChainWalletOverview(string storeId, string cryptoCode)
|
|
{
|
|
if (IsInvalidWalletRequest(cryptoCode, out var network,
|
|
out var derivationScheme, out var actionResult))
|
|
return actionResult;
|
|
|
|
var wallet = _btcPayWalletProvider.GetWallet(network);
|
|
var balance = await wallet.GetBalance(derivationScheme.AccountDerivation);
|
|
|
|
return Ok(new OnChainWalletOverviewData()
|
|
{
|
|
Label = derivationScheme.ToPrettyString(),
|
|
Balance = balance.Total.GetValue(network),
|
|
UnconfirmedBalance = balance.Unconfirmed.GetValue(network),
|
|
ConfirmedBalance = balance.Confirmed.GetValue(network),
|
|
});
|
|
}
|
|
|
|
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
|
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/feerate")]
|
|
public async Task<IActionResult> GetOnChainFeeRate(string storeId, string cryptoCode, int? blockTarget = null)
|
|
{
|
|
if (IsInvalidWalletRequest(cryptoCode, out var network,
|
|
out _, out var actionResult))
|
|
return actionResult;
|
|
|
|
var feeRateTarget = blockTarget ?? Store.GetStoreBlob().RecommendedFeeBlockTarget;
|
|
return Ok(new OnChainWalletFeeRateData()
|
|
{
|
|
FeeRate = await _feeProviderFactory.CreateFeeProvider(network)
|
|
.GetFeeRateAsync(feeRateTarget),
|
|
});
|
|
}
|
|
|
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
|
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/address")]
|
|
public async Task<IActionResult> GetOnChainWalletReceiveAddress(string storeId, string cryptoCode,
|
|
bool forceGenerate = false)
|
|
{
|
|
if (IsInvalidWalletRequest(cryptoCode, out var network,
|
|
out var derivationScheme, out var actionResult))
|
|
return actionResult;
|
|
|
|
var kpi = await _walletReceiveService.GetOrGenerate(new WalletId(storeId, cryptoCode), forceGenerate);
|
|
if (kpi is null)
|
|
{
|
|
return BadRequest();
|
|
}
|
|
|
|
var bip21 = network.GenerateBIP21(kpi.Address?.ToString(), null);
|
|
var allowedPayjoin = derivationScheme.IsHotWallet && Store.GetStoreBlob().PayJoinEnabled;
|
|
if (allowedPayjoin)
|
|
{
|
|
bip21.QueryParams.Add(PayjoinClient.BIP21EndpointKey,
|
|
Request.GetAbsoluteUri(Url.Action(nameof(PayJoinEndpointController.Submit), "PayJoinEndpoint",
|
|
new { cryptoCode })));
|
|
}
|
|
|
|
return Ok(new OnChainWalletAddressData()
|
|
{
|
|
Address = kpi.Address?.ToString(),
|
|
PaymentLink = bip21.ToString(),
|
|
KeyPath = kpi.KeyPath
|
|
});
|
|
}
|
|
|
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
|
[HttpDelete("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/address")]
|
|
public async Task<IActionResult> UnReserveOnChainWalletReceiveAddress(string storeId, string cryptoCode)
|
|
{
|
|
if (IsInvalidWalletRequest(cryptoCode, out _,
|
|
out _, out var actionResult))
|
|
return actionResult;
|
|
|
|
var addr = await _walletReceiveService.UnReserveAddress(new WalletId(storeId, cryptoCode));
|
|
if (addr is null)
|
|
{
|
|
return this.CreateAPIError("no-reserved-address",
|
|
$"There was no reserved address for {cryptoCode} on this store.");
|
|
}
|
|
|
|
return Ok();
|
|
}
|
|
|
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
|
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/transactions")]
|
|
public async Task<IActionResult> ShowOnChainWalletTransactions(
|
|
string storeId,
|
|
string cryptoCode,
|
|
[FromQuery] TransactionStatus[]? statusFilter = null,
|
|
[FromQuery] string? labelFilter = null,
|
|
[FromQuery] int skip = 0,
|
|
[FromQuery] int limit = int.MaxValue,
|
|
CancellationToken cancellationToken = default
|
|
)
|
|
{
|
|
if (IsInvalidWalletRequest(cryptoCode, out var network,
|
|
out var derivationScheme, out var actionResult))
|
|
return actionResult;
|
|
|
|
var wallet = _btcPayWalletProvider.GetWallet(network);
|
|
var walletId = new WalletId(storeId, cryptoCode);
|
|
var walletTransactionsInfoAsync = await _walletRepository.GetWalletTransactionsInfo(walletId, (string[]?)null);
|
|
|
|
var preFiltering = true;
|
|
if (statusFilter?.Any() is true || !string.IsNullOrWhiteSpace(labelFilter))
|
|
preFiltering = false;
|
|
var txs = await wallet.FetchTransactionHistory(derivationScheme.AccountDerivation, preFiltering ? skip : 0,
|
|
preFiltering ? limit : int.MaxValue, cancellationToken: cancellationToken);
|
|
if (!preFiltering)
|
|
{
|
|
var filteredList = new List<TransactionHistoryLine>(txs.Count);
|
|
foreach (var t in txs)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(labelFilter))
|
|
{
|
|
walletTransactionsInfoAsync.TryGetValue(t.TransactionId.ToString(), out var transactionInfo);
|
|
if (transactionInfo?.LabelColors.ContainsKey(labelFilter) is true)
|
|
filteredList.Add(t);
|
|
}
|
|
|
|
if (statusFilter?.Any() is true)
|
|
{
|
|
if (statusFilter.Contains(TransactionStatus.Confirmed) && t.Confirmations != 0)
|
|
filteredList.Add(t);
|
|
else if (statusFilter.Contains(TransactionStatus.Unconfirmed) && t.Confirmations == 0)
|
|
filteredList.Add(t);
|
|
}
|
|
}
|
|
|
|
txs = filteredList;
|
|
}
|
|
|
|
var result = txs.Skip(skip).Take(limit).Select(information =>
|
|
{
|
|
walletTransactionsInfoAsync.TryGetValue(information.TransactionId.ToString(), out var transactionInfo);
|
|
return ToModel(transactionInfo, information, wallet);
|
|
}).ToList();
|
|
return Ok(result);
|
|
}
|
|
|
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
|
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/transactions/{transactionId}")]
|
|
public async Task<IActionResult> GetOnChainWalletTransaction(string storeId, string cryptoCode,
|
|
string transactionId)
|
|
{
|
|
if (IsInvalidWalletRequest(cryptoCode, out var network,
|
|
out var derivationScheme, out var actionResult))
|
|
return actionResult;
|
|
|
|
var wallet = _btcPayWalletProvider.GetWallet(network);
|
|
var tx = await wallet.FetchTransaction(derivationScheme.AccountDerivation, uint256.Parse(transactionId));
|
|
if (tx is null)
|
|
{
|
|
return this.CreateAPIError(404, "transaction-not-found", "The transaction was not found.");
|
|
}
|
|
|
|
var walletId = new WalletId(storeId, cryptoCode);
|
|
var walletTransactionsInfoAsync =
|
|
(await _walletRepository.GetWalletTransactionsInfo(walletId, new[] { transactionId })).Values
|
|
.FirstOrDefault();
|
|
|
|
return Ok(ToModel(walletTransactionsInfoAsync, tx, wallet));
|
|
}
|
|
|
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
|
[HttpPatch(
|
|
"~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/transactions/{transactionId}")]
|
|
public async Task<IActionResult> PatchOnChainWalletTransaction(
|
|
string storeId,
|
|
string cryptoCode,
|
|
string transactionId,
|
|
[FromBody] PatchOnChainTransactionRequest request,
|
|
bool force = false
|
|
)
|
|
{
|
|
if (IsInvalidWalletRequest(cryptoCode, out var network,
|
|
out var derivationScheme, out var actionResult))
|
|
return actionResult;
|
|
|
|
var wallet = _btcPayWalletProvider.GetWallet(network);
|
|
var tx = await wallet.FetchTransaction(derivationScheme.AccountDerivation, uint256.Parse(transactionId));
|
|
if (!force && tx is null)
|
|
{
|
|
return this.CreateAPIError(404, "transaction-not-found", "The transaction was not found.");
|
|
}
|
|
|
|
var walletId = new WalletId(storeId, cryptoCode);
|
|
var txObjectId = new WalletObjectId(walletId, WalletObjectData.Types.Tx, transactionId);
|
|
|
|
if (request.Comment != null)
|
|
{
|
|
await _walletRepository.SetWalletObjectComment(txObjectId, request.Comment);
|
|
}
|
|
|
|
if (request.Labels != null)
|
|
{
|
|
await _walletRepository.AddWalletObjectLabels(txObjectId, request.Labels.ToArray());
|
|
}
|
|
|
|
var walletTransactionsInfo =
|
|
(await _walletRepository.GetWalletTransactionsInfo(walletId, new[] { transactionId }))
|
|
.Values
|
|
.FirstOrDefault();
|
|
|
|
return Ok(ToModel(walletTransactionsInfo, tx, wallet));
|
|
}
|
|
|
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
|
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/utxos")]
|
|
public async Task<IActionResult> GetOnChainWalletUTXOs(string storeId, string cryptoCode)
|
|
{
|
|
if (IsInvalidWalletRequest(cryptoCode, out var network,
|
|
out var derivationScheme, out var actionResult))
|
|
return actionResult;
|
|
|
|
var wallet = _btcPayWalletProvider.GetWallet(network);
|
|
|
|
var walletId = new WalletId(storeId, cryptoCode);
|
|
var utxos = await wallet.GetUnspentCoins(derivationScheme.AccountDerivation);
|
|
var walletTransactionsInfoAsync = await _walletRepository.GetWalletTransactionsInfo(walletId,
|
|
utxos.SelectMany(GetWalletObjectsQuery.Get).Distinct().ToArray());
|
|
var pmi = new PaymentMethodId(cryptoCode, PaymentTypes.BTCLike);
|
|
return Ok(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 OnChainWalletUTXOData()
|
|
{
|
|
Outpoint = coin.OutPoint,
|
|
Amount = coin.Value.GetValue(network),
|
|
Comment = info?.Comment,
|
|
#pragma warning disable CS0612 // Type or member is obsolete
|
|
Labels = info?.LegacyLabels ?? new Dictionary<string, LabelData>(),
|
|
#pragma warning restore CS0612 // Type or member is obsolete
|
|
Link = _transactionLinkProviders.GetTransactionLink(pmi, coin.OutPoint.ToString()),
|
|
Timestamp = coin.Timestamp,
|
|
KeyPath = coin.KeyPath,
|
|
Confirmations = coin.Confirmations,
|
|
Address = network.NBXplorerNetwork
|
|
.CreateAddress(derivationScheme.AccountDerivation, coin.KeyPath, coin.ScriptPubKey)
|
|
.ToString()
|
|
};
|
|
}).ToList()
|
|
);
|
|
}
|
|
|
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
|
[HttpPost("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/transactions")]
|
|
public async Task<IActionResult> CreateOnChainTransaction(string storeId, string cryptoCode,
|
|
[FromBody] CreateOnChainTransactionRequest request)
|
|
{
|
|
if (IsInvalidWalletRequest(cryptoCode, out var network,
|
|
out var derivationScheme, out var actionResult))
|
|
return actionResult;
|
|
if (network.ReadonlyWallet)
|
|
{
|
|
return this.CreateAPIError(503, "not-available",
|
|
$"{cryptoCode} sending services are not currently available");
|
|
}
|
|
|
|
//This API is only meant for hot wallet usage for now. We can expand later when we allow PSBT manipulation.
|
|
if (!(await CanUseHotWallet()).HotWallet)
|
|
{
|
|
return this.CreateAPIError(503, "not-available",
|
|
$"You need to allow non-admins to use hotwallets for their stores (in /server/policies)");
|
|
}
|
|
|
|
if (request.Destinations == null || !request.Destinations.Any())
|
|
{
|
|
ModelState.AddModelError(
|
|
nameof(request.Destinations),
|
|
"At least one destination must be specified"
|
|
);
|
|
|
|
return this.CreateValidationError(ModelState);
|
|
}
|
|
|
|
if (request.SelectedInputs != null && request.ExcludeUnconfirmed == true)
|
|
{
|
|
ModelState.AddModelError(
|
|
nameof(request.ExcludeUnconfirmed),
|
|
"Can't automatically exclude unconfirmed UTXOs while selection custom inputs"
|
|
);
|
|
|
|
return this.CreateValidationError(ModelState);
|
|
}
|
|
|
|
var explorerClient = _explorerClientProvider.GetExplorerClient(cryptoCode);
|
|
var wallet = _btcPayWalletProvider.GetWallet(network);
|
|
|
|
var utxos = await wallet.GetUnspentCoins(derivationScheme.AccountDerivation, request.ExcludeUnconfirmed);
|
|
if (request.SelectedInputs != null || !utxos.Any())
|
|
{
|
|
utxos = utxos.Where(coin => request.SelectedInputs?.Contains(coin.OutPoint) ?? true)
|
|
.ToArray();
|
|
if (utxos.Any() is false)
|
|
{
|
|
//no valid utxos selected
|
|
request.AddModelError(transactionRequest => transactionRequest.SelectedInputs,
|
|
"There are no available utxos based on your request", this);
|
|
}
|
|
}
|
|
|
|
var balanceAvailable = utxos.Sum(coin => coin.Value.GetValue(network));
|
|
|
|
var subtractFeesOutputsCount = new List<int>();
|
|
var subtractFees = request.Destinations.Any(o => o.SubtractFromAmount);
|
|
int? payjoinOutputIndex = null;
|
|
var sum = 0m;
|
|
var outputs = new List<WalletSendModel.TransactionOutput>();
|
|
for (var index = 0; index < request.Destinations.Count; index++)
|
|
{
|
|
var destination = request.Destinations[index];
|
|
|
|
if (destination.SubtractFromAmount)
|
|
{
|
|
subtractFeesOutputsCount.Add(index);
|
|
}
|
|
|
|
BitcoinUrlBuilder? bip21 = null;
|
|
var amount = destination.Amount;
|
|
if (amount.GetValueOrDefault(0) <= 0)
|
|
{
|
|
amount = null;
|
|
}
|
|
|
|
var address = string.Empty;
|
|
try
|
|
{
|
|
bip21 = new BitcoinUrlBuilder(destination.Destination, network.NBitcoinNetwork);
|
|
amount ??= bip21.Amount.GetValue(network);
|
|
if (bip21.Address is null)
|
|
request.AddModelError(transactionRequest => transactionRequest.Destinations[index],
|
|
"This BIP21 destination is missing a bitcoin address", this);
|
|
else
|
|
address = bip21.Address.ToString();
|
|
if (destination.SubtractFromAmount)
|
|
{
|
|
request.AddModelError(transactionRequest => transactionRequest.Destinations[index],
|
|
"You cannot use a BIP21 destination along with SubtractFromAmount", this);
|
|
}
|
|
}
|
|
catch (FormatException)
|
|
{
|
|
try
|
|
{
|
|
address = BitcoinAddress.Create(destination.Destination, network.NBitcoinNetwork).ToString();
|
|
}
|
|
catch (Exception)
|
|
{
|
|
request.AddModelError(transactionRequest => transactionRequest.Destinations[index],
|
|
"Destination must be a BIP21 payment link or an address", this);
|
|
}
|
|
}
|
|
|
|
if (amount is null || amount <= 0)
|
|
{
|
|
request.AddModelError(transactionRequest => transactionRequest.Destinations[index],
|
|
"Amount must be specified or destination must be a BIP21 payment link, and greater than 0",
|
|
this);
|
|
}
|
|
|
|
if (request.ProceedWithPayjoin &&
|
|
bip21?.UnknownParameters?.ContainsKey(PayjoinClient.BIP21EndpointKey) is true)
|
|
{
|
|
payjoinOutputIndex = index;
|
|
}
|
|
|
|
outputs.Add(new WalletSendModel.TransactionOutput()
|
|
{
|
|
DestinationAddress = address,
|
|
Amount = amount,
|
|
SubtractFeesFromOutput = destination.SubtractFromAmount
|
|
});
|
|
sum += destination.Amount ?? 0;
|
|
}
|
|
|
|
if (subtractFeesOutputsCount.Count > 1)
|
|
{
|
|
foreach (var subtractFeesOutput in subtractFeesOutputsCount)
|
|
{
|
|
request.AddModelError(model => model.Destinations[subtractFeesOutput].SubtractFromAmount,
|
|
"You can only subtract fees from one destination", this);
|
|
}
|
|
}
|
|
|
|
if (balanceAvailable < sum)
|
|
{
|
|
request.AddModelError(transactionRequest => transactionRequest.Destinations,
|
|
"You are attempting to send more than is available", this);
|
|
}
|
|
else if (balanceAvailable == sum && !subtractFees)
|
|
{
|
|
request.AddModelError(transactionRequest => transactionRequest.Destinations,
|
|
"You are sending your entire balance, you should subtract the fees from a destination", this);
|
|
}
|
|
|
|
var minRelayFee = _nbXplorerDashboard.Get(network.CryptoCode).Status.BitcoinStatus?.MinRelayTxFee ??
|
|
new FeeRate(1.0m);
|
|
if (request.FeeRate != null && request.FeeRate < minRelayFee)
|
|
{
|
|
ModelState.AddModelError(nameof(request.FeeRate),
|
|
"The fee rate specified is lower than the current minimum relay fee");
|
|
}
|
|
|
|
if (!ModelState.IsValid)
|
|
{
|
|
return this.CreateValidationError(ModelState);
|
|
}
|
|
|
|
CreatePSBTResponse psbt;
|
|
try
|
|
{
|
|
psbt = await _walletsController.CreatePSBT(network, derivationScheme,
|
|
new WalletSendModel()
|
|
{
|
|
SelectedInputs = request.SelectedInputs?.Select(point => point.ToString()),
|
|
Outputs = outputs,
|
|
AlwaysIncludeNonWitnessUTXO = true,
|
|
InputSelection = request.SelectedInputs?.Any() is true,
|
|
FeeSatoshiPerByte = request.FeeRate?.SatoshiPerByte,
|
|
NoChange = request.NoChange
|
|
},
|
|
CancellationToken.None);
|
|
}
|
|
catch (NBXplorerException ex)
|
|
{
|
|
return this.CreateAPIError(ex.Error.Code, ex.Error.Message);
|
|
}
|
|
catch (NotSupportedException)
|
|
{
|
|
return this.CreateAPIError(503, "not-available", "You need to update your version of NBXplorer");
|
|
}
|
|
|
|
derivationScheme.RebaseKeyPaths(psbt.PSBT);
|
|
|
|
var signingContext = new SigningContextModel()
|
|
{
|
|
PayJoinBIP21 =
|
|
payjoinOutputIndex is null
|
|
? null
|
|
: request.Destinations.ElementAt(payjoinOutputIndex.Value).Destination,
|
|
EnforceLowR = psbt.Suggestions?.ShouldEnforceLowR,
|
|
ChangeAddress = psbt.ChangeAddress?.ToString()
|
|
};
|
|
|
|
var signingKeyStr = await explorerClient
|
|
.GetMetadataAsync<string>(derivationScheme.AccountDerivation,
|
|
WellknownMetadataKeys.MasterHDKey);
|
|
if (!derivationScheme.IsHotWallet || signingKeyStr is null)
|
|
{
|
|
return this.CreateAPIError(503, "not-available",
|
|
$"{cryptoCode} sending services are not currently available");
|
|
}
|
|
|
|
var signingKey = ExtKey.Parse(signingKeyStr, network.NBitcoinNetwork);
|
|
|
|
var signingKeySettings = derivationScheme.GetSigningAccountKeySettings();
|
|
signingKeySettings.RootFingerprint ??= signingKey.GetPublicKey().GetHDFingerPrint();
|
|
RootedKeyPath rootedKeyPath = signingKeySettings.GetRootedKeyPath();
|
|
psbt.PSBT.RebaseKeyPaths(signingKeySettings.AccountKey, rootedKeyPath);
|
|
var accountKey = signingKey.Derive(rootedKeyPath.KeyPath);
|
|
|
|
if (signingContext?.EnforceLowR is bool v)
|
|
psbt.PSBT.Settings.SigningOptions.EnforceLowR = v;
|
|
else if (psbt.Suggestions?.ShouldEnforceLowR is bool v2)
|
|
psbt.PSBT.Settings.SigningOptions.EnforceLowR = v2;
|
|
|
|
var changed = psbt.PSBT.PSBTChanged(() => psbt.PSBT.SignAll(derivationScheme.AccountDerivation, accountKey,
|
|
rootedKeyPath));
|
|
|
|
if (!changed)
|
|
{
|
|
return this.CreateAPIError("psbt-signing-error",
|
|
"Impossible to sign the transaction. Probable cause: Incorrect account key path in wallet settings, PSBT already signed.");
|
|
}
|
|
|
|
psbt.PSBT.Finalize();
|
|
var transaction = psbt.PSBT.ExtractTransaction();
|
|
var transactionHash = transaction.GetHash();
|
|
BroadcastResult broadcastResult;
|
|
if (!string.IsNullOrEmpty(signingContext?.PayJoinBIP21))
|
|
{
|
|
signingContext.OriginalPSBT = psbt.PSBT.ToBase64();
|
|
try
|
|
{
|
|
await _delayedTransactionBroadcaster.Schedule(DateTimeOffset.UtcNow + TimeSpan.FromMinutes(2.0),
|
|
transaction, network);
|
|
_payjoinClient.MinimumFeeRate = minRelayFee;
|
|
var payjoinPSBT = await _payjoinClient.RequestPayjoin(
|
|
new BitcoinUrlBuilder(signingContext.PayJoinBIP21, network.NBitcoinNetwork),
|
|
new PayjoinWallet(derivationScheme),
|
|
psbt.PSBT, CancellationToken.None);
|
|
psbt.PSBT.Settings.SigningOptions =
|
|
new SigningOptions() { EnforceLowR = !(signingContext?.EnforceLowR is false) };
|
|
payjoinPSBT = psbt.PSBT.SignAll(derivationScheme.AccountDerivation, accountKey, rootedKeyPath);
|
|
payjoinPSBT.Finalize();
|
|
var payjoinTransaction = payjoinPSBT.ExtractTransaction();
|
|
var hash = payjoinTransaction.GetHash();
|
|
await this._walletRepository.AddWalletTransactionAttachment(new WalletId(Store.Id, cryptoCode),
|
|
hash, Attachment.Payjoin());
|
|
broadcastResult = await explorerClient.BroadcastAsync(payjoinTransaction);
|
|
if (broadcastResult.Success)
|
|
{
|
|
return await GetOnChainWalletTransaction(storeId, cryptoCode, hash.ToString());
|
|
}
|
|
}
|
|
catch (PayjoinException)
|
|
{
|
|
//not a critical thing, payjoin is great if possible, fine if not
|
|
}
|
|
}
|
|
|
|
if (!request.ProceedWithBroadcast)
|
|
{
|
|
return Ok(new JValue(transaction.ToHex()));
|
|
}
|
|
|
|
broadcastResult = await explorerClient.BroadcastAsync(transaction);
|
|
if (broadcastResult.Success)
|
|
{
|
|
return await GetOnChainWalletTransaction(storeId, cryptoCode, transactionHash.ToString());
|
|
}
|
|
else
|
|
{
|
|
return this.CreateAPIError("broadcast-error", broadcastResult.RPCMessage);
|
|
}
|
|
}
|
|
|
|
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects")]
|
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
|
public async Task<IActionResult> GetOnChainWalletObjects(string storeId, string cryptoCode, string? type = null, [FromQuery(Name = "ids")] string[]? ids = null, bool? includeNeighbourData = null)
|
|
{
|
|
if (ids?.Length is 0 && !Request.Query.ContainsKey("ids"))
|
|
ids = null;
|
|
if (type is null && ids is not null)
|
|
ModelState.AddModelError(nameof(ids), "If ids is specified, type should be specified");
|
|
if (!ModelState.IsValid)
|
|
return this.CreateValidationError(ModelState);
|
|
var walletId = new WalletId(storeId, cryptoCode);
|
|
return Ok((await _walletRepository.GetWalletObjects(new(walletId, type, ids) { IncludeNeighbours = includeNeighbourData ?? true })).Select(kv => kv.Value).Select(ToModel).ToArray());
|
|
}
|
|
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects/{objectType}/{objectId}")]
|
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
|
public async Task<IActionResult> GetOnChainWalletObject(string storeId, string cryptoCode,
|
|
string objectType, string objectId,
|
|
bool? includeNeighbourData = null)
|
|
{
|
|
var walletId = new WalletId(storeId, cryptoCode);
|
|
var wo = await _walletRepository.GetWalletObject(new(walletId, objectType, objectId), includeNeighbourData ?? true);
|
|
if (wo is null)
|
|
return WalletObjectNotFound();
|
|
return Ok(ToModel(wo));
|
|
}
|
|
|
|
[HttpDelete("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects/{objectType}/{objectId}")]
|
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
|
public async Task<IActionResult> RemoveOnChainWalletObject(string storeId, string cryptoCode,
|
|
string objectType, string objectId)
|
|
{
|
|
var walletId = new WalletId(storeId, cryptoCode);
|
|
if (await _walletRepository.RemoveWalletObjects(new WalletObjectId(walletId, objectType, objectId)))
|
|
return Ok();
|
|
else
|
|
return WalletObjectNotFound();
|
|
}
|
|
|
|
private IActionResult WalletObjectNotFound()
|
|
{
|
|
return this.CreateAPIError(404, "wallet-object-not-found", "This wallet object's can't be found");
|
|
}
|
|
|
|
[HttpPost("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects")]
|
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
|
public async Task<IActionResult> AddOrUpdateOnChainWalletObject(string storeId, string cryptoCode,
|
|
[FromBody] AddOnChainWalletObjectRequest request)
|
|
{
|
|
if (request?.Type is null)
|
|
ModelState.AddModelError(nameof(request.Type), "Type is required");
|
|
if (request?.Id is null)
|
|
ModelState.AddModelError(nameof(request.Id), "Id is required");
|
|
if (!ModelState.IsValid)
|
|
return this.CreateValidationError(ModelState);
|
|
|
|
var walletId = new WalletId(storeId, cryptoCode);
|
|
|
|
try
|
|
{
|
|
await _walletRepository.SetWalletObject(
|
|
new WalletObjectId(walletId, request!.Type, request.Id), request.Data);
|
|
return await GetOnChainWalletObject(storeId, cryptoCode, request!.Type, request.Id);
|
|
}
|
|
catch (DbUpdateException)
|
|
{
|
|
return WalletObjectNotFound();
|
|
}
|
|
}
|
|
|
|
[HttpPost("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects/{objectType}/{objectId}/links")]
|
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
|
public async Task<IActionResult> AddOrUpdateOnChainWalletLinks(string storeId, string cryptoCode,
|
|
string objectType, string objectId,
|
|
[FromBody] AddOnChainWalletObjectLinkRequest request)
|
|
{
|
|
if (request?.Type is null)
|
|
ModelState.AddModelError(nameof(request.Type), "Type is required");
|
|
if (request?.Id is null)
|
|
ModelState.AddModelError(nameof(request.Id), "Id is required");
|
|
if (!ModelState.IsValid)
|
|
return this.CreateValidationError(ModelState);
|
|
|
|
var walletId = new WalletId(storeId, cryptoCode);
|
|
try
|
|
{
|
|
await _walletRepository.SetWalletObjectLink(
|
|
new WalletObjectId(walletId, objectType, objectId),
|
|
new WalletObjectId(walletId, request!.Type, request.Id),
|
|
request?.Data);
|
|
return Ok();
|
|
}
|
|
catch (DbUpdateException)
|
|
{
|
|
return WalletObjectNotFound();
|
|
}
|
|
}
|
|
|
|
[HttpDelete("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects/{objectType}/{objectId}/links/{linkType}/{linkId}")]
|
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
|
public async Task<IActionResult> RemoveOnChainWalletLink(string storeId, string cryptoCode,
|
|
string objectType, string objectId,
|
|
string linkType, string linkId)
|
|
{
|
|
var walletId = new WalletId(storeId, cryptoCode);
|
|
if (await _walletRepository.RemoveWalletObjectLink(
|
|
new WalletObjectId(walletId, objectType, objectId),
|
|
new WalletObjectId(walletId, linkType, linkId)))
|
|
return Ok();
|
|
else
|
|
return WalletObjectNotFound();
|
|
}
|
|
|
|
private OnChainWalletObjectData ToModel(WalletObjectData data)
|
|
{
|
|
return new OnChainWalletObjectData()
|
|
{
|
|
Data = string.IsNullOrEmpty(data.Data) ? null : JObject.Parse(data.Data),
|
|
Type = data.Type,
|
|
Id = data.Id,
|
|
Links = data.GetLinks().Select(linkData => ToModel(linkData)).ToArray()
|
|
};
|
|
}
|
|
|
|
private OnChainWalletObjectData.OnChainWalletObjectLink ToModel((string type, string id, string linkdata, string objectdata) data)
|
|
{
|
|
return new OnChainWalletObjectData.OnChainWalletObjectLink()
|
|
{
|
|
LinkData = string.IsNullOrEmpty(data.linkdata) ? null : JObject.Parse(data.linkdata),
|
|
ObjectData = string.IsNullOrEmpty(data.objectdata) ? null : JObject.Parse(data.objectdata),
|
|
Type = data.type,
|
|
Id = data.id,
|
|
};
|
|
}
|
|
|
|
|
|
private async Task<(bool HotWallet, bool RPCImport)> CanUseHotWallet()
|
|
{
|
|
return await _authorizationService.CanUseHotWallet(PoliciesSettings, User);
|
|
}
|
|
|
|
private bool IsInvalidWalletRequest(string cryptoCode, [MaybeNullWhen(true)] out BTCPayNetwork network,
|
|
[MaybeNullWhen(true)] out DerivationSchemeSettings derivationScheme,
|
|
[MaybeNullWhen(false)] out IActionResult actionResult)
|
|
{
|
|
derivationScheme = null;
|
|
network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
|
|
if (network is null)
|
|
{
|
|
throw new JsonHttpException(this.CreateAPIError(404, "unknown-cryptocode",
|
|
"This crypto code isn't set up in this BTCPay Server instance"));
|
|
}
|
|
|
|
|
|
if (!network.WalletSupported || !_btcPayWalletProvider.IsAvailable(network))
|
|
{
|
|
actionResult = this.CreateAPIError(503, "not-available",
|
|
$"{cryptoCode} services are not currently available");
|
|
return true;
|
|
}
|
|
|
|
derivationScheme = GetDerivationSchemeSettings(cryptoCode);
|
|
if (derivationScheme?.AccountDerivation is null)
|
|
{
|
|
actionResult = this.CreateAPIError(503, "not-available",
|
|
$"{cryptoCode} doesn't have any derivation scheme set");
|
|
return true;
|
|
}
|
|
|
|
actionResult = null;
|
|
return false;
|
|
}
|
|
|
|
private DerivationSchemeSettings? GetDerivationSchemeSettings(string cryptoCode)
|
|
{
|
|
var paymentMethod = Store
|
|
.GetSupportedPaymentMethods(_btcPayNetworkProvider)
|
|
.OfType<DerivationSchemeSettings>()
|
|
.FirstOrDefault(p =>
|
|
p.PaymentId.PaymentType == Payments.PaymentTypes.BTCLike &&
|
|
p.PaymentId.CryptoCode.Equals(cryptoCode, StringComparison.InvariantCultureIgnoreCase));
|
|
return paymentMethod;
|
|
}
|
|
|
|
private OnChainWalletTransactionData ToModel(WalletTransactionInfo? walletTransactionsInfoAsync,
|
|
TransactionHistoryLine tx,
|
|
BTCPayWallet wallet)
|
|
{
|
|
return new OnChainWalletTransactionData()
|
|
{
|
|
TransactionHash = tx.TransactionId,
|
|
Comment = walletTransactionsInfoAsync?.Comment ?? string.Empty,
|
|
#pragma warning disable CS0612 // Type or member is obsolete
|
|
Labels = walletTransactionsInfoAsync?.LegacyLabels ?? new Dictionary<string, LabelData>(),
|
|
#pragma warning restore CS0612 // Type or member is obsolete
|
|
Amount = tx.BalanceChange.GetValue(wallet.Network),
|
|
BlockHash = tx.BlockHash,
|
|
BlockHeight = tx.Height,
|
|
Confirmations = tx.Confirmations,
|
|
Timestamp = tx.SeenAt,
|
|
Status = tx.Confirmations > 0 ? TransactionStatus.Confirmed : TransactionStatus.Unconfirmed
|
|
};
|
|
}
|
|
}
|
|
}
|