What changed, and why it matters
This commit simply moves three Greenfield API wallet controller files from one folder to another inside the BTCPay Server codebase and updates their class constructors to use C# primary constructors. The code logic, authorization checks, route URLs, and security behavior appear unchanged. It is a refactoring/organizational change, not a security fix or feature change.
No security action required. Treat as routine code reorganization. If reviewing for regressions, verify that dependency injection still resolves the controllers correctly after the namespace/location change and that no other files reference the old paths.
Security signals we found
No change to authorization policies or route templates
No change to input validation, transaction signing, or hot-wallet permission checks
No change to API surface or CORS configuration
Constructor-only refactor to primary constructors
Evidence from the diff
The diff deletes the Greenfield on-chain wallet and payment-method controllers from BTCPayServer/Controllers/GreenField/ and re-creates them under BTCPayServer/Plugins/Wallets/Controllers/. The controller bodies, route attributes, [Authorize] policies, CORS settings, request validation, wallet operations, transaction creation/broadcast, and object-link endpoints are preserved. The constructors were rewritten from explicit field assignments to C# 12 primary constructors, removing private readonly fields and the _ prefix from injected service references. A small unrelated snippet in BTCPayServer/Plugins/Wallets/HotwalletSafe.cs removes an unused using directive. No security-relevant logic changes are visible in the diff.
Changed components
BTCPayServer/Controllers/GreenField/GreenfieldStoreOnChainPaymentMethodsController.WalletGeneration.csBTCPayServer/Controllers/GreenField/GreenfieldStoreOnChainPaymentMethodsController.csBTCPayServer/Controllers/GreenField/GreenfieldStoreOnChainWalletsController.csBTCPayServer/Plugins/Wallets/Controllers/GreenfieldStoreOnChainPaymentMethodsController.WalletGeneration.csBTCPayServer/Plugins/Wallets/Controllers/GreenfieldStoreOnChainPaymentMethodsController.csBTCPayServer/Plugins/Wallets/Controllers/GreenfieldStoreOnChainWalletsController.csBTCPayServer/Plugins/Wallets/HotwalletSafe.csInspect captured patch +1197 / −1253
diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldStoreOnChainPaymentMethodsController.WalletGeneration.cs b/BTCPayServer/Controllers/GreenField/GreenfieldStoreOnChainPaymentMethodsController.WalletGeneration.cs
deleted file mode 100644
index 81bc3f1..0000000
--- a/BTCPayServer/Controllers/GreenField/GreenfieldStoreOnChainPaymentMethodsController.WalletGeneration.cs
+++ /dev/null
@@ -1,121 +0,0 @@
-using System;
-using System.Threading.Tasks;
-using BTCPayServer.Abstractions.Constants;
-using BTCPayServer.Abstractions.Extensions;
-using BTCPayServer.Client;
-using BTCPayServer.Data;
-using BTCPayServer.Events;
-using BTCPayServer.ModelBinders;
-using BTCPayServer.Payments;
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Cors;
-using Microsoft.AspNetCore.Mvc;
-using NBXplorer.Models;
-using Newtonsoft.Json.Linq;
-
-namespace BTCPayServer.Controllers.Greenfield
-{
- public partial class GreenfieldStoreOnChainPaymentMethodsController
- {
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
- [HttpPost("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/generate")]
- [EnableCors(CorsPolicies.All)]
- public async Task<IActionResult> GenerateOnChainWallet(string storeId,
- [ModelBinder(typeof(PaymentMethodIdModelBinder))]
- PaymentMethodId paymentMethodId,
- GenerateOnChainWalletRequest request)
- {
- request ??= new GenerateOnChainWalletRequest();
- AssertCryptoCodeWallet(paymentMethodId, out var network, out _);
-
- if (!_walletProvider.IsAvailable(network))
- {
- return this.CreateAPIError(503, "not-available",
- $"{paymentMethodId} services are not currently available");
- }
- if (request.Label is { Length: > 300 })
- ModelState.AddModelError(nameof(request.Label), "Label is too long (Max 300 characters)");
-
- if (IsConfigured(paymentMethodId, out _))
- {
- return this.CreateAPIError("already-configured",
- $"{paymentMethodId} wallet is already configured for this store");
- }
-
- var canUseHotWallet = await CanUseHotWallet();
- if (request.SavePrivateKeys && !canUseHotWallet.CanCreateHotWallet)
- {
- ModelState.AddModelError(nameof(request.SavePrivateKeys),
- "This instance forbids non-admins from having a hot wallet for your store.");
- }
-
- if (!ModelState.IsValid)
- {
- return this.CreateValidationError(ModelState);
- }
-
- var client = _explorerClientProvider.GetExplorerClient(network);
- GenerateWalletResponse response;
- try
- {
- response = await client.GenerateWalletAsync(new()
- {
- AccountNumber = request.AccountNumber,
- ExistingMnemonic = request.ExistingMnemonic?.ToString(),
- WordList = request.WordList,
- WordCount = request.WordCount,
- ScriptPubKeyType = request.ScriptPubKeyType,
- Passphrase = request.Passphrase,
- SavePrivateKeys = request.SavePrivateKeys,
- });
- if (response == null)
- {
- return this.CreateAPIError(503, "not-available",
- $"{paymentMethodId} services are not currently available");
- }
- }
- catch (Exception e)
- {
- return this.CreateAPIError(503, "not-available",
- $"{paymentMethodId} error: {e.Message}");
- }
-
- var derivationSchemeSettings = new DerivationSchemeSettings(response.DerivationScheme, network);
-
- derivationSchemeSettings.Source =
- request.ExistingMnemonic is null ? "NBXplorerGenerated" : "ImportedSeed";
- derivationSchemeSettings.IsHotWallet = request.SavePrivateKeys;
- derivationSchemeSettings.Label = request.Label;
- var accountSettings = derivationSchemeSettings.AccountKeySettings[0];
- accountSettings.AccountKeyPath = response.AccountKeyPath.KeyPath;
- accountSettings.RootFingerprint = response.AccountKeyPath.MasterFingerprint;
- derivationSchemeSettings.AccountOriginal = response.DerivationScheme.ToString();
-
- var store = Store;
- var storeBlob = store.GetStoreBlob();
- var handler = _handlers[paymentMethodId];
- store.SetPaymentMethodConfig(_handlers[paymentMethodId],
- derivationSchemeSettings);
- store.SetStoreBlob(storeBlob);
- await _storeRepository.UpdateStore(store);
-
- var result = new GenerateOnChainWalletResponse()
- {
- Enabled = !storeBlob.IsExcluded(paymentMethodId),
- PaymentMethodId = paymentMethodId.ToString(),
- Config = ((JObject)JToken.FromObject(derivationSchemeSettings, handler.Serializer.ForAPI())).ToObject<GenerateOnChainWalletResponse.ConfigData>(handler.Serializer.ForAPI())
- };
- result.Mnemonic = response.GetMnemonic();
- _eventAggregator.Publish(new WalletChangedEvent()
- {
- WalletId = new WalletId(storeId, network.CryptoCode)
- });
- return Ok(result);
- }
-
- private async Task<WalletCreationPermissions> CanUseHotWallet()
- {
- return await _authorizationService.CanUseHotWallet(PoliciesSettings, User);
- }
- }
-}
diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldStoreOnChainPaymentMethodsController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldStoreOnChainPaymentMethodsController.cs
deleted file mode 100644
index 6e58497..0000000
--- a/BTCPayServer/Controllers/GreenField/GreenfieldStoreOnChainPaymentMethodsController.cs
+++ /dev/null
@@ -1,166 +0,0 @@
-using System.Diagnostics.CodeAnalysis;
-using System.Threading.Tasks;
-using BTCPayServer.Abstractions.Constants;
-using BTCPayServer.Abstractions.Extensions;
-using BTCPayServer.Client;
-using BTCPayServer.Client.Models;
-using BTCPayServer.Data;
-using BTCPayServer.ModelBinders;
-using BTCPayServer.Payments;
-using BTCPayServer.Payments.Bitcoin;
-using BTCPayServer.Services;
-using BTCPayServer.Services.Invoices;
-using BTCPayServer.Services.Stores;
-using BTCPayServer.Services.Wallets;
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Cors;
-using Microsoft.AspNetCore.Mvc;
-using NBitcoin;
-using NBXplorer.DerivationStrategy;
-using Newtonsoft.Json.Linq;
-using StoreData = BTCPayServer.Data.StoreData;
-
-namespace BTCPayServer.Controllers.Greenfield
-{
- [ApiController]
- [Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
- [EnableCors(CorsPolicies.All)]
- public partial class GreenfieldStoreOnChainPaymentMethodsController : ControllerBase
- {
- private StoreData Store => HttpContext.GetStoreData();
-
- public PoliciesSettings PoliciesSettings { get; }
-
- private readonly StoreRepository _storeRepository;
- private readonly BTCPayWalletProvider _walletProvider;
- private readonly IAuthorizationService _authorizationService;
- private readonly ExplorerClientProvider _explorerClientProvider;
- private readonly PaymentMethodHandlerDictionary _handlers;
- private readonly EventAggregator _eventAggregator;
-
- public GreenfieldStoreOnChainPaymentMethodsController(
- StoreRepository storeRepository,
- BTCPayWalletProvider walletProvider,
- IAuthorizationService authorizationService,
- ExplorerClientProvider explorerClientProvider,
- PoliciesSettings policiesSettings,
- PaymentMethodHandlerDictionary handlers,
- EventAggregator eventAggregator)
- {
- _storeRepository = storeRepository;
- _walletProvider = walletProvider;
- _authorizationService = authorizationService;
- _explorerClientProvider = explorerClientProvider;
- _eventAggregator = eventAggregator;
- PoliciesSettings = policiesSettings;
- _handlers = handlers;
- }
-
- protected JsonHttpException ErrorPaymentMethodNotConfigured()
- {
- return new JsonHttpException(this.CreateAPIError(404, "paymentmethod-not-configured", "The payment method is not configured"));
- }
-
- [Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
- [HttpGet("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/preview")]
- public IActionResult GetOnChainPaymentMethodPreview(
- string storeId,
- [ModelBinder(typeof(PaymentMethodIdModelBinder))]
- PaymentMethodId paymentMethodId,
- int offset = 0, int count = 10)
- {
- AssertCryptoCodeWallet(paymentMethodId, out var network, out _);
- if (!IsConfigured(paymentMethodId, out var settings))
- {
- throw ErrorPaymentMethodNotConfigured();
- }
- return Ok(GetPreviewResultData(offset, count, network, settings.AccountDerivation));
- }
-
-
- [Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
- [HttpPost("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/preview")]
- public async Task<IActionResult> GetProposedOnChainPaymentMethodPreview(
- string storeId,
- [ModelBinder(typeof(PaymentMethodIdModelBinder))]
- PaymentMethodId paymentMethodId,
- [FromBody] UpdatePaymentMethodRequest request = null,
- int offset = 0, int count = 10)
- {
- if (request is null)
- {
- ModelState.AddModelError(nameof(request), "Missing body");
- return this.CreateValidationError(ModelState);
- }
- if (request.Config is null)
- {
- ModelState.AddModelError(nameof(request.Config), "Missing config");
- return this.CreateValidationError(ModelState);
- }
- AssertCryptoCodeWallet(paymentMethodId, out var network, out _);
-
- var handler = _handlers.GetBitcoinHandler(network);
- var ctx = new PaymentMethodConfigValidationContext(_authorizationService, ModelState, request.Config, User, Store.GetPaymentMethodConfig(paymentMethodId));
- await handler.ValidatePaymentMethodConfig(ctx);
- if (ctx.MissingPermission is not null)
- {
- return this.CreateAPIPermissionError(ctx.MissingPermission.Permission, ctx.MissingPermission.Message);
- }
- if (!ModelState.IsValid)
- return this.CreateValidationError(ModelState);
-
- var settings = handler.ParsePaymentMethodConfig(ctx.Config);
- var result = GetPreviewResultData(offset, count, network, settings.AccountDerivation);
- return Ok(result);
- }
-
- internal static OnChainPaymentMethodPreviewResultData GetPreviewResultData(int offset, int count, BTCPayNetwork network, DerivationStrategyBase strategy)
- {
- var line = strategy.GetLineFor(DerivationFeature.Deposit);
- var result = new OnChainPaymentMethodPreviewResultData();
- for (var i = offset; i < count; i++)
- {
- var keyPath = new KeyPath(0, (uint)i);
- if (strategy is PolicyDerivationStrategy)
- keyPath = null;
- var derivation = line.Derive((uint)i);
- result.Addresses.Add(
- new()
- {
- KeyPath = keyPath?.ToString(),
- Index = i,
- Address =
-#pragma warning disable CS0612 // Type or member is obsolete
- // We should be able to derive the address from the scriptPubKey.
- // However, Elements has blinded addresses, so we can't derive the address from the scriptPubKey.
- // We should probably just use a special if/else just for elements here instead of relying on obsolete stuff.
- network.NBXplorerNetwork.CreateAddress(strategy, keyPath ?? new(), derivation.ScriptPubKey)
-#pragma warning restore CS0612 // Type or member is obsolete
- .ToString()
- });
- }
- return result;
- }
-
- private void AssertCryptoCodeWallet(PaymentMethodId paymentMethodId, out BTCPayNetwork network, out BTCPayWallet wallet)
- {
- if (!_handlers.TryGetValue(paymentMethodId, out var h) || h is not BitcoinLikePaymentHandler handler)
- throw new JsonHttpException(this.CreateAPIError(404, "unknown-paymentMethodId", "This payment method id isn't set up in this BTCPay Server instance"));
- network = handler.Network;
- wallet = _walletProvider.GetWallet(network);
- if (wallet is null)
- throw ErrorPaymentMethodNotConfigured();
- }
-
- bool IsConfigured(PaymentMethodId paymentMethodId, [MaybeNullWhen(false)] out DerivationSchemeSettings settings)
- {
- var store = Store;
- var conf = store.GetPaymentMethodConfig(paymentMethodId);
- settings = null;
- if (conf is (null or { Type: JTokenType.Null }))
- return false;
- settings = ((BitcoinLikePaymentHandler)_handlers[paymentMethodId]).ParsePaymentMethodConfig(conf);
- return settings?.AccountDerivation is not null;
- }
- }
-}
diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldStoreOnChainWalletsController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldStoreOnChainWalletsController.cs
deleted file mode 100644
index e297207..0000000
--- a/BTCPayServer/Controllers/GreenField/GreenfieldStoreOnChainWalletsController.cs
+++ /dev/null
@@ -1,965 +0,0 @@
-#nullable enable
-using System;
-using System.Collections.Generic;
-using System.Diagnostics.CodeAnalysis;
-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.Plugins.Wallets.Views.ViewModels;
-using BTCPayServer.Payments;
-using BTCPayServer.Payments.Bitcoin;
-using BTCPayServer.Payments.PayJoin;
-using BTCPayServer.Payments.PayJoin.Sender;
-using BTCPayServer.Services;
-using BTCPayServer.Services.Invoices;
-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 : ControllerBase
- {
- private StoreData Store => HttpContext.GetStoreData();
-
- public PoliciesSettings PoliciesSettings { get; }
-
- private readonly IAuthorizationService _authorizationService;
- private readonly BTCPayWalletProvider _btcPayWalletProvider;
- private readonly PaymentMethodHandlerDictionary _handlers;
- 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;
- private readonly WalletHistogramService _walletHistogramService;
-
- public GreenfieldStoreOnChainWalletsController(
- IAuthorizationService authorizationService,
- BTCPayWalletProvider btcPayWalletProvider,
- PaymentMethodHandlerDictionary handlers,
- WalletRepository walletRepository,
- ExplorerClientProvider explorerClientProvider,
- NBXplorerDashboard nbXplorerDashboard,
- PoliciesSettings policiesSettings,
- UIWalletsController walletsController,
- PayjoinClient payjoinClient,
- DelayedTransactionBroadcaster delayedTransactionBroadcaster,
- EventAggregator eventAggregator,
- WalletReceiveService walletReceiveService,
- IFeeProviderFactory feeProviderFactory,
- UTXOLocker utxoLocker,
- WalletHistogramService walletHistogramService,
- TransactionLinkProviders transactionLinkProviders
- )
- {
- _authorizationService = authorizationService;
- _btcPayWalletProvider = btcPayWalletProvider;
- _handlers = handlers;
- _walletRepository = walletRepository;
- _explorerClientProvider = explorerClientProvider;
- PoliciesSettings = policiesSettings;
- _nbXplorerDashboard = nbXplorerDashboard;
- _walletsController = walletsController;
- _payjoinClient = payjoinClient;
- _delayedTransactionBroadcaster = delayedTransactionBroadcaster;
- _eventAggregator = eventAggregator;
- _walletReceiveService = walletReceiveService;
- _feeProviderFactory = feeProviderFactory;
- _utxoLocker = utxoLocker;
- _walletHistogramService = walletHistogramService;
- _transactionLinkProviders = transactionLinkProviders;
- }
-
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
- [HttpGet("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet")]
- public async Task<IActionResult> ShowOnChainWalletOverview(string storeId, string paymentMethodId)
- {
- if (IsInvalidWalletRequest(paymentMethodId, 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.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
- [HttpGet("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/histogram")]
- public async Task<IActionResult> GetOnChainWalletHistogram(string storeId, string paymentMethodId, [FromQuery] string? type = null)
- {
- if (IsInvalidWalletRequest(paymentMethodId, out var network, out _, out var actionResult))
- return actionResult;
-
- var walletId = new WalletId(storeId, network.CryptoCode);
- Enum.TryParse<HistogramType>(type, true, out var histType);
- var data = await _walletHistogramService.GetHistogram(Store, walletId, histType);
- if (data == null) return this.CreateAPIError(404, "histogram-not-found", "The wallet histogram was not found.");
-
- return Ok(new HistogramData
- {
- Type = data.Type,
- Balance = data.Balance,
- Series = data.Series,
- Labels = data.Labels
- });
- }
-
- [Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
- [HttpGet("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/feerate")]
- public async Task<IActionResult> GetOnChainFeeRate(string storeId, string paymentMethodId, int? blockTarget = null)
- {
- if (IsInvalidWalletRequest(paymentMethodId, 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/{paymentMethodId}/wallet/address")]
- public async Task<IActionResult> GetOnChainWalletReceiveAddress(string storeId, string paymentMethodId,
- bool forceGenerate = false)
- {
- if (IsInvalidWalletRequest(paymentMethodId, out var network,
- out var derivationScheme, out var actionResult))
- return actionResult;
-
- var kpi = await _walletReceiveService.GetOrGenerate(new WalletId(storeId, network.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)
- {
- var endpoint = Url.ActionAbsolute(Request, nameof(PayJoinEndpointController.Submit), "PayJoinEndpoint",
- new { network.CryptoCode }).ToString();
- bip21.QueryParams.Add(PayjoinClient.BIP21EndpointKey, endpoint);
- }
-
- 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/{paymentMethodId}/wallet/address")]
- public async Task<IActionResult> UnReserveOnChainWalletReceiveAddress(string storeId, string paymentMethodId)
- {
- if (IsInvalidWalletRequest(paymentMethodId, out var network,
- out _, out var actionResult))
- return actionResult;
-
- var addr = await _walletReceiveService.UnReserveAddress(new WalletId(storeId, network.CryptoCode));
- if (addr is null)
- {
- return this.CreateAPIError("no-reserved-address",
- $"There was no reserved address for {network.CryptoCode} on this store.");
- }
-
- return Ok();
- }
-
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
- [HttpGet("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/transactions")]
- public async Task<IActionResult> ShowOnChainWalletTransactions(
- string storeId,
- string paymentMethodId,
- [FromQuery] TransactionStatus[]? statusFilter = null,
- [FromQuery] string? labelFilter = null,
- [FromQuery] int skip = 0,
- [FromQuery] int limit = int.MaxValue,
- CancellationToken cancellationToken = default
- )
- {
- if (IsInvalidWalletRequest(paymentMethodId, out var network,
- out var derivationScheme, out var actionResult))
- return actionResult;
-
- var wallet = _btcPayWalletProvider.GetWallet(network);
- var walletId = new WalletId(storeId, network.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/{paymentMethodId}/wallet/transactions/{transactionId}")]
- public async Task<IActionResult> GetOnChainWalletTransaction(string storeId, string paymentMethodId,
- string transactionId)
- {
- if (IsInvalidWalletRequest(paymentMethodId, 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, network.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/{paymentMethodId}/wallet/transactions/{transactionId}")]
- public async Task<IActionResult> PatchOnChainWalletTransaction(
- string storeId,
- string paymentMethodId,
- string transactionId,
- [FromBody] PatchOnChainTransactionRequest request,
- bool force = false
- )
- {
- if (IsInvalidWalletRequest(paymentMethodId, 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, network.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/{paymentMethodId}/wallet/utxos")]
- public async Task<IActionResult> GetOnChainWalletUTXOs(string storeId, string paymentMethodId)
- {
- if (IsInvalidWalletRequest(paymentMethodId, out var network,
- out var derivationScheme, out var actionResult))
- return actionResult;
-
- var wallet = _btcPayWalletProvider.GetWallet(network);
-
- var walletId = new WalletId(storeId, network.CryptoCode);
- var utxos = await wallet.GetUnspentCoins(derivationScheme.AccountDerivation);
- var walletTransactionsInfoAsync = await _walletRepository.GetWalletTransactionsInfo(walletId,
- utxos.SelectMany(GetWalletObjectsQuery.Get).Distinct().ToArray());
- var pmi = PaymentMethodId.Parse(paymentMethodId);
- 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 = coin.Address.ToString()
- };
- }).ToList()
- );
- }
-
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
- [HttpPost("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/transactions")]
- public async Task<IActionResult> CreateOnChainTransaction(string storeId, string paymentMethodId,
- [FromBody] CreateOnChainTransactionRequest request)
- {
- if (IsInvalidWalletRequest(paymentMethodId, out var network,
- out var derivationScheme, out var actionResult))
- return actionResult;
-
- if (network.ReadonlyWallet)
- {
- return this.CreateAPIError(503, "not-available",
- $"This network only support read-only features");
- }
-
- // Only enforce the hot wallet policy when we are actually signing on the server.
- if (request.SignWithSeed && !(await CanUseHotWallet()).CanCreateHotWallet)
- {
- 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);
- }
-
- if (!request.SignWithSeed && request.ProceedWithBroadcast)
- {
- ModelState.AddModelError(nameof(request.ProceedWithBroadcast),
- "Cannot request broadcast when signing is disabled (signWithSeed = false).");
- }
-
- var explorerClient = _explorerClientProvider.GetExplorerClient(network);
- 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(storeId, network, derivationScheme,
- new WalletSendModel()
- {
- SelectedInputs = request.SelectedInputs?.Select(point => point.ToString()),
- Outputs = outputs,
- AlwaysIncludeNonWitnessUTXO = derivationScheme.DefaultIncludeNonWitnessUtxo,
- 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);
-
- if (!request.SignWithSeed)
- {
- return Ok(new CreateOnChainTransactionResponse
- {
- PSBT = psbt.PSBT.ToBase64()
- });
- }
-
- 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)
- {
- var reason = !derivationScheme.IsHotWallet ?
- "You cannot send from a cold wallet" :
- "NBXplorer doesn't have the seed of the wallet";
-
- return this.CreateAPIError(503, "not-available", reason);
- }
-
- var signingKey = ExtKey.Parse(signingKeyStr, network.NBitcoinNetwork);
-
- var signingKeySettings = derivationScheme.GetAccountKeySettingsFromRoot(signingKey);
- var rootedKeyPath = signingKeySettings?.GetRootedKeyPath();
- if (rootedKeyPath is null || signingKeySettings is null)
- {
- return this.CreateAPIError(503, "not-available",
- "The private key saved for this wallet doesn't match the derivation scheme");
- }
- 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, network.CryptoCode),
- hash, Attachment.Payjoin());
- broadcastResult = await explorerClient.BroadcastAsync(payjoinTransaction);
- if (broadcastResult.Success)
- {
- return await GetOnChainWalletTransaction(storeId, paymentMethodId, 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, paymentMethodId, transactionHash.ToString());
- }
- else
- {
- return this.CreateAPIError("broadcast-error", broadcastResult.RPCMessage);
- }
- }
-
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
- [HttpPost("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/transactions/broadcast")]
- public async Task<IActionResult> BroadcastOnChainTransaction(string storeId, string paymentMethodId,
- [FromBody] BroadcastOnChainTransactionRequest request)
- {
- if (string.IsNullOrWhiteSpace(request.Transaction))
- {
- ModelState.AddModelError(nameof(request.Transaction), "A PSBT or raw transaction is required.");
- return this.CreateValidationError(ModelState);
- }
-
- if (IsInvalidWalletRequest(paymentMethodId, out var network,
- out _, out var actionResult))
- return actionResult;
-
- if (network.ReadonlyWallet)
- {
- return this.CreateAPIError(503, "not-available",
- $"This network only support read-only features");
- }
-
- var explorerClient = _explorerClientProvider.GetExplorerClient(network);
- Transaction transaction;
- try
- {
- var psbt = PSBT.Parse(request.Transaction, network.NBitcoinNetwork);
- if (!psbt.IsAllFinalized())
- {
- try
- {
- psbt.Finalize();
- }
- catch (Exception)
- {
- // ignored, checked below
- }
- }
-
- if (!psbt.IsAllFinalized())
- {
- ModelState.AddModelError(nameof(request.Transaction),
- "The PSBT is not finalized and cannot be broadcast.");
- return this.CreateValidationError(ModelState);
- }
-
- transaction = psbt.ExtractTransaction();
- }
- catch (Exception)
- {
- try
- {
- transaction = Transaction.Parse(request.Transaction, network.NBitcoinNetwork);
- }
- catch (Exception)
- {
- ModelState.AddModelError(nameof(request.Transaction),
- "The transaction is not a valid PSBT or raw transaction.");
- return this.CreateValidationError(ModelState);
- }
- }
-
- var broadcastResult = await explorerClient.BroadcastAsync(transaction);
- if (broadcastResult.Success)
- {
- return await GetOnChainWalletTransaction(storeId, paymentMethodId,
- transaction.GetHash().ToString());
- }
-
- return this.CreateAPIError("broadcast-error", broadcastResult.RPCMessage);
- }
-
- [HttpGet("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/objects")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
- public async Task<IActionResult> GetOnChainWalletObjects(string storeId, string paymentMethodId, 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);
- if (IsInvalidWalletRequest(paymentMethodId, out var network, out var actionResult))
- return actionResult;
- var walletId = new WalletId(storeId, network.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/{paymentMethodId}/wallet/objects/{objectType}/{objectId}")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
- public async Task<IActionResult> GetOnChainWalletObject(string storeId, string paymentMethodId,
- string objectType, string objectId,
- bool? includeNeighbourData = null)
- {
- if (IsInvalidWalletRequest(paymentMethodId, out var network, out var actionResult))
- return actionResult;
- var walletId = new WalletId(storeId, network.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/{paymentMethodId}/wallet/objects/{objectType}/{objectId}")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
- public async Task<IActionResult> RemoveOnChainWalletObject(string storeId, string paymentMethodId,
- string objectType, string objectId)
- {
- if (IsInvalidWalletRequest(paymentMethodId, out var network, out var actionResult))
- return actionResult;
- var walletId = new WalletId(storeId, network.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/{paymentMethodId}/wallet/objects")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
- public async Task<IActionResult> AddOrUpdateOnChainWalletObject(string storeId,
- string paymentMethodId,
- [FromBody] AddOnChainWalletObjectRequest request)
- {
- if (IsInvalidWalletRequest(paymentMethodId, out var network, out var actionResult))
- return actionResult;
- 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, network.CryptoCode);
-
- try
- {
- await _walletRepository.SetWalletObject(
- new WalletObjectId(walletId, request!.Type, request.Id), request.Data);
- return await GetOnChainWalletObject(storeId, network.CryptoCode, request!.Type, request.Id);
- }
- catch (DbUpdateException)
- {
- return WalletObjectNotFound();
- }
- }
-
- [HttpPost("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/objects/{objectType}/{objectId}/links")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
- public async Task<IActionResult> AddOrUpdateOnChainWalletLinks(string storeId, string paymentMethodId,
- string objectType, string objectId,
- [FromBody] AddOnChainWalletObjectLinkRequest request)
- {
- if (IsInvalidWalletRequest(paymentMethodId, out var network, out var actionResult))
- return actionResult;
- 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, network.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/{paymentMethodId}/wallet/objects/{objectType}/{objectId}/links/{linkType}/{linkId}")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
- public async Task<IActionResult> RemoveOnChainWalletLink(string storeId, string paymentMethodId,
- string objectType, string objectId,
- string linkType, string linkId)
- {
- if (IsInvalidWalletRequest(paymentMethodId, out var network, out var actionResult))
- return actionResult;
- var walletId = new WalletId(storeId, network.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, JObject? linkdata, JObject? objectdata) data)
- {
- return new OnChainWalletObjectData.OnChainWalletObjectLink()
- {
- LinkData = data.linkdata,
- ObjectData = data.objectdata,
- Type = data.type,
- Id = data.id,
- };
- }
-
-
- private async Task<WalletCreationPermissions> CanUseHotWallet()
- {
- return await _authorizationService.CanUseHotWallet(PoliciesSettings, User);
- }
-
- private bool IsInvalidWalletRequest(string paymentMethodId, [MaybeNullWhen(true)] out BTCPayNetwork network,
- [MaybeNullWhen(true)] out DerivationSchemeSettings derivationScheme,
- [MaybeNullWhen(false)] out IActionResult actionResult)
- {
- derivationScheme = null;
- if (IsInvalidWalletRequest(paymentMethodId, out network, out actionResult))
- return true;
-
- derivationScheme = GetDerivationSchemeSettings(network.CryptoCode);
- if (derivationScheme?.AccountDerivation is null)
- {
- actionResult = this.CreateAPIError(503, "not-available",
- $"{network.CryptoCode} doesn't have any derivation scheme set");
- return true;
- }
-
- actionResult = null;
- return false;
- }
-
- private bool IsInvalidWalletRequest(string paymentMethodId, [MaybeNullWhen(true)] out BTCPayNetwork network,
- [MaybeNullWhen(false)] out IActionResult actionResult)
- {
- if (!PaymentMethodId.TryParse(paymentMethodId, out var pmi)
- || !_handlers.TryGetValue(pmi, out var handler)
- || handler is not IHasNetwork { Network: { WalletSupported: true } })
- {
- throw new JsonHttpException(this.CreateAPIError(404, "unknown-paymentMethodId",
- "This payment method doesn't exists or doesn't offer wallet services"));
- }
- network = ((IHasNetwork)handler).Network;
-
- if (!_btcPayWalletProvider.IsAvailable(network))
- {
- actionResult = this.CreateAPIError(503, "not-available",
- $"{pmi} services are not currently available");
- return true;
- }
- actionResult = null;
- return false;
- }
-
- private DerivationSchemeSettings? GetDerivationSchemeSettings(string cryptoCode)
- {
- return Store.GetPaymentMethodConfig<DerivationSchemeSettings>(PaymentTypes.CHAIN.GetPaymentMethodId(cryptoCode), _handlers);
- }
-
- 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
- };
- }
- }
-}
diff --git a/BTCPayServer/Plugins/Wallets/Controllers/GreenfieldStoreOnChainPaymentMethodsController.WalletGeneration.cs b/BTCPayServer/Plugins/Wallets/Controllers/GreenfieldStoreOnChainPaymentMethodsController.WalletGeneration.cs
new file mode 100644
index 0000000..223fae0
--- /dev/null
+++ b/BTCPayServer/Plugins/Wallets/Controllers/GreenfieldStoreOnChainPaymentMethodsController.WalletGeneration.cs
@@ -0,0 +1,121 @@
+using System;
+using System.Threading.Tasks;
+using BTCPayServer.Abstractions.Constants;
+using BTCPayServer.Abstractions.Extensions;
+using BTCPayServer.Client;
+using BTCPayServer.Data;
+using BTCPayServer.Events;
+using BTCPayServer.ModelBinders;
+using BTCPayServer.Payments;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Cors;
+using Microsoft.AspNetCore.Mvc;
+using NBXplorer.Models;
+using Newtonsoft.Json.Linq;
+
+namespace BTCPayServer.Controllers.Greenfield
+{
+ public partial class GreenfieldStoreOnChainPaymentMethodsController
+ {
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ [HttpPost("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/generate")]
+ [EnableCors(CorsPolicies.All)]
+ public async Task<IActionResult> GenerateOnChainWallet(string storeId,
+ [ModelBinder(typeof(PaymentMethodIdModelBinder))]
+ PaymentMethodId paymentMethodId,
+ GenerateOnChainWalletRequest request)
+ {
+ request ??= new GenerateOnChainWalletRequest();
+ AssertCryptoCodeWallet(paymentMethodId, out var network, out _);
+
+ if (!walletProvider.IsAvailable(network))
+ {
+ return this.CreateAPIError(503, "not-available",
+ $"{paymentMethodId} services are not currently available");
+ }
+ if (request.Label is { Length: > 300 })
+ ModelState.AddModelError(nameof(request.Label), "Label is too long (Max 300 characters)");
+
+ if (IsConfigured(paymentMethodId, out _))
+ {
+ return this.CreateAPIError("already-configured",
+ $"{paymentMethodId} wallet is already configured for this store");
+ }
+
+ var canUseHotWallet = await CanUseHotWallet();
+ if (request.SavePrivateKeys && !canUseHotWallet.CanCreateHotWallet)
+ {
+ ModelState.AddModelError(nameof(request.SavePrivateKeys),
+ "This instance forbids non-admins from having a hot wallet for your store.");
+ }
+
+ if (!ModelState.IsValid)
+ {
+ return this.CreateValidationError(ModelState);
+ }
+
+ var client = explorerClientProvider.GetExplorerClient(network);
+ GenerateWalletResponse response;
+ try
+ {
+ response = await client.GenerateWalletAsync(new()
+ {
+ AccountNumber = request.AccountNumber,
+ ExistingMnemonic = request.ExistingMnemonic?.ToString(),
+ WordList = request.WordList,
+ WordCount = request.WordCount,
+ ScriptPubKeyType = request.ScriptPubKeyType,
+ Passphrase = request.Passphrase,
+ SavePrivateKeys = request.SavePrivateKeys,
+ });
+ if (response == null)
+ {
+ return this.CreateAPIError(503, "not-available",
+ $"{paymentMethodId} services are not currently available");
+ }
+ }
+ catch (Exception e)
+ {
+ return this.CreateAPIError(503, "not-available",
+ $"{paymentMethodId} error: {e.Message}");
+ }
+
+ var derivationSchemeSettings = new DerivationSchemeSettings(response.DerivationScheme, network);
+
+ derivationSchemeSettings.Source =
+ request.ExistingMnemonic is null ? "NBXplorerGenerated" : "ImportedSeed";
+ derivationSchemeSettings.IsHotWallet = request.SavePrivateKeys;
+ derivationSchemeSettings.Label = request.Label;
+ var accountSettings = derivationSchemeSettings.AccountKeySettings[0];
+ accountSettings.AccountKeyPath = response.AccountKeyPath.KeyPath;
+ accountSettings.RootFingerprint = response.AccountKeyPath.MasterFingerprint;
+ derivationSchemeSettings.AccountOriginal = response.DerivationScheme.ToString();
+
+ var store = Store;
+ var storeBlob = store.GetStoreBlob();
+ var handler = handlers[paymentMethodId];
+ store.SetPaymentMethodConfig(handlers[paymentMethodId],
+ derivationSchemeSettings);
+ store.SetStoreBlob(storeBlob);
+ await storeRepository.UpdateStore(store);
+
+ var result = new GenerateOnChainWalletResponse()
+ {
+ Enabled = !storeBlob.IsExcluded(paymentMethodId),
+ PaymentMethodId = paymentMethodId.ToString(),
+ Config = ((JObject)JToken.FromObject(derivationSchemeSettings, handler.Serializer.ForAPI())).ToObject<GenerateOnChainWalletResponse.ConfigData>(handler.Serializer.ForAPI())
+ };
+ result.Mnemonic = response.GetMnemonic();
+ eventAggregator.Publish(new WalletChangedEvent()
+ {
+ WalletId = new WalletId(storeId, network.CryptoCode)
+ });
+ return Ok(result);
+ }
+
+ private async Task<WalletCreationPermissions> CanUseHotWallet()
+ {
+ return await authorizationService.CanUseHotWallet(PoliciesSettings, User);
+ }
+ }
+}
diff --git a/BTCPayServer/Plugins/Wallets/Controllers/GreenfieldStoreOnChainPaymentMethodsController.cs b/BTCPayServer/Plugins/Wallets/Controllers/GreenfieldStoreOnChainPaymentMethodsController.cs
new file mode 100644
index 0000000..cbb6d2e
--- /dev/null
+++ b/BTCPayServer/Plugins/Wallets/Controllers/GreenfieldStoreOnChainPaymentMethodsController.cs
@@ -0,0 +1,149 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Threading.Tasks;
+using BTCPayServer.Abstractions.Constants;
+using BTCPayServer.Abstractions.Extensions;
+using BTCPayServer.Client;
+using BTCPayServer.Client.Models;
+using BTCPayServer.Data;
+using BTCPayServer.ModelBinders;
+using BTCPayServer.Payments;
+using BTCPayServer.Payments.Bitcoin;
+using BTCPayServer.Services;
+using BTCPayServer.Services.Invoices;
+using BTCPayServer.Services.Stores;
+using BTCPayServer.Services.Wallets;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Cors;
+using Microsoft.AspNetCore.Mvc;
+using NBitcoin;
+using NBXplorer.DerivationStrategy;
+using Newtonsoft.Json.Linq;
+using StoreData = BTCPayServer.Data.StoreData;
+
+namespace BTCPayServer.Controllers.Greenfield
+{
+ [ApiController]
+ [Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ [EnableCors(CorsPolicies.All)]
+ public partial class GreenfieldStoreOnChainPaymentMethodsController(
+ StoreRepository storeRepository,
+ BTCPayWalletProvider walletProvider,
+ IAuthorizationService authorizationService,
+ ExplorerClientProvider explorerClientProvider,
+ PoliciesSettings policiesSettings,
+ PaymentMethodHandlerDictionary handlers,
+ EventAggregator eventAggregator)
+ : ControllerBase
+ {
+ private StoreData Store => HttpContext.GetStoreData();
+
+ public PoliciesSettings PoliciesSettings { get; } = policiesSettings;
+
+ protected JsonHttpException ErrorPaymentMethodNotConfigured()
+ {
+ return new JsonHttpException(this.CreateAPIError(404, "paymentmethod-not-configured", "The payment method is not configured"));
+ }
+
+ [Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ [HttpGet("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/preview")]
+ public IActionResult GetOnChainPaymentMethodPreview(
+ string storeId,
+ [ModelBinder(typeof(PaymentMethodIdModelBinder))]
+ PaymentMethodId paymentMethodId,
+ int offset = 0, int count = 10)
+ {
+ AssertCryptoCodeWallet(paymentMethodId, out var network, out _);
+ if (!IsConfigured(paymentMethodId, out var settings))
+ {
+ throw ErrorPaymentMethodNotConfigured();
+ }
+ return Ok(GetPreviewResultData(offset, count, network, settings.AccountDerivation));
+ }
+
+
+ [Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ [HttpPost("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/preview")]
+ public async Task<IActionResult> GetProposedOnChainPaymentMethodPreview(
+ string storeId,
+ [ModelBinder(typeof(PaymentMethodIdModelBinder))]
+ PaymentMethodId paymentMethodId,
+ [FromBody] UpdatePaymentMethodRequest request = null,
+ int offset = 0, int count = 10)
+ {
+ if (request is null)
+ {
+ ModelState.AddModelError(nameof(request), "Missing body");
+ return this.CreateValidationError(ModelState);
+ }
+ if (request.Config is null)
+ {
+ ModelState.AddModelError(nameof(request.Config), "Missing config");
+ return this.CreateValidationError(ModelState);
+ }
+ AssertCryptoCodeWallet(paymentMethodId, out var network, out _);
+
+ var handler = handlers.GetBitcoinHandler(network);
+ var ctx = new PaymentMethodConfigValidationContext(authorizationService, ModelState, request.Config, User, Store.GetPaymentMethodConfig(paymentMethodId));
+ await handler.ValidatePaymentMethodConfig(ctx);
+ if (ctx.MissingPermission is not null)
+ {
+ return this.CreateAPIPermissionError(ctx.MissingPermission.Permission, ctx.MissingPermission.Message);
+ }
+ if (!ModelState.IsValid)
+ return this.CreateValidationError(ModelState);
+
+ var settings = handler.ParsePaymentMethodConfig(ctx.Config);
+ var result = GetPreviewResultData(offset, count, network, settings.AccountDerivation);
+ return Ok(result);
+ }
+
+ internal static OnChainPaymentMethodPreviewResultData GetPreviewResultData(int offset, int count, BTCPayNetwork network, DerivationStrategyBase strategy)
+ {
+ var line = strategy.GetLineFor(DerivationFeature.Deposit);
+ var result = new OnChainPaymentMethodPreviewResultData();
+ for (var i = offset; i < count; i++)
+ {
+ var keyPath = new KeyPath(0, (uint)i);
+ if (strategy is PolicyDerivationStrategy)
+ keyPath = null;
+ var derivation = line.Derive((uint)i);
+ result.Addresses.Add(
+ new()
+ {
+ KeyPath = keyPath?.ToString(),
+ Index = i,
+ Address =
+#pragma warning disable CS0612 // Type or member is obsolete
+ // We should be able to derive the address from the scriptPubKey.
+ // However, Elements has blinded addresses, so we can't derive the address from the scriptPubKey.
+ // We should probably just use a special if/else just for elements here instead of relying on obsolete stuff.
+ network.NBXplorerNetwork.CreateAddress(strategy, keyPath ?? new(), derivation.ScriptPubKey)
+#pragma warning restore CS0612 // Type or member is obsolete
+ .ToString()
+ });
+ }
+ return result;
+ }
+
+ private void AssertCryptoCodeWallet(PaymentMethodId paymentMethodId, out BTCPayNetwork network, out BTCPayWallet wallet)
+ {
+ if (!handlers.TryGetValue(paymentMethodId, out var h) || h is not BitcoinLikePaymentHandler handler)
+ throw new JsonHttpException(this.CreateAPIError(404, "unknown-paymentMethodId", "This payment method id isn't set up in this BTCPay Server instance"));
+ network = handler.Network;
+ wallet = walletProvider.GetWallet(network);
+ if (wallet is null)
+ throw ErrorPaymentMethodNotConfigured();
+ }
+
+ bool IsConfigured(PaymentMethodId paymentMethodId, [MaybeNullWhen(false)] out DerivationSchemeSettings settings)
+ {
+ var store = Store;
+ var conf = store.GetPaymentMethodConfig(paymentMethodId);
+ settings = null;
+ if (conf is (null or { Type: JTokenType.Null }))
+ return false;
+ settings = ((BitcoinLikePaymentHandler)handlers[paymentMethodId]).ParsePaymentMethodConfig(conf);
+ return settings?.AccountDerivation is not null;
+ }
+ }
+}
diff --git a/BTCPayServer/Plugins/Wallets/Controllers/GreenfieldStoreOnChainWalletsController.cs b/BTCPayServer/Plugins/Wallets/Controllers/GreenfieldStoreOnChainWalletsController.cs
new file mode 100644
index 0000000..67442d8
--- /dev/null
+++ b/BTCPayServer/Plugins/Wallets/Controllers/GreenfieldStoreOnChainWalletsController.cs
@@ -0,0 +1,927 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+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.Plugins.Wallets.Views.ViewModels;
+using BTCPayServer.Payments;
+using BTCPayServer.Payments.Bitcoin;
+using BTCPayServer.Payments.PayJoin;
+using BTCPayServer.Payments.PayJoin.Sender;
+using BTCPayServer.Services;
+using BTCPayServer.Services.Invoices;
+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(
+ IAuthorizationService authorizationService,
+ BTCPayWalletProvider btcPayWalletProvider,
+ PaymentMethodHandlerDictionary handlers,
+ WalletRepository walletRepository,
+ ExplorerClientProvider explorerClientProvider,
+ NBXplorerDashboard nbXplorerDashboard,
+ PoliciesSettings policiesSettings,
+ UIWalletsController walletsController,
+ PayjoinClient payjoinClient,
+ DelayedTransactionBroadcaster delayedTransactionBroadcaster,
+ WalletReceiveService walletReceiveService,
+ IFeeProviderFactory feeProviderFactory,
+ WalletHistogramService walletHistogramService,
+ TransactionLinkProviders transactionLinkProviders)
+ : ControllerBase
+ {
+ private StoreData Store => HttpContext.GetStoreData();
+
+ public PoliciesSettings PoliciesSettings { get; } = policiesSettings;
+
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ [HttpGet("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet")]
+ public async Task<IActionResult> ShowOnChainWalletOverview(string storeId, string paymentMethodId)
+ {
+ if (IsInvalidWalletRequest(paymentMethodId, 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.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ [HttpGet("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/histogram")]
+ public async Task<IActionResult> GetOnChainWalletHistogram(string storeId, string paymentMethodId, [FromQuery] string? type = null)
+ {
+ if (IsInvalidWalletRequest(paymentMethodId, out var network, out _, out var actionResult))
+ return actionResult;
+
+ var walletId = new WalletId(storeId, network.CryptoCode);
+ Enum.TryParse<HistogramType>(type, true, out var histType);
+ var data = await walletHistogramService.GetHistogram(Store, walletId, histType);
+ if (data == null) return this.CreateAPIError(404, "histogram-not-found", "The wallet histogram was not found.");
+
+ return Ok(new HistogramData
+ {
+ Type = data.Type,
+ Balance = data.Balance,
+ Series = data.Series,
+ Labels = data.Labels
+ });
+ }
+
+ [Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ [HttpGet("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/feerate")]
+ public async Task<IActionResult> GetOnChainFeeRate(string storeId, string paymentMethodId, int? blockTarget = null)
+ {
+ if (IsInvalidWalletRequest(paymentMethodId, 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/{paymentMethodId}/wallet/address")]
+ public async Task<IActionResult> GetOnChainWalletReceiveAddress(string storeId, string paymentMethodId,
+ bool forceGenerate = false)
+ {
+ if (IsInvalidWalletRequest(paymentMethodId, out var network,
+ out var derivationScheme, out var actionResult))
+ return actionResult;
+
+ var kpi = await walletReceiveService.GetOrGenerate(new WalletId(storeId, network.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)
+ {
+ var endpoint = Url.ActionAbsolute(Request, nameof(PayJoinEndpointController.Submit), "PayJoinEndpoint",
+ new { network.CryptoCode }).ToString();
+ bip21.QueryParams.Add(PayjoinClient.BIP21EndpointKey, endpoint);
+ }
+
+ 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/{paymentMethodId}/wallet/address")]
+ public async Task<IActionResult> UnReserveOnChainWalletReceiveAddress(string storeId, string paymentMethodId)
+ {
+ if (IsInvalidWalletRequest(paymentMethodId, out var network,
+ out _, out var actionResult))
+ return actionResult;
+
+ var addr = await walletReceiveService.UnReserveAddress(new WalletId(storeId, network.CryptoCode));
+ if (addr is null)
+ {
+ return this.CreateAPIError("no-reserved-address",
+ $"There was no reserved address for {network.CryptoCode} on this store.");
+ }
+
+ return Ok();
+ }
+
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ [HttpGet("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/transactions")]
+ public async Task<IActionResult> ShowOnChainWalletTransactions(
+ string storeId,
+ string paymentMethodId,
+ [FromQuery] TransactionStatus[]? statusFilter = null,
+ [FromQuery] string? labelFilter = null,
+ [FromQuery] int skip = 0,
+ [FromQuery] int limit = int.MaxValue,
+ CancellationToken cancellationToken = default
+ )
+ {
+ if (IsInvalidWalletRequest(paymentMethodId, out var network,
+ out var derivationScheme, out var actionResult))
+ return actionResult;
+
+ var wallet = btcPayWalletProvider.GetWallet(network);
+ var walletId = new WalletId(storeId, network.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/{paymentMethodId}/wallet/transactions/{transactionId}")]
+ public async Task<IActionResult> GetOnChainWalletTransaction(string storeId, string paymentMethodId,
+ string transactionId)
+ {
+ if (IsInvalidWalletRequest(paymentMethodId, 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, network.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/{paymentMethodId}/wallet/transactions/{transactionId}")]
+ public async Task<IActionResult> PatchOnChainWalletTransaction(
+ string storeId,
+ string paymentMethodId,
+ string transactionId,
+ [FromBody] PatchOnChainTransactionRequest request,
+ bool force = false
+ )
+ {
+ if (IsInvalidWalletRequest(paymentMethodId, 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, network.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/{paymentMethodId}/wallet/utxos")]
+ public async Task<IActionResult> GetOnChainWalletUTXOs(string storeId, string paymentMethodId)
+ {
+ if (IsInvalidWalletRequest(paymentMethodId, out var network,
+ out var derivationScheme, out var actionResult))
+ return actionResult;
+
+ var wallet = btcPayWalletProvider.GetWallet(network);
+
+ var walletId = new WalletId(storeId, network.CryptoCode);
+ var utxos = await wallet.GetUnspentCoins(derivationScheme.AccountDerivation);
+ var walletTransactionsInfoAsync = await walletRepository.GetWalletTransactionsInfo(walletId,
+ utxos.SelectMany(GetWalletObjectsQuery.Get).Distinct().ToArray());
+ var pmi = PaymentMethodId.Parse(paymentMethodId);
+ 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 = coin.Address.ToString()
+ };
+ }).ToList()
+ );
+ }
+
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ [HttpPost("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/transactions")]
+ public async Task<IActionResult> CreateOnChainTransaction(string storeId, string paymentMethodId,
+ [FromBody] CreateOnChainTransactionRequest request)
+ {
+ if (IsInvalidWalletRequest(paymentMethodId, out var network,
+ out var derivationScheme, out var actionResult))
+ return actionResult;
+
+ if (network.ReadonlyWallet)
+ {
+ return this.CreateAPIError(503, "not-available",
+ $"This network only support read-only features");
+ }
+
+ // Only enforce the hot wallet policy when we are actually signing on the server.
+ if (request.SignWithSeed && !(await CanUseHotWallet()).CanCreateHotWallet)
+ {
+ 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);
+ }
+
+ if (!request.SignWithSeed && request.ProceedWithBroadcast)
+ {
+ ModelState.AddModelError(nameof(request.ProceedWithBroadcast),
+ "Cannot request broadcast when signing is disabled (signWithSeed = false).");
+ }
+
+ var explorerClient = explorerClientProvider.GetExplorerClient(network);
+ 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(storeId, network, derivationScheme,
+ new WalletSendModel()
+ {
+ SelectedInputs = request.SelectedInputs?.Select(point => point.ToString()),
+ Outputs = outputs,
+ AlwaysIncludeNonWitnessUTXO = derivationScheme.DefaultIncludeNonWitnessUtxo,
+ 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);
+
+ if (!request.SignWithSeed)
+ {
+ return Ok(new CreateOnChainTransactionResponse
+ {
+ PSBT = psbt.PSBT.ToBase64()
+ });
+ }
+
+ 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)
+ {
+ var reason = !derivationScheme.IsHotWallet ?
+ "You cannot send from a cold wallet" :
+ "NBXplorer doesn't have the seed of the wallet";
+
+ return this.CreateAPIError(503, "not-available", reason);
+ }
+
+ var signingKey = ExtKey.Parse(signingKeyStr, network.NBitcoinNetwork);
+
+ var signingKeySettings = derivationScheme.GetAccountKeySettingsFromRoot(signingKey);
+ var rootedKeyPath = signingKeySettings?.GetRootedKeyPath();
+ if (rootedKeyPath is null || signingKeySettings is null)
+ {
+ return this.CreateAPIError(503, "not-available",
+ "The private key saved for this wallet doesn't match the derivation scheme");
+ }
+ 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 walletRepository.AddWalletTransactionAttachment(new WalletId(Store.Id, network.CryptoCode),
+ hash, Attachment.Payjoin());
+ broadcastResult = await explorerClient.BroadcastAsync(payjoinTransaction);
+ if (broadcastResult.Success)
+ {
+ return await GetOnChainWalletTransaction(storeId, paymentMethodId, 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, paymentMethodId, transactionHash.ToString());
+ }
+ else
+ {
+ return this.CreateAPIError("broadcast-error", broadcastResult.RPCMessage);
+ }
+ }
+
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ [HttpPost("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/transactions/broadcast")]
+ public async Task<IActionResult> BroadcastOnChainTransaction(string storeId, string paymentMethodId,
+ [FromBody] BroadcastOnChainTransactionRequest request)
+ {
+ if (string.IsNullOrWhiteSpace(request.Transaction))
+ {
+ ModelState.AddModelError(nameof(request.Transaction), "A PSBT or raw transaction is required.");
+ return this.CreateValidationError(ModelState);
+ }
+
+ if (IsInvalidWalletRequest(paymentMethodId, out var network,
+ out _, out var actionResult))
+ return actionResult;
+
+ if (network.ReadonlyWallet)
+ {
+ return this.CreateAPIError(503, "not-available",
+ $"This network only support read-only features");
+ }
+
+ var explorerClient = explorerClientProvider.GetExplorerClient(network);
+ Transaction transaction;
+ try
+ {
+ var psbt = PSBT.Parse(request.Transaction, network.NBitcoinNetwork);
+ if (!psbt.IsAllFinalized())
+ {
+ try
+ {
+ psbt.Finalize();
+ }
+ catch (Exception)
+ {
+ // ignored, checked below
+ }
+ }
+
+ if (!psbt.IsAllFinalized())
+ {
+ ModelState.AddModelError(nameof(request.Transaction),
+ "The PSBT is not finalized and cannot be broadcast.");
+ return this.CreateValidationError(ModelState);
+ }
+
+ transaction = psbt.ExtractTransaction();
+ }
+ catch (Exception)
+ {
+ try
+ {
+ transaction = Transaction.Parse(request.Transaction, network.NBitcoinNetwork);
+ }
+ catch (Exception)
+ {
+ ModelState.AddModelError(nameof(request.Transaction),
+ "The transaction is not a valid PSBT or raw transaction.");
+ return this.CreateValidationError(ModelState);
+ }
+ }
+
+ var broadcastResult = await explorerClient.BroadcastAsync(transaction);
+ if (broadcastResult.Success)
+ {
+ return await GetOnChainWalletTransaction(storeId, paymentMethodId,
+ transaction.GetHash().ToString());
+ }
+
+ return this.CreateAPIError("broadcast-error", broadcastResult.RPCMessage);
+ }
+
+ [HttpGet("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/objects")]
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ public async Task<IActionResult> GetOnChainWalletObjects(string storeId, string paymentMethodId, 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);
+ if (IsInvalidWalletRequest(paymentMethodId, out var network, out var actionResult))
+ return actionResult;
+ var walletId = new WalletId(storeId, network.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/{paymentMethodId}/wallet/objects/{objectType}/{objectId}")]
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ public async Task<IActionResult> GetOnChainWalletObject(string storeId, string paymentMethodId,
+ string objectType, string objectId,
+ bool? includeNeighbourData = null)
+ {
+ if (IsInvalidWalletRequest(paymentMethodId, out var network, out var actionResult))
+ return actionResult;
+ var walletId = new WalletId(storeId, network.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/{paymentMethodId}/wallet/objects/{objectType}/{objectId}")]
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ public async Task<IActionResult> RemoveOnChainWalletObject(string storeId, string paymentMethodId,
+ string objectType, string objectId)
+ {
+ if (IsInvalidWalletRequest(paymentMethodId, out var network, out var actionResult))
+ return actionResult;
+ var walletId = new WalletId(storeId, network.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/{paymentMethodId}/wallet/objects")]
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ public async Task<IActionResult> AddOrUpdateOnChainWalletObject(string storeId,
+ string paymentMethodId,
+ [FromBody] AddOnChainWalletObjectRequest request)
+ {
+ if (IsInvalidWalletRequest(paymentMethodId, out var network, out var actionResult))
+ return actionResult;
+ 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, network.CryptoCode);
+
+ try
+ {
+ await walletRepository.SetWalletObject(
+ new WalletObjectId(walletId, request!.Type, request.Id), request.Data);
+ return await GetOnChainWalletObject(storeId, network.CryptoCode, request!.Type, request.Id);
+ }
+ catch (DbUpdateException)
+ {
+ return WalletObjectNotFound();
+ }
+ }
+
+ [HttpPost("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/objects/{objectType}/{objectId}/links")]
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ public async Task<IActionResult> AddOrUpdateOnChainWalletLinks(string storeId, string paymentMethodId,
+ string objectType, string objectId,
+ [FromBody] AddOnChainWalletObjectLinkRequest request)
+ {
+ if (IsInvalidWalletRequest(paymentMethodId, out var network, out var actionResult))
+ return actionResult;
+ 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, network.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/{paymentMethodId}/wallet/objects/{objectType}/{objectId}/links/{linkType}/{linkId}")]
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ public async Task<IActionResult> RemoveOnChainWalletLink(string storeId, string paymentMethodId,
+ string objectType, string objectId,
+ string linkType, string linkId)
+ {
+ if (IsInvalidWalletRequest(paymentMethodId, out var network, out var actionResult))
+ return actionResult;
+ var walletId = new WalletId(storeId, network.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, JObject? linkdata, JObject? objectdata) data)
+ {
+ return new OnChainWalletObjectData.OnChainWalletObjectLink()
+ {
+ LinkData = data.linkdata,
+ ObjectData = data.objectdata,
+ Type = data.type,
+ Id = data.id,
+ };
+ }
+
+
+ private async Task<WalletCreationPermissions> CanUseHotWallet()
+ {
+ return await authorizationService.CanUseHotWallet(PoliciesSettings, User);
+ }
+
+ private bool IsInvalidWalletRequest(string paymentMethodId, [MaybeNullWhen(true)] out BTCPayNetwork network,
+ [MaybeNullWhen(true)] out DerivationSchemeSettings derivationScheme,
+ [MaybeNullWhen(false)] out IActionResult actionResult)
+ {
+ derivationScheme = null;
+ if (IsInvalidWalletRequest(paymentMethodId, out network, out actionResult))
+ return true;
+
+ derivationScheme = GetDerivationSchemeSettings(network.CryptoCode);
+ if (derivationScheme?.AccountDerivation is null)
+ {
+ actionResult = this.CreateAPIError(503, "not-available",
+ $"{network.CryptoCode} doesn't have any derivation scheme set");
+ return true;
+ }
+
+ actionResult = null;
+ return false;
+ }
+
+ private bool IsInvalidWalletRequest(string paymentMethodId, [MaybeNullWhen(true)] out BTCPayNetwork network,
+ [MaybeNullWhen(false)] out IActionResult actionResult)
+ {
+ if (!PaymentMethodId.TryParse(paymentMethodId, out var pmi)
+ || !handlers.TryGetValue(pmi, out var handler)
+ || handler is not IHasNetwork { Network: { WalletSupported: true } })
+ {
+ throw new JsonHttpException(this.CreateAPIError(404, "unknown-paymentMethodId",
+ "This payment method doesn't exists or doesn't offer wallet services"));
+ }
+ network = ((IHasNetwork)handler).Network;
+
+ if (!btcPayWalletProvider.IsAvailable(network))
+ {
+ actionResult = this.CreateAPIError(503, "not-available",
+ $"{pmi} services are not currently available");
+ return true;
+ }
+ actionResult = null;
+ return false;
+ }
+
+ private DerivationSchemeSettings? GetDerivationSchemeSettings(string cryptoCode)
+ {
+ return Store.GetPaymentMethodConfig<DerivationSchemeSettings>(PaymentTypes.CHAIN.GetPaymentMethodId(cryptoCode), handlers);
+ }
+
+ 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
+ };
+ }
+ }
+}
diff --git a/BTCPayServer/Plugins/Wallets/HotwalletSafe.cs b/BTCPayServer/Plugins/Wallets/HotwalletSafe.cs
index 1ae0fe2..345f5ab 100644
--- a/BTCPayServer/Plugins/Wallets/HotwalletSafe.cs
+++ b/BTCPayServer/Plugins/Wallets/HotwalletSafe.cs
@@ -3,7 +3,6 @@ using System.Security.Claims;
using System.Threading.Tasks;
using BTCPayServer.Client;
using BTCPayServer.Data;
-using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
Why this scored 11/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.