Include witness UTXOs in SegWit PSBTs
What changed, and why it matters
This commit changes BTCPay Server so that when it builds or decodes Partially Signed Bitcoin Transactions (PSBTs), it explicitly adds a compact 'witness UTXO' record for any SegWit inputs. Previously, some SegWit PSBTs may only have carried the full previous transaction (non-witness UTXO). Adding the witness UTXO improves compatibility with hardware wallets and signers that require or prefer it, and can reduce the data those devices need to process. The change itself is a correctness/compatibility improvement rather than an obvious vulnerability fix, but the absence of witness UTXOs in SegWit PSBTs can cause signing failures or force fallback behaviour in some wallets.
Treat as a routine compatibility/robustness improvement. Review whether any downstream signers or users were previously failing to sign SegWit PSBTs produced by BTCPay Server, and consider documenting the change in release notes. No urgent security patch is indicated by the diff alone.
Security signals we found
PSBT data completeness change for SegWit inputs
Potential prior state: SegWit PSBTs may have been distributed without witness UTXOs
Hardware-wallet compatibility / BIP-174 compliance improvement
No explicit vulnerability, bug ID, or CVE referenced in commit
No input validation or sanitization changes beyond PSBT field population
Evidence from the diff
The patch introduces PSBTExtensions.AddWitnessUtxoToSegwitInputs(), which iterates PSBT inputs that already have a NonWitnessUtxo but lack a WitnessUtxo. If the referenced previous output is a native SegWit output (P2WPKH, P2WSH, or P2TR) or a nested P2SH-P2WSH output with a matching redeem script, it copies the previous output into input.WitnessUtxo. The method is invoked in UIWalletsController.PSBT.cs after PSBT generation and in WalletPSBTViewModel.cs after PSBT decoding. A unit test verifies that native P2WSH, P2WPKH, nested P2SH-P2WSH, and Taproot inputs receive WitnessUtxo, while legacy P2SH does not.
Changed components
BTCPayServer/Extensions/PSBTExtensions.csBTCPayServer/Plugins/Wallets/Controllers/UIWalletsController.PSBT.csBTCPayServer/Plugins/Wallets/Views/ViewModels/WalletPSBTViewModel.csBTCPayServer.Tests/PSBTTests.csInspect captured patch +83 / −0
diff --git a/BTCPayServer.Tests/PSBTTests.cs b/BTCPayServer.Tests/PSBTTests.cs
index 4585e02..68ef078 100644
--- a/BTCPayServer.Tests/PSBTTests.cs
+++ b/BTCPayServer.Tests/PSBTTests.cs
@@ -1,3 +1,4 @@
+using System.Linq;
using System.Threading.Tasks;
using NBitcoin;
using Xunit;
@@ -6,6 +7,54 @@ namespace BTCPayServer.Tests
{
public class PSBTTests(ITestOutputHelper helper) : UnitTestBase(helper)
{
+ [Fact]
+ public void AddsWitnessUtxoToSegwitInputs()
+ {
+ var network = Network.RegTest;
+ var keys = new[] { new Key(), new Key() };
+ var witnessScript = PayToMultiSigTemplate.Instance.GenerateScriptPubKey(
+ 2,
+ keys.Select(key => key.PubKey).ToArray());
+ var nativeP2wsh = witnessScript.WitHash.ScriptPubKey;
+ var nativeP2wpkh = new Key().PubKey.WitHash.ScriptPubKey;
+ var nestedP2wshRedeem = witnessScript.WitHash.ScriptPubKey;
+ var nestedP2wsh = nestedP2wshRedeem.Hash.ScriptPubKey;
+ var taproot = new Key().PubKey.GetScriptPubKey(ScriptPubKeyType.TaprootBIP86);
+ var legacyP2sh = witnessScript.Hash.ScriptPubKey;
+
+ var previousTransaction = network.CreateTransaction();
+ previousTransaction.Outputs.Add(Money.Coins(1.0m), nativeP2wsh);
+ previousTransaction.Outputs.Add(Money.Coins(1.1m), nativeP2wpkh);
+ previousTransaction.Outputs.Add(Money.Coins(1.2m), nestedP2wsh);
+ previousTransaction.Outputs.Add(Money.Coins(1.3m), taproot);
+ previousTransaction.Outputs.Add(Money.Coins(1.4m), legacyP2sh);
+
+ var spendingTransaction = network.CreateTransaction();
+ for (var i = 0; i < previousTransaction.Outputs.Count; i++)
+ spendingTransaction.Inputs.Add(new OutPoint(previousTransaction.GetHash(), i));
+ spendingTransaction.Outputs.Add(Money.Coins(4.5m), new Key().PubKey.WitHash.ScriptPubKey);
+
+ var psbt = PSBT.FromTransaction(spendingTransaction, network);
+ foreach (var input in psbt.Inputs)
+ input.NonWitnessUtxo = previousTransaction;
+ psbt.Inputs[0].WitnessScript = witnessScript;
+ psbt.Inputs[2].RedeemScript = nestedP2wshRedeem;
+ psbt.Inputs[2].WitnessScript = witnessScript;
+ psbt.Inputs[4].RedeemScript = witnessScript;
+
+ Assert.All(psbt.Inputs, input => Assert.Null(input.WitnessUtxo));
+
+ psbt.AddWitnessUtxoToSegwitInputs();
+
+ for (var i = 0; i < 4; i++)
+ {
+ Assert.NotNull(psbt.Inputs[i].WitnessUtxo);
+ Assert.Equal(previousTransaction.Outputs[i], psbt.Inputs[i].WitnessUtxo);
+ }
+ Assert.Null(psbt.Inputs[4].WitnessUtxo);
+ Assert.All(psbt.Inputs, input => Assert.Same(previousTransaction, input.NonWitnessUtxo));
+ }
+
[Fact]
[Trait("Playwright", "Playwright")]
public async Task CanPlayWithPSBT()
diff --git a/BTCPayServer/Extensions/PSBTExtensions.cs b/BTCPayServer/Extensions/PSBTExtensions.cs
index 4c31295..c487a0d 100644
--- a/BTCPayServer/Extensions/PSBTExtensions.cs
+++ b/BTCPayServer/Extensions/PSBTExtensions.cs
@@ -12,5 +12,37 @@ namespace BTCPayServer
var after = psbt.ToBase64();
return before != after;
}
+
+ public static PSBT AddWitnessUtxoToSegwitInputs(this PSBT psbt)
+ {
+ foreach (var input in psbt.Inputs)
+ {
+ var nonWitnessUtxo = input.NonWitnessUtxo;
+ if (input.WitnessUtxo is not null ||
+ nonWitnessUtxo is null ||
+ nonWitnessUtxo.GetHash() != input.PrevOut.Hash ||
+ input.PrevOut.N >= (uint)nonWitnessUtxo.Outputs.Count)
+ continue;
+
+ var previousOutput = nonWitnessUtxo.Outputs[(int)input.PrevOut.N];
+ var redeemScript = input.RedeemScript;
+ if (IsSegwit(previousOutput.ScriptPubKey) ||
+ redeemScript is not null &&
+ IsSegwit(redeemScript) &&
+ redeemScript.Hash.ScriptPubKey == previousOutput.ScriptPubKey)
+ {
+ input.WitnessUtxo = previousOutput;
+ }
+ }
+
+ return psbt;
+ }
+
+ private static bool IsSegwit(Script scriptPubKey)
+ {
+ return PayToWitPubKeyHashTemplate.Instance.CheckScriptPubKey(scriptPubKey) ||
+ PayToWitScriptHashTemplate.Instance.CheckScriptPubKey(scriptPubKey) ||
+ PayToTaprootTemplate.Instance.CheckScriptPubKey(scriptPubKey);
+ }
}
}
diff --git a/BTCPayServer/Plugins/Wallets/Controllers/UIWalletsController.PSBT.cs b/BTCPayServer/Plugins/Wallets/Controllers/UIWalletsController.PSBT.cs
index 1bb7a9b..a3410b3 100644
--- a/BTCPayServer/Plugins/Wallets/Controllers/UIWalletsController.PSBT.cs
+++ b/BTCPayServer/Plugins/Wallets/Controllers/UIWalletsController.PSBT.cs
@@ -69,6 +69,7 @@ namespace BTCPayServer.Controllers
if (psbt == null)
throw new NotSupportedException(StringLocalizer["You need to update your version of NBXplorer"]);
+ psbt.PSBT.AddWitnessUtxoToSegwitInputs();
return psbt;
}
diff --git a/BTCPayServer/Plugins/Wallets/Views/ViewModels/WalletPSBTViewModel.cs b/BTCPayServer/Plugins/Wallets/Views/ViewModels/WalletPSBTViewModel.cs
index 5a71b52..5b5816c 100644
--- a/BTCPayServer/Plugins/Wallets/Views/ViewModels/WalletPSBTViewModel.cs
+++ b/BTCPayServer/Plugins/Wallets/Views/ViewModels/WalletPSBTViewModel.cs
@@ -42,6 +42,7 @@ namespace BTCPayServer.Plugins.Wallets.Views.ViewModels
var psbt = await GetPSBTCore(network, modelState);
if (psbt != null)
{
+ psbt.AddWitnessUtxoToSegwitInputs();
Decoded = psbt.ToString();
PSBTHex = psbt.ToHex();
PSBT = psbt.ToBase64();
Why this scored 45/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.