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]
|
||||
name = "satmachineadmin"
|
||||
version = "0.0.0"
|
||||
version = "0.0.4"
|
||||
description = "Eightball is a simple API that allows you to create a random number generator."
|
||||
authors = ["benarc", "dni <dni@lnbits.com>"]
|
||||
|
||||
|
|
|
|||
|
|
@ -670,7 +670,7 @@ window.app = Vue.createApp({
|
|||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
} 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.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,
|
||||
|
|
@ -668,15 +669,21 @@ class LamassuTransactionProcessor:
|
|||
logger.error(f"Error fetching transactions from Lamassu database: {e}")
|
||||
return []
|
||||
|
||||
async def calculate_distribution_amounts(self, transaction: Dict[str, Any]) -> Dict[str, int]:
|
||||
"""Calculate how much each Flow Mode client should receive"""
|
||||
async def calculate_distribution_amounts(self, transaction: Dict[str, Any]) -> tuple[Dict[str, Any], int]:
|
||||
"""Calculate how much each Flow Mode client should receive.
|
||||
|
||||
Returns:
|
||||
tuple: (distributions dict, orphan_sats int)
|
||||
- distributions: {client_id: {fiat_amount, sats_amount, exchange_rate}}
|
||||
- orphan_sats: sats that couldn't be distributed due to sync mismatch
|
||||
"""
|
||||
try:
|
||||
# Get all active Flow Mode clients
|
||||
flow_clients = await get_flow_mode_clients()
|
||||
|
||||
if not flow_clients:
|
||||
logger.info("No Flow Mode clients found - skipping distribution")
|
||||
return {}
|
||||
return {}, 0
|
||||
|
||||
# Extract transaction details - guaranteed clean from data ingestion
|
||||
crypto_atoms = transaction.get("crypto_amount", 0) # Total sats with commission baked in
|
||||
|
|
@ -700,10 +707,10 @@ class LamassuTransactionProcessor:
|
|||
# Validate required fields
|
||||
if crypto_atoms is None:
|
||||
logger.error(f"Missing crypto_amount in transaction: {transaction}")
|
||||
return {}
|
||||
return {}, 0
|
||||
if fiat_amount is None:
|
||||
logger.error(f"Missing fiat_amount in transaction: {transaction}")
|
||||
return {}
|
||||
return {}, 0
|
||||
if commission_percentage is None:
|
||||
logger.warning(f"Missing commission_percentage in transaction: {transaction}, defaulting to 0")
|
||||
commission_percentage = 0.0
|
||||
|
|
@ -715,21 +722,13 @@ class LamassuTransactionProcessor:
|
|||
# Could use current time as fallback, but this indicates a data issue
|
||||
# transaction_time = datetime.now(timezone.utc)
|
||||
|
||||
# Calculate effective commission percentage after discount (following the reference logic)
|
||||
if commission_percentage > 0:
|
||||
effective_commission = commission_percentage * (100 - discount) / 100
|
||||
# Since crypto_atoms already includes commission, we need to extract the base amount
|
||||
# Formula: crypto_atoms = base_amount * (1 + effective_commission)
|
||||
# Therefore: base_amount = crypto_atoms / (1 + effective_commission)
|
||||
base_crypto_atoms = round(crypto_atoms / (1 + effective_commission))
|
||||
commission_amount_sats = crypto_atoms - base_crypto_atoms
|
||||
else:
|
||||
effective_commission = 0.0
|
||||
base_crypto_atoms = crypto_atoms
|
||||
commission_amount_sats = 0
|
||||
# Calculate commission split using the extracted pure function
|
||||
base_crypto_atoms, commission_amount_sats, effective_commission = calculate_commission(
|
||||
crypto_atoms, commission_percentage, discount
|
||||
)
|
||||
|
||||
# Calculate exchange rate based on base amounts
|
||||
exchange_rate = base_crypto_atoms / fiat_amount if fiat_amount > 0 else 0 # sats per fiat unit
|
||||
exchange_rate = calculate_exchange_rate(base_crypto_atoms, fiat_amount)
|
||||
|
||||
logger.info(f"Transaction - Total crypto: {crypto_atoms} sats")
|
||||
logger.info(f"Commission: {commission_percentage*100:.1f}% - {discount:.1f}% discount = {effective_commission*100:.1f}% effective ({commission_amount_sats} sats)")
|
||||
|
|
@ -757,58 +756,58 @@ class LamassuTransactionProcessor:
|
|||
|
||||
if total_confirmed_deposits == 0:
|
||||
logger.info("No clients with remaining DCA balance - skipping distribution")
|
||||
return {}
|
||||
return {}, 0
|
||||
|
||||
# Calculate proportional distribution with remainder allocation
|
||||
distributions = {}
|
||||
distributed_sats = 0
|
||||
client_calculations = []
|
||||
|
||||
# First pass: calculate base amounts and track remainders
|
||||
for client_id, client_balance in client_balances.items():
|
||||
# Calculate this client's proportion of the total DCA pool
|
||||
proportion = client_balance / total_confirmed_deposits
|
||||
|
||||
# Calculate exact share (with decimals)
|
||||
exact_share = base_crypto_atoms * proportion
|
||||
|
||||
# Use banker's rounding for base allocation
|
||||
client_sats_amount = round(exact_share)
|
||||
|
||||
client_calculations.append({
|
||||
'client_id': client_id,
|
||||
'proportion': proportion,
|
||||
'exact_share': exact_share,
|
||||
'allocated_sats': client_sats_amount,
|
||||
'client_balance': client_balance
|
||||
})
|
||||
|
||||
distributed_sats += client_sats_amount
|
||||
|
||||
# Handle any remainder due to rounding (should be small)
|
||||
remainder = base_crypto_atoms - distributed_sats
|
||||
|
||||
if remainder != 0:
|
||||
logger.info(f"Distributing remainder: {remainder} sats among {len(client_calculations)} clients")
|
||||
|
||||
# Sort clients by largest fractional remainder to distribute fairly
|
||||
client_calculations.sort(
|
||||
key=lambda x: x['exact_share'] - x['allocated_sats'],
|
||||
reverse=True
|
||||
# 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"
|
||||
)
|
||||
|
||||
# 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
|
||||
# Calculate distribution amounts
|
||||
distributions = {}
|
||||
|
||||
# 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']
|
||||
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)"
|
||||
)
|
||||
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
|
||||
|
|
@ -826,6 +825,7 @@ class LamassuTransactionProcessor:
|
|||
if total_distributed != base_crypto_atoms:
|
||||
logger.error(f"Distribution mismatch! Expected: {base_crypto_atoms} sats, Distributed: {total_distributed} sats")
|
||||
raise ValueError(f"Satoshi distribution calculation error: {base_crypto_atoms} != {total_distributed}")
|
||||
orphan_sats = 0
|
||||
|
||||
# Safety check: Re-verify all clients still have positive balances before finalizing distributions
|
||||
# This prevents race conditions where balances changed during calculation
|
||||
|
|
@ -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 {}
|
||||
return {}, orphan_sats
|
||||
|
||||
# For simplicity, we'll still return the original distributions but log the warning
|
||||
# In a production system, you might want to recalculate the entire distribution
|
||||
logger.warning("Proceeding with original distribution despite balance warnings - manual review recommended")
|
||||
|
||||
logger.info(f"Distribution verified: {total_distributed} sats distributed across {len(distributions)} clients (clients with positive allocations only)")
|
||||
return distributions
|
||||
return distributions, orphan_sats
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating distribution amounts: {e}")
|
||||
return {}
|
||||
return {}, 0
|
||||
|
||||
async def distribute_to_clients(self, transaction: Dict[str, Any], distributions: Dict[str, Dict[str, int]]) -> None:
|
||||
"""Send Bitcoin payments to DCA clients"""
|
||||
|
|
@ -965,7 +965,7 @@ class LamassuTransactionProcessor:
|
|||
}
|
||||
new_payment = await create_invoice(
|
||||
wallet_id=target_wallet.id,
|
||||
amount=amount_sats, # LNBits create_invoice expects sats
|
||||
amount=float(amount_sats), # LNBits create_invoice expects float
|
||||
internal=True, # Internal transfer within LNBits
|
||||
memo=memo,
|
||||
extra=extra
|
||||
|
|
@ -1062,18 +1062,13 @@ class LamassuTransactionProcessor:
|
|||
elif transaction_time.tzinfo != timezone.utc:
|
||||
transaction_time = transaction_time.astimezone(timezone.utc)
|
||||
|
||||
# Calculate commission metrics
|
||||
if commission_percentage > 0:
|
||||
effective_commission = commission_percentage * (100 - discount) / 100
|
||||
base_crypto_atoms = round(crypto_atoms / (1 + effective_commission))
|
||||
commission_amount_sats = crypto_atoms - base_crypto_atoms
|
||||
else:
|
||||
effective_commission = 0.0
|
||||
base_crypto_atoms = crypto_atoms
|
||||
commission_amount_sats = 0
|
||||
# Calculate commission metrics using the extracted pure function
|
||||
base_crypto_atoms, commission_amount_sats, effective_commission = calculate_commission(
|
||||
crypto_atoms, commission_percentage, discount
|
||||
)
|
||||
|
||||
# Calculate exchange rate
|
||||
exchange_rate = base_crypto_atoms / fiat_amount if fiat_amount > 0 else 0
|
||||
exchange_rate = calculate_exchange_rate(base_crypto_atoms, fiat_amount)
|
||||
|
||||
# Create transaction data with GTQ amounts
|
||||
transaction_data = CreateLamassuTransactionData(
|
||||
|
|
@ -1135,7 +1130,7 @@ class LamassuTransactionProcessor:
|
|||
|
||||
commission_payment = await create_invoice(
|
||||
wallet_id=admin_config.commission_wallet_id,
|
||||
amount=commission_amount_sats,
|
||||
amount=float(commission_amount_sats), # LNbits create_invoice expects float
|
||||
internal=True,
|
||||
memo=commission_memo,
|
||||
extra={
|
||||
|
|
@ -1190,9 +1185,15 @@ class LamassuTransactionProcessor:
|
|||
stored_transaction = await self.store_lamassu_transaction(transaction)
|
||||
|
||||
# Calculate distribution amounts
|
||||
distributions = await self.calculate_distribution_amounts(transaction)
|
||||
distributions, orphan_sats = await self.calculate_distribution_amounts(transaction)
|
||||
|
||||
if not distributions:
|
||||
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
|
||||
|
||||
|
|
@ -1201,12 +1202,10 @@ class LamassuTransactionProcessor:
|
|||
commission_percentage = transaction.get("commission_percentage", 0.0)
|
||||
discount = transaction.get("discount", 0.0)
|
||||
|
||||
if commission_percentage and commission_percentage > 0:
|
||||
effective_commission = commission_percentage * (100 - discount) / 100
|
||||
base_crypto_atoms = round(crypto_atoms / (1 + effective_commission))
|
||||
commission_amount_sats = crypto_atoms - base_crypto_atoms
|
||||
else:
|
||||
commission_amount_sats = 0
|
||||
# Calculate commission amount using the extracted pure function
|
||||
_, commission_amount_sats, _ = calculate_commission(
|
||||
crypto_atoms, commission_percentage, discount
|
||||
)
|
||||
|
||||
# Distribute to clients
|
||||
await self.distribute_to_clients(transaction, distributions)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue