Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion electrum/lnchannel.py
Original file line number Diff line number Diff line change
Expand Up @@ -1479,7 +1479,7 @@ def extract_preimage_from_htlc_txin(self, txin: TxInput, *, is_deeply_mined: boo
# small value htlcs: even a large htlc might not appear in the outgoing channel's ctx, e.g. maybe it was
# not committed yet - we should still make sure it gets removed on the incoming channel. (see #9631)
if preimage:
self.lnworker.save_preimage(payment_hash, preimage)
self.lnworker.save_preimage(payment_hash, preimage, mark_as_public=True)
for htlc, is_sent in found.values():
if is_sent:
self.lnworker.htlc_fulfilled(self, payment_hash, htlc.htlc_id)
Expand Down Expand Up @@ -1720,6 +1720,7 @@ def settle_htlc(self, preimage: bytes, htlc_id: int) -> None:
assert htlc_id not in self.hm.log[REMOTE]['settles']
self.hm.send_settle(htlc_id)
self.htlc_settle_time[htlc_id] = now()
self.lnworker.save_preimage(htlc.payment_hash, preimage, mark_as_public=True)

def get_payment_hash(self, htlc_id: int) -> bytes:
htlc = self.hm.get_htlc_by_id(LOCAL, htlc_id)
Expand All @@ -1737,6 +1738,7 @@ def receive_htlc_settle(self, preimage: bytes, htlc_id: int) -> None:
assert htlc_id not in self.hm.log[LOCAL]['settles']
with self.db_lock:
self.hm.recv_settle(htlc_id)
self.lnworker.save_preimage(htlc.payment_hash, preimage, mark_as_public=True)

def fail_htlc(self, htlc_id: int) -> None:
"""Fail a pending received HTLC.
Expand Down
1 change: 0 additions & 1 deletion electrum/lnpeer.py
Original file line number Diff line number Diff line change
Expand Up @@ -1983,7 +1983,6 @@ def on_update_fulfill_htlc(self, chan: Channel, payload):
f"chan={chan.get_id_for_log()}. {htlc_id=}. {chan.get_state()=!r}. {chan.peer_state=!r}")
return
chan.receive_htlc_settle(preimage, htlc_id) # TODO handle exc and maybe fail channel (e.g. bad htlc_id)
self.lnworker.save_preimage(payment_hash, preimage)
self.maybe_send_commitment(chan)

def on_update_fail_malformed_htlc(self, chan: Channel, payload):
Expand Down
23 changes: 20 additions & 3 deletions electrum/lnsweep.py
Original file line number Diff line number Diff line change
Expand Up @@ -480,14 +480,22 @@ def _maybe_reveal_preimage_for_htlc(
htlc: 'UpdateAddHtlc',
sweep_info_name: str,
) -> Tuple[Optional[bytes], Optional[KeepWatchingTXO]]:
"""Given a Remote-added-HTLC, return the preimage if it's okay to reveal it on-chain."""
if not chan.lnworker.is_complete_mpp(htlc.payment_hash):
"""Given a Remote-added-HTLC, return the preimage if it's okay to reveal it on-chain.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

lnsweep desperately needs unit tests. I have a WIP branch to build the architecture for it but we can merge this before I get to finish that.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I have tested the current branch using regtest (using the 'timebomb' approach).
It behaves well.


note: to be safe, even if we don't/can't reveal the preimage now, we should tell lnwatcher to
keep watching this HTLC at least until its CLTV, in case circumstances change.
"""
if not chan.lnworker.is_preimage_public(htlc.payment_hash) and not chan.lnworker.is_complete_mpp(htlc.payment_hash):
# - do not redeem this, it might publish the preimage of an incomplete MPP
# - OTOH maybe this chan just got closed, and we are still receiving new htlcs
# for this MPP set. So the MPP set might still transition to complete!
# The MPP_TIMEOUT is only around 2 minutes, so this window is short.
# The default keep_watching logic in lnwatcher is sufficient to call us again.
return None, None
keep_watching_txo = KeepWatchingTXO(
name=sweep_info_name + "_preimage_not_public",
until_height=htlc.cltv_abs,
)
return None, keep_watching_txo
if htlc.payment_hash.hex() in chan.lnworker.dont_settle_htlcs:
# we should not reveal the preimage *for now*, but we might still decide to reveal it later
keep_watching_txo = KeepWatchingTXO(
Expand All @@ -496,6 +504,15 @@ def _maybe_reveal_preimage_for_htlc(
)
return None, keep_watching_txo
preimage = chan.lnworker.get_preimage(htlc.payment_hash)
if preimage is None:
keep_watching_txo = KeepWatchingTXO(
name=sweep_info_name + "_preimage_missing",
until_height=htlc.cltv_abs,
)
return None, keep_watching_txo
# this preimage will be revealed
assert preimage
chan.lnworker.save_preimage(htlc.payment_hash, preimage, mark_as_public=True)
return preimage, None
Comment thread
SomberNight marked this conversation as resolved.


Expand Down
41 changes: 34 additions & 7 deletions electrum/lnworker.py
Original file line number Diff line number Diff line change
Expand Up @@ -1022,7 +1022,7 @@ def __init__(self, wallet: 'Abstract_Wallet', xprv, *, features: LnFeatures = No
self.lnrater: LNRater = None
# "RHASH:direction" -> amount_msat, status, min_final_cltv_delta, expiry_delay, creation_ts, invoice_features
self.payment_info = self.db.get_dict('lightning_payments') # type: dict[str, Tuple[Optional[int], int, int, int, int, int]]
self._preimages = self.db.get_dict('lightning_preimages') # RHASH -> preimage
self._preimages = self.db.get_dict('lightning_preimages') # RHASH -> (preimage, is_public)
self._bolt11_cache = {}
# note: this sweep_address is only used as fallback; as it might result in address-reuse
self.logs = defaultdict(list) # type: Dict[str, List[HtlcLog]] # key is RHASH # (not persisted)
Expand Down Expand Up @@ -2699,19 +2699,32 @@ def delete_payment_bundle(
del self._payment_bundles_pkey_to_canon[pkey]
del self._payment_bundles_canon_to_pkeylist[canon_pkey]

def save_preimage(self, payment_hash: bytes, preimage: bytes, *, write_to_disk: bool = True):
def save_preimage(
self,
payment_hash: bytes,
preimage: bytes,
*,
write_to_disk: bool = True,
mark_as_public: bool = False, # see is_preimage_public
):
assert isinstance(payment_hash, bytes), f"expected bytes, but got {type(payment_hash)}"
assert isinstance(preimage, bytes), f"expected bytes, but got {type(preimage)}"
if sha256(preimage) != payment_hash:
raise Exception("tried to save incorrect preimage for payment_hash")
if self._preimages.get(payment_hash.hex()) is not None:
return # we already have this preimage
self.logger.debug(f"saving preimage for {payment_hash.hex()}")
self._preimages[payment_hash.hex()] = preimage.hex()
old_tuple = _, old_is_public = self._preimages.get(payment_hash.hex(), (None, False))
mark_as_public |= old_is_public # disallow True->False transition
# sanity checks and conversions done.
new_tuple = preimage.hex(), mark_as_public
if old_tuple == new_tuple: # no change
return
self.logger.debug(f"saving preimage for {payment_hash.hex()} (public={mark_as_public})")
self._preimages[payment_hash.hex()] = new_tuple
Comment on lines +2702 to +2721
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

also, lnworker.save_preimage could take an additional parameter for this boolean.

I changed to this, as the previous API was too error-prone - there was already a bug in this branch due to that. (ordering of calls to lnworker.save_preimage vs lnworker.mark_preimage_as_public mattered)

Unfortunately, to keep the API (for callers) simple, I had to put quite a bit of complexity inside the implementation of save_preimage. Still, I think it's ~okay.

Copy link
Copy Markdown
Member

@ecdsa ecdsa Feb 25, 2026

Choose a reason for hiding this comment

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

regarding complexity, why does save_preimage require the payment_hash as parameter?
I guess we could kill that parameter, although the caller would probably want to check the hash in some cases.

Copy link
Copy Markdown
Member Author

@SomberNight SomberNight Feb 25, 2026

Choose a reason for hiding this comment

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

No substantial reason. Indeed it is a final sanity check that tests the preimage is correct.
Anyway, I would keep it as-is for now.

if write_to_disk:
self.wallet.save_db()

def get_preimage(self, payment_hash: bytes) -> Optional[bytes]:
assert isinstance(payment_hash, bytes), f"expected bytes, but got {type(payment_hash)}"
preimage_hex = self._preimages.get(payment_hash.hex())
preimage_hex, _ = self._preimages.get(payment_hash.hex(), (None, None))
if preimage_hex is None:
return None
preimage_bytes = bytes.fromhex(preimage_hex)
Expand All @@ -2723,6 +2736,20 @@ def get_preimage_hex(self, payment_hash: str) -> Optional[str]:
preimage_bytes = self.get_preimage(bytes.fromhex(payment_hash)) or b""
return preimage_bytes.hex() or None

def is_preimage_public(self, payment_hash: bytes) -> bool:
"""If another LN node knows a preimage besides us, we consider it public.
If a preimage is public, it is safe to reveal it in an arbitrary context.

For example, if there is a pending incoming partial MPP for an invoice we created,
we must not reveal the preimage, otherwise we will get paid less than invoice amount.
What if there is a force-close around that time? When is it safe to reveal the preimage on-chain?
e.g. if we already revealed the preimage either offchain or onchain, it is fine to reveal it again.
"""
assert isinstance(payment_hash, bytes), f"expected bytes, but got {type(payment_hash)}"
preimage_hex, is_public = self._preimages.get(payment_hash.hex(), (None, None))
assert preimage_hex is not None
return bool(is_public)

def get_payment_info(self, payment_hash: bytes, *, direction: lnutil.Direction) -> Optional[PaymentInfo]:
"""returns None if payment_hash is a payment we are forwarding"""
key = PaymentInfo.calc_db_key(payment_hash_hex=payment_hash.hex(), direction=direction)
Expand Down
2 changes: 1 addition & 1 deletion electrum/submarine_swaps.py
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,7 @@ async def _claim_swap(self, swap: SwapData) -> None:
if preimage:
swap.preimage = preimage
self.logger.info(f'found preimage: {preimage.hex()}')
self.lnworker.save_preimage(swap.payment_hash, preimage)
self.lnworker.save_preimage(swap.payment_hash, preimage, mark_as_public=True)
else:
# this is our refund tx
if spent_height > 0:
Expand Down
13 changes: 12 additions & 1 deletion electrum/wallet_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ def __init__(self, wallet_db: 'WalletDB'):
# seed_version is now used for the version of the wallet file
OLD_SEED_VERSION = 4 # electrum versions < 2.0
NEW_SEED_VERSION = 11 # electrum versions >= 2.0
FINAL_SEED_VERSION = 67 # electrum >= 2.7 will set this to prevent
FINAL_SEED_VERSION = 68 # electrum >= 2.7 will set this to prevent
# old versions from overwriting new format


Expand Down Expand Up @@ -242,6 +242,7 @@ def upgrade(self):
self._convert_version_65()
self._convert_version_66()
self._convert_version_67()
self._convert_version_68()
self.put('seed_version', FINAL_SEED_VERSION) # just to be sure

def _convert_wallet_type(self):
Expand Down Expand Up @@ -1354,6 +1355,16 @@ def _convert_version_67(self):
self.data['channels'] = channels
self.data['seed_version'] = 67

def _convert_version_68(self):
if not self._is_upgrade_method_needed(67, 67):
return
old_preimages = self.data.get('lightning_preimages', {})
new_preimages = {}
for _hash, preimage in old_preimages.items():
new_preimages[_hash] = (preimage, False)
Comment thread
SomberNight marked this conversation as resolved.
self.data['lightning_preimages'] = new_preimages
self.data['seed_version'] = 68

def _convert_imported(self):
if not self._is_upgrade_method_needed(0, 13):
return
Expand Down
2 changes: 1 addition & 1 deletion tests/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -571,7 +571,7 @@ async def test_hold_invoice_commands(self, mock_save_db):
wallet=wallet,
)
assert settle_result['settled'] == payment_hash
assert wallet.lnworker._preimages[payment_hash] == preimage.hex()
assert wallet.lnworker._preimages[payment_hash][0] == preimage.hex()
with (mock.patch.object(
wallet.lnworker,
'get_payment_value',
Expand Down
5 changes: 3 additions & 2 deletions tests/test_lnpeer.py
Original file line number Diff line number Diff line change
Expand Up @@ -472,11 +472,12 @@ async def _activate_trampoline(self, w: MockLNWallet):
def prepare_recipient(self, w2, payment_hash, test_hold_invoice, test_failure):
if not test_hold_invoice and not test_failure:
return
preimage = bytes.fromhex(w2._preimages.pop(payment_hash.hex()))
preimage_hex, is_public = w2._preimages.pop(payment_hash.hex())
preimage = bytes.fromhex(preimage_hex)
if test_hold_invoice:
async def cb(payment_hash):
if not test_failure:
w2.save_preimage(payment_hash, preimage)
w2.save_preimage(payment_hash, preimage, mark_as_public=is_public)
else:
raise OnionRoutingFailure(code=OnionFailureCode.INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS, data=b'')
w2.register_hold_invoice(payment_hash, cb)
Expand Down