173 lines
6.3 KiB
Python
173 lines
6.3 KiB
Python
"""US-033: leader-only settlement loop with batched USDT payouts.
|
|
|
|
Mining-pool trigger (threshold OR max-period, dust floor), pending debited
|
|
before the transaction is sent, unconfirmed batches resent by settlement id
|
|
(never double-paying), banned wallets skipped, history queryable over HTTP.
|
|
"""
|
|
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from meshnet_contracts import LocalSolanaContracts
|
|
from meshnet_tracker.billing import BillingLedger
|
|
from meshnet_tracker.server import TrackerServer
|
|
|
|
MODEL = "stub-model"
|
|
|
|
|
|
class _FakePayoutTreasury:
|
|
"""Records payout batches; can be told to fail the next N sends."""
|
|
|
|
def __init__(self):
|
|
self.batches: list[list[tuple[str, float]]] = []
|
|
self.fail_next = 0
|
|
self._counter = 0
|
|
|
|
def list_new_deposits(self, is_seen, *, limit=200):
|
|
return []
|
|
|
|
def send_payouts(self, payouts):
|
|
if self.fail_next > 0:
|
|
self.fail_next -= 1
|
|
raise RuntimeError("rpc timeout (simulated)")
|
|
self.batches.append(list(payouts))
|
|
self._counter += 1
|
|
return f"fake-tx-{self._counter}"
|
|
|
|
|
|
def _wait_for(predicate, timeout=5.0):
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
if predicate():
|
|
return True
|
|
time.sleep(0.05)
|
|
return False
|
|
|
|
|
|
def _make_tracker(ledger, treasury, *, threshold=0.01, period=3600.0,
|
|
dust=0.001, contracts=None, cluster_peers=None):
|
|
return TrackerServer(
|
|
billing=ledger,
|
|
treasury=treasury,
|
|
contracts=contracts,
|
|
deposit_poll_interval=30.0,
|
|
settle_period=period,
|
|
payout_threshold=threshold,
|
|
payout_dust_floor=dust,
|
|
settlement_check_interval=0.05,
|
|
cluster_peers=cluster_peers,
|
|
)
|
|
|
|
|
|
def test_threshold_triggers_payout_and_zeroes_pending():
|
|
"Threshold triggers payout and zeroes pending\n\nTags: billing, settlement"
|
|
ledger = BillingLedger(starting_credit=10.0, default_price_per_1k=0.02)
|
|
ledger.charge_request("client", MODEL, 1000, [("wallet-a", 12)]) # 0.018 pending
|
|
treasury = _FakePayoutTreasury()
|
|
tracker = _make_tracker(ledger, treasury, threshold=0.01)
|
|
tracker.start()
|
|
try:
|
|
assert _wait_for(lambda: treasury.batches)
|
|
assert treasury.batches[0] == [("wallet-a", pytest.approx(0.018))]
|
|
assert ledger.get_node_pending("wallet-a") == pytest.approx(0.0)
|
|
|
|
# /v1/billing/settlements is admin-gated now (ADR-0017, covered in
|
|
# test_auth_boundary.py); verify content via the ledger directly.
|
|
history = ledger.settlement_history()
|
|
assert len(history) == 1
|
|
assert history[0]["signature"] == "fake-tx-1"
|
|
assert history[0]["payouts"] == [
|
|
{"wallet": "wallet-a", "amount": pytest.approx(0.018)}
|
|
]
|
|
finally:
|
|
tracker.stop()
|
|
|
|
|
|
def test_period_triggers_payout_below_threshold():
|
|
"Period triggers payout below threshold\n\nTags: billing, settlement"
|
|
ledger = BillingLedger(starting_credit=10.0, default_price_per_1k=0.02)
|
|
ledger.charge_request("client", MODEL, 1000, [("wallet-a", 12)])
|
|
treasury = _FakePayoutTreasury()
|
|
tracker = _make_tracker(ledger, treasury, threshold=100.0, period=0.2)
|
|
tracker.start()
|
|
try:
|
|
assert _wait_for(lambda: treasury.batches)
|
|
assert ledger.get_node_pending("wallet-a") == pytest.approx(0.0)
|
|
finally:
|
|
tracker.stop()
|
|
|
|
|
|
def test_dust_floor_blocks_tiny_payouts():
|
|
"Dust floor blocks tiny payouts\n\nTags: billing, settlement"
|
|
ledger = BillingLedger(starting_credit=10.0, default_price_per_1k=0.02)
|
|
ledger.charge_request("client", MODEL, 10, [("wallet-a", 12)]) # 0.00018 pending
|
|
treasury = _FakePayoutTreasury()
|
|
tracker = _make_tracker(ledger, treasury, threshold=0.0, period=0.1, dust=0.001)
|
|
tracker.start()
|
|
try:
|
|
time.sleep(0.5)
|
|
assert treasury.batches == []
|
|
assert ledger.get_node_pending("wallet-a") > 0
|
|
finally:
|
|
tracker.stop()
|
|
|
|
|
|
def test_failed_transaction_retries_without_double_pay():
|
|
"Failed transaction retries without double pay\n\nTags: billing, settlement"
|
|
ledger = BillingLedger(starting_credit=10.0, default_price_per_1k=0.02)
|
|
ledger.charge_request("client", MODEL, 1000, [("wallet-a", 12)])
|
|
treasury = _FakePayoutTreasury()
|
|
treasury.fail_next = 3
|
|
tracker = _make_tracker(ledger, treasury, threshold=0.01)
|
|
tracker.start()
|
|
try:
|
|
assert _wait_for(lambda: treasury.batches)
|
|
time.sleep(0.3) # give the loop room to (incorrectly) double-send
|
|
assert len(treasury.batches) == 1
|
|
assert treasury.batches[0] == [("wallet-a", pytest.approx(0.018))]
|
|
assert ledger.get_node_pending("wallet-a") == pytest.approx(0.0)
|
|
history = ledger.settlement_history()
|
|
assert len(history) == 1 # one settlement id across all retries
|
|
assert history[0]["signature"] == "fake-tx-1"
|
|
finally:
|
|
tracker.stop()
|
|
|
|
|
|
def test_banned_wallet_is_never_paid():
|
|
"Banned wallet is never paid\n\nTags: billing, security, settlement, wallet"
|
|
contracts = LocalSolanaContracts()
|
|
contracts.registry.ban_wallet("wallet-banned")
|
|
ledger = BillingLedger(starting_credit=10.0, default_price_per_1k=0.02)
|
|
ledger.charge_request("client", MODEL, 1000, [("wallet-banned", 6), ("wallet-ok", 6)])
|
|
treasury = _FakePayoutTreasury()
|
|
tracker = _make_tracker(ledger, treasury, threshold=0.001, contracts=contracts)
|
|
tracker.start()
|
|
try:
|
|
assert _wait_for(lambda: treasury.batches)
|
|
time.sleep(0.3)
|
|
paid_wallets = {w for batch in treasury.batches for w, _ in batch}
|
|
assert paid_wallets == {"wallet-ok"}
|
|
assert ledger.get_node_pending("wallet-banned") > 0 # held, never paid
|
|
finally:
|
|
tracker.stop()
|
|
|
|
|
|
def test_non_leader_never_signs():
|
|
"A tracker in a cluster whose peers are unreachable never wins an election, so it must never send a payout transaction.\n\nTags: billing, settlement"
|
|
|
|
ledger = BillingLedger(starting_credit=10.0, default_price_per_1k=0.02)
|
|
ledger.charge_request("client", MODEL, 1000, [("wallet-a", 12)])
|
|
treasury = _FakePayoutTreasury()
|
|
tracker = _make_tracker(
|
|
ledger, treasury, threshold=0.001,
|
|
cluster_peers=["http://127.0.0.1:1", "http://127.0.0.1:2"],
|
|
)
|
|
tracker.start()
|
|
try:
|
|
time.sleep(0.8)
|
|
assert treasury.batches == []
|
|
assert ledger.get_node_pending("wallet-a") > 0
|
|
finally:
|
|
tracker.stop()
|