Adds the second operator-pushed kind-30078 document type alongside
cassette config (#29). Wire format locked at coord-log §2026-06-01T14:25Z.
models.py:
- FeePayloadComponents — producer-mandatory `components` sub-object
with super + operator splits per direction. Consumer-optional in v1
but ships on every payload from this producer for audit + future-
promo extensibility.
- FeeConfigPayload — the wire-format envelope. Pydantic validators
enforce: cash_*_fee_fraction in [0, 0.15] (cap per direction);
|total - (super + operator)| < 1e-6 (consistency assert per the
§07:33Z lnbits advisory, mirrored on bitspire's #57 consumer side);
schema_version integer ≥ 1.
fee_transport.py:
- build_fee_payload(super_config, machine) — compose + validate in
one call; returned payload is wire-shippable. Raises ValueError
(via Pydantic) if the constructed totals violate the cap. That
shouldn't happen in practice because the API guards in
views_api._assert_machine_fee_cap_safe + _assert_super_config_cap_safe
refuse cap-violating writes; if it does, refuse-to-publish rather
than ship a malformed event.
- publish_fee_config(machine, super_config, operator_user_id) —
builds, encrypts, signs, publishes via the shared
publish_encrypted_kind_30078 helper from nostr_publish. d-tag is
`bitspire-fees:<atm_pubkey_hex>` per spec; recipient is the ATM
npub canonicalised to hex; signer is the operator.
- Soft-fail discipline matches cassette_transport.publish_to_atm —
transport-layer errors (RelayUnavailable / SignerUnavailable /
OperatorIdentityMissing) log WARN + return None so trigger callers
(api_create_machine etc.) don't break on transient transport hiccups.
Cap violations are NOT soft-fail since they indicate an API-guard
bypass and need operator attention.
Tests (18 cases, all green):
- 9 FeeConfigPayload validator cases (well-formed accept, wire round-
trip, cap violations per direction, exact-cap acceptance, sum/
components mismatch per direction, schema_version ≥ 1, zero-zero
free-charge ATM)
- 4 build_fee_payload composition cases (basic, asymmetric directions,
super-only-no-operator default, cap violation at build time)
- 5 publish_fee_config soft-fail discipline cases (relay unavailable,
signer unavailable, operator identity missing, publish success with
d-tag + recipient + payload-shape assertions, cap violation raises
before reaching publish)
182/182 tests green.
Refs: aiolabs/satmachineadmin#37 (parent), #39 (Layer 2), coord-log
§2026-06-01T14:25Z (locked wire format), §2026-06-01T07:33Z (lnbits
consistency-assert advisory).
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>
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>
Pre-merge lint hygiene on the PR #30 touched files:
- `black` reformatted 9 files (cassette_transport, crud, models, tasks,
views_api, nip44, all 3 cassette test files, migrations). Cosmetic:
line lengths, trailing commas, multi-line argument layout.
- `ruff check --fix` cleared 176 of 202 errors auto-fixed. Mostly
`UP006` `typing.Optional` → `| None` modernization, `I001` import
sort order, `UP035` typing-extensions cleanup.
- Two new mypy regressions introduced by the migration commit dcb7de0
fixed:
- `crud.py:apply_bootstrap_state` — annotated `existing_first: dict
| None` on the dedup fetch.
- `tasks.py:_cassette_consumer_tick` — `# type: ignore[arg-type]` on
the `nostr_client.relay_manager.add_subscription` call; nostrclient's
upstream typing declares `list[str]` for filters but the actual
Nostr protocol takes `list[<filter-dict>]`. The runtime accepts it
(live smoke at 13:43Z dispatched `nip44_decrypt` cleanly through
this subscription); the typing mismatch is upstream's.
Remaining lint state, intentionally not addressed in this commit
(all pre-existing baseline, not regressions):
- 8 mypy errors in `calculations.py` + the unchanged-by-this-PR parts
of `crud.py` — pre-existing on v2-bitspire.
- 26 ruff style warnings: 14 are N805 false-positives on Pydantic
validators (`cls` first-arg is correct for `@validator`-decorated
methods); 4 are N818 exception-name-suffix preferences on my new
exception classes (renaming would touch many call sites; keep
`OperatorIdentityMissing` / `SignerUnavailable` / `RelayUnavailable`
/ `_NostrclientUnavailable` as-is for clarity); 5 are E501 line-too-
long on docstrings (the long lines are formatted for clarity);
1 RUF002 unicode-minus in a docstring.
Tests: 155 passed, 1 pre-existing async-plugin failure unchanged.
Live smoke (both publish + consume directions through the bunker)
unaffected — this is purely a code-style pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The wire format flips from
{"denominations": {"<denom>": {"position", "count"}}}
to
{"positions": {"<pos>": {"denomination", "count"}}}
per coord-log 2026-05-30T18:30Z + 18:45Z.
Per-row editable surface changes:
- denomination becomes mutable (operator swaps cartridges during refill)
- count remains mutable
- position becomes the row identity (hardware bay number)
Removed: the no_duplicate_positions validator (no longer relevant — position
is the key, dups are impossible at the dict level) and the implicit
"denomination unique" assumption. Multiple positions with the same
denomination are now operationally valid per bitspire 18:45Z.
CassetteConfig model adds state_denomination (Optional[int]) for v2
reverse-channel reconciliation diff highlighting.
Tests under tests/test_cassette_configs.py and test_cassette_state_consumer.py
will fail at this commit — they reference the old denomination-keyed
shape. They get rewritten in v1.1 commit f. Branch tip is green only after
commit f lands; this and the next 3 commits are intermediate states.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add the operator-side schema for per-machine ATM cassette inventory
(aiolabs/satmachineadmin#29). Schema choice mirrors the ATM-side
denomination-as-key invariant audited at coord-log 2026-05-30T06:40Z
across bitspire/atm-tui/src/db.zig:31, lamassu-next state-store.ts:54,
and hal-service.ts:116/189 — every ATM layer keys on denomination, so
the operator-side PK is (machine_id, denomination) to make
duplicate-denomination payloads impossible at the schema boundary.
Reserved nullable columns (state_count, state_at, state_event_id) hold
the latest bitspire-cassettes-state:<atm_pubkey_hex> event the ATM
publishes. v1 populates them on bootstrap-event receipt; v1 UI doesn't
render reconciliation. v2 reconciliation UI consumes them without a
migration.
Pydantic models in this commit:
- CassetteConfig — read model for a stored row
- UpsertCassetteConfigData — operator-edit form (count and/or position)
- CassettePayloadRow — one denomination's wire-format values
- PublishCassettesPayload — the full kind-30078 content payload,
bidirectional (operator → ATM and ATM → operator share the shape).
Validates int-coerced denomination keys, positive ints, no duplicate
positions, and exposes to_wire_dict() that re-stringifies keys for
JSON compatibility.
CRUD + transport + API + UI land in subsequent commits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each machine handles exactly one currency today (operator-set on
`dca_machines.fiat_code`). The deposit's currency is fully determined
by the machine it's recorded against, so it shouldn't be operator-
chooseable in the first place.
Surfaced during 2026-05-16 E2E testing: Jordan had a "15 USD" deposit
recorded against an EUR Sintra (operator typo in the freeform currency
input). The balance summary is currency-blind (`SUM(amount)` over
mixed currencies), so on the next cash-out the system distributed
15 EUR worth of sats on the strength of that 15 USD row. Worked out
by chance; could have over-paid by ~10% if the actual EUR/USD rate
had been further off.
Fix:
- `CreateDepositData` / `UpdateDepositData` no longer carry a
`currency` field. Any client-submitted value is silently dropped
at Pydantic validation, before reaching the handler.
- `api_create_deposit` resolves the machine's `fiat_code` and
passes it to `create_deposit(..., currency=...)` as a required
keyword arg. The deposit row's `currency` column always matches
the machine going forward.
- UI: the freeform `<q-input label="Currency">` becomes a read-only
`<q-chip>` slot on the amount field, sourced from the new
`depositMachineFiatCode` computed (resolves via the selected
client's machine).
- `m005_lock_deposit_currency_to_machine_fiat_code` migration
backfills existing rows: every `dca_deposits.currency` gets
rewritten to match its joined `dca_machines.fiat_code`. Greg's
stray `15 USD` row becomes `15 EUR` (the right answer at today's
invariant).
Multi-currency-per-machine support is explicitly out of scope here;
when hardware ships that reads multiple denominations across
currencies, the relevant changes are documented in issue #26's
"Future" section (dca_machines.fiat_codes set, currency-aware
balance summary, etc.). The current fix is "lock the input side";
that future work is "unlock it but constrained to the machine's
declared set".
3 new unit tests (`tests/test_deposit_currency.py`) lock in the
model-contract guarantees. Total suite 89 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Operator no longer chooses the LP's wallet / DCA mode / autoforward —
those belong to the LP, written via satmachineclient. The Add LP /
Edit LP dialogs reduce to (machine, user_id, optional username,
status). The clients table loses the wallet / mode / autoforward
columns and gains an "Onboarded" column showing whether the LP has a
`dca_lp` row yet (server-side LEFT JOIN; `DcaClient.lp_onboarded`).
Deposit creation gate (the structural enforcement of "must onboard
first"):
- Picker annotates each LP option with "— pending onboarding" and
disables un-onboarded LP rows.
- Selecting an un-onboarded LP shows an inline deep-orange banner
explaining the LP needs to open satmachineclient once.
- The Record button is `:disable`d in that state. The backend
refuses with HTTP 422 anyway (see previous commit) — UI is just
the first line of feedback.
Backend wiring:
- `DcaClient` model gains `lp_onboarded: bool = False`, populated
at SELECT time via a shared `_CLIENT_SELECT` / `_CLIENT_FROM`
fragment that LEFT JOINs `dca_lp` on `user_id`. All four list/
single-row read paths use it: by-id, by-(machine,user), by-machine,
by-operator, by-user. No extra round-trip per row.
- CSV export drops the removed columns; adds `lp_onboarded`.
All 86 unit tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LP-level preferences were denormalised across every `dca_clients` row
of a given user. Every LP enrolment carried its own wallet_id /
dca_mode / fixed_mode_daily_limit / autoforward_ln_address /
autoforward_enabled — and satmachineclient's `update_lp_autoforward`
did a multi-row UPDATE to keep them in sync. That sync dance was the
smell: user-level intent stored at machine-enrolment granularity.
New shape:
dca_lp (user_id PK, dca_wallet_id, default_dca_mode,
fixed_mode_daily_limit, autoforward_ln_address,
autoforward_enabled, ...)
dca_clients (id, machine_id, user_id, username, status, ...)
// pure (machine, LP) enrolment — wallet/mode/autoforward gone
Authority split:
- LP writes dca_lp via satmachineclient (Phase 2, separate commit).
- Operator writes dca_clients via satmachineadmin. They cannot
choose the LP's destination wallet — it's resolved from dca_lp
at distribution time. Better trust hygiene.
Onboarding gate:
- `api_create_deposit` refuses (HTTP 422) when the target LP has
no dca_lp row. Forces every LP through a "yes, I am here and
this is where I want my sats" gesture via satmachineclient
before any fiat starts accumulating against them.
Schema:
- m001 canonical schema updated: slim `dca_clients`, new `dca_lp`.
Fresh installs land here directly.
- m004 idempotent migration for installs that already have the
legacy `dca_clients.wallet_id` column: creates dca_lp,
backfills from the latest dca_clients row per user (window
function), then DROP COLUMN on the moved fields. Greg's live
test data survives the upgrade.
Distribution:
- `get_flow_mode_clients_for_machine` INNER JOINs dca_lp so
un-onboarded LPs are filtered out (no destination wallet).
- `_pay_one_dca_leg`, `_attempt_autoforward`, `settle_lp_balance`
all fetch `dca_lp` via the new `get_dca_lp(user_id)` helper.
Wallet + autoforward read from prefs, not from client.
Models:
- `DcaClient` loses 5 fields. `CreateDcaClientData` reduces to
(machine_id, user_id, username). `UpdateDcaClientData` keeps
only operator-controlled fields (username, status).
- New `DcaLpPreferences` + `UpsertDcaLpData` models for the
per-user surface (satmachineclient writes these in Phase 2).
CRUD:
- New: `get_dca_lp`, `lp_is_onboarded`, `upsert_dca_lp` (the
latter takes a `fallback_wallet_id` for first-onboarding when
satmachineclient auto-seeds from the LP's default LNbits wallet).
- `create_dca_client` insert reduces to the new column set.
Tests: 86 unit tests still green.
Next:
- Phase 1c (this repo): UI simplification for operator's
Add/Edit LP dialogs + deposit-gating UX.
- Phase 2 (satmachineclient): own dca_lp writes + auto-init with
the LP's default LNbits wallet on first dashboard visit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`net` is financial-accounting ambiguous (net of what?). In the
bitSpire/DCA context this column is specifically the principal the
operator distributes to LPs (gross − commission), not a generic net
amount. Renaming locally before any bitSpire firmware locks the
wire-level name; lamassu-next#44 should adopt the same name.
Scope:
- migrations.py: m003 ALTER TABLE … RENAME COLUMN, idempotent probe
pattern matching m002. Also updates the m001 canonical schema so
fresh installs land on the new column directly.
- models.py: `CreateDcaSettlementData.principal_sats` /
`DcaSettlement.principal_sats`. Field-doc comment updated.
- bitspire.py: both happy path and fallback path return
`principal_sats=…`. Reads `extra.get("principal_sats")` from the
bitSpire payload (lamassu-next#44 should follow this rename).
- crud.py: INSERT column list + `apply_partial_dispense(
new_principal_sats=…)` keyword.
- distribution.py: every `settlement.net_sats` → `settlement.
principal_sats`; partial-dispense memo + helper signatures updated;
the leg-order docblock at the top reads "principal_sats".
- tasks.py: landed-settlement log line.
- static/js/index.js: settlements-table column `principal_sats` with
label "Principal (→ LPs)".
- templates/satmachineadmin/index.html: q-td key + binding.
All 86 unit tests still pass. No backwards-compat shim — v2-bitspire
isn't released; the rename is a clean break.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When LNbits' nostr-transport stamps `nostr_sender_pubkey` and
`nostr_event_id` onto Payment.extra (post aiolabs/lnbits PR #4), the
listener now cross-checks the signer against the resolved machine's
`machine_npub` before any distribution. Mismatch / absence / unparseable
pubkey → settlement is recorded with `status='rejected'` and the
reason in `error_message`, distribution is skipped.
Wire shape:
bitspire.SettlementAttributionError + assert_nostr_attribution()
Raises on absence, mismatch, or unparseable pubkey on either side.
Normalises both `machine.machine_npub` (operator UI accepts hex
or `npub1...`) and the stamped sender through
`lnbits.utils.nostr.normalize_public_key` so the comparison is
canonical-hex on both sides.
tasks._handle_payment
parse_settlement -> stamp nostr_event_id onto bitspire_event_id ->
try assert_nostr_attribution: on failure, insert row with
initial_status='rejected' + error_message, return without
spawning process_settlement.
crud.create_settlement_idempotent
Now takes `initial_status` (required) and `error_message`.
Normal path passes 'pending'; rejected path passes 'rejected'
with the reason. Single-statement insert — no two-step pending->
errored dance.
crud.get_stuck_settlements_for_operator
New `rejected` bucket alongside `errored` / `stuck_pending` /
`stuck_processing`. Distinct because retry is wrong for these:
the row was misrouted, not operationally failed.
models.DcaSettlement.status enum extended with 'rejected'.
Worklist response model carries the new bucket; API + UI plumbed
end-to-end.
static/js/index.js + templates/satmachineadmin/index.html
New 'rejected' worklist bucket (deep-orange, gpp_bad icon).
Force-reset button now scoped to stuck_pending / stuck_processing
only — was 'not errored' which would have shown on rejected too.
10 unit tests in tests/test_nostr_attribution.py cover hex<->hex,
hex<->bech32, case-insensitivity, every absent variant, mismatch,
and unparseable on either side. All pass.
Closes the consumer-side of aiolabs/satmachineadmin#19 (G5).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the operator-facing limitation that commission split legs could
only target the operator's own LNbits wallets. Adopting the splitpayments
pattern — a single `target` string accepts:
- LNbits wallet id (UUID-shaped) — direct internal pay
- LNbits wallet invoice key — resolved via get_wallet_for_key, then
internal pay (lets the operator split to any LNbits user who shares
their invoice key)
- Lightning address (user@domain) — resolved via LNURL-pay
- LNURL string (LNURL1...) — resolved via LNURL-pay
Schema (m001 update — fresh-install only; no operator data in production):
dca_commission_splits.wallet_id → target
Backend (distribution.py):
- New _pay_split_leg helper: routes the leg by target type. External
targets (@ or LNURL prefix) go through get_pr_from_lnurl + pay_invoice;
internal targets go through create_invoice + pay_invoice (the original
path), with get_wallet_for_key as the first resolution step so
invoice keys work as well as wallet ids.
- _pay_operator_splits delegates per-leg payment to the new helper.
- dca_payments rows still record the leg as leg_type='operator_split';
external targets land destination_ln_address (the human-readable
target), internal targets land destination_wallet_id.
- Errors are caught and surfaced via the existing failed-leg path
so /retry can re-run them.
Frontend (commission tab):
- Each leg gets a per-row q-btn-toggle: "My wallet" vs "Lightning
address / LNURL / invoice key". Wallet mode shows the q-select of
the operator's own wallets (previous behaviour); external mode
shows a free-text q-input.
- On load, targetKind is inferred from whether the stored target
matches one of the operator's wallet ids (renders as 'wallet')
or not (renders as 'external'). The kind is UI-only, not persisted.
- Leg row laid out in a bordered card so the toggle + 3-column layout
don't crowd at narrow widths.
Refs: aiolabs/satmachineadmin#9
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes H4, H5, M8 from the v2-bitspire review (omnibus follow-up #11).
H4 — Decouple invoice listener from distribution.
tasks._handle_payment now spawns process_settlement on a background
task instead of awaiting it. The LNbits invoice queue is shared
across every extension on the node; under load (a machine with 50
LPs, a stalled internal payment, etc.) the previous synchronous path
could freeze the queue for everyone. Concurrency is safe because
fix bundle 1's claim_settlement_for_processing already prevents
double-processing on listener re-fires.
RUF006 fix: hold strong refs to in-flight tasks via a module-level
set so the GC doesn't collect them mid-flight (asyncio.create_task
only weakly references its task). Tasks self-clean via
add_done_callback(set.discard).
H5 + M8 — Skipped-leg audit rows for stranded sats.
Previously, four paths in distribution.py logged a warning and left
sats in the machine wallet, marking the settlement 'processed' with
no row-level visibility into where the un-paid sats sit:
1. _pay_super_fee: super_fee_pct > 0 but super_fee_wallet_id unset
2. _pay_operator_splits: no commission ruleset (default + override)
3. _pay_dca_distributions: exchange_rate = 0 (fallback path)
4. _pay_dca_distributions: no eligible LPs with positive balance
Plus a fifth case the review didn't enumerate but is the same shape:
5. _pay_dca_distributions: no flow-mode LPs at the machine at all
Each now writes a dca_payments row with status='skipped', the
intended leg_type (super_fee / operator_split / dca), the stranded
amount in amount_sats, and a human-readable error_message explaining
why. New _record_skipped_leg helper consolidates the pattern.
This makes stranded sats visible in:
- The machine detail dialog's settlements rows (the legs are
filtered into the audit blob alongside completed/failed legs)
- The payments CSV export
- GET /api/v1/dca/payments?leg_type=...
'skipped' is a documented leg-status value now (alongside pending /
completed / failed / voided / refunded) — no schema change since
status is TEXT.
Knock-on fix: void_open_legs_for_settlement (used by partial-dispense
recompute) now also includes status='skipped' in its WHERE clause so a
re-run doesn't double-count the audit rows from a prior attempt.
72/72 tests still pass. Lint clean.
Refs: aiolabs/satmachineadmin#11 — fix bundle 2 ✅
Remaining in #11: H6 (partial-dispense split ratio) + fix bundle 3
(dead-code purge) + the M and N items.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Completes the P3 operator-UX cluster. Surfaces settlements that didn't
process cleanly as a queryable worklist so operators can investigate +
retry without scanning the full settlement history.
New endpoints:
GET /api/v1/dca/settlements/stuck?threshold_minutes=30
Returns StuckSettlementsResponse with three buckets:
- errored: distribution failed; existing /retry endpoint handles
- stuck_pending: landed but never picked up (listener crashed
before invoking process_settlement)
- stuck_processing: claim taken but no completion in N minutes;
processor crashed mid-flight, processing_claim is set but no
terminal state landed
POST /api/v1/dca/settlements/{id}/force-reset
Operator escape hatch for genuinely stuck settlements. Flips
'pending'/'processing' → 'errored' so the /retry endpoint can take
over. Refuses unless the settlement is older than threshold_minutes
(default 30) so operators can't accidentally interrupt a
slow-but-running settlement. Age check uses created_at as proxy.
CRUD:
- get_stuck_settlements_for_operator(uid, threshold_minutes) joins
dca_settlements → dca_machines and returns the three lists
scoped per operator. No age filter on 'errored' (operators always
want to see those); age filter applies to 'pending'/'processing'.
- force_reset_stuck_settlement(id) UPDATEs 'pending'/'processing' to
'errored', clears processing_claim, sets a marker error_message.
The retry endpoint shipped in fix bundle 1 (commit 3ede66f) is the
intended downstream — operator sees stuck-processing row, hits force-
reset (flips to errored), then hits retry (flips to pending, voids
failed legs, re-runs process_settlement via the claim path).
34 routes registered. 72/72 tests pass.
Refs: aiolabs/satmachineadmin#9 — completes P3 operator-UX cluster
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the HIGH-severity security finding from the v2 branch review:
operator A could register a machine pointing at operator B's wallet_id
(or update their machine to do so), then drain B's wallet via the
settlement processor's pay_invoice call. LNbits' pay_invoice doesn't
enforce caller identity at the backend layer — wallet_id is trusted as
the source-of-truth for the source wallet.
Two-layer defence:
1. **API layer.** New _assert_wallet_owned_by helper in views_api.py
refuses any wallet_id from the request body that doesn't resolve to a
wallet owned by the authenticated operator. Applied on
api_create_machine and api_update_machine. Pattern lifted from the
existing api_settle_client_balance which already did this for
funding_wallet_id (260-265 in the original file).
2. **DB layer.** m007 adds a UNIQUE index on dca_machines.wallet_id —
even if a future endpoint forgets the API check, the DB rejects two
rows claiming the same wallet. CREATE UNIQUE INDEX is portable across
SQLite and PostgreSQL (ALTER TABLE ADD CONSTRAINT is not on SQLite).
Same commit also addresses concurrency findings H1+H2+H3 from the
architectural review (race conditions on process_settlement +
no retry path for errored settlements):
- m007 also adds processing_claim TEXT to dca_settlements.
- crud.claim_settlement_for_processing does optimistic-lock via
UPDATE ... SET status='processing', processing_claim=:token
WHERE id=:id AND status='pending' (portable; no UPDATE...RETURNING).
Read-back compares the token; only one concurrent caller wins.
- crud.reset_settlement_for_retry voids failed legs and flips
'errored' → 'pending' so process_settlement re-runs them. Completed
legs are LEFT IN PLACE — we never re-pay sats that already moved.
- crud.mark_settlement_status clears processing_claim on terminal
states so a fresh claim attempt won't see a stale token.
- distribution.process_settlement now uses the claim instead of the
status-read-and-check pattern. Concurrent listener re-fires +
partial-dispense recomputes can't double-pay legs.
- New endpoint:
POST /api/v1/dca/settlements/{id}/retry (operator-scoped)
Refuses if status != 'errored' (400). Resets, then re-runs
process_settlement via the claim path.
DcaSettlement gains a processing_claim: Optional[str] field. Visible to
operators in settlement detail; stale claims (status='processing' for
many minutes) are a "processor crashed mid-flight" signal — operator
can manually mark errored + retry.
32 routes registered. 72/72 tests pass.
Refs: aiolabs/satmachineadmin#9 — closes the v2-branch security finding
and HIGH-priority concurrency findings from the internal review.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the v1 feature request satmachineadmin#4 (balance settlement for
small remaining LP balances). Operator hits 'Settle' on an LP, specifies
the exchange rate they're willing to honor, and the system pays out the
remaining fiat balance in sats from the operator's chosen funding wallet.
Avoids the Zeno's-paradox of vanishing tiny proportional shares — small
balances no longer drag on forever; they get cleanly zeroed.
New endpoint:
POST /api/v1/dca/clients/{client_id}/settle
body: SettleBalanceData {funding_wallet_id, exchange_rate,
amount_fiat?, notes?}
Flow (distribution.settle_lp_balance):
1. Get LP's remaining balance summary
2. amount_fiat capped at remaining (defaults to full remaining)
3. amount_sats = round(amount_fiat * exchange_rate)
4. Internal transfer funding_wallet → client.wallet via
create_invoice(internal=True) + pay_invoice
5. Records leg_type='settlement' in dca_payments
Two ownership checks at the API boundary: client (via machine→operator)
and funding_wallet_id (via lnbits.core.crud.get_wallet → wallet.user
== current operator). 400 (not 404) if funding wallet isn't owned —
operators can identify their own wallets so leaking existence is fine.
Updated get_client_balance_summary to count both leg_type='dca' AND
leg_type='settlement' completed legs against the LP's remaining
balance. Without this update, settled amounts would leave the LP's
balance unchanged in the summary and re-fire on the next bitSpire tx.
Exchange rate is operator-supplied and required — explicit so there's
no ambiguity about what rate was used. Operator can use exchange spot,
market midpoint, or a favorable rate as a gesture; the rate is recorded
on the dca_payments row alongside amount_fiat for audit.
72/72 tests still pass. 31 routes total.
Refs: aiolabs/satmachineadmin#9, closes#4 (in spirit, marked once
verified end-to-end)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the v1 feature request satmachineadmin#3 (partial transaction
processing) and adds operator-authored audit notes on settlements.
Schema (m006_add_settlement_notes):
ALTER TABLE dca_settlements ADD COLUMN notes TEXT
The notes column is append-only (prepend with timestamp, never edit in
place). Stores both system-generated audit memos (partial-dispense
recompute provenance) and operator-authored free-form notes (cash-
drawer reconciliation context, off-LN refund records, etc.).
Partial-dispense endpoint:
POST /api/v1/dca/settlements/{id}/partial-dispense
body: PartialDispenseData {dispensed_fraction OR dispensed_sats, notes}
Recompute path (in distribution.apply_partial_dispense_and_redistribute):
1. Refuse if any leg has status='completed' (Lightning can't claw back)
2. Resolve new_gross from dispensed_fraction or dispensed_sats
3. Linear-scale net/commission/fiat — preserves the original commission
ratio exactly; only rounding may drift by 1 sat
4. Re-stage-1 split using the CURRENT super_fee_pct (super may have
changed the rate since the original landed)
5. Build a memo capturing original values + reason + new values
6. Void pending/failed legs (status → 'voided')
7. Overwrite the settlement's monetary fields + prepend memo to notes
8. Reset status to 'pending' → process_settlement re-runs distribution
Operator notes endpoint:
POST /api/v1/dca/settlements/{id}/notes
body: AppendSettlementNoteData {note}
Each operator note is timestamped (UTC) and tagged with the author's
user_id so the audit trail is accountable. Non-empty, max 2000 chars.
72/72 tests still pass. 30 routes total. The full-directory ruff number
ballooned to ~500 because it includes legacy transaction_processor.py
(orphaned, not imported anywhere) and other v1 cruft on the branch.
Files I actively maintain are clean.
Note: a richer queryable audit history (filter by author / time range /
action type / etc.) is being tracked as a separate future-work issue.
The notes-column approach here is the v1 audit story; the dedicated
history table will be additive.
Refs: aiolabs/satmachineadmin#9, closes#3 (in spirit, marked
once verified end-to-end)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The initial m005 made bitspire_event_id the UNIQUE idempotency key on
dca_settlements, but settlements arriving through LNbits' invoice
listener (the canonical path per nostr-transport-branch architecture)
don't carry a Nostr event id at the Payment level — that's the
underlying transport's concern, not exposed to extensions.
The natural unique key is payment_hash:
- every LN invoice has a globally unique payment_hash
- subscription replays / dispatcher double-fires dedup via UNIQUE
- it's always present on the Payment object the invoice_listener delivers
Reshape the dca_settlements column constraints:
- payment_hash: TEXT NOT NULL UNIQUE (was: NOT NULL + separate index)
- bitspire_event_id: TEXT (was: NOT NULL UNIQUE) — kept nullable for
a future path where we subscribe to raw kind-21000 Nostr events
directly, bypassing the Payment system
Also rename the CRUD helper: get_settlement_by_event_id →
get_settlement_by_payment_hash, and update create_settlement_idempotent
to dedup on payment_hash. CreateDcaSettlementData / DcaSettlement
adjust accordingly.
The schema is unshipped (v2-bitspire branch is local only) — fixing
m005 in-place is appropriate. The separate dca_telemetry path for
kind-30078/30079 events already uses (machine_id, beacon_received_at)
semantics, so the UNIQUE-by-Nostr-event-id pattern isn't needed there
either.
Caught during P1a design before subscribing to register_invoice_listener.
Refs: aiolabs/satmachineadmin#9, aiolabs/lamassu-next#44
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the Lamassu-era data models (LamassuConfig, StoredLamassuTransaction,
single-config CRUD carriers) with the v2 Pydantic surface matching m005:
- Machine / CreateMachineData / UpdateMachineData: per-operator multi-machine
registry keyed by Nostr npub. Replaces single-row LamassuConfig + SSH fields.
- DcaClient now scoped per (machine_id, user_id). Includes autoforward fields
for satmachineadmin#8 (best-effort LN-address forwarding for LPs).
- DcaDeposit gains machine_id + creator_user_id (audit trail finding from v1).
- DcaSettlement: idempotency carrier for bitSpire kind-21000 events. Carries
platform_fee_sats + operator_fee_sats as absolute ints (v1 hook for the v2
customer-discount engine — see plan).
- CommissionSplitLeg / CommissionSplit / SetCommissionSplitsData: operator's
remainder-distribution rules. SetCommissionSplitsData validates legs sum
to 1.0 at the boundary so crud.py only sees valid sets.
- DcaPayment dropped transaction_type in favor of leg_type discriminator
(dca | super_fee | operator_split | settlement | autoforward | refund).
Gains settlement_id + machine_id + operator_user_id + destination_*.
- TelemetrySnapshot: sparse beacon + fleet snapshot fields, all nullable so
we degrade gracefully against today's minimal kind-30078 payload.
- SuperConfig / UpdateSuperConfigData: super-only platform-fee carrier.
- PartialDispenseData (satmachineadmin#3), SettleBalanceData (satmachineadmin#4):
operator UX action carriers.
Stays on pydantic v1 @validator pattern + Optional[X] hints to match the
rest of the codebase. The UP045 / N805 lint noise is pre-existing tech debt
across the repo, not introduced here.
Refs: plan at ~/.claude/plans/snug-gliding-shamir.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add PUT /api/v1/dca/deposits/{id} endpoint to update amount, currency,
and notes on pending deposits. Add DELETE endpoint to remove deposits
not yet inserted into the machine. Both endpoints reject confirmed
deposits. Frontend now shows edit/delete buttons only for pending rows.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Refactor GTQ storage migration: Moved the conversion logic for centavo amounts to GTQ into a new migration function, m004_convert_to_gtq_storage, ensuring proper data type changes and updates across relevant tables. This enhances clarity and maintains the integrity of the migration process.
Refactor DCA Admin page endpoints: Update description, remove unused CRUD operations and API endpoints related to MyExtension, and streamline the code for improved clarity and functionality.
Remove QR Code dialog from MyExtension index.html: streamline the UI by eliminating unused dialog components, enhancing code clarity and maintainability.