Validate Esplora merkle proof against the block header's merkle root - #4596

Merged
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
tnull:2026-05-esplora-merkle-root-validation
May 6, 2026
Merged

Validate Esplora merkle proof against the block header's merkle root#4596
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
tnull:2026-05-esplora-merkle-root-validation

Conversation

@tnull

@tnulltnull commented May 5, 2026

Copy link
Copy Markdown
Contributor

EsploraSyncClient::get_confirmed_tx parsed the SPV proof returned by the Esplora server but threw away the security check: the merkle root computed by PartialMerkleTree::extract_matches was discarded (let _ = …), and only the leaf-equality check (matches[0] == txid) remained. Anyone can construct a single-leaf partial tree advertising an arbitrary txid via PartialMerkleTree::from_txids(&[txid], &[true]), so this gate was vacuous.

Note: I recall that at implementation time we decided doing this check wasn't necessary/redundant. But, it can surely not hurt to be more strict here.

`EsploraSyncClient::get_confirmed_tx` parsed the SPV proof returned by
the Esplora server but threw away the security check: the merkle root
computed by `PartialMerkleTree::extract_matches` was discarded
(`let _ = …`), and only the leaf-equality check (`matches[0] == txid`)
remained. Anyone can construct a single-leaf partial tree advertising
an arbitrary txid via `PartialMerkleTree::from_txids(&[txid], &[true])`,
so this gate was vacuous.
A malicious or compromised Esplora server could therefore convince
`EsploraSyncClient` that any transaction was confirmed in any block by
returning `MerkleBlock { header: real_header, txn: forged_partial_tree }`,
causing LDK to feed a synthesized `ConfirmedTx` into `Confirm`
implementations such as `ChannelManager` / `ChainMonitor`. From there,
the channel-funding / closing / HTLC flows would treat the transaction
as confirmed at an attacker-chosen height, with consequences ranging
from premature state transitions to force-close races.
Capture the merkle root returned by `extract_matches` and require it
to equal `block_header.merkle_root`, matching the validation the
Electrum sibling already performs via `validate_merkle_proof`.
Co-Authored-By: HAL 9000
@ldk-reviews-bot

ldk-reviews-bot commented May 5, 2026

Copy link
Copy Markdown

👋 I see @joostjager was un-assigned.
If you'd like another reviewer assignment, please click here.

@ldk-claude-review-bot

Copy link
Copy Markdown
Collaborator

The fix is straightforward and correct. Let me verify the full diff one more time to make sure I haven't missed anything.

The change is isolated to 6 lines in esplora.rs. The logic:

  1. extract_matches result is now captured via .ok()Option<TxMerkleNode>
  2. Compared against Some(block_header.merkle_root)
  3. If extract_matches fails (returns Err), .ok() produces None, which correctly fails the check
  4. All prior validation conditions are preserved

No issues found in the code change itself. The one concern worth raising is the lack of a regression test.

No issues found.

Cross-cutting concern:

  • This is a security-critical fix but has no unit/regression test demonstrating that a forged merkle proof (one where the partial tree computes to a different root than block_header.merkle_root) is now rejected. The existing integration tests in lightning-transaction-sync/tests/integration_tests.rs use a live electrsd and don't cover adversarial server responses. A targeted unit test constructing a MerkleBlock with a mismatched root would lock in this invariant and prevent future regressions. That said, mocking the Esplora client may not be trivial, so this is a suggestion rather than a blocker.

@codecov

codecovBot commented May 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 86.22%. Comparing base (1a26867) to head (b64efcd).
⚠️ Report is 13 commits behind head on main.

Additional details and impacted files
@@ Coverage Diff @@## main #4596 +/- ##
==========================================
- Coverage 86.84% 86.22% -0.62% 
==========================================
Files 161 159 -2 Lines 109260 109170 -90 Branches 109260 109170 -90 ==========================================
- Hits 94882 94136 -746 - Misses 11797 12424 +627 - Partials 2581 2610 +29 
FlagCoverage Δ
fuzzing-fake-hashes?
fuzzing-real-hashes?
tests86.22% <ø> (+<0.01%)⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@TheBlueMatt
TheBlueMatt merged commit 81d11f7 into lightningdevkit:mainMay 6, 2026
23 of 25 checks passed
@TheBlueMatt

Copy link
Copy Markdown
Collaborator

Backported to 0.1 in #4680.

@TheBlueMatt

Copy link
Copy Markdown
Collaborator

Backported to 0.2 in #4683.

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.

4 participants

@tnull@ldk-reviews-bot@ldk-claude-review-bot@TheBlueMatt
, '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

