From d0a947b7e6ec87fa8126f2a6b09015f4ba21b3b1 Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 14 May 2026 17:17:41 +0200 Subject: [PATCH] feat(v2): balance settlement at current rate (P3e) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the v1 feature request satmachineadmin#4 (balance settlement for small remaining LP balances). Operator hits 'Settle' on an LP, specifies the exchange rate they're willing to honor, and the system pays out the remaining fiat balance in sats from the operator's chosen funding wallet. Avoids the Zeno's-paradox of vanishing tiny proportional shares — small balances no longer drag on forever; they get cleanly zeroed. New endpoint: POST /api/v1/dca/clients/{client_id}/settle body: SettleBalanceData {funding_wallet_id, exchange_rate, amount_fiat?, notes?} Flow (distribution.settle_lp_balance): 1. Get LP's remaining balance summary 2. amount_fiat capped at remaining (defaults to full remaining) 3. amount_sats = round(amount_fiat * exchange_rate) 4. Internal transfer funding_wallet → client.wallet via create_invoice(internal=True) + pay_invoice 5. Records leg_type='settlement' in dca_payments Two ownership checks at the API boundary: client (via machine→operator) and funding_wallet_id (via lnbits.core.crud.get_wallet → wallet.user == current operator). 400 (not 404) if funding wallet isn't owned — operators can identify their own wallets so leaking existence is fine. Updated get_client_balance_summary to count both leg_type='dca' AND leg_type='settlement' completed legs against the LP's remaining balance. Without this update, settled amounts would leave the LP's balance unchanged in the summary and re-fire on the next bitSpire tx. Exchange rate is operator-supplied and required — explicit so there's no ambiguity about what rate was used. Operator can use exchange spot, market midpoint, or a favorable rate as a gesture; the rate is recorded on the dca_payments row alongside amount_fiat for audit. 72/72 tests still pass. 31 routes total. Refs: aiolabs/satmachineadmin#9, closes #4 (in spirit, marked once verified end-to-end) Co-Authored-By: Claude Opus 4.7 (1M context) --- crud.py | 6 ++- distribution.py | 104 ++++++++++++++++++++++++++++++++++++++++++++++++ models.py | 32 ++++++++++++--- views_api.py | 40 ++++++++++++++++++- 4 files changed, 174 insertions(+), 8 deletions(-) diff --git a/crud.py b/crud.py index be7a12e..b65826c 100644 --- a/crud.py +++ b/crud.py @@ -891,11 +891,15 @@ async def get_client_balance_summary( """, {"cid": client_id}, ) + # Both DCA legs (auto, from bitSpire settlements) and balance-settle legs + # (operator-initiated under #4) reduce the LP's remaining fiat balance. payments_row = await db.fetchone( """ SELECT COALESCE(SUM(amount_fiat), 0) AS total FROM satoshimachine.dca_payments - WHERE client_id = :cid AND leg_type = 'dca' AND status = 'completed' + WHERE client_id = :cid + AND leg_type IN ('dca', 'settlement') + AND status = 'completed' """, {"cid": client_id}, ) diff --git a/distribution.py b/distribution.py index 9e266b8..624f53c 100644 --- a/distribution.py +++ b/distribution.py @@ -50,10 +50,12 @@ from .crud import ( ) from .models import ( CreateDcaPaymentData, + DcaClient, DcaPayment, DcaSettlement, Machine, PartialDispenseData, + SettleBalanceData, SuperConfig, ) @@ -111,6 +113,108 @@ def _build_partial_dispense_memo( ) +async def settle_lp_balance( + client: DcaClient, machine: Machine, data: SettleBalanceData +) -> DcaPayment: + """Operator UX action — closes satmachineadmin#4. + + Settle an LP's remaining fiat balance from the operator's chosen funding + wallet at the rate the operator specified. Records a leg_type='settlement' + row that counts against the LP's balance summary (so a subsequent + get_client_balance_summary reflects the new zero/reduced balance). + + Caller is responsible for verifying the operator owns both the client's + machine and the funding wallet (API endpoint does this). The amount_fiat + is capped at the LP's remaining balance — operators cannot accidentally + over-pay via this path. + """ + summary = await get_client_balance_summary(client.id) + if summary is None: + raise ValueError(f"client {client.id} balance not available") + remaining = float(summary.remaining_balance) + if remaining <= 0: + raise ValueError( + f"client {client.id} has no remaining balance to settle" + ) + + # Resolve fiat amount: explicit if given (capped at remaining), else full. + requested = ( + float(data.amount_fiat) if data.amount_fiat is not None else remaining + ) + amount_fiat = round(min(requested, remaining), 2) + if amount_fiat <= 0: + raise ValueError("computed settlement amount is zero") + + exchange_rate = float(data.exchange_rate) + amount_sats = round(amount_fiat * exchange_rate) + if amount_sats <= 0: + raise ValueError( + f"computed sat amount is zero (amount_fiat={amount_fiat}, " + f"exchange_rate={exchange_rate})" + ) + + reason = (data.notes or "").strip() or "(no reason given)" + memo = ( + f"satmachine balance settle — {amount_fiat:.2f} " + f"{machine.fiat_code} @ {exchange_rate:g} sat/{machine.fiat_code} " + f"= {amount_sats} sats. Reason: {reason}" + ) + + leg_row = await create_dca_payment( + CreateDcaPaymentData( + settlement_id=None, + client_id=client.id, + machine_id=machine.id, + operator_user_id=machine.operator_user_id, + leg_type="settlement", + destination_wallet_id=client.wallet_id, + destination_ln_address=None, + amount_sats=amount_sats, + amount_fiat=amount_fiat, + exchange_rate=exchange_rate, + transaction_time=datetime.now(timezone.utc), + external_payment_hash=None, + ) + ) + extra = { + "satmachine_leg": "settlement", + "satmachine_client_id": client.id, + "satmachine_machine_npub": machine.machine_npub, + "satmachine_exchange_rate": exchange_rate, + } + try: + new_invoice = await create_invoice( + wallet_id=client.wallet_id, + amount=float(amount_sats), + internal=True, + memo=memo, + extra=extra, + ) + if not new_invoice or not new_invoice.bolt11: + await update_payment_status( + leg_row.id, "failed", None, "create_invoice returned empty" + ) + raise ValueError("create_invoice returned empty") + paid = await pay_invoice( + wallet_id=data.funding_wallet_id, + payment_request=new_invoice.bolt11, + description=memo, + tag=_payment_tag(machine), + extra=extra, + ) + completed = await update_payment_status( + leg_row.id, "completed", paid.payment_hash, None + ) + return completed if completed is not None else leg_row + except Exception as exc: + logger.error( + f"distribution: balance-settle failed for client {client.id} " + f"({amount_sats} sats from wallet {data.funding_wallet_id}): {exc}" + ) + await update_payment_status(leg_row.id, "failed", None, str(exc)[:512]) + raise + + async def apply_partial_dispense_and_redistribute( settlement_id: str, data: PartialDispenseData ) -> DcaSettlement: diff --git a/models.py b/models.py index e5bfd46..f3e94e2 100644 --- a/models.py +++ b/models.py @@ -426,16 +426,36 @@ class AppendSettlementNoteData(BaseModel): class SettleBalanceData(BaseModel): """Resolves satmachineadmin#4 — operator settles small remaining LP balance - from their own wallet at the current exchange rate.""" + from their own wallet at a specified exchange rate. + + Use case: an LP has a small remaining fiat balance (e.g. 47 GTQ) that + keeps shrinking proportionally on each new transaction (Zeno's paradox). + Operator hits 'Settle', specifies the exchange rate they're willing to + honor, and the system pays out the remaining balance in sats from the + operator's wallet. The LP's balance goes to zero; settlement legs count + against the LP's balance summary alongside DCA legs. + """ - client_id: str funding_wallet_id: str - # If None, settle the full remaining balance. + # The exchange rate the operator is settling at (sats per 1 fiat unit). + # Operator picks the rate so they can use exchange spot, a market + # midpoint, or a favorable rate as a gesture. Required and explicit so + # there's no ambiguity about what rate was used. + exchange_rate: float + # If None, settle the LP's full remaining balance. Else partial. amount_fiat: Optional[float] = None notes: Optional[str] = None + @validator("exchange_rate") + def positive_rate(cls, v): + if v is None or v <= 0: + raise ValueError("exchange_rate must be > 0 (sats per fiat unit)") + return float(v) + @validator("amount_fiat") def round_amount(cls, v): - if v is not None: - return round(float(v), 2) - return v + if v is None: + return v + if v <= 0: + raise ValueError("amount_fiat must be > 0 if specified") + return round(float(v), 2) diff --git a/views_api.py b/views_api.py index e0e5b7b..7fc176f 100644 --- a/views_api.py +++ b/views_api.py @@ -8,6 +8,7 @@ from http import HTTPStatus from fastapi import APIRouter, Depends, HTTPException +from lnbits.core.crud import get_wallet from lnbits.core.models import User from lnbits.decorators import check_super_user, check_user_exists @@ -42,7 +43,10 @@ from .crud import ( update_machine, update_super_config, ) -from .distribution import apply_partial_dispense_and_redistribute +from .distribution import ( + apply_partial_dispense_and_redistribute, + settle_lp_balance, +) from .models import ( AppendSettlementNoteData, ClientBalanceSummary, @@ -57,6 +61,7 @@ from .models import ( Machine, PartialDispenseData, SetCommissionSplitsData, + SettleBalanceData, SuperConfig, UpdateDcaClientData, UpdateDepositData, @@ -231,6 +236,39 @@ async def api_get_client_balance( return summary +@satmachineadmin_api_router.post( + "/api/v1/dca/clients/{client_id}/settle", response_model=DcaPayment +) +async def api_settle_client_balance( + client_id: str, + data: SettleBalanceData, + user: User = Depends(check_user_exists), +) -> DcaPayment: + """Operator UX — closes satmachineadmin#4. + + Settle an LP's remaining fiat balance from the operator's chosen funding + wallet at the specified exchange rate. The amount_fiat is capped at the + LP's remaining balance; if omitted, settles the full remaining. + + Use case: avoid the Zeno's-paradox of vanishing tiny shares for small + remaining balances. Operator hits 'Settle' on the LP, gets to specify + the rate, and the system pays out the rest in sats from their wallet. + """ + client = await _client_owned_by(client_id, user.id) + machine = await _machine_owned_by(client.machine_id, user.id) + # Verify the operator owns the funding wallet. + funding_wallet = await get_wallet(data.funding_wallet_id) + if funding_wallet is None or funding_wallet.user != user.id: + raise HTTPException( + HTTPStatus.BAD_REQUEST, + "funding_wallet_id is not owned by the authenticated operator", + ) + try: + return await settle_lp_balance(client, machine, data) + except ValueError as exc: + raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc + + # ============================================================================= # Deposits — operator records fiat handed in by an LP at a machine. # =============================================================================