fix: prevent cumulative change-address index leak in dry-run tx builds - #72

Merged
ovitrif merged 1 commit into
fix/dsym-debug-symbolsfrom
fix/change-address-index-leak
Mar 4, 2026
Merged

fix: prevent cumulative change-address index leak in dry-run tx builds#72
ovitrif merged 1 commit into
fix/dsym-debug-symbolsfrom
fix/change-address-index-leak

Conversation

@ben-kaufman

Copy link
Copy Markdown

Summary

  • Fix cumulative change-address derivation index leak caused by BDK's TxBuilder::finish() being called for fee estimation / dry-run purposes without cancelling the resulting PSBT. Each uncancelled finish() permanently burns a change address index.
  • Add AggregateWallet::cancel_dry_run_tx() to unmark change addresses on the primary wallet without persisting, and call it on all dry-run and error-after-finish() paths.
  • Add 4 unit tests covering the bug, the fix, error-path handling, and real-tx-after-dry-run interaction.

Detail

BDK's TxBuilder::finish() has two side-effects on the internal (change) keychain: it reveals a new derivation index and marks it as "used". When the PSBT is only used for fee estimation (never signed/broadcast), the marked address is permanently consumed. Repeated fee estimations steadily burn through the change address keyspace — a problem for wallet recovery (gap-limit), resource efficiency, and address hygiene.

cancel_dry_run_tx calls BDK's cancel_tx on the primary wallet to reverse the "used" marking so the next finish() reuses the same index. The reveal itself persists harmlessly in the staged changeset.

Call sites fixed

Call siteWhat changed
build_transaction_psbt — AllRetainingReserve temp txcancel_tx moved before the ? on calculate_fee so it fires unconditionally
build_transaction_psbt — reserve checksEvery early-return error path now calls cancel_dry_run_tx before returning
calculate_transaction_feecancel_dry_run_tx fires unconditionally before fee result is unwrapped
select_confirmed_utxoscancel_dry_run_tx fires after UTXO collection, before return

Safety

No change-address reuse between broadcast transactions is possible:

  • Mutex<AggregateWallet> serializes all wallet access
  • cancel_dry_run_tx is never called on the broadcast path (send_to_address)
  • Real finish() re-marks the address as "used" and persists it

Test plan

  • test_finish_without_cancel_leaks_change_index — demonstrates the bug
  • test_cancel_dry_run_prevents_cumulative_index_leak — proves the fix across 5 iterations
  • test_cancel_after_failed_intermediate_prevents_leak — error-path coverage
  • test_dry_run_cancel_then_real_tx_reuses_change_address — real tx after dry-run
  • cargo build / cargo fmt / cargo clippy clean

@ovitrifovitrif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.
I will rebuild bindings. Probably can revert to rc.31, since my original release needs to be recreated, as it missed a point.

@ovitrif
ovitrif merged commit ee72371 into fix/dsym-debug-symbolsMar 4, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@ben-kaufman@ovitrif
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix: prevent cumulative change-address index leak in dry-run tx builds - #72

Merged
ovitrif merged 1 commit into
fix/dsym-debug-symbolsfrom
fix/change-address-index-leak
Mar 4, 2026
Merged

fix: prevent cumulative change-address index leak in dry-run tx builds#72
ovitrif merged 1 commit into
fix/dsym-debug-symbolsfrom
fix/change-address-index-leak

Conversation

@ben-kaufman

Copy link
Copy Markdown

Summary

  • Fix cumulative change-address derivation index leak caused by BDK's TxBuilder::finish() being called for fee estimation / dry-run purposes without cancelling the resulting PSBT. Each uncancelled finish() permanently burns a change address index.
  • Add AggregateWallet::cancel_dry_run_tx() to unmark change addresses on the primary wallet without persisting, and call it on all dry-run and error-after-finish() paths.
  • Add 4 unit tests covering the bug, the fix, error-path handling, and real-tx-after-dry-run interaction.

