Compare commits

..

No commits in common. "main" and "v0.0.3" have entirely different histories.
main ... v0.0.3

5 changed files with 117 additions and 644 deletions

View file

@ -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

View file

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "satmachineadmin" 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." description = "Eightball is a simple API that allows you to create a random number generator."
authors = ["benarc", "dni <dni@lnbits.com>"] authors = ["benarc", "dni <dni@lnbits.com>"]

View file

@ -670,7 +670,7 @@ window.app = Vue.createApp({
} catch (error) { } catch (error) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
} finally { } finally {
this.processingSpecificTransaction = false this.runningTestTransaction = false
} }
}, },

View file

@ -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"]

View file

@ -26,7 +26,6 @@ from lnbits.core.crud.wallets import get_wallet
from lnbits.core.services import update_wallet_balance from lnbits.core.services import update_wallet_balance
from lnbits.settings import settings from lnbits.settings import settings
from .calculations import calculate_commission, calculate_distribution, calculate_exchange_rate
from .crud import ( from .crud import (
get_flow_mode_clients, get_flow_mode_clients,
get_payments_by_lamassu_transaction, get_payments_by_lamassu_transaction,
@ -669,21 +668,15 @@ class LamassuTransactionProcessor:
logger.error(f"Error fetching transactions from Lamassu database: {e}") logger.error(f"Error fetching transactions from Lamassu database: {e}")
return [] return []
async def calculate_distribution_amounts(self, transaction: Dict[str, Any]) -> tuple[Dict[str, Any], int]: async def calculate_distribution_amounts(self, transaction: Dict[str, Any]) -> Dict[str, int]:
"""Calculate how much each Flow Mode client should receive. """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: try:
# Get all active Flow Mode clients # Get all active Flow Mode clients
flow_clients = await get_flow_mode_clients() flow_clients = await get_flow_mode_clients()
if not flow_clients: if not flow_clients:
logger.info("No Flow Mode clients found - skipping distribution") logger.info("No Flow Mode clients found - skipping distribution")
return {}, 0 return {}
# Extract transaction details - guaranteed clean from data ingestion # Extract transaction details - guaranteed clean from data ingestion
crypto_atoms = transaction.get("crypto_amount", 0) # Total sats with commission baked in crypto_atoms = transaction.get("crypto_amount", 0) # Total sats with commission baked in
@ -707,10 +700,10 @@ class LamassuTransactionProcessor:
# Validate required fields # Validate required fields
if crypto_atoms is None: if crypto_atoms is None:
logger.error(f"Missing crypto_amount in transaction: {transaction}") logger.error(f"Missing crypto_amount in transaction: {transaction}")
return {}, 0 return {}
if fiat_amount is None: if fiat_amount is None:
logger.error(f"Missing fiat_amount in transaction: {transaction}") logger.error(f"Missing fiat_amount in transaction: {transaction}")
return {}, 0 return {}
if commission_percentage is None: if commission_percentage is None:
logger.warning(f"Missing commission_percentage in transaction: {transaction}, defaulting to 0") logger.warning(f"Missing commission_percentage in transaction: {transaction}, defaulting to 0")
commission_percentage = 0.0 commission_percentage = 0.0
@ -722,13 +715,21 @@ class LamassuTransactionProcessor:
# Could use current time as fallback, but this indicates a data issue # Could use current time as fallback, but this indicates a data issue
# transaction_time = datetime.now(timezone.utc) # transaction_time = datetime.now(timezone.utc)
# Calculate commission split using the extracted pure function # Calculate effective commission percentage after discount (following the reference logic)
base_crypto_atoms, commission_amount_sats, effective_commission = calculate_commission( if commission_percentage > 0:
crypto_atoms, commission_percentage, discount 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 # 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"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)") 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: if total_confirmed_deposits == 0:
logger.info("No clients with remaining DCA balance - skipping distribution") logger.info("No clients with remaining DCA balance - skipping distribution")
return {}, 0 return {}
# Detect sync mismatch: more money in ATM than tracked client balances # Calculate proportional distribution with remainder allocation
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
distributions = {} distributions = {}
distributed_sats = 0
if sync_mismatch: client_calculations = []
# SYNC MISMATCH MODE: Cap each client's allocation to their remaining fiat balance
# Each client gets sats equivalent to their full remaining balance # First pass: calculate base amounts and track remainders
for client_id, client_balance in client_balances.items(): for client_id, client_balance in client_balances.items():
# Calculate sats equivalent to this client's remaining fiat balance # Calculate this client's proportion of the total DCA pool
client_sats_amount = round(client_balance * exchange_rate) proportion = client_balance / total_confirmed_deposits
proportion = client_balance / total_confirmed_deposits
# Calculate exact share (with decimals)
# Calculate equivalent fiat value in GTQ for tracking purposes exact_share = base_crypto_atoms * proportion
client_fiat_amount = round(client_sats_amount / exchange_rate, 2) if exchange_rate > 0 else 0.0
# Use banker's rounding for base allocation
distributions[client_id] = { client_sats_amount = round(exact_share)
"fiat_amount": client_fiat_amount,
"sats_amount": client_sats_amount, client_calculations.append({
"exchange_rate": exchange_rate 'client_id': client_id,
} 'proportion': proportion,
'exact_share': exact_share,
logger.info(f"Client {client_id[:8]}... gets {client_sats_amount} sats (≈{client_fiat_amount:.2f} GTQ, {proportion:.2%} share)") 'allocated_sats': client_sats_amount,
'client_balance': client_balance
# 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 distributed_sats += client_sats_amount
logger.info(
f"Sync mismatch distribution: {total_distributed} sats to clients, " # Handle any remainder due to rounding (should be small)
f"{orphan_sats} sats orphaned (staying in source wallet)" 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 # Distribute remainder one sat at a time to clients with largest fractional parts
sat_allocations = calculate_distribution(base_crypto_atoms, client_balances) for i in range(abs(remainder)):
if remainder > 0:
if not sat_allocations: client_calculations[i % len(client_calculations)]['allocated_sats'] += 1
logger.info("No allocations calculated - skipping distribution") else:
return {}, 0 client_calculations[i % len(client_calculations)]['allocated_sats'] -= 1
# Build final distributions dict with additional tracking fields # Second pass: create distributions with final amounts
for client_id, client_sats_amount in sat_allocations.items(): for calc in client_calculations:
# Calculate proportion for logging client_id = calc['client_id']
proportion = client_balances[client_id] / total_confirmed_deposits 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 # 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, distributions[client_id] = {
"sats_amount": client_sats_amount, "fiat_amount": client_fiat_amount,
"exchange_rate": exchange_rate "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)")
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()) # Verification: ensure total distribution equals base amount
if total_distributed != base_crypto_atoms: total_distributed = sum(dist["sats_amount"] for dist in distributions.values())
logger.error(f"Distribution mismatch! Expected: {base_crypto_atoms} sats, Distributed: {total_distributed} sats") if total_distributed != base_crypto_atoms:
raise ValueError(f"Satoshi distribution calculation error: {base_crypto_atoms} != {total_distributed}") logger.error(f"Distribution mismatch! Expected: {base_crypto_atoms} sats, Distributed: {total_distributed} sats")
orphan_sats = 0 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 # Safety check: Re-verify all clients still have positive balances before finalizing distributions
# This prevents race conditions where balances changed during calculation # This prevents race conditions where balances changed during calculation
@ -845,18 +845,18 @@ class LamassuTransactionProcessor:
# Recalculate proportions if some clients were rejected # Recalculate proportions if some clients were rejected
if len(final_distributions) == 0: if len(final_distributions) == 0:
logger.info("All clients rejected due to negative balances - no distributions") 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 # 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 # 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.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)") 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: except Exception as e:
logger.error(f"Error calculating distribution amounts: {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: async def distribute_to_clients(self, transaction: Dict[str, Any], distributions: Dict[str, Dict[str, int]]) -> None:
"""Send Bitcoin payments to DCA clients""" """Send Bitcoin payments to DCA clients"""
@ -965,7 +965,7 @@ class LamassuTransactionProcessor:
} }
new_payment = await create_invoice( new_payment = await create_invoice(
wallet_id=target_wallet.id, 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 internal=True, # Internal transfer within LNBits
memo=memo, memo=memo,
extra=extra extra=extra
@ -1062,13 +1062,18 @@ class LamassuTransactionProcessor:
elif transaction_time.tzinfo != timezone.utc: elif transaction_time.tzinfo != timezone.utc:
transaction_time = transaction_time.astimezone(timezone.utc) transaction_time = transaction_time.astimezone(timezone.utc)
# Calculate commission metrics using the extracted pure function # Calculate commission metrics
base_crypto_atoms, commission_amount_sats, effective_commission = calculate_commission( if commission_percentage > 0:
crypto_atoms, commission_percentage, discount 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 # 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 # Create transaction data with GTQ amounts
transaction_data = CreateLamassuTransactionData( transaction_data = CreateLamassuTransactionData(
@ -1130,7 +1135,7 @@ class LamassuTransactionProcessor:
commission_payment = await create_invoice( commission_payment = await create_invoice(
wallet_id=admin_config.commission_wallet_id, wallet_id=admin_config.commission_wallet_id,
amount=float(commission_amount_sats), # LNbits create_invoice expects float amount=commission_amount_sats,
internal=True, internal=True,
memo=commission_memo, memo=commission_memo,
extra={ extra={
@ -1185,16 +1190,10 @@ class LamassuTransactionProcessor:
stored_transaction = await self.store_lamassu_transaction(transaction) stored_transaction = await self.store_lamassu_transaction(transaction)
# Calculate distribution amounts # Calculate distribution amounts
distributions, orphan_sats = await self.calculate_distribution_amounts(transaction) distributions = await self.calculate_distribution_amounts(transaction)
if not distributions: if not distributions:
if orphan_sats > 0: logger.info(f"No distributions calculated for transaction {transaction_id}")
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 return
# Calculate commission amount for sending to commission wallet # Calculate commission amount for sending to commission wallet
@ -1202,10 +1201,12 @@ class LamassuTransactionProcessor:
commission_percentage = transaction.get("commission_percentage", 0.0) commission_percentage = transaction.get("commission_percentage", 0.0)
discount = transaction.get("discount", 0.0) discount = transaction.get("discount", 0.0)
# Calculate commission amount using the extracted pure function if commission_percentage and commission_percentage > 0:
_, commission_amount_sats, _ = calculate_commission( effective_commission = commission_percentage * (100 - discount) / 100
crypto_atoms, commission_percentage, discount 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 # Distribute to clients
await self.distribute_to_clients(transaction, distributions) await self.distribute_to_clients(transaction, distributions)