Feature: Cold wallet transaction support via Greenfield API (#7068)
What changed, and why it matters
This commit adds new API features to BTCPay Server so users can create unsigned Bitcoin transactions (PSBTs) and broadcast already-signed transactions through the Greenfield API. It is a feature addition, not a bug fix. The code does not appear to introduce an obvious vulnerability, but it changes how wallet transactions are authorized and signed, which is security-sensitive. There is no vendor statement or external report saying this commit fixes a security issue.
Treat this as a normal feature commit. Review the new broadcast endpoint for PSBT parsing robustness, ensure authorization checks are appropriate for unsigned PSBT creation, and verify that the relaxed hot-wallet policy cannot be abused to sign or broadcast transactions without proper permissions. No immediate security patch action is indicated by the supplied materials.
Security signals we found
New API surface for transaction creation and broadcasting
Authorization gate relaxed for unsigned PSBT creation (hot-wallet policy only applies when signing on server)
New broadcast endpoint parses and finalizes PSBTs from user input before broadcasting
No explicit security advisory, CVE, or bug-fix language in commit message or diff
Evidence from the diff
The commit introduces: (1) a SignWithSeed flag on CreateOnChainTransactionRequest that defaults to true; (2) a new client method CreateOnChainTransactionPSBT and response type returning an unsigned PSBT; and (3) a new BroadcastOnChainTransaction endpoint that accepts a finalized PSBT or raw transaction hex and broadcasts it. Server-side hot-wallet policy is now only enforced when SignWithSeed is true. The broadcast endpoint validates that PSBTs are finalized before extraction and falls back to parsing raw transactions. Tests cover both raw-transaction and PSBT broadcast flows.
Changed components
BTCPayServer Greenfield API on-chain wallet controllerBTCPayServer.Client on-chain wallet clientSwagger template for store on-chain wallet endpointsInspect captured patch +349 / −2
diff --git a/BTCPayServer.Client/BTCPayServerClient.OnChainWallet.cs b/BTCPayServer.Client/BTCPayServerClient.OnChainWallet.cs
index 75165de..ee41c05 100644
--- a/BTCPayServer.Client/BTCPayServerClient.OnChainWallet.cs
+++ b/BTCPayServer.Client/BTCPayServerClient.OnChainWallet.cs
@@ -102,6 +102,11 @@ public partial class BTCPayServerClient
throw new ArgumentOutOfRangeException(nameof(request.ProceedWithBroadcast),
"Please use CreateOnChainTransactionButDoNotBroadcast when wanting to only create the transaction");
}
+ if (request.SignWithSeed is false)
+ {
+ throw new ArgumentOutOfRangeException(nameof(request.SignWithSeed),
+ "Please use CreateOnChainTransactionPSBT when wanting an unsigned PSBT");
+ }
return await SendHttpRequest<OnChainWalletTransactionData>($"api/v1/stores/{storeId}/payment-methods/{cryptoCode}-CHAIN/wallet/transactions", request, HttpMethod.Post, token);
}
@@ -114,6 +119,34 @@ public partial class BTCPayServerClient
throw new ArgumentOutOfRangeException(nameof(request.ProceedWithBroadcast),
"Please use CreateOnChainTransaction when wanting to also broadcast the transaction");
}
+ if (request.SignWithSeed is false)
+ {
+ throw new ArgumentOutOfRangeException(nameof(request.SignWithSeed),
+ "Please use CreateOnChainTransactionPSBT when wanting an unsigned PSBT");
+ }
return Transaction.Parse(await SendHttpRequest<string>($"api/v1/stores/{storeId}/payment-methods/{cryptoCode}-CHAIN/wallet/transactions", request, HttpMethod.Post, token), network);
}
+
+ public virtual async Task<CreateOnChainTransactionResponse> CreateOnChainTransactionPSBT(string storeId,
+ string cryptoCode, CreateOnChainTransactionRequest request,
+ CancellationToken token = default)
+ {
+ if (request.SignWithSeed)
+ {
+ throw new ArgumentOutOfRangeException(nameof(request.SignWithSeed),
+ $"Please use {nameof(CreateOnChainTransactionButDoNotBroadcast)} when wanting to sign the transaction");
+ }
+ if (request.ProceedWithBroadcast)
+ {
+ throw new ArgumentOutOfRangeException(nameof(request.ProceedWithBroadcast),
+ $"Please use {nameof(CreateOnChainTransaction)} when wanting to also broadcast the transaction");
+ }
+ return await SendHttpRequest<CreateOnChainTransactionResponse>($"api/v1/stores/{storeId}/payment-methods/{cryptoCode}-CHAIN/wallet/transactions", request, HttpMethod.Post, token);
+ }
+
+ public virtual async Task<OnChainWalletTransactionData> BroadcastOnChainTransaction(string storeId,
+ string cryptoCode, BroadcastOnChainTransactionRequest request, CancellationToken token = default)
+ {
+ return await SendHttpRequest<BroadcastOnChainTransactionRequest, OnChainWalletTransactionData>($"api/v1/stores/{storeId}/payment-methods/{cryptoCode}-CHAIN/wallet/transactions/broadcast", null, request, HttpMethod.Post, token);
+ }
}
diff --git a/BTCPayServer.Client/Models/BroadcastOnChainTransactionRequest.cs b/BTCPayServer.Client/Models/BroadcastOnChainTransactionRequest.cs
new file mode 100644
index 0000000..347216e
--- /dev/null
+++ b/BTCPayServer.Client/Models/BroadcastOnChainTransactionRequest.cs
@@ -0,0 +1,8 @@
+#nullable enable
+namespace BTCPayServer.Client.Models
+{
+ public class BroadcastOnChainTransactionRequest
+ {
+ public string Transaction { get; set; } = string.Empty;
+ }
+}
diff --git a/BTCPayServer.Client/Models/CreateOnChainTransactionRequest.cs b/BTCPayServer.Client/Models/CreateOnChainTransactionRequest.cs
index fa9bbe1..5ed8a43 100644
--- a/BTCPayServer.Client/Models/CreateOnChainTransactionRequest.cs
+++ b/BTCPayServer.Client/Models/CreateOnChainTransactionRequest.cs
@@ -21,6 +21,7 @@ namespace BTCPayServer.Client.Models
public FeeRate FeeRate { get; set; }
public bool ProceedWithPayjoin { get; set; } = true;
public bool ProceedWithBroadcast { get; set; } = true;
+ public bool SignWithSeed { get; set; } = true;
public bool NoChange { get; set; } = false;
[JsonProperty(ItemConverterType = typeof(SaneOutpointJsonConverter))]
public List<OutPoint> SelectedInputs { get; set; } = null;
diff --git a/BTCPayServer.Client/Models/CreateOnChainTransactionResponse.cs b/BTCPayServer.Client/Models/CreateOnChainTransactionResponse.cs
new file mode 100644
index 0000000..b5383fe
--- /dev/null
+++ b/BTCPayServer.Client/Models/CreateOnChainTransactionResponse.cs
@@ -0,0 +1,8 @@
+#nullable enable
+namespace BTCPayServer.Client.Models
+{
+ public class CreateOnChainTransactionResponse
+ {
+ public string PSBT { get; set; } = string.Empty;
+ }
+}
diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs
index d4edada..9536663 100644
--- a/BTCPayServer.Tests/GreenfieldAPITests.cs
+++ b/BTCPayServer.Tests/GreenfieldAPITests.cs
@@ -3795,6 +3795,124 @@ namespace BTCPayServer.Tests
Assert.Contains(
await client.ShowOnChainWalletTransactions(walletId.StoreId, walletId.CryptoCode, null, "test label"), data => data.TransactionHash == txdata.TransactionHash);
+ // unsigned PSBT creation and broadcast endpoint
+ var psbtDestination = await client.GetOnChainWalletReceiveAddress(walletId.StoreId, walletId.CryptoCode, true);
+ var psbtRequest = new CreateOnChainTransactionRequest()
+ {
+ Destinations = new List<CreateOnChainTransactionRequest.CreateOnChainTransactionRequestDestination>()
+ {
+ new CreateOnChainTransactionRequest.CreateOnChainTransactionRequestDestination()
+ {
+ Destination = psbtDestination.Address,
+ Amount = 0.0002m
+ }
+ },
+ FeeRate = new FeeRate(5m),
+ ProceedWithBroadcast = false,
+ SignWithSeed = false
+ };
+ var psbtResponse = await client.CreateOnChainTransactionPSBT(walletId.StoreId, walletId.CryptoCode, psbtRequest);
+ Assert.False(string.IsNullOrEmpty(psbtResponse.PSBT));
+ var unsignedPsbt = PSBT.Parse(psbtResponse.PSBT, tester.ExplorerClient.Network.NBitcoinNetwork);
+ Assert.False(unsignedPsbt.IsAllFinalized());
+ Assert.Contains(unsignedPsbt.Outputs, output => output.ScriptPubKey == BitcoinAddress.Create(psbtDestination.Address, tester.ExplorerClient.Network.NBitcoinNetwork).ScriptPubKey);
+
+ await AssertValidationError(new[] { nameof(BroadcastOnChainTransactionRequest.Transaction) }, async () =>
+ {
+ await client.BroadcastOnChainTransaction(walletId.StoreId, walletId.CryptoCode,
+ new BroadcastOnChainTransactionRequest()
+ {
+ Transaction = psbtResponse.PSBT
+ });
+ });
+
+ var broadcastDestination = await client.GetOnChainWalletReceiveAddress(walletId.StoreId, walletId.CryptoCode, true);
+ var broadcastRequest = new CreateOnChainTransactionRequest()
+ {
+ Destinations = new List<CreateOnChainTransactionRequest.CreateOnChainTransactionRequestDestination>()
+ {
+ new CreateOnChainTransactionRequest.CreateOnChainTransactionRequestDestination()
+ {
+ Destination = broadcastDestination.Address,
+ Amount = 0.0002m
+ }
+ },
+ FeeRate = new FeeRate(5m),
+ ProceedWithBroadcast = false
+ };
+ var signedTx = await client.CreateOnChainTransactionButDoNotBroadcast(walletId.StoreId, walletId.CryptoCode,
+ broadcastRequest, tester.ExplorerClient.Network.NBitcoinNetwork);
+
+ var broadcastedTx = await client.BroadcastOnChainTransaction(walletId.StoreId, walletId.CryptoCode,
+ new BroadcastOnChainTransactionRequest()
+ {
+ Transaction = signedTx.ToHex()
+ });
+ Assert.Equal(signedTx.GetHash(), broadcastedTx.TransactionHash);
+ Assert.Equal(TransactionStatus.Unconfirmed, broadcastedTx.Status);
+ Assert.NotNull(await tester.ExplorerClient.GetTransactionAsync(broadcastedTx.TransactionHash));
+
+ // Now lets try base64 PSBT broadcasting
+ var broadcastRequest2 = new CreateOnChainTransactionRequest()
+ {
+ Destinations = new List<CreateOnChainTransactionRequest.CreateOnChainTransactionRequestDestination>()
+ {
+ new CreateOnChainTransactionRequest.CreateOnChainTransactionRequestDestination()
+ {
+ Destination = broadcastDestination.Address,
+ Amount = 0.0002m
+ }
+ },
+ FeeRate = new FeeRate(5m),
+ ProceedWithBroadcast = false
+ };
+
+ var signedTx2 = await client.CreateOnChainTransactionButDoNotBroadcast(walletId.StoreId, walletId.CryptoCode,
+ broadcastRequest2, tester.ExplorerClient.Network.NBitcoinNetwork);
+
+ // Create a clone so we don't modify the original 'signedTx'
+ // NBitcoin does not allow parsing of signed transactions into PSBT directly so we need to remove the signature first
+ var unsignedTx = signedTx2.Clone();
+
+ // Strip signatures from the clone (Satisfies the ArgumentException)
+ foreach (var input in unsignedTx.Inputs)
+ {
+ input.WitScript = WitScript.Empty;
+ input.ScriptSig = Script.Empty;
+ }
+
+ // Create the PSBT from the stripped transaction
+ var psbt2 = PSBT.FromTransaction(unsignedTx, tester.ExplorerClient.Network.NBitcoinNetwork);
+
+ // Re-attach the signatures from the original 'signedTx'
+ for (int i = 0; i < signedTx2.Inputs.Count; i++)
+ {
+ var input = signedTx2.Inputs[i];
+
+ // Copy Witness Data (SegWit)
+ if (input.WitScript != WitScript.Empty)
+ {
+ psbt2.Inputs[i].FinalScriptWitness = input.WitScript;
+ }
+
+ // Copy ScriptSig (Legacy or Nested SegWit)
+ if (input.ScriptSig != Script.Empty)
+ {
+ psbt2.Inputs[i].FinalScriptSig = input.ScriptSig;
+ }
+ }
+
+
+ var broadcastedTx2 = await client.BroadcastOnChainTransaction(walletId.StoreId, walletId.CryptoCode,
+ new BroadcastOnChainTransactionRequest()
+ {
+ Transaction = psbt2.ToBase64()
+ });
+
+ Assert.Equal(signedTx2.GetHash(), broadcastedTx2.TransactionHash);
+ Assert.Equal(TransactionStatus.Unconfirmed, broadcastedTx2.Status);
+ Assert.NotNull(await tester.ExplorerClient.GetTransactionAsync(broadcastedTx2.TransactionHash));
+
await tester.WaitForEvent<NewBlockEvent>(async () =>
{
await tester.ExplorerNode.GenerateAsync(1);
diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldStoreOnChainWalletsController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldStoreOnChainWalletsController.cs
index af38a0c..a09dce7 100644
--- a/BTCPayServer/Controllers/GreenField/GreenfieldStoreOnChainWalletsController.cs
+++ b/BTCPayServer/Controllers/GreenField/GreenfieldStoreOnChainWalletsController.cs
@@ -374,14 +374,15 @@ namespace BTCPayServer.Controllers.Greenfield
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");
}
- //This API is only meant for hot wallet usage for now. We can expand later when we allow PSBT manipulation.
- if (!(await CanUseHotWallet()).CanCreateHotWallet)
+ // 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)");
@@ -407,6 +408,12 @@ namespace BTCPayServer.Controllers.Greenfield
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);
@@ -556,6 +563,14 @@ namespace BTCPayServer.Controllers.Greenfield
derivationScheme.RebaseKeyPaths(psbt.PSBT);
+ if (!request.SignWithSeed)
+ {
+ return Ok(new CreateOnChainTransactionResponse
+ {
+ PSBT = psbt.PSBT.ToBase64()
+ });
+ }
+
var signingContext = new SigningContextModel()
{
PayJoinBIP21 =
@@ -656,6 +671,77 @@ namespace BTCPayServer.Controllers.Greenfield
}
}
+ [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)
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 b29175c..f533b1f 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
@@ -356,6 +356,10 @@
"description": "The unbroadcasted transaction in hex format",
"type": "string"
},
+ {
+ "description": "An unsigned PSBT in base64 format",
+ "$ref": "#/components/schemas/CreateOnChainTransactionResponse"
+ },
{
"$ref": "#/components/schemas/OnChainWalletTransactionData"
}
@@ -381,6 +385,66 @@
]
}
},
+ "/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/transactions/broadcast": {
+ "post": {
+ "tags": [
+ "Store Wallet (On Chain)"
+ ],
+ "summary": "Broadcast an on-chain transaction or finalized PSBT",
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/StoreId"
+ },
+ {
+ "$ref": "#/components/parameters/PaymentMethodId"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/BroadcastOnChainTransactionRequest"
+ }
+ }
+ }
+ },
+ "description": "Broadcast a raw transaction hex string or a finalized PSBT for the specified wallet.",
+ "operationId": "StoreOnChainWallets_BroadcastOnChainTransaction",
+ "responses": {
+ "200": {
+ "description": "broadcasted transaction",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OnChainWalletTransactionData"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "If you are authenticated but forbidden to view the specified store"
+ },
+ "404": {
+ "description": "The key is not found for this store/wallet"
+ },
+ "422": {
+ "description": "The PSBT is not finalized or the transaction payload is invalid."
+ },
+ "503": {
+ "description": "This network only supports read-only features or hotwallets are disabled."
+ }
+ },
+ "security": [
+ {
+ "API_Key": [
+ "btcpay.store.canmodifystoresettings"
+ ],
+ "Basic": []
+ }
+ ]
+ }
+ },
"/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/transactions/{transactionId}": {
"get": {
"tags": [
@@ -1167,6 +1231,12 @@
"nullable": true,
"description": "Whether to broadcast the transaction after creating it or to simply return the transaction in hex format."
},
+ "signWithSeed": {
+ "type": "boolean",
+ "default": true,
+ "nullable": true,
+ "description": "If false, build an unsigned PSBT and skip server-side signing (use the CreateOnChainTransactionPSBT client helper)."
+ },
"noChange": {
"type": "boolean",
"default": false,
@@ -1213,6 +1283,29 @@
}
}
}
+ },
+ "CreateOnChainTransactionResponse": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "psbt": {
+ "type": "string",
+ "description": "Unsigned PSBT in base64 format"
+ }
+ }
+ },
+ "BroadcastOnChainTransactionRequest": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "transaction": {
+ "type": "string",
+ "description": "A finalized PSBT (base64) or raw transaction hex string"
+ }
+ },
+ "required": [
+ "transaction"
+ ]
}
}
},
Why this scored 32/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.