Detail

BDK's TxBuilder::finish() has two side-effects on the internal (change) keychain: it reveals a new derivation index and marks it as "used". When the PSBT is only used for fee estimation (never signed/broadcast), the marked address is permanently consumed. Repeated fee estimations steadily burn through the change address keyspace — a problem for wallet recovery (gap-limit), resource efficiency, and address hygiene.

cancel_dry_run_tx calls BDK's cancel_tx on the primary wallet to reverse the "used" marking so the next finish() reuses the same index. The reveal itself persists harmlessly in the staged changeset.

Call sites fixed

Call siteWhat changed
build_transaction_psbt — AllRetainingReserve temp txcancel_tx moved before the ? on calculate_fee so it fires unconditionally
build_transaction_psbt — reserve checksEvery early-return error path now calls cancel_dry_run_tx before returning
calculate_transaction_feecancel_dry_run_tx fires unconditionally before fee result is unwrapped
select_confirmed_utxoscancel_dry_run_tx fires after UTXO collection, before return

Safety

No change-address reuse between broadcast transactions is possible:

  • Mutex<AggregateWallet> serializes all wallet access
  • cancel_dry_run_tx is never called on the broadcast path (send_to_address)
  • Real finish() re-marks the address as "used" and persists it

Test plan

  • test_finish_without_cancel_leaks_change_index — demonstrates the bug
  • test_cancel_dry_run_prevents_cumulative_index_leak — proves the fix across 5 iterations
  • test_cancel_after_failed_intermediate_prevents_leak — error-path coverage
  • test_dry_run_cancel_then_real_tx_reuses_change_address — real tx after dry-run
  • cargo build / cargo fmt / cargo clippy clean

@ovitrifovitrif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.
I will rebuild bindings. Probably can revert to rc.31, since my original release needs to be recreated, as it missed a point.

@ovitrif
ovitrif merged commit ee72371 into fix/dsym-debug-symbolsMar 4, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@ben-kaufman@ovitrif
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix: prevent cumulative change-address index leak in dry-run tx builds - #72

Merged
ovitrif merged 1 commit into
fix/dsym-debug-symbolsfrom
fix/change-address-index-leak
Mar 4, 2026
Merged

fix: prevent cumulative change-address index leak in dry-run tx builds#72
ovitrif merged 1 commit into
fix/dsym-debug-symbolsfrom
fix/change-address-index-leak

Conversation

@ben-kaufman

Copy link
Copy Markdown

Summary

  • Fix cumulative change-address derivation index leak caused by BDK's TxBuilder::finish() being called for fee estimation / dry-run purposes without cancelling the resulting PSBT. Each uncancelled finish() permanently burns a change address index.
  • Add AggregateWallet::cancel_dry_run_tx() to unmark change addresses on the primary wallet without persisting, and call it on all dry-run and error-after-finish() paths.
  • Add 4 unit tests covering the bug, the fix, error-path handling, and real-tx-after-dry-run interaction.

Detail

BDK's TxBuilder::finish() has two side-effects on the internal (change) keychain: it reveals a new derivation index and marks it as "used". When the PSBT is only used for fee estimation (never signed/broadcast), the marked address is permanently consumed. Repeated fee estimations steadily burn through the change address keyspace — a problem for wallet recovery (gap-limit), resource efficiency, and address hygiene.

cancel_dry_run_tx calls BDK's cancel_tx on the primary wallet to reverse the "used" marking so the next finish() reuses the same index. The reveal itself persists harmlessly in the staged changeset.

Call sites fixed

Call siteWhat changed
build_transaction_psbt — AllRetainingReserve temp txcancel_tx moved before the ? on calculate_fee so it fires unconditionally
build_transaction_psbt — reserve checksEvery early-return error path now calls cancel_dry_run_tx before returning
calculate_transaction_feecancel_dry_run_tx fires unconditionally before fee result is unwrapped
select_confirmed_utxoscancel_dry_run_tx fires after UTXO collection, before return

Safety

No change-address reuse between broadcast transactions is possible:

  • Mutex<AggregateWallet> serializes all wallet access
  • cancel_dry_run_tx is never called on the broadcast path (send_to_address)
  • Real finish() re-marks the address as "used" and persists it

Test plan

  • test_finish_without_cancel_leaks_change_index — demonstrates the bug
  • test_cancel_dry_run_prevents_cumulative_index_leak — proves the fix across 5 iterations
  • test_cancel_after_failed_intermediate_prevents_leak — error-path coverage
  • test_dry_run_cancel_then_real_tx_reuses_change_address — real tx after dry-run
  • cargo build / cargo fmt / cargo clippy clean

@ovitrifovitrif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.
I will rebuild bindings. Probably can revert to rc.31, since my original release needs to be recreated, as it missed a point.

@ovitrif
ovitrif merged commit ee72371 into fix/dsym-debug-symbolsMar 4, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@ben-kaufman@ovitrif
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix: prevent cumulative change-address index leak in dry-run tx builds - #72

Merged
ovitrif merged 1 commit into
fix/dsym-debug-symbolsfrom
fix/change-address-index-leak
Mar 4, 2026
Merged

fix: prevent cumulative change-address index leak in dry-run tx builds#72
ovitrif merged 1 commit into
fix/dsym-debug-symbolsfrom
fix/change-address-index-leak

Conversation

@ben-kaufman

Copy link
Copy Markdown

Summary

  • Fix cumulative change-address derivation index leak caused by BDK's TxBuilder::finish() being called for fee estimation / dry-run purposes without cancelling the resulting PSBT. Each uncancelled finish() permanently burns a change address index.
  • Add AggregateWallet::cancel_dry_run_tx() to unmark change addresses on the primary wallet without persisting, and call it on all dry-run and error-after-finish() paths.
  • Add 4 unit tests covering the bug, the fix, error-path handling, and real-tx-after-dry-run interaction.

Detail

BDK's TxBuilder::finish() has two side-effects on the internal (change) keychain: it reveals a new derivation index and marks it as "used". When the PSBT is only used for fee estimation (never signed/broadcast), the marked address is permanently consumed. Repeated fee estimations steadily burn through the change address keyspace — a problem for wallet recovery (gap-limit), resource efficiency, and address hygiene.

cancel_dry_run_tx calls BDK's cancel_tx on the primary wallet to reverse the "used" marking so the next finish() reuses the same index. The reveal itself persists harmlessly in the staged changeset.

Call sites fixed

Call siteWhat changed
build_transaction_psbt — AllRetainingReserve temp txcancel_tx moved before the ? on calculate_fee so it fires unconditionally
build_transaction_psbt — reserve checksEvery early-return error path now calls cancel_dry_run_tx before returning
calculate_transaction_feecancel_dry_run_tx fires unconditionally before fee result is unwrapped
select_confirmed_utxoscancel_dry_run_tx fires after UTXO collection, before return

Safety

No change-address reuse between broadcast transactions is possible:

  • Mutex<AggregateWallet> serializes all wallet access
  • cancel_dry_run_tx is never called on the broadcast path (send_to_address)
  • Real finish() re-marks the address as "used" and persists it

Test plan

  • test_finish_without_cancel_leaks_change_index — demonstrates the bug
  • test_cancel_dry_run_prevents_cumulative_index_leak — proves the fix across 5 iterations
  • test_cancel_after_failed_intermediate_prevents_leak — error-path coverage
  • test_dry_run_cancel_then_real_tx_reuses_change_address — real tx after dry-run
  • cargo build / cargo fmt / cargo clippy clean

@ovitrifovitrif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.
I will rebuild bindings. Probably can revert to rc.31, since my original release needs to be recreated, as it missed a point.

@ovitrif
ovitrif merged commit ee72371 into fix/dsym-debug-symbolsMar 4, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@ben-kaufman@ovitrif
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix: prevent cumulative change-address index leak in dry-run tx builds - #72