Validate Esplora merkle proof against the block header's merkle root - #4596

Merged
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
tnull:2026-05-esplora-merkle-root-validation
May 6, 2026
Merged

Validate Esplora merkle proof against the block header's merkle root#4596
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
tnull:2026-05-esplora-merkle-root-validation

Conversation

@tnull

@tnulltnull commented May 5, 2026

Copy link
Copy Markdown
Contributor

EsploraSyncClient::get_confirmed_tx parsed the SPV proof returned by the Esplora server but threw away the security check: the merkle root computed by PartialMerkleTree::extract_matches was discarded (let _ = …), and only the leaf-equality check (matches[0] == txid) remained. Anyone can construct a single-leaf partial tree advertising an arbitrary txid via PartialMerkleTree::from_txids(&[txid], &[true]), so this gate was vacuous.

Note: I recall that at implementation time we decided doing this check wasn't necessary/redundant. But, it can surely not hurt to be more strict here.

`EsploraSyncClient::get_confirmed_tx` parsed the SPV proof returned by
the Esplora server but threw away the security check: the merkle root
computed by `PartialMerkleTree::extract_matches` was discarded
(`let _ = …`), and only the leaf-equality check (`matches[0] == txid`)
remained. Anyone can construct a single-leaf partial tree advertising
an arbitrary txid via `PartialMerkleTree::from_txids(&[txid], &[true])`,
so this gate was vacuous.
A malicious or compromised Esplora server could therefore convince
`EsploraSyncClient` that any transaction was confirmed in any block by
returning `MerkleBlock { header: real_header, txn: forged_partial_tree }`,
causing LDK to feed a synthesized `ConfirmedTx` into `Confirm`
implementations such as `ChannelManager` / `ChainMonitor`. From there,
the channel-funding / closing / HTLC flows would treat the transaction
as confirmed at an attacker-chosen height, with consequences ranging
from premature state transitions to force-close races.
Capture the merkle root returned by `extract_matches` and require it
to equal `block_header.merkle_root`, matching the validation the
Electrum sibling already performs via `validate_merkle_proof`.
Co-Authored-By: HAL 9000
@ldk-reviews-bot

ldk-reviews-bot commented May 5, 2026

Copy link
Copy Markdown

👋 I see @joostjager was un-assigned.
If you'd like another reviewer assignment, please click here.

@ldk-claude-review-bot

Copy link
Copy Markdown
Collaborator

The fix is straightforward and correct. Let me verify the full diff one more time to make sure I haven't missed anything.

The change is isolated to 6 lines in esplora.rs. The logic:

  1. extract_matches result is now captured via .ok()Option<TxMerkleNode>
  2. Compared against Some(block_header.merkle_root)
  3. If extract_matches fails (returns Err), .ok() produces None, which correctly fails the check
  4. All prior validation conditions are preserved

No issues found in the code change itself. The one concern worth raising is the lack of a regression test.

No issues found.

Cross-cutting concern:

  • This is a security-critical fix but has no unit/regression test demonstrating that a forged merkle proof (one where the partial tree computes to a different root than block_header.merkle_root) is now rejected. The existing integration tests in lightning-transaction-sync/tests/integration_tests.rs use a live electrsd and don't cover adversarial server responses. A targeted unit test constructing a MerkleBlock with a mismatched root would lock in this invariant and prevent future regressions. That said, mocking the Esplora client may not be trivial, so this is a suggestion rather than a blocker.

@codecov

codecovBot commented May 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 86.22%. Comparing base (1a26867) to head (b64efcd).
⚠️ Report is 13 commits behind head on main.

Additional details and impacted files
@@ Coverage Diff @@## main #4596 +/- ##
==========================================
- Coverage 86.84% 86.22% -0.62% 
==========================================
Files 161 159 -2 Lines 109260 109170 -90 Branches 109260 109170 -90 ==========================================
- Hits 94882 94136 -746 - Misses 11797 12424 +627 - Partials 2581 2610 +29 
FlagCoverage Δ
fuzzing-fake-hashes?
fuzzing-real-hashes?
tests86.22% <ø> (+<0.01%)⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@TheBlueMatt
TheBlueMatt merged commit 81d11f7 into lightningdevkit:mainMay 6, 2026
23 of 25 checks passed
@TheBlueMatt

Copy link
Copy Markdown
Collaborator

Backported to 0.1 in #4680.

@TheBlueMatt

Copy link
Copy Markdown
Collaborator

Backported to 0.2 in #4683.

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.

4 participants

@tnull@ldk-reviews-bot@ldk-claude-review-bot@TheBlueMatt
, '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

