feat: Layer 1 — per-machine operator_fee_fraction + principal-based split math (closes super under-payment) #38

Closed
opened 2026-05-31 20:19:01 +00:00 by padreug · 1 comment
Owner

Layer 1 of the operator-configurable fee architecture (parent: #37).

Fully autonomous on the satmachineadmin side — no bitspire coordination required. Corrects the under-payment of the super fee on its own; Layer 2 and Layer 3 are independent improvements that close the operator-configuration loop.

Locked design decisions (per #37)

  • Separate cash-in and cash-out fields at both SuperConfig and Machine levels. Both SuperConfig grows super_cash_in_fee_fraction + super_cash_out_fee_fraction; Machine grows operator_cash_in_fee_fraction + operator_cash_out_fee_fraction.

What ships

1. Schema migrations — add the four fee fields

  • SuperConfig (singleton): super_cash_in_fee_fraction DECIMAL(5,4) NOT NULL DEFAULT 0, super_cash_out_fee_fraction DECIMAL(5,4) NOT NULL DEFAULT 0. Existing super_fee_fraction column: deprecated in this migration (kept as nullable for one release cycle so a downgrade doesn't crash; remove in a follow-up). On migrate-up, backfill super_cash_in_fee_fraction = super_cash_out_fee_fraction = super_fee_fraction so the live config preserves its current intent.
  • Machine (per-row): operator_cash_in_fee_fraction DECIMAL(5,4) NOT NULL DEFAULT 0, operator_cash_out_fee_fraction DECIMAL(5,4) NOT NULL DEFAULT 0. Existing rows get 0 via DEFAULT clause.
  • Migration: m00N_split_fee_fractions_by_direction in migrations_fork.py per the existing fork-migrations pattern (idempotent ALTER TABLE … ADD COLUMN IF NOT EXISTS).

2. Pydantic models

  • SuperConfig: drops super_fee_fraction, grows the two new fields. UpdateSuperConfigData mirrors with Optional[float] for each + the existing 0 ≤ x ≤ 1 validator applied to both.
  • Machine / CreateMachineData / UpdateMachineData: gain operator_cash_in_fee_fraction and operator_cash_out_fee_fraction with the same validator.
  • Total-fee cap validation: reject create/update if any single direction's super + operator > cap (cap value TBD per open design question 1 in #37).

3. parse_settlement math — fix to principal-based, direction-aware

bitspire.py:256-257 currently:

platform_fee_sats = round(fee_sats * super_fee_fraction)
operator_fee_sats = fee_sats - platform_fee_sats

→ should be:

# tx_type is "cash_in" or "cash_out" — already present in Payment.extra per #44
super_frac = (super_config.super_cash_in_fee_fraction
              if tx_type == "cash_in"
              else super_config.super_cash_out_fee_fraction)
operator_frac = (machine.operator_cash_in_fee_fraction
                 if tx_type == "cash_in"
                 else machine.operator_cash_out_fee_fraction)
platform_fee_sats = round(principal_sats * super_frac)
operator_fee_sats = round(principal_sats * operator_frac)
# fee_sats from bitspire becomes a validation input, not a source of truth

Function signature gains the two direction-specific operator fractions OR a reference to the Machine row (cleaner — let the function read the right field by tx_type internally).

4. Validate fee_sats from bitspire matches computed total (optional, behind a flag)

expected_total_fee_sats = platform_fee_sats + operator_fee_sats

If abs(extra.get("fee_sats") - expected_total_fee_sats) > tolerance (e.g. 2 sats for rounding), record settlement with status='disputed' + error_message describing the mismatch. Operator sees disputed-status settlements in the UI for manual review.

Tolerance ±2 sats covers the round-half-up/down divergence between bitspire's principal-based fee computation and satmachineadmin's recompute. Anything outside that range is bitspire-side firmware drift or active manipulation.

This validation only meaningfully works AFTER Layer 2 + Layer 3 ship (because before then, bitspire is sending hardcoded 7.77% that won't match anything the operator configured). Implement behind a config flag (enforce_fee_match: bool on SuperConfig, default false) so Layer 1 can ship without breaking pre-Layer-2 settlements.

5. Caller-site update

tasks.py:_handle_payment (the only caller of parse_settlement) passes through both the Machine row and the inferred tx_type from Payment.extra.

6. UI

Machine detail modal grows two fee inputs (Quasar <q-input> type="number", min=0, max=1, step=0.001, suffix display showing percentage) labelled "Cash-in fee" and "Cash-out fee". Super-config admin page mirrors with the corresponding pair.

Tests

  • test_parse_settlement_principal_based_math_cash_in — assert cash-in split uses cash_in fractions against principal
  • test_parse_settlement_principal_based_math_cash_out — assert cash-out split uses cash_out fractions against principal
  • test_parse_settlement_zero_operator_fee_is_legacy_compatible — both fractions = 0 → super gets their share, operator gets 0; valid edge for a free-charge ATM
  • test_machine_crud_round_trips_both_operator_fee_fractions — create + update + fetch preserves both values
  • test_create_machine_validates_operator_fee_range0 ≤ x ≤ 1, raises on out-of-range, for both fields
  • test_super_plus_operator_cap_validation — single-direction sum > cap raises 400 (TBD once cap is decided)
  • test_fee_sats_mismatch_records_disputed_status (behind enforce flag) — bitspire sends inconsistent fee_sats → settlement.status = 'disputed', error_message names the divergence
  • test_super_config_migration_backfills_directional_from_singleton — pre-migration super_fee_fraction=0.05 → post-migration super_cash_in_fee_fraction=0.05, super_cash_out_fee_fraction=0.05

Migration notes

Existing rows get directional fee fractions = 0 (operator) or backfilled-from-singleton (super). This is a silent change in behavior for existing settlements once the new math fires: super starts getting their full configured fraction of principal (≈13× more than today) for both directions, operator initially gets 0 until they go set their per-machine rates via the UI. Operators should be told to set their rates before this migration deploys.

Per the existing fork-migrations pattern, this is migrations_fork.py material — keeps migrations.py byte-identical to upstream.

Future-proofing for promos

Per parent #37's "Future-proofing for promos" section: the principal-based math + per-direction independence + per-component settlement recording all preserve clean extensibility for future per-user discounts. Don't pre-add a discount_sats column now — single ADD COLUMN later when promo work scopes.

Out of scope (separate sub-issues)

  • Publishing fee config to bitspire via Nostr → Layer 2 (#39)
  • Bitspire consuming fee config + dropping hardcoded constants → Layer 3 (aiolabs/lamassu-next#57)
  • Historical settlement reconcile (recompute pre-Layer-1 under-paid super shares) — operational, not code
  • User-targeted promos (per-customer discounts) — see #37 future-proofing notes; out of scope

Parent: #37

Layer 1 of the operator-configurable fee architecture (parent: #37). **Fully autonomous on the satmachineadmin side — no bitspire coordination required.** Corrects the under-payment of the super fee on its own; Layer 2 and Layer 3 are independent improvements that close the operator-configuration loop. ## Locked design decisions (per #37) - ✅ **Separate cash-in and cash-out fields** at both `SuperConfig` and `Machine` levels. Both `SuperConfig` grows `super_cash_in_fee_fraction` + `super_cash_out_fee_fraction`; `Machine` grows `operator_cash_in_fee_fraction` + `operator_cash_out_fee_fraction`. ## What ships ### 1. Schema migrations — add the four fee fields - **`SuperConfig`** (singleton): `super_cash_in_fee_fraction DECIMAL(5,4) NOT NULL DEFAULT 0`, `super_cash_out_fee_fraction DECIMAL(5,4) NOT NULL DEFAULT 0`. Existing `super_fee_fraction` column: deprecated in this migration (kept as nullable for one release cycle so a downgrade doesn't crash; remove in a follow-up). On migrate-up, backfill `super_cash_in_fee_fraction = super_cash_out_fee_fraction = super_fee_fraction` so the live config preserves its current intent. - **`Machine`** (per-row): `operator_cash_in_fee_fraction DECIMAL(5,4) NOT NULL DEFAULT 0`, `operator_cash_out_fee_fraction DECIMAL(5,4) NOT NULL DEFAULT 0`. Existing rows get `0` via DEFAULT clause. - Migration: `m00N_split_fee_fractions_by_direction` in `migrations_fork.py` per the existing fork-migrations pattern (idempotent `ALTER TABLE … ADD COLUMN IF NOT EXISTS`). ### 2. Pydantic models - `SuperConfig`: drops `super_fee_fraction`, grows the two new fields. `UpdateSuperConfigData` mirrors with `Optional[float]` for each + the existing `0 ≤ x ≤ 1` validator applied to both. - `Machine` / `CreateMachineData` / `UpdateMachineData`: gain `operator_cash_in_fee_fraction` and `operator_cash_out_fee_fraction` with the same validator. - Total-fee cap validation: reject create/update if any single direction's `super + operator > cap` (cap value TBD per open design question 1 in #37). ### 3. `parse_settlement` math — fix to principal-based, direction-aware `bitspire.py:256-257` currently: ```python platform_fee_sats = round(fee_sats * super_fee_fraction) operator_fee_sats = fee_sats - platform_fee_sats ``` → should be: ```python # tx_type is "cash_in" or "cash_out" — already present in Payment.extra per #44 super_frac = (super_config.super_cash_in_fee_fraction if tx_type == "cash_in" else super_config.super_cash_out_fee_fraction) operator_frac = (machine.operator_cash_in_fee_fraction if tx_type == "cash_in" else machine.operator_cash_out_fee_fraction) platform_fee_sats = round(principal_sats * super_frac) operator_fee_sats = round(principal_sats * operator_frac) # fee_sats from bitspire becomes a validation input, not a source of truth ``` Function signature gains the two direction-specific operator fractions OR a reference to the `Machine` row (cleaner — let the function read the right field by tx_type internally). ### 4. Validate `fee_sats` from bitspire matches computed total (optional, behind a flag) `expected_total_fee_sats = platform_fee_sats + operator_fee_sats` If `abs(extra.get("fee_sats") - expected_total_fee_sats) > tolerance` (e.g. 2 sats for rounding), record settlement with `status='disputed'` + `error_message` describing the mismatch. Operator sees disputed-status settlements in the UI for manual review. Tolerance ±2 sats covers the round-half-up/down divergence between bitspire's principal-based fee computation and satmachineadmin's recompute. Anything outside that range is bitspire-side firmware drift or active manipulation. This validation only meaningfully works AFTER Layer 2 + Layer 3 ship (because before then, bitspire is sending hardcoded 7.77% that won't match anything the operator configured). Implement behind a config flag (`enforce_fee_match: bool` on SuperConfig, default `false`) so Layer 1 can ship without breaking pre-Layer-2 settlements. ### 5. Caller-site update `tasks.py:_handle_payment` (the only caller of `parse_settlement`) passes through both the `Machine` row and the inferred `tx_type` from `Payment.extra`. ### 6. UI Machine detail modal grows two fee inputs (Quasar `<q-input>` type="number", min=0, max=1, step=0.001, suffix display showing percentage) labelled "Cash-in fee" and "Cash-out fee". Super-config admin page mirrors with the corresponding pair. ## Tests - `test_parse_settlement_principal_based_math_cash_in` — assert cash-in split uses `cash_in` fractions against principal - `test_parse_settlement_principal_based_math_cash_out` — assert cash-out split uses `cash_out` fractions against principal - `test_parse_settlement_zero_operator_fee_is_legacy_compatible` — both fractions = 0 → super gets their share, operator gets 0; valid edge for a free-charge ATM - `test_machine_crud_round_trips_both_operator_fee_fractions` — create + update + fetch preserves both values - `test_create_machine_validates_operator_fee_range` — `0 ≤ x ≤ 1`, raises on out-of-range, for both fields - `test_super_plus_operator_cap_validation` — single-direction sum > cap raises 400 (TBD once cap is decided) - `test_fee_sats_mismatch_records_disputed_status` (behind enforce flag) — bitspire sends inconsistent fee_sats → settlement.status = 'disputed', error_message names the divergence - `test_super_config_migration_backfills_directional_from_singleton` — pre-migration `super_fee_fraction=0.05` → post-migration `super_cash_in_fee_fraction=0.05, super_cash_out_fee_fraction=0.05` ## Migration notes Existing rows get directional fee fractions = 0 (operator) or backfilled-from-singleton (super). **This is a silent change in behavior** for existing settlements once the new math fires: super starts getting their full configured fraction of principal (≈13× more than today) for both directions, operator initially gets 0 until they go set their per-machine rates via the UI. Operators should be told to set their rates before this migration deploys. Per the existing fork-migrations pattern, this is `migrations_fork.py` material — keeps `migrations.py` byte-identical to upstream. ## Future-proofing for promos Per parent #37's "Future-proofing for promos" section: the principal-based math + per-direction independence + per-component settlement recording all preserve clean extensibility for future per-user discounts. Don't pre-add a `discount_sats` column now — single ADD COLUMN later when promo work scopes. ## Out of scope (separate sub-issues) - Publishing fee config to bitspire via Nostr → Layer 2 (#39) - Bitspire consuming fee config + dropping hardcoded constants → Layer 3 (`aiolabs/lamassu-next#57`) - Historical settlement reconcile (recompute pre-Layer-1 under-paid super shares) — operational, not code - User-targeted promos (per-customer discounts) — see #37 future-proofing notes; out of scope Parent: #37
Author
Owner

Schema update — adding fee_mismatch_sats per lnbits advisory

Per the lnbits session advisory in ~/dev/coordination/log.md §2026-06-01T07:00Z, adopting the phased enforce_fee_match posture:

  • Phase 1 (this issue, #38) — log mismatch + record the delta, never reject. Cash is already dispensed by settlement time, so reject creates an operationally worse state than log.
  • Phase 2 (future) — promote to reject once p99 observed drift demonstrably stays under tolerance. Filed as follow-up after Layer 1 + 2 land.

Schema delta (adds one column to the migration in #38)

ALTER TABLE satoshimachine.dca_settlements
  ADD COLUMN fee_mismatch_sats INTEGER NULL;
-- Positive = bitspire over-reported vs principal*(super+operator)
-- Negative = bitspire under-reported
-- NULL = pre-Phase-1 settlement, or no comparison run

Settlement-handler shape (phase 1)

expected_fee_sats = round(principal_sats * (super_total_frac + operator_total_frac))
fee_mismatch_sats = bitspire_fee_sats - expected_fee_sats
tolerance = max(1, int(principal_sats * 0.001))  # 1-sat floor, 0.1% relative ceiling

if abs(fee_mismatch_sats) > tolerance:
    logger.warning(
        "fee mismatch: bitspire=%d expected=%d delta=%d tolerance=%d principal=%d "
        "super_frac=%.4f operator_frac=%.4f direction=%s settlement_id=%s",
        bitspire_fee_sats, expected_fee_sats, fee_mismatch_sats, tolerance,
        principal_sats, super_total_frac, operator_total_frac, direction, settlement_id,
    )
# Record the delta unconditionally — even sub-tolerance deltas are useful triage data
settlement.fee_mismatch_sats = fee_mismatch_sats

Tolerance value is the lnbits draft (max(1, principal_sats × 0.001)) — open to revising once we have data. The phase-2 promotion criterion is "p99 of |fee_mismatch_sats| well under the tolerance over N weeks of phase-1 data."

Promo extensibility note

The discount-extension future-proofing referenced in the §20:45Z log entry stays clean here: when promos arrive, the expected_fee_sats formula gains a - discount_sats term, and fee_mismatch_sats still answers the same question ("did bitspire's fee report match our model?"). No schema rework — the future discount_sats column is independent of this one.

refs: coord-log §2026-06-01T07:00Z (lnbits advisory), §2026-06-01T13:30Z (this session adopting)

## Schema update — adding `fee_mismatch_sats` per lnbits advisory Per the lnbits session advisory in `~/dev/coordination/log.md` §`2026-06-01T07:00Z`, adopting the phased `enforce_fee_match` posture: - **Phase 1 (this issue, #38)** — log mismatch + record the delta, never reject. Cash is already dispensed by settlement time, so reject creates an operationally worse state than log. - **Phase 2 (future)** — promote to reject once p99 observed drift demonstrably stays under tolerance. Filed as follow-up after Layer 1 + 2 land. ### Schema delta (adds one column to the migration in #38) ```sql ALTER TABLE satoshimachine.dca_settlements ADD COLUMN fee_mismatch_sats INTEGER NULL; -- Positive = bitspire over-reported vs principal*(super+operator) -- Negative = bitspire under-reported -- NULL = pre-Phase-1 settlement, or no comparison run ``` ### Settlement-handler shape (phase 1) ```python expected_fee_sats = round(principal_sats * (super_total_frac + operator_total_frac)) fee_mismatch_sats = bitspire_fee_sats - expected_fee_sats tolerance = max(1, int(principal_sats * 0.001)) # 1-sat floor, 0.1% relative ceiling if abs(fee_mismatch_sats) > tolerance: logger.warning( "fee mismatch: bitspire=%d expected=%d delta=%d tolerance=%d principal=%d " "super_frac=%.4f operator_frac=%.4f direction=%s settlement_id=%s", bitspire_fee_sats, expected_fee_sats, fee_mismatch_sats, tolerance, principal_sats, super_total_frac, operator_total_frac, direction, settlement_id, ) # Record the delta unconditionally — even sub-tolerance deltas are useful triage data settlement.fee_mismatch_sats = fee_mismatch_sats ``` Tolerance value is the lnbits draft (max(1, principal_sats × 0.001)) — open to revising once we have data. The phase-2 promotion criterion is "p99 of |fee_mismatch_sats| well under the tolerance over N weeks of phase-1 data." ### Promo extensibility note The discount-extension future-proofing referenced in the §`20:45Z` log entry stays clean here: when promos arrive, the expected_fee_sats formula gains a `- discount_sats` term, and `fee_mismatch_sats` still answers the same question ("did bitspire's fee report match our model?"). No schema rework — the future `discount_sats` column is independent of this one. refs: coord-log §`2026-06-01T07:00Z` (lnbits advisory), §`2026-06-01T13:30Z` (this session adopting)
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/satmachineadmin#38
No description provided.