Fix: Recreating an aborted TX in MultiSig on Server setup crashes (#6682)

This commit is contained in:
Nicolas Dorier
2025-04-21 17:10:28 +09:00
committed by GitHub
parent 2f26979ed7
commit 3d363baa9e
8 changed files with 118 additions and 37 deletions

View File

@@ -8,6 +8,7 @@ namespace BTCPayServer.Data;
public class PendingTransaction: IHasBlob<PendingTransactionBlob>
{
public string Id { get; set; }
public string TransactionId { get; set; }
public string CryptoCode { get; set; }
public string StoreId { get; set; }
@@ -30,7 +31,9 @@ public class PendingTransaction: IHasBlob<PendingTransactionBlob>
.HasForeignKey(i => i.StoreId)
.OnDelete(DeleteBehavior.Cascade);
builder.Entity<PendingTransaction>().HasKey(transaction => new {transaction.CryptoCode, transaction.TransactionId});
builder.Entity<PendingTransaction>().HasKey(t => t.Id);
builder.Entity<PendingTransaction>().HasIndex(t => new { t.StoreId });
builder.Entity<PendingTransaction>().HasIndex(t => new { t.TransactionId });
builder.Entity<PendingTransaction>()
.Property(o => o.Blob2)

View File

@@ -0,0 +1,69 @@
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20250418074941_changependingtxsid")]
public partial class changependingtxsid : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropPrimaryKey(
name: "PK_PendingTransactions",
table: "PendingTransactions");
migrationBuilder.AlterColumn<string>(
name: "TransactionId",
table: "PendingTransactions",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<string>(
name: "CryptoCode",
table: "PendingTransactions",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AddColumn<string>(
name: "Id",
table: "PendingTransactions",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.Sql(
"""
UPDATE "PendingTransactions" SET "Id" =
lpad(to_hex(trunc(random() * 1e10)::bigint), 8, '0') || '-' ||
lpad(to_hex(trunc(random() * 1e10)::bigint), 4, '0') || '-' ||
lpad(to_hex(trunc(random() * 1e10)::bigint), 4, '0') || '-' ||
lpad(to_hex(trunc(random() * 1e10)::bigint), 4, '0') || '-' ||
lpad(to_hex(trunc(random() * 1e10)::bigint), 12, '0');
""");
migrationBuilder.AddPrimaryKey(
name: "PK_PendingTransactions",
table: "PendingTransactions",
column: "Id");
migrationBuilder.CreateIndex(
name: "IX_PendingTransactions_TransactionId",
table: "PendingTransactions",
column: "TransactionId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

View File

@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using System.Collections.Generic;
using BTCPayServer.Data;
@@ -652,15 +652,15 @@ namespace BTCPayServer.Migrations
modelBuilder.Entity("BTCPayServer.Data.PendingTransaction", b =>
{
b.Property<string>("CryptoCode")
.HasColumnType("text");
b.Property<string>("TransactionId")
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("Blob2")
.HasColumnType("JSONB");
b.Property<string>("CryptoCode")
.HasColumnType("text");
b.Property<DateTimeOffset?>("Expiry")
.HasColumnType("timestamp with time zone");
@@ -673,10 +673,15 @@ namespace BTCPayServer.Migrations
b.Property<string>("StoreId")
.HasColumnType("text");
b.HasKey("CryptoCode", "TransactionId");
b.Property<string>("TransactionId")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("StoreId");
b.HasIndex("TransactionId");
b.ToTable("PendingTransactions");
});

View File

@@ -558,8 +558,7 @@ namespace BTCPayServer.Controllers
if (vm.SigningContext.PendingTransactionId is not null)
{
await _pendingTransactionService.Broadcasted(walletId.CryptoCode, walletId.StoreId,
vm.SigningContext.PendingTransactionId);
await _pendingTransactionService.Broadcasted(GetPendingTxId(walletId, vm.SigningContext.PendingTransactionId));
}
if (!string.IsNullOrEmpty(vm.ReturnUrl))

View File

@@ -145,39 +145,38 @@ namespace BTCPayServer.Controllers
_displayFormatter = displayFormatter;
}
[HttpGet("{walletId}/pending/{transactionId}/cancel")]
[HttpGet("{walletId}/pending/{pendingTransactionId}/cancel")]
public IActionResult CancelPendingTransaction(
[ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
string transactionId)
string pendingTransactionId)
{
return View("Confirm", new ConfirmModel("Abort Pending Transaction",
"Proceeding with this action will invalidate Pending Transaction and all accepted signatures.",
"Confirm Abort"));
}
[HttpPost("{walletId}/pending/{transactionId}/cancel")]
[HttpPost("{walletId}/pending/{pendingTransactionId}/cancel")]
public async Task<IActionResult> CancelPendingTransactionConfirmed(
[ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
string transactionId)
string pendingTransactionId)
{
await _pendingTransactionService.CancelPendingTransaction(walletId.CryptoCode, walletId.StoreId, transactionId);
await _pendingTransactionService.CancelPendingTransaction(GetPendingTxId(walletId, pendingTransactionId));
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Success,
Message = $"Aborted Pending Transaction {transactionId}"
Message = $"Aborted Pending Transaction {pendingTransactionId}"
});
return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() });
}
[HttpGet("{walletId}/pending/{transactionId}")]
[HttpGet("{walletId}/pending/{pendingTransactionId}")]
public async Task<IActionResult> ViewPendingTransaction(
[ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
string transactionId)
string pendingTransactionId)
{
var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
var pendingTransaction =
await _pendingTransactionService.GetPendingTransaction(walletId.CryptoCode, walletId.StoreId,
transactionId);
await _pendingTransactionService.GetPendingTransaction(GetPendingTxId(walletId, pendingTransactionId));
if (pendingTransaction is null)
return NotFound();
var blob = pendingTransaction.GetBlob();
@@ -197,7 +196,7 @@ namespace BTCPayServer.Controllers
CryptoCode = network.CryptoCode,
SigningContext = new SigningContextModel(currentPsbt)
{
PendingTransactionId = transactionId,
PendingTransactionId = pendingTransactionId,
PSBT = currentPsbt.ToBase64(),
},
};
@@ -206,6 +205,10 @@ namespace BTCPayServer.Controllers
return View("WalletPSBTDecoded", vm);
}
private PendingTransactionService.PendingTransactionFullId GetPendingTxId(WalletId walletId, string pendingTransactionId)
=> new (walletId.CryptoCode, walletId.StoreId, pendingTransactionId);
[Route("{walletId}/transactions/bump")]
[Route("{walletId}/transactions/{transactionId}/bump")]
public async Task<IActionResult> WalletBumpFee([ModelBinder(typeof(WalletIdModelBinder))]
@@ -1354,7 +1357,7 @@ namespace BTCPayServer.Controllers
if (vm.SigningContext.PendingTransactionId is not null)
{
var psbt = PSBT.Parse(vm.SigningContext.PSBT, NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode).NBitcoinNetwork);
var pendingTransaction = await _pendingTransactionService.CollectSignature(psbt, CancellationToken.None);
var pendingTransaction = await _pendingTransactionService.CollectSignature(GetPendingTxId(walletId, vm.SigningContext.PendingTransactionId), psbt, CancellationToken.None);
if (pendingTransaction != null)
return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() });

