diff --git a/calculations.py b/calculations.py deleted file mode 100644 index a7b3aa9..0000000 --- a/calculations.py +++ /dev/null @@ -1,147 +0,0 @@ -""" -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 885fe45..c5c417d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "satmachineadmin" -version = "0.0.4" +version = "0.0.0" 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 610ec0a..ee3e587 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 && this.g.user?.wallets) { + if (data && data.source_wallet_id) { 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 && this.g.user?.wallets) { + if (data && data.commission_wallet_id) { 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.processingSpecificTransaction = false + this.runningTestTransaction = false } }, diff --git a/templates/satmachineadmin/index.html b/templates/satmachineadmin/index.html index 83de8c7..46eef37 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 661e5ab..bd6ae77 100644 --- a/transaction_processor.py +++ b/transaction_processor.py @@ -26,7 +26,6 @@ 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, @@ -669,21 +668,15 @@ class LamassuTransactionProcessor: logger.error(f"Error fetching transactions from Lamassu database: {e}") return [] - 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 - """ + async def calculate_distribution_amounts(self, transaction: Dict[str, Any]) -> Dict[str, int]: + """Calculate how much each Flow Mode client should receive""" 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 {}, 0 + return {} # Extract transaction details - guaranteed clean from data ingestion crypto_atoms = transaction.get("crypto_amount", 0) # Total sats with commission baked in @@ -707,10 +700,10 @@ class LamassuTransactionProcessor: # Validate required fields if crypto_atoms is None: logger.error(f"Missing crypto_amount in transaction: {transaction}") - return {}, 0 + return {} if fiat_amount is None: logger.error(f"Missing fiat_amount in transaction: {transaction}") - return {}, 0 + return {} if commission_percentage is None: logger.warning(f"Missing commission_percentage in transaction: {transaction}, defaulting to 0") commission_percentage = 0.0 @@ -722,13 +715,21 @@ class LamassuTransactionProcessor: # Could use current time as fallback, but this indicates a data issue # transaction_time = datetime.now(timezone.utc) - # Calculate commission split using the extracted pure function - base_crypto_atoms, commission_amount_sats, effective_commission = calculate_commission( - crypto_atoms, commission_percentage, discount - ) - + # 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 exchange rate based on base amounts - exchange_rate = calculate_exchange_rate(base_crypto_atoms, fiat_amount) + exchange_rate = base_crypto_atoms / fiat_amount if fiat_amount > 0 else 0 # sats per fiat unit 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)") @@ -756,76 +757,75 @@ class LamassuTransactionProcessor: if total_confirmed_deposits == 0: logger.info("No clients with remaining DCA balance - skipping distribution") - 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" - ) - - # Calculate distribution amounts + return {} + + # Calculate proportional distribution with remainder allocation 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)" + 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 ) - 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 + + # 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}") # 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 {}, orphan_sats - + return {} + # 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, orphan_sats - + return distributions + except Exception as e: logger.error(f"Error calculating distribution amounts: {e}") - return {}, 0 + return {} 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=float(amount_sats), # LNBits create_invoice expects float + amount=amount_sats, # LNBits create_invoice expects sats internal=True, # Internal transfer within LNBits memo=memo, extra=extra @@ -1062,13 +1062,18 @@ class LamassuTransactionProcessor: elif transaction_time.tzinfo != timezone.utc: transaction_time = transaction_time.astimezone(timezone.utc) - # Calculate commission metrics using the extracted pure function - base_crypto_atoms, commission_amount_sats, effective_commission = calculate_commission( - crypto_atoms, commission_percentage, discount - ) - + # 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 exchange rate - exchange_rate = calculate_exchange_rate(base_crypto_atoms, fiat_amount) + exchange_rate = base_crypto_atoms / fiat_amount if fiat_amount > 0 else 0 # Create transaction data with GTQ amounts transaction_data = CreateLamassuTransactionData( @@ -1130,7 +1135,7 @@ class LamassuTransactionProcessor: commission_payment = await create_invoice( wallet_id=admin_config.commission_wallet_id, - amount=float(commission_amount_sats), # LNbits create_invoice expects float + amount=commission_amount_sats, internal=True, memo=commission_memo, extra={ @@ -1185,16 +1190,10 @@ class LamassuTransactionProcessor: stored_transaction = await self.store_lamassu_transaction(transaction) # Calculate distribution amounts - distributions, orphan_sats = await self.calculate_distribution_amounts(transaction) - + distributions = await self.calculate_distribution_amounts(transaction) + if not distributions: - 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}") + logger.info(f"No distributions calculated for transaction {transaction_id}") return # Calculate commission amount for sending to commission wallet @@ -1202,10 +1201,12 @@ class LamassuTransactionProcessor: commission_percentage = transaction.get("commission_percentage", 0.0) discount = transaction.get("discount", 0.0) - # Calculate commission amount using the extracted pure function - _, commission_amount_sats, _ = calculate_commission( - crypto_atoms, commission_percentage, discount - ) + 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 # Distribute to clients await self.distribute_to_clients(transaction, distributions) diff --git a/views.py b/views.py index 6532836..61701cd 100644 --- a/views.py +++ b/views.py @@ -1,11 +1,9 @@ # Description: DCA Admin page endpoints. -from http import HTTPStatus - -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, Request from fastapi.responses import HTMLResponse from lnbits.core.models import User -from lnbits.decorators import check_user_exists +from lnbits.decorators import check_super_user from lnbits.helpers import template_renderer satmachineadmin_generic_router = APIRouter() @@ -17,11 +15,7 @@ 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_user_exists)): - if not user.super_user: - raise HTTPException( - HTTPStatus.FORBIDDEN, "User not authorized. No super user privileges." - ) +async def index(req: Request, user: User = Depends(check_super_user)): return satmachineadmin_renderer().TemplateResponse( "satmachineadmin/index.html", {"request": req, "user": user.json()} )