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>
Pairs with aiolabs/lamassu-next@8318489 which now stamps the customer-
transacted fiat amount as a top-level field on Payment.extra, sourced
directly from bitSpire's bill validator / dispenser ledger.
Previously `_parse_extra` computed `fiat_amount = gross_sats /
exchange_rate` (which is wrong — that's the fiat-equivalent of the
gross including commission, not the customer's transaction value)
or `principal_sats / exchange_rate` (close but assumes commission
lives entirely in BTC and accumulates rounding from floor() in the
bitSpire-side principalSats calc). Both are derivations from
adjacent quantities; the bill validator already knows the answer.
Now: read `extra.get("fiat_amount")` verbatim. Source of truth ends
up on the settlement row exactly as the machine recorded it.
Surfaced during the 2026-05-16 cash-out E2E test: 20 EUR customer
transaction was rendering as 21.55 EUR in the Fiat column — that
21.55 was the fiat-equivalent of the gross sats including commission,
not the cash that physically came out of the machine.
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>
The collapsed m001 introduced commit (2886dd7) renamed wallet_id → target
on dca_commission_splits, but a real-world install caught a subtle
LNbits-side wrinkle: the sqlite file persists across extension
uninstall+reinstall. LNbits' uninstall wipes the dbversions tracker
(so m001 re-runs), but NOT the satoshimachine.sqlite3 file. With
`CREATE TABLE IF NOT EXISTS` in m001, the pre-existing
dca_commission_splits table (created by an earlier partial m005 with
the old `wallet_id` column) survived unchanged. m001 marked itself
complete, then runtime queries blew up because the model expected
`target` but the DB still had `wallet_id`:
ERROR | distribution.process_settlement:389
unexpected: 1 validation error for CommissionSplit
target
field required (type=value_error.missing)
m002 fixes it idempotently:
- Probes for the wallet_id column via SELECT
- If it exists (stale install): ALTER TABLE … RENAME COLUMN
- If the SELECT errors (fresh install or already renamed): no-op
ALTER TABLE … RENAME COLUMN is portable across SQLite 3.25+ and
PostgreSQL. Both backends preserve row data on rename.
Refs: aiolabs/satmachineadmin#9, found while validating cash-in flow
end-to-end (LNURL-withdraw redemption on the regtest stack).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Caught while answering the user's question about retry behaviour.
The /retry endpoint previously voided FAILED legs and flipped the
settlement back to 'pending', which then re-ran process_settlement.
But process_settlement re-creates every leg from scratch (super_fee +
operator_split + dca legs); it doesn't dedupe against already-completed
ones. So if a previous distribution attempt completed some legs and
failed others (status='errored' with mixed leg outcomes), hitting
/retry would re-pay every successful leg — actually double-paying real
sats.
Fix: refuse /retry with 400 when count_completed_legs_for_settlement > 0.
The error message tells the operator their options:
- Edit the commission_splits ruleset to remove already-paid targets
before retrying
- Or pay the missing legs out-of-band
For the all-failed case (no completed legs), /retry continues to work
as before — all-or-nothing retry is safe.
This mirrors the existing partial-dispense guard
(distribution.apply_partial_dispense_and_redistribute) which refuses
when any leg has completed for the same reason (Lightning sats can't
be clawed back).
Splitpayments doesn't have this concern because each split is a
separate one-off payment with no retry semantics — they just log and
move on. Our model has an explicit retry but needs the symmetric
double-pay guard.
Future enhancement (post-v1): make process_settlement leg-aware so
it skips already-completed (settlement_id, leg_type, target) tuples
on re-run. Would let /retry handle partial-success cases too. Tracked
informally as an open thread; not on the omnibus issue yet.
76/76 tests pass.
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>
The earlier mass rewrite in cb19ba3 used a regex with `[^>]*?` to match
attribute spans, which stops at the first `>` it encounters. That broke
on tags with `:rules="[v => ...]"` where the JS arrow function's `>`
character truncated the match short of the actual `/>`. 8 tags survived
the rewrite (mostly form fields in dialog bodies). The Add-machine
dialog was the most visible victim — Vue's compiler tried to make
sense of the partially-malformed q-input and dumped the machine_npub
field plus the next two siblings side-by-side instead of stacked.
Fix: replaced the regex pass with a small stateful scanner that walks
the file tag-by-tag and is quote-aware (treats `>` inside paired
double or single quotes as literal characters, not tag terminators).
The scanner found and expanded the remaining 8 tags + verified zero
self-closing q-* tags remain anywhere in the template. 343 q-* opens
total in the file.
Caught while clicking through the Add-machine dialog in the UI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
FastAPI matches routes in declaration order. The literal /settlements/stuck
was being shadowed by /settlements/{settlement_id} declared earlier, so
GET /settlements/stuck was matching settlement_id="stuck" and 404'ing
with "Settlement not found". Caught while clicking through the v2 UI
post-reinstall: the Worklist tab couldn't load.
Fix: declare the literal sub-route first. Also added a NOTE comment
above the section so a future re-shuffle re-checks the order before
landing.
Verified routes register in correct order (line numbers in views_api.py):
/settlements (404)
/settlements/stuck (433) ← literal
/settlements/{id} (463) ← path-param
/settlements/{id}/partial-dispense (478)
/settlements/{id}/force-reset (513)
/settlements/{id}/retry (565)
/settlements/{id}/notes (600)
76/76 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User confirmed no production servers are affected, so squashing the
staged migrations into a single source-of-truth migration is safe.
Reductions:
m001-m004: legacy Lamassu schema (single-config + SSH-tunnel poller)
m005: v2 initial schema (had a SQLite CREATE-INDEX syntax bug)
m006: notes column
m007: processing_claim column + dca_machines.wallet_id UNIQUE
───────── → m001_satmachine_v2_initial (single function)
What this commit changes:
- Replaces seven migration functions with one. Diff -180 lines net
(477 → 297). The collapsed migration carries the corrected SQLite
syntax (no schema prefix on CREATE INDEX tables) and is idempotent
end-to-end (CREATE TABLE/INDEX IF NOT EXISTS, seed check-then-insert).
- All design choices the staged migrations earned are preserved in
the inline comments: payment_hash idempotency key, absolute
platform_fee_sats/operator_fee_sats, wallet_id UNIQUE defence-in-
depth against IDOR, processing_claim optimistic-lock, notes
append-only audit memo.
- Pre-collapse history available in git on commits before this one.
What this commit does NOT change: schema. The final v2 tables / columns
/ indexes are identical to what m005+m006+m007 produced.
Upgrade path: anyone on the v2-bitspire branch with a partial-run
tracker (5/6/7) needs to uninstall + reinstall the extension to wipe
the dbversions tracker, then m001 runs fresh. Anyone on the legacy v1
main branch (tracker=4) does the same — uninstall + reinstall.
Refs: aiolabs/satmachineadmin#11 — closes the migration-collapse
follow-up that was deferred from fix bundle 3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three live-test bugs caught while wiring the v2 frontend to a real
LNbits regtest instance.
1. **SQLite parse error on CREATE INDEX with schema-prefixed table.**
`CREATE INDEX foo ON satoshimachine.bar (col)` errors with
"near '.': syntax error" on SQLite. PG accepts the prefix on the
table; SQLite expects the schema prefix on the INDEX NAME only,
not on the table. Cleanest portable fix (libra extension pattern):
drop `satoshimachine.` from the table reference inside CREATE INDEX.
The index lands in the same schema as the table regardless.
2. **m005 non-idempotent after partial failure.** The previous bug
above tripped m005 mid-flight (CREATE TABLE super_config + CREATE
TABLE dca_machines succeeded, then the first CREATE INDEX errored
and aborted). LNbits doesn't mark partial migrations done, so the
next boot re-ran m005 — and CREATE TABLE super_config now errored
with "table already exists". To make recovery clean:
- CREATE TABLE IF NOT EXISTS on every table (13 tables)
- CREATE INDEX IF NOT EXISTS on every index (10 indexes)
- super_config seed INSERT wrapped in check-then-insert so the
PK conflict on 'default' on re-run is avoided
3. **Vue compiler error code 30 — self-closing tags on non-void
elements in templates/satmachineadmin/index.html.** The previous
commit `98f82be` on satmachineclient called this out as a known
LNbits UMD gotcha: Vue 3 UMD's compiler doesn't auto-expand `<q-icon />`
the way SFCs do — the browser HTML parser sees the malformed self-
closing tag and aborts compilation. 118 tags expanded from
`<q-foo … />` to `<q-foo …></q-foo>` via mechanical rewrite.
Verified end-to-end against docker regtest-lnbits-1:
- All three migrations (m005, m006, m007) ran cleanly
- Schema has all 8 v2 tables + 10 indexes
- "satmachineadmin v2 loaded" + invoice listener registered
- /satmachineadmin/ returns 200; JS loads; super-config + machines
endpoints respond
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
~1300 lines removed across four cleanups. Pure deletions; no behavioural
changes.
1. **transaction_processor.py — DELETED (1274 lines).** Orphaned v1 file
that hasn't been imported anywhere since fix-bundle-1 wired the v2
distribution chain. The historical Lamassu logic is preserved in git
history at any commit on main.
2. **views_api.v2_in_progress_stub — DELETED.** The catch-all that
returned 503 for any unmatched /api/v1/dca/* path. With P3a–P9g
shipped, every documented endpoint is implemented; the catch-all was
stale and (per issue #11 M7) unauthenticated, so it leaked the
extension's existence to anonymous probes. Removed entirely.
3. **tasks.hourly_transaction_polling — DELETED.** v1 LegacyLamassu
polling no-op. The associated `create_permanent_unique_task` spawn
in __init__.py is also gone (was spawning a forever-sleeping task
for no reason).
4. **__init__.py scaffolding artifacts.**
- Replaced the placeholder "you can debug in your extension using
'import logger from loguru'" template log with a meaningful
"satmachineadmin v2 loaded" INFO line.
- Dropped the now-stale `hourly_transaction_polling` import + spawn.
- Sorted __all__ (RUF022).
Migration collapse (m001..m007 → single m001_v2_initial) was on the
fix-bundle-3 list but is deferred to a separate PR. The current
migrations are harmless on fresh installs (idempotent CREATE/DROP
chain) and collapsing them risks breaking the LNbits version tracker
on the off chance any operator has v1 data; better to do that as a
dedicated migration-discipline change once we're confident no v1
operator data exists in the wild.
Routes: 34 → 33 (catch-all gone). 76/76 tests pass.
Refs: aiolabs/satmachineadmin#11 — fix bundle 3 ✅ (modulo migration
collapse). Remaining in #11: M1-M12 + N1-N12.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes H6 from #11. The partial-dispense recompute path was reading the
CURRENT super_fee_pct via get_super_config() to re-derive the platform/
operator split. That breaks the "absolute fields are the source of
truth" invariant the v2 schema was built around: if super raises (or
lowers) the global rate between landing and partial-dispense, the
operator's share would retroactively shift — without any notice to the
operator and contrary to the original transaction's contract.
Fix: re-derive new_platform from the *original* platform_fee_sats /
commission_sats ratio stored on the settlement row, not from the
current super_config. The contract was locked at landing; rate changes
after the fact must not retroactively touch this transaction.
Before:
super_config = await get_super_config()
super_fee_pct = float(super_config.super_fee_pct) if super_config else 0.0
new_platform, new_operator = split_two_stage_commission(
new_commission, super_fee_pct
)
After:
ratio = (settlement.platform_fee_sats / settlement.commission_sats
if settlement.commission_sats > 0 else 0.0)
new_platform = round(new_commission * ratio)
new_platform = max(0, min(new_platform, new_commission))
new_operator = new_commission - new_platform
Note: split_two_stage_commission at LANDING time (in bitspire.py) still
uses the current super_fee_pct — that's correct, the rate at landing is
the locked rate. Only the *recompute* path was wrong.
Tests:
TestPartialDispenseSplitRatio.test_plan_scenario_30pct_lands_then_partial:
100-sat commission @ 30% → partial to 50% → 15/35 (preserves ratio).
test_super_changed_rate_doesnt_affect_existing_settlement:
Super raises rate to 50% after a 30% landing; partial-dispense to
50% must keep the ORIGINAL ~30% platform share, not the new 50%.
test_zero_original_commission_yields_zero_platform: edge case.
test_invariant_sum_equals_new_commission: parametrised sum invariant.
Also dropped the now-unused split_two_stage_commission import from
distribution.py (still used in bitspire.py at landing time and by the
test suite, just not in this file anymore).
54 / 54 tests pass.
Refs: aiolabs/satmachineadmin#11 — H6 ✅
Remaining in #11: fix bundle 3 (dead-code purge), M and N items.
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>
Combines the final three P9 pieces into a single commit since each is
small and they share the JS state plumbing.
Super-fee edit (P9f — visible only to super_user):
- "Edit" affordance on the platform-fee banner, gated on
g.user.super_user (LNbits passes this through windowMixin)
- Modal: super_fee_pct (decimal 0..1) + super_fee_wallet_id (text)
- PUT /api/v1/dca/super-config (check_super_user on the backend)
- Operators see the same banner read-only — no edit button rendered
Worklist tab (P9g part 1):
- Reuses GET /api/v1/dca/settlements/stuck?threshold_minutes=N
- Three labeled buckets: errored / stuck_pending / stuck_processing,
each with row count chip
- Per-row actions: open machine detail (reuses viewMachine), retry
(for errored), force-reset (for stuck — confirmation dialog warns
only-use-if-truly-stuck)
- Threshold input (default 30 min) + manual refresh button
- "All clear" green banner when worklist is empty
- Auto-loads on `created()` so the badge count is accurate from boot
Reports tab (P9g part 2):
- Four CSV download cards: machines / clients / deposits / payments
- Clients CSV merges in the per-LP balance summary from clientBalances
so the export captures total_deposits/payments/remaining + currency
- Payments CSV lazy-loads from GET /api/v1/dca/payments since payments
aren't cached in dashboard state (could be many rows)
- _downloadCsv helper properly quotes/escapes values with embedded
commas/quotes/newlines per RFC 4180
- All exports are client-side; no new endpoint required
P9 is now complete (P9a–P9g). The v1 super-only Lamassu dashboard is
fully replaced. Operators can register machines, manage LPs + deposits,
configure commission splits, work through errored settlements, and
export their data — all against the v2 backend.
Total v2 frontend: ~1326 lines JS + ~1349 lines template, replacing
the v1's 773 + 851. Increase is from the much larger v2 surface
(machines, leg-typed payments, commission editor, worklist, settle-
balance, partial-dispense, notes, force-reset, retry).
Refs: aiolabs/satmachineadmin#9 — completes P9
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Operator configures how the post-platform-fee commission remainder is
sliced across their wallets. Default ruleset applies fleet-wide; optional
per-machine overrides take precedence for that machine only.
Template (Commission tab content):
- Scope selector: "Default ruleset" or one option per operator machine
(override). Switching reloads the legs from the API.
- Live sum indicator (green ✓ if 100%, red ✗ otherwise). Save button
is disabled until the sum is valid.
- Editable row per leg: wallet select + label input + pct input.
Each row shows the % equivalent inline (e.g. 0.30 → 30.0%).
- Add-leg button appends an empty row.
- Preview banner: shows how an example 1000-sat operator commission
would split across the current legs, mirroring the server-side
last-leg-absorbs-rounding rule (calculations.allocate_operator_split_legs).
- "Remove override" button on per-machine scopes: deletes the override
so the default applies again (default legs untouched).
- Empty-state banner explains the consequence of no rules: operator
commission stays in the machine wallet.
JS:
- commissionScope state: null = default, else machine_id
- commissionScopeOptions computed: default + one per machine
- commissionLegs[] mirror the server's CommissionSplitLeg shape
- commissionSum / commissionSumValid: client-side invariant check
matching the SetCommissionSplitsData validator (within 0.0001)
- commissionPreview: pure JS port of allocate_operator_split_legs,
so the visualization matches what the server actually does
- saveCommissionSplits sends machine_id=null for default, else the
machine id; legs sort_order set from array index
- confirmDeleteCommissionOverride calls DELETE with ?machine_id=X to
clear just the override (no body)
- loadCommissionSplits called on created() so the tab is ready when
the operator clicks it
Routes wired:
GET /api/v1/dca/commission-splits
GET /api/v1/dca/commission-splits?machine_id=X
PUT /api/v1/dca/commission-splits
DELETE /api/v1/dca/commission-splits?machine_id=X
Refs: aiolabs/satmachineadmin#9
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Operator records fiat handed in by LPs and drives the pending → confirmed
status transition that promotes deposits into LP balances.
Template (Deposits tab content + dialogs):
- Filter strip: status dropdown (all/pending/confirmed/rejected) and
LP dropdown (filtered by all the operator's LPs across machines)
- Table columns: status badge, LP (+ machine subtitle), amount, created
at, confirmed at, notes, action menu
- Action menu (only enabled on pending status — confirmed/rejected are
immutable for audit):
• Confirm — flips to status='confirmed' + refreshes LP balance
• Reject — opens reject dialog for optional reason notes
• Edit — amount/currency/notes change
• Delete
- Empty-state banners: orange if no LPs (deposits are LP-scoped), blue
if LPs exist but no deposits yet, grey if filters return nothing
- Record-deposit dialog: LP select (auto-derives machine), amount,
currency, notes
- Edit-deposit dialog: amount/currency/notes; LP+machine immutable
- Reject-deposit dialog: optional reason text persisted with the status
JS:
- loadDeposits, depositStatusColor, clientUsernameById helpers
- depositClientOptions computed: includes machine name in each option
label so operators see exactly where the deposit will land
- filteredDeposits computed: client-side filter on the loaded list
(no server-side filter param — operator's deposit volume small enough)
- submitDeposit handles both create and update paths; the create body
explicitly includes machine_id (auto-derived from the selected LP)
so the server can cross-check (client_id, machine_id) alignment
- confirmDepositStatus refreshes the LP balance after confirming,
since the confirmed deposit now affects remaining_balance display
Routes wired:
GET /api/v1/dca/deposits
POST /api/v1/dca/deposits
PUT /api/v1/dca/deposits/{id}
PUT /api/v1/dca/deposits/{id}/status
DELETE /api/v1/dca/deposits/{id}
Refs: aiolabs/satmachineadmin#9
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds operator-scoped LP (liquidity provider) management. Operators
register LPs against specific machines, monitor remaining balances, and
settle small remainders via the P3e endpoint.
Template (Clients tab content + dialogs):
- Table with columns: machine, LP (username/short user_id), wallet,
DCA mode badge, remaining balance (color-coded: green if positive,
grey if zero), autoforward icon (with tooltip showing LN address),
status badge, action menu
- Empty-state banners: orange if no machines yet (LPs are
machine-scoped), blue if machines exist but no LPs registered
- Register-LP dialog: machine select + user_id + wallet_id + display
name + DCA mode (flow / fixed) + fixed-mode daily limit (conditional)
+ autoforward toggle + autoforward LN address (conditional)
- Edit-LP dialog: same minus immutable user_id/wallet_id, plus status
select (active/paused/closed)
- Settle-balance dialog (closes#4): funding wallet select + exchange
rate (operator-supplied) + optional amount_fiat (blank = full
remaining) + notes textarea. Shows the LP's current remaining
balance prominently before submission.
JS:
- loadClients pulls all operator's LPs across their fleet
- Per-LP balance summaries pre-loaded (one GET per LP — N+1, captured
in review issue #11 M3 for follow-up with a single grouped JOIN)
- openAddClientDialog / openEditClientDialog with separate cleaner
helpers (_cleanClientCreate vs _cleanClientUpdate) since the v2
API immutable-field rules differ between create and update
- openSettleBalanceDialog refreshes balance immediately before
showing the modal so the operator sees the up-to-date number
- confirmDeleteClient + DELETE wired
- machineNameById helper for displaying which machine an LP is at
- machineOptions computed for the register-LP machine select
- machinesById computed cache (avoids O(N*M) lookups in render loop)
Routes wired:
GET /api/v1/dca/clients
GET /api/v1/dca/clients/{id}/balance
POST /api/v1/dca/clients
PUT /api/v1/dca/clients/{id}
DELETE /api/v1/dca/clients/{id}
POST /api/v1/dca/clients/{id}/settle
Refs: aiolabs/satmachineadmin#9 — closes the Clients-tab gap in #4 +
exposes the autoforward setting (#8) in operator UI
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the operator's primary workspace: a full-screen dialog opened from
any Fleet row that shows the machine's settlement history with action
menus for retry / partial-dispense / force-reset / note-add.
Template (templates/satmachineadmin/index.html):
- Full-screen Quasar dialog with q-bar header (machine name + fiat
chip + reload + close)
- Machine metadata strip: npub (copyable), wallet_id, location,
fallback_commission_pct
- Settlements table: status badge, time, gross / net / commission
(with super/op breakdown beneath), fiat amount, payment_hash short
- Notes blob expansion under each settlement row (pre-formatted)
- Per-row action menu (q-btn-dropdown):
• Add note — always available
• Retry — when status='errored'
• Partial dispense — when status in {pending, errored}
• Force-reset — when status in {pending, processing}
- Warning icon (⚠) on rows where used_fallback_split=true, namechecking
aiolabs/lamassu-next#44 in the tooltip
- Three sub-dialogs:
• Partial-dispense with fraction/sats toggle + notes input
• Add-note dialog (free-form, non-empty validation)
• (Retry/force-reset use Quasar.Dialog inline)
JS (static/js/index.js):
- viewMachine() opens detail and triggers reloadMachineDetail()
- GET /api/v1/dca/machines/{id}/settlements feeds the table
- confirmRetrySettlement → POST .../retry
- openPartialDispense → POST .../partial-dispense
- confirmForceReset → POST .../force-reset
- openSettlementNote → POST .../notes
- _replaceSettlement() updates the table row in-place from PUT/POST
responses so the operator sees instant feedback without a reload
- settlementStatusColor() maps statuses to Quasar badge colors
- formatSats / formatFiat / formatTime helpers; respect locale
Also: added data/ + *.sqlite3 to .gitignore so the
2026-05-14 auth-key leak can't recur from this repo (the equivalent
fix already landed in satmachineclient on the matching branch).
Refs: aiolabs/satmachineadmin#9 — closes the operator-detail-view
gap for #3 (partial dispense) + #4 (settlement) UX
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the v1 single-page super-only dashboard with the v2 operator-
scoped tabbed shell. This is the entry point for the v2 frontend — gets
the operator into a working state where they can register their first
machine and see the v2 endpoints behind a usable UI.
Template — templates/satmachineadmin/index.html (full rewrite):
- Tab strip: Fleet | Clients | Deposits | Commission | Worklist | Reports
- Header bar with operator-focused title + refresh button
- Platform-fee banner reads super-config (visible to all operators)
- Fleet tab: machines table + add/edit/delete row actions
- Worklist tab gets a count badge (red) when stuck/errored settlements
exist; populated by GET /settlements/stuck on load
- Other tabs land placeholder banners pointing at their P9b–P9g task
- Add-machine + edit-machine dialogs with full form, including
fallback_commission_pct note that namechecks lamassu-next#44
JS — static/js/index.js (full rewrite):
- Vue 3 + Quasar UMD app following the workspace CLAUDE.md conventions
- ${ } delimiters (Jinja owns {{ }})
- g.user guards (LNbits 1.4 timing — g.user can be null on initial mount)
- For typography overrides, inline :style instead of utility classes
(LNbits theme overrides Quasar's .text-* with !important)
- Pale bg-*-1 backgrounds paired with explicit dark text class for
dark-mode legibility
- LNbits.api.request for all calls; Quasar.Notify for feedback;
Quasar.copyToClipboard for npub copy
- Routes wired:
GET /api/v1/dca/super-config → banner readout
GET /api/v1/dca/machines → fleet table
POST /api/v1/dca/machines → add modal
PUT /api/v1/dca/machines/{id} → edit modal
DELETE /api/v1/dca/machines/{id} → confirm dialog
GET /api/v1/dca/settlements/stuck → worklist tab badge
Deleted the entire v1 surface: lamassu_config form, SSH tunnel settings,
single-config polling controls, quick-deposit form (deposits get their
own tab in P9d), manual transaction dialog (the partial-dispense +
retry endpoints replace it in P9b), distribution drill-down dialog.
Next: P9b (machine detail drawer — settlements list, retry button,
partial-dispense modal, notes panel), P9c (clients tab), P9d (deposits
tab), P9e (commission splits editor), P9g (worklist + CSV reports).
Refs: aiolabs/satmachineadmin#9
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes satmachineadmin#8 — operator-configured LP autoforward to an
external Lightning address. The data path was already in place from P0d
(autoforward_enabled + autoforward_ln_address on dca_clients); this
commit wires the actual outbound LN-address payment.
Flow (in distribution._attempt_autoforward, called from the DCA leg path):
1. DCA leg lands in LP's LNbits wallet (regular internal transfer)
2. If client.autoforward_enabled AND autoforward_ln_address set:
a. Wrap address in lnurl.LnAddress
b. Resolve to bolt11 via lnbits.core.services.lnurl.get_pr_from_lnurl
c. Pay bolt11 from LP's wallet via pay_invoice
d. Record a leg_type='autoforward' dca_payments row with
destination_ln_address set
3. On ANY failure (malformed addr, LNURL resolution fail, payment
timeout): log warning, mark the autoforward leg 'failed', and
leave sats in the LP's LNbits wallet — the explicit safety
constraint from the original issue.
Audit: every autoforward attempt records a row (success or fail) so
operators can see in payment history which forwards landed externally
vs which left sats in LNbits. The destination_ln_address column on
dca_payments was already nullable to support this use case.
Safety guards:
- Skip autoforward if the DCA leg itself failed (nothing to forward).
- _attempt_autoforward never re-raises — failed forwarding must not
abort subsequent DCA legs for other LPs at this machine.
- Sats only move from the LP's wallet (which they own), never from
the operator's or super's wallets.
Refactor: extracted _pay_one_dca_leg from _pay_dca_distributions
to keep the outer function under the C901 complexity limit.
72/72 tests pass.
Refs: aiolabs/satmachineadmin#9, closes#8 (autoforward feature
request) — marked once verified end-to-end with a real LN address.
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>
Adds 3 operator-scoped endpoints for managing the commission remainder
ruleset:
GET /api/v1/dca/commission-splits
— operator's default ruleset
GET /api/v1/dca/commission-splits?machine_id=X
— per-machine override (just the
override, not the default)
GET /api/v1/dca/commission-splits?machine_id=X&effective=true
— what the settlement processor
actually applies (override if
set, else operator default)
PUT /api/v1/dca/commission-splits — atomic replace; model validator
enforces legs sum to 1.0
DELETE /api/v1/dca/commission-splits — clear default (per-machine
overrides still apply)
DELETE /api/v1/dca/commission-splits?machine_id=X
— clear per-machine override
(falls back to default)
All routes verify operator owns the referenced machine (404 not 403 if
not). The DELETE path bypasses SetCommissionSplitsData's sum-to-1.0
validator by calling replace_commission_splits([]) directly, since an
empty ruleset is the correct "no rules" state — distribution.py logs a
warning and leaves operator_fee_sats in the machine wallet when this
happens.
28 routes registered total. 72/72 tests pass.
Refs: aiolabs/satmachineadmin#9
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds 6 operator-scoped deposit endpoints:
POST /api/v1/dca/deposits — record fiat from an LP
(creator_user_id = the
operator who recorded)
GET /api/v1/dca/deposits — operator's deposits (all)
GET /api/v1/dca/deposits?client_id=X — scoped to one LP
GET /api/v1/dca/deposits/{id} — single
PUT /api/v1/dca/deposits/{id} — edit (pending only)
PUT /api/v1/dca/deposits/{id}/status — confirm/reject
DELETE /api/v1/dca/deposits/{id} — delete (pending only)
Cross-checks (client_id, machine_id) at create to prevent operators
binding deposits across machines incorrectly. Edits + deletes are
restricted to pending status so confirmed deposits become immutable
audit records (consistent with v1's existing behaviour from commit
28241e7).
Refs: aiolabs/satmachineadmin#9
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds 6 operator-scoped LP management endpoints:
POST /api/v1/dca/clients — register LP at a machine
GET /api/v1/dca/clients — operator's LPs (all)
GET /api/v1/dca/clients?machine_id=X — scoped to one machine
GET /api/v1/dca/clients/{id} — single LP
PUT /api/v1/dca/clients/{id} — update mode/autoforward/etc
DELETE /api/v1/dca/clients/{id} — delete
GET /api/v1/dca/clients/{id}/balance — fiat balance summary
Ownership transitively checked via the LP's machine — operators can
only see/modify LPs at machines they own. New _machine_owned_by and
_client_owned_by helpers consolidate the 404-not-403 ownership pattern.
Refs: aiolabs/satmachineadmin#9
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After a settlement lands (P1a), this commit pays out the three leg
groups via LNbits internal transfers (create_invoice + pay_invoice with
internal=True). Wired synchronously from the invoice listener — latency
is one bitSpire-tx wide. process_settlement is idempotent (status guard)
so retries are safe.
distribution.py — three leg groups, in order:
1. super_fee leg:
platform_fee_sats → super_fee_wallet_id (if set)
skip + warn if super fee % > 0 but wallet not configured
2. operator_split legs:
operator_fee_sats sliced per the operator's commission_splits
ruleset (per-machine override or operator default)
skip + warn if operator has no ruleset configured
3. dca legs:
net_sats distributed proportionally to active flow-mode LPs at
this machine, each capped at the LP's remaining-fiat-balance-
in-sats (preserves the v1 sync-mismatch fix from PR #2)
skip if exchange_rate=0 (fallback path with missing rate)
Every leg lands a dca_payments row with the leg_type discriminator and
inherits Payment.tag "satmachine:{machine_npub}" so LNbits payment-
history filters work natively across machines + operators.
Atomicity model: LN payments cannot be rolled back. Each leg is
attempted independently; success/fail recorded on the dca_payments row.
The settlement is marked 'processed' only when every leg completed; any
failure marks 'errored' with a concatenated message but leaves successful
legs in place. Sats that don't pay out (failed legs, missing super
wallet, no commission ruleset, no LP coverage) remain in the machine's
wallet — visible to the operator on the dashboard.
calculations.py — extracted two pure helpers:
split_two_stage_commission(commission_sats, super_fee_pct)
Stage-1: super takes super_fee_pct (rounded); operator absorbs the
rounding remainder so platform + operator == commission_sats exactly.
allocate_operator_split_legs(operator_fee_sats, leg_pcts)
Stage-2: distributes the remainder across N legs per pct rules. Last
leg absorbs the rounding remainder so sum(legs) == operator_fee_sats.
50 new tests cover the plan's verification scenario:
100 sats commission, super=30%, operator splits 50/30/20
→ super 30, operator 35/21/14. Sum 100 ✓
plus all the edge cases the plan called out (super=0, super=100,
single-leg, zero-fee, parametrised invariant on sums).
views_api.py adds the super-only platform-fee write endpoint:
PUT /api/v1/dca/super-config (check_super_user)
This is the only super-only endpoint in v2 — sets super_fee_pct and the
destination wallet for collecting the fee.
72/72 tests pass (22 calculation + 50 two-stage-split). 13 routes
registered against LNbits 1.4 (nostr-transport).
Refs: aiolabs/satmachineadmin#9
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the views_api.py stub with the v1 operator-scoped REST surface
needed for the P1 frontend tasks (machine onboarding by npub, settlement
review, payment-leg audit). All endpoints filter on the authenticated
user's id so two operators on the same LNbits instance can never see
each other's data.
Endpoints (12 routes):
Machines (CRUD):
POST /api/v1/dca/machines — add by npub + wallet_id
GET /api/v1/dca/machines — operator's fleet
GET /api/v1/dca/machines/{id} — single (ownership check)
PUT /api/v1/dca/machines/{id} — update (ownership check)
DELETE /api/v1/dca/machines/{id} — delete (ownership check)
Settlements (read-only at this phase):
GET /api/v1/dca/settlements — operator-wide
GET /api/v1/dca/machines/{id}/settlements — per machine
GET /api/v1/dca/settlements/{id} — single (ownership check)
Payments (leg-typed audit):
GET /api/v1/dca/payments?leg_type=… — operator's payment legs
Super config (read-only here):
GET /api/v1/dca/super-config — operators read the
platform fee they pay
Catch-all:
/api/v1/dca/{...} → 503 with a precise message for not-yet-implemented
endpoints (clients, deposits, commission splits, partial-tx,
balance-settle, super-config write — all P2+).
All ownership checks live at the API boundary: if the route's resource
points to a machine the operator doesn't own, we 404 (not 403) so
operators can't probe for the existence of other operators' machines.
Verified routes register cleanly against LNbits 1.4 (nostr-transport).
22/22 calculation tests still green.
Refs: aiolabs/satmachineadmin#9
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the no-op tasks.py stub with a real invoice listener that lands
bitSpire settlements idempotently into dca_settlements.
Architecture: satmachineadmin runs *inside* the LNbits process, so it
plugs into LNbits' canonical extension hook (register_invoice_listener
from lnbits.tasks) instead of going through the Nostr transport layer.
External clients like bitSpire use Nostr; internal extensions consume
the resulting Payment objects directly. One invoice_listener queue per
extension, dispatched by invoice_callback_dispatcher.
Flow:
bitSpire ATM (Nostr kind-21000)
→ LNbits nostr_transport handler
→ core Payment system (create_invoice + status=SUCCESS on settle)
→ invoice_callback_dispatcher
→ satmachineadmin's invoice_queue
→ _handle_payment filters by wallet_id → active machine
→ bitspire.parse_settlement reads Payment.extra (or back-derives)
→ create_settlement_idempotent (keyed on payment_hash UNIQUE)
The parser (new bitspire.py module) is bitSpire-specific:
- Happy path (post-aiolabs/lamassu-next#44): Payment.extra carries
{source:"bitspire", net_sats, fee_sats, fee_pct, exchange_rate,
currency, txid, machine_npub, bills, cassettes}. Read directly,
zero back-derivation.
- Fallback path (pre-#44): extra is absent. Back-derive the split
using machine.fallback_commission_pct with the Lamassu-style
formula (calculations.calculate_commission), mark
used_fallback_split=true, log a WARNING that namechecks the
upstream issue so it's findable in logs.
Two-stage commission split (super first, operator remainder) is
computed at land time so the audit row is complete:
platform_fee_sats = round(commission_sats * super_fee_pct)
operator_fee_sats = commission_sats - platform_fee_sats
The actual payout (LP DCA legs + super-fee leg + operator-split legs)
happens in a separate settlement-processor task in P2. P1 only LANDS
the settlement with status='pending'.
Smoke-tested both paths against real LNbits 1.4 (nostr-transport venv):
happy: 266800 gross → 258835 net + 7965 commission
(2390 super @ 30%, 5575 operator)
fallback: 266800 gross → 254095 net + 12705 commission @ 5% default
Also adds crud.get_active_machine_by_wallet_id, the lookup that gates
inbound payments to known machine wallets.
Refs: aiolabs/satmachineadmin#9, aiolabs/lamassu-next#44
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 v1's super-only single-config CRUD with the v2 operator-scoped data
layer that matches the m005 schema:
- Machines: create/get/get_by_npub/list_for_operator/update/delete
- Clients: scoped per (machine, user). Adds list_for_operator (across an
operator's fleet) and list_for_user (LP cross-operator view), plus
get_flow_mode_clients_for_machine for the distribution algorithm.
- Deposits: now carry machine_id and creator_user_id; per-operator listing.
- Settlements: create_settlement_idempotent treats bitspire_event_id as the
uniqueness key, returning the existing row on replay so subscription
re-delivery is safe by construction. mark_settlement_status drives the
pending → processed/partial/refunded/errored lifecycle.
- Commission splits: replace_commission_splits is an atomic per-scope
replace; the SetCommissionSplitsData model already validates legs sum
to 1.0 at the boundary. get_effective_commission_splits handles the
per-machine-override-or-operator-default precedence.
- Payments: leg-typed (dca / super_fee / operator_split / settlement /
autoforward / refund) with helpers for settlement/client/operator scopes.
- Balance summary: sums confirmed deposits minus completed dca legs.
- Telemetry: upsert_beacon_snapshot uses COALESCE so today's sparse
kind-30078 payload doesn't clobber post-#43 fields when they start
arriving. upsert_fleet_snapshot stores raw JSON until lamassu-next#42
fixes the kind-30079 schema.
- Super config: singleton get/update.
Also stubs three legacy entry points so __init__.py imports cleanly while
the rest of P0/P1 is in flight:
- tasks.py: no-op stubs for wait_for_paid_invoices + hourly_transaction_polling.
Real Nostr subscription manager lands in P1.
- views_api.py: a single /api/v1/dca/{...} catch-all returns 503 with a
precise message. v2 endpoints land in P1+.
- views.py: drops the super-only check on the index page (v2 is
operator-installable); platform-fee config moves to a super-only API in P1.
transaction_processor.py is left untouched but is now orphaned (no one
imports it) — gets a full rewrite in P1.
Refs: plan at ~/.claude/plans/snug-gliding-shamir.md
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>
Breaking redesign. Drops the v1 Lamassu-era tables (lamassu_config,
lamassu_transactions, plus the singular-config dca_clients/deposits/payments)
and creates the v2 schema:
- dca_machines: per-operator multi-machine registry, keyed by Nostr npub.
Replaces the single-row lamassu_config pattern.
- dca_settlements: bitSpire kind-21000 idempotency. platform_fee_sats and
operator_fee_sats stored as absolute BIGINT — v1 hook so the v2 customer-
discount engine can record who-forgave-what without a migration.
- dca_commission_splits: operator-defined remainder rules (per-machine or
default; sum-to-1.0 invariant enforced at write).
- dca_payments: leg-typed (dca | super_fee | operator_split | settlement |
autoforward | refund). Drops the old transaction_type field.
- dca_clients: now scoped per (machine_id, user_id) so an LP can hold
positions across machines/operators on the same instance.
- dca_deposits: gains machine_id + creator_user_id for audit.
- dca_telemetry: sparse kind-30078 / kind-30079 snapshots; post-#43 fields
nullable until lamassu-next enriches the beacon.
- super_config: singleton row for super_fee_pct + super_fee_wallet_id.
No backwards compatibility — operators on the previous schema must wipe and
re-onboard. Old migrations m001-m004 remain so fresh installs still walk the
versioned path; m005 drops their tables before creating the v2 schema.
Incidental: stripped trailing whitespace in m004 (W291/W293 hygiene).
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>
When there's a sync mismatch (more cash in ATM than tracked client
balances), cap each client's allocation to their remaining fiat
balance equivalent in sats. Orphan sats stay in the source wallet.
This prevents over-allocation when deposits haven't been recorded
yet or when there's a timing mismatch between ATM transactions
and balance tracking.
- Detect sync mismatch: total_confirmed_deposits < fiat_amount
- In sync mismatch mode: allocate based on client balance, not tx amount
- Track orphan_sats that couldn't be distributed
- Normal mode unchanged: proportional distribution using calculate_distribution()
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
LNbits create_invoice expects amount as float, not int. Added
explicit float() cast to both DCA distribution and commission
payment invoice creation calls.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Extract pure calculation functions to calculations.py (no lnbits deps)
- transaction_processor.py now imports from calculations.py (DRY)
- Add 22 tests covering commission, distribution, and fiat round-trip
- Include real Lamassu transaction data (8.75%, 5.5% commission rates)
- Test edge cases: discounts (90%, 100%), zero commission, small amounts
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The finally block was resetting runningTestTransaction instead of
processingSpecificTransaction, causing the button to stay in loading
state after processing.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
LNbits 1.4 changed check_super_user to return Account (no wallets)
instead of User (with wallets). This broke the template rendering
because LNbits.map.user() requires the wallets property.
Switch to check_user_exists (returns User with wallets) and manually
check user.super_user for access control. This follows the same
pattern used by LNbits core admin pages.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
LNbits 1.4 changed g.user initialization (PR #3615), moving it from
windowMixin to base.html. This means g.user can be null during initial
Vue template evaluation.
- Use optional chaining g.user?.wallets || [] in template
- Add null guard before accessing this.g.user.wallets in JS
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Removes the test transaction button from the admin UI.
The test transaction endpoint is still available in the API for development and debugging purposes.
Implements functionality to manually process specific Lamassu transactions by ID, bypassing dispense checks.
This allows administrators to handle transactions that may have failed due to dispense issues or were settled manually outside of the automated process.
The feature includes a new UI dialog for entering the transaction ID and an API endpoint to fetch and process the transaction, crediting wallets and distributing funds according to the DCA configuration.