Validate Esplora merkle proof against the block header's merkle root - #4596

Merged
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
tnull:2026-05-esplora-merkle-root-validation
May 6, 2026
Merged

Validate Esplora merkle proof against the block header's merkle root#4596
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
tnull:2026-05-esplora-merkle-root-validation

Conversation

@tnull

@tnulltnull commented May 5, 2026

Copy link
Copy Markdown
Contributor

EsploraSyncClient::get_confirmed_tx parsed the SPV proof returned by the Esplora server but threw away the security check: the merkle root computed by PartialMerkleTree::extract_matches was discarded (let _ = …), and only the leaf-equality check (matches[0] == txid) remained. Anyone can construct a single-leaf partial tree advertising an arbitrary txid via PartialMerkleTree::from_txids(&[txid], &[true]), so this gate was vacuous.

Note: I recall that at implementation time we decided doing this check wasn't necessary/redundant. But, it can surely not hurt to be more strict here.

`EsploraSyncClient::get_confirmed_tx` parsed the SPV proof returned by
the Esplora server but threw away the security check: the merkle root
computed by `PartialMerkleTree::extract_matches` was discarded
(`let _ = …`), and only the leaf-equality check (`matches[0] == txid`)
remained. Anyone can construct a single-leaf partial tree advertising
an arbitrary txid via `PartialMerkleTree::from_txids(&[txid], &[true])`,
so this gate was vacuous.
A malicious or compromised Esplora server could therefore convince
`EsploraSyncClient` that any transaction was confirmed in any block by
returning `MerkleBlock { header: real_header, txn: forged_partial_tree }`,
causing LDK to feed a synthesized `ConfirmedTx` into `Confirm`
implementations such as `ChannelManager` / `ChainMonitor`. From there,
the channel-funding / closing / HTLC flows would treat the transaction
as confirmed at an attacker-chosen height, with consequences ranging
from premature state transitions to force-close races.
Capture the merkle root returned by `extract_matches` and require it
to equal `block_header.merkle_root`, matching the validation the
Electrum sibling already performs via `validate_merkle_proof`.
Co-Authored-By: HAL 9000
@ldk-reviews-bot

ldk-reviews-bot commented May 5, 2026

Copy link
Copy Markdown

👋 I see @joostjager was un-assigned.
If you'd like another reviewer assignment, please click here.

@ldk-claude-review-bot

Copy link
Copy Markdown
Collaborator

The fix is straightforward and correct. Let me verify the full diff one more time to make sure I haven't missed anything.

The change is isolated to 6 lines in esplora.rs. The logic:

  1. extract_matches result is now captured via .ok()Option<TxMerkleNode>
  2. Compared against Some(block_header.merkle_root)
  3. If extract_matches fails (returns Err), .ok() produces None, which correctly fails the check
  4. All prior validation conditions are preserved

No issues found in the code change itself. The one concern worth raising is the lack of a regression test.

No issues found.

Cross-cutting concern:

  • This is a security-critical fix but has no unit/regression test demonstrating that a forged merkle proof (one where the partial tree computes to a different root than block_header.merkle_root) is now rejected. The existing integration tests in lightning-transaction-sync/tests/integration_tests.rs use a live electrsd and don't cover adversarial server responses. A targeted unit test constructing a MerkleBlock with a mismatched root would lock in this invariant and prevent future regressions. That said, mocking the Esplora client may not be trivial, so this is a suggestion rather than a blocker.

@codecov

codecovBot commented May 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 86.22%. Comparing base (1a26867) to head (b64efcd).
⚠️ Report is 13 commits behind head on main.

Additional details and impacted files
@@ Coverage Diff @@## main #4596 +/- ##
==========================================
- Coverage 86.84% 86.22% -0.62% 
==========================================
Files 161 159 -2 Lines 109260 109170 -90 Branches 109260 109170 -90 ==========================================
- Hits 94882 94136 -746 - Misses 11797 12424 +627 - Partials 2581 2610 +29 
FlagCoverage Δ
fuzzing-fake-hashes?
fuzzing-real-hashes?
tests86.22% <ø> (+<0.01%)⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@TheBlueMatt
TheBlueMatt merged commit 81d11f7 into lightningdevkit:mainMay 6, 2026
23 of 25 checks passed
@TheBlueMatt

Copy link
Copy Markdown
Collaborator

Backported to 0.1 in #4680.

@TheBlueMatt

Copy link
Copy Markdown
Collaborator

Backported to 0.2 in #4683.

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.

4 participants

@tnull@ldk-reviews-bot@ldk-claude-review-bot@TheBlueMatt
, '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

