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

@ -82,7 +82,7 @@ window.app = Vue.createApp({
columns: [
{name: 'machine', label: 'Machine', field: 'machine_id', align: 'left'},
{name: 'created_at', label: 'Created', field: 'created_at', align: 'left'},
{name: 'gross_sats', label: 'Gross', field: 'gross_sats', align: 'right'},
{name: 'wire_sats', label: 'Wire', field: 'wire_sats', align: 'right'},
{
name: 'error_message',
label: 'Error',
@ -100,7 +100,7 @@ window.app = Vue.createApp({
superFeeDialog: {
show: false,
saving: false,
data: {super_fee_pct: 0, super_fee_wallet_id: ''}
data: {super_fee_fraction: 0, super_fee_wallet_id: ''}
},
// UI configuration -----------------------------------------------
@ -111,12 +111,6 @@ window.app = Vue.createApp({
{name: 'machine_npub', label: 'npub', field: 'machine_npub', align: 'left'},
{name: 'wallet_id', label: 'Wallet', field: 'wallet_id', align: 'left'},
{name: 'fiat_code', label: 'Fiat', field: 'fiat_code', align: 'left'},
{
name: 'fallback_commission_pct',
label: 'Fallback %',
field: 'fallback_commission_pct',
align: 'right'
},
{name: 'actions', label: 'Actions', field: 'id', align: 'right'}
],
pagination: {rowsPerPage: 10, sortBy: 'name'}
@ -166,12 +160,12 @@ window.app = Vue.createApp({
columns: [
{name: 'status', label: 'Status', field: 'status', align: 'left'},
{name: 'created_at', label: 'Time', field: 'created_at', align: 'left'},
{name: 'gross_sats', label: 'Gross', field: 'gross_sats', align: 'right'},
{name: 'wire_sats', label: 'Wire', field: 'wire_sats', align: 'right'},
{name: 'principal_sats', label: 'Principal (→ LPs)', field: 'principal_sats', align: 'right'},
{
name: 'commission_sats',
label: 'Commission',
field: 'commission_sats',
name: 'fee_sats',
label: 'Fee',
field: 'fee_sats',
align: 'right'
},
{name: 'fiat_amount', label: 'Fiat', field: 'fiat_amount', align: 'right'},
@ -332,7 +326,7 @@ window.app = Vue.createApp({
},
commissionSum() {
return this.commissionLegs.reduce(
(acc, leg) => acc + (Number(leg.pct) || 0), 0
(acc, leg) => acc + (Number(leg.fraction) || 0), 0
)
},
commissionSumValid() {
@ -351,7 +345,7 @@ window.app = Vue.createApp({
if (idx === this.commissionLegs.length - 1) {
sats = remaining
} else {
sats = Math.round(total * (Number(leg.pct) || 0))
sats = Math.round(total * (Number(leg.fraction) || 0))
remaining -= sats
}
out.push({label: leg.label, sats})
@ -531,7 +525,7 @@ window.app = Vue.createApp({
// -----------------------------------------------------------------
openSuperFeeDialog() {
this.superFeeDialog.data = {
super_fee_pct: this.superConfig?.super_fee_pct ?? 0,
super_fee_fraction: this.superConfig?.super_fee_fraction ?? 0,
super_fee_wallet_id: this.superConfig?.super_fee_wallet_id || ''
}
this.superFeeDialog.show = true
@ -544,7 +538,7 @@ window.app = Vue.createApp({
const {data} = await LNbits.api.request(
'PUT', SUPER_FEE_PATH, null,
{
super_fee_pct: Number(d.super_fee_pct),
super_fee_fraction: Number(d.super_fee_fraction),
super_fee_wallet_id: (d.super_fee_wallet_id || '').trim() || null
}
)
@ -565,7 +559,7 @@ window.app = Vue.createApp({
this._downloadCsv(
'machines.csv',
['id', 'machine_npub', 'wallet_id', 'name', 'location', 'fiat_code',
'is_active', 'fallback_commission_pct', 'created_at'],
'is_active', 'created_at'],
this.machines
)
},
@ -687,7 +681,6 @@ window.app = Vue.createApp({
location: machine.location || '',
wallet_id: machine.wallet_id,
fiat_code: machine.fiat_code,
fallback_commission_pct: machine.fallback_commission_pct,
is_active: machine.is_active
}
this.editMachineDialog.show = true
@ -706,7 +699,6 @@ window.app = Vue.createApp({
location: d.location,
wallet_id: d.wallet_id,
fiat_code: d.fiat_code,
fallback_commission_pct: d.fallback_commission_pct,
is_active: d.is_active
}
)
@ -1118,7 +1110,7 @@ window.app = Vue.createApp({
target: leg.target || '',
targetKind: this._inferTargetKind(leg.target),
label: leg.label || '',
pct: Number(leg.pct) || 0
fraction: Number(leg.fraction) || 0
}))
} catch (e) {
this.commissionLegs = []
@ -1140,7 +1132,7 @@ window.app = Vue.createApp({
target: this.walletOptions[0]?.value || '',
targetKind: 'wallet',
label: '',
pct: 0
fraction: 0
})
},
@ -1157,7 +1149,7 @@ window.app = Vue.createApp({
legs: this.commissionLegs.map((leg, idx) => ({
target: (leg.target || '').toString().trim(),
label: leg.label || null,
pct: Number(leg.pct),
fraction: Number(leg.fraction),
sort_order: idx
}))
}
@ -1330,8 +1322,7 @@ window.app = Vue.createApp({
wallet_id: null,
name: '',
location: '',
fiat_code: 'GTQ',
fallback_commission_pct: 0.05
fiat_code: 'GTQ'
}
},
@ -1341,8 +1332,7 @@ window.app = Vue.createApp({
wallet_id: d.wallet_id,
name: (d.name || '').trim() || null,
location: (d.location || '').trim() || null,
fiat_code: (d.fiat_code || 'GTQ').trim(),
fallback_commission_pct: Number(d.fallback_commission_pct ?? 0.05)
fiat_code: (d.fiat_code || 'GTQ').trim()
}
},