Merged
ovitrif merged 1 commit into
fix/dsym-debug-symbolsfrom
fix/change-address-index-leak
Mar 4, 2026
Merged

fix: prevent cumulative change-address index leak in dry-run tx builds#72
ovitrif merged 1 commit into
fix/dsym-debug-symbolsfrom
fix/change-address-index-leak

Conversation

@ben-kaufman

Copy link
Copy Markdown

Summary

  • Fix cumulative change-address derivation index leak caused by BDK's TxBuilder::finish() being called for fee estimation / dry-run purposes without cancelling the resulting PSBT. Each uncancelled finish() permanently burns a change address index.
  • Add AggregateWallet::cancel_dry_run_tx() to unmark change addresses on the primary wallet without persisting, and call it on all dry-run and error-after-finish() paths.
  • Add 4 unit tests covering the bug, the fix, error-path handling, and real-tx-after-dry-run interaction.

Detail

BDK's TxBuilder::finish() has two side-effects on the internal (change) keychain: it reveals a new derivation index and marks it as "used". When the PSBT is only used for fee estimation (never signed/broadcast), the marked address is permanently consumed. Repeated fee estimations steadily burn through the change address keyspace — a problem for wallet recovery (gap-limit), resource efficiency, and address hygiene.

cancel_dry_run_tx calls BDK's cancel_tx on the primary wallet to reverse the "used" marking so the next finish() reuses the same index. The reveal itself persists harmlessly in the staged changeset.

Call sites fixed

Call siteWhat changed
build_transaction_psbt — AllRetainingReserve temp txcancel_tx moved before the ? on calculate_fee so it fires unconditionally
build_transaction_psbt — reserve checksEvery early-return error path now calls cancel_dry_run_tx before returning
calculate_transaction_feecancel_dry_run_tx fires unconditionally before fee result is unwrapped
select_confirmed_utxoscancel_dry_run_tx fires after UTXO collection, before return

Safety

No change-address reuse between broadcast transactions is possible:

  • Mutex<AggregateWallet> serializes all wallet access
  • cancel_dry_run_tx is never called on the broadcast path (send_to_address)
  • Real finish() re-marks the address as "used" and persists it

Test plan

  • test_finish_without_cancel_leaks_change_index — demonstrates the bug
  • test_cancel_dry_run_prevents_cumulative_index_leak — proves the fix across 5 iterations
  • test_cancel_after_failed_intermediate_prevents_leak — error-path coverage
  • test_dry_run_cancel_then_real_tx_reuses_change_address — real tx after dry-run
  • cargo build / cargo fmt / cargo clippy clean

@ovitrifovitrif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.
I will rebuild bindings. Probably can revert to rc.31, since my original release needs to be recreated, as it missed a point.

@ovitrif
ovitrif merged commit ee72371 into fix/dsym-debug-symbolsMar 4, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@ben-kaufman@ovitrif
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix: prevent cumulative change-address index leak in dry-run tx builds - #72

Merged
ovitrif merged 1 commit into
fix/dsym-debug-symbolsfrom
fix/change-address-index-leak
Mar 4, 2026
Merged

fix: prevent cumulative change-address index leak in dry-run tx builds#72
ovitrif merged 1 commit into
fix/dsym-debug-symbolsfrom
fix/change-address-index-leak

Conversation

@ben-kaufman

Copy link
Copy Markdown

Summary

  • Fix cumulative change-address derivation index leak caused by BDK's TxBuilder::finish() being called for fee estimation / dry-run purposes without cancelling the resulting PSBT. Each uncancelled finish() permanently burns a change address index.
  • Add AggregateWallet::cancel_dry_run_tx() to unmark change addresses on the primary wallet without persisting, and call it on all dry-run and error-after-finish() paths.
  • Add 4 unit tests covering the bug, the fix, error-path handling, and real-tx-after-dry-run interaction.

