Add granular API permissions for on-chain wallets (#7357)
What changed, and why it matters
This commit introduces more fine-grained permission checks for BTCPay Server's on-chain wallet API. Previously, many wallet operations required broad 'modify store settings' or 'view store settings' permissions. Now, operations are gated by dedicated wallet permissions such as viewing wallet data, managing wallet settings, creating/signing/broadcasting transactions, and managing wallet transactions. The change also fixes a Payjoin bug where the PSBT was finalized before the Payjoin request, which could break Payjoin flows. Overall, this is a hardening/security improvement rather than an introduced vulnerability.
No immediate action required; this is a security-hardening change. Operators should review API keys after upgrading to ensure they grant the new wallet-specific scopes where needed, since old broad scopes may no longer suffice for some wallet endpoints.
Security signals we found
Authorization policy tightening from broad store policies to wallet-specific policies
New explicit permission checks in transaction creation endpoint
Payjoin PSBT handling bugfix: clone before finalize to keep Payjoin PSBT unfinalized until after request
Test coverage added for forbidden access scenarios under new granular permissions
Swagger documentation updated to reflect new required API key scopes
Evidence from the diff
The commit refactors authorization policies on Greenfield on-chain wallet endpoints from broad store-level policies (CanModifyStoreSettings/CanViewStoreSettings) to wallet-specific policies (CanViewWallet, CanManageWalletSettings, CanManageWalletTransactions, CanCreateWalletTransactions, CanSignWalletTransactions, CanBroadcastWalletTransactions). It adds explicit authorization checks inside CreateOnChainTransaction requiring both create and sign permissions, plus broadcast permission when ProceedWithBroadcast is true. It also clones the PSBT before finalizing so that Payjoin receives an unfinalized PSBT, then finalizes and signs the Payjoin PSBT separately. Tests are updated to assert 403 responses for clients lacking the new granular permissions.
Changed components
BTCPayServer/Plugins/Wallets/Controllers/GreenfieldStoreOnChainPaymentMethodsController.csBTCPayServer/Plugins/Wallets/Controllers/GreenfieldStoreOnChainWalletsController.csBTCPayServer/wwwroot/swagger/v1/swagger.template.stores-wallet.on-chain.jsonBTCPayServer/wwwroot/swagger/v1/swagger.template.stores-wallet.on-chain.objects.jsonBTCPayServer.Tests/GreenfieldAPITests.csInspect captured patch +122 / −49
diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs
index ac04494..1b755c1 100644
--- a/BTCPayServer.Tests/GreenfieldAPITests.cs
+++ b/BTCPayServer.Tests/GreenfieldAPITests.cs
@@ -1,10 +1,12 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
+using BTCPayServer.BIP78.Sender;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
@@ -17,6 +19,7 @@ using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.PayoutProcessors;
using BTCPayServer.PayoutProcessors.Lightning;
+using BTCPayServer.Plugins.Wallets;
using BTCPayServer.Plugins.PointOfSale.Controllers;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
@@ -2399,6 +2402,8 @@ namespace BTCPayServer.Tests
var client = await user.CreateClient(Policies.CanModifyStoreSettings);
var client2 = await user2.CreateClient(Policies.CanModifyStoreSettings);
var viewOnlyClient = await user.CreateClient(Policies.CanViewStoreSettings);
+ var walletViewerClient = await user.CreateClient(WalletPolicies.CanViewWallet);
+ var walletSettingsClient = await user.CreateClient(WalletPolicies.CanManageWalletSettings);
var store = await client.CreateStore(new CreateStoreRequest() { Name = "test store" });
@@ -2417,9 +2422,13 @@ namespace BTCPayServer.Tests
await client.PreviewStoreOnChainPaymentMethodAddresses(store.Id, "BTC");
});
- Assert.Equal(firstAddress, (await viewOnlyClient.PreviewProposedStoreOnChainPaymentMethodAddresses(store.Id, "BTC", xpub)).Addresses.First().Address);
+ await AssertHttpError(403, async () =>
+ {
+ await viewOnlyClient.PreviewProposedStoreOnChainPaymentMethodAddresses(store.Id, "BTC", xpub);
+ });
+ Assert.Equal(firstAddress, (await walletSettingsClient.PreviewProposedStoreOnChainPaymentMethodAddresses(store.Id, "BTC", xpub)).Addresses.First().Address);
// Testing if the rewrite rule to old API path is working
- await viewOnlyClient.SendHttpRequest($"api/v1/stores/{store.Id}/payment-methods/onchain/BTC/preview", new JObject() { ["config"] = xpub }, HttpMethod.Post);
+ await walletSettingsClient.SendHttpRequest($"api/v1/stores/{store.Id}/payment-methods/onchain/BTC/preview", new JObject() { ["config"] = xpub }, HttpMethod.Post);
var method = await client.UpdateStorePaymentMethod(store.Id, "BTC-CHAIN", new UpdatePaymentMethodRequest() { Enabled = true, Config = JValue.CreateString(xpub)});
var method2 = await client.UpdateStorePaymentMethod(store.Id, "BTC-CHAIN", new UpdatePaymentMethodRequest() { Enabled = true, Config = new JObject() { ["derivationScheme"] = xpub, ["label"] = "test", ["accountKeyPath"] = "aaaaaaaa/84'/0'/0'" } });
@@ -2429,7 +2438,7 @@ namespace BTCPayServer.Tests
Assert.Equal(method.ToJson(), method3.ToJson());
- Assert.Equal(firstAddress, (await viewOnlyClient.PreviewStoreOnChainPaymentMethodAddresses(store.Id, "BTC")).Addresses.First().Address);
+ Assert.Equal(firstAddress, (await walletViewerClient.PreviewStoreOnChainPaymentMethodAddresses(store.Id, "BTC")).Addresses.First().Address);
await AssertHttpError(403, async () =>
{
await viewOnlyClient.RemoveStorePaymentMethod(store.Id, "BTC-CHAIN");
@@ -2503,8 +2512,14 @@ namespace BTCPayServer.Tests
var user = tester.NewAccount();
await user.GrantAccessAsync(true);
- var client = await user.CreateClient(Policies.CanModifyStoreSettings, Policies.CanModifyServerSettings);
+ var client = await user.CreateClient(WalletPolicies.CanManageWallets, Policies.CanModifyStoreSettings,
+ Policies.CanModifyServerSettings);
+ var noBroadcastClient = await user.CreateClient(WalletPolicies.CanCreateWalletTransactions,
+ WalletPolicies.CanSignWalletTransactions, Policies.CanModifyServerSettings);
+ var createOnlyClient = await user.CreateClient(WalletPolicies.CanCreateWalletTransactions,
+ Policies.CanModifyServerSettings);
var viewOnlyClient = await user.CreateClient(Policies.CanViewStoreSettings);
+ var walletViewerClient = await user.CreateClient(WalletPolicies.CanViewWallet);
var walletId = await user.RegisterDerivationSchemeAsync("BTC", ScriptPubKeyType.Segwit, true);
//view only clients can't do jack shit with this API
@@ -2514,6 +2529,7 @@ namespace BTCPayServer.Tests
});
var overview = await client.ShowOnChainWalletOverview(walletId.StoreId, walletId.CryptoCode);
Assert.Equal(0m, overview.Balance);
+ Assert.Equal(0m, (await walletViewerClient.ShowOnChainWalletOverview(walletId.StoreId, walletId.CryptoCode)).Balance);
var fee = await client.GetOnChainFeeRate(walletId.StoreId, walletId.CryptoCode);
Assert.NotNull(fee.FeeRate);
@@ -2522,6 +2538,7 @@ namespace BTCPayServer.Tests
{
await viewOnlyClient.GetOnChainWalletReceiveAddress(walletId.StoreId, walletId.CryptoCode);
});
+ Assert.NotNull((await walletViewerClient.GetOnChainWalletReceiveAddress(walletId.StoreId, walletId.CryptoCode)).Address);
// Testing if the rewrite rule to old API path is working
await AssertHttpError(403, async () =>
@@ -2537,6 +2554,7 @@ namespace BTCPayServer.Tests
{
await viewOnlyClient.GetOnChainWalletUTXOs(walletId.StoreId, walletId.CryptoCode);
});
+ Assert.Empty(await walletViewerClient.GetOnChainWalletUTXOs(walletId.StoreId, walletId.CryptoCode));
Assert.Empty(await client.GetOnChainWalletUTXOs(walletId.StoreId, walletId.CryptoCode));
uint256 txhash = null;
await tester.WaitForEvent<NewOnChainTransactionEvent>(async () =>
@@ -2550,6 +2568,10 @@ namespace BTCPayServer.Tests
var address4 = await client.GetOnChainWalletReceiveAddress(walletId.StoreId, walletId.CryptoCode, false);
Assert.NotEqual(address3.Address, address4.Address);
await client.UnReserveOnChainWalletReceiveAddress(walletId.StoreId, walletId.CryptoCode);
+ await AssertHttpError(403, async () =>
+ {
+ await walletViewerClient.UnReserveOnChainWalletReceiveAddress(walletId.StoreId, walletId.CryptoCode);
+ });
var address5 = await client.GetOnChainWalletReceiveAddress(walletId.StoreId, walletId.CryptoCode, true);
Assert.Equal(address5.Address, address4.Address);
@@ -2565,6 +2587,7 @@ namespace BTCPayServer.Tests
{
await viewOnlyClient.GetOnChainWalletHistogram(walletId.StoreId, walletId.CryptoCode);
});
+ Assert.NotNull(await walletViewerClient.GetOnChainWalletHistogram(walletId.StoreId, walletId.CryptoCode));
var histogram = await client.GetOnChainWalletHistogram(walletId.StoreId, walletId.CryptoCode);
Assert.Equal(histogram.Balance, histogram.Series.Last());
Assert.Equal(0.01m, histogram.Balance);
@@ -2589,6 +2612,17 @@ namespace BTCPayServer.Tests
{
await viewOnlyClient.CreateOnChainTransaction(walletId.StoreId, walletId.CryptoCode, createTxRequest);
});
+ await AssertHttpError(403, async () =>
+ {
+ await walletViewerClient.CreateOnChainTransaction(walletId.StoreId, walletId.CryptoCode, createTxRequest);
+ });
+ var forbidden = await Assert.ThrowsAsync<HttpRequestException>(async () =>
+ await createOnlyClient.CreateOnChainTransaction(walletId.StoreId, walletId.CryptoCode, createTxRequest));
+ Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode);
+ createTxRequest.ProceedWithBroadcast = true;
+ forbidden = await Assert.ThrowsAsync<HttpRequestException>(async () =>
+ await noBroadcastClient.CreateOnChainTransaction(walletId.StoreId, walletId.CryptoCode, createTxRequest));
+ Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode);
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(async () =>
{
await client.CreateOnChainTransactionButDoNotBroadcast(walletId.StoreId, walletId.CryptoCode,
@@ -2610,6 +2644,34 @@ namespace BTCPayServer.Tests
Assert.Contains(tx.Outputs, txout => txout.IsTo(nodeAddress) && txout.Value.ToDecimal(MoneyUnit.BTC) == 0.001m);
Assert.True((await tester.ExplorerNode.TestMempoolAcceptAsync(tx)).IsAllowed);
+ var payjoinDestination = await client.GetOnChainWalletReceiveAddress(walletId.StoreId,
+ walletId.CryptoCode, true);
+ var payjoinEndpoint = Uri.EscapeDataString("https://example.com/payjoin");
+ var payjoinBip21 =
+ $"bitcoin:{payjoinDestination.Address}?amount=0.0002&{PayjoinClient.BIP21EndpointKey}={payjoinEndpoint}";
+ var payjoinNoBroadcastTx = await noBroadcastClient.CreateOnChainTransactionButDoNotBroadcast(
+ walletId.StoreId,
+ walletId.CryptoCode,
+ new CreateOnChainTransactionRequest
+ {
+ Destinations =
+ new List<CreateOnChainTransactionRequest.CreateOnChainTransactionRequestDestination>
+ {
+ new()
+ {
+ Destination = payjoinBip21
+ }
+ },
+ FeeRate = new FeeRate(5m),
+ ProceedWithBroadcast = false
+ },
+ tester.ExplorerClient.Network.NBitcoinNetwork);
+ Assert.Contains(payjoinNoBroadcastTx.Outputs,
+ txout => txout.IsTo(BitcoinAddress.Create(payjoinDestination.Address,
+ tester.ExplorerClient.Network.NBitcoinNetwork)) &&
+ txout.Value.ToDecimal(MoneyUnit.BTC) == 0.0002m);
+ Assert.Null(await tester.ExplorerClient.GetTransactionAsync(payjoinNoBroadcastTx.GetHash()));
+
// no change test
createTxRequest.NoChange = true;
tx = await client.CreateOnChainTransactionButDoNotBroadcast(walletId.StoreId, walletId.CryptoCode,
@@ -2715,6 +2777,7 @@ namespace BTCPayServer.Tests
{
await viewOnlyClient.GetOnChainWalletTransaction(walletId.StoreId, walletId.CryptoCode, txdata.TransactionHash.ToString());
});
+ Assert.NotNull(await walletViewerClient.GetOnChainWalletTransaction(walletId.StoreId, walletId.CryptoCode, txdata.TransactionHash.ToString()));
var transaction = await client.GetOnChainWalletTransaction(walletId.StoreId, walletId.CryptoCode, txdata.TransactionHash.ToString());
// Check skip doesn't crash
@@ -2749,6 +2812,8 @@ namespace BTCPayServer.Tests
{
await viewOnlyClient.ShowOnChainWalletTransactions(walletId.StoreId, walletId.CryptoCode);
});
+ Assert.Contains(
+ await walletViewerClient.ShowOnChainWalletTransactions(walletId.StoreId, walletId.CryptoCode), data => data.TransactionHash == txdata.TransactionHash);
Assert.True(Assert.Single(
await client.ShowOnChainWalletTransactions(walletId.StoreId, walletId.CryptoCode,
new[] { TransactionStatus.Confirmed })).TransactionHash == utxo.Outpoint.Hash);
diff --git a/BTCPayServer/Plugins/Wallets/Controllers/GreenfieldStoreOnChainPaymentMethodsController.cs b/BTCPayServer/Plugins/Wallets/Controllers/GreenfieldStoreOnChainPaymentMethodsController.cs
index cbb6d2e..d0db4f8 100644
--- a/BTCPayServer/Plugins/Wallets/Controllers/GreenfieldStoreOnChainPaymentMethodsController.cs
+++ b/BTCPayServer/Plugins/Wallets/Controllers/GreenfieldStoreOnChainPaymentMethodsController.cs
@@ -8,6 +8,7 @@ using BTCPayServer.Data;
using BTCPayServer.ModelBinders;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin;
+using BTCPayServer.Plugins.Wallets;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Stores;
@@ -44,7 +45,7 @@ namespace BTCPayServer.Controllers.Greenfield
return new JsonHttpException(this.CreateAPIError(404, "paymentmethod-not-configured", "The payment method is not configured"));
}
- [Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ [Authorize(Policy = WalletPolicies.CanViewWallet, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/preview")]
public IActionResult GetOnChainPaymentMethodPreview(
string storeId,
@@ -61,7 +62,7 @@ namespace BTCPayServer.Controllers.Greenfield
}
- [Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ [Authorize(Policy = WalletPolicies.CanManageWalletSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPost("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/preview")]
public async Task<IActionResult> GetProposedOnChainPaymentMethodPreview(
string storeId,
diff --git a/BTCPayServer/Plugins/Wallets/Controllers/GreenfieldStoreOnChainWalletsController.cs b/BTCPayServer/Plugins/Wallets/Controllers/GreenfieldStoreOnChainWalletsController.cs
index 67442d8..17dbe46 100644
--- a/BTCPayServer/Plugins/Wallets/Controllers/GreenfieldStoreOnChainWalletsController.cs
+++ b/BTCPayServer/Plugins/Wallets/Controllers/GreenfieldStoreOnChainWalletsController.cs
@@ -8,10 +8,10 @@ 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;
using BTCPayServer.Plugins.Wallets.Views.ViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin;
@@ -57,7 +57,7 @@ namespace BTCPayServer.Controllers.Greenfield
public PoliciesSettings PoliciesSettings { get; } = policiesSettings;
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ [Authorize(Policy = WalletPolicies.CanViewWallet, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet")]
public async Task<IActionResult> ShowOnChainWalletOverview(string storeId, string paymentMethodId)
{
@@ -77,7 +77,7 @@ namespace BTCPayServer.Controllers.Greenfield
});
}
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ [Authorize(Policy = WalletPolicies.CanViewWallet, 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)
{
@@ -98,7 +98,7 @@ namespace BTCPayServer.Controllers.Greenfield
});
}
- [Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ [Authorize(Policy = WalletPolicies.CanViewWallet, 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)
{
@@ -114,7 +114,7 @@ namespace BTCPayServer.Controllers.Greenfield
});
}
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ [Authorize(Policy = WalletPolicies.CanViewWallet, 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)
@@ -146,7 +146,7 @@ namespace BTCPayServer.Controllers.Greenfield
});
}
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ [Authorize(Policy = WalletPolicies.CanManageWalletTransactions, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpDelete("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/address")]
public async Task<IActionResult> UnReserveOnChainWalletReceiveAddress(string storeId, string paymentMethodId)
{
@@ -164,7 +164,7 @@ namespace BTCPayServer.Controllers.Greenfield
return Ok();
}
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ [Authorize(Policy = WalletPolicies.CanViewWallet, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/transactions")]
public async Task<IActionResult> ShowOnChainWalletTransactions(
string storeId,
@@ -221,7 +221,7 @@ namespace BTCPayServer.Controllers.Greenfield
return Ok(result);
}
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ [Authorize(Policy = WalletPolicies.CanViewWallet, 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)
@@ -245,7 +245,7 @@ namespace BTCPayServer.Controllers.Greenfield
return Ok(ToModel(walletTransactionsInfoAsync, tx, wallet));
}
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ [Authorize(Policy = WalletPolicies.CanManageWalletTransactions, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPatch(
"~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/transactions/{transactionId}")]
public async Task<IActionResult> PatchOnChainWalletTransaction(
@@ -288,7 +288,7 @@ namespace BTCPayServer.Controllers.Greenfield
return Ok(ToModel(walletTransactionsInfo, tx, wallet));
}
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ [Authorize(Policy = WalletPolicies.CanViewWallet, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/utxos")]
public async Task<IActionResult> GetOnChainWalletUTXOs(string storeId, string paymentMethodId)
{
@@ -328,7 +328,7 @@ namespace BTCPayServer.Controllers.Greenfield
);
}
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ [Authorize(Policy = WalletPolicies.CanCreateWalletTransactions, 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)
@@ -343,6 +343,11 @@ namespace BTCPayServer.Controllers.Greenfield
$"This network only support read-only features");
}
+ if (!(await authorizationService.AuthorizeAsync(User, storeId, WalletPolicies.CanSignWalletTransactions)).Succeeded ||
+ (request.ProceedWithBroadcast &&
+ !(await authorizationService.AuthorizeAsync(User, storeId, WalletPolicies.CanBroadcastWalletTransactions)).Succeeded))
+ return Forbid();
+
// Only enforce the hot wallet policy when we are actually signing on the server.
if (request.SignWithSeed && !(await CanUseHotWallet()).CanCreateHotWallet)
{
@@ -451,7 +456,8 @@ namespace BTCPayServer.Controllers.Greenfield
this);
}
- if (request.ProceedWithPayjoin &&
+ if (request.ProceedWithBroadcast &&
+ request.ProceedWithPayjoin &&
bip21?.UnknownParameters?.ContainsKey(PayjoinClient.BIP21EndpointKey) is true)
{
payjoinOutputIndex = index;
@@ -474,7 +480,6 @@ namespace BTCPayServer.Controllers.Greenfield
"You can only subtract fees from one destination", this);
}
}
-
if (balanceAvailable < sum)
{
request.AddModelError(transactionRequest => transactionRequest.Destinations,
@@ -581,6 +586,7 @@ namespace BTCPayServer.Controllers.Greenfield
"Impossible to sign the transaction. Probable cause: Incorrect account key path in wallet settings, PSBT already signed.");
}
+ var signedPSBT = psbt.PSBT.Clone();
psbt.PSBT.Finalize();
var transaction = psbt.PSBT.ExtractTransaction();
var transactionHash = transaction.GetHash();
@@ -596,10 +602,10 @@ namespace BTCPayServer.Controllers.Greenfield
var payjoinPSBT = await payjoinClient.RequestPayjoin(
new BitcoinUrlBuilder(signingContext.PayJoinBIP21, network.NBitcoinNetwork),
new PayjoinWallet(derivationScheme),
- psbt.PSBT, CancellationToken.None);
- psbt.PSBT.Settings.SigningOptions =
+ signedPSBT, CancellationToken.None);
+ payjoinPSBT.Settings.SigningOptions =
new SigningOptions() { EnforceLowR = !(signingContext?.EnforceLowR is false) };
- payjoinPSBT = psbt.PSBT.SignAll(derivationScheme.AccountDerivation, accountKey, rootedKeyPath);
+ payjoinPSBT = payjoinPSBT.SignAll(derivationScheme.AccountDerivation, accountKey, rootedKeyPath);
payjoinPSBT.Finalize();
var payjoinTransaction = payjoinPSBT.ExtractTransaction();
var hash = payjoinTransaction.GetHash();
@@ -633,7 +639,7 @@ namespace BTCPayServer.Controllers.Greenfield
}
}
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ [Authorize(Policy = WalletPolicies.CanBroadcastWalletTransactions, 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)
@@ -705,7 +711,7 @@ namespace BTCPayServer.Controllers.Greenfield
}
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/objects")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ [Authorize(Policy = WalletPolicies.CanViewWallet, 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"))
@@ -720,7 +726,7 @@ namespace BTCPayServer.Controllers.Greenfield
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)]
+ [Authorize(Policy = WalletPolicies.CanViewWallet, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> GetOnChainWalletObject(string storeId, string paymentMethodId,
string objectType, string objectId,
bool? includeNeighbourData = null)
@@ -735,7 +741,7 @@ namespace BTCPayServer.Controllers.Greenfield
}
[HttpDelete("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/objects/{objectType}/{objectId}")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ [Authorize(Policy = WalletPolicies.CanManageWalletTransactions, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> RemoveOnChainWalletObject(string storeId, string paymentMethodId,
string objectType, string objectId)
{
@@ -754,7 +760,7 @@ namespace BTCPayServer.Controllers.Greenfield
}
[HttpPost("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/objects")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ [Authorize(Policy = WalletPolicies.CanManageWalletTransactions, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> AddOrUpdateOnChainWalletObject(string storeId,
string paymentMethodId,
[FromBody] AddOnChainWalletObjectRequest request)
@@ -783,7 +789,7 @@ namespace BTCPayServer.Controllers.Greenfield
}
[HttpPost("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/objects/{objectType}/{objectId}/links")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ [Authorize(Policy = WalletPolicies.CanManageWalletTransactions, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> AddOrUpdateOnChainWalletLinks(string storeId, string paymentMethodId,
string objectType, string objectId,
[FromBody] AddOnChainWalletObjectLinkRequest request)
@@ -813,7 +819,7 @@ namespace BTCPayServer.Controllers.Greenfield
}
[HttpDelete("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/objects/{objectType}/{objectId}/links/{linkType}/{linkId}")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
+ [Authorize(Policy = WalletPolicies.CanManageWalletTransactions, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> RemoveOnChainWalletLink(string storeId, string paymentMethodId,
string objectType, string objectId,
string linkType, string linkId)
diff --git a/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores-wallet.on-chain.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores-wallet.on-chain.json
index bf42154..f92c30f 100644
--- a/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores-wallet.on-chain.json
+++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores-wallet.on-chain.json
@@ -37,7 +37,7 @@
"security": [
{
"API_Key": [
- "btcpay.store.canmodifystoresettings"
+ "btcpay.store.canviewwallet"
],
"Basic": []
}
@@ -81,7 +81,7 @@
"security": [
{
"API_Key": [
- "btcpay.store.canmodifystoresettings"
+ "btcpay.store.canviewwallet"
],
"Basic": []
}
@@ -135,7 +135,7 @@
"security": [
{
"API_Key": [
- "btcpay.store.canviewstoresettings"
+ "btcpay.store.canviewwallet"
],
"Basic": []
}
@@ -189,7 +189,7 @@
"security": [
{
"API_Key": [
- "btcpay.store.canmodifystoresettings"
+ "btcpay.store.canviewwallet"
],
"Basic": []
}
@@ -224,7 +224,7 @@
"security": [
{
"API_Key": [
- "btcpay.store.canmodifystoresettings"
+ "btcpay.store.canmanagewallettransactions"
],
"Basic": []
}
@@ -314,7 +314,7 @@
"security": [
{
"API_Key": [
- "btcpay.store.canmodifystoresettings"
+ "btcpay.store.canviewwallet"
],
"Basic": []
}
@@ -343,7 +343,7 @@
}
}
},
- "description": "Create store on-chain wallet transaction",
+ "description": "Create store on-chain wallet transaction. Requires `btcpay.store.cancreatetransactions` and `btcpay.store.cansigntransactions`. Also requires `btcpay.store.canbroadcasttransactions` when `proceedWithBroadcast` is true, including Payjoin.",
"operationId": "StoreOnChainWallets_CreateOnChainTransaction",
"responses": {
"200": {
@@ -378,7 +378,8 @@
"security": [
{
"API_Key": [
- "btcpay.store.canmodifystoresettings"
+ "btcpay.store.cancreatetransactions",
+ "btcpay.store.cansigntransactions"
],
"Basic": []
}
@@ -438,7 +439,7 @@
"security": [
{
"API_Key": [
- "btcpay.store.canmodifystoresettings"
+ "btcpay.store.canbroadcasttransactions"
],
"Basic": []
}
@@ -491,7 +492,7 @@
"security": [
{
"API_Key": [
- "btcpay.store.canmodifystoresettings"
+ "btcpay.store.canviewwallet"
],
"Basic": []
}
@@ -561,7 +562,7 @@
"security": [
{
"API_Key": [
- "btcpay.store.canmodifystoresettings"
+ "btcpay.store.canmanagewallettransactions"
],
"Basic": []
}
@@ -608,7 +609,7 @@
"security": [
{
"API_Key": [
- "btcpay.store.canmodifystoresettings"
+ "btcpay.store.canviewwallet"
],
"Basic": []
}
@@ -758,7 +759,7 @@
"security": [
{
"API_Key": [
- "btcpay.store.canviewstoresettings"
+ "btcpay.store.canviewwallet"
],
"Basic": []
}
@@ -837,7 +838,7 @@
"security": [
{
"API_Key": [
- "btcpay.store.canviewstoresettings"
+ "btcpay.store.canmanagewalletsettings"
],
"Basic": []
}
diff --git a/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores-wallet.on-chain.objects.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores-wallet.on-chain.objects.json
index 277985d..f11b014 100644
--- a/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores-wallet.on-chain.objects.json
+++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores-wallet.on-chain.objects.json
@@ -73,7 +73,7 @@
"security": [
{
"API_Key": [
- "btcpay.store.canmodifystoresettings"
+ "btcpay.store.canviewwallet"
],
"Basic": []
}
@@ -125,7 +125,7 @@
"security": [
{
"API_Key": [
- "btcpay.store.canmodifystoresettings"
+ "btcpay.store.canmanagewallettransactions"
],
"Basic": []
}
@@ -199,7 +199,7 @@
"security": [
{
"API_Key": [
- "btcpay.store.canmodifystoresettings"
+ "btcpay.store.canviewwallet"
],
"Basic": []
}
@@ -254,7 +254,7 @@
"security": [
{
"API_Key": [
- "btcpay.store.canmodifystoresettings"
+ "btcpay.store.canmanagewallettransactions"
],
"Basic": []
}
@@ -321,7 +321,7 @@
"security": [
{
"API_Key": [
- "btcpay.store.canmodifystoresettings"
+ "btcpay.store.canmanagewallettransactions"
],
"Basic": []
}
@@ -398,7 +398,7 @@
"security": [
{
"API_Key": [
- "btcpay.store.canmodifystoresettings"
+ "btcpay.store.canmanagewallettransactions"
],
"Basic": []
}
Why this scored 40/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.