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>
This commit is contained in:
parent
6348c55e37
commit
d717a6e214
12 changed files with 530 additions and 681 deletions
146
tasks.py
146
tasks.py
|
|
@ -3,14 +3,26 @@
|
|||
# Subscribes to LNbits' invoice dispatcher (register_invoice_listener), then
|
||||
# for each successful inbound payment:
|
||||
# 1. Checks if wallet_id belongs to an active dca_machines row. If not, skip.
|
||||
# 2. Parses Payment.extra for bitSpire split metadata (post-lamassu-next#44).
|
||||
# Falls back to machine.fallback_commission_pct if extra is absent.
|
||||
# 3. Computes the two-stage split (super_fee first, operator remainder).
|
||||
# 4. Inserts a dca_settlements row idempotently (keyed by payment_hash).
|
||||
# 5. Spawns the distribution processor on a background task so the
|
||||
# 2. Verifies the originating Nostr signer matches the machine identity
|
||||
# (assert_nostr_attribution; uses Payment.extra.nostr_sender_pubkey
|
||||
# stamped by lnbits nostr-transport dispatcher).
|
||||
# 3. Parses Payment.extra for bitSpire's canonical split stamp per
|
||||
# aiolabs/lamassu-next#44 (`source: "bitspire"`, principal_sats,
|
||||
# fee_sats, exchange_rate). Raises if the stamp is missing or
|
||||
# garbage (no more Lamassu-era reverse-derivation fallback).
|
||||
# 4. Computes the two-stage split (super_fee first, operator remainder).
|
||||
# 5. Inserts a dca_settlements row idempotently (keyed by payment_hash).
|
||||
# 6. Spawns the distribution processor on a background task so the
|
||||
# LNbits invoice queue (which serves ALL extensions on the node)
|
||||
# keeps draining while we move sats. Concurrency is safe because
|
||||
# process_settlement now uses an optimistic-lock claim (fix bundle 1).
|
||||
#
|
||||
# Rejection paths (settlement still recorded with status='rejected' for
|
||||
# operator forensics, but distribution is skipped):
|
||||
# - SettlementAttributionError: signer mismatch (G5).
|
||||
# - SettlementMetadataError: Payment.extra missing bitSpire stamp.
|
||||
# - SettlementInvariantError: stamped values violate the canonical
|
||||
# sat-amount invariants (range/sum).
|
||||
|
||||
import asyncio
|
||||
|
||||
|
|
@ -20,6 +32,8 @@ from loguru import logger
|
|||
|
||||
from .bitspire import (
|
||||
SettlementAttributionError,
|
||||
SettlementInvariantError,
|
||||
SettlementMetadataError,
|
||||
assert_nostr_attribution,
|
||||
parse_settlement,
|
||||
)
|
||||
|
|
@ -29,6 +43,7 @@ from .crud import (
|
|||
get_super_config,
|
||||
)
|
||||
from .distribution import process_settlement
|
||||
from .models import CreateDcaSettlementData, Machine
|
||||
|
||||
LISTENER_NAME = "ext_satmachineadmin"
|
||||
|
||||
|
|
@ -64,48 +79,47 @@ async def _handle_payment(payment: Payment) -> None:
|
|||
if machine is None:
|
||||
return
|
||||
extra = payment.extra or {}
|
||||
|
||||
# 1) Attribution FIRST — uses only `extra.nostr_sender_pubkey` (no parse
|
||||
# needed). If this fails, every subsequent field on `extra` is
|
||||
# attacker-controlled and untrustworthy — record a minimal rejected
|
||||
# row with placeholder zeros (don't display unverified split numbers
|
||||
# in the operator dashboard).
|
||||
try:
|
||||
assert_nostr_attribution(machine, extra)
|
||||
except SettlementAttributionError as exc:
|
||||
await _record_rejected(payment, machine, exc)
|
||||
return
|
||||
|
||||
# 2) Parse + invariants. parse_settlement enforces the canonical
|
||||
# sat-amount invariants on the bitSpire-stamped numbers (range +
|
||||
# direction-specific sum). Raises SettlementMetadataError if the
|
||||
# stamp is missing, SettlementInvariantError on any range/sum
|
||||
# breach.
|
||||
super_config = await get_super_config()
|
||||
super_fee_pct = float(super_config.super_fee_pct) if super_config else 0.0
|
||||
data, used_fallback = parse_settlement(
|
||||
machine=machine,
|
||||
payment_hash=payment.payment_hash,
|
||||
gross_sats=payment.sat,
|
||||
extra=extra,
|
||||
super_fee_pct=super_fee_pct,
|
||||
super_fee_fraction = (
|
||||
float(super_config.super_fee_fraction) if super_config else 0.0
|
||||
)
|
||||
try:
|
||||
data = parse_settlement(
|
||||
machine=machine,
|
||||
payment_hash=payment.payment_hash,
|
||||
wire_sats=payment.sat,
|
||||
extra=extra,
|
||||
super_fee_fraction=super_fee_fraction,
|
||||
)
|
||||
except (SettlementMetadataError, SettlementInvariantError) as exc:
|
||||
await _record_rejected(payment, machine, exc)
|
||||
return
|
||||
|
||||
# Stamp the originating Nostr event id (the kind-21000 create_invoice
|
||||
# RPC) onto the row for post-hoc forensics — pairs with the
|
||||
# assert_nostr_attribution check below so an auditor can trace
|
||||
# settlement -> RPC event -> signing key without trusting our DB.
|
||||
# RPC) onto the row for post-hoc forensics — an auditor can trace
|
||||
# settlement → RPC event → signing key without trusting our DB.
|
||||
nostr_event_id = extra.get("nostr_event_id")
|
||||
if isinstance(nostr_event_id, str) and nostr_event_id:
|
||||
data.bitspire_event_id = nostr_event_id
|
||||
|
||||
# Cross-check the signature-verified signer pubkey (stamped by
|
||||
# LNbits' nostr-transport dispatcher onto Payment.extra) against
|
||||
# the machine identity. Routing today is wallet_id-only with no
|
||||
# cryptographic binding — this restores end-to-end attribution
|
||||
# between "the npub that asked LNbits for the invoice" and "the
|
||||
# machine we're crediting" (aiolabs/satmachineadmin#19, G5).
|
||||
try:
|
||||
assert_nostr_attribution(machine, extra)
|
||||
except SettlementAttributionError as exc:
|
||||
rejected = await create_settlement_idempotent(
|
||||
data, initial_status="rejected", error_message=str(exc)
|
||||
)
|
||||
if rejected is None:
|
||||
logger.error(
|
||||
f"satmachineadmin: failed to insert rejected settlement for "
|
||||
f"payment_hash={payment.payment_hash[:12]}..."
|
||||
)
|
||||
return
|
||||
logger.error(
|
||||
f"satmachineadmin: rejected settlement {rejected.id} "
|
||||
f"(machine={machine.machine_npub[:12]}..., "
|
||||
f"payment_hash={payment.payment_hash[:12]}...): {exc}"
|
||||
)
|
||||
return
|
||||
|
||||
# 3) Insert + distribute.
|
||||
settlement = await create_settlement_idempotent(data, initial_status="pending")
|
||||
if settlement is None:
|
||||
logger.error(
|
||||
|
|
@ -113,14 +127,13 @@ async def _handle_payment(payment: Payment) -> None:
|
|||
f"payment_hash={payment.payment_hash[:12]}..."
|
||||
)
|
||||
return
|
||||
fb = " (fallback split)" if used_fallback else ""
|
||||
logger.info(
|
||||
f"satmachineadmin: landed settlement {settlement.id} for "
|
||||
f"machine={machine.machine_npub[:12]}... "
|
||||
f"gross={data.gross_sats}sats principal={data.principal_sats}sats "
|
||||
f"commission={data.commission_sats}sats "
|
||||
f"wire={data.wire_sats}sats principal={data.principal_sats}sats "
|
||||
f"fee={data.fee_sats}sats "
|
||||
f"(super_fee={data.platform_fee_sats} "
|
||||
f"operator_fee={data.operator_fee_sats}){fb}"
|
||||
f"operator_fee={data.operator_fee_sats})"
|
||||
)
|
||||
# Spawn distribution on a background task so the LNbits invoice queue
|
||||
# (shared across all extensions) keeps draining while we move sats.
|
||||
|
|
@ -131,3 +144,48 @@ async def _handle_payment(payment: Payment) -> None:
|
|||
task = asyncio.create_task(process_settlement(settlement.id))
|
||||
_inflight_distributions.add(task)
|
||||
task.add_done_callback(_inflight_distributions.discard)
|
||||
|
||||
|
||||
async def _record_rejected(
|
||||
payment: Payment, machine: Machine, exc: Exception
|
||||
) -> None:
|
||||
"""Insert a minimal `dca_settlements` row with `status='rejected'` and
|
||||
the exception message for operator forensics.
|
||||
|
||||
Used for every rejection path (attribution / metadata / invariant).
|
||||
The split fields are zero placeholders — we deliberately do NOT
|
||||
display attacker-supplied numbers in the operator dashboard. The
|
||||
wire amount (`payment.sat`) is the only value LNbits authenticated;
|
||||
everything else from Payment.extra is untrusted in this branch.
|
||||
"""
|
||||
data = CreateDcaSettlementData(
|
||||
machine_id=machine.id,
|
||||
payment_hash=payment.payment_hash,
|
||||
wire_sats=payment.sat,
|
||||
fiat_amount=0.0,
|
||||
fiat_code=machine.fiat_code,
|
||||
exchange_rate=0.0,
|
||||
principal_sats=0,
|
||||
fee_sats=0,
|
||||
platform_fee_sats=0,
|
||||
operator_fee_sats=0,
|
||||
# tx_type is unknown for rejection paths; default to cash_out
|
||||
# (the only direction currently wired). When S8 lands the
|
||||
# listener will branch on tx_type from extra, and this default
|
||||
# gets revisited.
|
||||
tx_type="cash_out",
|
||||
)
|
||||
rejected = await create_settlement_idempotent(
|
||||
data, initial_status="rejected", error_message=str(exc)
|
||||
)
|
||||
if rejected is None:
|
||||
logger.error(
|
||||
f"satmachineadmin: failed to insert rejected settlement for "
|
||||
f"payment_hash={payment.payment_hash[:12]}..."
|
||||
)
|
||||
return
|
||||
logger.error(
|
||||
f"satmachineadmin: rejected settlement {rejected.id} "
|
||||
f"(machine={machine.machine_npub[:12]}..., "
|
||||
f"payment_hash={payment.payment_hash[:12]}...): {exc}"
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue