From 25be6cff876be4b29cb7de9866d3ab1c2b7e18e1 Mon Sep 17 00:00:00 2001 From: padreug Date: Mon, 5 Jan 2026 11:11:19 +0100 Subject: [PATCH 1/7] Fix LNbits 1.4 compatibility: add null guards for g.user.wallets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LNbits 1.4 changed g.user initialization (PR #3615), moving it from windowMixin to base.html. This means g.user can be null during initial Vue template evaluation. - Use optional chaining g.user?.wallets || [] in template - Add null guard before accessing this.g.user.wallets in JS 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- static/js/index.js | 4 ++-- templates/satmachineadmin/index.html | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/static/js/index.js b/static/js/index.js index ee3e587..687db7d 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 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 @@ - + Date: Mon, 5 Jan 2026 12:14:48 +0100 Subject: [PATCH 2/7] fix: use check_user_exists for LNbits 1.4 compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LNbits 1.4 changed check_super_user to return Account (no wallets) instead of User (with wallets). This broke the template rendering because LNbits.map.user() requires the wallets property. Switch to check_user_exists (returns User with wallets) and manually check user.super_user for access control. This follows the same pattern used by LNbits core admin pages. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- views.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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()} ) From 8d94dcc2b723ed4825a700e47e122435189bd6ea Mon Sep 17 00:00:00 2001 From: padreug Date: Mon, 5 Jan 2026 20:57:25 +0100 Subject: [PATCH 3/7] fix: reset correct loading state after manual transaction processing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The finally block was resetting runningTestTransaction instead of processingSpecificTransaction, causing the button to stay in loading state after processing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- static/js/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/js/index.js b/static/js/index.js index 687db7d..610ec0a 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.runningTestTransaction = false + this.processingSpecificTransaction = false } }, From 397fd4b0028cfe1335cb644653767c49723cb535 Mon Sep 17 00:00:00 2001 From: padreug Date: Sun, 11 Jan 2026 14:14:18 +0100 Subject: [PATCH 4/7] feat: add unit tests for DCA calculations with empirical Lamassu data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract pure calculation functions to calculations.py (no lnbits deps) - transaction_processor.py now imports from calculations.py (DRY) - Add 22 tests covering commission, distribution, and fiat round-trip - Include real Lamassu transaction data (8.75%, 5.5% commission rates) - Test edge cases: discounts (90%, 100%), zero commission, small amounts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- calculations.py | 147 ++++++++++++++ tests/test_calculations.py | 381 +++++++++++++++++++++++++++++++++++++ transaction_processor.py | 116 ++++------- 3 files changed, 560 insertions(+), 84 deletions(-) create mode 100644 calculations.py create mode 100644 tests/test_calculations.py 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/tests/test_calculations.py b/tests/test_calculations.py new file mode 100644 index 0000000..04262a0 --- /dev/null +++ b/tests/test_calculations.py @@ -0,0 +1,381 @@ +""" +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 bd6ae77..45e0880 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, @@ -715,21 +716,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)") @@ -758,67 +751,29 @@ class LamassuTransactionProcessor: if total_confirmed_deposits == 0: logger.info("No clients with remaining DCA balance - skipping distribution") return {} - - # Calculate proportional distribution with remainder allocation + + # Calculate sat allocations using the extracted pure function + sat_allocations = calculate_distribution(base_crypto_atoms, client_balances) + + if not sat_allocations: + logger.info("No allocations calculated - skipping distribution") + return {} + + # Build final distributions dict with additional tracking fields 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 - ) - - # 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'] - + 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 @@ -1062,18 +1017,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( @@ -1201,12 +1151,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) From 49f3670bac46d36d480d70fb32fcfe0f392a4256 Mon Sep 17 00:00:00 2001 From: padreug Date: Sun, 11 Jan 2026 15:40:17 +0100 Subject: [PATCH 5/7] fix: cast amount to float for LNbits create_invoice API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LNbits create_invoice expects amount as float, not int. Added explicit float() cast to both DCA distribution and commission payment invoice creation calls. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- transaction_processor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/transaction_processor.py b/transaction_processor.py index 45e0880..a848998 100644 --- a/transaction_processor.py +++ b/transaction_processor.py @@ -920,7 +920,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 @@ -1085,7 +1085,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={ From 545a0284a7c9a93b8b4d0fc540c9bf029a0cb464 Mon Sep 17 00:00:00 2001 From: padreug Date: Sun, 11 Jan 2026 15:54:48 +0100 Subject: [PATCH 6/7] fix: cap DCA allocations when ATM cash exceeds tracked balances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When there's a sync mismatch (more cash in ATM than tracked client balances), cap each client's allocation to their remaining fiat balance equivalent in sats. Orphan sats stay in the source wallet. This prevents over-allocation when deposits haven't been recorded yet or when there's a timing mismatch between ATM transactions and balance tracking. - Detect sync mismatch: total_confirmed_deposits < fiat_amount - In sync mismatch mode: allocate based on client balance, not tx amount - Track orphan_sats that couldn't be distributed - Normal mode unchanged: proportional distribution using calculate_distribution() 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- transaction_processor.py | 131 +++++++++++++++++++++++++++------------ 1 file changed, 91 insertions(+), 40 deletions(-) diff --git a/transaction_processor.py b/transaction_processor.py index a848998..661e5ab 100644 --- a/transaction_processor.py +++ b/transaction_processor.py @@ -669,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 @@ -701,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 @@ -750,37 +756,76 @@ class LamassuTransactionProcessor: if total_confirmed_deposits == 0: logger.info("No clients with remaining DCA balance - skipping distribution") - return {} + return {}, 0 - # Calculate sat allocations using the extracted pure function - sat_allocations = calculate_distribution(base_crypto_atoms, client_balances) + # 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" + ) - if not sat_allocations: - logger.info("No allocations calculated - skipping distribution") - return {} - - # Build final distributions dict with additional tracking fields + # Calculate distribution amounts distributions = {} - 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 + 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 - distributions[client_id] = { - "fiat_amount": client_fiat_amount, - "sats_amount": client_sats_amount, - "exchange_rate": exchange_rate - } + # 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 - 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}") + 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 @@ -800,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""" @@ -1140,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 From 6eb076d5f65977255ba8f3fa2613e30c22935b70 Mon Sep 17 00:00:00 2001 From: padreug Date: Sun, 11 Jan 2026 16:10:48 +0100 Subject: [PATCH 7/7] chore: bump version to 0.0.4 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 "]