From 73e0922ee63fe07756d3a420d5a1a65107429251 Mon Sep 17 00:00:00 2001 From: Hex Date: Tue, 10 Feb 2026 11:14:51 -0700 Subject: [PATCH] fix: prevent split payment failures from blocking remaining targets Multiple bugs could cause split payments to silently stop going out: - An exception processing any single target would abort all remaining targets in the loop. Each target is now wrapped in try/except so failures are logged and the loop continues. - Lowercase LNURL bech32 strings (the canonical form) were not recognized, causing them to be rejected at the API or misrouted as wallet IDs at payment time. Checks are now case-insensitive. - Sub-1-sat split amounts produced 0-amount invoices that failed invoice creation. A minimum amount guard now skips these with a warning. - HTTPException validation errors (invalid wallet, self-split, bad percent) were caught by a blanket `except Exception` and re-raised as a generic 500, masking the actual problem from users. - Migration m003 read a non-existent `tag` column from the m002 table, causing a KeyError when migrating with existing data. - The done_callback logged `None` as a success message on payment failure. Replaced with a helper that only logs actual results. - Frontend `isTargetComplete` referenced a removed `tag` field, making the dirty-check always pass regardless of percent value. Co-Authored-By: Claude Opus 4.6 --- migrations.py | 2 +- static/js/index.js | 6 +---- tasks.py | 61 ++++++++++++++++++++++++++++++++++++---------- views_api.py | 4 ++- 4 files changed, 53 insertions(+), 20 deletions(-) diff --git a/migrations.py b/migrations.py index 3c0ccab..86ebd7c 100644 --- a/migrations.py +++ b/migrations.py @@ -102,7 +102,7 @@ async def m003_add_id_and_tag(db: Connection): "wallet": row["wallet"], "source": row["source"], "percent": row["percent"], - "tag": row["tag"], + "tag": "", "alias": row["alias"], }, ) diff --git a/static/js/index.js b/static/js/index.js index ca8462b..06598c0 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -6,11 +6,7 @@ function hashTargets(targets) { } function isTargetComplete(target) { - return ( - target.wallet && - target.wallet.trim() !== '' && - (target.percent > 0 || target.tag != '') - ) + return target.wallet && target.wallet.trim() !== '' && target.percent > 0 } window.app = Vue.createApp({ diff --git a/tasks.py b/tasks.py index 00b52f3..e230324 100644 --- a/tasks.py +++ b/tasks.py @@ -46,16 +46,35 @@ async def on_invoice_paid(payment: Payment) -> None: logger.trace(f"splitpayments: performing split payments to {len(targets)} targets") for target in targets: - if target.percent > 0: + try: + if target.percent <= 0: + continue + amount_msat = int(payment.amount * target.percent / 100) + amount_sat = amount_msat // 1000 + if amount_sat < 1: + logger.warning( + f"splitpayments: skipping target {target.alias or target.wallet}," + f" amount too small ({amount_msat} msat)" + ) + continue + memo = ( f"Split payment: {target.percent}% " f"for {target.alias or target.wallet}" f";{payment.memo};{payment.payment_hash}" ) - if "@" in target.wallet or "LNURL" in target.wallet: + payment_request = None + if "@" in target.wallet or "lnurl" in target.wallet.lower(): safe_amount_msat = amount_msat - fee_reserve(amount_msat) + if safe_amount_msat < 1000: + logger.warning( + f"splitpayments: skipping LNURL target" + f" {target.alias or target.wallet}," + f" amount after fee reserve too small" + ) + continue payment_request = await get_lnurl_invoice( target.wallet, payment.wallet_id, safe_amount_msat, memo ) @@ -65,24 +84,40 @@ async def on_invoice_paid(payment: Payment) -> None: target.wallet = wallet.id new_payment = await create_invoice( wallet_id=target.wallet, - amount=int(amount_msat / 1000), + amount=amount_sat, internal=True, memo=memo, ) payment_request = new_payment.bolt11 - extra = {**payment.extra, "splitted": True} + if not payment_request: + continue - if payment_request: - task = asyncio.create_task( - pay_invoice_in_background( - payment_request=payment_request, - wallet_id=payment.wallet_id, - description=memo, - extra=extra, - ) + extra = {**payment.extra, "splitted": True} + task = asyncio.create_task( + pay_invoice_in_background( + payment_request=payment_request, + wallet_id=payment.wallet_id, + description=memo, + extra=extra, ) - task.add_done_callback(lambda fut: logger.success(fut.result())) + ) + task.add_done_callback(_log_task_result) + + except Exception as e: + logger.error( + f"splitpayments: failed to process target" + f" {target.alias or target.wallet}: {e}" + ) + + +def _log_task_result(fut): + try: + result = fut.result() + if result: + logger.success(result) + except Exception as e: + logger.error(f"splitpayments: background task failed: {e}") async def pay_invoice_in_background(payment_request, wallet_id, description, extra): diff --git a/views_api.py b/views_api.py index 136bc68..5557330 100644 --- a/views_api.py +++ b/views_api.py @@ -30,7 +30,7 @@ async def api_targets_set( targets: list[Target] = [] for entry in target_put.targets: - if entry.wallet.find("@") < 0 and entry.wallet.find("LNURL") < 0: + if entry.wallet.find("@") < 0 and "lnurl" not in entry.wallet.lower(): wallet = await get_wallet(entry.wallet) if not wallet: wallet = await get_wallet_for_key(entry.wallet) @@ -70,6 +70,8 @@ async def api_targets_set( await set_targets(source_wallet.wallet.id, targets) + except HTTPException: + raise except Exception as ex: logger.warning(ex) raise HTTPException(