Compare commits
5 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6eb076d5f6 | |||
| 545a0284a7 | |||
| 49f3670bac | |||
| 397fd4b002 | |||
| 8d94dcc2b7 |
5 changed files with 645 additions and 118 deletions
147
calculations.py
Normal file
147
calculations.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "satmachineadmin"
|
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."
|
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>"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -670,7 +670,7 @@ window.app = Vue.createApp({
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
LNbits.utils.notifyApiError(error)
|
LNbits.utils.notifyApiError(error)
|
||||||
} finally {
|
} finally {
|
||||||
this.runningTestTransaction = false
|
this.processingSpecificTransaction = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
381
tests/test_calculations.py
Normal file
381
tests/test_calculations.py
Normal file
|
|
@ -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"]
|
||||||
|
|
@ -26,6 +26,7 @@ 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,
|
||||||
|
|
@ -668,15 +669,21 @@ 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]) -> Dict[str, int]:
|
async def calculate_distribution_amounts(self, transaction: Dict[str, Any]) -> tuple[Dict[str, Any], 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 {}
|
return {}, 0
|
||||||
|
|
||||||
# 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
|
||||||
|
|
@ -700,10 +707,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 {}
|
return {}, 0
|
||||||
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 {}
|
return {}, 0
|
||||||
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
|
||||||
|
|
@ -715,21 +722,13 @@ 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 effective commission percentage after discount (following the reference logic)
|
# Calculate commission split using the extracted pure function
|
||||||
if commission_percentage > 0:
|
base_crypto_atoms, commission_amount_sats, effective_commission = calculate_commission(
|
||||||
effective_commission = commission_percentage * (100 - discount) / 100
|
crypto_atoms, commission_percentage, discount
|
||||||
# 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 = 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"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)")
|
||||||
|
|
@ -757,75 +756,76 @@ 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 {}
|
return {}, 0
|
||||||
|
|
||||||
# Calculate proportional distribution with remainder allocation
|
# Detect sync mismatch: more money in ATM than tracked client balances
|
||||||
distributions = {}
|
sync_mismatch = total_confirmed_deposits < fiat_amount
|
||||||
distributed_sats = 0
|
if sync_mismatch:
|
||||||
client_calculations = []
|
orphan_fiat = fiat_amount - total_confirmed_deposits
|
||||||
|
logger.warning(
|
||||||
# First pass: calculate base amounts and track remainders
|
f"Sync mismatch detected: tracked balances ({total_confirmed_deposits:.2f} GTQ) "
|
||||||
for client_id, client_balance in client_balances.items():
|
f"< transaction ({fiat_amount} GTQ). Orphan amount: {orphan_fiat:.2f} GTQ"
|
||||||
# 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
|
# Calculate distribution amounts
|
||||||
for i in range(abs(remainder)):
|
distributions = {}
|
||||||
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
|
if sync_mismatch:
|
||||||
for calc in client_calculations:
|
# SYNC MISMATCH MODE: Cap each client's allocation to their remaining fiat balance
|
||||||
client_id = calc['client_id']
|
# Each client gets sats equivalent to their full remaining balance
|
||||||
client_sats_amount = calc['allocated_sats']
|
for client_id, client_balance in client_balances.items():
|
||||||
proportion = calc['proportion']
|
# 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
|
# 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
|
client_fiat_amount = round(client_sats_amount / exchange_rate, 2) if exchange_rate > 0 else 0.0
|
||||||
|
|
||||||
distributions[client_id] = {
|
distributions[client_id] = {
|
||||||
"fiat_amount": client_fiat_amount,
|
"fiat_amount": client_fiat_amount,
|
||||||
"sats_amount": client_sats_amount,
|
"sats_amount": client_sats_amount,
|
||||||
"exchange_rate": exchange_rate
|
"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
|
# Calculate orphan sats (difference between base amount and distributed)
|
||||||
total_distributed = sum(dist["sats_amount"] for dist in distributions.values())
|
total_distributed = sum(dist["sats_amount"] for dist in distributions.values())
|
||||||
if total_distributed != base_crypto_atoms:
|
orphan_sats = base_crypto_atoms - total_distributed
|
||||||
logger.error(f"Distribution mismatch! Expected: {base_crypto_atoms} sats, Distributed: {total_distributed} sats")
|
logger.info(
|
||||||
raise ValueError(f"Satoshi distribution calculation error: {base_crypto_atoms} != {total_distributed}")
|
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
|
# 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 {}
|
return {}, orphan_sats
|
||||||
|
|
||||||
# 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
|
return distributions, orphan_sats
|
||||||
|
|
||||||
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 {}
|
return {}, 0
|
||||||
|
|
||||||
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=amount_sats, # LNBits create_invoice expects sats
|
amount=float(amount_sats), # LNBits create_invoice expects float
|
||||||
internal=True, # Internal transfer within LNBits
|
internal=True, # Internal transfer within LNBits
|
||||||
memo=memo,
|
memo=memo,
|
||||||
extra=extra
|
extra=extra
|
||||||
|
|
@ -1062,18 +1062,13 @@ 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
|
# Calculate commission metrics using the extracted pure function
|
||||||
if commission_percentage > 0:
|
base_crypto_atoms, commission_amount_sats, effective_commission = 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:
|
|
||||||
effective_commission = 0.0
|
|
||||||
base_crypto_atoms = crypto_atoms
|
|
||||||
commission_amount_sats = 0
|
|
||||||
|
|
||||||
# Calculate exchange rate
|
# 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
|
# Create transaction data with GTQ amounts
|
||||||
transaction_data = CreateLamassuTransactionData(
|
transaction_data = CreateLamassuTransactionData(
|
||||||
|
|
@ -1135,7 +1130,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=commission_amount_sats,
|
amount=float(commission_amount_sats), # LNbits create_invoice expects float
|
||||||
internal=True,
|
internal=True,
|
||||||
memo=commission_memo,
|
memo=commission_memo,
|
||||||
extra={
|
extra={
|
||||||
|
|
@ -1190,10 +1185,16 @@ 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 = await self.calculate_distribution_amounts(transaction)
|
distributions, orphan_sats = await self.calculate_distribution_amounts(transaction)
|
||||||
|
|
||||||
if not distributions:
|
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
|
return
|
||||||
|
|
||||||
# Calculate commission amount for sending to commission wallet
|
# Calculate commission amount for sending to commission wallet
|
||||||
|
|
@ -1201,12 +1202,10 @@ 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)
|
||||||
|
|
||||||
if commission_percentage and commission_percentage > 0:
|
# Calculate commission amount using the extracted pure function
|
||||||
effective_commission = commission_percentage * (100 - discount) / 100
|
_, commission_amount_sats, _ = calculate_commission(
|
||||||
base_crypto_atoms = round(crypto_atoms / (1 + effective_commission))
|
crypto_atoms, commission_percentage, discount
|
||||||
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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue