refactor: improve label manager caching and refresh
What changed, and why it matters
This commit is a routine code cleanup of the wallet address label picker in BTCPay Server. It rewrites how the page caches and refreshes the list of available labels, removes an old Vue.js event listener, and adds a guard so the script does nothing if the expected HTML element is missing. There is no clear security bug being fixed and no security relevance is stated by the project.
No immediate security action required. Treat as a normal maintenance refactor. If reviewing for defense in depth, verify that `escape()` in TomSelect render functions remains effective and that `updateUrl` endpoints still enforce authorization and validate label input server-side, since the client-side code no longer performs any additional label merging.
Security signals we found
No security framing in commit title or message
No CVE, advisory, or researcher attribution present
Refactor only: logic flow preserved, no new input validation or output encoding added
Removed client-side label-merge event could theoretically reduce risk of stale/cross-instance UI state, but this is speculative
DOM rendering still uses `escape()` for label text, preserving existing XSS mitigation
Evidence from the diff
The diff refactors initLabelManager in site.js and removes a labelmanager:changed event handler from ReservedAddresses.cshtml. Key changes: (1) early if (!element) return; guard; (2) extraction of a fetchWalletLabels(force) helper that caches the GET fetchUrl promise in window[commonCallId] and allows forced refresh; (3) new refreshLabelOptions() that updates the TomSelect option list after a successful POST to updateUrl; (4) removal of the custom labelmanager:changed event dispatch and the Vue-side listener that merged labels into the local address list. The update POST body still uses select.items directly. The refactor is described by the author as improving caching/refresh behavior, not as a security fix.
Changed components
BTCPayServer/Views/UIWallets/ReservedAddresses.cshtmlBTCPayServer/wwwroot/main/site.jsWallet label manager UI component (TomSelect-based)Inspect captured patch +125 / −124
diff --git a/BTCPayServer/Views/UIWallets/ReservedAddresses.cshtml b/BTCPayServer/Views/UIWallets/ReservedAddresses.cshtml
index 50bc1b4..45c09f7 100644
--- a/BTCPayServer/Views/UIWallets/ReservedAddresses.cshtml
+++ b/BTCPayServer/Views/UIWallets/ReservedAddresses.cshtml
@@ -145,21 +145,6 @@
},
mounted() {
initLabelManagers();
-
- const labelManagerList = document.querySelectorAll("input.label-manager");
- labelManagerList.forEach(labelManager => {
- labelManager.addEventListener("labelmanager:changed", ({ detail }) => {
- const { walletObjectId, labels: newLabels } = detail;
-
- const targetAddress = this.addresses.find(addr => addr.address === walletObjectId);
- if (!targetAddress) return;
-
- const existingLabels = targetAddress.labels?.map(l => l.text) || [];
- const merged = Array.from(new Set([...existingLabels, ...newLabels])).map(text => ({ text }));
-
- this.$set(targetAddress, 'labels', merged);
- });
- });
},
methods: {
setPageSize(size) {
diff --git a/BTCPayServer/wwwroot/main/site.js b/BTCPayServer/wwwroot/main/site.js
index b1da5fb..ea04a41 100644
--- a/BTCPayServer/wwwroot/main/site.js
+++ b/BTCPayServer/wwwroot/main/site.js
@@ -15,131 +15,147 @@ const switchTimeFormat = event => {
async function initLabelManager (elementId) {
const element = document.getElementById(elementId);
+ if (!element) return;
const labelStyle = data =>
data && data.color && data.textColor
? `--label-bg:${data.color};--label-fg:${data.textColor}`
: '--label-bg:var(--btcpay-neutral-300);--label-fg:var(--btcpay-neutral-800)'
- if (element) {
- const { fetchUrl, updateUrl, walletId, walletObjectType, walletObjectId, labels, selectElement } = element.dataset;
- const commonCallId = `walletLabels-${walletId}`;
- if (!window[commonCallId]) {
- window[commonCallId] = fetch(fetchUrl, {
- method: 'GET',
- credentials: 'include',
- headers: {
- 'Content-Type': 'application/json'
- },
- }).then(res => res.json());
- }
- const items = element.value.split(',').filter(x => !!x);
- const options = await window[commonCallId].then(labels => {
- const newItems = items.filter(item => !labels.find(label => label.label === item));
- labels = [...labels, ...newItems.map(item => ({ label: item }))];
- return labels;
+ const { fetchUrl, updateUrl, walletId, walletObjectType, walletObjectId, labels, selectElement } = element.dataset;
+ const commonCallId = `walletLabels-${walletId}`;
+ const fetchWalletLabels = async (force = false) => {
+ if (!force && window[commonCallId])
+ return window[commonCallId];
+
+ window[commonCallId] = fetch(fetchUrl, {
+ method: 'GET',
+ credentials: 'include',
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ }).then(res => res.json());
+
+ return window[commonCallId];
+ };
+
+ const items = element.value.split(',').filter(x => !!x);
+ const options = await fetchWalletLabels().then(serverLabels => {
+ const newItems = items.filter(item => !serverLabels.find(label => label.label === item));
+ return [...serverLabels, ...newItems.map(item => ({ label: item }))];
+ });
+ const richInfo = labels ? JSON.parse(labels) : {};
+ let select;
+ const refreshLabelOptions = async () => {
+ if (!fetchUrl || !select) return;
+
+ const updatedLabels = await fetchWalletLabels(true);
+
+ updatedLabels.forEach(lbl => {
+ if (select.options[lbl.label]) {
+ select.updateOption(lbl.label, lbl);
+ } else {
+ select.addOption(lbl);
+ }
});
- const richInfo = labels ? JSON.parse(labels) : {};
- const config = {
- options,
- items,
- valueField: "label",
- labelField: "label",
- searchField: "label",
- create: true,
- persist: true,
- allowEmptyOption: false,
- closeAfterSelect: false,
- render: {
- dropdown (){
- return '<div class="dropdown-menu"></div>';
- },
- option_create: function(data, escape) {
- return `<div class="transaction-label create" style="${labelStyle(null)}">Add <strong>${escape(data.input)}</strong>…</div>`;
- },
- option (data, escape) {
- return `<div class="transaction-label" style="${labelStyle(data)}"><span>${escape(data.label)}</span></div>`;
- },
- item (data, escape) {
- const info = richInfo && richInfo[data.label];
- const additionalInfo = info
- ? `<a href="${info.link}" target="_blank" rel="noreferrer noopener" class="transaction-label-info transaction-details-icon" title="${info.tooltip}" data-bs-html="true"
- data-bs-toggle="tooltip" data-bs-custom-class="transaction-label-tooltip"><svg role="img" class="icon icon-info"><use href="/img/icon-sprite.svg#info"></use></svg></a>`
- : '';
- const inner = `<span>${escape(data.label)}</span>${additionalInfo}`;
- return `<div class="transaction-label" style="${labelStyle(data)}">${inner}</div>`;
- }
- },
- onItemAdd (val) {
- window[commonCallId] = window[commonCallId].then(labels => {
- return [...labels, { label: val }]
- });
- document.dispatchEvent(new CustomEvent(`${commonCallId}-option-added`, {
- detail: val
- }));
- },
- async onChange (values) {
- const labels = Array.isArray(values) ? values : values.split(',');
+ select.refreshItems();
+ };
- element.dispatchEvent(new CustomEvent("labelmanager:changed", {
- detail: {
- walletObjectId,
- labels: labels
- }
+ const config = {
+ options,
+ items,
+ valueField: 'label',
+ labelField: 'label',
+ searchField: 'label',
+ create: true,
+ persist: true,
+ allowEmptyOption: false,
+ closeAfterSelect: false,
+ render: {
+ dropdown () {
+ return '<div class="dropdown-menu"></div>';
+ },
+ option_create (data, escape) {
+ return `<div class="transaction-label create" style="${labelStyle(
+ null
+ )}">Add <strong>${escape(data.input)}</strong>…</div>`;
+ },
+ option (data, escape) {
+ return `<div class="transaction-label" style="${labelStyle(
+ data
+ )}"><span>${escape(data.label)}</span></div>`;
+ },
+ item (data, escape) {
+ const info = richInfo && richInfo[data.label];
+ const additionalInfo = info
+ ? `<a href="${info.link}" target="_blank" rel="noreferrer noopener" class="transaction-label-info transaction-details-icon" title="${info.tooltip}" data-bs-html="true"
+ data-bs-toggle="tooltip" data-bs-custom-class="transaction-label-tooltip"><svg role="img" class="icon icon-info"><use href="/img/icon-sprite.svg#info"></use></svg></a>`
+ : '';
+ const inner = `<span>${escape(data.label)}</span>${additionalInfo}`;
+ return `<div class="transaction-label" style="${labelStyle(
+ data
+ )}">${inner}</div>`;
+ }
+ },
+ onItemAdd (val) {
+ document.dispatchEvent(
+ new CustomEvent(`${commonCallId}-option-added`, {
+ detail: val
}));
-
- const selectElementI = selectElement ? document.getElementById(selectElement) : null;
- if (selectElementI){
- while (selectElementI.options.length > 0) {
- selectElementI.remove(0);
- }
- select.items.forEach((item) => {
- selectElementI.add(new Option(item, item, true, true));
- })
+ },
+ async onChange () {
+ const selectElementI = selectElement ? document.getElementById(selectElement) : null;
+ if (selectElementI) {
+ while (selectElementI.options.length > 0) {
+ selectElementI.remove(0);
}
- if(!updateUrl)
- return;
- select.lock();
- try {
- const response = await fetch(updateUrl, {
- method: "POST",
- credentials: "include",
- headers: {
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({
- id: walletObjectId,
- type: walletObjectType,
- labels: select.items
- })
- });
- if (!response.ok) {
- throw new Error('Network response was not OK');
- }
- } catch (error) {
- console.error('There has been a problem with your fetch operation:', error);
- } finally {
- select.unlock();
+ select.items.forEach(item => {
+ selectElementI.add(new Option(item, item, true, true));
+ });
+ }
+ if (!updateUrl) return;
+ select.lock();
+ try {
+ const response = await fetch(updateUrl, {
+ method: 'POST',
+ credentials: 'include',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ id: walletObjectId,
+ type: walletObjectType,
+ labels: select.items
+ })
+ });
+ if (!response.ok) {
+ throw new Error('Network response was not OK');
}
+
+ await refreshLabelOptions();
+ } catch (error) {
+ console.error('There has been a problem with your fetch operation:', error);
+ } finally {
+ select.unlock();
}
- };
- const select = new TomSelect(element, config);
+ }
+ };
+ select = new TomSelect(element, config);
- element.parentElement.querySelectorAll('.ts-control .transaction-label a').forEach(lbl => {
- lbl.addEventListener('click', e => {
- e.stopPropagation()
- })
+ element.parentElement.querySelectorAll('.ts-control .transaction-label a').forEach(lbl => {
+ lbl.addEventListener('click', e => {
+ e.stopPropagation()
})
+ })
- document.addEventListener(`${commonCallId}-option-added`, evt => {
- if (!(evt.detail in select.options)) {
- select.addOption({
- label: evt.detail
- })
- }
- })
- }
+ document.addEventListener(`${commonCallId}-option-added`, evt => {
+ if (!(evt.detail in select.options)) {
+ select.addOption({
+ label: evt.detail
+ })
+ }
+ })
}
const initLabelManagers = () => {
Why this scored 13/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.