Fix pending multisig signature retries
What changed, and why it matters
This commit fixes a bug in BTCPay Server's multisig transaction handling. Previously, the service could get stuck in a 'Pending' state even when enough valid signatures had been collected, because it discarded new signature submissions that didn't immediately improve progress and didn't retry finalizing the transaction. The fix keeps distinct-but-not-yet-helpful PSBTs (partially signed transactions), records them, and retries finalization each time a new PSBT arrives, allowing later valid signatures to complete the transaction.
Review the updated progress accounting and finalization retry logic for correctness across all multisig threshold configurations. Ensure the new test cases exercise edge cases such as repeated identical PSBTs, invalid signatures from eligible keys, and mixed valid/invalid signature sets. Consider whether any pending transactions in production are stuck in the Pending state and may need reprocessing.
Security signals we found
Logic flaw in multisig signature collection could leave transactions permanently pending despite sufficient valid signatures
Previously ignored distinct PSBTs that did not immediately increase signature progress
Missing retry of transaction finalization after collecting additional signatures
Progress accounting used Math.Min(valid sigs, threshold) instead of actual valid sig count, potentially under-reporting collected signatures
Fix adds structured logging for finalization failures and duplicate PSBT detection
Evidence from the diff
The change is in PendingTransactionService.CollectSignature. The old code required a ‘meaningful delta’ in signature progress before it would store a new PSBT or attempt finalization; if a new PSBT was distinct but didn’t increase the per-input valid-signature count, it was silently dropped. The patch removes the early return on !HasMeaningfulDelta, always records the submitted PSBT, applies progress, and then calls TryFinalize. It also changes ApplyProgress so that the counted valid signatures per input is the actual number of valid partial sigs from known pubkeys, capped only when finalized. Logging is added to make finalization attempts and failures visible. Tests are updated/added to cover 2-of-4 multisig scenarios where an invalid-but-eligible signature is retained and a later valid signature allows finalization.
Changed components
BTCPayServer/HostedServices/PendingTransactionService.csBTCPayServer.Tests/MultisigTests.csInspect captured patch +153 / −18
diff --git a/BTCPayServer.Tests/MultisigTests.cs b/BTCPayServer.Tests/MultisigTests.cs
index 7d236c7..b2a0804 100644
--- a/BTCPayServer.Tests/MultisigTests.cs
+++ b/BTCPayServer.Tests/MultisigTests.cs
@@ -122,10 +122,10 @@ public class MultisigTests(ITestOutputHelper helper) : UnitTestBase(helper)
signerAPartial,
CancellationToken.None);
blob = pendingTransaction!.GetBlob();
- Assert.Single(blob.CollectedSignatures);
+ Assert.Equal(2, blob.CollectedSignatures.Count);
Assert.Equal(1, blob.SignaturesCollected);
Assert.Equal(PendingTransactionState.Pending, pendingTransaction.State);
- Assert.Equal(1, signatureCollectedEvents);
+ Assert.Equal(2, signatureCollectedEvents);
var signerBFirstInput = SignInputs(testPsbt.BasePsbt, testPsbt.SignerB, 0);
pendingTransaction = await pendingTransactionService.CollectSignature(
@@ -133,10 +133,10 @@ public class MultisigTests(ITestOutputHelper helper) : UnitTestBase(helper)
signerBFirstInput,
CancellationToken.None);
blob = pendingTransaction!.GetBlob();
- Assert.Equal(2, blob.CollectedSignatures.Count);
+ Assert.Equal(3, blob.CollectedSignatures.Count);
Assert.Equal(1, blob.SignaturesCollected);
Assert.Equal(PendingTransactionState.Pending, pendingTransaction.State);
- Assert.Equal(2, signatureCollectedEvents);
+ Assert.Equal(3, signatureCollectedEvents);
var signerBSecondInput = SignInputs(testPsbt.BasePsbt, testPsbt.SignerB, 1);
pendingTransaction = await pendingTransactionService.CollectSignature(
@@ -144,11 +144,11 @@ public class MultisigTests(ITestOutputHelper helper) : UnitTestBase(helper)
signerBSecondInput,
CancellationToken.None);
blob = pendingTransaction!.GetBlob();
- Assert.Equal(3, blob.CollectedSignatures.Count);
+ Assert.Equal(4, blob.CollectedSignatures.Count);
Assert.Equal(2, blob.SignaturesCollected);
Assert.Equal(0, Math.Max(0, (blob.SignaturesNeeded ?? 0) - (blob.SignaturesCollected ?? 0)));
Assert.Equal(PendingTransactionState.Signed, pendingTransaction.State);
- Assert.Equal(3, signatureCollectedEvents);
+ Assert.Equal(4, signatureCollectedEvents);
var secondPendingTransaction = await pendingTransactionService.CreatePendingTransaction(
storeId,
@@ -258,6 +258,78 @@ public class MultisigTests(ITestOutputHelper helper) : UnitTestBase(helper)
Assert.Equal(1, blob.SignaturesCollected);
}
+ [Fact]
+ [Trait("Integration", "Integration")]
+ public async Task PendingTwoOfFourMultisigRetainsDistinctPSBTsAndRetriesFinalization()
+ {
+ var dbTester = CreateDBTester();
+ await dbTester.MigrateAsync();
+ var contextFactory = dbTester.CreateContextFactory();
+ var storeId = await CreateTestStore(contextFactory);
+ var pendingTransactionService = new PendingTransactionService(
+ CreateNetworkProvider(),
+ contextFactory,
+ new EventAggregator(BTCPayLogs),
+ LoggerFactory.CreateLogger<PendingTransactionService>());
+ var testPsbt = CreatePendingMultisigPsbt(4);
+ var pendingTransaction = await pendingTransactionService.CreatePendingTransaction(
+ storeId,
+ "BTC",
+ testPsbt.BasePsbt,
+ RequestBaseUrl.FromUrl("https://example.com"),
+ cancellationToken: CancellationToken.None);
+ var pendingTransactionId =
+ new PendingTransactionService.PendingTransactionFullId("BTC", storeId, pendingTransaction.Id);
+
+ var signerAAllInputs = SignInputs(testPsbt.BasePsbt, testPsbt.SignerA, 0, 1);
+ pendingTransaction = await pendingTransactionService.CollectSignature(
+ pendingTransactionId,
+ signerAAllInputs,
+ CancellationToken.None);
+
+ // This PSBT is distinct, but adds no signature progress. Retain it and retry finalization anyway.
+ var signerAFirstInput = SignInputs(testPsbt.BasePsbt, testPsbt.SignerA, 0);
+ pendingTransaction = await pendingTransactionService.CollectSignature(
+ pendingTransactionId,
+ signerAFirstInput,
+ CancellationToken.None);
+ var blob = pendingTransaction!.GetBlob();
+ Assert.Equal(2, blob.CollectedSignatures.Count);
+ Assert.Equal(1, blob.SignaturesCollected);
+
+ // Repeating the exact same PSBT is still ignored.
+ pendingTransaction = await pendingTransactionService.CollectSignature(
+ pendingTransactionId,
+ signerAFirstInput,
+ CancellationToken.None);
+ blob = pendingTransaction!.GetBlob();
+ Assert.Equal(2, blob.CollectedSignatures.Count);
+
+ // Simulate the reported 2/4 Pending state: the second signature claims an eligible
+ // pubkey, but its signature cannot finalize the transaction.
+ var invalidFourthSigner = InvalidateAndReassignSignatures(
+ SignInputs(testPsbt.BasePsbt, testPsbt.SignerB, 0, 1),
+ testPsbt.Signers[3].PubKey);
+ pendingTransaction = await pendingTransactionService.CollectSignature(
+ pendingTransactionId,
+ invalidFourthSigner,
+ CancellationToken.None);
+ blob = pendingTransaction!.GetBlob();
+ Assert.Equal(2, blob.SignaturesCollected);
+ Assert.Equal(PendingTransactionState.Pending, pendingTransaction.State);
+
+ // A third eligible signer must be retained, reported as 3/4, and trigger
+ // another finalization attempt. NBitcoin can now select two valid signatures.
+ pendingTransaction = await pendingTransactionService.CollectSignature(
+ pendingTransactionId,
+ SignInputs(testPsbt.BasePsbt, testPsbt.SignerC, 0, 1),
+ CancellationToken.None);
+ blob = pendingTransaction!.GetBlob();
+ Assert.Equal(4, blob.CollectedSignatures.Count);
+ Assert.Equal(3, blob.SignaturesCollected);
+ Assert.Equal(PendingTransactionState.Signed, pendingTransaction.State);
+ }
+
[Fact]
[Trait("Playwright", "Playwright-2")]
public async Task CanEnableAndUseMultisigWallet()
@@ -750,13 +822,16 @@ public class MultisigTests(ITestOutputHelper helper) : UnitTestBase(helper)
return psbt.ToBase64();
}
- private static TestPendingMultisigPsbt CreatePendingMultisigPsbt()
+ private static TestPendingMultisigPsbt CreatePendingMultisigPsbt(int signerCount = 3)
{
+ if (signerCount < 2)
+ throw new ArgumentOutOfRangeException(nameof(signerCount));
+
var network = Network.RegTest;
- var signerA = new Key();
- var signerB = new Key();
- var signerC = new Key();
- var witnessScript = PayToMultiSigTemplate.Instance.GenerateScriptPubKey(2, signerA.PubKey, signerB.PubKey, signerC.PubKey);
+ var signers = Enumerable.Range(0, signerCount).Select(_ => new Key()).ToArray();
+ var witnessScript = PayToMultiSigTemplate.Instance.GenerateScriptPubKey(
+ 2,
+ signers.Select(signer => signer.PubKey).ToArray());
var scriptPubKey = witnessScript.WitHash.ScriptPubKey;
var previousTransactionA = network.CreateTransaction();
@@ -772,7 +847,7 @@ public class MultisigTests(ITestOutputHelper helper) : UnitTestBase(helper)
builder.SetChange(new Key().PubKey.WitHash.ScriptPubKey);
builder.SendFees(Money.Satoshis(10_000));
- return new TestPendingMultisigPsbt(builder.BuildPSBT(false), signerA, signerB, signerC);
+ return new TestPendingMultisigPsbt(builder.BuildPSBT(false), signers);
}
private static PSBT SignInputs(PSBT basePsbt, Key signer, params int[] inputIndexes)
@@ -786,6 +861,19 @@ public class MultisigTests(ITestOutputHelper helper) : UnitTestBase(helper)
return psbt;
}
+ private static PSBT InvalidateAndReassignSignatures(PSBT psbt, PubKey claimedSigner)
+ {
+ foreach (var input in psbt.Inputs)
+ {
+ var signatureBytes = input.PartialSigs.Single().Value.ToBytes();
+ signatureBytes[10] ^= 1;
+ input.PartialSigs.Clear();
+ input.PartialSigs.Add(claimedSigner, new TransactionSignature(signatureBytes));
+ }
+
+ return psbt;
+ }
+
private static async Task<string> CreateTestStore(ApplicationDbContextFactory contextFactory)
{
await using var ctx = contextFactory.CreateContext();
@@ -808,5 +896,10 @@ public class MultisigTests(ITestOutputHelper helper) : UnitTestBase(helper)
return new StoreRepository(contextFactory, new JsonSerializerSettings(), eventAggregator, settingsRepository);
}
- private sealed record TestPendingMultisigPsbt(PSBT BasePsbt, Key SignerA, Key SignerB, Key SignerC);
+ private sealed record TestPendingMultisigPsbt(PSBT BasePsbt, Key[] Signers)
+ {
+ public Key SignerA => Signers[0];
+ public Key SignerB => Signers[1];
+ public Key SignerC => Signers[2];
+ }
}
diff --git a/BTCPayServer/HostedServices/PendingTransactionService.cs b/BTCPayServer/HostedServices/PendingTransactionService.cs
index 3097ed6..cb508b8 100644
--- a/BTCPayServer/HostedServices/PendingTransactionService.cs
+++ b/BTCPayServer/HostedServices/PendingTransactionService.cs
@@ -190,14 +190,17 @@ public class PendingTransactionService(
var network = networkProvider.GetNetwork<BTCPayNetwork>(pendingTransaction.CryptoCode)?.NBitcoinNetwork ?? psbt.Network;
if (blob.CollectedSignatures.Any(s => s.ReceivedPSBT == newPsbtBase64))
+ {
+ logger.LogInformation(
+ "Skipping finalization retry for pending transaction {PendingTransactionId}: this PSBT was already collected",
+ pendingTransaction.Id);
return (pendingTransaction, false);
+ }
var beforeProgress = GetSignatureProgress(BuildEffectivePsbt(blob, network));
var mergedPsbt = BuildEffectivePsbt(blob, network, psbt);
var afterProgress = GetSignatureProgress(mergedPsbt);
-
- if (!HasMeaningfulDelta(beforeProgress, afterProgress))
- return (pendingTransaction, false);
+ var meaningfulDelta = HasMeaningfulDelta(beforeProgress, afterProgress);
blob.CollectedSignatures.Add(new CollectedSignature
{
@@ -206,11 +209,50 @@ public class PendingTransactionService(
});
ApplyProgress(blob, afterProgress);
- if (mergedPsbt.TryFinalize(out _))
+ logger.LogInformation(
+ "Retrying finalization for pending transaction {PendingTransactionId} after collecting PSBT {CollectedPSBTCount}. " +
+ "Signature progress: {SignaturesCollected}/{SignaturesNeeded}; progress changed: {SignatureProgressChanged}",
+ pendingTransaction.Id,
+ blob.CollectedSignatures.Count,
+ blob.SignaturesCollected,
+ blob.SignaturesNeeded,
+ meaningfulDelta);
+
+ if (mergedPsbt.TryFinalize(out var finalizationErrors))
{
if ((blob.SignaturesCollected ?? 0) < (blob.SignaturesNeeded ?? 0))
blob.SignaturesCollected = blob.SignaturesNeeded;
pendingTransaction.State = PendingTransactionState.Signed;
+ logger.LogInformation(
+ "Finalized pending transaction {PendingTransactionId} after collecting PSBT {CollectedPSBTCount}",
+ pendingTransaction.Id,
+ blob.CollectedSignatures.Count);
+ }
+ else
+ {
+ var failedInputIndexes = finalizationErrors is null or { Count: 0 }
+ ? "unknown"
+ : string.Join(",", finalizationErrors.Select(error => error.InputIndex).Distinct());
+ if ((blob.SignaturesCollected ?? 0) >= (blob.SignaturesNeeded ?? int.MaxValue))
+ {
+ logger.LogWarning(
+ "Finalization attempt failed for pending transaction {PendingTransactionId} despite signature progress " +
+ "{SignaturesCollected}/{SignaturesNeeded}. Failed input indexes: {FailedInputIndexes}",
+ pendingTransaction.Id,
+ blob.SignaturesCollected,
+ blob.SignaturesNeeded,
+ failedInputIndexes);
+ }
+ else
+ {
+ logger.LogDebug(
+ "Finalization attempt failed for pending transaction {PendingTransactionId}. Signature progress: " +
+ "{SignaturesCollected}/{SignaturesNeeded}; failed input indexes: {FailedInputIndexes}",
+ pendingTransaction.Id,
+ blob.SignaturesCollected,
+ blob.SignaturesNeeded,
+ failedInputIndexes);
+ }
}
pendingTransaction.SetBlob(blob);
return (pendingTransaction, true);
@@ -355,7 +397,7 @@ public class PendingTransactionService(
var validExpectedPartialSigCount = input.PartialSigs.Keys.Count(multisigParams.PubKeys.Contains);
var collected = finalized
? multisigParams.SignatureCount
- : Math.Min(validExpectedPartialSigCount, multisigParams.SignatureCount);
+ : validExpectedPartialSigCount;
inputs.Add(new PendingTransactionInputProgress(
true,
multisigParams.SignatureCount,
Why this scored 42/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.