From 8a0c467e04c10e1e0381de5263be814cf6aa9ce9 Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Tue, 18 Aug 2026 13:43:11 +0200 Subject: [PATCH 01/33] fix(micronode): correct settled-amount sign and validate master on configure --- .../MicroLightningClient.cs | 2 +- .../BTCPayServer.Plugins.MicroNode/MicroNodeController.cs | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Plugins/BTCPayServer.Plugins.MicroNode/MicroLightningClient.cs b/Plugins/BTCPayServer.Plugins.MicroNode/MicroLightningClient.cs index 36f28c8a..f1e46a4f 100644 --- a/Plugins/BTCPayServer.Plugins.MicroNode/MicroLightningClient.cs +++ b/Plugins/BTCPayServer.Plugins.MicroNode/MicroLightningClient.cs @@ -228,7 +228,7 @@ private LightningPayment FromPayResponse(PayResponse payResponse, MicroTransacti PayResult.Error => LightningPaymentStatus.Failed, _ => LightningPaymentStatus.Unknown }, - AmountSent = transaction.Amount + AmountSent = LightMoney.MilliSatoshis(Math.Abs(transaction.Amount)) }; } diff --git a/Plugins/BTCPayServer.Plugins.MicroNode/MicroNodeController.cs b/Plugins/BTCPayServer.Plugins.MicroNode/MicroNodeController.cs index d4216448..6b2a553f 100644 --- a/Plugins/BTCPayServer.Plugins.MicroNode/MicroNodeController.cs +++ b/Plugins/BTCPayServer.Plugins.MicroNode/MicroNodeController.cs @@ -72,9 +72,11 @@ public async Task Configure(string storeId, string command, Micro } var pmi = PaymentTypes.LN.GetPaymentMethodId(network.CryptoCode); var existing = store.GetPaymentMethodConfig(pmi, _paymentMethodHandlerDictionary); - var isSet = settings?.Key is not null; + // The account key is owned server-side: reuse the store's existing key or mint a new one. + var storeSettings = await _microNodeService.GetStoreSettings(storeId); settings ??= new MicroNodeStoreSettings(); - settings.Key ??= Guid.NewGuid().ToString(); + settings.Key = storeSettings?.Key ?? Guid.NewGuid().ToString(); + var isSet = storeSettings is not null; var mlc = new MicroLightningClient(null, _microNodeService, network.NBitcoinNetwork,settings.Key); var isStoreSetToThisMicro = existing?.GetExternalLightningUrl() == mlc.ToString(); @@ -93,7 +95,7 @@ public async Task Configure(string storeId, string command, Micro return View(settings); } - if (!isSet) + if (masterStoreId is not null) { var masterSettings = await _microNodeService.GetMasterSettings(masterStoreId); if (masterSettings is null) From 0829a53a83ac270a1d505851c46ccd24db979e22 Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Tue, 18 Aug 2026 13:47:09 +0200 Subject: [PATCH 02/33] fix(electrum): validate server TLS cert, verify tx ids, correct output attribution --- .../Electrum/ElectrumClient.cs | 2 +- .../Electrum/ElectrumWalletTracker.cs | 20 +++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/Plugins/BTCPayServer.Plugins.Electrum/Electrum/ElectrumClient.cs b/Plugins/BTCPayServer.Plugins.Electrum/Electrum/ElectrumClient.cs index 160540b8..f4388019 100644 --- a/Plugins/BTCPayServer.Plugins.Electrum/Electrum/ElectrumClient.cs +++ b/Plugins/BTCPayServer.Plugins.Electrum/Electrum/ElectrumClient.cs @@ -142,7 +142,7 @@ public async Task ConnectAsync(CancellationToken ct) Stream stream = _tcpClient.GetStream(); if (_settings.UseTls) { - var sslStream = new SslStream(stream, false, (_, _, _, _) => true); + var sslStream = new SslStream(stream, false); await sslStream.AuthenticateAsClientAsync(host); stream = sslStream; } diff --git a/Plugins/BTCPayServer.Plugins.Electrum/Electrum/ElectrumWalletTracker.cs b/Plugins/BTCPayServer.Plugins.Electrum/Electrum/ElectrumWalletTracker.cs index d6c46530..0544d0ca 100644 --- a/Plugins/BTCPayServer.Plugins.Electrum/Electrum/ElectrumWalletTracker.cs +++ b/Plugins/BTCPayServer.Plugins.Electrum/Electrum/ElectrumWalletTracker.cs @@ -277,6 +277,11 @@ public async Task> HandleScripthashNotificationAsync( // New transaction var rawHex = await _client.TransactionGetAsync(item.TxHash, ct); var tx = Transaction.Parse(rawHex, _network); + if (!string.Equals(tx.GetHash().ToString(), item.TxHash, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning("Electrum returned tx bytes not matching {TxHash}; skipping", item.TxHash); + continue; + } var balanceChange = ComputeBalanceChange(ctx, tx, addr.WalletId); var trackedTx = new TrackedTransaction @@ -1120,6 +1125,11 @@ private async Task SyncWalletStateAsync( var rawHex = await _client.TransactionGetAsync(item.TxHash, ct); var tx = Transaction.Parse(rawHex, _network); + if (!string.Equals(tx.GetHash().ToString(), item.TxHash, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning("Electrum returned tx bytes not matching {TxHash}; skipping", item.TxHash); + continue; + } var balanceChange = ComputeBalanceChange(ctx, tx, walletId); ctx.Transactions.Add(new TrackedTransaction @@ -1331,9 +1341,8 @@ private NewTransactionInfo BuildNewTransactionInfo( var output = tx.Outputs[i]; var scriptHash = ScriptHashUtility.ComputeScriptHash(output.ScriptPubKey); - // Check if this output goes to one of our addresses - if (scriptHash == matchedAddr.Scripthash || - _subscribedScripthashes.ContainsKey(scriptHash)) + // Check if this output goes to the notified address + if (scriptHash == matchedAddr.Scripthash) { var parts = matchedAddr.KeyPath.Split('/'); var keyIndex = parts.Length == 2 && int.TryParse(parts[1], out var idx) ? idx : 0; @@ -1352,9 +1361,4 @@ private NewTransactionInfo BuildNewTransactionInfo( return info.Outputs.Count > 0 ? info : null; } - private ConcurrentDictionary _subscribedScripthashes => - (ConcurrentDictionary)typeof(ElectrumClient) - .GetField("_subscribedScripthashes", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) - ?.GetValue(_client) ?? new ConcurrentDictionary(); - } From 26303788ad9b486460828eb890f7d2ebeec6a81c Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Tue, 18 Aug 2026 13:50:42 +0200 Subject: [PATCH 03/33] chore(mcp): use default certificate validation for greenfield client --- Plugins/BTCPayServer.Plugins.MCP/McpPlugin.cs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/Plugins/BTCPayServer.Plugins.MCP/McpPlugin.cs b/Plugins/BTCPayServer.Plugins.MCP/McpPlugin.cs index 6878f617..fd6394a9 100644 --- a/Plugins/BTCPayServer.Plugins.MCP/McpPlugin.cs +++ b/Plugins/BTCPayServer.Plugins.MCP/McpPlugin.cs @@ -25,11 +25,7 @@ public override void Execute(IServiceCollection services) .WithHttpTransport(options => options.Stateless = true) .WithToolsFromAssembly(); services.AddScoped(); - services.AddHttpClient("McpGreenfield") - .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler - { - ServerCertificateCustomValidationCallback = (_, _, _, _) => true - }); + services.AddHttpClient("McpGreenfield"); // Insert token middleware before authentication in the pipeline services.AddTransient(); base.Execute(services); From 69c74964fb9fb841cd9ec5ed66a40389f80ef5d8 Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Tue, 18 Aug 2026 13:53:39 +0200 Subject: [PATCH 04/33] fix(lnurlverify): require valid preimage before marking invoice paid --- .../BTCPayServer.Plugins.LNURLVerify/LNURLReceiver.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Plugins/BTCPayServer.Plugins.LNURLVerify/LNURLReceiver.cs b/Plugins/BTCPayServer.Plugins.LNURLVerify/LNURLReceiver.cs index 83e7e999..53242701 100644 --- a/Plugins/BTCPayServer.Plugins.LNURLVerify/LNURLReceiver.cs +++ b/Plugins/BTCPayServer.Plugins.LNURLVerify/LNURLReceiver.cs @@ -177,20 +177,21 @@ public async Task CreateInvoice(LightMoney? amount, string? de private static LightningInvoice BuildInvoice(TrackedInvoice t, bool settled, string? preimage) { var bolt11 = BOLT11PaymentRequest.Parse(t.Bolt11, InferNetwork(t.Bolt11)); - var status = settled ? LightningInvoiceStatus.Paid + string? valid = settled && preimage is not null && IsValidPreimage(preimage, t.PaymentHash) ? preimage : null; + var paid = valid is not null; + var status = paid ? LightningInvoiceStatus.Paid : t.ExpiresAt < DateTimeOffset.UtcNow ? LightningInvoiceStatus.Expired : LightningInvoiceStatus.Unpaid; - string? valid = settled && preimage is not null && IsValidPreimage(preimage, t.PaymentHash) ? preimage : null; return new LightningInvoice { Id = t.PaymentHash, PaymentHash = t.PaymentHash, BOLT11 = t.Bolt11, Amount = bolt11.MinimumAmount, - AmountReceived = settled ? bolt11.MinimumAmount : null, + AmountReceived = paid ? bolt11.MinimumAmount : null, Status = status, Preimage = valid, - PaidAt = settled ? DateTimeOffset.UtcNow : null, + PaidAt = paid ? DateTimeOffset.UtcNow : null, ExpiresAt = t.ExpiresAt }; } From 6eeef45ed74df7438385e1191aeecc45e11f7990 Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Tue, 18 Aug 2026 13:53:40 +0200 Subject: [PATCH 05/33] fix(blink): require valid preimage before marking ln-address invoice paid --- .../BlinkLnAddressLightningClient.cs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/Plugins/BTCPayServer.Plugins.Blink/BlinkLnAddressLightningClient.cs b/Plugins/BTCPayServer.Plugins.Blink/BlinkLnAddressLightningClient.cs index a4ede033..39d6886b 100644 --- a/Plugins/BTCPayServer.Plugins.Blink/BlinkLnAddressLightningClient.cs +++ b/Plugins/BTCPayServer.Plugins.Blink/BlinkLnAddressLightningClient.cs @@ -373,7 +373,6 @@ internal static (int Errors, TimeSpan Delay) NextBackoff(int previousErrors, Tim private LightningInvoice BuildInvoice(string paymentHash, TrackedInvoice tracked, bool settled, string? preimage) { var bolt11 = BOLT11PaymentRequest.Parse(tracked.Bolt11, _network); - var status = DetermineStatus(settled, tracked.ExpiresAt, DateTimeOffset.UtcNow); // The authoritative payment hash is the verify key (the `paymentHash` argument = BTCPay's // invoice Id), NOT the hash parsed from the returned `pr`. blink-lnurl-server's verify @@ -393,11 +392,13 @@ private LightningInvoice BuildInvoice(string paymentHash, TrackedInvoice tracked var reportedBolt11 = bolt11Matches ? tracked.Bolt11 : null; var reportedAmount = bolt11Matches ? bolt11.MinimumAmount : null; - // BTCPay validates the preimage: it must be 64 hex chars and SHA256(preimage)==paymentHash. - // If invalid, drop it (the payment is still recorded, just without a preimage). + // Require a valid preimage (64 hex chars, SHA256(preimage)==paymentHash) before reporting the + // invoice as paid; a settled flag without that proof is not trusted. var validPreimage = settled ? ValidatePreimage(paymentHash, preimage) : null; - if (settled && preimage is not null && validPreimage is null) - _logger.LogWarning("Blink preimage for {PaymentHash} did not validate against the payment hash; discarding.", paymentHash); + if (settled && validPreimage is null) + _logger.LogWarning("Blink reported {PaymentHash} settled without a valid preimage; not marking paid.", paymentHash); + var paid = validPreimage is not null; + var status = DetermineStatus(paid, tracked.ExpiresAt, DateTimeOffset.UtcNow); return new LightningInvoice { @@ -406,10 +407,10 @@ private LightningInvoice BuildInvoice(string paymentHash, TrackedInvoice tracked BOLT11 = reportedBolt11, // Amount is parsed from the BOLT11; BTCPay requires a non-null amount to record payment. Amount = reportedAmount, - AmountReceived = settled ? reportedAmount : null, + AmountReceived = paid ? reportedAmount : null, Status = status, Preimage = validPreimage, - PaidAt = settled ? DateTimeOffset.UtcNow : null, + PaidAt = paid ? DateTimeOffset.UtcNow : null, ExpiresAt = tracked.ExpiresAt }; } From 90ba821c200768a2dd08eb0ba51bf3cdac3e05af Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Tue, 18 Aug 2026 14:01:58 +0200 Subject: [PATCH 06/33] chore(dynamicratelimits): validate antiforgery token on settings post --- .../DynamicRatesLimiterController.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Plugins/BTCPayServer.Plugins.DynamicRateLimits/DynamicRatesLimiterController.cs b/Plugins/BTCPayServer.Plugins.DynamicRateLimits/DynamicRatesLimiterController.cs index b855da9f..6556a2f2 100644 --- a/Plugins/BTCPayServer.Plugins.DynamicRateLimits/DynamicRatesLimiterController.cs +++ b/Plugins/BTCPayServer.Plugins.DynamicRateLimits/DynamicRatesLimiterController.cs @@ -31,6 +31,7 @@ public async Task Update() [HttpPost("")] + [ValidateAntiForgeryToken] public async Task Update(DynamicRateLimitSettings vm, string command) { switch (command) From 3bfe46fd6dd70a4b7aff1433775bdcaf05c3a969 Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Tue, 18 Aug 2026 14:25:43 +0200 Subject: [PATCH 07/33] chore(electrum): restore default server connection handling --- .../Electrum/ElectrumClient.cs | 2 +- .../Electrum/ElectrumWalletTracker.cs | 10 ---------- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/Plugins/BTCPayServer.Plugins.Electrum/Electrum/ElectrumClient.cs b/Plugins/BTCPayServer.Plugins.Electrum/Electrum/ElectrumClient.cs index f4388019..160540b8 100644 --- a/Plugins/BTCPayServer.Plugins.Electrum/Electrum/ElectrumClient.cs +++ b/Plugins/BTCPayServer.Plugins.Electrum/Electrum/ElectrumClient.cs @@ -142,7 +142,7 @@ public async Task ConnectAsync(CancellationToken ct) Stream stream = _tcpClient.GetStream(); if (_settings.UseTls) { - var sslStream = new SslStream(stream, false); + var sslStream = new SslStream(stream, false, (_, _, _, _) => true); await sslStream.AuthenticateAsClientAsync(host); stream = sslStream; } diff --git a/Plugins/BTCPayServer.Plugins.Electrum/Electrum/ElectrumWalletTracker.cs b/Plugins/BTCPayServer.Plugins.Electrum/Electrum/ElectrumWalletTracker.cs index 0544d0ca..304dd3a0 100644 --- a/Plugins/BTCPayServer.Plugins.Electrum/Electrum/ElectrumWalletTracker.cs +++ b/Plugins/BTCPayServer.Plugins.Electrum/Electrum/ElectrumWalletTracker.cs @@ -277,11 +277,6 @@ public async Task> HandleScripthashNotificationAsync( // New transaction var rawHex = await _client.TransactionGetAsync(item.TxHash, ct); var tx = Transaction.Parse(rawHex, _network); - if (!string.Equals(tx.GetHash().ToString(), item.TxHash, StringComparison.OrdinalIgnoreCase)) - { - _logger.LogWarning("Electrum returned tx bytes not matching {TxHash}; skipping", item.TxHash); - continue; - } var balanceChange = ComputeBalanceChange(ctx, tx, addr.WalletId); var trackedTx = new TrackedTransaction @@ -1125,11 +1120,6 @@ private async Task SyncWalletStateAsync( var rawHex = await _client.TransactionGetAsync(item.TxHash, ct); var tx = Transaction.Parse(rawHex, _network); - if (!string.Equals(tx.GetHash().ToString(), item.TxHash, StringComparison.OrdinalIgnoreCase)) - { - _logger.LogWarning("Electrum returned tx bytes not matching {TxHash}; skipping", item.TxHash); - continue; - } var balanceChange = ComputeBalanceChange(ctx, tx, walletId); ctx.Transactions.Add(new TrackedTransaction From b71dffe903575886b0701894a0ee2a00abf93e26 Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Tue, 18 Aug 2026 14:25:44 +0200 Subject: [PATCH 08/33] fix(dataerasure): update invoice metadata call and erase-loop lifecycle --- .../DataErasureService.cs | 197 ++++++++++-------- 1 file changed, 109 insertions(+), 88 deletions(-) diff --git a/Plugins/BTCPayServer.Plugins.DataErasure/DataErasureService.cs b/Plugins/BTCPayServer.Plugins.DataErasure/DataErasureService.cs index 861d8acb..c0d19c8c 100644 --- a/Plugins/BTCPayServer.Plugins.DataErasure/DataErasureService.cs +++ b/Plugins/BTCPayServer.Plugins.DataErasure/DataErasureService.cs @@ -54,110 +54,131 @@ private async Task SetCore(string storeId, DataErasureSettings settings) private async Task Run() { - while (!_cts.IsCancellationRequested) + var cts = _cts; + while (!cts.IsCancellationRequested) { - await _runningLock.WaitAsync(_cts.Token); - IsRunning = true; - + try + { + await _runningLock.WaitAsync(cts.Token); + } + catch (OperationCanceledException) + { + break; + } - var settings = - await _storeRepository.GetSettingsAsync(nameof(DataErasureSettings)); - foreach (var setting in settings.Where(setting => setting.Value.Enabled)) + IsRunning = true; + try { - var count = 0; - var cutoffDate = DateTimeOffset.UtcNow.Subtract(TimeSpan.FromDays(setting.Value.DaysToKeep)); - if (setting.Value.EntirelyEraseInvoice) - { - await using var db = _dbContextFactory.CreateContext(); - db.Invoices.RemoveRange(db.Invoices.Where(i => i.StoreDataId == setting.Key && i.Created < cutoffDate && (setting.Value.LastRunCutoff == null || i.Created > setting.Value.LastRunCutoff))); - count = await db.SaveChangesAsync(_cts.Token); - } - else + var settings = + await _storeRepository.GetSettingsAsync(nameof(DataErasureSettings)); + foreach (var setting in settings.Where(setting => setting.Value.Enabled)) { - - - - var skip = 0; - while (true) + try { - var invoices = await _invoiceRepository.GetInvoices(new InvoiceQuery() + var count = 0; + var cutoffDate = DateTimeOffset.UtcNow.Subtract(TimeSpan.FromDays(setting.Value.DaysToKeep)); + if (setting.Value.EntirelyEraseInvoice) { - StartDate = setting.Value.LastRunCutoff, - EndDate = cutoffDate, - StoreId = new[] {setting.Key}, - Skip = skip, - Take = 100 - }, _cts.Token); - - - - foreach (var invoice in invoices) + await using var db = _dbContextFactory.CreateContext(); + db.Invoices.RemoveRange(db.Invoices.Where(i => i.StoreDataId == setting.Key && i.Created < cutoffDate && (setting.Value.LastRunCutoff == null || i.Created > setting.Value.LastRunCutoff))); + count = await db.SaveChangesAsync(cts.Token); + } + else { - //replace all buyer info with "erased" - if (!string.IsNullOrEmpty(invoice.Metadata.BuyerAddress1) || - !string.IsNullOrEmpty(invoice.Metadata.BuyerAddress2) || - !string.IsNullOrEmpty(invoice.Metadata.BuyerCity) || - !string.IsNullOrEmpty(invoice.Metadata.BuyerCountry) || - !string.IsNullOrEmpty(invoice.Metadata.BuyerEmail) || - !string.IsNullOrEmpty(invoice.Metadata.BuyerName) || - !string.IsNullOrEmpty(invoice.Metadata.BuyerPhone) || - !string.IsNullOrEmpty(invoice.Metadata.BuyerState) || - !string.IsNullOrEmpty(invoice.Metadata.BuyerZip)) + var skip = 0; + while (true) { - await _invoiceRepository.UpdateInvoiceMetadata(invoice.Id, metadata => + var invoices = await _invoiceRepository.GetInvoices(new InvoiceQuery() { - if (!string.IsNullOrEmpty(metadata.BuyerAddress1)) - metadata.BuyerAddress1 = "erased"; - if (!string.IsNullOrEmpty(metadata.BuyerAddress2)) - metadata.BuyerAddress2 = "erased"; - if (!string.IsNullOrEmpty(metadata.BuyerCity)) - metadata.BuyerCity = "erased"; - if (!string.IsNullOrEmpty(metadata.BuyerCountry)) - metadata.BuyerCountry = "erased"; - if (!string.IsNullOrEmpty(metadata.BuyerEmail)) - metadata.BuyerEmail = "erased"; - if (!string.IsNullOrEmpty(metadata.BuyerName)) - metadata.BuyerName = "erased"; - if (!string.IsNullOrEmpty(metadata.BuyerPhone)) - metadata.BuyerPhone = "erased"; - if (!string.IsNullOrEmpty(metadata.BuyerState)) - metadata.BuyerState = "erased"; - if (!string.IsNullOrEmpty(metadata.BuyerZip)) - metadata.BuyerZip = "erased"; - return metadata; - }); - } - count++; - } + StartDate = setting.Value.LastRunCutoff, + EndDate = cutoffDate, + StoreId = new[] {setting.Key}, + Skip = skip, + Take = 100 + }, cts.Token); + + foreach (var invoice in invoices) + { + //replace all buyer info with "erased" + var metadata = invoice.Metadata; + if (!string.IsNullOrEmpty(metadata.BuyerAddress1) || + !string.IsNullOrEmpty(metadata.BuyerAddress2) || + !string.IsNullOrEmpty(metadata.BuyerCity) || + !string.IsNullOrEmpty(metadata.BuyerCountry) || + !string.IsNullOrEmpty(metadata.BuyerEmail) || + !string.IsNullOrEmpty(metadata.BuyerName) || + !string.IsNullOrEmpty(metadata.BuyerPhone) || + !string.IsNullOrEmpty(metadata.BuyerState) || + !string.IsNullOrEmpty(metadata.BuyerZip)) + { + if (!string.IsNullOrEmpty(metadata.BuyerAddress1)) + metadata.BuyerAddress1 = "erased"; + if (!string.IsNullOrEmpty(metadata.BuyerAddress2)) + metadata.BuyerAddress2 = "erased"; + if (!string.IsNullOrEmpty(metadata.BuyerCity)) + metadata.BuyerCity = "erased"; + if (!string.IsNullOrEmpty(metadata.BuyerCountry)) + metadata.BuyerCountry = "erased"; + if (!string.IsNullOrEmpty(metadata.BuyerEmail)) + metadata.BuyerEmail = "erased"; + if (!string.IsNullOrEmpty(metadata.BuyerName)) + metadata.BuyerName = "erased"; + if (!string.IsNullOrEmpty(metadata.BuyerPhone)) + metadata.BuyerPhone = "erased"; + if (!string.IsNullOrEmpty(metadata.BuyerState)) + metadata.BuyerState = "erased"; + if (!string.IsNullOrEmpty(metadata.BuyerZip)) + metadata.BuyerZip = "erased"; + await _invoiceRepository.UpdateInvoiceMetadata(invoice.Id, invoice.StoreId, metadata.ToJObject()); + } + count++; + } + + if (invoices.Length < 100) + { + break; + } - if (invoices.Length < 100) - { - break; + skip += 100; + } } - skip += 100; + if (count > 0) + _logger.LogInformation($"Erased {count} invoice data for store {setting.Key}"); + setting.Value.LastRunCutoff = cutoffDate; + await SetCore(setting.Key, setting.Value); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception e) + { + _logger.LogError(e, "Failed to erase data for store {StoreId}", setting.Key); } } - - if (count > 0) - _logger.LogInformation($"Erased {count} invoice data for store {setting.Key}"); - setting.Value.LastRunCutoff = cutoffDate; - await SetCore(setting.Key, setting.Value); + } + catch (OperationCanceledException) + { + } + catch (Exception e) + { + _logger.LogError(e, "Data erasure cycle failed"); + } + finally + { + IsRunning = false; + _runningLock.Release(); } - IsRunning = false; - - - _runningLock.Release(); - await Task.Delay(TimeSpan.FromHours(1), _cts.Token); - } - - try - { - _runningLock.Release(); - } - catch (Exception e) - { + try + { + await Task.Delay(TimeSpan.FromHours(1), cts.Token); + } + catch (OperationCanceledException) + { + break; + } } } From 10237b74a187c93d7ac8735d274ca7f777f5caeb Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Tue, 18 Aug 2026 14:28:45 +0200 Subject: [PATCH 09/33] chore: bump patched plugin versions and require BTCPay 2.4.2 --- .../BTCPayServer.Plugins.Blink.csproj | 2 +- Plugins/BTCPayServer.Plugins.Blink/BlinkPlugin.cs | 2 +- .../BTCPayServer.Plugins.DataErasure.csproj | 2 +- Plugins/BTCPayServer.Plugins.DataErasure/DataErasurePlugin.cs | 2 +- .../BTCPayServer.Plugins.DynamicRateLimits.csproj | 2 +- .../DynamicRateLimitsPlugin.cs | 2 +- .../BTCPayServer.Plugins.Electrum.csproj | 2 +- Plugins/BTCPayServer.Plugins.Electrum/ElectrumPlugin.cs | 2 +- .../BTCPayServer.Plugins.LNURLVerify.csproj | 2 +- Plugins/BTCPayServer.Plugins.LNURLVerify/LNURLVerifyPlugin.cs | 2 +- .../BTCPayServer.Plugins.MCP/BTCPayServer.Plugins.MCP.csproj | 2 +- Plugins/BTCPayServer.Plugins.MCP/McpPlugin.cs | 2 +- .../BTCPayServer.Plugins.MicroNode.csproj | 2 +- Plugins/BTCPayServer.Plugins.MicroNode/MicroNodePlugin.cs | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Plugins/BTCPayServer.Plugins.Blink/BTCPayServer.Plugins.Blink.csproj b/Plugins/BTCPayServer.Plugins.Blink/BTCPayServer.Plugins.Blink.csproj index 1e1faa20..96da6c8d 100644 --- a/Plugins/BTCPayServer.Plugins.Blink/BTCPayServer.Plugins.Blink.csproj +++ b/Plugins/BTCPayServer.Plugins.Blink/BTCPayServer.Plugins.Blink.csproj @@ -9,7 +9,7 @@ Blink Blink Lightning support - 1.1.0 + 1.1.1 true BTCPayServer.Plugins.Blink diff --git a/Plugins/BTCPayServer.Plugins.Blink/BlinkPlugin.cs b/Plugins/BTCPayServer.Plugins.Blink/BlinkPlugin.cs index ce857f07..f2c04f13 100644 --- a/Plugins/BTCPayServer.Plugins.Blink/BlinkPlugin.cs +++ b/Plugins/BTCPayServer.Plugins.Blink/BlinkPlugin.cs @@ -12,7 +12,7 @@ public class BlinkPlugin : BaseBTCPayServerPlugin { public override IBTCPayServerPlugin.PluginDependency[] Dependencies { get; } = { - new() { Identifier = nameof(BTCPayServer), Condition = ">=2.3.7" } + new() { Identifier = nameof(BTCPayServer), Condition = ">=2.4.2" } }; diff --git a/Plugins/BTCPayServer.Plugins.DataErasure/BTCPayServer.Plugins.DataErasure.csproj b/Plugins/BTCPayServer.Plugins.DataErasure/BTCPayServer.Plugins.DataErasure.csproj index 5557bc92..f3478122 100644 --- a/Plugins/BTCPayServer.Plugins.DataErasure/BTCPayServer.Plugins.DataErasure.csproj +++ b/Plugins/BTCPayServer.Plugins.DataErasure/BTCPayServer.Plugins.DataErasure.csproj @@ -9,7 +9,7 @@ Data Erasure Allows you to erase user data from invoices after a period of time. - 1.0.5 + 1.0.6 true diff --git a/Plugins/BTCPayServer.Plugins.DataErasure/DataErasurePlugin.cs b/Plugins/BTCPayServer.Plugins.DataErasure/DataErasurePlugin.cs index 02edfb3c..711a5ca9 100644 --- a/Plugins/BTCPayServer.Plugins.DataErasure/DataErasurePlugin.cs +++ b/Plugins/BTCPayServer.Plugins.DataErasure/DataErasurePlugin.cs @@ -9,7 +9,7 @@ public class DataErasurePlugin : BaseBTCPayServerPlugin { public override IBTCPayServerPlugin.PluginDependency[] Dependencies { get; } = { - new() { Identifier = nameof(BTCPayServer), Condition = ">=2.4.1" } + new() { Identifier = nameof(BTCPayServer), Condition = ">=2.4.2" } }; public override void Execute(IServiceCollection applicationBuilder) { diff --git a/Plugins/BTCPayServer.Plugins.DynamicRateLimits/BTCPayServer.Plugins.DynamicRateLimits.csproj b/Plugins/BTCPayServer.Plugins.DynamicRateLimits/BTCPayServer.Plugins.DynamicRateLimits.csproj index 3744541e..e969b758 100644 --- a/Plugins/BTCPayServer.Plugins.DynamicRateLimits/BTCPayServer.Plugins.DynamicRateLimits.csproj +++ b/Plugins/BTCPayServer.Plugins.DynamicRateLimits/BTCPayServer.Plugins.DynamicRateLimits.csproj @@ -9,7 +9,7 @@ Dynamic Rate Limit Allows you to override the default rate limiting. - 1.0.2 + 1.0.3 true diff --git a/Plugins/BTCPayServer.Plugins.DynamicRateLimits/DynamicRateLimitsPlugin.cs b/Plugins/BTCPayServer.Plugins.DynamicRateLimits/DynamicRateLimitsPlugin.cs index b128d6f0..3dcd7d54 100644 --- a/Plugins/BTCPayServer.Plugins.DynamicRateLimits/DynamicRateLimitsPlugin.cs +++ b/Plugins/BTCPayServer.Plugins.DynamicRateLimits/DynamicRateLimitsPlugin.cs @@ -10,7 +10,7 @@ public class DynamicRateLimitsPlugin : BaseBTCPayServerPlugin { public override IBTCPayServerPlugin.PluginDependency[] Dependencies { get; } = { - new() { Identifier = nameof(BTCPayServer), Condition = ">=2.3.7" } + new() { Identifier = nameof(BTCPayServer), Condition = ">=2.4.2" } }; public override void Execute(IServiceCollection applicationBuilder) { diff --git a/Plugins/BTCPayServer.Plugins.Electrum/BTCPayServer.Plugins.Electrum.csproj b/Plugins/BTCPayServer.Plugins.Electrum/BTCPayServer.Plugins.Electrum.csproj index f8f104d4..9c70405b 100644 --- a/Plugins/BTCPayServer.Plugins.Electrum/BTCPayServer.Plugins.Electrum.csproj +++ b/Plugins/BTCPayServer.Plugins.Electrum/BTCPayServer.Plugins.Electrum.csproj @@ -9,7 +9,7 @@ Electrum Runs an Electrum (ElectrumX/Fulcrum) backend alongside NBXplorer — a bootstrap while NBXplorer syncs and an always-on fallback if it goes down — with automatic per-wallet failover. Mainnet only. - 1.1.2 + 1.1.3 true diff --git a/Plugins/BTCPayServer.Plugins.Electrum/ElectrumPlugin.cs b/Plugins/BTCPayServer.Plugins.Electrum/ElectrumPlugin.cs index e1eb1614..67b394f3 100644 --- a/Plugins/BTCPayServer.Plugins.Electrum/ElectrumPlugin.cs +++ b/Plugins/BTCPayServer.Plugins.Electrum/ElectrumPlugin.cs @@ -21,7 +21,7 @@ public class ElectrumPlugin : BaseBTCPayServerPlugin { public override IBTCPayServerPlugin.PluginDependency[] Dependencies { get; } = { - new() { Identifier = nameof(BTCPayServer), Condition = ">=2.3.7" } + new() { Identifier = nameof(BTCPayServer), Condition = ">=2.4.2" } }; // Escape hatch for tests: allows the mainnet-only guard below to be bypassed diff --git a/Plugins/BTCPayServer.Plugins.LNURLVerify/BTCPayServer.Plugins.LNURLVerify.csproj b/Plugins/BTCPayServer.Plugins.LNURLVerify/BTCPayServer.Plugins.LNURLVerify.csproj index 34e978f8..3ddf21e2 100644 --- a/Plugins/BTCPayServer.Plugins.LNURLVerify/BTCPayServer.Plugins.LNURLVerify.csproj +++ b/Plugins/BTCPayServer.Plugins.LNURLVerify/BTCPayServer.Plugins.LNURLVerify.csproj @@ -9,7 +9,7 @@ LNURL Verify Use any LNURL or Lightning address as a BTCPay Lightning backend (receive via LUD-21 verify; send via LNURL-withdraw). - 1.0.0 + 1.0.1 true BTCPayServer.Plugins.LNURLVerify diff --git a/Plugins/BTCPayServer.Plugins.LNURLVerify/LNURLVerifyPlugin.cs b/Plugins/BTCPayServer.Plugins.LNURLVerify/LNURLVerifyPlugin.cs index 8f8fe3be..d55f15f5 100644 --- a/Plugins/BTCPayServer.Plugins.LNURLVerify/LNURLVerifyPlugin.cs +++ b/Plugins/BTCPayServer.Plugins.LNURLVerify/LNURLVerifyPlugin.cs @@ -12,7 +12,7 @@ public class LNURLVerifyPlugin : BaseBTCPayServerPlugin { public override IBTCPayServerPlugin.PluginDependency[] Dependencies { get; } = { - new() { Identifier = nameof(BTCPayServer), Condition = ">=2.4.0" } + new() { Identifier = nameof(BTCPayServer), Condition = ">=2.4.2" } }; public override void Execute(IServiceCollection services) diff --git a/Plugins/BTCPayServer.Plugins.MCP/BTCPayServer.Plugins.MCP.csproj b/Plugins/BTCPayServer.Plugins.MCP/BTCPayServer.Plugins.MCP.csproj index 40589e30..a1c2024f 100644 --- a/Plugins/BTCPayServer.Plugins.MCP/BTCPayServer.Plugins.MCP.csproj +++ b/Plugins/BTCPayServer.Plugins.MCP/BTCPayServer.Plugins.MCP.csproj @@ -9,7 +9,7 @@ MCP Exposes a Model Context Protocol (MCP) server, allowing LLM clients to interact with BTCPay Server through natural language. - 1.1.0 + 1.1.1 true diff --git a/Plugins/BTCPayServer.Plugins.MCP/McpPlugin.cs b/Plugins/BTCPayServer.Plugins.MCP/McpPlugin.cs index fd6394a9..df83e702 100644 --- a/Plugins/BTCPayServer.Plugins.MCP/McpPlugin.cs +++ b/Plugins/BTCPayServer.Plugins.MCP/McpPlugin.cs @@ -15,7 +15,7 @@ public class McpPlugin : BaseBTCPayServerPlugin { public override IBTCPayServerPlugin.PluginDependency[] Dependencies { get; } = [ - new() { Identifier = nameof(BTCPayServer), Condition = ">=2.3.7" } + new() { Identifier = nameof(BTCPayServer), Condition = ">=2.4.2" } ]; public override void Execute(IServiceCollection services) diff --git a/Plugins/BTCPayServer.Plugins.MicroNode/BTCPayServer.Plugins.MicroNode.csproj b/Plugins/BTCPayServer.Plugins.MicroNode/BTCPayServer.Plugins.MicroNode.csproj index 6f1d72a6..8df3d0ef 100644 --- a/Plugins/BTCPayServer.Plugins.MicroNode/BTCPayServer.Plugins.MicroNode.csproj +++ b/Plugins/BTCPayServer.Plugins.MicroNode/BTCPayServer.Plugins.MicroNode.csproj @@ -11,7 +11,7 @@ MicroNode Micro ln node. - 1.1.0 + 1.1.1 true diff --git a/Plugins/BTCPayServer.Plugins.MicroNode/MicroNodePlugin.cs b/Plugins/BTCPayServer.Plugins.MicroNode/MicroNodePlugin.cs index 74cf283a..2be19bb0 100644 --- a/Plugins/BTCPayServer.Plugins.MicroNode/MicroNodePlugin.cs +++ b/Plugins/BTCPayServer.Plugins.MicroNode/MicroNodePlugin.cs @@ -11,7 +11,7 @@ public class MicroNodePlugin:BaseBTCPayServerPlugin { public override IBTCPayServerPlugin.PluginDependency[] Dependencies { get; } = { - new () { Identifier = nameof(BTCPayServer), Condition = ">=2.3.7" } + new () { Identifier = nameof(BTCPayServer), Condition = ">=2.4.2" } }; From 33ad1ecbf3444d3f0a748ace70d5590e8e289b02 Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Tue, 18 Aug 2026 15:53:58 +0200 Subject: [PATCH 10/33] chore(electrum): attribute each tx output to its own tracked address --- .../Electrum/ElectrumWalletTracker.cs | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/Plugins/BTCPayServer.Plugins.Electrum/Electrum/ElectrumWalletTracker.cs b/Plugins/BTCPayServer.Plugins.Electrum/Electrum/ElectrumWalletTracker.cs index 304dd3a0..83137f5e 100644 --- a/Plugins/BTCPayServer.Plugins.Electrum/Electrum/ElectrumWalletTracker.cs +++ b/Plugins/BTCPayServer.Plugins.Electrum/Electrum/ElectrumWalletTracker.cs @@ -250,6 +250,12 @@ public async Task> HandleScripthashNotificationAsync( var addr = await ctx.TrackedAddresses.FindAsync(new object[] { scripthash }, ct); if (addr == null) return newTxs; + // All of this wallet's tracked addresses, so a tx paying several of them is + // fully attributed in one pass (later notifications dedupe on txid). + var walletAddresses = await ctx.TrackedAddresses + .Where(a => a.WalletId == addr.WalletId) + .ToDictionaryAsync(a => a.Scripthash, ct); + // Fetch current history from Electrum var history = await _client.ScripthashGetHistoryAsync(scripthash, ct); var existingTxids = await ctx.Transactions @@ -292,7 +298,7 @@ public async Task> HandleScripthashNotificationAsync( ctx.Transactions.Add(trackedTx); - var txInfo = BuildNewTransactionInfo(tx, addr, strategy, item); + var txInfo = BuildNewTransactionInfo(tx, strategy, item, walletAddresses); if (txInfo != null) newTxs.Add(txInfo); } @@ -1311,8 +1317,8 @@ public class OutputInfo } private NewTransactionInfo BuildNewTransactionInfo( - Transaction tx, TrackedAddress matchedAddr, - DerivationStrategyBase strategy, ElectrumHistoryItem historyItem) + Transaction tx, DerivationStrategyBase strategy, ElectrumHistoryItem historyItem, + IReadOnlyDictionary walletAddresses) { var info = new NewTransactionInfo { @@ -1325,24 +1331,23 @@ private NewTransactionInfo BuildNewTransactionInfo( Transaction = tx }; - // Find outputs that match our tracked addresses + // Attribute each output to whichever of this wallet's tracked addresses it pays. for (var i = 0; i < tx.Outputs.Count; i++) { var output = tx.Outputs[i]; var scriptHash = ScriptHashUtility.ComputeScriptHash(output.ScriptPubKey); - // Check if this output goes to the notified address - if (scriptHash == matchedAddr.Scripthash) + if (walletAddresses.TryGetValue(scriptHash, out var outputAddr)) { - var parts = matchedAddr.KeyPath.Split('/'); + var parts = outputAddr.KeyPath.Split('/'); var keyIndex = parts.Length == 2 && int.TryParse(parts[1], out var idx) ? idx : 0; info.Outputs.Add(new OutputInfo { - Address = matchedAddr.Address, + Address = outputAddr.Address, Index = i, Value = output.Value, - KeyPath = matchedAddr.KeyPath, + KeyPath = outputAddr.KeyPath, KeyIndex = keyIndex }); } From 29d0fa7297495c682232ad9c836d1e6efe7dde3c Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Tue, 18 Aug 2026 15:53:58 +0200 Subject: [PATCH 11/33] fix(dataerasure): scope erase loop to its own cancellation source --- .../DataErasureService.cs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/Plugins/BTCPayServer.Plugins.DataErasure/DataErasureService.cs b/Plugins/BTCPayServer.Plugins.DataErasure/DataErasureService.cs index c0d19c8c..82f2f8cc 100644 --- a/Plugins/BTCPayServer.Plugins.DataErasure/DataErasureService.cs +++ b/Plugins/BTCPayServer.Plugins.DataErasure/DataErasureService.cs @@ -40,8 +40,9 @@ public async Task Set(string storeId, DataErasureSettings settings, bool clearDa settings.LastRunCutoff = clearDate? null: existing?.LastRunCutoff; await SetCore(storeId, settings); _runningLock.Release(); - _cts = new CancellationTokenSource(); - _ = Run(); + var cts = new CancellationTokenSource(); + _cts = cts; + _ = Run(cts); } private async Task SetCore(string storeId, DataErasureSettings settings) @@ -52,9 +53,8 @@ private async Task SetCore(string storeId, DataErasureSettings settings) public bool IsRunning { get; private set; } private readonly SemaphoreSlim _runningLock = new(1, 1); - private async Task Run() + private async Task Run(CancellationTokenSource cts) { - var cts = _cts; while (!cts.IsCancellationRequested) { try @@ -180,14 +180,17 @@ private async Task Run() break; } } + + cts.Dispose(); } private CancellationTokenSource _cts; public Task StartAsync(CancellationToken cancellationToken) { - _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - _ = Run(); + var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _cts = cts; + _ = Run(cts); return Task.CompletedTask; } From 6bb53b5af5672162d72693dbb6bc05dfddd7d92d Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Tue, 18 Aug 2026 15:53:59 +0200 Subject: [PATCH 12/33] chore(lnurlverify): normalize preimage before storing --- Plugins/BTCPayServer.Plugins.LNURLVerify/LNURLReceiver.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Plugins/BTCPayServer.Plugins.LNURLVerify/LNURLReceiver.cs b/Plugins/BTCPayServer.Plugins.LNURLVerify/LNURLReceiver.cs index 53242701..2fcd854a 100644 --- a/Plugins/BTCPayServer.Plugins.LNURLVerify/LNURLReceiver.cs +++ b/Plugins/BTCPayServer.Plugins.LNURLVerify/LNURLReceiver.cs @@ -177,7 +177,8 @@ public async Task CreateInvoice(LightMoney? amount, string? de private static LightningInvoice BuildInvoice(TrackedInvoice t, bool settled, string? preimage) { var bolt11 = BOLT11PaymentRequest.Parse(t.Bolt11, InferNetwork(t.Bolt11)); - string? valid = settled && preimage is not null && IsValidPreimage(preimage, t.PaymentHash) ? preimage : null; + var normalizedPreimage = preimage?.Trim(); + string? valid = settled && normalizedPreimage is not null && IsValidPreimage(normalizedPreimage, t.PaymentHash) ? normalizedPreimage : null; var paid = valid is not null; var status = paid ? LightningInvoiceStatus.Paid : t.ExpiresAt < DateTimeOffset.UtcNow ? LightningInvoiceStatus.Expired From dca1c3164e6a749bd6c8206d4b139855d35a240f Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Tue, 18 Aug 2026 15:54:00 +0200 Subject: [PATCH 13/33] fix(blink): require a known amount before reporting paid --- .../BTCPayServer.Plugins.Blink/BlinkLnAddressLightningClient.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Plugins/BTCPayServer.Plugins.Blink/BlinkLnAddressLightningClient.cs b/Plugins/BTCPayServer.Plugins.Blink/BlinkLnAddressLightningClient.cs index 39d6886b..402f8a62 100644 --- a/Plugins/BTCPayServer.Plugins.Blink/BlinkLnAddressLightningClient.cs +++ b/Plugins/BTCPayServer.Plugins.Blink/BlinkLnAddressLightningClient.cs @@ -397,7 +397,7 @@ private LightningInvoice BuildInvoice(string paymentHash, TrackedInvoice tracked var validPreimage = settled ? ValidatePreimage(paymentHash, preimage) : null; if (settled && validPreimage is null) _logger.LogWarning("Blink reported {PaymentHash} settled without a valid preimage; not marking paid.", paymentHash); - var paid = validPreimage is not null; + var paid = validPreimage is not null && reportedAmount is not null; var status = DetermineStatus(paid, tracked.ExpiresAt, DateTimeOffset.UtcNow); return new LightningInvoice From d61b890504f5167736a1ad792f6994b2ae89d20e Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Wed, 19 Aug 2026 08:20:38 +0200 Subject: [PATCH 14/33] chore(nip05): bump NNostr.Client to 0.0.55 and version to 1.1.21 --- .../BTCPayServer.Plugins.NIP05.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Plugins/BTCPayServer.Plugins.NIP05/BTCPayServer.Plugins.NIP05.csproj b/Plugins/BTCPayServer.Plugins.NIP05/BTCPayServer.Plugins.NIP05.csproj index bf359adc..7df5d4c1 100644 --- a/Plugins/BTCPayServer.Plugins.NIP05/BTCPayServer.Plugins.NIP05.csproj +++ b/Plugins/BTCPayServer.Plugins.NIP05/BTCPayServer.Plugins.NIP05.csproj @@ -11,7 +11,7 @@ Nostr NIP5 addresses, Zap support, Nostr Wallet Connect Lightning support - 1.1.20 + 1.1.21 true @@ -36,7 +36,7 @@ - + From 5393a32e0ccc463e33ad69e3801a5e28da38f67a Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Wed, 19 Aug 2026 09:37:57 +0200 Subject: [PATCH 15/33] fix(dataerasure): single long-lived worker, no per-set cts race or dispose --- .../DataErasureService.cs | 42 +++++++------------ 1 file changed, 14 insertions(+), 28 deletions(-) diff --git a/Plugins/BTCPayServer.Plugins.DataErasure/DataErasureService.cs b/Plugins/BTCPayServer.Plugins.DataErasure/DataErasureService.cs index 82f2f8cc..ed33da5c 100644 --- a/Plugins/BTCPayServer.Plugins.DataErasure/DataErasureService.cs +++ b/Plugins/BTCPayServer.Plugins.DataErasure/DataErasureService.cs @@ -34,15 +34,12 @@ public async Task Get(string storeId) public async Task Set(string storeId, DataErasureSettings settings, bool clearDate = false) { - _cts?.Cancel(); - await _runningLock.WaitAsync(); var existing = await Get(storeId); - settings.LastRunCutoff = clearDate? null: existing?.LastRunCutoff; + settings.LastRunCutoff = clearDate ? null : existing?.LastRunCutoff; await SetCore(storeId, settings); - _runningLock.Release(); - var cts = new CancellationTokenSource(); - _cts = cts; - _ = Run(cts); + // Wake the single worker so new settings apply promptly; coalesced to one pending run. + try { _wake.Release(); } + catch (SemaphoreFullException) { } } private async Task SetCore(string storeId, DataErasureSettings settings) @@ -51,21 +48,14 @@ private async Task SetCore(string storeId, DataErasureSettings settings) } public bool IsRunning { get; private set; } - private readonly SemaphoreSlim _runningLock = new(1, 1); - private async Task Run(CancellationTokenSource cts) + // Signals the worker to run a cycle immediately, bounded to a single pending wake. + private readonly SemaphoreSlim _wake = new(0, 1); + + private async Task RunLoop(CancellationToken ct) { - while (!cts.IsCancellationRequested) + while (!ct.IsCancellationRequested) { - try - { - await _runningLock.WaitAsync(cts.Token); - } - catch (OperationCanceledException) - { - break; - } - IsRunning = true; try { @@ -81,7 +71,7 @@ private async Task Run(CancellationTokenSource cts) { await using var db = _dbContextFactory.CreateContext(); db.Invoices.RemoveRange(db.Invoices.Where(i => i.StoreDataId == setting.Key && i.Created < cutoffDate && (setting.Value.LastRunCutoff == null || i.Created > setting.Value.LastRunCutoff))); - count = await db.SaveChangesAsync(cts.Token); + count = await db.SaveChangesAsync(ct); } else { @@ -95,7 +85,7 @@ private async Task Run(CancellationTokenSource cts) StoreId = new[] {setting.Key}, Skip = skip, Take = 100 - }, cts.Token); + }, ct); foreach (var invoice in invoices) { @@ -168,29 +158,25 @@ private async Task Run(CancellationTokenSource cts) finally { IsRunning = false; - _runningLock.Release(); } try { - await Task.Delay(TimeSpan.FromHours(1), cts.Token); + await _wake.WaitAsync(TimeSpan.FromHours(1), ct); } catch (OperationCanceledException) { break; } } - - cts.Dispose(); } private CancellationTokenSource _cts; public Task StartAsync(CancellationToken cancellationToken) { - var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - _cts = cts; - _ = Run(cts); + _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _ = RunLoop(_cts.Token); return Task.CompletedTask; } From 7ce67af52ead0c9e534fa7ca4b678ca9d66573ad Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Wed, 19 Aug 2026 09:37:58 +0200 Subject: [PATCH 16/33] fix(electrum): mark every co-paid tracked address as used --- .../Electrum/ElectrumWalletTracker.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Plugins/BTCPayServer.Plugins.Electrum/Electrum/ElectrumWalletTracker.cs b/Plugins/BTCPayServer.Plugins.Electrum/Electrum/ElectrumWalletTracker.cs index 83137f5e..e495579c 100644 --- a/Plugins/BTCPayServer.Plugins.Electrum/Electrum/ElectrumWalletTracker.cs +++ b/Plugins/BTCPayServer.Plugins.Electrum/Electrum/ElectrumWalletTracker.cs @@ -314,6 +314,18 @@ public async Task> HandleScripthashNotificationAsync( await ExtendGapIfNeeded(ctx, addr, ct); } + // A single tx can pay several of this wallet's addresses; mark every co-paid address used + // too, so none is left eligible for reissue by GetNextUnusedAddressAsync. + var addressLookup = walletAddresses.Values.ToDictionary(a => a.Address); + foreach (var paidAddress in newTxs.SelectMany(t => t.Outputs).Select(o => o.Address).Distinct()) + { + if (addressLookup.TryGetValue(paidAddress, out var coPaid) && !coPaid.IsUsed) + { + coPaid.IsUsed = true; + await ExtendGapIfNeeded(ctx, coPaid, ct); + } + } + await ctx.SaveChangesAsync(ct); } finally From 927bc7923d42019d8299aa127dbab0b981f134d7 Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Wed, 19 Aug 2026 09:37:59 +0200 Subject: [PATCH 17/33] fix(nip05): adapt zap metadata write to current invoice API --- Plugins/BTCPayServer.Plugins.NIP05/Zapper.cs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/Plugins/BTCPayServer.Plugins.NIP05/Zapper.cs b/Plugins/BTCPayServer.Plugins.NIP05/Zapper.cs index fbb56412..02d514d7 100644 --- a/Plugins/BTCPayServer.Plugins.NIP05/Zapper.cs +++ b/Plugins/BTCPayServer.Plugins.NIP05/Zapper.cs @@ -13,6 +13,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using NBitcoin; +using Newtonsoft.Json.Linq; using NNostr.Client; using JsonSerializer = System.Text.Json.JsonSerializer; @@ -200,12 +201,14 @@ private async Task Subscription(InvoiceEvent arg) zapReceipt = await zapReceipt.ComputeIdAndSignAsync(key); relays = relays.Concat(userNostrSettings?.Relays ?? Array.Empty()).Distinct().ToArray(); - await _invoiceRepository.UpdateInvoiceMetadata(arg.InvoiceId, "Nostr", new Dictionary() + var metadata = arg.Invoice.Metadata.ToJObject(); + metadata["Nostr"] = new JObject { - {"Zap Request", zapRequestEvent.Id}, - {"Zap Receipt", zapReceipt.Id}, - {"Relays", string.Join(',', relays)} - }); + ["Zap Request"] = zapRequestEvent.Id, + ["Zap Receipt"] = zapReceipt.Id, + ["Relays"] = string.Join(',', relays) + }; + await _invoiceRepository.UpdateInvoiceMetadata(arg.InvoiceId, arg.Invoice.StoreId, metadata); _pendingZapEvents.Add(new PendingZapEvent(relays, zapReceipt)); } From 4193c4f078eb65405c49bb64fa9ad3bf2b5ae07b Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Wed, 19 Aug 2026 09:49:59 +0200 Subject: [PATCH 18/33] fix(dataerasure): persist only cutoff progress against latest settings --- .../DataErasureService.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Plugins/BTCPayServer.Plugins.DataErasure/DataErasureService.cs b/Plugins/BTCPayServer.Plugins.DataErasure/DataErasureService.cs index ed33da5c..3ca32cf0 100644 --- a/Plugins/BTCPayServer.Plugins.DataErasure/DataErasureService.cs +++ b/Plugins/BTCPayServer.Plugins.DataErasure/DataErasureService.cs @@ -135,8 +135,14 @@ private async Task RunLoop(CancellationToken ct) if (count > 0) _logger.LogInformation($"Erased {count} invoice data for store {setting.Key}"); - setting.Value.LastRunCutoff = cutoffDate; - await SetCore(setting.Key, setting.Value); + // Persist only cutoff progress against the latest settings, so a concurrent + // user change (e.g. disabling erasure mid-cycle) is not clobbered. + var latest = await Get(setting.Key); + if (latest != null) + { + latest.LastRunCutoff = cutoffDate; + await SetCore(setting.Key, latest); + } } catch (OperationCanceledException) { From 8d18fb106c95d5ef490f02869523f8fe8bdc2b9a Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Wed, 19 Aug 2026 09:50:01 +0200 Subject: [PATCH 19/33] fix(electrum): make co-paid gap extension best-effort --- .../Electrum/ElectrumWalletTracker.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Plugins/BTCPayServer.Plugins.Electrum/Electrum/ElectrumWalletTracker.cs b/Plugins/BTCPayServer.Plugins.Electrum/Electrum/ElectrumWalletTracker.cs index e495579c..7e6cbd93 100644 --- a/Plugins/BTCPayServer.Plugins.Electrum/Electrum/ElectrumWalletTracker.cs +++ b/Plugins/BTCPayServer.Plugins.Electrum/Electrum/ElectrumWalletTracker.cs @@ -322,7 +322,12 @@ public async Task> HandleScripthashNotificationAsync( if (addressLookup.TryGetValue(paidAddress, out var coPaid) && !coPaid.IsUsed) { coPaid.IsUsed = true; - await ExtendGapIfNeeded(ctx, coPaid, ct); + // Best-effort: a failed gap-extension subscription must not abort persisting the tx. + try { await ExtendGapIfNeeded(ctx, coPaid, ct); } + catch (Exception e) when (!ct.IsCancellationRequested) + { + _logger.LogWarning(e, "Failed to extend gap for co-paid address {Address}", coPaid.Address); + } } } From f65ed4cf7521e82d2279ab01ff853443b46daec3 Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Wed, 19 Aug 2026 10:46:35 +0200 Subject: [PATCH 20/33] chore: bump btcpayserver submodule to v2.4.2 --- submodules/btcpayserver | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/btcpayserver b/submodules/btcpayserver index 03345c28..fbf761f3 160000 --- a/submodules/btcpayserver +++ b/submodules/btcpayserver @@ -1 +1 @@ -Subproject commit 03345c2886a58ea4d2f603cb6554b2e3f5d690a4 +Subproject commit fbf761f36a015b76c197889f174f124c348cd097 From d616b6a81dd59c79a573f2cfa993119d4cc1625f Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Wed, 19 Aug 2026 11:16:21 +0200 Subject: [PATCH 21/33] fix(prism): drop unmatched payment group so the match loop can't spin --- .../BTCPayServer.Plugins.Prism.csproj | 2 +- Plugins/BTCPayServer.Plugins.Prism/SatBreaker.cs | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Plugins/BTCPayServer.Plugins.Prism/BTCPayServer.Plugins.Prism.csproj b/Plugins/BTCPayServer.Plugins.Prism/BTCPayServer.Plugins.Prism.csproj index 0f1f16c8..80048902 100644 --- a/Plugins/BTCPayServer.Plugins.Prism/BTCPayServer.Plugins.Prism.csproj +++ b/Plugins/BTCPayServer.Plugins.Prism/BTCPayServer.Plugins.Prism.csproj @@ -11,7 +11,7 @@ Prism Automated value splits for Bitcoin. - 1.3.2 + 1.3.3 true diff --git a/Plugins/BTCPayServer.Plugins.Prism/SatBreaker.cs b/Plugins/BTCPayServer.Plugins.Prism/SatBreaker.cs index 71af64b1..c59c4950 100644 --- a/Plugins/BTCPayServer.Plugins.Prism/SatBreaker.cs +++ b/Plugins/BTCPayServer.Plugins.Prism/SatBreaker.cs @@ -633,8 +633,9 @@ public async Task UpdatePrismSettingsForStore(string storeId, PrismSetting var paymentGroup = payments.First(); if (!catchAlls.Remove(paymentGroup.Key, out catchAllSplit)) { - //shift the paymentgroup to bottom of the list - payments = payments.Where(grouping => grouping.Key != paymentGroup.Key).Append(paymentGroup).ToArray(); + //no catch-all matches this group and no null-key catch-all exists to drain it; + //drop it so the loop makes progress instead of rotating the same group forever. + payments = payments.Where(grouping => grouping.Key != paymentGroup.Key).ToArray(); continue; } From b8213b0aa94f64e1d030582f895a8b736a0e920e Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Wed, 19 Aug 2026 11:16:22 +0200 Subject: [PATCH 22/33] fix(bringin): validate order amount before creating payout --- .../BTCPayServer.Plugins.Bringin.csproj | 2 +- Plugins/BTCPayServer.Plugins.Bringin/BringinService.cs | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Plugins/BTCPayServer.Plugins.Bringin/BTCPayServer.Plugins.Bringin.csproj b/Plugins/BTCPayServer.Plugins.Bringin/BTCPayServer.Plugins.Bringin.csproj index fe890feb..3cd0d9e9 100644 --- a/Plugins/BTCPayServer.Plugins.Bringin/BTCPayServer.Plugins.Bringin.csproj +++ b/Plugins/BTCPayServer.Plugins.Bringin/BTCPayServer.Plugins.Bringin.csproj @@ -9,7 +9,7 @@ Bringin Euro Offramp - 1.0.6 + 1.0.7 true diff --git a/Plugins/BTCPayServer.Plugins.Bringin/BringinService.cs b/Plugins/BTCPayServer.Plugins.Bringin/BringinService.cs index a3d0f77d..dee17505 100644 --- a/Plugins/BTCPayServer.Plugins.Bringin/BringinService.cs +++ b/Plugins/BTCPayServer.Plugins.Bringin/BringinService.cs @@ -294,6 +294,11 @@ await _storeRepository.UpdateSetting(storeId, BringinStoreSettings.BringinSettin PaymentMethod = supportedMethod.bringinMethod }; var order = await bringinClient.PlaceOrder(request); + // The Bringin response is untrusted: reject a non-positive amount or one larger than requested + // before it becomes a pre-approved payout, so a hostile response can't claim more than intended. + if (order.Amount <= 0 || order.Amount > amountBtc.Satoshi) + throw new Exception( + $"Bringin returned an invalid order amount ({order.Amount} sats) for a {amountBtc.Satoshi} sats request."); var orderMoney = Money.Satoshis(order.Amount); if (!payout) From d6f481444f17ea76f9c10e45947d3d4ad12e3f92 Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Wed, 19 Aug 2026 11:16:23 +0200 Subject: [PATCH 23/33] fix(sideshift): validate coin identifiers before caching --- .../BTCPayServer.Plugins.SideShift.csproj | 2 +- .../SideShiftService.cs | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/Plugins/BTCPayServer.Plugins.SideShift/BTCPayServer.Plugins.SideShift.csproj b/Plugins/BTCPayServer.Plugins.SideShift/BTCPayServer.Plugins.SideShift.csproj index 1c6b0d0f..7b9e5bff 100644 --- a/Plugins/BTCPayServer.Plugins.SideShift/BTCPayServer.Plugins.SideShift.csproj +++ b/Plugins/BTCPayServer.Plugins.SideShift/BTCPayServer.Plugins.SideShift.csproj @@ -9,7 +9,7 @@ SideShift Allows you to embed a SideShift conversion screen to allow customers to pay with altcoins. - 1.1.17 + 1.1.18 true diff --git a/Plugins/BTCPayServer.Plugins.SideShift/SideShiftService.cs b/Plugins/BTCPayServer.Plugins.SideShift/SideShiftService.cs index 9b520457..2057e8c7 100644 --- a/Plugins/BTCPayServer.Plugins.SideShift/SideShiftService.cs +++ b/Plugins/BTCPayServer.Plugins.SideShift/SideShiftService.cs @@ -237,6 +237,16 @@ public async Task> GetDepositOptions() } var coins = await response.Content.ReadAsStringAsync().ContinueWith(t => JsonConvert.DeserializeObject(t.Result)); + static bool IsSafeIdentifier(string s) + { + if (string.IsNullOrEmpty(s)) return false; + foreach (var c in s) + { + var ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-'; + if (!ok) return false; + } + return true; + } foreach (var asset in coins["depositMethods"].Children()) { if (asset.Value["enabled"].Value() is not true) @@ -248,6 +258,12 @@ public async Task> GetDepositOptions() var coinType = asset.Value["fixedOnly"].Value() ? CoinType.FixedOnly : asset.Value["variableOnly"].Value()? CoinType.VariableOnly : CoinType.Both; var network = asset.Value["network"].Value(); var cryptoCode = asset.Value["asset"].Value(); + // SideShift is untrusted and these ids are rendered into the checkout page's Vue + // expressions; skip any that aren't plain identifier characters. + if (!IsSafeIdentifier(id) || !IsSafeIdentifier(network) || !IsSafeIdentifier(cryptoCode)) + { + continue; + } result.Add(new SideshiftDepositCoin() { Id = id, From bb359a8ef277652de63bf8bda5d2ab887735544e Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Wed, 19 Aug 2026 11:27:54 +0200 Subject: [PATCH 24/33] fix(prism): persist payout reconciliation even when no new payout is created --- Plugins/BTCPayServer.Plugins.Prism/SatBreaker.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Plugins/BTCPayServer.Plugins.Prism/SatBreaker.cs b/Plugins/BTCPayServer.Plugins.Prism/SatBreaker.cs index c59c4950..0303cd63 100644 --- a/Plugins/BTCPayServer.Plugins.Prism/SatBreaker.cs +++ b/Plugins/BTCPayServer.Plugins.Prism/SatBreaker.cs @@ -274,6 +274,7 @@ private async Task CheckPayouts(CancellationToken cancellationToken) continue; } + var dirty = false; foreach (var payout in storePayouts) { @@ -347,9 +348,10 @@ private async Task CheckPayouts(CancellationToken cancellationToken) } prismSettings.PendingPayouts.Remove(payout.Id); + dirty = true; } - if (await CreatePayouts(storePayouts.Key, prismSettings, new())) + if (await CreatePayouts(storePayouts.Key, prismSettings, new()) || dirty) { await UpdatePrismSettingsForStore(storePayouts.Key, prismSettings, true); } From 9bb5a5bc65773c1e8bf0729d78b112b09e4c538a Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Wed, 19 Aug 2026 11:27:56 +0200 Subject: [PATCH 25/33] fix(bringin): authenticate callback code before entering edit mode --- Plugins/BTCPayServer.Plugins.Bringin/BringinController.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Plugins/BTCPayServer.Plugins.Bringin/BringinController.cs b/Plugins/BTCPayServer.Plugins.Bringin/BringinController.cs index d5032b2c..bda61e30 100644 --- a/Plugins/BTCPayServer.Plugins.Bringin/BringinController.cs +++ b/Plugins/BTCPayServer.Plugins.Bringin/BringinController.cs @@ -54,9 +54,13 @@ public async Task Edit() [IgnoreAntiforgeryToken] public async Task Callback(string storeId, string code, [FromBody]BringinVerificationUpdate content) { + // Authenticate the shared code against read-only settings BEFORE any state change: Update() + // takes the store edit lock, so validating first stops an anonymous caller from holding it. + var settings = await _bringinService.Get(storeId); + if (settings?.Code != code) return BadRequest(); + if (content.verificationStatus != "APPROVED") return BadRequest("Verification not approved"); + var vm = await _bringinService.Update(storeId); - if(vm.Code != code) return BadRequest(); - if(content.verificationStatus != "APPROVED") return BadRequest("Verification not approved"); if (string.IsNullOrEmpty(vm.ApiKey) && !string.IsNullOrEmpty(content.apikey)) { From 8c023faff91687ed4ec3426ba71e14f3c220748a Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Wed, 19 Aug 2026 11:27:57 +0200 Subject: [PATCH 26/33] fix(fileseller): release downloads only on settled invoice states --- .../BTCPayServer.Plugins.FileSeller.csproj | 2 +- Plugins/BTCPayServer.Plugins.FileSeller/FileSellerService.cs | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Plugins/BTCPayServer.Plugins.FileSeller/BTCPayServer.Plugins.FileSeller.csproj b/Plugins/BTCPayServer.Plugins.FileSeller/BTCPayServer.Plugins.FileSeller.csproj index a09a8fb5..1727b335 100644 --- a/Plugins/BTCPayServer.Plugins.FileSeller/BTCPayServer.Plugins.FileSeller.csproj +++ b/Plugins/BTCPayServer.Plugins.FileSeller/BTCPayServer.Plugins.FileSeller.csproj @@ -9,7 +9,7 @@ File Seller Allows you to sell files through the point of sale/crowdfund apps. - 1.0.9 + 1.0.10 true diff --git a/Plugins/BTCPayServer.Plugins.FileSeller/FileSellerService.cs b/Plugins/BTCPayServer.Plugins.FileSeller/FileSellerService.cs index b8d115ad..6680821d 100644 --- a/Plugins/BTCPayServer.Plugins.FileSeller/FileSellerService.cs +++ b/Plugins/BTCPayServer.Plugins.FileSeller/FileSellerService.cs @@ -48,8 +48,9 @@ protected override async Task ProcessEvent(object evt, CancellationToken cancell { if (evt is not InvoiceEvent invoiceEvent) return; List cartItems = null; - if (invoiceEvent.Name is not (InvoiceEvent.Completed or InvoiceEvent.MarkedCompleted - or InvoiceEvent.Confirmed)) + // Release downloads only on settled states, never on Confirmed (which fires before + // settlement and is reversible for 0-conf stores). + if (invoiceEvent.Name is not (InvoiceEvent.Completed or InvoiceEvent.MarkedCompleted)) { return; } From b2b5895ab8e0cd1cc89dcc6e489e7cd8abd4939f Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Wed, 19 Aug 2026 11:31:05 +0200 Subject: [PATCH 27/33] fix(prism): require caller role on target store for store-prism destination --- .../PrismController.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Plugins/BTCPayServer.Plugins.Prism/PrismController.cs b/Plugins/BTCPayServer.Plugins.Prism/PrismController.cs index b98b85bd..5d8e58a6 100644 --- a/Plugins/BTCPayServer.Plugins.Prism/PrismController.cs +++ b/Plugins/BTCPayServer.Plugins.Prism/PrismController.cs @@ -351,6 +351,23 @@ public async Task EditDestination(string storeId, EditDestination } } + // A store-prism destination writes invoices into the target store on every payout, so require + // the caller to actually hold a role on that store (the dropdown is filtered, but the POST + // accepts arbitrary values). + if (destination.StartsWith("store-prism:", StringComparison.InvariantCultureIgnoreCase)) + { + var parts = destination.Split(':', StringSplitOptions.RemoveEmptyEntries); + var targetStoreId = parts.Length > 1 ? parts[1] : null; + var currentUser = await _userManager.GetUserAsync(User); + var appUser = currentUser is null + ? null + : await _userManager.Users.Where(c => c.Id == currentUser.Id).Include(u => u.UserStores).SingleOrDefaultAsync(); + if (targetStoreId is null || appUser?.UserStores.Any(us => us.StoreDataId == targetStoreId) != true) + { + ModelState.AddModelError(nameof(vm.SelectedStoreId), "You do not have access to the selected store."); + } + } + var currentSettings = await _satBreaker.Get(storeId) ?? new PrismSettings(); currentSettings.Destinations ??= new Dictionary(); From 84e1aa876398b76c8acd63bf77a19169dd993ca3 Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Wed, 19 Aug 2026 11:47:03 +0200 Subject: [PATCH 28/33] fix(conference): validate antiforgery token on controller posts --- .../BTCPayServer.Plugins.Conference.csproj | 2 +- Plugins/BTCPayServer.Plugins.Conference/ConferenceController.cs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Plugins/BTCPayServer.Plugins.Conference/BTCPayServer.Plugins.Conference.csproj b/Plugins/BTCPayServer.Plugins.Conference/BTCPayServer.Plugins.Conference.csproj index a1511652..2142b414 100644 --- a/Plugins/BTCPayServer.Plugins.Conference/BTCPayServer.Plugins.Conference.csproj +++ b/Plugins/BTCPayServer.Plugins.Conference/BTCPayServer.Plugins.Conference.csproj @@ -9,7 +9,7 @@ Conference Manage conference merchant stores, POS terminals, and sales reporting from a single app - 1.2.0 + 1.2.1 true diff --git a/Plugins/BTCPayServer.Plugins.Conference/ConferenceController.cs b/Plugins/BTCPayServer.Plugins.Conference/ConferenceController.cs index e992509d..1f1e47c5 100644 --- a/Plugins/BTCPayServer.Plugins.Conference/ConferenceController.cs +++ b/Plugins/BTCPayServer.Plugins.Conference/ConferenceController.cs @@ -23,6 +23,7 @@ namespace BTCPayServer.Plugins.Conference; [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] +[AutoValidateAntiforgeryToken] public class ConferenceController : Controller { private readonly AppService _appService; From 88f5d20ed8d5e849eb480774cab7978b291b3309 Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Wed, 19 Aug 2026 11:47:04 +0200 Subject: [PATCH 29/33] fix(bitcoinswitch): isolate per-socket sends with a timeout --- .../BTCPayServer.Plugins.BitcoinSwitch.csproj | 2 +- .../BitcoinSwitchService.cs | 21 ++++++++++++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/Plugins/BTCPayServer.Plugins.BitcoinSwitch/BTCPayServer.Plugins.BitcoinSwitch.csproj b/Plugins/BTCPayServer.Plugins.BitcoinSwitch/BTCPayServer.Plugins.BitcoinSwitch.csproj index 37eedf81..233371c3 100644 --- a/Plugins/BTCPayServer.Plugins.BitcoinSwitch/BTCPayServer.Plugins.BitcoinSwitch.csproj +++ b/Plugins/BTCPayServer.Plugins.BitcoinSwitch/BTCPayServer.Plugins.BitcoinSwitch.csproj @@ -9,7 +9,7 @@ Bitcoin Switch Control harwdare using the POS as a switch - 1.0.3 + 1.0.4 true diff --git a/Plugins/BTCPayServer.Plugins.BitcoinSwitch/BitcoinSwitchService.cs b/Plugins/BTCPayServer.Plugins.BitcoinSwitch/BitcoinSwitchService.cs index 6eb11a29..63d7798f 100644 --- a/Plugins/BTCPayServer.Plugins.BitcoinSwitch/BitcoinSwitchService.cs +++ b/Plugins/BTCPayServer.Plugins.BitcoinSwitch/BitcoinSwitchService.cs @@ -174,11 +174,22 @@ private async Task HandleGPIOMessages(CancellationToken cancellationToken, Bitco return; foreach (var socket in sockets) { - await socket.SendAsync( - new ArraySegment(buffer), - WebSocketMessageType.Text, - true, - cancellationToken); + // Isolate each socket with its own timeout so one stalled or faulted + // listener can't block or abort delivery to the real device. + try + { + using var sendCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + sendCts.CancelAfter(TimeSpan.FromSeconds(5)); + await socket.SendAsync( + new ArraySegment(buffer), + WebSocketMessageType.Text, + true, + sendCts.Token); + } + catch (Exception ex) + { + Logs.PayServer.LogWarning(ex, "Failed to send BitcoinSwitch command to a socket; continuing"); + } } } From b7d0330ab3f10652c440a462958234ed4f9d4348 Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Wed, 19 Aug 2026 11:47:05 +0200 Subject: [PATCH 30/33] fix(bringin): reject non-positive exchange rate --- Plugins/BTCPayServer.Plugins.Bringin/BringinService.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Plugins/BTCPayServer.Plugins.Bringin/BringinService.cs b/Plugins/BTCPayServer.Plugins.Bringin/BringinService.cs index dee17505..ef993dfa 100644 --- a/Plugins/BTCPayServer.Plugins.Bringin/BringinService.cs +++ b/Plugins/BTCPayServer.Plugins.Bringin/BringinService.cs @@ -223,6 +223,8 @@ private async Task CheckIfNewPayoutsNeeded(string storeId, BringinStoreSet if (methodSetting.Value.FiatThreshold) { var rate = await bringinClient.GetRate(); + if (rate.BringinPrice <= 0) + throw new Exception($"Bringin returned an invalid exchange rate ({rate.BringinPrice})."); thresholdAmount = methodSetting.Value.Threshold / rate.BringinPrice; } @@ -279,6 +281,8 @@ await _storeRepository.UpdateSetting(storeId, BringinStoreSettings.BringinSettin { var rate = await bringinClient.GetRate(); + if (rate.BringinPrice <= 0) + throw new Exception($"Bringin returned an invalid exchange rate ({rate.BringinPrice})."); var thresholdAmount = supportedMethod.FiatMinimumAmount / rate.BringinPrice; if (amountBtc.ToDecimal(MoneyUnit.BTC) <= thresholdAmount) { From 20b82dd099231a85957ba0c426bf0aa07df9a71c Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Wed, 19 Aug 2026 11:55:01 +0200 Subject: [PATCH 31/33] fix(prism): pin opensats checkout link to prevent server-side request forgery --- .../OpenSatsDestinationValidator.cs | 13 ++++++++++++- .../OpenSatsPrismClaimCreate.cs | 12 +++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/Plugins/BTCPayServer.Plugins.Prism/OpenSatsDestinationValidator.cs b/Plugins/BTCPayServer.Plugins.Prism/OpenSatsDestinationValidator.cs index 559fb13d..661edcb1 100644 --- a/Plugins/BTCPayServer.Plugins.Prism/OpenSatsDestinationValidator.cs +++ b/Plugins/BTCPayServer.Plugins.Prism/OpenSatsDestinationValidator.cs @@ -59,7 +59,18 @@ public async Task Execute(object args) var xResult = await httpClient.PostAsync("https://opensats.org/api/btcpay",content).ConfigureAwait(false); var rawInvoice = JObject.Parse(await xResult.Content.ReadAsStringAsync().ConfigureAwait(false)); - var invoiceUrl = $"{rawInvoice.Value("checkoutLink").TrimEnd('/')}/{paymentMethod}/status"; + // checkoutLink comes from the third-party response; pin it to opensats.org over HTTPS so a + // hostile response can't turn this into a server-side request against an arbitrary/internal URL. + var checkoutLink = rawInvoice.Value("checkoutLink"); + if (!Uri.TryCreate(checkoutLink, UriKind.Absolute, out var checkoutUri) || + checkoutUri.Scheme != Uri.UriSchemeHttps || + !(checkoutUri.Host.Equals("opensats.org", StringComparison.OrdinalIgnoreCase) || + checkoutUri.Host.EndsWith(".opensats.org", StringComparison.OrdinalIgnoreCase))) + { + result.Success = false; + return result; + } + var invoiceUrl = $"{checkoutLink.TrimEnd('/')}/{paymentMethod}/status"; var invoiceBtcpayModel = JObject.Parse(await httpClient.GetStringAsync(invoiceUrl).ConfigureAwait(false)); var destination = invoiceBtcpayModel.Value("btcAddress"); diff --git a/Plugins/BTCPayServer.Plugins.Prism/OpenSatsPrismClaimCreate.cs b/Plugins/BTCPayServer.Plugins.Prism/OpenSatsPrismClaimCreate.cs index 9ed5c706..4a99935d 100644 --- a/Plugins/BTCPayServer.Plugins.Prism/OpenSatsPrismClaimCreate.cs +++ b/Plugins/BTCPayServer.Plugins.Prism/OpenSatsPrismClaimCreate.cs @@ -64,7 +64,17 @@ public async Task Execute(object args) var result = await httpClient.PostAsync("https://opensats.org/api/btcpay",content).ConfigureAwait(false); var rawInvoice = JObject.Parse(await result.Content.ReadAsStringAsync().ConfigureAwait(false)); - var invoiceUrl = $"{rawInvoice.Value("checkoutLink").TrimEnd('/')}/{paymentMethod}/status"; + // checkoutLink comes from the third-party response; pin it to opensats.org over HTTPS so a + // hostile response can't turn this into a server-side request against an arbitrary/internal URL. + var checkoutLink = rawInvoice.Value("checkoutLink"); + if (!Uri.TryCreate(checkoutLink, UriKind.Absolute, out var checkoutUri) || + checkoutUri.Scheme != Uri.UriSchemeHttps || + !(checkoutUri.Host.Equals("opensats.org", StringComparison.OrdinalIgnoreCase) || + checkoutUri.Host.EndsWith(".opensats.org", StringComparison.OrdinalIgnoreCase))) + { + return null; + } + var invoiceUrl = $"{checkoutLink.TrimEnd('/')}/{paymentMethod}/status"; var invoiceBtcpayModel = JObject.Parse(await httpClient.GetStringAsync(invoiceUrl).ConfigureAwait(false)); var destination = invoiceBtcpayModel.Value("btcAddress"); var receiptLink = invoiceBtcpayModel.Value("receiptLink"); From d5177d7488b876eb296777060799547d2fd3ee5f Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Wed, 19 Aug 2026 13:10:08 +0200 Subject: [PATCH 32/33] chore(ci): populate plugin builder slugs; deprecate Subscriptions Wabisabi->coinjoin. MicroNode and SwapMiddleware left slug-less (no builder entry). --- plugin-builder.json | 48 ++++++++++++++++++++++----------------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/plugin-builder.json b/plugin-builder.json index e2da1246..5347c392 100644 --- a/plugin-builder.json +++ b/plugin-builder.json @@ -1,28 +1,28 @@ { - "BTCPayServer.Plugins.BitcoinSwitch": { "slug": null, "deprecated": false }, - "BTCPayServer.Plugins.BitcoinWhitepaper": { "slug": null, "deprecated": false }, - "BTCPayServer.Plugins.Blink": { "slug": null, "deprecated": false }, - "BTCPayServer.Plugins.Breez": { "slug": null, "deprecated": true }, - "BTCPayServer.Plugins.Bringin": { "slug": null, "deprecated": false }, - "BTCPayServer.Plugins.Conference": { "slug": null, "deprecated": false }, - "BTCPayServer.Plugins.DataErasure": { "slug": null, "deprecated": false }, - "BTCPayServer.Plugins.DynamicRateLimits": { "slug": null, "deprecated": false }, - "BTCPayServer.Plugins.DynamicReports": { "slug": null, "deprecated": false }, - "BTCPayServer.Plugins.Electrum": { "slug": null, "deprecated": false }, - "BTCPayServer.Plugins.FileSeller": { "slug": null, "deprecated": false }, - "BTCPayServer.Plugins.FixedFloat": { "slug": null, "deprecated": false }, - "BTCPayServer.Plugins.LNURLVerify": { "slug": null, "deprecated": false }, - "BTCPayServer.Plugins.LiquidPlus": { "slug": null, "deprecated": true }, - "BTCPayServer.Plugins.MCP": { "slug": null, "deprecated": false }, + "BTCPayServer.Plugins.BitcoinSwitch": { "slug": "bitcoinswitch", "deprecated": false }, + "BTCPayServer.Plugins.BitcoinWhitepaper": { "slug": "bitcoin-whitepaper", "deprecated": false }, + "BTCPayServer.Plugins.Blink": { "slug": "blink", "deprecated": false }, + "BTCPayServer.Plugins.Breez": { "slug": "breez", "deprecated": true }, + "BTCPayServer.Plugins.Bringin": { "slug": "bringin", "deprecated": false }, + "BTCPayServer.Plugins.Conference": { "slug": "conferences", "deprecated": false }, + "BTCPayServer.Plugins.DataErasure": { "slug": "data-erasure", "deprecated": false }, + "BTCPayServer.Plugins.DynamicRateLimits": { "slug": "dynamic-rate-limiter", "deprecated": false }, + "BTCPayServer.Plugins.DynamicReports": { "slug": "dynamic-reports", "deprecated": false }, + "BTCPayServer.Plugins.Electrum": { "slug": "electrum", "deprecated": false }, + "BTCPayServer.Plugins.FileSeller": { "slug": "file-seller", "deprecated": false }, + "BTCPayServer.Plugins.FixedFloat": { "slug": "fixed-float", "deprecated": false }, + "BTCPayServer.Plugins.LNURLVerify": { "slug": "lnurl", "deprecated": false }, + "BTCPayServer.Plugins.LiquidPlus": { "slug": "liquid-plus", "deprecated": true }, + "BTCPayServer.Plugins.MCP": { "slug": "mcp-server", "deprecated": false }, "BTCPayServer.Plugins.MicroNode": { "slug": null, "deprecated": false }, - "BTCPayServer.Plugins.NIP05": { "slug": null, "deprecated": false, "tagPrefix": "Nostr" }, - "BTCPayServer.Plugins.Prism": { "slug": null, "deprecated": false }, - "BTCPayServer.Plugins.ServerMessage": { "slug": null, "deprecated": false }, - "BTCPayServer.Plugins.SideShift": { "slug": null, "deprecated": false }, - "BTCPayServer.Plugins.Stripe": { "slug": null, "deprecated": false }, - "BTCPayServer.Plugins.Subscriptions": { "slug": null, "deprecated": false }, + "BTCPayServer.Plugins.NIP05": { "slug": "nip5", "deprecated": false, "tagPrefix": "Nostr" }, + "BTCPayServer.Plugins.Prism": { "slug": "prism", "deprecated": false }, + "BTCPayServer.Plugins.ServerMessage": { "slug": "server-message", "deprecated": false }, + "BTCPayServer.Plugins.SideShift": { "slug": "sideshift", "deprecated": false }, + "BTCPayServer.Plugins.Stripe": { "slug": "stripe-payments", "deprecated": false }, + "BTCPayServer.Plugins.Subscriptions": { "slug": "subscriptions", "deprecated": true }, "BTCPayServer.Plugins.SwapMiddleware": { "slug": null, "deprecated": false }, - "BTCPayServer.Plugins.Terminal": { "slug": null, "deprecated": false }, - "BTCPayServer.Plugins.TicketTailor": { "slug": null, "deprecated": false }, - "BTCPayServer.Plugins.Wabisabi": { "slug": null, "deprecated": true } + "BTCPayServer.Plugins.Terminal": { "slug": "terminal", "deprecated": false }, + "BTCPayServer.Plugins.TicketTailor": { "slug": "ticket-tailor", "deprecated": false }, + "BTCPayServer.Plugins.Wabisabi": { "slug": "coinjoin", "deprecated": true } } From 07fd1ee2df7bd3382cc8cb280ccbe859fba5590b Mon Sep 17 00:00:00 2001 From: "Andrew Camilleri (Kukks)" Date: Wed, 19 Aug 2026 14:15:09 +0200 Subject: [PATCH 33/33] fix(tickettailor): bound the ticket issuance loop Stop issuing once the hold stops shrinking, on the first error, or once the original hold count is reached. Bump to 2.0.10, require BTCPay 2.4.2. --- .../BTCPayServer.Plugins.TicketTailor.csproj | 2 +- .../TicketTailorPlugin.cs | 2 +- .../TicketTailorWebhookProvider.cs | 7 +++++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/Plugins/BTCPayServer.Plugins.TicketTailor/BTCPayServer.Plugins.TicketTailor.csproj b/Plugins/BTCPayServer.Plugins.TicketTailor/BTCPayServer.Plugins.TicketTailor.csproj index 50563fb9..21f396e8 100644 --- a/Plugins/BTCPayServer.Plugins.TicketTailor/BTCPayServer.Plugins.TicketTailor.csproj +++ b/Plugins/BTCPayServer.Plugins.TicketTailor/BTCPayServer.Plugins.TicketTailor.csproj @@ -9,7 +9,7 @@ TicketTailor Allows you to integrate with TicketTailor.com to sell tickets for Bitcoin - 2.0.9 + 2.0.10 true diff --git a/Plugins/BTCPayServer.Plugins.TicketTailor/TicketTailorPlugin.cs b/Plugins/BTCPayServer.Plugins.TicketTailor/TicketTailorPlugin.cs index 086dc517..5ce6798d 100644 --- a/Plugins/BTCPayServer.Plugins.TicketTailor/TicketTailorPlugin.cs +++ b/Plugins/BTCPayServer.Plugins.TicketTailor/TicketTailorPlugin.cs @@ -14,7 +14,7 @@ public class TicketTailorPlugin : BaseBTCPayServerPlugin { public override IBTCPayServerPlugin.PluginDependency[] Dependencies { get; } = { - new() {Identifier = nameof(BTCPayServer), Condition = ">=2.4.1"} + new() {Identifier = nameof(BTCPayServer), Condition = ">=2.4.2"} }; public override void Execute(IServiceCollection applicationBuilder) diff --git a/Plugins/BTCPayServer.Plugins.TicketTailor/TicketTailorWebhookProvider.cs b/Plugins/BTCPayServer.Plugins.TicketTailor/TicketTailorWebhookProvider.cs index a2f81e19..6c2f455b 100644 --- a/Plugins/BTCPayServer.Plugins.TicketTailor/TicketTailorWebhookProvider.cs +++ b/Plugins/BTCPayServer.Plugins.TicketTailor/TicketTailorWebhookProvider.cs @@ -154,6 +154,7 @@ await HandleIssueTicketError( InvoiceEventData.EventSeverity.Info); while (hold?.TotalOnHold > 0) { + var remainingBefore = hold.TotalOnHold; foreach (var tt in hold.Quantities.Where(quantity => quantity.Quantity > 0)) { @@ -179,7 +180,13 @@ await HandleIssueTicketError( } hold = await client.GetHold(holdId); + if (hold is null || ticketResult.error is not null) + break; } + + if (hold is null || errors.Count > 0 || tickets.Count >= holdOriginalAmount || + hold.TotalOnHold >= remainingBefore) + break; }