What changed, and why it matters
This commit is a routine test and UI cleanup. It reworks how old payment-request data is migrated into newer database columns, adds null checks so missing fields don't crash, and changes a table header from 'Reference Id' to 'Id'. There is no direct evidence of an exploitable security bug in the diff.
Treat as a normal maintenance/test commit. If deploying, run payment-request migration tests on a copy of production data to confirm currency/amount/title values migrate correctly and no data loss occurs. No urgent security action is indicated by the diff alone.
Security signals we found
Data migration logic changed, which could affect stored payment-request integrity if migration conditions are wrong
Added null checks before reading 'currency' and 'amount' JSON keys, reducing risk of migration-time exceptions
No input validation, authentication, authorization, cryptography, or output-encoding changes observed
No vendor security disclosure or advisory referenced in commit
Evidence from the diff
The main code change refactors PaymentRequestData.TryMigrate(). The old logic only ran migration when Blob was non-empty or Blob2 existed without Currency. The new logic always parses Blob2, migrates expiryDate, and conditionally migrates currency/amount only when those JSON keys exist, removing them from Blob2 afterwards. It also always returns true and runs title migration separately. Test helpers were renamed (ContinueMigration -> CompleteMigrations) and assertions updated to expect title to remain in Blob2. A Razor view label was changed from ‘Reference Id’ to ‘Id’.
Changed components
BTCPayServer.Data/Data/PaymentRequestData.Migration.csBTCPayServer/Views/UIPaymentRequest/GetPaymentRequests.cshtmlBTCPayServer.Tests/DatabaseTester.csBTCPayServer.Tests/DatabaseTests.csBTCPayServer.Tests/UnitTest1.csInspect captured patch +53 / −47
diff --git a/BTCPayServer.Data/Data/PaymentRequestData.Migration.cs b/BTCPayServer.Data/Data/PaymentRequestData.Migration.cs
index 35e03f4..b43d37f 100644
--- a/BTCPayServer.Data/Data/PaymentRequestData.Migration.cs
+++ b/BTCPayServer.Data/Data/PaymentRequestData.Migration.cs
@@ -17,55 +17,59 @@ namespace BTCPayServer.Data
public bool TryMigrate()
{
- var migrated = false;
#pragma warning disable CS0618 // Type or member is obsolete
- if (Blob is not (null or { Length: 0 }) || (Blob2 is not null && Currency is null))
+ if (Blob is (null or { Length: 0 }) && Blob2 is not null && Currency is not null && Title is not null)
+ return false;
+ if (Blob2 is null)
{
- if (Blob2 is null)
- {
- Blob2 = Blob is not (null or { Length: 0 }) ? MigrationExtensions.Unzip(Blob) : "{}";
- Blob2 = MigrationExtensions.SanitizeJSON(Blob2);
- }
- Blob = null;
+ Blob2 = Blob is not (null or { Length: 0 }) ? MigrationExtensions.Unzip(Blob) : "{}";
+ Blob2 = MigrationExtensions.SanitizeJSON(Blob2);
+ }
+ Blob = null;
#pragma warning restore CS0618 // Type or member is obsolete
- var jobj = JObject.Parse(Blob2);
- // Fixup some legacy payment requests
- if (jobj["expiryDate"]?.Type == JTokenType.Date)
- {
- var date = NBitcoin.Utils.UnixTimeToDateTime(NBitcoin.Utils.DateTimeToUnixTime(jobj["expiryDate"].Value<DateTime>()));
- jobj.Remove("expiryDate");
- Expiry = date;
- }
- else if (jobj["expiryDate"]?.Type == JTokenType.Integer)
- {
- var date = NBitcoin.Utils.UnixTimeToDateTime(jobj["expiryDate"].Value<long>());
- jobj.Remove("expiryDate");
- Expiry = date;
- }
- Currency = jobj["currency"].Value<string>();
- Amount = jobj["amount"] switch
- {
- JValue jv when jv.Type == JTokenType.Float => jv.Value<decimal>(),
- JValue jv when jv.Type == JTokenType.Integer => jv.Value<long>(),
- JValue jv when jv.Type == JTokenType.String && decimal.TryParse(jv.Value<string>(), CultureInfo.InvariantCulture, out var d) => d,
- _ => 0m
- };
- Blob2 = jobj.ToString(Newtonsoft.Json.Formatting.None);
- migrated = true;
+ var jobj = JObject.Parse(Blob2);
+ // Fixup some legacy payment requests
+ if (jobj["expiryDate"]?.Type == JTokenType.Date)
+ {
+ var date = NBitcoin.Utils.UnixTimeToDateTime(NBitcoin.Utils.DateTimeToUnixTime(jobj["expiryDate"].Value<DateTime>()));
+ jobj.Remove("expiryDate");
+ Expiry = date;
+ }
+ else if (jobj["expiryDate"]?.Type == JTokenType.Integer)
+ {
+ var date = NBitcoin.Utils.UnixTimeToDateTime(jobj["expiryDate"].Value<long>());
+ jobj.Remove("expiryDate");
+ Expiry = date;
}
+ if (jobj["currency"] is not null)
+ {
+ Currency = jobj["currency"].Value<string>();
+ jobj.Remove("currency");
+ }
+ if (jobj["amount"] is not null)
+ {
+ Amount = jobj["amount"] switch
+ {
+ JValue jv when jv.Type == JTokenType.Float => jv.Value<decimal>(),
+ JValue jv when jv.Type == JTokenType.Integer => jv.Value<long>(),
+ JValue jv when jv.Type == JTokenType.String && decimal.TryParse(jv.Value<string>(), CultureInfo.InvariantCulture, out var d) => d,
+ _ => 0m
+ };
+ jobj.Remove("amount");
+ }
+ Blob2 = jobj.ToString(Newtonsoft.Json.Formatting.None);
// Run Title migration separately (only if Title column exists)
try
{
- if (TryMigrateTitle())
- migrated = true;
+ TryMigrateTitle();
}
catch
{
// Title column doesn't exist yet - will be migrated later
}
- return migrated;
+ return true;
}
public bool TryMigrateTitle()
diff --git a/BTCPayServer.Tests/DatabaseTester.cs b/BTCPayServer.Tests/DatabaseTester.cs
index aa436db..6f5605e 100644
--- a/BTCPayServer.Tests/DatabaseTester.cs
+++ b/BTCPayServer.Tests/DatabaseTester.cs
@@ -87,10 +87,10 @@ namespace BTCPayServer.Tests
await ctx.Database.MigrateAsync();
}
- public async Task ContinueMigration()
+ public async Task CompleteMigrations()
{
if (notAppliedMigrations is null)
- throw new InvalidOperationException("Call MigrateUpTo first");
+ throw new InvalidOperationException("Call MigrateUntil first");
using var ctx = CreateContext();
var db = ctx.Database.GetDbConnection();
await db.ExecuteAsync("DELETE FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = ANY (@migrations)", new { migrations = notAppliedMigrations });
diff --git a/BTCPayServer.Tests/DatabaseTests.cs b/BTCPayServer.Tests/DatabaseTests.cs
index be0ff66..0d38fdd 100644
--- a/BTCPayServer.Tests/DatabaseTests.cs
+++ b/BTCPayServer.Tests/DatabaseTests.cs
@@ -154,7 +154,7 @@ namespace BTCPayServer.Tests
await conn.ExecuteAsync("INSERT INTO \"Invoices\" (\"Id\", \"Created\") VALUES ('i', NOW())");
await conn.ExecuteAsync(
"INSERT INTO \"AddressInvoices\" VALUES ('aaa#BTC', 'i'),('bbb','i'),('ccc#BTC_LNU', 'i'),('ddd#XMR_MoneroLike', 'i'),('eee#ZEC_ZcashLike', 'i')");
- await tester.ContinueMigration();
+ await tester.CompleteMigrations();
foreach (var v in new[] { ("aaa", "BTC-CHAIN"), ("bbb", "BTC-CHAIN"), ("ddd", "XMR-CHAIN") , ("eee", "ZEC-CHAIN") })
{
var ok = await conn.ExecuteScalarAsync<bool>("SELECT 't'::BOOLEAN FROM \"AddressInvoices\" WHERE \"Address\"=@a AND \"PaymentMethodId\"=@b", new { a = v.Item1, b = v.Item2 });
@@ -216,7 +216,7 @@ namespace BTCPayServer.Tests
}
};
await conn.ExecuteAsync("INSERT INTO \"Payouts\"(\"Id\", \"StoreDataId\", \"PullPaymentDataId\", \"PaymentMethodId\", \"Blob\", \"State\", \"Date\") VALUES (@Id, @StoreId, @PullPaymentDataId, @PaymentMethodId, @Blob::JSONB, 'state', NOW())", parameters);
- await tester.ContinueMigration();
+ await tester.CompleteMigrations();
var migrated = await conn.ExecuteScalarAsync<bool>("SELECT 't'::BOOLEAN FROM \"PullPayments\" WHERE \"Id\"='pp1' AND \"Limit\"=10.0 AND \"Currency\"='GBP' AND \"Blob\"->>'SupportedPayoutMethods'='[\"BTC-CHAIN\", \"BTC-LN\"]'");
Assert.True(migrated);
diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs
index 4f42953..6b6c3b5 100644
--- a/BTCPayServer.Tests/UnitTest1.cs
+++ b/BTCPayServer.Tests/UnitTest1.cs
@@ -3115,7 +3115,7 @@ namespace BTCPayServer.Tests
[Fact(Timeout = LongRunningTestTimeout)]
[Trait("Integration", "Integration")]
- public async Task CanMigratePaymentRequests()
+ public async Task CanMigratePaymentRequestsAmountCurrency()
{
var tester = CreateDBTester();
await tester.MigrateUntil("20250407133937_pr_expiry");
@@ -3160,7 +3160,7 @@ namespace BTCPayServer.Tests
);
""");
- await tester.ContinueMigration();
+ await tester.CompleteMigrations();
// The blob isn't cleaned yet, this is the migrator who does that
await conn.QuerySingleAsync("""
SELECT * FROM "PaymentRequests"
@@ -3181,6 +3181,8 @@ namespace BTCPayServer.Tests
AND "Status" = 'Processing'
""");
+
+
var pr = ctx.PaymentRequests.First(r => r.Id == "03463aab-844e-4d60-872f-26310b856131");
Assert.Equal("USD", pr.Currency);
Assert.Equal(0.001m, pr.Amount);
@@ -3204,8 +3206,8 @@ namespace BTCPayServer.Tests
""")).Blob2;
var expectedBlob2 = new JObject()
{
- ["email"] = "f.f@gmail.com",
- ["title"] = "Online"
+ ["email"] = "f.f@gmail.com"
+ //,["title"] = "Online"
};
Assert.Equal(JObject.Parse(actualBlob2), expectedBlob2);
@@ -3216,8 +3218,8 @@ namespace BTCPayServer.Tests
""")).Blob2;
expectedBlob2 = new JObject()
{
- ["email"] = "f.f@gmail.com",
- ["title"] = "Online"
+ ["email"] = "f.f@gmail.com"
+ //,["title"] = "Online"
};
Assert.Equal(JObject.Parse(actualBlob2), expectedBlob2);
@@ -3264,7 +3266,7 @@ namespace BTCPayServer.Tests
);
""");
- await tester.ContinueMigration();
+ await tester.CompleteMigrations();
// The Title column exists but isn't populated yet - it's still in Blob2
// This simulates a payment request that went through the Currency/Amount migration
diff --git a/BTCPayServer/Views/UIPaymentRequest/GetPaymentRequests.cshtml b/BTCPayServer/Views/UIPaymentRequest/GetPaymentRequests.cshtml
index f15390b..d0cf1df 100644
--- a/BTCPayServer/Views/UIPaymentRequest/GetPaymentRequests.cshtml
+++ b/BTCPayServer/Views/UIPaymentRequest/GetPaymentRequests.cshtml
@@ -302,7 +302,7 @@
</th>
<th text-translate="true">Title</th>
<th style="width: 20px;"></th>
- <th text-translate="true">Reference Id</th>
+ <th text-translate="true">Id</th>
<th text-translate="true">Labels</th>
<th text-translate="true">Status</th>
<th class="amount-col" text-translate="true">Amount</th>
Why this scored 16/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.