diff --git a/calculations.py b/calculations.py new file mode 100644 index 0000000..a7b3aa9 --- /dev/null +++ b/calculations.py @@ -0,0 +1,147 @@ +""" +Pure calculation functions for DCA transaction processing. + +These functions have no external dependencies (no lnbits, no database) +and can be easily tested in isolation. +""" + +from typing import Dict, Tuple + + +def calculate_commission( + crypto_atoms: int, + commission_percentage: float, + discount: float = 0.0 +) -> Tuple[int, int, float]: + """ + Calculate commission split from a Lamassu transaction. + + The crypto_atoms from Lamassu already includes the commission baked in. + This function extracts the base amount (for DCA distribution) and + commission amount (for commission wallet). + + Formula: + effective_commission = commission_percentage * (100 - discount) / 100 + base_amount = round(crypto_atoms / (1 + effective_commission)) + commission_amount = crypto_atoms - base_amount + + Args: + crypto_atoms: Total sats from Lamassu (includes commission) + commission_percentage: Commission rate as decimal (e.g., 0.03 for 3%) + discount: Discount percentage on commission (e.g., 10.0 for 10% off) + + Returns: + Tuple of (base_amount_sats, commission_amount_sats, effective_commission_rate) + + Example: + >>> calculate_commission(266800, 0.03, 0.0) + (259029, 7771, 0.03) + """ + if commission_percentage > 0: + effective_commission = commission_percentage * (100 - discount) / 100 + base_crypto_atoms = round(crypto_atoms / (1 + effective_commission)) + commission_amount_sats = crypto_atoms - base_crypto_atoms + else: + effective_commission = 0.0 + base_crypto_atoms = crypto_atoms + commission_amount_sats = 0 + + return base_crypto_atoms, commission_amount_sats, effective_commission + + +def calculate_distribution( + base_amount_sats: int, + client_balances: Dict[str, float], + min_balance_threshold: float = 0.01 +) -> Dict[str, int]: + """ + Calculate proportional distribution of sats to clients based on their fiat balances. + + Uses proportional allocation with remainder distribution to ensure + the total distributed equals exactly the base amount. + + Args: + base_amount_sats: Total sats to distribute (after commission) + client_balances: Dict of {client_id: remaining_balance_fiat} + min_balance_threshold: Minimum balance to be included (default 0.01) + + Returns: + Dict of {client_id: allocated_sats} + + Example: + >>> calculate_distribution(100000, {"a": 500.0, "b": 500.0}) + {"a": 50000, "b": 50000} + """ + # Filter out clients with balance below threshold + active_balances = { + client_id: balance + for client_id, balance in client_balances.items() + if balance >= min_balance_threshold + } + + if not active_balances: + return {} + + total_balance = sum(active_balances.values()) + + if total_balance == 0: + return {} + + # First pass: calculate base allocations and track for remainder distribution + client_calculations = [] + distributed_sats = 0 + + for client_id, balance in active_balances.items(): + proportion = balance / total_balance + exact_share = base_amount_sats * proportion + allocated_sats = round(exact_share) + + client_calculations.append({ + 'client_id': client_id, + 'proportion': proportion, + 'exact_share': exact_share, + 'allocated_sats': allocated_sats, + }) + distributed_sats += allocated_sats + + # Handle remainder due to rounding + remainder = base_amount_sats - distributed_sats + + if remainder != 0: + # Sort by largest fractional remainder to distribute fairly + client_calculations.sort( + key=lambda x: x['exact_share'] - x['allocated_sats'], + reverse=True + ) + + # Distribute remainder one sat at a time + for i in range(abs(remainder)): + idx = i % len(client_calculations) + if remainder > 0: + client_calculations[idx]['allocated_sats'] += 1 + else: + client_calculations[idx]['allocated_sats'] -= 1 + + # Build final distributions dict + distributions = { + calc['client_id']: calc['allocated_sats'] + for calc in client_calculations + } + + return distributions + + +def calculate_exchange_rate(base_crypto_atoms: int, fiat_amount: float) -> float: + """ + Calculate exchange rate in sats per fiat unit. + + Args: + base_crypto_atoms: Base amount in sats (after commission) + fiat_amount: Fiat amount dispensed + + Returns: + Exchange rate as sats per fiat unit + """ + if fiat_amount <= 0: + return 0.0 + return base_crypto_atoms / fiat_amount diff --git a/pyproject.toml b/pyproject.toml index c5c417d..885fe45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "satmachineadmin" -version = "0.0.0" +version = "0.0.4" description = "Eightball is a simple API that allows you to create a random number generator." authors = ["benarc", "dni "] diff --git a/static/js/index.js b/static/js/index.js index ee3e587..610ec0a 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -181,13 +181,13 @@ window.app = Vue.createApp({ this.lamassuConfig = data // When opening config dialog, populate the selected wallets if they exist - if (data && data.source_wallet_id) { + if (data && data.source_wallet_id && this.g.user?.wallets) { const wallet = this.g.user.wallets.find(w => w.id === data.source_wallet_id) if (wallet) { this.configDialog.data.selectedWallet = wallet } } - if (data && data.commission_wallet_id) { + if (data && data.commission_wallet_id && this.g.user?.wallets) { const commissionWallet = this.g.user.wallets.find(w => w.id === data.commission_wallet_id) if (commissionWallet) { this.configDialog.data.selectedCommissionWallet = commissionWallet @@ -670,7 +670,7 @@ window.app = Vue.createApp({ } catch (error) { LNbits.utils.notifyApiError(error) } finally { - this.runningTestTransaction = false + this.processingSpecificTransaction = false } }, diff --git a/templates/satmachineadmin/index.html b/templates/satmachineadmin/index.html index 46eef37..83de8c7 100644 --- a/templates/satmachineadmin/index.html +++ b/templates/satmachineadmin/index.html @@ -547,17 +547,17 @@ - + 0 + + def test_100_percent_discount(self): + """100% discount should result in zero commission.""" + base, commission, effective = calculate_commission(100000, 0.03, 100.0) + + assert effective == 0.0 + assert commission == 0 + assert base == 100000 + + def test_many_clients_distribution(self): + """Test distribution with many clients.""" + # 10 clients with varying balances + client_balances = {f"client_{i}": float(i * 100) for i in range(1, 11)} + + distributions = calculate_distribution(1000000, client_balances) + + assert len(distributions) == 10 + assert sum(distributions.values()) == 1000000 + + # Verify proportionality (client_10 should get ~18% with balance 1000) + # Total balance = 100+200+...+1000 = 5500 + # client_10 proportion = 1000/5500 ≈ 18.18% + assert distributions["client_10"] > distributions["client_1"] diff --git a/transaction_processor.py b/transaction_processor.py index bd6ae77..661e5ab 100644 --- a/transaction_processor.py +++ b/transaction_processor.py @@ -26,6 +26,7 @@ from lnbits.core.crud.wallets import get_wallet from lnbits.core.services import update_wallet_balance from lnbits.settings import settings +from .calculations import calculate_commission, calculate_distribution, calculate_exchange_rate from .crud import ( get_flow_mode_clients, get_payments_by_lamassu_transaction, @@ -668,15 +669,21 @@ class LamassuTransactionProcessor: logger.error(f"Error fetching transactions from Lamassu database: {e}") return [] - async def calculate_distribution_amounts(self, transaction: Dict[str, Any]) -> Dict[str, int]: - """Calculate how much each Flow Mode client should receive""" + async def calculate_distribution_amounts(self, transaction: Dict[str, Any]) -> tuple[Dict[str, Any], int]: + """Calculate how much each Flow Mode client should receive. + + Returns: + tuple: (distributions dict, orphan_sats int) + - distributions: {client_id: {fiat_amount, sats_amount, exchange_rate}} + - orphan_sats: sats that couldn't be distributed due to sync mismatch + """ try: # Get all active Flow Mode clients flow_clients = await get_flow_mode_clients() - + if not flow_clients: logger.info("No Flow Mode clients found - skipping distribution") - return {} + return {}, 0 # Extract transaction details - guaranteed clean from data ingestion crypto_atoms = transaction.get("crypto_amount", 0) # Total sats with commission baked in @@ -700,10 +707,10 @@ class LamassuTransactionProcessor: # Validate required fields if crypto_atoms is None: logger.error(f"Missing crypto_amount in transaction: {transaction}") - return {} + return {}, 0 if fiat_amount is None: logger.error(f"Missing fiat_amount in transaction: {transaction}") - return {} + return {}, 0 if commission_percentage is None: logger.warning(f"Missing commission_percentage in transaction: {transaction}, defaulting to 0") commission_percentage = 0.0 @@ -715,21 +722,13 @@ class LamassuTransactionProcessor: # Could use current time as fallback, but this indicates a data issue # transaction_time = datetime.now(timezone.utc) - # Calculate effective commission percentage after discount (following the reference logic) - if commission_percentage > 0: - effective_commission = commission_percentage * (100 - discount) / 100 - # Since crypto_atoms already includes commission, we need to extract the base amount - # Formula: crypto_atoms = base_amount * (1 + effective_commission) - # Therefore: base_amount = crypto_atoms / (1 + effective_commission) - base_crypto_atoms = round(crypto_atoms / (1 + effective_commission)) - commission_amount_sats = crypto_atoms - base_crypto_atoms - else: - effective_commission = 0.0 - base_crypto_atoms = crypto_atoms - commission_amount_sats = 0 - + # Calculate commission split using the extracted pure function + base_crypto_atoms, commission_amount_sats, effective_commission = calculate_commission( + crypto_atoms, commission_percentage, discount + ) + # Calculate exchange rate based on base amounts - exchange_rate = base_crypto_atoms / fiat_amount if fiat_amount > 0 else 0 # sats per fiat unit + exchange_rate = calculate_exchange_rate(base_crypto_atoms, fiat_amount) logger.info(f"Transaction - Total crypto: {crypto_atoms} sats") logger.info(f"Commission: {commission_percentage*100:.1f}% - {discount:.1f}% discount = {effective_commission*100:.1f}% effective ({commission_amount_sats} sats)") @@ -757,75 +756,76 @@ class LamassuTransactionProcessor: if total_confirmed_deposits == 0: logger.info("No clients with remaining DCA balance - skipping distribution") - return {} - - # Calculate proportional distribution with remainder allocation - distributions = {} - distributed_sats = 0 - client_calculations = [] - - # First pass: calculate base amounts and track remainders - for client_id, client_balance in client_balances.items(): - # Calculate this client's proportion of the total DCA pool - proportion = client_balance / total_confirmed_deposits - - # Calculate exact share (with decimals) - exact_share = base_crypto_atoms * proportion - - # Use banker's rounding for base allocation - client_sats_amount = round(exact_share) - - client_calculations.append({ - 'client_id': client_id, - 'proportion': proportion, - 'exact_share': exact_share, - 'allocated_sats': client_sats_amount, - 'client_balance': client_balance - }) - - distributed_sats += client_sats_amount - - # Handle any remainder due to rounding (should be small) - remainder = base_crypto_atoms - distributed_sats - - if remainder != 0: - logger.info(f"Distributing remainder: {remainder} sats among {len(client_calculations)} clients") - - # Sort clients by largest fractional remainder to distribute fairly - client_calculations.sort( - key=lambda x: x['exact_share'] - x['allocated_sats'], - reverse=True + return {}, 0 + + # Detect sync mismatch: more money in ATM than tracked client balances + sync_mismatch = total_confirmed_deposits < fiat_amount + if sync_mismatch: + orphan_fiat = fiat_amount - total_confirmed_deposits + logger.warning( + f"Sync mismatch detected: tracked balances ({total_confirmed_deposits:.2f} GTQ) " + f"< transaction ({fiat_amount} GTQ). Orphan amount: {orphan_fiat:.2f} GTQ" ) - - # Distribute remainder one sat at a time to clients with largest fractional parts - for i in range(abs(remainder)): - if remainder > 0: - client_calculations[i % len(client_calculations)]['allocated_sats'] += 1 - else: - client_calculations[i % len(client_calculations)]['allocated_sats'] -= 1 - - # Second pass: create distributions with final amounts - for calc in client_calculations: - client_id = calc['client_id'] - client_sats_amount = calc['allocated_sats'] - proportion = calc['proportion'] - - # Calculate equivalent fiat value in GTQ for tracking purposes - client_fiat_amount = round(client_sats_amount / exchange_rate, 2) if exchange_rate > 0 else 0.0 - - distributions[client_id] = { - "fiat_amount": client_fiat_amount, - "sats_amount": client_sats_amount, - "exchange_rate": exchange_rate - } - - logger.info(f"Client {client_id[:8]}... gets {client_sats_amount} sats (≈{client_fiat_amount:.2f} GTQ, {proportion:.2%} share)") - - # Verification: ensure total distribution equals base amount - total_distributed = sum(dist["sats_amount"] for dist in distributions.values()) - if total_distributed != base_crypto_atoms: - logger.error(f"Distribution mismatch! Expected: {base_crypto_atoms} sats, Distributed: {total_distributed} sats") - raise ValueError(f"Satoshi distribution calculation error: {base_crypto_atoms} != {total_distributed}") + + # Calculate distribution amounts + distributions = {} + + if sync_mismatch: + # SYNC MISMATCH MODE: Cap each client's allocation to their remaining fiat balance + # Each client gets sats equivalent to their full remaining balance + for client_id, client_balance in client_balances.items(): + # Calculate sats equivalent to this client's remaining fiat balance + client_sats_amount = round(client_balance * exchange_rate) + proportion = client_balance / total_confirmed_deposits + + # Calculate equivalent fiat value in GTQ for tracking purposes + client_fiat_amount = round(client_sats_amount / exchange_rate, 2) if exchange_rate > 0 else 0.0 + + distributions[client_id] = { + "fiat_amount": client_fiat_amount, + "sats_amount": client_sats_amount, + "exchange_rate": exchange_rate + } + + logger.info(f"Client {client_id[:8]}... gets {client_sats_amount} sats (≈{client_fiat_amount:.2f} GTQ, {proportion:.2%} share)") + + # Calculate orphan sats (difference between base amount and distributed) + total_distributed = sum(dist["sats_amount"] for dist in distributions.values()) + orphan_sats = base_crypto_atoms - total_distributed + logger.info( + f"Sync mismatch distribution: {total_distributed} sats to clients, " + f"{orphan_sats} sats orphaned (staying in source wallet)" + ) + else: + # NORMAL MODE: Proportional distribution based on transaction amount + sat_allocations = calculate_distribution(base_crypto_atoms, client_balances) + + if not sat_allocations: + logger.info("No allocations calculated - skipping distribution") + return {}, 0 + + # Build final distributions dict with additional tracking fields + for client_id, client_sats_amount in sat_allocations.items(): + # Calculate proportion for logging + proportion = client_balances[client_id] / total_confirmed_deposits + + # Calculate equivalent fiat value in GTQ for tracking purposes + client_fiat_amount = round(client_sats_amount / exchange_rate, 2) if exchange_rate > 0 else 0.0 + + distributions[client_id] = { + "fiat_amount": client_fiat_amount, + "sats_amount": client_sats_amount, + "exchange_rate": exchange_rate + } + + logger.info(f"Client {client_id[:8]}... gets {client_sats_amount} sats (≈{client_fiat_amount:.2f} GTQ, {proportion:.2%} share)") + + # Verification: ensure total distribution equals base amount + total_distributed = sum(dist["sats_amount"] for dist in distributions.values()) + if total_distributed != base_crypto_atoms: + logger.error(f"Distribution mismatch! Expected: {base_crypto_atoms} sats, Distributed: {total_distributed} sats") + raise ValueError(f"Satoshi distribution calculation error: {base_crypto_atoms} != {total_distributed}") + orphan_sats = 0 # Safety check: Re-verify all clients still have positive balances before finalizing distributions # This prevents race conditions where balances changed during calculation @@ -845,18 +845,18 @@ class LamassuTransactionProcessor: # Recalculate proportions if some clients were rejected if len(final_distributions) == 0: logger.info("All clients rejected due to negative balances - no distributions") - return {} - + return {}, orphan_sats + # For simplicity, we'll still return the original distributions but log the warning # In a production system, you might want to recalculate the entire distribution logger.warning("Proceeding with original distribution despite balance warnings - manual review recommended") - + logger.info(f"Distribution verified: {total_distributed} sats distributed across {len(distributions)} clients (clients with positive allocations only)") - return distributions - + return distributions, orphan_sats + except Exception as e: logger.error(f"Error calculating distribution amounts: {e}") - return {} + return {}, 0 async def distribute_to_clients(self, transaction: Dict[str, Any], distributions: Dict[str, Dict[str, int]]) -> None: """Send Bitcoin payments to DCA clients""" @@ -965,7 +965,7 @@ class LamassuTransactionProcessor: } new_payment = await create_invoice( wallet_id=target_wallet.id, - amount=amount_sats, # LNBits create_invoice expects sats + amount=float(amount_sats), # LNBits create_invoice expects float internal=True, # Internal transfer within LNBits memo=memo, extra=extra @@ -1062,18 +1062,13 @@ class LamassuTransactionProcessor: elif transaction_time.tzinfo != timezone.utc: transaction_time = transaction_time.astimezone(timezone.utc) - # Calculate commission metrics - if commission_percentage > 0: - effective_commission = commission_percentage * (100 - discount) / 100 - base_crypto_atoms = round(crypto_atoms / (1 + effective_commission)) - commission_amount_sats = crypto_atoms - base_crypto_atoms - else: - effective_commission = 0.0 - base_crypto_atoms = crypto_atoms - commission_amount_sats = 0 - + # Calculate commission metrics using the extracted pure function + base_crypto_atoms, commission_amount_sats, effective_commission = calculate_commission( + crypto_atoms, commission_percentage, discount + ) + # Calculate exchange rate - exchange_rate = base_crypto_atoms / fiat_amount if fiat_amount > 0 else 0 + exchange_rate = calculate_exchange_rate(base_crypto_atoms, fiat_amount) # Create transaction data with GTQ amounts transaction_data = CreateLamassuTransactionData( @@ -1135,7 +1130,7 @@ class LamassuTransactionProcessor: commission_payment = await create_invoice( wallet_id=admin_config.commission_wallet_id, - amount=commission_amount_sats, + amount=float(commission_amount_sats), # LNbits create_invoice expects float internal=True, memo=commission_memo, extra={ @@ -1190,10 +1185,16 @@ class LamassuTransactionProcessor: stored_transaction = await self.store_lamassu_transaction(transaction) # Calculate distribution amounts - distributions = await self.calculate_distribution_amounts(transaction) - + distributions, orphan_sats = await self.calculate_distribution_amounts(transaction) + if not distributions: - logger.info(f"No distributions calculated for transaction {transaction_id}") + if orphan_sats > 0: + logger.warning( + f"No client distributions for transaction {transaction_id}, " + f"but {orphan_sats} orphan sats remain in source wallet" + ) + else: + logger.info(f"No distributions calculated for transaction {transaction_id}") return # Calculate commission amount for sending to commission wallet @@ -1201,12 +1202,10 @@ class LamassuTransactionProcessor: commission_percentage = transaction.get("commission_percentage", 0.0) discount = transaction.get("discount", 0.0) - if commission_percentage and commission_percentage > 0: - effective_commission = commission_percentage * (100 - discount) / 100 - base_crypto_atoms = round(crypto_atoms / (1 + effective_commission)) - commission_amount_sats = crypto_atoms - base_crypto_atoms - else: - commission_amount_sats = 0 + # Calculate commission amount using the extracted pure function + _, commission_amount_sats, _ = calculate_commission( + crypto_atoms, commission_percentage, discount + ) # Distribute to clients await self.distribute_to_clients(transaction, distributions) diff --git a/views.py b/views.py index 61701cd..6532836 100644 --- a/views.py +++ b/views.py @@ -1,9 +1,11 @@ # Description: DCA Admin page endpoints. -from fastapi import APIRouter, Depends, Request +from http import HTTPStatus + +from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import HTMLResponse from lnbits.core.models import User -from lnbits.decorators import check_super_user +from lnbits.decorators import check_user_exists from lnbits.helpers import template_renderer satmachineadmin_generic_router = APIRouter() @@ -15,7 +17,11 @@ def satmachineadmin_renderer(): # DCA Admin page - Requires superuser access @satmachineadmin_generic_router.get("/", response_class=HTMLResponse) -async def index(req: Request, user: User = Depends(check_super_user)): +async def index(req: Request, user: User = Depends(check_user_exists)): + if not user.super_user: + raise HTTPException( + HTTPStatus.FORBIDDEN, "User not authorized. No super user privileges." + ) return satmachineadmin_renderer().TemplateResponse( "satmachineadmin/index.html", {"request": req, "user": user.json()} )