Validate Esplora merkle proof against the block header's merkle root - #4596

Merged
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
tnull:2026-05-esplora-merkle-root-validation
May 6, 2026
Merged

Validate Esplora merkle proof against the block header's merkle root#4596
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
tnull:2026-05-esplora-merkle-root-validation

Conversation

@tnull

@tnulltnull commented May 5, 2026

Copy link
Copy Markdown
Contributor

EsploraSyncClient::get_confirmed_tx parsed the SPV proof returned by the Esplora server but threw away the security check: the merkle root computed by PartialMerkleTree::extract_matches was discarded (let _ = …), and only the leaf-equality check (matches[0] == txid) remained. Anyone can construct a single-leaf partial tree advertising an arbitrary txid via PartialMerkleTree::from_txids(&[txid], &[true]), so this gate was vacuous.

Note: I recall that at implementation time we decided doing this check wasn't necessary/redundant. But, it can surely not hurt to be more strict here.

`EsploraSyncClient::get_confirmed_tx` parsed the SPV proof returned by
the Esplora server but threw away the security check: the merkle root
computed by `PartialMerkleTree::extract_matches` was discarded
(`let _ = …`), and only the leaf-equality check (`matches[0] == txid`)
remained. Anyone can construct a single-leaf partial tree advertising
an arbitrary txid via `PartialMerkleTree::from_txids(&[txid], &[true])`,
so this gate was vacuous.
A malicious or compromised Esplora server could therefore convince
`EsploraSyncClient` that any transaction was confirmed in any block by
returning `MerkleBlock { header: real_header, txn: forged_partial_tree }`,
causing LDK to feed a synthesized `ConfirmedTx` into `Confirm`
implementations such as `ChannelManager` / `ChainMonitor`. From there,
the channel-funding / closing / HTLC flows would treat the transaction
as confirmed at an attacker-chosen height, with consequences ranging
from premature state transitions to force-close races.
Capture the merkle root returned by `extract_matches` and require it
to equal `block_header.merkle_root`, matching the validation the
Electrum sibling already performs via `validate_merkle_proof`.
Co-Authored-By: HAL 9000
@ldk-reviews-bot

ldk-reviews-bot commented May 5, 2026

Copy link
Copy Markdown

👋 I see @joostjager was un-assigned.
If you'd like another reviewer assignment, please click here.

@ldk-claude-review-bot

Copy link
Copy Markdown
Collaborator

The fix is straightforward and correct. Let me verify the full diff one more time to make sure I haven't missed anything.

The change is isolated to 6 lines in esplora.rs. The logic:

  1. extract_matches result is now captured via .ok()Option<TxMerkleNode>
  2. Compared against Some(block_header.merkle_root)
  3. If extract_matches fails (returns Err), .ok() produces None, which correctly fails the check
  4. All prior validation conditions are preserved

No issues found in the code change itself. The one concern worth raising is the lack of a regression test.

No issues found.

Cross-cutting concern:

  • This is a security-critical fix but has no unit/regression test demonstrating that a forged merkle proof (one where the partial tree computes to a different root than block_header.merkle_root) is now rejected. The existing integration tests in lightning-transaction-sync/tests/integration_tests.rs use a live electrsd and don't cover adversarial server responses. A targeted unit test constructing a MerkleBlock with a mismatched root would lock in this invariant and prevent future regressions. That said, mocking the Esplora client may not be trivial, so this is a suggestion rather than a blocker.

@codecov

codecovBot commented May 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 86.22%. Comparing base (1a26867) to head (b64efcd).
⚠️ Report is 13 commits behind head on main.

Additional details and impacted files
@@ Coverage Diff @@## main #4596 +/- ##
==========================================
- Coverage 86.84% 86.22% -0.62% 
==========================================
Files 161 159 -2 Lines 109260 109170 -90 Branches 109260 109170 -90 ==========================================
- Hits 94882 94136 -746 - Misses 11797 12424 +627 - Partials 2581 2610 +29 
FlagCoverage Δ
fuzzing-fake-hashes?
fuzzing-real-hashes?
tests86.22% <ø> (+<0.01%)⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@TheBlueMatt
TheBlueMatt merged commit 81d11f7 into lightningdevkit:mainMay 6, 2026
23 of 25 checks passed
@TheBlueMatt

Copy link
Copy Markdown
Collaborator

Backported to 0.1 in #4680.

@TheBlueMatt

Copy link
Copy Markdown
Collaborator

Backported to 0.2 in #4683.

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.

4 participants

@tnull@ldk-reviews-bot@ldk-claude-review-bot@TheBlueMatt
, '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