Detail

BDK's TxBuilder::finish() has two side-effects on the internal (change) keychain: it reveals a new derivation index and marks it as "used". When the PSBT is only used for fee estimation (never signed/broadcast), the marked address is permanently consumed. Repeated fee estimations steadily burn through the change address keyspace — a problem for wallet recovery (gap-limit), resource efficiency, and address hygiene.

cancel_dry_run_tx calls BDK's cancel_tx on the primary wallet to reverse the "used" marking so the next finish() reuses the same index. The reveal itself persists harmlessly in the staged changeset.

Call sites fixed

Call siteWhat changed
build_transaction_psbt — AllRetainingReserve temp txcancel_tx moved before the ? on calculate_fee so it fires unconditionally
build_transaction_psbt — reserve checksEvery early-return error path now calls cancel_dry_run_tx before returning
calculate_transaction_feecancel_dry_run_tx fires unconditionally before fee result is unwrapped
select_confirmed_utxoscancel_dry_run_tx fires after UTXO collection, before return

Safety

No change-address reuse between broadcast transactions is possible:

  • Mutex<AggregateWallet> serializes all wallet access
  • cancel_dry_run_tx is never called on the broadcast path (send_to_address)
  • Real finish() re-marks the address as "used" and persists it

Test plan

  • test_finish_without_cancel_leaks_change_index — demonstrates the bug
  • test_cancel_dry_run_prevents_cumulative_index_leak — proves the fix across 5 iterations
  • test_cancel_after_failed_intermediate_prevents_leak — error-path coverage
  • test_dry_run_cancel_then_real_tx_reuses_change_address — real tx after dry-run
  • cargo build / cargo fmt / cargo clippy clean

@ovitrifovitrif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.
I will rebuild bindings. Probably can revert to rc.31, since my original release needs to be recreated, as it missed a point.

@ovitrif
ovitrif merged commit ee72371 into fix/dsym-debug-symbolsMar 4, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@ben-kaufman@ovitrif
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix: prevent cumulative change-address index leak in dry-run tx builds - #72

Merged
ovitrif merged 1 commit into
fix/dsym-debug-symbolsfrom
fix/change-address-index-leak
Mar 4, 2026
Merged

fix: prevent cumulative change-address index leak in dry-run tx builds#72
ovitrif merged 1 commit into
fix/dsym-debug-symbolsfrom
fix/change-address-index-leak

Conversation

@ben-kaufman

Copy link
Copy Markdown

Summary

  • Fix cumulative change-address derivation index leak caused by BDK's TxBuilder::finish() being called for fee estimation / dry-run purposes without cancelling the resulting PSBT. Each uncancelled finish() permanently burns a change address index.
  • Add AggregateWallet::cancel_dry_run_tx() to unmark change addresses on the primary wallet without persisting, and call it on all dry-run and error-after-finish() paths.
  • Add 4 unit tests covering the bug, the fix, error-path handling, and real-tx-after-dry-run interaction.

Detail

BDK's TxBuilder::finish() has two side-effects on the internal (change) keychain: it reveals a new derivation index and marks it as "used". When the PSBT is only used for fee estimation (never signed/broadcast), the marked address is permanently consumed. Repeated fee estimations steadily burn through the change address keyspace — a problem for wallet recovery (gap-limit), resource efficiency, and address hygiene.

cancel_dry_run_tx calls BDK's cancel_tx on the primary wallet to reverse the "used" marking so the next finish() reuses the same index. The reveal itself persists harmlessly in the staged changeset.

Call sites fixed

Call siteWhat changed
build_transaction_psbt — AllRetainingReserve temp txcancel_tx moved before the ? on calculate_fee so it fires unconditionally
build_transaction_psbt — reserve checksEvery early-return error path now calls cancel_dry_run_tx before returning
calculate_transaction_feecancel_dry_run_tx fires unconditionally before fee result is unwrapped
select_confirmed_utxoscancel_dry_run_tx fires after UTXO collection, before return

