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

@ -62,7 +62,7 @@ async def m001_satmachine_v2_initial(db):
await db.execute(f"""
CREATE TABLE IF NOT EXISTS satoshimachine.super_config (
id TEXT PRIMARY KEY,
super_fee_pct DECIMAL(10,4) NOT NULL DEFAULT 0.0000,
super_fee_fraction DECIMAL(10,4) NOT NULL DEFAULT 0.0000,
super_fee_wallet_id TEXT,
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
@ -72,7 +72,7 @@ async def m001_satmachine_v2_initial(db):
)
if not existing:
await db.execute(
"INSERT INTO satoshimachine.super_config (id, super_fee_pct) "
"INSERT INTO satoshimachine.super_config (id, super_fee_fraction) "
"VALUES ('default', 0.0000)"
)
@ -88,7 +88,6 @@ async def m001_satmachine_v2_initial(db):
location TEXT,
fiat_code TEXT NOT NULL DEFAULT 'GTQ',
is_active BOOLEAN NOT NULL DEFAULT true,
fallback_commission_pct DECIMAL(10,4) NOT NULL DEFAULT 0.0500,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
@ -180,10 +179,10 @@ async def m001_satmachine_v2_initial(db):
# append-only audit memo for partial-dispense + operator notes.
#
# platform_fee_sats and operator_fee_sats are absolute BIGINT,
# NOT derived percentages — when the v2 customer-discount engine
# NOT derived fractions — when the v2 customer-discount engine
# ships, these two columns are the audit-grade record of who
# forgave what per transaction. Do not collapse them into a single
# commission_pct. See plan section "Customer discounts" and #10.
# fee_fraction. See plan section "Customer discounts" and #10.
await db.execute(f"""
CREATE TABLE IF NOT EXISTS satoshimachine.dca_settlements (
id TEXT PRIMARY KEY,
@ -191,15 +190,14 @@ async def m001_satmachine_v2_initial(db):
payment_hash TEXT NOT NULL UNIQUE,
bitspire_event_id TEXT,
bitspire_txid TEXT,
gross_sats BIGINT NOT NULL,
wire_sats BIGINT NOT NULL,
fiat_amount DECIMAL(10,2) NOT NULL,
fiat_code TEXT NOT NULL DEFAULT 'GTQ',
exchange_rate REAL NOT NULL,
principal_sats BIGINT NOT NULL,
commission_sats BIGINT NOT NULL,
fee_sats BIGINT NOT NULL,
platform_fee_sats BIGINT NOT NULL,
operator_fee_sats BIGINT NOT NULL,
used_fallback_split BOOLEAN NOT NULL DEFAULT false,
tx_type TEXT NOT NULL,
bills_json TEXT,
cassettes_json TEXT,
@ -217,9 +215,9 @@ async def m001_satmachine_v2_initial(db):
)
# 7. dca_commission_splits — operator's rules for distributing the
# *remainder* (commission_sats - platform_fee_sats). One row per
# *remainder* (fee_sats - platform_fee_sats). One row per
# leg. machine_id=NULL = operator default; non-null = per-machine
# override. Sum(pct) per (operator, machine) must equal 1.0 —
# override. Sum(fraction) per (operator, machine) must equal 1.0 —
# enforced at write-time in crud.py.
#
# `target` accepts any of (splitpayments-style):
@ -235,7 +233,7 @@ async def m001_satmachine_v2_initial(db):
operator_user_id TEXT NOT NULL,
target TEXT NOT NULL,
label TEXT,
pct DECIMAL(10,4) NOT NULL,
fraction DECIMAL(10,4) NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
@ -439,6 +437,76 @@ async def m004_introduce_dca_lp_table(db):
await db.execute(f"ALTER TABLE satoshimachine.dca_clients DROP COLUMN {col}")
async def m006_rename_to_canonical_sat_vocabulary(db):
"""Adopt the cross-codebase canonical sat-amount vocabulary AND drop
the now-obsolete Lamassu-era fallback columns, per the decision at
memory `reference_sat_amount_vocabulary.md` (2026-05-26):
Renames:
- 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
Drops (Lamassu-era reverse-derivation is obsolete since bitSpire
stamps both `principal_sats` AND `fee_sats` directly on
Payment.extra per lamassu-next#44 — there's nothing to back-derive):
- dca_machines.fallback_commission_pct (was the rate used by the
deleted `_parse_fallback` path)
- dca_settlements.used_fallback_split (was the per-row marker for
that path)
Same canonical applies on the lamassu-next + atm-tui side; the
rename is coordinated via `~/dev/coordination/log.md` (2026-05-26).
Each step is idempotent probe for the OLD column; rename/drop only
if present; otherwise no-op (covers fresh installs where m001
already laid down the canonical schema).
Why a single migration: all driven by the same decision and any
external code wants to see the whole rename + cleanup land at once.
"""
renames = [
("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"),
]
for table, old_col, new_col in renames:
try:
await db.fetchone(
f"SELECT {old_col} FROM satoshimachine.{table} LIMIT 1"
)
except Exception:
# old column doesn't exist; either rename already landed or
# m001 produced the canonical schema directly on fresh install.
continue
await db.execute(
f"ALTER TABLE satoshimachine.{table} "
f"RENAME COLUMN {old_col} TO {new_col}"
)
# Drop the Lamassu-era fallback columns. Same idempotency pattern.
# Try both old (_pct) and new (_fraction) names for the dca_machines
# column since an install could be at either rename state.
drops = [
("dca_machines", "fallback_commission_pct"),
("dca_machines", "fallback_commission_fraction"),
("dca_settlements", "used_fallback_split"),
]
for table, col in drops:
try:
await db.fetchone(
f"SELECT {col} FROM satoshimachine.{table} LIMIT 1"
)
except Exception:
# column doesn't exist; either already dropped or never present.
continue
await db.execute(
f"ALTER TABLE satoshimachine.{table} DROP COLUMN {col}"
)
async def m005_lock_deposit_currency_to_machine_fiat_code(db):
"""Rewrite every `dca_deposits.currency` row to match its joined
`dca_machines.fiat_code`.