Validate Esplora merkle proof against the block header's merkle root - #4596

Merged
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
tnull:2026-05-esplora-merkle-root-validation
May 6, 2026
Merged

Validate Esplora merkle proof against the block header's merkle root#4596
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
tnull:2026-05-esplora-merkle-root-validation

Conversation

@tnull

@tnulltnull commented May 5, 2026

Copy link
Copy Markdown
Contributor

EsploraSyncClient::get_confirmed_tx parsed the SPV proof returned by the Esplora server but threw away the security check: the merkle root computed by PartialMerkleTree::extract_matches was discarded (let _ = …), and only the leaf-equality check (matches[0] == txid) remained. Anyone can construct a single-leaf partial tree advertising an arbitrary txid via PartialMerkleTree::from_txids(&[txid], &[true]), so this gate was vacuous.

Note: I recall that at implementation time we decided doing this check wasn't necessary/redundant. But, it can surely not hurt to be more strict here.

`EsploraSyncClient::get_confirmed_tx` parsed the SPV proof returned by
the Esplora server but threw away the security check: the merkle root
computed by `PartialMerkleTree::extract_matches` was discarded
(`let _ = …`), and only the leaf-equality check (`matches[0] == txid`)
remained. Anyone can construct a single-leaf partial tree advertising
an arbitrary txid via `PartialMerkleTree::from_txids(&[txid], &[true])`,
so this gate was vacuous.
A malicious or compromised Esplora server could therefore convince
`EsploraSyncClient` that any transaction was confirmed in any block by
returning `MerkleBlock { header: real_header, txn: forged_partial_tree }`,
causing LDK to feed a synthesized `ConfirmedTx` into `Confirm`
implementations such as `ChannelManager` / `ChainMonitor`. From there,
the channel-funding / closing / HTLC flows would treat the transaction
as confirmed at an attacker-chosen height, with consequences ranging
from premature state transitions to force-close races.
Capture the merkle root returned by `extract_matches` and require it
to equal `block_header.merkle_root`, matching the validation the
Electrum sibling already performs via `validate_merkle_proof`.
Co-Authored-By: HAL 9000
@ldk-reviews-bot

ldk-reviews-bot commented May 5, 2026

Copy link
Copy Markdown

👋 I see @joostjager was un-assigned.
If you'd like another reviewer assignment, please click here.

@ldk-claude-review-bot

Copy link
Copy Markdown
Collaborator

The fix is straightforward and correct. Let me verify the full diff one more time to make sure I haven't missed anything.

The change is isolated to 6 lines in esplora.rs. The logic:

  1. extract_matches result is now captured via .ok()Option<TxMerkleNode>
  2. Compared against Some(block_header.merkle_root)
  3. If extract_matches fails (returns Err), .ok() produces None, which correctly fails the check
  4. All prior validation conditions are preserved

No issues found in the code change itself. The one concern worth raising is the lack of a regression test.

No issues found.

Cross-cutting concern:

  • This is a security-critical fix but has no unit/regression test demonstrating that a forged merkle proof (one where the partial tree computes to a different root than block_header.merkle_root) is now rejected. The existing integration tests in lightning-transaction-sync/tests/integration_tests.rs use a live electrsd and don't cover adversarial server responses. A targeted unit test constructing a MerkleBlock with a mismatched root would lock in this invariant and prevent future regressions. That said, mocking the Esplora client may not be trivial, so this is a suggestion rather than a blocker.

@codecov

codecovBot commented May 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 86.22%. Comparing base (1a26867) to head (b64efcd).
⚠️ Report is 13 commits behind head on main.

Additional details and impacted files
@@ Coverage Diff @@## main #4596 +/- ##
==========================================
- Coverage 86.84% 86.22% -0.62% 
==========================================
Files 161 159 -2 Lines 109260 109170 -90 Branches 109260 109170 -90 ==========================================
- Hits 94882 94136 -746 - Misses 11797 12424 +627 - Partials 2581 2610 +29 
FlagCoverage Δ
fuzzing-fake-hashes?
fuzzing-real-hashes?
tests86.22% <ø> (+<0.01%)⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@TheBlueMatt
TheBlueMatt merged commit 81d11f7 into lightningdevkit:mainMay 6, 2026
23 of 25 checks passed
@TheBlueMatt

Copy link
Copy Markdown
Collaborator

Backported to 0.1 in #4680.

@TheBlueMatt

Copy link
Copy Markdown
Collaborator

Backported to 0.2 in #4683.

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.

4 participants

@tnull@ldk-reviews-bot@ldk-claude-review-bot@TheBlueMatt
, '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