View File

@@ -13,6 +13,7 @@ using BTCPayServer.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NBitcoin;
using NBitcoin.DataEncoders;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using WebhookDeliveryData = BTCPayServer.Data.WebhookDeliveryData;
@@ -58,11 +59,12 @@ public class PendingTransactionService(
else if (evt is NewOnChainTransactionEvent newTransactionEvent)
{
await using var ctx = dbContextFactory.CreateContext();
var cryptoCode = newTransactionEvent.NewTransactionEvent.CryptoCode;
var txInputs = newTransactionEvent.NewTransactionEvent.TransactionData.Transaction.Inputs
.Select(i => i.PrevOut.ToString()).ToArray();
var txHash = newTransactionEvent.NewTransactionEvent.TransactionData.TransactionHash.ToString();
var pendingTransactions = await ctx.PendingTransactions
.Where(p => p.TransactionId == txHash || p.OutpointsUsed.Any(o => txInputs.Contains(o)))
.Where(p => p.CryptoCode == cryptoCode && (p.TransactionId == txHash || p.OutpointsUsed.Any(o => txInputs.Contains(o))))
.ToArrayAsync(cancellationToken: cancellationToken);
if (!pendingTransactions.Any())
{
@@ -119,6 +121,7 @@ public class PendingTransactionService(
await using var ctx = dbContextFactory.CreateContext();
var pendingTransaction = new PendingTransaction
{
Id = Guid.NewGuid().ToString(),
CryptoCode = cryptoCode,
TransactionId = txId.ToString(),
State = PendingTransactionState.Pending,
@@ -147,13 +150,12 @@ public class PendingTransactionService(
return pendingTransaction;
}
public async Task<PendingTransaction?> CollectSignature(PSBT psbt, CancellationToken cancellationToken)
public async Task<PendingTransaction?> CollectSignature(PendingTransactionFullId id, PSBT psbt, CancellationToken cancellationToken)
{
var cryptoCode = psbt.Network.NetworkSet.CryptoCode;
var txId = psbt.GetGlobalTransaction().GetHash();
await using var ctx = dbContextFactory.CreateContext();
var pendingTransaction =
await ctx.PendingTransactions.FindAsync(new object[] { cryptoCode, txId.ToString() }, cancellationToken);
var pendingTransaction = await ctx.PendingTransactions.FirstOrDefaultAsync(p =>
p.CryptoCode == id.CryptoCode && p.StoreId == id.StoreId && p.Id == id.Id, cancellationToken);
if (pendingTransaction?.State is not PendingTransactionState.Pending)
{
return null;
@@ -227,12 +229,12 @@ public class PendingTransactionService(
}
public async Task<PendingTransaction?> GetPendingTransaction(string cryptoCode, string storeId, string txId)
public record PendingTransactionFullId(string CryptoCode, string StoreId, string Id);
public async Task<PendingTransaction?> GetPendingTransaction(PendingTransactionFullId id)
{
await using var ctx = dbContextFactory.CreateContext();
return await ctx.PendingTransactions.FirstOrDefaultAsync(p =>
p.CryptoCode == cryptoCode && p.StoreId == storeId && p.TransactionId == txId);
p.CryptoCode == id.CryptoCode && p.StoreId == id.StoreId && p.Id == id.Id);
}
public async Task<PendingTransaction[]> GetPendingTransactions(string cryptoCode, string storeId)
@@ -244,11 +246,11 @@ public class PendingTransactionService(
.ToArrayAsync();
}
public async Task CancelPendingTransaction(string cryptoCode, string storeId, string transactionId)
public async Task CancelPendingTransaction(PendingTransactionFullId id)
{
await using var ctx = dbContextFactory.CreateContext();
var pt = await ctx.PendingTransactions.FirstOrDefaultAsync(p =>
p.CryptoCode == cryptoCode && p.StoreId == storeId && p.TransactionId == transactionId &&
p.CryptoCode == id.CryptoCode && p.StoreId == id.StoreId && p.Id == id.Id &&
(p.State == PendingTransactionState.Pending || p.State == PendingTransactionState.Signed));
if (pt is null) return;
pt.State = PendingTransactionState.Cancelled;
@@ -260,11 +262,11 @@ public class PendingTransactionService(
});
}
public async Task Broadcasted(string cryptoCode, string storeId, string transactionId)
public async Task Broadcasted(PendingTransactionFullId id)
{
await using var ctx = dbContextFactory.CreateContext();
var pt = await ctx.PendingTransactions.FirstOrDefaultAsync(p =>
p.CryptoCode == cryptoCode && p.StoreId == storeId && p.TransactionId == transactionId &&
p.CryptoCode == id.CryptoCode && p.StoreId == id.StoreId && p.Id == id.Id &&
(p.State == PendingTransactionState.Pending || p.State == PendingTransactionState.Signed));
if (pt is null) return;
pt.State = PendingTransactionState.Broadcast;

View File

@@ -107,7 +107,7 @@
else if (Model.SigningContext.PendingTransactionId is not null)
{
<a asp-action="CancelPendingTransaction" asp-route-walletId="@walletId"
asp-route-transactionId="@Model.SigningContext.PendingTransactionId" class="btn btn-danger">Cancel</a>
asp-route-pendingTransactionId="@Model.SigningContext.PendingTransactionId" class="btn btn-danger">Cancel</a>
}
</div>
</form>

View File

@@ -191,10 +191,10 @@
<td><span id="Sigs_@(index)__Scheme">@ptblob?.SignaturesNeeded/@ptblob?.SignaturesTotal</span></td>
<td>
<a asp-action="ViewPendingTransaction" asp-route-walletId="@walletId"
asp-route-transactionId="@pendingTransaction.TransactionId">@(pendingTransaction.State == PendingTransactionState.Signed ? "Broadcast" : "View")</a>
asp-route-pendingTransactionId="@pendingTransaction.Id">@(pendingTransaction.State == PendingTransactionState.Signed ? "Broadcast" : "View")</a>
-
<a asp-action="CancelPendingTransaction" asp-route-walletId="@walletId"
asp-route-transactionId="@pendingTransaction.TransactionId">Abort</a>
asp-route-pendingTransactionId="@pendingTransaction.Id">Abort</a>
</td>
</tr>
}