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..687db7d 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -670,7 +670,7 @@ window.app = Vue.createApp({ } catch (error) { LNbits.utils.notifyApiError(error) } finally { - this.processingSpecificTransaction = false + this.runningTestTransaction = false } }, diff --git a/tests/test_calculations.py b/tests/test_calculations.py deleted file mode 100644 index 04262a0..0000000 --- a/tests/test_calculations.py +++ /dev/null @@ -1,381 +0,0 @@ -""" -Tests for DCA transaction calculations using empirical data. - -These tests verify commission and distribution calculations against -real Lamassu transaction data to ensure the math is correct. -""" - -import pytest -from decimal import Decimal -from typing import Dict, List, Tuple - -# Import from the parent package (following lnurlp pattern) -from ..calculations import calculate_commission, calculate_distribution, calculate_exchange_rate - - -# ============================================================================= -# COMMISSION CALCULATION TESTS -# ============================================================================= - -class TestCommissionCalculation: - """Tests for commission calculation logic.""" - - # Empirical test cases: (crypto_atoms, commission%, discount%, expected_base, expected_commission) - # Formula: base = round(crypto_atoms / (1 + effective_commission)) - # Where: effective_commission = commission_percentage * (100 - discount) / 100 - EMPIRICAL_COMMISSION_CASES = [ - # ============================================================= - # REAL LAMASSU TRANSACTIONS (extracted from production database) - # ============================================================= - - # 8.75% commission, no discount - small transaction - # 15600 / 1.0875 = 14344.827... → 14345 - (15600, 0.0875, 0.0, 14345, 1255), - - # 8.75% commission, no discount - large transaction - # 309200 / 1.0875 = 284322.298... → 284322 - (309200, 0.0875, 0.0, 284322, 24878), - - # 5.5% commission, no discount - # 309500 / 1.055 = 293364.928... → 293365 - (309500, 0.055, 0.0, 293365, 16135), - - # 5.5% commission with 100% discount (no commission charged) - # effective = 0.055 * (100-100)/100 = 0 - (292400, 0.055, 100.0, 292400, 0), - - # 5.5% commission with 90% discount - # effective = 0.055 * (100-90)/100 = 0.0055 - # 115000 / 1.0055 = 114370.96... → 114371 - (115000, 0.055, 90.0, 114371, 629), - - # 5.5% commission, no discount - 1300 GTQ transaction - # 205600 / 1.055 = 194881.516... → 194882 - # Note: This tx showed 0.01 GTQ rounding discrepancy in per-client fiat - (205600, 0.055, 0.0, 194882, 10718), - - # ============================================================= - # SYNTHETIC TEST CASES (edge cases) - # ============================================================= - - # Zero commission - all goes to base - (100000, 0.0, 0.0, 100000, 0), - - # Small amount edge case (1 sat minimum) - (100, 0.03, 0.0, 97, 3), - ] - - @pytest.mark.parametrize( - "crypto_atoms,commission_pct,discount,expected_base,expected_commission", - EMPIRICAL_COMMISSION_CASES, - ids=[ - "lamassu_8.75pct_small", - "lamassu_8.75pct_large", - "lamassu_5.5pct_no_discount", - "lamassu_5.5pct_100pct_discount", - "lamassu_5.5pct_90pct_discount", - "lamassu_5.5pct_1300gtq", - "zero_commission", - "small_amount_100sats", - ] - ) - def test_commission_calculation( - self, - crypto_atoms: int, - commission_pct: float, - discount: float, - expected_base: int, - expected_commission: int - ): - """Test commission calculation against empirical data.""" - base, commission, _ = calculate_commission(crypto_atoms, commission_pct, discount) - - assert base == expected_base, f"Base amount mismatch: got {base}, expected {expected_base}" - assert commission == expected_commission, f"Commission mismatch: got {commission}, expected {expected_commission}" - - # Invariant: base + commission must equal total - assert base + commission == crypto_atoms, "Base + commission must equal total crypto_atoms" - - def test_commission_invariant_always_sums_to_total(self): - """Commission + base must always equal the original amount.""" - test_values = [1, 100, 1000, 10000, 100000, 266800, 1000000] - commission_rates = [0.0, 0.01, 0.03, 0.05, 0.10] - discounts = [0.0, 10.0, 25.0, 50.0] - - for crypto_atoms in test_values: - for comm_rate in commission_rates: - for discount in discounts: - base, commission, _ = calculate_commission(crypto_atoms, comm_rate, discount) - assert base + commission == crypto_atoms, \ - f"Invariant failed: {base} + {commission} != {crypto_atoms} " \ - f"(rate={comm_rate}, discount={discount})" - - -# ============================================================================= -# DISTRIBUTION CALCULATION TESTS -# ============================================================================= - -class TestDistributionCalculation: - """Tests for proportional distribution logic.""" - - def test_single_client_gets_all(self): - """Single client should receive entire distribution.""" - distributions = calculate_distribution( - base_amount_sats=100000, - client_balances={"client_a": 500.00} - ) - - assert distributions == {"client_a": 100000} - - def test_two_clients_equal_balance(self): - """Two clients with equal balance should split evenly.""" - distributions = calculate_distribution( - base_amount_sats=100000, - client_balances={ - "client_a": 500.00, - "client_b": 500.00 - } - ) - - assert distributions["client_a"] == 50000 - assert distributions["client_b"] == 50000 - assert sum(distributions.values()) == 100000 - - def test_two_clients_unequal_balance(self): - """Two clients with 75/25 balance split.""" - distributions = calculate_distribution( - base_amount_sats=100000, - client_balances={ - "client_a": 750.00, - "client_b": 250.00 - } - ) - - assert distributions["client_a"] == 75000 - assert distributions["client_b"] == 25000 - assert sum(distributions.values()) == 100000 - - def test_distribution_invariant_sums_to_total(self): - """Total distributed sats must always equal base amount.""" - # Test with various client configurations - test_cases = [ - {"a": 100.0}, - {"a": 100.0, "b": 100.0}, - {"a": 100.0, "b": 200.0, "c": 300.0}, - {"a": 33.33, "b": 33.33, "c": 33.34}, # Tricky rounding case - {"a": 1000.0, "b": 1.0}, # Large imbalance - ] - - for client_balances in test_cases: - for base_amount in [100, 1000, 10000, 100000, 258835]: - distributions = calculate_distribution(base_amount, client_balances) - total_distributed = sum(distributions.values()) - - assert total_distributed == base_amount, \ - f"Distribution sum {total_distributed} != base {base_amount} " \ - f"for balances {client_balances}" - - def test_zero_balance_client_excluded(self): - """Clients with zero balance should be excluded.""" - distributions = calculate_distribution( - base_amount_sats=100000, - client_balances={ - "client_a": 500.00, - "client_b": 0.0, - "client_c": 500.00 - } - ) - - assert "client_b" not in distributions - assert distributions["client_a"] == 50000 - assert distributions["client_c"] == 50000 - - def test_tiny_balance_excluded(self): - """Clients with balance < 0.01 should be excluded.""" - distributions = calculate_distribution( - base_amount_sats=100000, - client_balances={ - "client_a": 500.00, - "client_b": 0.005, # Less than threshold - } - ) - - assert "client_b" not in distributions - assert distributions["client_a"] == 100000 - - def test_no_eligible_clients_returns_empty(self): - """If no clients have balance, return empty distribution.""" - distributions = calculate_distribution( - base_amount_sats=100000, - client_balances={ - "client_a": 0.0, - "client_b": 0.0, - } - ) - - assert distributions == {} - - def test_fiat_round_trip_invariant(self): - """ - Verify that distributed sats convert back to original fiat amount. - - The sum of each client's fiat equivalent should equal the original - fiat amount (within rounding tolerance). - """ - # Use real Lamassu transaction data - test_cases = [ - # (crypto_atoms, fiat_amount, commission_pct, discount, client_balances) - (309200, 2000.0, 0.0875, 0.0, {"a": 1000.0, "b": 1000.0}), - (309500, 2000.0, 0.055, 0.0, {"a": 500.0, "b": 1000.0, "c": 500.0}), - (292400, 2000.0, 0.055, 100.0, {"a": 800.0, "b": 1200.0}), - (115000, 800.0, 0.055, 90.0, {"a": 400.0, "b": 400.0}), - # Transaction that showed 0.01 GTQ rounding discrepancy with 4 clients - (205600, 1300.0, 0.055, 0.0, {"a": 1.0, "b": 986.0, "c": 14.0, "d": 4.0}), - ] - - for crypto_atoms, fiat_amount, comm_pct, discount, client_balances in test_cases: - # Calculate commission and base amount - base_sats, _, _ = calculate_commission(crypto_atoms, comm_pct, discount) - - # Calculate exchange rate - exchange_rate = calculate_exchange_rate(base_sats, fiat_amount) - - # Distribute sats to clients - distributions = calculate_distribution(base_sats, client_balances) - - # Convert each client's sats back to fiat - total_fiat_distributed = sum( - sats / exchange_rate for sats in distributions.values() - ) - - # Should equal original fiat amount (within small rounding tolerance) - assert abs(total_fiat_distributed - fiat_amount) < 0.01, \ - f"Fiat round-trip failed: {total_fiat_distributed:.2f} != {fiat_amount:.2f} " \ - f"(crypto={crypto_atoms}, comm={comm_pct}, discount={discount})" - - -# ============================================================================= -# EMPIRICAL END-TO-END TESTS -# ============================================================================= - -class TestEmpiricalTransactions: - """ - End-to-end tests using real Lamassu transaction data. - - Add your empirical test cases here! Each case should include: - - Transaction details (crypto_atoms, fiat, commission, discount) - - Client balances at time of transaction - - Expected distribution outcome - """ - - # TODO: Add your empirical data here - # Example structure: - EMPIRICAL_SCENARIOS = [ - { - "name": "real_tx_266800sats_two_equal_clients", - "transaction": { - "crypto_atoms": 266800, - "fiat_amount": 2000, - "commission_percentage": 0.03, - "discount": 0.0, - }, - "client_balances": { - "client_a": 1000.00, # 50% of total - "client_b": 1000.00, # 50% of total - }, - # 266800 / 1.03 = 259029 - "expected_base_sats": 259029, - "expected_commission_sats": 7771, - "expected_distributions": { - # 259029 / 2 = 129514.5 → both get 129514 or 129515 - # With banker's rounding: 129514.5 → 129514 (even) - # Remainder of 1 sat goes to first client by fractional sort - "client_a": 129515, - "client_b": 129514, - }, - }, - # Add more scenarios from your real data! - ] - - @pytest.mark.parametrize( - "scenario", - EMPIRICAL_SCENARIOS, - ids=[s["name"] for s in EMPIRICAL_SCENARIOS] - ) - def test_empirical_scenario(self, scenario): - """Test full transaction flow against empirical data.""" - tx = scenario["transaction"] - - # Calculate commission - base, commission, _ = calculate_commission( - tx["crypto_atoms"], - tx["commission_percentage"], - tx["discount"] - ) - - assert base == scenario["expected_base_sats"], \ - f"Base amount mismatch in {scenario['name']}" - assert commission == scenario["expected_commission_sats"], \ - f"Commission mismatch in {scenario['name']}" - - # Calculate distribution - distributions = calculate_distribution( - base, - scenario["client_balances"] - ) - - # Verify each client's allocation - for client_id, expected_sats in scenario["expected_distributions"].items(): - actual_sats = distributions.get(client_id, 0) - assert actual_sats == expected_sats, \ - f"Distribution mismatch for {client_id} in {scenario['name']}: " \ - f"got {actual_sats}, expected {expected_sats}" - - # Verify total distribution equals base - assert sum(distributions.values()) == base, \ - f"Total distribution doesn't match base in {scenario['name']}" - - -# ============================================================================= -# EDGE CASE TESTS -# ============================================================================= - -class TestEdgeCases: - """Tests for edge cases and boundary conditions.""" - - def test_minimum_amount_1_sat(self): - """Test with minimum possible amount (1 sat).""" - base, commission, _ = calculate_commission(1, 0.03, 0.0) - # With 3% commission on 1 sat, base rounds to 1, commission to 0 - assert base + commission == 1 - - def test_large_transaction(self): - """Test with large transaction (100 BTC worth of sats).""" - crypto_atoms = 10_000_000_000 # 100 BTC in sats - base, commission, _ = calculate_commission(crypto_atoms, 0.03, 0.0) - - assert base + commission == crypto_atoms - assert commission > 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)