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>
111 lines
3.6 KiB
Python
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)
|