Full sweep of every machine_npub deref found one more reachable crash:
_record_rejected (tasks.py) logs machine_npub[:12], and the
assert_nostr_attribution guard now routes an unpaired machine there, so
None[:12] -> TypeError. Fall back to machine.id.
Every other deref is safe by the attribution-gate invariant: a settlement only
flows past assert_nostr_attribution (now rejecting unpaired) for a paired
machine, so the downstream distribution / parse-path / "landed" logs can't see
None; the collision-loop display already uses `(m.machine_npub or m.id)`.
Adds tests/test_unpaired_machine_guards.py: attribution rejects an unpaired
machine with the domain SettlementAttributionError (not AttributeError), and
build_state_d_tags skips it. New tests + every guard-affected suite pass.
(Two pre-existing test_pair_endpoint failures — #29 drift: fake_pair lacks
bunker_relay, and the test DB lacks super_config — are out of scope; filed
separately.)
The `_handle_payment` cash-in branch existed but had never been exercised
end-to-end — bitSpire cash-in payouts only started reaching it once the
withdraw extension learned to stamp `source=bitspire` on an LNURL-withdraw
payout (aiolabs/withdraw#3). With that wired up, the first real cash-in
exposed two bugs:
1. `payment.sat` is signed by protocol direction — negative for an
outbound (cash-in) payout. It was passed straight to `parse_settlement`
as `wire_sats`, which enforces `wire_sats >= 0`, so every cash-in was
rejected ("wire_sats must be >= 0, got -75795"). A settlement's
`wire_sats` is a magnitude (direction lives in `tx_type`); pass
`abs(payment.sat)`. Same in `_record_rejected`.
2. `_record_rejected` hard-coded `tx_type="cash_out"`, so a rejected
cash-in showed the wrong direction in the operator dashboard. The
parsed tx_type isn't available on the rejection path, but the
authenticated protocol direction is — derive it: outbound → cash_in,
inbound → cash_out.
Verified on the dev stack: a stamped cash-in now lands a `cash_in`
settlement (net 75795, principal 82386, fee 6591), pays the super its 3%
(2472 sats), and correctly skips the DCA leg (principal stays in the
operator's wallet as liquidity from the cash-in customer).
Fork of satmachineadmin's v2-bitspire line into its own repo. Renames
both identifiers so this extension is fully independent of the original
satmachineadmin install (which remains in service):
- extension id satmachineadmin -> spirekeeper
(router prefix, static path/static_url_for, module symbols, task
names, templates dir, config/manifest paths)
- database name satoshimachine -> spirekeeper
(Database(ext_spirekeeper), all schema-qualified table refs)
Also resets versioning to 0.1.0, sets the display name + manifest to
spirekeeper/aiolabs, and fixes the placeholder pyproject description.
Historical aiolabs/satmachineadmin#N issue references in comments are
left pointing at the original repo where those issues live.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Layer 2 prep: a second consumer (fee_transport.py for #39) is about to
land that uses the same operator-signer + NIP-44 v2 + nostrclient publish
flow as cassette_transport.py. Extracting shared primitives now rather
than duplicating ~100 lines.
New `nostr_publish.py` module:
- Error hierarchy: NostrPublishError base + OperatorIdentityMissing,
SignerUnavailable, RelayUnavailable subclasses (all transport-layer
failures, domain-agnostic).
- `resolve_operator_signer(operator_user_id)` — fetch account + resolve
to NostrSigner, with the can-sign + has-pubkey checks.
- `sign_as_operator(operator_user_id, event)` — wrap signer.sign_event,
set created_at before signing.
- `nip44_encrypt_via_signer` + `nip44_decrypt_via_signer` — transitional
LocalSigner → RemoteBunkerSigner cascade (bunker handles natively;
LocalSigner falls back to hand-rolled NIP-44 v2 against the stored
prvkey).
- `publish_signed_event(signed)` — nostrclient relay-manager publish
with lazy import + RelayUnavailable on missing extension.
- High-level `publish_encrypted_kind_30078(operator_user_id,
recipient_pubkey_hex, d_tag, payload)` — builds event, encrypts via
signer, signs, publishes. The whole flow in one call; callers
(cassette_transport, soon fee_transport) just specify domain.
`cassette_transport.py`:
- Imports from nostr_publish; CassetteTransportError becomes a subclass
of NostrPublishError so existing catches still work.
- `publish_to_atm` reduces to a thin wrapper that builds the
cassette-specific payload + d-tag and delegates to
`publish_encrypted_kind_30078`.
- Consumer path (`decrypt_and_parse_state_event`) still owns
cassette-specific decode/transient distinctions; uses imported
`nip44_decrypt_via_signer`.
- Re-exports OperatorIdentityMissing / SignerUnavailable /
RelayUnavailable so views_api can keep importing from
cassette_transport without change.
`tasks.py` — cassette bootstrap consumer imports `resolve_operator_signer`
from nostr_publish directly instead of the cassette_transport
underscore-prefixed name.
164/164 tests green; behavior unchanged.
Refs: aiolabs/satmachineadmin#37 (parent), #39 (Layer 2, this commit
is prep).
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>
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>
Migrates the cassette transport's encrypt/decrypt paths off direct
`account.prvkey` reads to `signer.nip44_encrypt` / `signer.nip44_decrypt`
on the NostrSigner ABC landed by aiolabs/lnbits PR #38 (phase 2.4). Closes
the operator-side regression flagged at coord-log 2026-05-31T06:50Z:
Greg's RemoteBunkerSigner-migrated account had `accounts.prvkey IS NULL`
post-bunker, which the old code couldn't handle — consumer was logging
WARN every poll cycle and skipping every inbound state event.
## What changed
### cassette_transport.py
- New imports: `resolve_signer`, `SignerError`, `SignerUnavailableError`,
`NsecBunkerTimeoutError`, `NsecBunkerRpcError` from the post-#38 lnbits
surface. (The `try: from lnbits.core.signers import SignerError` block
in the old code was permanently failing because `SignerError` actually
lives in `lnbits.core.signers.base`, not the package root — fixed.)
- New `_resolve_operator_signer(operator_user_id)`: single source of
truth for "give me the operator's account + NostrSigner, or raise an
operator-facing error." Used by both the publish path and the consumer
task.
- New `_nip44_encrypt_via_signer(account, signer, plaintext, peer)`
and `_nip44_decrypt_via_signer(...)`: route through `signer.nip44_*`
first; on `SignerUnavailableError` from a LocalSigner stub (the
post-#38 ABC has LocalSigner raise on nip44_* explicitly — bunker
migration required for NIP-44 v2), fall back to the hand-rolled impl
against `account.prvkey`. Transitional until every operator on the
instance is bunker-backed (S7).
- `_sign_as_operator` simplified: now `await signer.sign_event(event)`
(the ABC is async; the old code passed `signer.sign_event` to the
caller without await, returning a coroutine — also broken but never
hit because the ImportError fallback fired first).
- `publish_to_atm` flow: `_resolve_operator_signer` → `_nip44_encrypt_
via_signer` → `_sign_as_operator` → publish. Each step maps bunker /
signer errors to `OperatorIdentityMissing` (400) / `SignerUnavailable`
(503) / `CassetteTransportError` (500) for the API handler.
- `decrypt_and_parse_state_event` now `async` and takes `(event, account,
signer)` instead of `(event, operator_privkey_hex)`. Maps
`NsecBunkerTimeoutError` → `CassetteEventTransientError` (caller
should retry on next poll, NOT advance `state_event_id`).
`NsecBunkerRpcError` / `SignerUnavailableError` / `Nip44Error` / etc.
→ `CassetteEventDecodeError` (terminal — caller logs + skips).
- New `CassetteEventTransientError` class for the bunker-timeout case.
Distinct from `CassetteEventDecodeError` so the consumer can log at
INFO + retry vs WARNING + advance.
- Deleted `_get_operator_privkey_hex` (no longer needed).
### tasks.py — _handle_cassette_state_event
- Resolves the signer via `_resolve_operator_signer(machine.operator_
user_id)`. On `CassetteTransportError` (OperatorIdentityMissing /
SignerUnavailable), logs + skips.
- Awaits `decrypt_and_parse_state_event(event_obj, account, signer)`.
On `CassetteEventTransientError`, logs at INFO + returns (state_event_
id NOT advanced → consumer retries on next poll cycle).
On `CassetteEventDecodeError`, logs at WARNING + returns (still
state_event_id NOT advanced for v1; the WARN log surfaces the
underlying issue for operator triage).
### tests/test_cassette_state_consumer.py — rewritten
- Three test doubles: `_FakeBunkerSigner` (working nip44_decrypt via
hand-rolled impl), `_FakeLocalSignerStub` (raises like the post-#38
LocalSigner stub), `_FakeRaisingSigner` (configurable exception).
- `_fake_account` helper using SimpleNamespace — the code under test
only reads `.signer_type` + `.prvkey`.
- Five test classes covering: bunker-signer happy path (incl. multi-
same-denom round-trip), LocalSigner transitional fallback,
bunker-error mapping (timeout → transient, rpc reject → decode),
payload validation (tamper / wrong-key / missing-fields / garbage
JSON / wrong shape), d-tag construction (unchanged, kept as
regression guard).
- Async coroutines driven via `asyncio.run` — matches the existing
project pattern (no pytest-asyncio plugin in CI; see test_init.py
failure mode).
### nip44.py — docstring update
Added a "Runtime status (post lnbits PR #38, 2026-05-31)" section
documenting that runtime usage moved to `signer.nip44_*` and this
module's role narrowed to (a) the LocalSigner transitional fallback
called from `cassette_transport`, and (b) test-only fixtures in
test_nip44_v2.py for spec-vector + bitspire cross-test validation.
"Don't add new runtime call sites here. The signer abstraction is
the path."
## Verification
- 155 passed, 1 pre-existing async-plugin failure unchanged. The 19
consumer tests cover bunker happy path + LocalSigner fallback +
bunker error mapping + payload validation + d-tag construction.
- Live smoke against Greg's RemoteBunkerSigner-migrated account
on the regtest container: consumer correctly resolves the bunker
signer, fires `NIP-46 rpc -> method=nip44_decrypt`, catches the
resulting `NsecBunkerTimeoutError` (the local nsecbunkerd is not
responding within 15s — separate operational concern), maps to
`CassetteEventTransientError`, logs at INFO with "will retry next
poll", and crucially does NOT advance `state_event_id` on the
cassette_configs rows. Retry semantics preserved.
## Outstanding
- The bunker timeout itself is an operational issue (nsecbunkerd
config / policy / process state for kind-less nip44_decrypt RPC) —
not a satmachineadmin code concern; surface to the nsecbunkerd /
lnbits sessions if it persists.
- Once every operator on the instance is on RemoteBunkerSigner (S7
fully landed), the `_nip44_*_via_signer` helpers collapse to a
direct `await signer.nip44_*` call, the LocalSigner fallback can
be deleted, and `nip44.py`'s runtime exports retire (test-only).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
API endpoint:
- api_publish_machine_cassettes validates incoming payload.positions
set matches stored cassette_configs.position set (was: denomination
set match). Error message updated to "slot count is hardware-fixed
— re-provision the ATM via atm-tui to add/remove physical bays."
- Per-row upsert loop iterates payload.positions and passes
UpsertCassetteConfigData(denomination=, count=) — operator edits
denomination + count for a fixed slot.
Bootstrap consumer task: just a log-message field rename (now reports
"N cassettes" from len(payload.positions) instead of len(payload.
denominations)). Per-event handler already routes through the
transport's decrypt_and_parse_state_event, which returns a
PublishCassettesPayload that's now position-keyed via the model.
Tests still red until commit f.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Long-running task wired into satmachineadmin_start that subscribes to
kind-30078 bitspire-cassettes-state:<atm_pubkey_hex> events from every
active machine's ATM and upserts cassette_configs via apply_bootstrap_state
on receipt. Pairs with bitspire's one-shot bootstrap publish in
aiolabs/lamassu-next#56 — operator's first config publish then validates
against a non-empty denomination set.
Pattern mirrors wait_for_paid_invoices (try/except per event, never lets
the loop die). Uses the same nostr_client.relay_manager singleton that
cassette_transport.publish_to_atm uses, just on the subscribe side.
Implementation: poll the singleton NostrRouter.received_subscription_events
dict keyed by our subscription_id (satmachineadmin-cassette-bootstrap).
This is the same drain pattern nostrclient's per-WebSocket NostrRouter
uses; since we use a distinct sub_id, no cross-contamination with
WebSocket-connected clients of nostrclient.
Filter is re-derived from active machines each tick — newly-added
machines start receiving bootstrap events without an LNbits restart.
Soft-fail surfaces (none crash the listener):
- nostrclient extension not installed → log + 30s backoff
- inbound event sig-verify fails → log + skip
- sender pubkey not in dca_machines → log + skip (relay noise)
- operator privkey not on file → log + skip
- NIP-44 v2 decrypt / payload validation fails → log + skip
- apply_bootstrap_state error → log + skip
Per-event handler routes to the right operator's privkey by looking up
the machine via get_machine_by_atm_pubkey_hex (O(N) over active
machines — fine for small fleets; if fleets grow, normalize machine_npub
at write + add an index).
CRUD additions:
- list_all_active_machines: cross-operator query for the subscription
filter
- get_machine_by_atm_pubkey_hex: route inbound events to the right
machine row + operator account; accepts hex or bech32 storage
14 tests in test_cassette_state_consumer.py covering:
- decrypt_and_parse_state_event happy path + 6 negative paths (tamper,
wrong privkey, malformed pubkey, missing fields, garbage JSON,
wrong-shape payload)
- d-tag construction regression guard (REGRESSION GUARD: d-tag uses
ATM hex pubkey not internal UUID — pins the load-bearing detail
from coord-log 11:50Z)
- build_state_d_tags_for_machines + bech32 → hex canonicalisation
Full handler dispatch (verify_event → get_machine_by_atm_pubkey_hex →
apply_bootstrap_state) needs a live LNbits DB; smoke-tested manually
per the existing project convention.
Total: 146 passed, 1 skipped (cross-test fixture pending), 1 pre-existing
async-plugin failure unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Structural half of S8 (aiolabs/satmachineadmin#22). Listener now
accepts BOTH inbound and outbound payments instead of filtering on
`is_in=True`; distribution gates the DCA leg on tx_type so the
liquidity-flow direction at the ATM drives behaviour, not the
Lightning protocol direction at the operator's wallet.
tasks.py:
- Drop the `if not payment.is_in` pre-filter; keep `payment.success`.
- Pair-name the two axes (`is_lightning_inbound`/`_outbound` for
protocol vs `tx_type ∈ {cash_out, cash_in}` for business) per
the naming-inversion memory.
- Outbound payments need `extra.source == "bitspire"` before we
touch them — without it we can't tell the operator paying their
landlord from a cash-in settlement; skip silently.
- Cross-axis sanity gate: refuse to process when protocol direction
disagrees with business direction (cash_out must be inbound,
cash_in must be outbound). Catches a buggy/malicious upstream
stamping `type=cash_out` on an outbound payment.
distribution.py:
- Gate `_pay_dca_distributions` on `tx_type == "cash_out"`. Cash-in
liquidity stays in the operator's wallet — there's no LP share to
distribute. Skipped leg is written as an audit row via
`_record_skipped_leg` so the dashboard surfaces "DCA intentionally
skipped" instead of a phantom missing leg.
Still pending in S8: the UI marker (cash_in tx_type chip in the
operator settlements table) and end-to-end test against a real
LNURL-withdraw redemption.
Tests: 75 passed (no regression vs prior green state; `test_router`
remains a pre-existing pytest-asyncio plugin issue).
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>
~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 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>
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 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>
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>
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.