Commit graph

273 commits

Author SHA1 Message Date
3014962563 refactor(v2)(ui): denomination editable per slot, position read-only (#29 v1.1)
UI flips to position-keyed per the v1.1 redesign:

  - Column order: Bay | Denomination | Count | ATM-reported | Updated
    (position first, since it's the row identity)
  - Position becomes read-only: rendered as "Bay N" label
  - Denomination becomes an editable q-input (with the fiat code as a
    suffix on the input)
  - Count remains editable
  - ATM-reported column now shows "<denom> <fiat> · ×<count>" combining
    state_denomination + state_count for at-a-glance reconciliation
    (still v1: only the bootstrap snapshot; v2 reverse-channel makes
    this live)
  - Confirm-modal preview list: header is "Bay N", side shows the
    denomination + count being sent

JS:
  - cassettesTable.columns reordered to put position first
  - markCassetteDirty pivots on position (the immutable identity) and
    compares denomination + count against pristine
  - submitCassettePublish builds {positions: {<pos>: {denomination,
    count}}} payload instead of {denominations: ...}

No "lock icon" on denomination — the previous instinct to add one was
based on the m007 misinterpretation. v1.1 design correctly makes
denomination operator-editable.

Tests still red until commit f.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 22:28:37 +02:00
34e324b4c5 refactor(v2): cassette consumer + API endpoint — position-keyed (#29 v1.1)
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>
2026-05-30 22:26:55 +02:00
5dbd7314f4 refactor(v2): cassette CRUD + transport — position-keyed (#29 v1.1)
CRUD layer flips:
  - get_cassette_config(machine_id, position) — was (..., denomination)
  - list_cassette_configs_for_machine returns ORDER BY position alone
    (no secondary denomination ordering — position is the unique key)
  - update_cassette_config(machine_id, position, data, updated_by) —
    operator edits denomination + count for a fixed slot
  - apply_bootstrap_state upserts ON CONFLICT(machine_id, position)
    iterating payload.positions; populates new state_denomination
    column from row.denomination alongside state_count

cassette_transport.py needs almost no functional change — the wire
shape is implicit via PublishCassettesPayload.to_wire_dict (now emits
{"positions": {...}}) and decrypt_and_parse_state_event accepts what
the model parses. Just the module docstring + the publish log line
get updated to reference positions rather than denominations.

Tests still red until commit f rewrites them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 22:25:27 +02:00
427cad33de refactor(v2): cassette models — position-keyed wire shape (#29 v1.1)
The wire format flips from
  {"denominations": {"<denom>": {"position", "count"}}}
to
  {"positions": {"<pos>": {"denomination", "count"}}}

per coord-log 2026-05-30T18:30Z + 18:45Z.

Per-row editable surface changes:
  - denomination becomes mutable (operator swaps cartridges during refill)
  - count remains mutable
  - position becomes the row identity (hardware bay number)

Removed: the no_duplicate_positions validator (no longer relevant — position
is the key, dups are impossible at the dict level) and the implicit
"denomination unique" assumption. Multiple positions with the same
denomination are now operationally valid per bitspire 18:45Z.

CassetteConfig model adds state_denomination (Optional[int]) for v2
reverse-channel reconciliation diff highlighting.

Tests under tests/test_cassette_configs.py and test_cassette_state_consumer.py
will fail at this commit — they reference the old denomination-keyed
shape. They get rewritten in v1.1 commit f. Branch tip is green only after
commit f lands; this and the next 3 commits are intermediate states.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 22:23:51 +02:00
df6e8e0a22 feat(v2): m008 flip cassette_configs PK to (machine_id, position) (#29 v1.1)
Coordinated v1.1 fix with the ATM side per coord-log 2026-05-30T18:30Z
+ 18:45Z. The m007 schema's denomination-PK was wrong:

  - Operators need to swap cartridge denominations during refill (a $20
    bay becomes a $50 bay) without re-provisioning. m007 made
    denomination immutable per slot.
  - Real machines have N cartridges of the same denomination for
    cash-out throughput (e.g., four $20 cartridges on a single ATM to
    avoid mid-day refill). m007 + denomination-PK rejected duplicates.

The flip:
  - PK becomes (machine_id, position). Position is the fixed hardware
    bay number; denomination + count are operator-editable per row.
  - No UNIQUE constraint on denomination — multiple same-denom cassettes
    are operationally valid.
  - New nullable column state_denomination for v2 reverse-channel
    reconciliation (operator-believed denomination per slot vs ATM-
    reported denomination — diff highlighting in v2 UI).

SQLite doesn't support ALTER PRIMARY KEY directly; the migration does
the standard create-new / backfill-from-old / drop / rename dance.
Idempotent via state_denomination column-probe.

Backfill choice: in m007 the row's denomination was simultaneously the
operator-believed AND the ATM-reported value (only write path was the
bootstrap consumer copying state.db verbatim). At migration time
state_denomination = current denomination as a best-guess baseline;
the next bootstrap event re-populates the state_* columns
authoritatively per the v1.1 wire shape.

Wire shape, models, CRUD, transport, consumer, API, and UI all flip in
subsequent commits. PR #30 will grow from 9 → 14 commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 22:22:29 +02:00
23b615601f fix(v2)(ui): dirty cassette row — inset shadow accent, not bg-color class
Previous attempt (commit 5f9c84b) added text-grey-9 to bg-yellow-1 on
the q-tr dirty class. Didn't work: per CLAUDE.md "CSS specificity trap"
— LNbits applies theme overrides on Quasar utility classes with
!important, so extension class rules lose the fight even when the
pairing is correct.

Switching to inline :style with an inset left-edge box-shadow accent
(4px yellow bar) instead of changing background or text colors at all.
Per CLAUDE.md: "Inline `style` attrs (static or via Vue `:style`) win
without an arms race." The dirty indicator is now a vertical yellow
accent on the row's left edge — visible under dark theme without
touching contrast.

Reported by user during v1 joint-smoke UX pass: editing count or
position turned the row cream-coloured and made cell contents
unreadable. Fix verified with the same screenshot scenario.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 19:39:35 +02:00
5f9c84b6e8 fix(v2)(ui): dirty cassette row needs explicit text-grey-9 under dark theme
Per workspace CLAUDE.md "Dark-mode color discipline": pale bg-{color}-1
utilities render white-on-cream under the LNbits dark theme. The dirty-
row highlight in the Cassettes sub-tab used bg-yellow-1 alone, so the
denomination text (rendered as default-coloured <b>) went invisible on
the pale yellow background as soon as the operator started editing.

Paired with text-grey-9 the way the existing q-banner classes in this
file already are (bg-blue-1 text-grey-9, bg-orange-1 text-grey-9, etc).
Sintra dispatcher Greg surfaced this during the v1 joint smoke today
(coord-log 2026-05-30T17:55Z).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 19:34:11 +02:00
5631246337 test(v2): wire bitspire's NIP-44 v2 cross-test fixture from coord log (#29 v1)
Bitspire posted the sample event at ~/dev/coordination/log.md
2026-05-30T13:15Z — encrypted via @bitSpire/nostr-client's
encryptContentV2 + createSignedEvent (the same production code path
the ATM bootstrap publish uses), round-tripped on bitspire side
before posting.

Replaces the @pytest.mark.skip stub from commit da07bae with three
real cross-impl byte-compat assertions in TestBitspireCrossTest:

  1. test_decrypts_bitspire_sample_event — the load-bearing one. Our
     nip44.decrypt_from recovers the expected
     {"denominations": {"20": ..., "50": ...}} plaintext from the
     fixture's ciphertext. Confirms our hand-rolled NIP-44 v2 produces
     wire output that nostr-tools' impl reads, and vice versa.

  2. test_signature_verifies_via_lnbits_helper — lnbits.utils.nostr.
     verify_event returns True for the fixture's (id, pubkey, sig).
     Confirms both sides hash the event id the same way + Schnorr-
     verify under the same x-only public-key convention. The consumer
     path runs verify_event before NIP-44 decrypt, so this is the
     other half of the sig-algorithm agreement check.

  3. test_encrypt_round_trip_via_our_impl_decrypts_with_their_keys —
     encrypts the expected plaintext using OUR encrypt_for with the
     fixture's ATM keypair as sender + operator pubkey as recipient;
     decrypts back with OUR decrypt_from; asserts the recovered
     plaintext matches. Locks the encrypt direction too. Asserts the
     re-encrypted ciphertext differs from the fixture's (NIP-44 v2
     nonces are random — byte-equality would be a CSPRNG regression).

If any of these ever fail, the spec ambiguity surfaces before either
side ships — exactly what the cross-test is for.

Same trap I made writing 16:35Z (didn't re-tail before writing, missed
bitspire's 13:15Z fixture post between my 15:55Z ask and the 16:35Z
ack) that bitspire owned at 07:55Z and I'd written into my session
memory as a rule. Symmetric lesson — the trap fires for any session
that goes head-down on implementation work.

Total: 149 passed (146 + 3 new), 0 skipped (cross-test no longer
skipped), 1 pre-existing async-plugin failure unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 18:34:54 +02:00
407149137a feat(v2)(ui): cassette sub-tab in machine detail + overwrite-confirm modal (#29 v1)
The operator-facing surface for #29 v1. Two changes:

1. Sub-tab inside the existing machine-detail modal
   (`templates/satmachineadmin/index.html`):
   - q-tabs strip with Settlements + Cassettes inside the machine detail
     q-card-section, wrapping the existing Settlements content in a
     q-tab-panel name="settlements" and adding a new q-tab-panel
     name="cassettes"
   - Cassettes panel renders the cassette_configs rows from
     GET /api/v1/dca/machines/{id}/cassettes:
       - One row per denomination (read-only label)
       - Editable q-input for count + position (the only operator-
         editable fields per the locked design)
       - ATM-reported count column (read-only, shows the v2 reverse-
         channel state_count when populated; v1 only populates on
         bootstrap)
       - Last-updated timestamp
       - Dirty rows highlighted bg-yellow-1
   - "Revert" + "Publish to ATM" buttons in the header; both disabled
     until at least one row is dirty
   - "Waiting for ATM bootstrap" banner when cassette_configs is empty
     (the bootstrap consumer hasn't received the ATM's state event yet)

2. Confirm-on-publish modal (per coord-log `07:50Z`):
   - Yellow warning banner: "This publish will overwrite the ATM's
     currently-tracked counts. If the ATM has dispensed cash since your
     last refill, those decrements will be lost. Publish only after a
     physical refill (a known total), not to 'tweak' counts mid-day. v2
     reconciliation will replace this modal with reconciled state display."
   - Per-denomination preview list of what's being sent
   - Cancel + Publish-to-ATM buttons

Vue 3 + Quasar UMD compliance per workspace CLAUDE.md: explicit-close
tags (no self-closing), v-model.number on the numeric inputs,
@update:model-value to trigger dirty-tracking, JSON-clone for the
pristine snapshot.

JS additions in `static/js/index.js`:
  - machineDetail.cassetteEdits / .cassettesPristine / .cassettesDirty /
    .cassettesLoading / .cassettesPublishing / .cassettesError state
  - cassettesTable.columns (no pagination — small fleets)
  - cassettePublishConfirm.show
  - loadMachineCassettes — fetches + sets pristine snapshot
  - markCassetteDirty — compares to pristine, toggles _dirty + the
    overall cassettesDirty flag
  - revertCassetteEdits — deep-clone pristine back into edits
  - openCassettePublishConfirm — opens the modal
  - submitCassettePublish — builds PublishCassettesPayload from edits,
    POSTs to /machines/{id}/cassettes/publish, refreshes from the
    response, closes modal on success, surfaces 400/503 errors in
    the inline banner

reloadMachineDetail now also calls loadMachineCassettes so the
Cassettes tab is pre-populated and tab-switching is flicker-free.
viewMachine resets the cassette state (edits, pristine, dirty, error,
activeTab) on each open.

This is the final commit in the #29 v1 chain. PR #30 is ready for
review once the build + manual smoke pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 18:26:05 +02:00
f8042f8e4d feat(v2): POST cassettes/publish API endpoint + ownership guard (#29 v1)
Two operator-scoped endpoints, both gated by check_user_exists +
_machine_owned_by:

  GET  /api/v1/dca/machines/{machine_id}/cassettes
    List the operator-owned machine's cassette_configs rows. Empty list
    means the ATM hasn't published its bootstrap event yet (or the
    consumer task hasn't drained it); UI shows a "waiting for ATM" state.

  POST /api/v1/dca/machines/{machine_id}/cassettes/publish
    Operator submits the full per-machine cassette state (PublishCassettes
    Payload) for publish to the ATM. Validates the denomination set
    matches what's stored (defensive — UI prevents add/remove but API
    enforces), upserts each row with the operator's user id as audit
    updated_by, then calls cassette_transport.publish_to_atm to encrypt+
    sign+publish kind-30078.

The path param `{machine_id}` is satmachineadmin's internal dca_machines.id
UUID; the handler fetches Machine and uses machine.machine_npub
canonicalised via normalize_public_key as the `<m>` value in the d-tag
bitspire-cassettes:<atm_pubkey_hex> per the locked design and the
2026-05-30T11:50Z coord-log nudge. Translation happens inside
cassette_transport._atm_hex_pubkey so the API handler stays thin.

Error mapping:
  400 — payload denomination set doesn't match stored set (operator
        publishing for a cassette the ATM doesn't have, or no rows
        exist because the bootstrap hasn't landed)
  400 — OperatorIdentityMissing (operator hasn't onboarded a Nostr
        identity via LNbits Nostr-login)
  503 — SignerUnavailable (signer offline / client-side-only)
  503 — RelayUnavailable (nostrclient extension not installed)
  500 — anything else from the publish path

Returns the fresh cassette_configs rows after the upserts so the UI
refreshes its table from one round-trip.

Total: 146 passed (route registration verified via FastAPI router
introspection), 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>
2026-05-30 18:21:51 +02:00
e57a73083e feat(v2): bootstrap consumer task — auto-populate cassette_configs (#29 v1)
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>
2026-05-30 18:19:15 +02:00
b9d5ea3c57 feat(v2): cassette_transport — kind-30078 publish + decrypt (#29 v1)
The Nostr-wire layer for operator ↔ ATM cassette config. Owns both
directions:

  operator → ATM (publish_to_atm):
    build PublishCassettesPayload → NIP-44 v2 encrypt to ATM pubkey →
    sign as operator via _sign_as_operator hybrid → publish through
    nostrclient.router.nostr_client.relay_manager
    d-tag: bitspire-cassettes:<atm_pubkey_hex>
    p-tag: <atm_pubkey_hex>

  ATM → operator (decrypt_and_parse_state_event):
    consumer task feeds inbound events (already sig-verified by the
    subscription layer); we NIP-44 v2 decrypt with operator privkey +
    event sender pubkey, JSON-parse, validate as PublishCassettesPayload
    d-tag: bitspire-cassettes-state:<atm_pubkey_hex>
    p-tag: <operator_pubkey_hex>

`_sign_as_operator` recovers the hybrid signer pattern from commits
131ff92 / e13178d (removed in dcd0874 for the NIP-78 fleet rip): tries
`from lnbits.core.signers import resolve_signer` first (post-#17 path),
falls back to a direct `account.prvkey` read for pre-#17 lnbits hosts.
Both paths produce identical signed events. Unlike the prior fleet-
publish that soft-failed on missing identity (CRUD side-effect), this
publish is operator-initiated so missing identity raises
OperatorIdentityMissing for the API to surface as 400.

`_atm_hex_pubkey(machine)` centralises the `<m>` placeholder rule from
the 2026-05-30T11:50Z coord-log entry: always normalize_public_key on
machine.machine_npub, NEVER use the internal dca_machines.id UUID. The
build_state_d_tags_for_machines helper exposes the canonical d-tag
list for the consumer subscription filter to use.

Typed errors map cleanly to HTTP statuses in the API caller:
  - OperatorIdentityMissing → 400 (operator hasn't onboarded)
  - SignerUnavailable → 503 (signer offline / client-side-only)
  - RelayUnavailable → 503 (nostrclient not installed)
  - CassetteEventDecodeError → consumer-side log + skip (never crash)

NIP-44 v2 ECDH needs the raw operator scalar, which the signer
abstraction's high-level sign_event doesn't expose. v1 reads
account.prvkey directly (same surface as the pre-#17 sign fallback);
post-bunker (lnbits#18) this becomes a NIP-44-over-bunker RPC and the
operator nsec leaves the LNbits host — v2 follow-up.

Smoke-tested via docker exec: round-trip publish (build → encrypt →
parse) of the realistic {"denominations": {"20": ..., "50": ...}}
payload; tamper detection on a corrupted content field; malformed
pubkey rejection.

Full suite: 132 passed, 1 skipped, 1 pre-existing async-plugin failure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 18:14:16 +02:00
da07bae554 feat(v2): hand-rolled NIP-44 v2 crypto + reference-vector tests (#29 v1)
LNbits ships only NIP-04 (AES-CBC) in lnbits.utils.nostr.encrypt_content,
but the locked design at #29 (paired with lamassu-next#56) wires kind-30078
cassette config with NIP-44 v2 content per the privacy-by-default
architecture (dcd0874). Hand-rolling rather than adding a Python lib dep
per the plan-approval (option A) — keeps the impl auditable inline and
avoids pulling in a non-trivial dep tree.

nip44.py covers the full envelope:
  - get_conversation_key — ECDH x-coord + HKDF-extract with salt b"nip44-v2"
  - encrypt_with_conversation_key / decrypt_with_conversation_key — low-level,
    nonce-controllable for testing pinned vectors
  - encrypt_for / decrypt_from — high-level pair-keyed API (the shape app
    code reaches for)
  - _pad / _unpad — NIP-44 v2 length-prefixed padding scheme
  - HMAC-SHA256 verification on nonce || ciphertext, constant-time compare
    via hmac.compare_digest
  - Typed errors (Nip44VersionError / Nip44MacError / Nip44LengthError)
    so callers can distinguish tamper from corruption from spec mismatch

Stack: coincurve for ECDH (already a transitive lnbits dep), cryptography
for ChaCha20 + HKDF-expand (also already there). No new pyproject deps.

34 tests in tests/test_nip44_v2.py, three layers:
  1. Pinned reference vector — conversation_key for (sec=1, sec=2) matches
     the canonical paulmillr/nip44 published value
     (c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d).
     Regression-fails loudly if key derivation drifts.
  2. Round-trip + tamper detection — encrypt/decrypt across plaintext
     lengths (1, 32, 33, 1000, 5000, 65535 bytes); flipped MAC byte;
     flipped ciphertext byte; flipped nonce byte; wrong recipient privkey;
     version-byte rejection; padding-formula spot checks.
  3. Cross-impl byte-compat — placeholder test_decrypts_bitspire_sample
     marked @pytest.mark.skip, pending bitspire posting a sample event
     encrypted on their nostr-tools side to the coord log (per the
     2026-05-30T15:55Z entry). Wire that fixture and unskip when posted.

Total: 132 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>
2026-05-30 18:10:30 +02:00
9b8008db1f feat(v2): cassette_configs CRUD + unit tests (#29 v1)
Wire up the cassette_configs storage layer:
  - get_cassette_config / list_cassette_configs_for_machine — reads
  - update_cassette_config — operator UI per-row edit (count + position).
    Refuses to create new rows; the denomination set is hardware-determined
    per #29 row lifecycle.
  - apply_bootstrap_state — consumer-side upsert from an ATM-published
    kind-30078 bitspire-cassettes-state event. Populates both the
    operator-believed columns and the v2 reverse-channel columns
    (state_count, state_at, state_event_id) in one transaction. Returns
    False on relay re-delivery (any existing row's state_event_id matches
    the incoming event_id).
  - _should_apply_bootstrap_state — pure-function dedup gate extracted
    from apply_bootstrap_state so the relay-re-delivery decision is
    unit-testable without a database round-trip.

23 new pure-function/model tests in tests/test_cassette_configs.py
covering the wire-shape validators (denomination key coercion, no-duplicate-
positions, int ranges, wire-dict round-trip) and the dedup-helper logic.
DB-touching CRUD follows the existing project convention (see
test_deposit_currency.py rationale): smoke-tested manually via the dev
container, integration tests deferred.

Total: 98 passed, 1 pre-existing async-plugin failure unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 18:03:52 +02:00
13684e7134 feat(v2): m007 cassette_configs schema + Pydantic models (#29 v1)
Add the operator-side schema for per-machine ATM cassette inventory
(aiolabs/satmachineadmin#29). Schema choice mirrors the ATM-side
denomination-as-key invariant audited at coord-log 2026-05-30T06:40Z
across bitspire/atm-tui/src/db.zig:31, lamassu-next state-store.ts:54,
and hal-service.ts:116/189 — every ATM layer keys on denomination, so
the operator-side PK is (machine_id, denomination) to make
duplicate-denomination payloads impossible at the schema boundary.

Reserved nullable columns (state_count, state_at, state_event_id) hold
the latest bitspire-cassettes-state:<atm_pubkey_hex> event the ATM
publishes. v1 populates them on bootstrap-event receipt; v1 UI doesn't
render reconciliation. v2 reconciliation UI consumes them without a
migration.

Pydantic models in this commit:
  - CassetteConfig — read model for a stored row
  - UpsertCassetteConfigData — operator-edit form (count and/or position)
  - CassettePayloadRow — one denomination's wire-format values
  - PublishCassettesPayload — the full kind-30078 content payload,
    bidirectional (operator → ATM and ATM → operator share the shape).
    Validates int-coerced denomination keys, positive ints, no duplicate
    positions, and exposes to_wire_dict() that re-stringifies keys for
    JSON compatibility.

CRUD + transport + API + UI land in subsequent commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 18:00:11 +02:00
58a0974117 chore: ignore uv.lock until PEP 621 migration
uv.lock is a header-only file (no deps pinned) because pyproject.toml
still uses [tool.poetry] tables that uv can't read. Ignore for now.
Real fix tracked at aiolabs/satmachineadmin#28.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 23:33:49 +02:00
cf6c0b4b7a docs: security pathway write-up + printable PDF
Adds the bitSpire ↔ LNbits security pathway document drafted at the
start of v2 hardening — state-of-the-union, threat model, audit
findings, and the layered Nostr-native defence proposal (S0–S8).
Markdown source + printable A4 PDF + the CSS used by pandoc to
render. Linked from MEMORY index for future sessions to consult
when reviewing security work.

Carries the original Sprint-1 plan (NIP-26 delegation, NIP-40
expiration, NIP-78 fleet roster, etc.); subsequent work pivoted
NIP-26 → NIP-46 (bunker) per lnbits#18 and ripped out the public
NIP-78 publishing per the privacy-by-default operator preference.
Treat the doc as a frozen snapshot of the design at v1 — the
architectural framing remains useful even where individual
sub-issues have moved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 23:30:36 +02:00
ecf432c6a0 feat(v2)(ui): tx_type chip in operator settlements table (S8 UI)
Adds a "Direction" column to the per-machine settlements table that
renders a coloured Quasar chip with a directional icon:

  - cash-out (green-8, south_west arrow) — customer paid ATM invoice in
    BTC, operator wallet received sats. Principal distributes to LPs.
  - cash-in  (orange-8, north_east arrow) — customer redeemed LNURL-
    withdraw at the ATM, operator wallet sent sats. No DCA leg;
    liquidity stays in the operator wallet.

Tooltips spell out the meaning so the operator doesn't have to
remember the canonical mapping (cash_out ↔ inbound, cash_in ↔ outbound)
on sight. Defaults to cash_out for any unknown / legacy row, which is
safe because pre-S6 rows are all cash_out and the rejection-record
path also stamps cash_out.

Closes the UI half of aiolabs/satmachineadmin#22 (S8 cash-in path);
the structural half (direction discriminator + DCA skip) shipped in
eca6e96. End-to-end test against a live LNURL-withdraw redemption is
the remaining S8 acceptance gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 23:28:42 +02:00
eca6e961b7 feat(v2): wire cash-in routing — direction discriminator + DCA skip
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>
2026-05-26 23:21:30 +02:00
dcd08748a7 revert(v2): drop NIP-78 fleet publishing (privacy by default)
Pulls the kind:30078 per-machine config + fleet roster publish path
introduced at 131ff92. The default-public posture leaked operator
fleet composition (which npubs they run, where they're located, fiat
codes) to whatever relays nostrclient was configured with — a robbery
/ competitor-intel / extortion target surface the operator never
opted into.

Privacy by default is the operator's stated preference: nothing about
the fleet goes on relays unless the operator explicitly opts in via a
future toggle. Roster lookups now read from satmachineadmin's local
DB only (the S6 LNbits-side roster-gating becomes a local-DB-read
story, not a public-relay subscription).

Pre-launch — no external consumer to coordinate with, so the rip-out
is clean. Future opt-in publishing tracked in follow-up issue.

Removed:
 - nostr_publish.py (publish_machine_config / publish_fleet_roster /
   tombstone_machine_config / _sign_as_operator hybrid)
 - The three publish call sites in api_create_machine /
   api_update_machine / api_delete_machine.

Heartbeat-style public metadata (the kind of info bitSpire already
emits about machine liveness, location, active state) is still a
legitimate publish target — but that's the ATM's job, not the
operator's. Designed in the follow-up issue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 23:20:24 +02:00
e13178d3ac feat(v2): route nostr_publish signing through lnbits#17 signer abstraction (hybrid)
Responds to the lnbits session's 19:30Z coordination-log flag: PR #17
will NULL `accounts.prvkey` on cascade via the m002 classify job, which
would break the S4 fleet-roster publishing path (`131ff92`) — it reads
`account.prvkey` directly.

Hybrid migration in `_sign_as_operator`:

  1. Try `from lnbits.core.signers import resolve_signer` — post-#17
     lnbits provides this; routes through the per-account signer that
     understands LocalSigner (envelope-encrypted nsec at rest),
     ClientSideOnlySigner (server can't sign — soft-fail), and the
     future RemoteBunkerSigner (lnbits#18; phase 2).
  2. On ImportError, fall through to the direct `account.prvkey` read
     identical to the pre-#17 implementation. Same wire-level signed
     event either way; the fallback exists only to avoid a hard
     ordering dependency between this commit and the lnbits #17
     cascade landing on the host.

Soft-failure surfaces (all log + skip, don't break machine CRUD):
  - operator has no pubkey on file → skip.
  - signer resolve fails (unclassified account, etc.) → skip.
  - `signer.can_sign()` False (ClientSideOnlySigner) → skip.
  - `SignerUnavailableError` raised at sign time → skip.

Why hybrid instead of waiting for #17 to land first: pre-#17 lnbits is
what's currently in production / dev. If we ship a hard `from
lnbits.core.signers import ...` now, satmachineadmin breaks at import
time on every host running the current nostr-transport branch. The
try/except guard is the same shape lnbits core uses for cross-extension
imports (nostrmarket / nostrrelay).

Sister migrations on other extensions (nostrmarket, restaurant, tasks,
events) are tracked at `aiolabs/lnbits#21` umbrella + per-extension
issues that the lnbits session filed in the 2026-05-26T20:00Z audit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 22:24:29 +02:00
131ff92aa8 feat(v2): publish operator-signed kind:30078 fleet roster + per-machine config (S4)
Closes aiolabs/satmachineadmin#18 (S4 — NIP-78 per-machine config +
fleet roster). On every machine create/update/delete, publish two
operator-signed kind:30078 (NIP-78 addressable) events via the
`nostrclient` LNbits extension:

  - `bitspire-config:<machine_id>` — per-machine config event, one
    per machine. Tagged with `p=<atm_npub>` so external observers
    can filter by ATM pubkey: `{"#p": ["<atm_npub>"]}`.

  - `bitspire-fleet` — aggregate roster across the operator's
    active fleet. Lists every machine's atm_pubkey + display fields.
    Tagged with `p=<atm_npub>` per active machine.

Delete path tombstones the per-machine config (replaceable kind:30078
with `content.deleted=true`) and re-publishes the roster without the
machine — external readers see the tombstone OR the absence from the
roster.

Implementation choice — direct in-process singleton import (path b
from the pre-flight check, not the WebSocket path a):

  from nostrclient.router import nostr_client
  nostr_client.relay_manager.publish_message(json.dumps(["EVENT", e]))

Bypasses the public/private WebSocket entirely. Cleaner than going
through `wss://localhost/nostrclient/api/v1/<encrypted_ws_id>`. Same
cross-extension import pattern lnbits core uses for
nostrmarket.services + nostrrelay.crud (guarded by try/except).

Soft-failure throughout:
  - nostrclient extension not installed → log warning + skip.
  - Operator account has no Nostr keypair on file (account never went
    through Nostr-login flow, or post-bunker future where nsec is
    moved off-disk per lnbits#18) → log warning + skip.
  - The settlement / distribution path does NOT depend on the publish
    — these events exist for external observers, not internal flow
    control.

Out of scope (intentionally):
  - ATM-side consumer in lamassu-next (forward-looking, will read
    `#p=<atm_npub>` to learn its operator's config).
  - LNbits-server-side roster-gating in the nostr-transport handler
    (S6 / lnbits#14 Item 3 — needs satmachineadmin to publish first;
    this commit lays the groundwork).
  - Operator's NIP-65 relay list as the publish target (today we use
    whatever nostrclient is configured with; future per-operator
    relay lists can live on accounts.relays or similar).

m006 (the canonical-vocabulary rename migration shipped at d717a6e)
ran cleanly against the regtest container on lnbits restart.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:28:26 +02:00
d717a6e214 refactor(v2): canonical sat-amount vocabulary + delete Lamassu-era reverse-derivation
Cross-codebase decision logged at memory `reference_sat_amount_vocabulary.md`
and at `~/dev/coordination/log.md` (2026-05-26). Canonical names with
explicit units across satmachineadmin, lamassu-next, atm-tui:

  - `wire_sats` — actual Lightning payment amount (direction-agnostic;
                  was `gross_sats`, only "gross" for cash-out)
  - `principal_sats` — market-rate sats before commission (unchanged)
  - `fee_sats` — commission (was `commission_sats` internally;
                 already the wire format)
  - `fee_fraction` — commission rate as unit fraction in [0, 1]
                     (was `*_pct` / `fee_percent`; eliminates the
                     latent 100x bug from `feePercent * 100` on the
                     lamassu-next side)

Invariants enforced in bitspire._assert_sat_invariants on every
parsed settlement — range (all sats >= 0, 0 <= fee_fraction <= 1) +
direction-specific sum:

  - cash-out: wire_sats == principal_sats + fee_sats
  - cash-in:  wire_sats == principal_sats - fee_sats
              AND fee_sats <= principal_sats

Breaches raise SettlementInvariantError; tasks._handle_payment
records the row as `status='rejected'` with the exception message
and skips distribution. Attribution failure path symmetric.

Schema changes (m001 + m006):

  - dca_settlements.gross_sats              -> wire_sats
  - dca_settlements.commission_sats         -> fee_sats
  - super_config.super_fee_pct              -> super_fee_fraction
  - dca_commission_splits.pct               -> fraction
  - dca_machines.fallback_commission_pct    DROPPED (obsolete)
  - dca_settlements.used_fallback_split     DROPPED (obsolete)

m006 idempotently renames + drops columns on existing installs;
m001 lays down the canonical schema for fresh installs.

Obsolete code removed (Lamassu-era reverse-derivation):

  - calculations.calculate_commission — back-derived principal+fee
    from gross-with-commission-baked-in. v2 stamps both directly.
  - calculations.calculate_exchange_rate — bitSpire stamps directly.
  - bitspire._parse_fallback — sole caller of calculate_commission.
  - Machine.fallback_commission_fraction — only read by _parse_fallback.
  - DcaSettlement.used_fallback_split — only written by _parse_fallback.

parse_settlement now raises SettlementMetadataError if Payment.extra
lacks the bitSpire stamp or required absolute sat fields. No silent
back-derivation; upstream-bug surfacing via dashboard rejection.

Frontend (JS + Quasar templates) updated for the column renames and
the removed fallback fields. Settlements table renders "Wire" + "Fee"
columns; the "(fallback split)" warning badge is gone.

Tests:
  - test_calculations.py: kept distribution tests; deleted
    calculate_commission + calculate_exchange_rate tests.
  - test_two_stage_split.py: renamed variables; rewrote docstring
    value literals (e.g. `super_fee_fraction=0.30` not `=30%`).
  - test_nostr_attribution.py: dropped fallback_commission_fraction
    from machine fixture.
  - 72/72 pass on regtest container.

Cross-codebase follow-ups tracked in coordination log:
  - lamassu-next: rename `fee_percent` -> `fee_fraction` on
    Payment.extra + state.db; drop the `* 100` at lightning.ts:780.
  - atm-tui: read `fee_fraction` column in db.zig.

Memory artefacts:
  - reference_sat_amount_vocabulary.md (canonical + invariants)
  - feedback_pct_to_fraction_renames_need_value_sweep.md (gotcha)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:08:30 +02:00
6348c55e37 fix(v2)(ui): split v-text from <q-chip> children in deposit dialog
Vue compile error 56: `v-text` on an element with explicit children
(the `<q-tooltip>` slot) is conflicting — v-text replaces innerHTML,
so the tooltip would be silently discarded and Vue refuses to compile
the template at all.

Move the currency-code text into a `<span v-text="...">` sibling of
the `<q-tooltip>` inside the chip. Same render output; valid template.

Regression from d2e6827.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:04:40 +02:00
d2e682712d feat(v2): lock deposit currency to machine.fiat_code (closes #26)
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>
2026-05-16 18:03:34 +02:00
da25d2e1f8 fix(v2): read fiat_amount directly from Payment.extra (bill-validator truth)
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>
2026-05-16 16:39:47 +02:00
cfad4e341c feat(v2)(ui): operator-side LP UI matches the new dca_lp authority split
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>
2026-05-16 10:12:23 +02:00
80b5a6d785 refactor(v2): hoist LP state (wallet, mode, autoforward) into dca_lp table
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>
2026-05-16 10:05:54 +02:00
1feaba80ed refactor(v2): rename net_sats → principal_sats for semantic clarity
`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>
2026-05-15 23:21:32 +02:00
9414a18f82 feat(v2): reject settlements that fail nostr attribution cross-check (S5 G5)
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>
2026-05-15 22:39:30 +02:00
47916bdddd fix(v2): m002 — rename dca_commission_splits.wallet_id → target
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>
2026-05-15 07:55:57 +02:00
a86f8dc25d fix(v2): refuse /retry when any leg already completed (double-pay guard)
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>
2026-05-14 19:38:32 +02:00
5de9cd5205 feat(v2): commission split target accepts wallet id, invoice key, LN address, or LNURL
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>
2026-05-14 19:37:33 +02:00
8968c0ae07 fix(v2)(ui): finish expanding self-closing q-* tags (rules-attribute corner case)
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>
2026-05-14 19:25:03 +02:00
32484e3ce8 fix(v2): reorder /settlements/stuck before /settlements/{id} (route literal vs path-param collision)
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>
2026-05-14 19:22:10 +02:00
2886dd7394 chore(v2): collapse m001-m007 into single m001_satmachine_v2_initial
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>
2026-05-14 19:15:28 +02:00
cb19ba3675 fix(v2): m005-m007 idempotency + SQLite CREATE INDEX syntax; template self-closing tags
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>
2026-05-14 19:12:51 +02:00
b96837164e chore(v2): dead-code purge (fix bundle 3)
~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>
2026-05-14 19:00:43 +02:00
00b8253dd3 fix(v2): partial-dispense preserves original split ratio (H6)
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>
2026-05-14 18:58:15 +02:00
ecef916dda fix(v2): decouple listener + skipped-leg audit (fix bundle 2)
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>
2026-05-14 18:49:16 +02:00
f4eb7ec928 feat(v2): super-fee edit + Worklist + Reports (P9f+g, completes P9)
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>
2026-05-14 18:09:07 +02:00
5c8e629752 feat(v2): Commission splits editor (P9e)
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>
2026-05-14 18:07:08 +02:00
ce4d7e4dd6 feat(v2): Deposits tab — record/confirm/reject workflow (P9d)
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>
2026-05-14 18:05:25 +02:00
0800a1acb0 feat(v2): Clients tab — LP management + settle balance modal (P9c)
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>
2026-05-14 18:03:29 +02:00
13ac33047b feat(v2): machine detail dialog — settlements + per-row actions (P9b)
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>
2026-05-14 18:01:08 +02:00
21d159d709 feat(v2): tabbed dashboard skeleton + Fleet tab (P9a)
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>
2026-05-14 17:58:43 +02:00
0bdee0f62b feat(v2): LP auto-forward to LN address (P6 — closes #8)
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>
2026-05-14 17:46:02 +02:00
578f2c142d feat(v2): abandoned-tx queue + force-reset for stuck settlements (P3f)
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>
2026-05-14 17:43:20 +02:00
3ede66ff92 fix(v2)(security): wallet IDOR + settlement-processing concurrency
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>
2026-05-14 17:37:58 +02:00
d0a947b7e6 feat(v2): balance settlement at current rate (P3e)
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>
2026-05-14 17:17:41 +02:00