Add BIP-329 wallet label import (#7457)
What changed, and why it matters
This commit adds a feature that lets BTCPay Server users import wallet labels from a BIP-329 file. The code parses uploaded JSONL files, validates entries, and attaches labels to transactions, addresses, or transaction outputs. The change is a normal feature addition; there is no direct evidence in the commit that it fixes a security vulnerability, but it does introduce a new file-upload and parsing path that should be reviewed for robustness.
Treat as a feature commit, not a security patch. As a defensive review, verify that the 1 MB upload limit and per-line parsing are sufficient for DoS protection, confirm that the address network check prevents cross-network label injection, and ensure the new endpoint is covered by the existing CanManageWalletTransactions authorization policy. No urgent security action is indicated by the diff alone.
Security signals we found
New file-upload endpoint accepting .jsonl, .json, and .txt extensions
File size capped at 1 MB
JSON parsing of user-supplied input with per-line try/catch
Address validation against the wallet's NBitcoin network before persistence
Refactored label repository method to batch and deduplicate labels
Evidence from the diff
The commit introduces Bip329Import.Parse(), a new UIWalletsController.ImportWalletLabels() POST action, and UI forms to upload .jsonl/.json/.txt label files. It validates file size (1 MB max), parses one JSON object per line, skips malformed/unsupported lines, deduplicates entries, and calls WalletRepository.AddWalletObjectLabels() to persist labels. The parser only accepts type values tx, addr, and output, validates txids as uint256, addresses against the wallet’s NBitcoin network, and outputs as OutPoint. WalletRepository.AddWalletObjectLabels() was refactored to batch label creation and avoid duplicate labels per object.
Changed components
BTCPayServer/Plugins/Wallets/Controllers/UIWalletsController.csBTCPayServer/Plugins/Wallets/Views/UIWallets/WalletLabels.cshtmlBTCPayServer/Plugins/Wallets/Views/UIWallets/WalletTransactions.cshtmlBTCPayServer/Services/WalletRepository.csBTCPayServer/Services/Wallets/Import/Bip329Import.csInspect captured patch +224 / −8
diff --git a/BTCPayServer.Tests/FastTests.cs b/BTCPayServer.Tests/FastTests.cs
index b6086d3..8664943 100644
--- a/BTCPayServer.Tests/FastTests.cs
+++ b/BTCPayServer.Tests/FastTests.cs
@@ -29,6 +29,7 @@ using BTCPayServer.Services.Fees;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Wallets;
+using BTCPayServer.Services.Wallets.Import;
using BTCPayServer.Validation;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Configuration.Memory;
@@ -226,6 +227,39 @@ namespace BTCPayServer.Tests
Assert.False(blob.ReceiptOptions.Enabled);
}
+ [Fact]
+ public async Task CanParseBip329LabelsImport()
+ {
+ var network = Network.RegTest;
+ var txId = "aecb52b892f5e12454b3ee1ad554ffe28c1cca35ffdfaa441c74a30cf7a279f0";
+ var address = new Key().PubKey.GetAddress(ScriptPubKeyType.Segwit, network).ToString();
+ var mainnetAddress = new Key().PubKey.GetAddress(ScriptPubKeyType.Segwit, Network.Main).ToString();
+ var input =
+ $"{{\"type\":\"tx\",\"ref\":\"{txId.ToUpperInvariant()}\",\"label\":\" fee reimbursement \"}}\n" +
+ $"{{\"type\":\"addr\",\"ref\":\"{address}\",\"label\":\"donations\"}}\n" +
+ $"{{\"type\":\"output\",\"ref\":\"{txId}:1\",\"label\":\"change\"}}\n" +
+ "\n" +
+ $"{{\"type\":\"tx\",\"ref\":\"{txId.ToUpperInvariant()}\",\"label\":\" fee reimbursement \"}}\n" +
+ $"{{\"type\":\"addr\",\"ref\":\"{mainnetAddress}\",\"label\":\"wrong network\"}}\n" +
+ $"{{\"type\":\"xpub\",\"ref\":\"xpub-ref\",\"label\":\"unsupported type\"}}\n" +
+ $"{{\"type\":\"tx\",\"ref\":\"not-a-txid\",\"label\":\"bad ref\"}}\n" +
+ $"{{\"type\":\"tx\",\"ref\":\"{txId}\",\"label\":\"\"}}\n" +
+ $"{{\"type\":\"tx\",\"ref\":\"{txId}\"}}\n" +
+ $"{{\"type\":\"tx\",\"ref\":\"{txId}\",\"label\":{{}}}}\n" +
+ $"{{\"type\":\"tx\",\"ref\":[1],\"label\":\"array ref\"}}\n" +
+ $"{{\"type\":5,\"ref\":\"{txId}\",\"label\":\"numeric type\"}}\n" +
+ "not json\n";
+
+ var result = await Bip329Import.Parse(new StringReader(input), network);
+
+ Assert.Equal(3, result.Labels.Count);
+ // duplicate line deduped, invalid lines skipped, blank line ignored
+ Assert.Equal(9, result.SkippedLines);
+ Assert.Contains(result.Labels, l => l is { ObjectType: WalletObjectData.Types.Tx, Label: "fee reimbursement" } && l.ObjectId == txId);
+ Assert.Contains(result.Labels, l => l is { ObjectType: WalletObjectData.Types.Address, Label: "donations" } && l.ObjectId == address);
+ Assert.Contains(result.Labels, l => l is { ObjectType: WalletObjectData.Types.Utxo, Label: "change" } && l.ObjectId == OutPoint.Parse($"{txId}-1").ToString());
+ }
+
[Fact]
public void CanParsePaymentMethodId()
{
diff --git a/BTCPayServer.Tests/WalletTests.cs b/BTCPayServer.Tests/WalletTests.cs
index a57aecb..36ed976 100644
--- a/BTCPayServer.Tests/WalletTests.cs
+++ b/BTCPayServer.Tests/WalletTests.cs
@@ -778,11 +778,38 @@ public class WalletTests(ITestOutputHelper helper) : UnitTestBase(helper)
});
await s.InWalletTransactions().AssertHasLabels(targetLabel);
+
+ // Import BIP-329 labels from the transactions page toolbar (#6663)
+ var walletId = new WalletId(s.StoreId, "BTC");
+ const string toolbarLabel = "bip329-toolbar";
+ var toolbarTxId = transactions[1].TransactionHash.ToString();
+ var toolbarFile = Path.Combine(Path.GetTempPath(), $"bip329-{Guid.NewGuid():N}.jsonl");
+ await File.WriteAllTextAsync(toolbarFile, $"{{\"type\":\"tx\",\"ref\":\"{toolbarTxId}\",\"label\":\"{toolbarLabel}\"}}\n");
+ await s.Page.SetInputFilesAsync("#Dropdowns .wallet-labels__import-file", toolbarFile);
+ await s.Page.WaitForURLAsync(s.ServerUri + $"wallets/{walletId}/labels");
+ await s.FindAlertMessage(partialText: "Imported 1 label(s).");
+ var toolbarTx = await client.GetOnChainWalletTransaction(s.StoreId, "BTC", toolbarTxId);
+ Assert.Contains(toolbarLabel, toolbarTx.Labels.Keys);
+
+ await s.GoToWalletTransactions(s.WalletId);
// The label dropdown exposes a "Manage Labels" link that navigates to the wallet labels page (#7252)
await s.SearchFilters.LabelSelectorToggle.ClickAsync();
await s.Page.ClickAsync("#LabelSelectorMenu a:has-text('Manage Labels')");
- var walletId = new WalletId(s.StoreId , "BTC");
await s.Page.WaitForURLAsync(s.ServerUri + $"wallets/{walletId}/labels");
+
+ // Import BIP-329 labels from the wallet labels page (#6663)
+ const string importedLabel = "bip329-imported";
+ var importedTxId = transactions[0].TransactionHash.ToString();
+ var importFile = Path.Combine(Path.GetTempPath(), $"bip329-{Guid.NewGuid():N}.jsonl");
+ await File.WriteAllTextAsync(importFile,
+ $"{{\"type\":\"tx\",\"ref\":\"{importedTxId}\",\"label\":\"{importedLabel}\"}}\n" +
+ "not json\n");
+ await s.Page.SetInputFilesAsync(".wallet-labels__import-file", importFile);
+ await s.Page.ClickAsync(".wallet-labels__import-button");
+ await s.FindAlertMessage(partialText: "Imported 1 label(s), skipped 1 line(s).");
+ Assert.True(await s.Page.Locator($".transaction-label:has-text('{importedLabel}')").IsVisibleAsync());
+ var importedTx = await client.GetOnChainWalletTransaction(s.StoreId, "BTC", importedTxId);
+ Assert.Contains(importedLabel, importedTx.Labels.Keys);
}
[Fact]
diff --git a/BTCPayServer/Plugins/Wallets/Controllers/UIWalletsController.cs b/BTCPayServer/Plugins/Wallets/Controllers/UIWalletsController.cs
index 6f5564f..395d55c 100644
--- a/BTCPayServer/Plugins/Wallets/Controllers/UIWalletsController.cs
+++ b/BTCPayServer/Plugins/Wallets/Controllers/UIWalletsController.cs
@@ -3,6 +3,7 @@ using System;
using System.Collections.Generic;
using System.Data.Common;
using System.Globalization;
+using System.IO;
using System.Linq;
using System.Net.Mime;
using System.Text;
@@ -33,6 +34,7 @@ using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
using BTCPayServer.Services.Wallets;
using BTCPayServer.Services.Wallets.Export;
+using BTCPayServer.Services.Wallets.Import;
using BTCPayServer.Plugins.Wallets;
using Dapper;
using Microsoft.AspNetCore.Authorization;
@@ -2274,6 +2276,46 @@ namespace BTCPayServer.Controllers
return RedirectToAction(nameof(WalletLabels), new { walletId });
}
+ const int MaxLabelImportSize = 1_000_000;
+ [HttpPost("{walletId}/labels/import")]
+ [Authorize(Policy = WalletPolicies.CanManageWalletTransactions, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+ public async Task<IActionResult> ImportWalletLabels(
+ [ModelBinder(typeof(WalletIdModelBinder))]
+ WalletId walletId, IFormFile? file)
+ {
+ if (file is null || file.Length == 0)
+ {
+ TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["Please select a BIP-329 file to import."].Value;
+ return RedirectToAction(nameof(WalletLabels), new { walletId });
+ }
+
+ if (file.Length > MaxLabelImportSize)
+ {
+ TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["The import file is too big (1 MB maximum)."].Value;
+ return RedirectToAction(nameof(WalletLabels), new { walletId });
+ }
+
+ var network = handlers.GetBitcoinHandler(walletId.CryptoCode).Network;
+ using var reader = new StreamReader(file.OpenReadStream());
+ var import = await Bip329Import.Parse(reader, network.NBitcoinNetwork);
+ if (import.Labels.Count == 0)
+ {
+ TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["No labels could be imported from this file. Expected BIP-329 format: one JSON object per line."].Value;
+ return RedirectToAction(nameof(WalletLabels), new { walletId });
+ }
+
+ var reqs = import.Labels
+ .GroupBy(l => (l.ObjectType, l.ObjectId))
+ .Select(g => (new WalletObjectId(walletId, g.Key.ObjectType, g.Key.ObjectId), g.Select(l => l.Label).ToArray()))
+ .ToArray();
+ await WalletRepository.AddWalletObjectLabels(reqs);
+
+ TempData[WellKnownTempData.SuccessMessage] = import.SkippedLines == 0
+ ? StringLocalizer["Imported {0} label(s).", import.Labels.Count].Value
+ : StringLocalizer["Imported {0} label(s), skipped {1} line(s).", import.Labels.Count, import.SkippedLines].Value;
+ return RedirectToAction(nameof(WalletLabels), new { walletId });
+ }
+
private string? GetImage(BTCPayNetwork network)
{
var pmi = PaymentTypes.CHAIN.GetPaymentMethodId(network.CryptoCode);
diff --git a/BTCPayServer/Plugins/Wallets/Views/UIWallets/WalletLabels.cshtml b/BTCPayServer/Plugins/Wallets/Views/UIWallets/WalletLabels.cshtml
index 68b1a03..196c129 100644
--- a/BTCPayServer/Plugins/Wallets/Views/UIWallets/WalletLabels.cshtml
+++ b/BTCPayServer/Plugins/Wallets/Views/UIWallets/WalletLabels.cshtml
@@ -40,6 +40,12 @@
<partial name="_StatusMessage" />
+<form method="post" enctype="multipart/form-data" asp-action="ImportWalletLabels" asp-route-walletId="@Model.WalletId"
+ class="wallet-labels__import d-flex flex-wrap align-items-center gap-2 mb-4" permission="@WalletPolicies.CanManageWalletTransactions">
+ <input type="file" name="file" class="form-control wallet-labels__import-file w-auto" accept=".jsonl,.json,.txt" required />
+ <button type="submit" class="btn btn-secondary wallet-labels__import-button" text-translate="true">Import BIP-329 labels</button>
+</form>
+
@if (Model.Labels.Any())
{
<div class="table-responsive-md">
diff --git a/BTCPayServer/Plugins/Wallets/Views/UIWallets/WalletTransactions.cshtml b/BTCPayServer/Plugins/Wallets/Views/UIWallets/WalletTransactions.cshtml
index a1d10b8..a17817b 100644
--- a/BTCPayServer/Plugins/Wallets/Views/UIWallets/WalletTransactions.cshtml
+++ b/BTCPayServer/Plugins/Wallets/Views/UIWallets/WalletTransactions.cshtml
@@ -165,6 +165,14 @@
// the actions div marks the end of the list table
observer.observe($actions);
+
+ delegate('click', '.wallet-labels__import-button', event => {
+ event.target.closest('form').querySelector('.wallet-labels__import-file').click()
+ })
+ delegate('change', '.wallet-labels__import-file', event => {
+ if (event.target.files.length > 0)
+ event.target.closest('form').submit()
+ })
</script>
}
@@ -197,6 +205,11 @@
</div>
</form>
</div>
+ <form asp-action="ImportWalletLabels" asp-route-walletId="@walletId" method="post" enctype="multipart/form-data"
+ class="wallet-labels__import" permission="@WalletPolicies.CanManageWalletTransactions">
+ <input type="file" name="file" class="wallet-labels__import-file d-none" accept=".jsonl,.json,.txt" aria-hidden="true" tabindex="-1" />
+ <button type="button" class="btn btn-secondary wallet-labels__import-button" text-translate="true">Import BIP-329 labels</button>
+ </form>
</div>
</div>
<partial name="_StatusMessage" />
diff --git a/BTCPayServer/Services/WalletRepository.cs b/BTCPayServer/Services/WalletRepository.cs
index bbc5364..d81b07f 100644
--- a/BTCPayServer/Services/WalletRepository.cs
+++ b/BTCPayServer/Services/WalletRepository.cs
@@ -657,16 +657,24 @@ namespace BTCPayServer.Services
public async Task AddWalletObjectLabels(WalletObjectId id, params string[] labels)
{
ArgumentNullException.ThrowIfNull(id);
- var objs = new List<WalletObjectData>();
+ await AddWalletObjectLabels(new[] { (id, labels) });
+ }
+
+ public async Task AddWalletObjectLabels((WalletObjectId id, string[] labels)[] reqs)
+ {
+ var objs = new Dictionary<WalletObjectId, WalletObjectData>();
var links = new List<WalletObjectLinkData>();
- objs.Add(NewWalletObjectData(id));
- foreach (var l in labels.Select(l => l.Trim().Truncate(MaxLabelSize)))
+ foreach (var (id, labels) in reqs)
{
- var label = CreateLabel(id.WalletId, l);
- objs.Add(label.ObjectData);
- links.Add(NewWalletObjectLinkData(label.Id, id));
+ objs.TryAdd(id, NewWalletObjectData(id));
+ foreach (var l in labels.Select(l => l.Trim().Truncate(MaxLabelSize)).Distinct())
+ {
+ var label = CreateLabel(id.WalletId, l);
+ objs.TryAdd(label.Id, label.ObjectData);
+ links.Add(NewWalletObjectLinkData(label.Id, id));
+ }
}
- await EnsureCreated(objs, links);
+ await EnsureCreated(objs.Values.ToList(), links);
}
public Task AddWalletTransactionAttachment(WalletId walletId, uint256 txId, Attachment attachment)
{
diff --git a/BTCPayServer/Services/Wallets/Import/Bip329Import.cs b/BTCPayServer/Services/Wallets/Import/Bip329Import.cs
new file mode 100644
index 0000000..5d523c5
--- /dev/null
+++ b/BTCPayServer/Services/Wallets/Import/Bip329Import.cs
@@ -0,0 +1,86 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading.Tasks;
+using BTCPayServer.Data;
+using NBitcoin;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+namespace BTCPayServer.Services.Wallets.Import
+{
+ // https://github.com/bitcoin/bips/blob/master/bip-0329.mediawiki
+ public static class Bip329Import
+ {
+ public record ImportedLabel(string ObjectType, string ObjectId, string Label);
+ public record Result(List<ImportedLabel> Labels, int SkippedLines);
+
+ public static async Task<Result> Parse(TextReader reader, Network network)
+ {
+ var labels = new List<ImportedLabel>();
+ var seen = new HashSet<ImportedLabel>();
+ var skipped = 0;
+ while (await reader.ReadLineAsync() is { } line)
+ {
+ line = line.Trim();
+ if (line.Length == 0)
+ continue;
+ var entry = TryParseLine(line, network);
+ if (entry is null)
+ skipped++;
+ else if (seen.Add(entry))
+ labels.Add(entry);
+ }
+ return new Result(labels, skipped);
+ }
+
+ static ImportedLabel? TryParseLine(string line, Network network)
+ {
+ JObject obj;
+ try
+ {
+ obj = JObject.Parse(line);
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ var label = GetString(obj, "label")?.Trim();
+ var reference = GetString(obj, "ref");
+ if (string.IsNullOrEmpty(label) || string.IsNullOrEmpty(reference))
+ return null;
+ return GetString(obj, "type") switch
+ {
+ "tx" when uint256.TryParse(reference, out var txId) =>
+ new ImportedLabel(WalletObjectData.Types.Tx, txId.ToString(), label),
+ "addr" when TryParseAddress(reference, network) is { } address =>
+ new ImportedLabel(WalletObjectData.Types.Address, address, label),
+ "output" when TryParseOutpoint(reference) is { } outpoint =>
+ new ImportedLabel(WalletObjectData.Types.Utxo, outpoint, label),
+ _ => null
+ };
+ }
+
+ static string? GetString(JObject obj, string name)
+ => obj[name] is JValue { Type: JTokenType.String, Value: string s } ? s : null;
+
+ static string? TryParseAddress(string str, Network network)
+ {
+ try
+ {
+ return BitcoinAddress.Create(str, network).ToString();
+ }
+ catch (FormatException)
+ {
+ return null;
+ }
+ }
+
+ static string? TryParseOutpoint(string str)
+ {
+ // BIP-329 references outputs as <txid>:<vout>, NBitcoin parses <txid>-<vout>
+ return OutPoint.TryParse(str.Replace(':', '-'), out var outpoint) ? outpoint!.ToString() : null;
+ }
+ }
+}
Why this scored 21/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.