Safety

No change-address reuse between broadcast transactions is possible:

  • Mutex<AggregateWallet> serializes all wallet access
  • cancel_dry_run_tx is never called on the broadcast path (send_to_address)
  • Real finish() re-marks the address as "used" and persists it

Test plan

  • test_finish_without_cancel_leaks_change_index — demonstrates the bug
  • test_cancel_dry_run_prevents_cumulative_index_leak — proves the fix across 5 iterations
  • test_cancel_after_failed_intermediate_prevents_leak — error-path coverage
  • test_dry_run_cancel_then_real_tx_reuses_change_address — real tx after dry-run
  • cargo build / cargo fmt / cargo clippy clean

@ovitrifovitrif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.
I will rebuild bindings. Probably can revert to rc.31, since my original release needs to be recreated, as it missed a point.

@ovitrif
ovitrif merged commit ee72371 into fix/dsym-debug-symbolsMar 4, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@ben-kaufman@ovitrif
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix: prevent cumulative change-address index leak in dry-run tx builds - #72

Merged
ovitrif merged 1 commit into
fix/dsym-debug-symbolsfrom
fix/change-address-index-leak
Mar 4, 2026
Merged

fix: prevent cumulative change-address index leak in dry-run tx builds#72
ovitrif merged 1 commit into
fix/dsym-debug-symbolsfrom
fix/change-address-index-leak

Conversation

@ben-kaufman

Copy link
Copy Markdown

Summary

  • Fix cumulative change-address derivation index leak caused by BDK's TxBuilder::finish() being called for fee estimation / dry-run purposes without cancelling the resulting PSBT. Each uncancelled finish() permanently burns a change address index.
  • Add AggregateWallet::cancel_dry_run_tx() to unmark change addresses on the primary wallet without persisting, and call it on all dry-run and error-after-finish() paths.
  • Add 4 unit tests covering the bug, the fix, error-path handling, and real-tx-after-dry-run interaction.

Detail

BDK's TxBuilder::finish() has two side-effects on the internal (change) keychain: it reveals a new derivation index and marks it as "used". When the PSBT is only used for fee estimation (never signed/broadcast), the marked address is permanently consumed. Repeated fee estimations steadily burn through the change address keyspace — a problem for wallet recovery (gap-limit), resource efficiency, and address hygiene.

cancel_dry_run_tx calls BDK's cancel_tx on the primary wallet to reverse the "used" marking so the next finish() reuses the same index. The reveal itself persists harmlessly in the staged changeset.

Call sites fixed

Call siteWhat changed
build_transaction_psbt — AllRetainingReserve temp txcancel_tx moved before the ? on calculate_fee so it fires unconditionally
build_transaction_psbt — reserve checksEvery early-return error path now calls cancel_dry_run_tx before returning
calculate_transaction_feecancel_dry_run_tx fires unconditionally before fee result is unwrapped
select_confirmed_utxoscancel_dry_run_tx fires after UTXO collection, before return

Safety

No change-address reuse between broadcast transactions is possible:

  • Mutex<AggregateWallet> serializes all wallet access
  • cancel_dry_run_tx is never called on the broadcast path (send_to_address)
  • Real finish() re-marks the address as "used" and persists it

Test plan

  • test_finish_without_cancel_leaks_change_index — demonstrates the bug
  • test_cancel_dry_run_prevents_cumulative_index_leak — proves the fix across 5 iterations
  • test_cancel_after_failed_intermediate_prevents_leak — error-path coverage
  • test_dry_run_cancel_then_real_tx_reuses_change_address — real tx after dry-run
  • cargo build / cargo fmt / cargo clippy clean

@ovitrifovitrif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.
I will rebuild bindings. Probably can revert to rc.31, since my original release needs to be recreated, as it missed a point.

@ovitrif
ovitrif merged commit ee72371 into fix/dsym-debug-symbolsMar 4, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@ben-kaufman@ovitrif