spirekeeper/tests/test_nostr_attribution.py
Padreug d717a6e214 refactor(v2): canonical sat-amount vocabulary + delete Lamassu-era reverse-derivation
Cross-codebase decision logged at memory `reference_sat_amount_vocabulary.md`
and at `~/dev/coordination/log.md` (2026-05-26). Canonical names with
explicit units across satmachineadmin, lamassu-next, atm-tui:

  - `wire_sats` — actual Lightning payment amount (direction-agnostic;
                  was `gross_sats`, only "gross" for cash-out)
  - `principal_sats` — market-rate sats before commission (unchanged)
  - `fee_sats` — commission (was `commission_sats` internally;
                 already the wire format)
  - `fee_fraction` — commission rate as unit fraction in [0, 1]
                     (was `*_pct` / `fee_percent`; eliminates the
                     latent 100x bug from `feePercent * 100` on the
                     lamassu-next side)

Invariants enforced in bitspire._assert_sat_invariants on every
parsed settlement — range (all sats >= 0, 0 <= fee_fraction <= 1) +
direction-specific sum:

  - cash-out: wire_sats == principal_sats + fee_sats
  - cash-in:  wire_sats == principal_sats - fee_sats
              AND fee_sats <= principal_sats

Breaches raise SettlementInvariantError; tasks._handle_payment
records the row as `status='rejected'` with the exception message
and skips distribution. Attribution failure path symmetric.

Schema changes (m001 + m006):

  - dca_settlements.gross_sats              -> wire_sats
  - dca_settlements.commission_sats         -> fee_sats
  - super_config.super_fee_pct              -> super_fee_fraction
  - dca_commission_splits.pct               -> fraction
  - dca_machines.fallback_commission_pct    DROPPED (obsolete)
  - dca_settlements.used_fallback_split     DROPPED (obsolete)

m006 idempotently renames + drops columns on existing installs;
m001 lays down the canonical schema for fresh installs.

Obsolete code removed (Lamassu-era reverse-derivation):

  - calculations.calculate_commission — back-derived principal+fee
    from gross-with-commission-baked-in. v2 stamps both directly.
  - calculations.calculate_exchange_rate — bitSpire stamps directly.
  - bitspire._parse_fallback — sole caller of calculate_commission.
  - Machine.fallback_commission_fraction — only read by _parse_fallback.
  - DcaSettlement.used_fallback_split — only written by _parse_fallback.

parse_settlement now raises SettlementMetadataError if Payment.extra
lacks the bitSpire stamp or required absolute sat fields. No silent
back-derivation; upstream-bug surfacing via dashboard rejection.

Frontend (JS + Quasar templates) updated for the column renames and
the removed fallback fields. Settlements table renders "Wire" + "Fee"
columns; the "(fallback split)" warning badge is gone.

Tests:
  - test_calculations.py: kept distribution tests; deleted
    calculate_commission + calculate_exchange_rate tests.
  - test_two_stage_split.py: renamed variables; rewrote docstring
    value literals (e.g. `super_fee_fraction=0.30` not `=30%`).
  - test_nostr_attribution.py: dropped fallback_commission_fraction
    from machine fixture.
  - 72/72 pass on regtest container.

Cross-codebase follow-ups tracked in coordination log:
  - lamassu-next: rename `fee_percent` -> `fee_fraction` on
    Payment.extra + state.db; drop the `* 100` at lightning.ts:780.
  - atm-tui: read `fee_fraction` column in db.zig.

Memory artefacts:
  - reference_sat_amount_vocabulary.md (canonical + invariants)
  - feedback_pct_to_fraction_renames_need_value_sweep.md (gotcha)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:08:30 +02:00

111 lines
3.6 KiB
Python

"""
Tests for `bitspire.assert_nostr_attribution` — the S5 consumer-side
cross-check that pairs the signature-verified signer pubkey LNbits
stamps onto Payment.extra (post aiolabs/lnbits PR #4) with the machine
record we're about to credit.
In v2 every bitSpire ATM creates invoices via nostr-transport, so any
inbound payment landing on a `dca_machines` wallet must carry
`extra["nostr_sender_pubkey"]` and that pubkey must canonicalise to
the same hex as `machine.machine_npub`. Anything else raises
`SettlementAttributionError` and the listener records the row with
`status='rejected'` instead of distributing.
"""
from datetime import datetime, timezone
import pytest
from ..bitspire import SettlementAttributionError, assert_nostr_attribution
from ..models import Machine
# A real Nostr pubkey pair (hex + canonical bech32). Throwaway fixture —
# never used to sign anything live.
_PUBKEY_HEX = "82341f882b6eabcbd6b1c2da5cd14df14b8e91dd0e6da41a72b78ad8f3a7d3b9"
_PUBKEY_NPUB = "npub1sg6plzptd64uh443ctd9e52d799caywapek6gxnjk79d3ua86wuszhap5a"
_OTHER_HEX = "deadbeef" * 8
def _machine(npub: str) -> Machine:
now = datetime.now(timezone.utc)
return Machine(
id="m1",
operator_user_id="op1",
machine_npub=npub,
wallet_id="w1",
name="sintra-1",
location=None,
fiat_code="EUR",
is_active=True,
created_at=now,
updated_at=now,
)
def test_returns_silently_when_sender_hex_matches_machine_hex():
assert_nostr_attribution(
_machine(_PUBKEY_HEX),
{"source": "bitspire", "nostr_sender_pubkey": _PUBKEY_HEX},
)
def test_returns_silently_when_sender_hex_matches_machine_bech32():
"""Operator entered npub1... in the UI; LNbits stamps hex. Both must
normalise to the same canonical hex before comparison."""
assert_nostr_attribution(
_machine(_PUBKEY_NPUB),
{"source": "bitspire", "nostr_sender_pubkey": _PUBKEY_HEX},
)
def test_returns_silently_under_case_variance():
assert_nostr_attribution(
_machine(_PUBKEY_HEX.upper()),
{"nostr_sender_pubkey": _PUBKEY_HEX.lower()},
)
@pytest.mark.parametrize(
"extra",
[
{},
{"source": "bitspire"},
{"nostr_sender_pubkey": ""},
{"nostr_sender_pubkey": None},
],
)
def test_raises_when_attribution_absent(extra):
"""Every cash-out invoice goes through nostr-transport in v2; a
settlement reaching a machine wallet without `nostr_sender_pubkey`
means it was issued by some other path (HTTP API, manual UI, a
different extension). Always wrong for a `dca_machines` wallet."""
with pytest.raises(SettlementAttributionError) as exc:
assert_nostr_attribution(_machine(_PUBKEY_HEX), extra)
assert "missing nostr_sender_pubkey" in str(exc.value)
def test_raises_when_sender_differs_from_machine():
with pytest.raises(SettlementAttributionError) as exc:
assert_nostr_attribution(
_machine(_PUBKEY_HEX),
{"nostr_sender_pubkey": _OTHER_HEX},
)
assert "does not match" in str(exc.value)
def test_raises_when_sender_pubkey_unparseable():
with pytest.raises(SettlementAttributionError) as exc:
assert_nostr_attribution(
_machine(_PUBKEY_HEX),
{"nostr_sender_pubkey": "not-a-real-pubkey"},
)
assert "unparseable pubkey" in str(exc.value)
def test_raises_when_machine_npub_unparseable():
with pytest.raises(SettlementAttributionError) as exc:
assert_nostr_attribution(
_machine("not-a-real-pubkey"),
{"nostr_sender_pubkey": _PUBKEY_HEX},
)
assert "unparseable pubkey" in str(exc.value)