Compare commits

..

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

6 changed files with 120 additions and 653 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]
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 <dni@lnbits.com>"]

View file

@ -670,7 +670,7 @@ window.app = Vue.createApp({
} catch (error) {
LNbits.utils.notifyApiError(error)
} 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.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)

View file

@ -1,11 +1,9 @@
# Description: DCA Admin page endpoints.
from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from lnbits.decorators import check_super_user
from lnbits.helpers import template_renderer
satmachineadmin_generic_router = APIRouter()
@ -17,11 +15,7 @@ def satmachineadmin_renderer():
# DCA Admin page - Requires superuser access
@satmachineadmin_generic_router.get("/", response_class=HTMLResponse)
async def index(req: Request, user: User = Depends(check_user_exists)):
if not user.super_user:
raise HTTPException(
HTTPStatus.FORBIDDEN, "User not authorized. No super user privileges."
)
async def index(req: Request, user: User = Depends(check_super_user)):
return satmachineadmin_renderer().TemplateResponse(
"satmachineadmin/index.html", {"request": req, "user": user.json()}
)