Skip to content

Commit 9791dbc

Browse files
committed
wallet: share prevout lookups within one paid-detection pass
_is_onchain_invoice_paid redoes the prevout lookup and per-prevout get_tx_height for each invoice, even though invoices often share a scriptpubkey (repeat payments to the same destination). One _update_onchain_invoice_paid_detection pass over such invoices does the identical lookup for each of them, and unconfirmed invoices (conf==0, e.g. during initial sync before merkle proofs arrive) cannot enter the paid-keys cache, so during sync every tx addition pays the full cost again, on the asyncio event loop thread. Memoize the per-scriptpubkey lookup for the duration of one detection pass; the per-invoice height filtering still applies on top. The cache only exists while a pass runs, so it needs no invalidation: adb state cannot change mid-pass, as both mutate on the asyncio thread. It also covers the second lookup that get_invoice_status does for invoices that cannot be cache-seeded (unconfirmed/unpaid). On a real wallet (686 invoices, 572 sharing one destination): - detection pass at wallet open: ~18s -> ~0.6s (~36s on master) - one adb_added_tx event while invoices are unconfirmed (initial sync): ~18s -> ~0.6s (~35s on master) A full sync from seed with these invoices present went from 12+ hours (GUI frozen, servers timing out) to ~5 minutes.
1 parent d756248 commit 9791dbc

2 files changed

Lines changed: 95 additions & 35 deletions

File tree

electrum/wallet.py

Lines changed: 55 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,8 @@ def __init__(self, db: WalletDB, *, config: SimpleConfig):
423423
self._last_full_history = None
424424
self._tx_parents_cache = {}
425425
self._paid_invoice_keys_cache = set() # type: Set[str]
426+
# only set while an _update_onchain_invoice_paid_detection pass runs, see there
427+
self._invoice_prevouts_cache = None # type: Optional[Dict[bytes, Sequence[Tuple[str, int, int, int]]]]
426428
self._coin_price_cache = {}
427429
self._default_labels = {}
428430
self._accounting_addresses = set() # addresses counted as ours after successful sweep
@@ -1376,33 +1378,39 @@ def _prepare_onchain_invoice_paid_detection(self):
13761378
self._update_onchain_invoice_paid_detection(self._invoices.keys())
13771379

13781380
def _update_onchain_invoice_paid_detection(self, invoice_keys: Iterable[str]) -> None:
1379-
for invoice_key in invoice_keys:
1380-
invoice = self._invoices.get(invoice_key)
1381-
if not invoice:
1382-
continue
1383-
# clear the cache first so self.get_invoice_status takes the slow path,
1384-
# which is needed to detect paid->unpaid transitions (e.g. reorgs)
1385-
self._paid_invoice_keys_cache.discard(invoice_key)
1386-
if invoice.is_lightning():
1387-
is_paid_lightning = bool(self.lnworker and self.lnworker.get_invoice_status(invoice) == PR_PAID)
1388-
if is_paid_lightning:
1389-
self._paid_invoice_keys_cache.add(invoice_key)
1390-
if is_paid_lightning or not invoice.get_address():
1381+
# invoices often share scriptpubkeys; share the prevout lookups across this pass,
1382+
# including the ones done by self.get_invoice_status below
1383+
self._invoice_prevouts_cache = {}
1384+
try:
1385+
for invoice_key in invoice_keys:
1386+
invoice = self._invoices.get(invoice_key)
1387+
if not invoice:
13911388
continue
1392-
is_paid, conf_needed, relevant_txs = self._is_onchain_invoice_paid(invoice)
1393-
if is_paid:
1394-
for txid in relevant_txs:
1395-
self._invoices_from_txid_map[txid].add(invoice_key)
1396-
# re-add to the cache, so that self.get_invoice_status below short-circuits
1397-
# instead of redoing the prevout scan. not for lightning invoices:
1398-
# their LN status takes precedence there (e.g. an in-flight LN payment)
1399-
if not invoice.is_lightning() and conf_needed:
1400-
self._paid_invoice_keys_cache.add(invoice_key)
1401-
for txout in invoice.get_outputs():
1402-
self._invoices_from_scriptpubkey_map[txout.scriptpubkey].add(invoice_key)
1403-
# update invoice status
1404-
status = self.get_invoice_status(invoice)
1405-
util.trigger_callback('invoice_status', self, invoice_key, status)
1389+
# clear the cache first so self.get_invoice_status takes the slow path,
1390+
# which is needed to detect paid->unpaid transitions (e.g. reorgs)
1391+
self._paid_invoice_keys_cache.discard(invoice_key)
1392+
if invoice.is_lightning():
1393+
is_paid_lightning = bool(self.lnworker and self.lnworker.get_invoice_status(invoice) == PR_PAID)
1394+
if is_paid_lightning:
1395+
self._paid_invoice_keys_cache.add(invoice_key)
1396+
if is_paid_lightning or not invoice.get_address():
1397+
continue
1398+
is_paid, conf_needed, relevant_txs = self._is_onchain_invoice_paid(invoice)
1399+
if is_paid:
1400+
for txid in relevant_txs:
1401+
self._invoices_from_txid_map[txid].add(invoice_key)
1402+
# re-add to the cache, so that self.get_invoice_status below short-circuits
1403+
# instead of redoing the prevout scan. not for lightning invoices:
1404+
# their LN status takes precedence there (e.g. an in-flight LN payment)
1405+
if not invoice.is_lightning() and conf_needed:
1406+
self._paid_invoice_keys_cache.add(invoice_key)
1407+
for txout in invoice.get_outputs():
1408+
self._invoices_from_scriptpubkey_map[txout.scriptpubkey].add(invoice_key)
1409+
# update invoice status
1410+
status = self.get_invoice_status(invoice)
1411+
util.trigger_callback('invoice_status', self, invoice_key, status)
1412+
finally:
1413+
self._invoice_prevouts_cache = None
14061414

14071415
def _is_onchain_invoice_paid(self, invoice: BaseInvoice) -> Tuple[bool, Optional[int], Sequence[str]]:
14081416
"""Returns whether on-chain invoice/request is satisfied, num confs required txs have,
@@ -1419,15 +1427,12 @@ def _is_onchain_invoice_paid(self, invoice: BaseInvoice) -> Tuple[bool, Optional
14191427
conf_needed = None # type: Optional[int]
14201428
with self.lock:
14211429
for invoice_scriptpubkey, invoice_amt in invoice_amounts.items():
1422-
scripthash = bitcoin.script_to_scripthash(invoice_scriptpubkey)
1423-
prevouts_and_values = self.db.get_prevouts_by_scripthash(scripthash)
14241430
confs_and_values = []
1425-
for prevout, v in prevouts_and_values:
1426-
relevant_txs.add(prevout.txid.hex())
1427-
tx_height = self.adb.get_tx_height(prevout.txid.hex())
1428-
if 0 < tx_height.height() <= invoice.height: # exclude txs older than invoice
1431+
for txid, height, conf, v in self._get_prevout_infos_for_scriptpubkey(invoice_scriptpubkey):
1432+
relevant_txs.add(txid)
1433+
if 0 < height <= invoice.height: # exclude txs older than invoice
14291434
continue
1430-
confs_and_values.append((tx_height.conf or 0, v))
1435+
confs_and_values.append((conf, v))
14311436
# check that there is at least one TXO, and that they pay enough.
14321437
# note: "at least one TXO" check is needed for zero amount invoice (e.g. OP_RETURN)
14331438
vsum = 0
@@ -1440,6 +1445,23 @@ def _is_onchain_invoice_paid(self, invoice: BaseInvoice) -> Tuple[bool, Optional
14401445
is_paid = False
14411446
return is_paid, conf_needed, list(relevant_txs)
14421447

1448+
def _get_prevout_infos_for_scriptpubkey(self, scriptpubkey: bytes) -> Sequence[Tuple[str, int, int, int]]:
1449+
"""Returns (txid, height, conf, value) for each prevout paying scriptpubkey.
1450+
Memoized while an _update_onchain_invoice_paid_detection pass runs (see there).
1451+
"""
1452+
cache = self._invoice_prevouts_cache
1453+
infos = cache.get(scriptpubkey) if cache is not None else None
1454+
if infos is None:
1455+
scripthash = bitcoin.script_to_scripthash(scriptpubkey)
1456+
infos = []
1457+
for prevout, v in self.db.get_prevouts_by_scripthash(scripthash):
1458+
txid = prevout.txid.hex()
1459+
tx_height = self.adb.get_tx_height(txid)
1460+
infos.append((txid, tx_height.height(), tx_height.conf or 0, v))
1461+
if cache is not None:
1462+
cache[scriptpubkey] = infos
1463+
return infos
1464+
14431465
def is_onchain_invoice_paid(self, invoice: BaseInvoice) -> Tuple[bool, Optional[int]]:
14441466
is_paid, conf_needed, relevant_txs = self._is_onchain_invoice_paid(invoice)
14451467
return is_paid, conf_needed

tests/test_invoices.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -275,15 +275,15 @@ def _make_wallet(self):
275275
wallet.adb.receive_tx_callback(funding_tx, tx_height=TX_HEIGHT_UNCONFIRMED)
276276
return wallet
277277

278-
def _make_outgoing_invoice(self, dest_addr: str, amount_sat: int, *, t: int = 1700000000):
278+
def _make_outgoing_invoice(self, dest_addr: str, amount_sat: int, *, t: int = 1700000000, height: int = 0):
279279
outputs = [PartialTxOutput.from_address_and_value(dest_addr, amount_sat)]
280280
return Invoice(
281281
amount_msat=amount_sat * 1000,
282282
message="",
283283
time=t,
284284
exp=LN_EXPIRY_NEVER,
285285
outputs=outputs,
286-
height=0,
286+
height=height,
287287
lightning_invoice=None,
288288
)
289289

@@ -505,6 +505,44 @@ def spy(invoice):
505505
"tx verification must not rescan an invoice already cached as PR_PAID")
506506
self.assertEqual(PR_PAID, wallet.get_invoice_status(inv))
507507

508+
async def test_detection_pass_looks_up_each_scripthash_once(self):
509+
"""Invoices sharing a destination scriptpubkey must not each redo the same
510+
prevout lookup within one detection pass; the per-invoice height filtering
511+
must still apply on top of the shared lookup."""
512+
wallet = self._make_wallet()
513+
dest = "tb1qmjzmg8nd4z56ar4fpngzsr6euktrhnjg9td385"
514+
invs = [self._make_outgoing_invoice(dest, 1_000 + i, t=1700000000 + i) for i in range(5)]
515+
# This one was created above the paying tx's height, so the tx must not count for it.
516+
inv_late = self._make_outgoing_invoice(dest, 1_000, t=1700000010, height=1005)
517+
for inv in invs + [inv_late]:
518+
wallet.save_invoice(inv, write_to_disk=False)
519+
# Pay the shared destination (confirmed at height 1001).
520+
outputs = [PartialTxOutput.from_address_and_value(dest, 50_000)]
521+
tx = wallet.make_unsigned_transaction(outputs=outputs, fee_policy=FixedFeePolicy(1000))
522+
wallet.sign_transaction(tx, password=None)
523+
wallet.adb.receive_tx_callback(tx, tx_height=TX_HEIGHT_UNCONFIRMED)
524+
wallet.db.put('stored_height', 1010)
525+
wallet.adb.add_verified_tx(tx.txid(), TxMinedInfo(_height=1001, timestamp=1700000001, txpos=1, header_hash="01"*32))
526+
527+
# Track the prevout lookups.
528+
looked_up = []
529+
orig = wallet.db.get_prevouts_by_scripthash
530+
def spy(scripthash):
531+
looked_up.append(scripthash)
532+
return orig(scripthash)
533+
wallet.db.get_prevouts_by_scripthash = spy
534+
535+
wallet._paid_invoice_keys_cache.clear() # force the slow path
536+
wallet._prepare_onchain_invoice_paid_detection()
537+
538+
self.assertEqual(1, len(looked_up),
539+
"one shared scriptpubkey must be looked up exactly once per pass")
540+
# The shared lookup must not change the per-invoice outcomes.
541+
for inv in invs:
542+
self.assertIn(inv.get_id(), wallet._paid_invoice_keys_cache)
543+
self.assertNotIn(inv_late.get_id(), wallet._paid_invoice_keys_cache)
544+
self.assertEqual(PR_UNPAID, wallet.get_invoice_status(inv_late))
545+
508546
async def test_paid_keys_demoted_on_reorg(self):
509547
"""A reorg unverifying the paying tx must remove the invoice from the cache,
510548
otherwise get_invoice_status would keep returning a stale PR_PAID."""

0 commit comments

Comments
 (0)