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:
Padreug 2026-05-26 20:08:30 +02:00
commit d717a6e214
12 changed files with 530 additions and 681 deletions

View file

@ -31,13 +31,13 @@
<q-banner
v-if="superConfig"
class="q-mb-md"
:class="superConfig.super_fee_pct > 0 ? 'bg-blue-1 text-grey-9' : 'bg-grey-2 text-grey-9'">
:class="superConfig.super_fee_fraction > 0 ? 'bg-blue-1 text-grey-9' : 'bg-grey-2 text-grey-9'">
<template v-slot:avatar>
<q-icon name="account_balance" :color="superConfig.super_fee_pct > 0 ? 'blue' : 'grey'"></q-icon>
<q-icon name="account_balance" :color="superConfig.super_fee_fraction > 0 ? 'blue' : 'grey'"></q-icon>
</template>
<span :style="{fontWeight: 500}">
LNbits platform fee:
<span :style="{color: '#1976d2'}">${ (superConfig.super_fee_pct * 100).toFixed(2) }%</span>
<span :style="{color: '#1976d2'}">${ (superConfig.super_fee_fraction * 100).toFixed(2) }%</span>
of each transaction's commission.
</span>
<span :style="{opacity: 0.7, fontSize: '0.85em'}" class="q-ml-sm">
@ -143,12 +143,6 @@
v-text="shortId(props.row.wallet_id)"></code>
</q-td>
<q-td key="fiat_code" v-text="props.row.fiat_code"></q-td>
<q-td key="fallback_commission_pct">
<span v-text="(props.row.fallback_commission_pct * 100).toFixed(2) + '%'"></span>
<q-tooltip>
Used only when bitSpire doesn't supply a per-tx split.
</q-tooltip>
</q-td>
<q-td key="actions" auto-width>
<q-btn flat dense round size="sm" icon="visibility"
color="primary"
@ -529,13 +523,13 @@
dense outlined></q-input>
</div>
<div class="col-5 col-md-3">
<q-input v-model.number="leg.pct"
<q-input v-model.number="leg.fraction"
label="% (0..1)"
type="number" step="0.01" min="0" max="1"
dense outlined>
<template v-slot:append>
<span :style="{fontSize: '0.75em', opacity: 0.6}"
v-text="((leg.pct || 0) * 100).toFixed(1) + '%'"></span>
v-text="((leg.fraction || 0) * 100).toFixed(1) + '%'"></span>
</template>
</q-input>
</div>
@ -630,8 +624,8 @@
<span :style="{fontSize: '0.85em'}"
v-text="formatTime(props.row.created_at)"></span>
</q-td>
<q-td key="gross_sats" class="text-right">
<span v-text="formatSats(props.row.gross_sats)"></span>
<q-td key="wire_sats" class="text-right">
<span v-text="formatSats(props.row.wire_sats)"></span>
</q-td>
<q-td key="error_message">
<span :style="{fontSize: '0.85em', opacity: 0.8}"
@ -792,14 +786,6 @@
dense outlined
:rules="[v => !!v || 'Pick a wallet']"></q-select>
<q-input
v-model.number="addMachineDialog.data.fallback_commission_pct"
label="Fallback commission % (decimal: 0.05 = 5%)"
hint="Only used if bitSpire doesn't supply a per-tx split (lamassu-next#44)."
type="number" step="0.0001" min="0" max="1"
class="q-mb-md"
dense outlined></q-input>
<q-input
v-model="addMachineDialog.data.fiat_code"
label="Fiat code"
@ -855,12 +841,6 @@
<div class="text-caption" :style="{opacity: 0.6}">Location</div>
<span v-text="machineDetail.machine.location || '—'"></span>
</div>
<div class="col-6 col-md-3">
<div class="text-caption" :style="{opacity: 0.6}">
Fallback commission %
</div>
<span v-text="(machineDetail.machine.fallback_commission_pct * 100).toFixed(2) + '%'"></span>
</div>
</div>
<q-separator class="q-mb-md"></q-separator>
@ -893,27 +873,19 @@
<q-td key="status">
<q-badge :color="settlementStatusColor(props.row.status)"
:label="props.row.status"></q-badge>
<q-icon v-if="props.row.used_fallback_split"
name="warning_amber" color="orange" size="sm"
class="q-ml-xs">
<q-tooltip>
Fallback split — bitSpire didn't supply per-tx
net/fee. See lamassu-next#44.
</q-tooltip>
</q-icon>
</q-td>
<q-td key="created_at">
<span :style="{fontSize: '0.85em'}"
v-text="formatTime(props.row.created_at)"></span>
</q-td>
<q-td key="gross_sats" class="text-right">
<span v-text="formatSats(props.row.gross_sats)"></span>
<q-td key="wire_sats" class="text-right">
<span v-text="formatSats(props.row.wire_sats)"></span>
</q-td>
<q-td key="principal_sats" class="text-right">
<span v-text="formatSats(props.row.principal_sats)"></span>
</q-td>
<q-td key="commission_sats" class="text-right">
<span v-text="formatSats(props.row.commission_sats)"></span>
<q-td key="fee_sats" class="text-right">
<span v-text="formatSats(props.row.fee_sats)"></span>
<div :style="{fontSize: '0.75em', opacity: 0.6}">
super
<span v-text="formatSats(props.row.platform_fee_sats)"></span>
@ -996,7 +968,7 @@
<q-icon name="info" color="blue"></q-icon>
</template>
Original gross:
<b v-text="formatSats(partialDispenseDialog.settlement.gross_sats)"></b>.
<b v-text="formatSats(partialDispenseDialog.settlement.wire_sats)"></b>.
Provide what was actually dispensed. Sat amounts will scale linearly,
the commission split will recompute, and distribution will re-run.
</q-banner>
@ -1019,7 +991,7 @@
label="Dispensed sats"
hint="Exact sat amount actually dispensed (≤ original gross)"
type="number" step="1" min="0"
:max="partialDispenseDialog.settlement.gross_sats"
:max="partialDispenseDialog.settlement.wire_sats"
dense outlined></q-input>
</q-tab-panel>
</q-tab-panels>
@ -1085,7 +1057,7 @@
Operators see this as a read-only banner. Wallet ID is where the
collected fee lands; typically a wallet you (the super) own.
</p>
<q-input v-model.number="superFeeDialog.data.super_fee_pct"
<q-input v-model.number="superFeeDialog.data.super_fee_fraction"
label="Fee % (decimal, 0..1)"
hint="0.30 = 30% of every operator's commission"
type="number" step="0.0001" min="0" max="1"
@ -1337,10 +1309,6 @@
emit-value map-options
class="q-mb-md"
dense outlined></q-select>
<q-input v-model.number="editMachineDialog.data.fallback_commission_pct"
label="Fallback commission %"
type="number" step="0.0001" min="0" max="1"
class="q-mb-md" dense outlined></q-input>
<q-input v-model="editMachineDialog.data.fiat_code"
label="Fiat code" class="q-mb-md" dense outlined></q-input>
<q-toggle v-model="editMachineDialog.data.is_active"