Validate Esplora merkle proof against the block header's merkle root - #4596

Merged
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
tnull:2026-05-esplora-merkle-root-validation
May 6, 2026
Merged

Validate Esplora merkle proof against the block header's merkle root#4596
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
tnull:2026-05-esplora-merkle-root-validation

Conversation

@tnull

@tnulltnull commented May 5, 2026

Copy link
Copy Markdown
Contributor

EsploraSyncClient::get_confirmed_tx parsed the SPV proof returned by the Esplora server but threw away the security check: the merkle root computed by PartialMerkleTree::extract_matches was discarded (let _ = …), and only the leaf-equality check (matches[0] == txid) remained. Anyone can construct a single-leaf partial tree advertising an arbitrary txid via PartialMerkleTree::from_txids(&[txid], &[true]), so this gate was vacuous.

Note: I recall that at implementation time we decided doing this check wasn't necessary/redundant. But, it can surely not hurt to be more strict here.

`EsploraSyncClient::get_confirmed_tx` parsed the SPV proof returned by
the Esplora server but threw away the security check: the merkle root
computed by `PartialMerkleTree::extract_matches` was discarded
(`let _ = …`), and only the leaf-equality check (`matches[0] == txid`)
remained. Anyone can construct a single-leaf partial tree advertising
an arbitrary txid via `PartialMerkleTree::from_txids(&[txid], &[true])`,
so this gate was vacuous.
A malicious or compromised Esplora server could therefore convince
`EsploraSyncClient` that any transaction was confirmed in any block by
returning `MerkleBlock { header: real_header, txn: forged_partial_tree }`,
causing LDK to feed a synthesized `ConfirmedTx` into `Confirm`
implementations such as `ChannelManager` / `ChainMonitor`. From there,
the channel-funding / closing / HTLC flows would treat the transaction
as confirmed at an attacker-chosen height, with consequences ranging
from premature state transitions to force-close races.
Capture the merkle root returned by `extract_matches` and require it
to equal `block_header.merkle_root`, matching the validation the
Electrum sibling already performs via `validate_merkle_proof`.
Co-Authored-By: HAL 9000
@ldk-reviews-bot

ldk-reviews-bot commented May 5, 2026

Copy link
Copy Markdown

👋 I see @joostjager was un-assigned.
If you'd like another reviewer assignment, please click here.

@ldk-claude-review-bot

Copy link
Copy Markdown
Collaborator

The fix is straightforward and correct. Let me verify the full diff one more time to make sure I haven't missed anything.

The change is isolated to 6 lines in esplora.rs. The logic:

  1. extract_matches result is now captured via .ok()Option<TxMerkleNode>
  2. Compared against Some(block_header.merkle_root)
  3. If extract_matches fails (returns Err), .ok() produces None, which correctly fails the check
  4. All prior validation conditions are preserved

No issues found in the code change itself. The one concern worth raising is the lack of a regression test.

No issues found.

Cross-cutting concern:

  • This is a security-critical fix but has no unit/regression test demonstrating that a forged merkle proof (one where the partial tree computes to a different root than block_header.merkle_root) is now rejected. The existing integration tests in lightning-transaction-sync/tests/integration_tests.rs use a live electrsd and don't cover adversarial server responses. A targeted unit test constructing a MerkleBlock with a mismatched root would lock in this invariant and prevent future regressions. That said, mocking the Esplora client may not be trivial, so this is a suggestion rather than a blocker.

@codecov

codecovBot commented May 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 86.22%. Comparing base (1a26867) to head (b64efcd).
⚠️ Report is 13 commits behind head on main.

Additional details and impacted files
@@ Coverage Diff @@## main #4596 +/- ##
==========================================
- Coverage 86.84% 86.22% -0.62% 
==========================================
Files 161 159 -2 Lines 109260 109170 -90 Branches 109260 109170 -90 ==========================================
- Hits 94882 94136 -746 - Misses 11797 12424 +627 - Partials 2581 2610 +29 
FlagCoverage Δ
fuzzing-fake-hashes?
fuzzing-real-hashes?
tests86.22% <ø> (+<0.01%)⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@TheBlueMatt
TheBlueMatt merged commit 81d11f7 into lightningdevkit:mainMay 6, 2026
23 of 25 checks passed
@TheBlueMatt

Copy link
Copy Markdown
Collaborator

Backported to 0.1 in #4680.

@TheBlueMatt

Copy link
Copy Markdown
Collaborator

Backported to 0.2 in #4683.

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.

4 participants

@tnull@ldk-reviews-bot@ldk-claude-review-bot@TheBlueMatt
, '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

Validate Esplora merkle proof against the block header's merkle root - #4596

Merged
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
tnull:2026-05-esplora-merkle-root-validation
May 6, 2026
Merged

Validate Esplora merkle proof against the block header's merkle root#4596
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
tnull:2026-05-esplora-merkle-root-validation

Conversation

@tnull

@tnulltnull commented May 5, 2026

Copy link
Copy Markdown
Contributor

EsploraSyncClient::get_confirmed_tx parsed the SPV proof returned by the Esplora server but threw away the security check: the merkle root computed by PartialMerkleTree::extract_matches was discarded (let _ = …), and only the leaf-equality check (matches[0] == txid) remained. Anyone can construct a single-leaf partial tree advertising an arbitrary txid via PartialMerkleTree::from_txids(&[txid], &[true]), so this gate was vacuous.

Note: I recall that at implementation time we decided doing this check wasn't necessary/redundant. But, it can surely not hurt to be more strict here.

`EsploraSyncClient::get_confirmed_tx` parsed the SPV proof returned by
the Esplora server but threw away the security check: the merkle root
computed by `PartialMerkleTree::extract_matches` was discarded
(`let _ = …`), and only the leaf-equality check (`matches[0] == txid`)
remained. Anyone can construct a single-leaf partial tree advertising
an arbitrary txid via `PartialMerkleTree::from_txids(&[txid], &[true])`,
so this gate was vacuous.
A malicious or compromised Esplora server could therefore convince
`EsploraSyncClient` that any transaction was confirmed in any block by
returning `MerkleBlock { header: real_header, txn: forged_partial_tree }`,
causing LDK to feed a synthesized `ConfirmedTx` into `Confirm`
implementations such as `ChannelManager` / `ChainMonitor`. From there,
the channel-funding / closing / HTLC flows would treat the transaction
as confirmed at an attacker-chosen height, with consequences ranging
from premature state transitions to force-close races.
Capture the merkle root returned by `extract_matches` and require it
to equal `block_header.merkle_root`, matching the validation the
Electrum sibling already performs via `validate_merkle_proof`.
Co-Authored-By: HAL 9000
@ldk-reviews-bot

ldk-reviews-bot commented May 5, 2026

Copy link
Copy Markdown

👋 I see @joostjager was un-assigned.
If you'd like another reviewer assignment, please click here.

@ldk-claude-review-bot

Copy link
Copy Markdown
Collaborator

The fix is straightforward and correct. Let me verify the full diff one more time to make sure I haven't missed anything.

The change is isolated to 6 lines in esplora.rs. The logic:

  1. extract_matches result is now captured via .ok()Option<TxMerkleNode>
  2. Compared against Some(block_header.merkle_root)
  3. If extract_matches fails (returns Err), .ok() produces None, which correctly fails the check
  4. All prior validation conditions are preserved

No issues found in the code change itself. The one concern worth raising is the lack of a regression test.

No issues found.

Cross-cutting concern:

  • This is a security-critical fix but has no unit/regression test demonstrating that a forged merkle proof (one where the partial tree computes to a different root than block_header.merkle_root) is now rejected. The existing integration tests in lightning-transaction-sync/tests/integration_tests.rs use a live electrsd and don't cover adversarial server responses. A targeted unit test constructing a MerkleBlock with a mismatched root would lock in this invariant and prevent future regressions. That said, mocking the Esplora client may not be trivial, so this is a suggestion rather than a blocker.

@codecov

codecovBot commented May 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 86.22%. Comparing base (1a26867) to head (b64efcd).
⚠️ Report is 13 commits behind head on main.

Additional details and impacted files
@@ Coverage Diff @@## main #4596 +/- ##
==========================================
- Coverage 86.84% 86.22% -0.62% 
==========================================
Files 161 159 -2 Lines 109260 109170 -90 Branches 109260 109170 -90 ==========================================
- Hits 94882 94136 -746 - Misses 11797 12424 +627 - Partials 2581 2610 +29 
FlagCoverage Δ
fuzzing-fake-hashes?
fuzzing-real-hashes?
tests86.22% <ø> (+<0.01%)⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@TheBlueMatt
TheBlueMatt merged commit 81d11f7 into lightningdevkit:mainMay 6, 2026
23 of 25 checks passed
@TheBlueMatt

Copy link
Copy Markdown
Collaborator

Backported to 0.1 in #4680.

@TheBlueMatt

Copy link
Copy Markdown
Collaborator

Backported to 0.2 in #4683.

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.

4 participants

@tnull@ldk-reviews-bot@ldk-claude-review-bot@TheBlueMatt
, '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

