feat(v2): principal-based fee split + per-direction config (closes #38) #42

Merged
padreug merged 5 commits from feat/principal-based-fees into v2-bitspire 2026-06-01 17:45:31 +00:00
Owner

Summary

Layer 1 of the operator-configurable fee architecture (parent: #37). Closes the load-bearing super under-payment bug standalone, independent of Layers 2 (#39) and 3 (aiolabs/lamassu-next#57).

Architectural intent (per #37):

  • Super (lnbits administrator) sets per-direction fractions; applies across every machine on the instance. Calculated against principal.
  • Operator (per-machine) sets per-direction fractions; sits on top of super, calculated against principal.
  • Total customer fee = (super + operator)% of principal. Distribution: super gets super%, operator gets operator% (through their existing commission-leg rules).

Bug closed (Bug 1 from #37): pre-#38 math interpreted super_fee_fraction as fraction-of-fee rather than fraction-of-principal, paying the super ~13× below intent on every cash-out since the bitspire wire-shape landed. Now: principal-based, direction-aware.

Commits

  1. d87d0db — m009 schema + Pydantic models. Adds 4 directional fee columns (super × 2 directions, machine × 2 directions) + fee_mismatch_sats settlement column. Backfills existing super_fee_fraction into both directional fields, then drops it.
  2. 4cd0041 — CRUD + per-direction fee cap validation. 15% cap per direction (super + operator), enforced at API boundary. Cross-row checks: super-config update validates against all machines; machine update validates against current super-config.
  3. 1babdfb — Principal-based split math. New split_principal_based(principal, super_frac, operator_frac) replaces the legacy split_two_stage_commission. parse_settlement signature changes to take super_config: SuperConfig + reads directional fractions by tx_type. Closes the load-bearing bug.
  4. d9e8a04fee_mismatch_sats Phase-1 observability. Records bitspire_fee_sats - (platform + operator) on every settlement. WARN-logs when |delta| > tolerance (= max(1, principal × 0.001)). Option A locked per coord-log §2026-06-01T07:00Z (lnbits advisory): always record, no gate.
  5. 10f4b50 — UI. Per-direction fee inputs in super-config + machine modals. Banner shows both directional super fees side-by-side. Cap (15%) hinted in input labels.

Strict-from-the-start

Per workspace CLAUDE.md "Backwards-compatibility on pre-public-launch code": no compat shims. The deprecated super_fee_fraction column is dropped in m009 after backfill; the legacy split_two_stage_commission function is removed; the singleton field is gone from SuperConfig + UpdateSuperConfigData.

Test plan

  • 164/164 tests green (up from 75 baseline + cassette work since)
  • New test_principal_based_fees.py — 6 cases for split_principal_based, 5 cases for parse_settlement directional dispatch (cash-in routes through cash-in fractions; cash-out through cash-out; unknown tx_type raises; cross-direction guard)
  • New test_fee_cap_validation.py — 13 cases for both per-direction cap helpers (in/out independently, exact-cap acceptance via 4-decimal rounding, no-super-config degenerate, partial-update PATCH semantics, error-message naming the offending machine)
  • New test_fee_mismatch_recording.py — 8 cases (zero / positive / negative deltas, pre-Layer-3 large-delta scenario, tolerance boundary, 1-sat floor on tiny principal)
  • m009 migration verified live against the dev container's DB: super_fee_fraction column dropped, directional columns + fee_mismatch_sats present, backfill carries the live 0.33 singleton into both directional fields
  • Manual UI smoke: banner renders correctly, super-config save lands (0.03 / 0.03), machine edit accepts operator fees (0.05 / 0.05), cap-validation error fires correctly on attempted 0.13 (super 0.03 + operator 0.13 = 0.16 > 0.15)
  • Joint smoke with physical cash-out — blocked on three bitspire-side aiolabs/lamassu-next#57 deploy gaps surfaced today (hardcoded wss://relay.aiolabs.dev default, dead VITE_LNBITS_HTTP_URL echo, operator-fees subscriber doesn't run in maintenance state). Filed at coord-log §2026-06-01T18:30Z. Not a blocker for this PR — Layer 1 is fully autonomous from bitspire.

Migration notes

  • Existing rows: super-config backfill carries the legacy super_fee_fraction singleton into both directional fields. Machines get 0.0 / 0.0 operator fees and the operator sets per-direction values via the UI before activating.
  • Heads up: the new 15% cap per direction will reject the dev container's current 0.33 / 0.33 backfilled values on the next super-config save. Production will land at 0.03 / 0.03 (well clear of cap). Pre-Layer-1 historical settlements were paid 13× below intent; operational reconcile is a Padreug-owned item per #37 sequencing.

Out of scope (follow-ups)

  • Layer 2 (#39) Nostr publisher — starting next
  • Layer 3 (aiolabs/lamassu-next#57) bitspire consumer + the three deploy gaps from §18:30Z
  • Phase-2 enforce_fee_match settlement-reject (after observability data justifies the tighter posture)
  • Historical settlement reconcile

🤖 Generated with Claude Code

## Summary Layer 1 of the operator-configurable fee architecture (parent: #37). Closes the load-bearing super under-payment bug standalone, independent of Layers 2 (#39) and 3 (`aiolabs/lamassu-next#57`). **Architectural intent** (per #37): - Super (lnbits administrator) sets per-direction fractions; applies across every machine on the instance. Calculated against principal. - Operator (per-machine) sets per-direction fractions; sits on top of super, calculated against principal. - Total customer fee = (super + operator)% of principal. Distribution: super gets super%, operator gets operator% (through their existing commission-leg rules). **Bug closed** (Bug 1 from #37): pre-#38 math interpreted `super_fee_fraction` as fraction-of-fee rather than fraction-of-principal, paying the super ~13× below intent on every cash-out since the bitspire wire-shape landed. Now: principal-based, direction-aware. ## Commits 1. **`d87d0db`** — m009 schema + Pydantic models. Adds 4 directional fee columns (super × 2 directions, machine × 2 directions) + `fee_mismatch_sats` settlement column. Backfills existing `super_fee_fraction` into both directional fields, then drops it. 2. **`4cd0041`** — CRUD + per-direction fee cap validation. 15% cap per direction (super + operator), enforced at API boundary. Cross-row checks: super-config update validates against all machines; machine update validates against current super-config. 3. **`1babdfb`** — Principal-based split math. New `split_principal_based(principal, super_frac, operator_frac)` replaces the legacy `split_two_stage_commission`. `parse_settlement` signature changes to take `super_config: SuperConfig` + reads directional fractions by `tx_type`. Closes the load-bearing bug. 4. **`d9e8a04`** — `fee_mismatch_sats` Phase-1 observability. Records `bitspire_fee_sats - (platform + operator)` on every settlement. WARN-logs when `|delta| > tolerance` (= `max(1, principal × 0.001)`). Option A locked per coord-log §`2026-06-01T07:00Z` (lnbits advisory): always record, no gate. 5. **`10f4b50`** — UI. Per-direction fee inputs in super-config + machine modals. Banner shows both directional super fees side-by-side. Cap (15%) hinted in input labels. ## Strict-from-the-start Per workspace CLAUDE.md "Backwards-compatibility on pre-public-launch code": no compat shims. The deprecated `super_fee_fraction` column is dropped in m009 after backfill; the legacy `split_two_stage_commission` function is removed; the singleton field is gone from `SuperConfig` + `UpdateSuperConfigData`. ## Test plan - [x] 164/164 tests green (up from 75 baseline + cassette work since) - [x] New `test_principal_based_fees.py` — 6 cases for `split_principal_based`, 5 cases for `parse_settlement` directional dispatch (cash-in routes through cash-in fractions; cash-out through cash-out; unknown tx_type raises; cross-direction guard) - [x] New `test_fee_cap_validation.py` — 13 cases for both per-direction cap helpers (in/out independently, exact-cap acceptance via 4-decimal rounding, no-super-config degenerate, partial-update PATCH semantics, error-message naming the offending machine) - [x] New `test_fee_mismatch_recording.py` — 8 cases (zero / positive / negative deltas, pre-Layer-3 large-delta scenario, tolerance boundary, 1-sat floor on tiny principal) - [x] `m009` migration verified live against the dev container's DB: `super_fee_fraction` column dropped, directional columns + `fee_mismatch_sats` present, backfill carries the live `0.33` singleton into both directional fields - [x] Manual UI smoke: banner renders correctly, super-config save lands (0.03 / 0.03), machine edit accepts operator fees (0.05 / 0.05), cap-validation error fires correctly on attempted 0.13 (super 0.03 + operator 0.13 = 0.16 > 0.15) - [ ] **Joint smoke with physical cash-out** — blocked on three bitspire-side `aiolabs/lamassu-next#57` deploy gaps surfaced today (hardcoded `wss://relay.aiolabs.dev` default, dead `VITE_LNBITS_HTTP_URL` echo, operator-fees subscriber doesn't run in maintenance state). Filed at coord-log §`2026-06-01T18:30Z`. Not a blocker for this PR — Layer 1 is fully autonomous from bitspire. ## Migration notes - Existing rows: super-config backfill carries the legacy `super_fee_fraction` singleton into both directional fields. Machines get `0.0 / 0.0` operator fees and the operator sets per-direction values via the UI before activating. - **Heads up**: the new 15% cap per direction will reject the dev container's current `0.33 / 0.33` backfilled values on the next super-config save. Production will land at `0.03 / 0.03` (well clear of cap). Pre-Layer-1 historical settlements were paid 13× below intent; operational reconcile is a Padreug-owned item per #37 sequencing. ## Out of scope (follow-ups) - Layer 2 (#39) Nostr publisher — starting next - Layer 3 (`aiolabs/lamassu-next#57`) bitspire consumer + the three deploy gaps from §`18:30Z` - Phase-2 `enforce_fee_match` settlement-reject (after observability data justifies the tighter posture) - Historical settlement reconcile 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Adds the schema delta + Pydantic mirror for per-direction fee
configuration:

- super_config gains super_cash_in_fee_fraction / super_cash_out_fee_fraction
  (backfilled from the deprecated singleton on migrate-up so live config
  preserves intent).
- dca_machines gains operator_cash_in_fee_fraction / operator_cash_out_fee_fraction
  (default 0; operator-settable per machine via the upcoming UI).
- dca_settlements gains fee_mismatch_sats BIGINT NULL — Phase-1 observability
  column per coord-log §2026-06-01T07:00Z (lnbits) + option A locked.
- MAX_FEE_FRACTION_PER_DIRECTION = 0.15 lives in calculations.py as the
  single source of truth (defense-in-depth cap, mirrored on the consumer
  side per aiolabs/lamassu-next#57).

Pydantic validators on the new fields keep [0, 1] range checks; the
per-direction cap validation lives on the CRUD path in the next commit
(needs cross-row context: super-config change must validate against all
machines, machine change against current super-config).

Closes one step of #38 (Layer 1 of the operator-configurable fee
architecture, parent #37). Subsequent commits add CRUD, principal-based
split math (fixes the load-bearing super under-payment bug), and the
UI surface.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wires the new directional fee fields through the write path and adds
the 15%-per-direction cap guard at the API boundary.

CRUD:
- create_machine INSERT includes operator_cash_in_fee_fraction +
  operator_cash_out_fee_fraction (Pydantic default 0 covers existing
  callers).
- update_machine + update_super_config already use generic update_data
  dict, so the new fields flow through without per-call changes.

API boundary (views_api.py):
- _assert_machine_fee_cap_safe(operator_in, operator_out) — pairs
  candidates against current super-config, rejects if (super_X +
  operator_X) > 0.15 for either direction. Called from api_create_machine
  + api_update_machine (with partial-PATCH semantics: unset fields keep
  the machine's current value).
- _assert_super_config_cap_safe(new_super_in, new_super_out) — fetches
  every active machine; rejects with offending-machine name in the 400
  detail if any (effective_super + operator) > cap. Called from
  api_update_super_config.

Cap rounding: float arithmetic rounds (super + operator) to 4 decimals
(DECIMAL(10,4) precision) before comparing, so the IEEE 754 surprise
0.10 + 0.05 = 0.15000000000000002 doesn't trip the cap.

Tests (13 cases, all green): both directions hit the cap, exact-cap
acceptance, no-super-config degenerate path, partial PATCH on
super-config, offending-machine name in error detail, empty-fleet
vacuous safety.

Refs: aiolabs/satmachineadmin#38 (Layer 1), coord-log §2026-06-01T07:22Z
(cap lock at 15% per direction, defense in depth).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the broken fraction-of-fee math with fraction-of-principal,
direction-aware. Pre-#38: super_fee_fraction was interpreted as
`round(fee_sats * super_fraction)`, paying super ~13× below intent on
every cashout since the bitspire wire-shape landed. Post-#38: super
and operator shares are computed independently against principal
using the per-direction fractions from SuperConfig + Machine.

Per workspace CLAUDE.md "Backwards-compatibility on pre-public-launch
code" (v2-bitspire hasn't shipped to users), no compat shims:

- calculations.py: delete `split_two_stage_commission` (legacy
  fraction-of-fee). Keep `split_principal_based` as the sole split fn.
- migrations.py m009: extend to also DROP the deprecated
  `super_fee_fraction` column after backfilling its value into the
  new directional fields.
- models.py: drop `super_fee_fraction` from SuperConfig +
  UpdateSuperConfigData entirely.
- bitspire.py parse_settlement: new signature takes `super_config:
  SuperConfig` instead of `super_fee_fraction: float`. Resolves
  directional fractions from super_config + machine by tx_type, then
  computes via split_principal_based. Raises SettlementInvariantError
  on unknown tx_type.
- tasks.py: pass `super_config` through to parse_settlement; assert
  non-None (m001 inserts the singleton at install time — None is an
  impossible state).
- partial-dispense ratio path in distribution.py is unchanged — still
  uses `settlement.platform_fee_sats / settlement.fee_sats` from the
  landed row, which is the right invariant (lock at landing) and
  independent of the per-direction config.

Tests:
- Rename `test_two_stage_split.py` → `test_operator_split_legs.py`.
  Drop the legacy-function test classes. Keep TestAllocateOperatorSplitLegs
  (still-production fn) and TestPartialDispenseSplitRatio (inline ratio
  math in distribution.py).
- New `test_principal_based_fees.py`: pure-math tests for
  `split_principal_based` (six cases including a direct regression
  test pinning the pre-#38 bug at 240→3000 sats per 100k principal at
  3% super), plus parse_settlement directional dispatch tests
  (cash-in routes through cash-in fractions; cash-out through
  cash-out; unknown tx_type raises; zero-zero free-charge ATM; cross-
  direction guard).

Migration verified end-to-end via container restart: super_config
columns post-m009 = id/super_fee_wallet_id/updated_at/
super_cash_in_fee_fraction/super_cash_out_fee_fraction (no
super_fee_fraction). dca_machines + dca_settlements gained the
expected new columns. 156/156 tests green.

Refs: aiolabs/satmachineadmin#37 (parent), #38 (this layer). Closes
the load-bearing super under-payment bug standalone.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase-1 observability per coord-log §2026-06-01T07:00Z (option A
locked: always record, no enforce_fee_match gate):

  fee_mismatch_sats = bitspire_fee_sats - (platform_fee_sats + operator_fee_sats)

Positive = bitspire over-reported; negative = under-reported; zero =
exact match. Recorded unconditionally on every settlement; WARN-
logged via loguru only when |delta| > tolerance, where
tolerance = max(1, int(principal_sats * 0.001)) — 1-sat floor with
0.1% relative ceiling.

bitspire.py:parse_settlement:
- Computes the delta after split_principal_based returns.
- WARN log line carries bitspire_fee_sats / expected / delta /
  tolerance / principal / both fractions / tx_type / machine-npub
  prefix for triage queries.
- Always stamps fee_mismatch_sats onto CreateDcaSettlementData.
- Comment explains the pre-Layer-3 expectation: large deltas are
  expected while the ATM hardcodes 7.77% cash-out (aiolabs/lamassu-
  next#57); the data here will quiet once Layer 3 ships.

crud.py:create_settlement_idempotent: extends the INSERT to persist
the new column.

Tests:
- tests/conftest.py: `loguru_capture` fixture — loguru routes to a
  pre-bound stderr sink that pytest's caplog (stdlib only) misses and
  capsys can't see; the fixture adds a list-sink for the test's
  duration. Reusable for future log-behavior tests.
- tests/test_fee_mismatch_recording.py: 8 cases covering exact-match
  zero delta, bitspire over- and under-reporting, the pre-Layer-3
  large-delta scenario, within-tolerance silence, over-tolerance
  warning, diagnostic-fields presence in the WARN line, and the
  1-sat floor on tiny-principal settlements.

164/164 tests green. Phase 2 (reject on out-of-tolerance) lands as
a follow-up once observability data justifies the tighter posture.

Refs: aiolabs/satmachineadmin#38 (Layer 1), coord-log §2026-06-01T07:00Z
(lnbits advisory + option A lock).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
feat(v2)(ui): per-direction fee inputs in super-config + machine modals (#38 5/5)
Some checks failed
ci.yml / feat(v2)(ui): per-direction fee inputs in super-config + machine modals (#38 5/5) (pull_request) Failing after 0s
10f4b50ca5
Surfaces the new directional fee fields in the admin dashboard so
operators + the LNbits super can configure cash-in and cash-out fees
independently:

Templates (`templates/satmachineadmin/index.html`):
- Platform fee banner now shows both directional super fractions
  side-by-side ("cash-in X% · cash-out Y% of each transaction's
  principal"). Wording updated to "principal" not "commission" since
  the math is now principal-based.
- Super-fee edit dialog: replaces the single q-input with two
  (super_cash_in_fee_fraction + super_cash_out_fee_fraction); each
  capped at 0.15 via max attr (visual hint; server enforces).
- Add-machine + edit-machine dialogs both gain operator_cash_in_fee_
  fraction + operator_cash_out_fee_fraction inputs with the same 0.15
  cap hint. Hint text mentions the "sits on top of platform fee, total
  capped at 15% per direction" semantics so operators understand the
  layering.

JS (`static/js/index.js`):
- superFeeDialog.data shape switches to the new directional fields.
- openSuperFeeDialog / submitSuperFee load + POST the new shape.
- _emptyMachineForm / _cleanMachineForm pass through operator
  directional fields (Number-coerced, default 0).
- openEditMachineDialog / submitEditMachine include the operator fee
  fields in the form data + PUT body.
- New computed `superAnyFee` drives the banner styling (sum of both
  directional fractions — non-zero → blue active banner; zero → muted
  grey "free instance" banner).

All Quasar UMD components use explicit close tags per the UMD-mode
parsing rule.

Migration carry-over verified in dev container: pre-m009
super_fee_fraction=0.33 backfilled to super_cash_in=0.33 +
super_cash_out=0.33 on migrate-up. Note this puts existing dev
instances above the new 15% cap; operators will see the cap
validation error on their next super-config save and must adjust to
≤0.15 per direction. Production aiolabs/server-deploy will land at
0.03 on both directions (well under cap).

164/164 tests green. Layer 1 (#38) complete; Layer 2 (#39) wire-
format publisher is the next milestone.

Refs: aiolabs/satmachineadmin#37 (parent), #38 (closes Layer 1).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
padreug merged commit 52911af7b1 into v2-bitspire 2026-06-01 17:45:31 +00:00
padreug deleted branch feat/principal-based-fees 2026-06-01 17:45:31 +00:00
Sign in to join this conversation.
No reviewers
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!42
No description provided.