"""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 json import time import urllib.request import pytest from meshnet_contracts import LocalSolanaContracts from meshnet_tracker.billing import ( BillingLedger, DEFAULT_PAYOUT_DUST_FLOOR, DEFAULT_PAYOUT_THRESHOLD, DEFAULT_SETTLE_PERIOD, ) 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) port = 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() # Production defaults — sourced from billing.py (cli.py uses the same constants) PROD_SETTLE_PERIOD = DEFAULT_SETTLE_PERIOD PROD_PAYOUT_THRESHOLD = DEFAULT_PAYOUT_THRESHOLD PROD_DUST_FLOOR = DEFAULT_PAYOUT_DUST_FLOOR def _set_pending_since(ledger: BillingLedger, wallet: str, ts: float) -> None: with ledger._lock: ledger._pending_since[wallet] = ts def test_prod_settlement_constants(): "Production settlement policy is 24 h period, 5 USDT threshold, 0.01 dust floor.\n\nTags: billing, settlement" assert DEFAULT_SETTLE_PERIOD == 86400.0 assert DEFAULT_PAYOUT_THRESHOLD == 5.0 assert DEFAULT_PAYOUT_DUST_FLOOR == 0.01 def test_payables_24h_period_not_due_before_elapsed(): "Sub-threshold pending is not payable until the 24 h period elapses.\n\nTags: billing, settlement" ledger = BillingLedger(starting_credit=100.0, default_price_per_1k=0.02) ledger.charge_request("client", MODEL, 1000, [("node-wallet", 12)]) assert ledger.get_node_pending("node-wallet") == pytest.approx(0.018) t0 = 1_700_000_000.0 _set_pending_since(ledger, "node-wallet", t0) due_early = ledger.payables( threshold=PROD_PAYOUT_THRESHOLD, max_period=PROD_SETTLE_PERIOD, dust_floor=PROD_DUST_FLOOR, now=t0 + PROD_SETTLE_PERIOD - 1.0, ) assert due_early == [] def test_payables_24h_period_triggers_at_boundary(): "Sub-threshold pending becomes payable exactly after the 24 h period.\n\nTags: billing, settlement" ledger = BillingLedger(starting_credit=100.0, default_price_per_1k=0.02) ledger.charge_request("client", MODEL, 1000, [("node-wallet", 12)]) pending = ledger.get_node_pending("node-wallet") t0 = 1_700_000_000.0 _set_pending_since(ledger, "node-wallet", t0) due = ledger.payables( threshold=PROD_PAYOUT_THRESHOLD, max_period=PROD_SETTLE_PERIOD, dust_floor=PROD_DUST_FLOOR, now=t0 + PROD_SETTLE_PERIOD, ) assert due == [("node-wallet", pytest.approx(pending))] def test_payables_threshold_triggers_before_24h_period(): "Pending ≥ 5 USDT pays immediately without waiting the 24 h period.\n\nTags: billing, settlement" ledger = BillingLedger(starting_credit=1000.0, default_price_per_1k=0.02) # cost = 300k * 0.02 / 1k = 6.0 USDT → node share 5.4 USDT ledger.charge_request("client", MODEL, 300_000, [("node-wallet", 12)]) assert ledger.get_node_pending("node-wallet") >= PROD_PAYOUT_THRESHOLD due = ledger.payables( threshold=PROD_PAYOUT_THRESHOLD, max_period=PROD_SETTLE_PERIOD, dust_floor=PROD_DUST_FLOOR, now=time.time(), ) assert len(due) == 1 assert due[0][0] == "node-wallet" def test_prod_defaults_hold_sub_threshold_pending(): "Tracker with prod 24 h / 5 USDT settings does not pay tiny balances early.\n\nTags: billing, settlement" ledger = BillingLedger(starting_credit=100.0, default_price_per_1k=0.02) ledger.charge_request("client", MODEL, 1000, [("node-wallet", 12)]) treasury = _FakePayoutTreasury() tracker = _make_tracker( ledger, treasury, threshold=PROD_PAYOUT_THRESHOLD, period=PROD_SETTLE_PERIOD, dust=PROD_DUST_FLOOR, ) tracker.start() try: time.sleep(0.5) assert treasury.batches == [] assert ledger.get_node_pending("node-wallet") > 0 finally: tracker.stop() def test_prod_threshold_triggers_immediate_payout(): "Tracker with prod 24 h period still pays when pending ≥ 5 USDT.\n\nTags: billing, settlement" ledger = BillingLedger(starting_credit=1000.0, default_price_per_1k=0.02) ledger.charge_request("client", MODEL, 300_000, [("node-wallet", 12)]) treasury = _FakePayoutTreasury() tracker = _make_tracker( ledger, treasury, threshold=PROD_PAYOUT_THRESHOLD, period=PROD_SETTLE_PERIOD, dust=PROD_DUST_FLOOR, ) tracker.start() try: assert _wait_for(lambda: treasury.batches) assert ledger.get_node_pending("node-wallet") == pytest.approx(0.0) assert treasury.batches[0][0][0] == "node-wallet" assert treasury.batches[0][0][1] >= PROD_PAYOUT_THRESHOLD finally: tracker.stop()