Validate Esplora merkle proof against the block header's merkle root - #4596

Merged
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
tnull:2026-05-esplora-merkle-root-validation
May 6, 2026
Merged

Validate Esplora merkle proof against the block header's merkle root#4596
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
tnull:2026-05-esplora-merkle-root-validation

Conversation

@tnull

@tnulltnull commented May 5, 2026

Copy link
Copy Markdown
Contributor

EsploraSyncClient::get_confirmed_tx parsed the SPV proof returned by the Esplora server but threw away the security check: the merkle root computed by PartialMerkleTree::extract_matches was discarded (let _ = …), and only the leaf-equality check (matches[0] == txid) remained. Anyone can construct a single-leaf partial tree advertising an arbitrary txid via PartialMerkleTree::from_txids(&[txid], &[true]), so this gate was vacuous.

Note: I recall that at implementation time we decided doing this check wasn't necessary/redundant. But, it can surely not hurt to be more strict here.

`EsploraSyncClient::get_confirmed_tx` parsed the SPV proof returned by
the Esplora server but threw away the security check: the merkle root
computed by `PartialMerkleTree::extract_matches` was discarded
(`let _ = …`), and only the leaf-equality check (`matches[0] == txid`)
remained. Anyone can construct a single-leaf partial tree advertising
an arbitrary txid via `PartialMerkleTree::from_txids(&[txid], &[true])`,
so this gate was vacuous.
A malicious or compromised Esplora server could therefore convince
`EsploraSyncClient` that any transaction was confirmed in any block by
returning `MerkleBlock { header: real_header, txn: forged_partial_tree }`,
causing LDK to feed a synthesized `ConfirmedTx` into `Confirm`
implementations such as `ChannelManager` / `ChainMonitor`. From there,
the channel-funding / closing / HTLC flows would treat the transaction
as confirmed at an attacker-chosen height, with consequences ranging
from premature state transitions to force-close races.
Capture the merkle root returned by `extract_matches` and require it
to equal `block_header.merkle_root`, matching the validation the
Electrum sibling already performs via `validate_merkle_proof`.
Co-Authored-By: HAL 9000
@ldk-reviews-bot

ldk-reviews-bot commented May 5, 2026

Copy link
Copy Markdown

👋 I see @joostjager was un-assigned.
If you'd like another reviewer assignment, please click here.

@ldk-claude-review-bot

Copy link
Copy Markdown
Collaborator

The fix is straightforward and correct. Let me verify the full diff one more time to make sure I haven't missed anything.

The change is isolated to 6 lines in esplora.rs. The logic:

  1. extract_matches result is now captured via .ok()Option<TxMerkleNode>
  2. Compared against Some(block_header.merkle_root)
  3. If extract_matches fails (returns Err), .ok() produces None, which correctly fails the check
  4. All prior validation conditions are preserved

No issues found in the code change itself. The one concern worth raising is the lack of a regression test.

No issues found.

Cross-cutting concern:

  • This is a security-critical fix but has no unit/regression test demonstrating that a forged merkle proof (one where the partial tree computes to a different root than block_header.merkle_root) is now rejected. The existing integration tests in lightning-transaction-sync/tests/integration_tests.rs use a live electrsd and don't cover adversarial server responses. A targeted unit test constructing a MerkleBlock with a mismatched root would lock in this invariant and prevent future regressions. That said, mocking the Esplora client may not be trivial, so this is a suggestion rather than a blocker.

@codecov

codecovBot commented May 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 86.22%. Comparing base (1a26867) to head (b64efcd).
⚠️ Report is 13 commits behind head on main.

Additional details and impacted files
@@ Coverage Diff @@## main #4596 +/- ##
==========================================
- Coverage 86.84% 86.22% -0.62% 
==========================================
Files 161 159 -2 Lines 109260 109170 -90 Branches 109260 109170 -90 ==========================================
- Hits 94882 94136 -746 - Misses 11797 12424 +627 - Partials 2581 2610 +29 
FlagCoverage Δ
fuzzing-fake-hashes?
fuzzing-real-hashes?
tests86.22% <ø> (+<0.01%)⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@TheBlueMatt
TheBlueMatt merged commit 81d11f7 into lightningdevkit:mainMay 6, 2026
23 of 25 checks passed
@TheBlueMatt

Copy link
Copy Markdown
Collaborator

Backported to 0.1 in #4680.

@TheBlueMatt

Copy link
Copy Markdown
Collaborator

Backported to 0.2 in #4683.

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.

4 participants

@tnull@ldk-reviews-bot@ldk-claude-review-bot@TheBlueMatt