From 0bdee0f62b3d2b5f35bd7582f206b2a90033acc7 Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 14 May 2026 17:46:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(v2):=20LP=20auto-forward=20to=20LN=20addre?= =?UTF-8?q?ss=20(P6=20=E2=80=94=20closes=20#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes satmachineadmin#8 — operator-configured LP autoforward to an external Lightning address. The data path was already in place from P0d (autoforward_enabled + autoforward_ln_address on dca_clients); this commit wires the actual outbound LN-address payment. Flow (in distribution._attempt_autoforward, called from the DCA leg path): 1. DCA leg lands in LP's LNbits wallet (regular internal transfer) 2. If client.autoforward_enabled AND autoforward_ln_address set: a. Wrap address in lnurl.LnAddress b. Resolve to bolt11 via lnbits.core.services.lnurl.get_pr_from_lnurl c. Pay bolt11 from LP's wallet via pay_invoice d. Record a leg_type='autoforward' dca_payments row with destination_ln_address set 3. On ANY failure (malformed addr, LNURL resolution fail, payment timeout): log warning, mark the autoforward leg 'failed', and leave sats in the LP's LNbits wallet — the explicit safety constraint from the original issue. Audit: every autoforward attempt records a row (success or fail) so operators can see in payment history which forwards landed externally vs which left sats in LNbits. The destination_ln_address column on dca_payments was already nullable to support this use case. Safety guards: - Skip autoforward if the DCA leg itself failed (nothing to forward). - _attempt_autoforward never re-raises — failed forwarding must not abort subsequent DCA legs for other LPs at this machine. - Sats only move from the LP's wallet (which they own), never from the operator's or super's wallets. Refactor: extracted _pay_one_dca_leg from _pay_dca_distributions to keep the outer function under the C901 complexity limit. 72/72 tests pass. Refs: aiolabs/satmachineadmin#9, closes #8 (autoforward feature request) — marked once verified end-to-end with a real LN address. Co-Authored-By: Claude Opus 4.7 (1M context) --- distribution.py | 133 +++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 114 insertions(+), 19 deletions(-) diff --git a/distribution.py b/distribution.py index e45abfc..1941838 100644 --- a/distribution.py +++ b/distribution.py @@ -27,6 +27,8 @@ from datetime import datetime, timezone from typing import List from lnbits.core.services import create_invoice, pay_invoice +from lnbits.core.services.lnurl import get_pr_from_lnurl +from lnurl import LnAddress from loguru import logger from .calculations import ( @@ -470,35 +472,128 @@ async def _pay_dca_distributions( remaining_fiat = client_balances[client_id] cap_sats = int(remaining_fiat * float(settlement.exchange_rate)) capped_allocations[client_id] = min(raw_sats, cap_sats) - # Pay each capped allocation. client_by_id = {c.id: c for c in clients} for client_id, amount_sats in capped_allocations.items(): - if amount_sats <= 0: - continue - client = client_by_id[client_id] - amount_fiat = round(amount_sats / float(settlement.exchange_rate), 2) - memo = ( - f"DCA: {amount_sats} sats • {amount_fiat:.2f} {settlement.fiat_code}" - ) - await _pay_internal( - settlement=settlement, - machine=machine, - leg_type="dca", - client_id=client.id, - destination_wallet_id=client.wallet_id, - amount_sats=amount_sats, - amount_fiat=amount_fiat, - exchange_rate=float(settlement.exchange_rate), - memo=memo, - errors=errors, + await _pay_one_dca_leg( + settlement, machine, client_by_id[client_id], amount_sats, errors ) +async def _pay_one_dca_leg( + settlement: DcaSettlement, + machine: Machine, + client: DcaClient, + amount_sats: int, + errors: List[str], +) -> None: + """Pay a single DCA leg + best-effort autoforward.""" + if amount_sats <= 0: + return + amount_fiat = round(amount_sats / float(settlement.exchange_rate), 2) + memo = ( + f"DCA: {amount_sats} sats • {amount_fiat:.2f} {settlement.fiat_code}" + ) + dca_leg = await _pay_internal( + settlement=settlement, + machine=machine, + leg_type="dca", + client_id=client.id, + destination_wallet_id=client.wallet_id, + amount_sats=amount_sats, + amount_fiat=amount_fiat, + exchange_rate=float(settlement.exchange_rate), + memo=memo, + errors=errors, + ) + # Best-effort auto-forward to LP's external LN address (closes + # satmachineadmin#8). Skip if the DCA leg failed (nothing to forward). + # If autoforward fails, sats stay in the LP's LNbits wallet — the + # explicit safety constraint. + if ( + dca_leg is not None + and dca_leg.status == "completed" + and client.autoforward_enabled + and client.autoforward_ln_address + ): + await _attempt_autoforward(client, machine, settlement, amount_sats) + + # ============================================================================= # Internal transfer helper # ============================================================================= +async def _attempt_autoforward( + client: DcaClient, + machine: Machine, + settlement: DcaSettlement, + amount_sats: int, +) -> None: + """LP auto-forward (best-effort) — closes satmachineadmin#8. + + Resolves the LP's configured LN address, requests a bolt11 invoice for + the DCA leg's sat amount, and pays it from the LP's LNbits wallet. Each + attempt records a dca_payments row with leg_type='autoforward' for + audit, regardless of outcome. + + Safety: on any failure (malformed address, LNURL resolution fail, + payment timeout, etc.) we log a warning and leave the sats in the LP's + LNbits wallet. The LP can move them manually via the LNbits UI. We + never re-raise; failed forwarding must not block subsequent legs. + """ + address = client.autoforward_ln_address + if not address: + return + leg = await create_dca_payment( + CreateDcaPaymentData( + settlement_id=settlement.id, + client_id=client.id, + machine_id=machine.id, + operator_user_id=machine.operator_user_id, + leg_type="autoforward", + destination_wallet_id=None, + destination_ln_address=address, + amount_sats=amount_sats, + amount_fiat=None, + exchange_rate=None, + transaction_time=datetime.now(timezone.utc), + external_payment_hash=None, + ) + ) + try: + lnaddr = LnAddress(address) + bolt11 = await get_pr_from_lnurl( + lnurl=lnaddr, + amount_msat=amount_sats * 1000, + comment=f"satmachine autoforward — {machine.machine_npub[:12]}", + ) + paid = await pay_invoice( + wallet_id=client.wallet_id, + payment_request=bolt11, + description=f"satmachine autoforward → {address}", + tag=_payment_tag(machine), + extra={ + "satmachine_leg": "autoforward", + "satmachine_settlement_id": settlement.id, + "satmachine_machine_npub": machine.machine_npub, + "satmachine_destination": address, + }, + ) + await update_payment_status( + leg.id, "completed", paid.payment_hash, None + ) + logger.info( + f"distribution: autoforward {amount_sats} sats from client " + f"{client.id} → {address} OK" + ) + except Exception as exc: + logger.warning( + f"distribution: autoforward FAILED for client {client.id} " + f"→ {address}: {exc}. Sats stay in LP's LNbits wallet." + ) + await update_payment_status(leg.id, "failed", None, str(exc)[:512]) + + async def _pay_internal( *, settlement: DcaSettlement,