Commit graph

73 commits

Author SHA1 Message Date
d52a3bfafe fix: guard every machine_npub deref against unpaired machines (None)
Some checks failed
ci.yml / fix: guard every machine_npub deref against unpaired machines (None) (pull_request) Failing after 0s
machine_npub became nullable in #29/m011 (register-unpaired flow), but
several consumers still assumed it's non-None and crashed
`normalize_public_key(None)` with `AttributeError: 'NoneType' object has no
attribute 'startswith'`. On the demo (which had an unpaired machine) this
broke the platform-fee update (500) and spammed the cassette consumer with
errors every 2s. The #29 create/pair paths were guarded; these were missed:

- views_api `api_update_super_config`: the "republish fee to every active
  machine" loop → skip unpaired (they get their config at pairing).
- cassette_transport `build_state_d_tags_for_machines`: skip unpaired (no
  state-beacon d-tag yet) — the cassette-consumer loop crash.
- crud `get_machine_by_atm_pubkey_hex`: its `except (ValueError,
  AssertionError)` didn't catch the AttributeError; skip unpaired before
  normalize — the cassette event-handler crash.
- bitspire `assert_nostr_attribution`: reject (SettlementAttributionError) an
  unpaired machine instead of crashing the payment listener.
- views_api cassettes/publish endpoint: 400 (not paired) instead of crashing
  publish_to_atm.

Verified on the dev stack: with an unpaired active machine present, the
cassette consumer registers (skipping it) and runs clean — no AttributeError.
2026-06-22 16:45:29 +02:00
73bd274979 feat(pairing,ui): optional machine_npub + bunker_relay override + fee decimal-input UX
Some checks failed
ci.yml / feat(pairing,ui): optional machine_npub + bunker_relay override + fee decimal-input UX (pull_request) Failing after 0s
Three changes from the nsecbunkerd#27 bunker-pairing smoke (validated
end-to-end on the Sintra, 2026-06-21); intermingled per-file, so landed
together.

1. Optional machine_npub (model A1) — register UNPAIRED, bunker mints the
   identity at pairing:
   - machine_npub now nullable (migration m011 rebuilds dca_machines for
     sqlite / ALTER ... DROP NOT NULL for postgres; UNIQUE stays, NULLs
     don't collide so any number of unpaired machines coexist).
   - CreateMachineData.machine_npub -> str | None; create skips the
     collision-check + fee publish when blank; api_pair_machine now
     publishes the fee config after minting, so an unpaired machine clears
     its awaiting-fees gate once paired.
   - Supplying an npub up front is the DEVELOPMENT self-key path (a machine
     holding its own signing key) — available to anyone but the form field
     is explicitly marked DEVELOPMENT ONLY.
   - Frontend: npub field optional, required rule dropped, null-safe
     display (shortNpub -> "unpaired", guarded slices), empty -> null.

2. bunker_relay override on POST /machines/{id}/pair: PairMachineData gains
   bunker_relay; api_pair_machine threads it to pair_spire. Lets the seed's
   bunker:// relay differ from the relay lnbits uses to reach the bunker
   (internal docker host vs LAN/public) — needed for split-relay / dev
   deploys. Without it the smoke had to mint via a script.

3. Fees are decimal fractions, not percents: relabel super + operator fee
   inputs ("decimal fraction, 0-0.15") + a shared _assertFeesDecimal()
   guard (super/add/edit submits) so a percent typo (3 instead of 0.03)
   gets a clear toast, not a raw 400.

refs: nsecbunkerd#27/#36; aiolabs/bitspire#52; coordination smoke 2026-06-21

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:31:55 +02:00
a5efdf22a1 feat(pairing): optional token TTL + revoke endpoint (#9/#12, #22)
Some checks failed
ci.yml / feat(pairing): optional token TTL + revoke endpoint (#9/#12, #22) (pull_request) Failing after 0s
Builds on the seed-URL pairing in #21 (stacked).

(b) TTL — PairMachineData.duration_hours (validated > 0) threads through
    pair_spire -> create_new_token (lnbits#55). None = non-expiring.

(c) Revoke — POST /machines/{id}/revoke -> revoke_spire ->
    admin_client.revoke_key_user(spire-<id>). Per spirekeeper#22, revoke
    MUST go through KeyUser.revokedAt (revoke_key_user), NOT token revoke:
    lnbits eager-binds (redeems) the connect token at provision, so
    nsecbunkerd has materialised the policy into per-KeyUser grants its
    ACL checks BEFORE the Token.revokedAt filter -> token revoke is a
    silent no-op. Returns RevokeResult{revoked_count}: >=1 = cut, 0 =
    never bound. set_machine_unpaired clears paired_at (keeps npub +
    bunker_spire_key_name for audit / re-pair).

7 new tests (duration threading + default-None; revoke routes to
revoke_key_user and never token-revoke + error mapping; endpoint wiring
revoke happy/zero/502). 210 green; new code black/ruff-clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:51:54 +02:00
761f078053 feat(pairing): POST /machines/{id}/pair endpoint (#9)
Some checks failed
ci.yml / feat(pairing): POST /machines/{id}/pair endpoint (#9) (pull_request) Failing after 0s
Wires the pairing service into the operator API. api_pair_machine:
  - _machine_owned_by ownership guard (404 on miss)
  - opens NsecBunkerAdminClient.from_settings() and runs pair_spire
  - maps bunker failures: not-configured -> 503, PairingError/NsecBunkerError
    -> 502 (nothing persisted on failure)
  - runs _assert_no_pubkey_collision on the bunker-minted hex, then
    set_machine_pairing persists machine_npub (= minted spire identity, so
    path-B roster routes it), bunker_spire_key_name, paired_at.

Re-pair supported; revoke/expiry gated on aiolabs/lnbits#54.

Adds Create... PairMachineData {relays} body, set_machine_pairing CRUD,
and 3 endpoint wiring tests (persist+collision, empty-relays 400, failure
502). 203 tests green. Pre-existing black/ruff debt in crud/views_api left
untouched (version-drift churn avoided); new code is lint-clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 23:39:18 +02:00
439c47ceae docs: repoint migrated issue refs to spirekeeper numbers
Follow-up to the satmachineadmin->spirekeeper issue migration. The 20
open issues were recreated on aiolabs/spirekeeper with reassigned
numbers; this repoints in-repo references to the migrated issues at
their new spirekeeper numbers (#3->#1, #4->#2, #8->#4, #9->#5, #10->#6,
#17->#11, #21->#12, #28->#16, #44->#20). References to closed/non-
migrated satmachineadmin issues (#20/#22/#26/#29/#32/#37/#38/#39) stay
pointing at the original repo where they were resolved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:42:56 +02:00
a059e3f596 refactor: rename extension identity to spirekeeper
Fork of satmachineadmin's v2-bitspire line into its own repo. Renames
both identifiers so this extension is fully independent of the original
satmachineadmin install (which remains in service):

  - extension id   satmachineadmin -> spirekeeper
    (router prefix, static path/static_url_for, module symbols, task
     names, templates dir, config/manifest paths)
  - database name  satoshimachine  -> spirekeeper
    (Database(ext_spirekeeper), all schema-qualified table refs)

Also resets versioning to 0.1.0, sets the display name + manifest to
spirekeeper/aiolabs, and fixes the placeholder pyproject description.
Historical aiolabs/satmachineadmin#N issue references in comments are
left pointing at the original repo where those issues live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:30:05 +02:00
794d7e5395 feat(v2): wire fee-config publish into machine + super-config triggers (#39 3/3)
Three trigger points wire fee_transport.publish_fee_config into the
satmachineadmin API endpoints per the #39 spec. All three soft-fail on
transport errors — the underlying CRUD operation (machine create /
update / super-config save) succeeds even when the publish couldn't
reach the relay or the signer, and the operator can re-trigger by
editing again.

views_api.py:
- api_create_machine — publishes always after create, even when
  operator fees default to 0/0 (the resulting super-only payload is
  what unblocks the ATM past its `awaiting-fees` maintenance gate).
  Reads super_config singleton; if absent (m001 should have inserted
  it, so this is an impossible state), skips the publish to avoid
  crashing create.
- api_update_machine — publishes only when either
  operator_cash_*_fee_fraction is in the patch payload. Skip on
  name/location/wallet_id/is_active/fiat_code edits since those don't
  affect the fee model the ATM enforces (avoids unnecessary relay
  churn).
- api_update_super_config — publishes to every active machine when
  either super fraction changes. Per-machine: that machine's
  operator_user_id is the signer (machines owned by different
  operators sign with different keys); each soft-fail is independent.
  Skip if only super_fee_wallet_id changed (no fee-model impact).

Tests (9 cases, all green):
- 3 create-machine triggers: default 0/0 operator fees still publishes
  super-only payload, nonzero operator fees publish full payload,
  None super_config short-circuits without crashing
- 4 update-machine triggers: publishes on cash_in change, publishes on
  cash_out change, skips on name-only, skips on is_active-only
- 2 super-config triggers: publishes per-active-machine signed by
  each machine's operator on fraction change, skips entirely on
  wallet-id-only change (with an assertion that list_all_active_machines
  is never called, proving the short-circuit path)

191/191 tests green. Layer 2 (#39) complete; ready for joint smoke
once bitspire fixes the three deploy gaps from coord-log §2026-06-01T18:30Z
(`relay.aiolabs.dev` default, `VITE_LNBITS_HTTP_URL` dead echo,
operator-fees subscriber not running in maintenance state).

Refs: aiolabs/satmachineadmin#37 (parent), #39 (closes Layer 2),
aiolabs/lamassu-next#57 (Layer 3 consumer — blocked on bitspire-side
gaps).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 20:07:56 +02:00
4cd0041923 feat(v2): CRUD + per-direction fee cap validation (#38 2/5)
Wires the new directional fee fields through the write path and adds
the 15%-per-direction cap guard at the API boundary.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 10:42:03 +02:00
05c1105897 feat(v2): collision guard — refuse machines whose npub matches an operator account (#32)
Adds `_assert_no_pubkey_collision` to `views_api`, wired into
`api_create_machine` between the wallet-ownership guard and the
`create_machine` CRUD call. Refuses with HTTP 400 + operator-actionable
error message if the supplied `machine_npub` matches any existing
LNbits operator account's `accounts.pubkey`.

## Why this matters

Reproducer 2026-05-30T21:33Z (coord-log archive `2026-05-31-pre-rotation.md`):
Greg's operator account `accounts.pubkey` had been seeded as the same
value as Sintra's `dca_machines.machine_npub` (`522a4538…`) during
manual setup. The collision masked the routing bug for days — lnbits'
nostr-transport `auth.py:resolve_nostr_auth` was routing inbound
kind-21000 RPCs from the ATM directly to Greg's wallet *by coincidence*
of the matching pubkey. When Greg's account migrated to
`RemoteBunkerSigner` and got a fresh pubkey, the coincidence broke +
`auto-account-from-npub` fired for the orphaned ATM npub. A real $20
test cash-out silently landed on a fresh auto-account wallet
(`a94b564f…`); satmachineadmin lost the settlement entirely — no
`dca_settlements` row, no DCA distribution, no commission split.

The proper architectural fix is path B / `aiolabs/satmachineadmin#20`
(S6, in-progress with lnbits — coord-log `2026-05-31T15:25Z`). This
guard is the complementary preventive layer: stops a future operator
from re-entering the broken state by registering a machine whose npub
collides with an existing account.

## What's in this commit

- **`views_api._assert_no_pubkey_collision`** — canonicalises the input
  npub (accepts hex or `npub1…` bech32) via `normalize_public_key`,
  queries `lnbits.core.crud.users.get_account_by_pubkey` (which itself
  lowercases internally), raises HTTPException(400) on hit. Error
  message names the canonical pubkey prefix, explains the
  pubkey-collision dependency that breaks on operator pubkey rotation,
  + points to the `lamassu-next provision-atm` remediation path +
  this issue for context.
- **Wired into `api_create_machine`** after `_assert_wallet_owned_by`
  + before `create_machine`. `api_update_machine` is unaffected
  because `UpdateMachineData` doesn't allow npub changes on existing
  rows.
- **`tests/test_collision_guard.py`** — 7 unit tests covering hex /
  bech32 / uppercase-hex inputs all canonicalise to the same lookup,
  the no-collision case returns silently, error message asserts
  (truncated pubkey + remediation hint). Uses pytest monkeypatch to
  isolate the assertion logic from a live `get_account_by_pubkey` DB
  call — matches the assertion-style pattern of
  `tests/test_nostr_attribution.py`.
- **`CLAUDE.md`** — new "No-collision invariant" subsection under
  Security Considerations: documents the rule + the SQL check
  operators can run on existing installs + the
  `ATM_PRIVATE_KEY`-unset remediation + cross-refs to `#20` and `#32`.

## Regtest SQL check result

Ran the diagnostic SQL against the regtest LNbits + satmachineadmin DBs:

- 1 active `dca_machines.machine_npub`: `522a4538…` (Greg's Sintra)
- 1 collision found: the auto-account orphan `a94b564f…` (username =
  None — auto-account signature) created during yesterday's silent-drop
  failure mode. NOT a legitimate operator account. Greg's actual
  operator account `ac35c9fc…` carries pubkey `197a4cf4…` post-bunker
  migration, no collision there.

The orphan is operational cleanup (sweep + delete), separate from this
code fix. No real-operator collisions remain on the regtest instance.

## Test status

162 passed, 1 pre-existing async-plugin failure unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 18:45:56 +02:00
d448fab0d2 chore(v2): lint pass — black + ruff auto-fix + mypy regressions (#29 v1.1)
Pre-merge lint hygiene on the PR #30 touched files:

- `black` reformatted 9 files (cassette_transport, crud, models, tasks,
  views_api, nip44, all 3 cassette test files, migrations). Cosmetic:
  line lengths, trailing commas, multi-line argument layout.
- `ruff check --fix` cleared 176 of 202 errors auto-fixed. Mostly
  `UP006` `typing.Optional` → `| None` modernization, `I001` import
  sort order, `UP035` typing-extensions cleanup.
- Two new mypy regressions introduced by the migration commit dcb7de0
  fixed:
  - `crud.py:apply_bootstrap_state` — annotated `existing_first: dict
    | None` on the dedup fetch.
  - `tasks.py:_cassette_consumer_tick` — `# type: ignore[arg-type]` on
    the `nostr_client.relay_manager.add_subscription` call; nostrclient's
    upstream typing declares `list[str]` for filters but the actual
    Nostr protocol takes `list[<filter-dict>]`. The runtime accepts it
    (live smoke at 13:43Z dispatched `nip44_decrypt` cleanly through
    this subscription); the typing mismatch is upstream's.

Remaining lint state, intentionally not addressed in this commit
(all pre-existing baseline, not regressions):
- 8 mypy errors in `calculations.py` + the unchanged-by-this-PR parts
  of `crud.py` — pre-existing on v2-bitspire.
- 26 ruff style warnings: 14 are N805 false-positives on Pydantic
  validators (`cls` first-arg is correct for `@validator`-decorated
  methods); 4 are N818 exception-name-suffix preferences on my new
  exception classes (renaming would touch many call sites; keep
  `OperatorIdentityMissing` / `SignerUnavailable` / `RelayUnavailable`
  / `_NostrclientUnavailable` as-is for clarity); 5 are E501 line-too-
  long on docstrings (the long lines are formatted for clarity);
  1 RUF002 unicode-minus in a docstring.

Tests: 155 passed, 1 pre-existing async-plugin failure unchanged.
Live smoke (both publish + consume directions through the bunker)
unaffected — this is purely a code-style pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 15:50:14 +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
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
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
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
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
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
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
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
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
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
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
2883eb7b79 feat(v2): partial-dispense + operator notes on settlements (P3d)
Closes the v1 feature request satmachineadmin#3 (partial transaction
processing) and adds operator-authored audit notes on settlements.

Schema (m006_add_settlement_notes):
  ALTER TABLE dca_settlements ADD COLUMN notes TEXT

The notes column is append-only (prepend with timestamp, never edit in
place). Stores both system-generated audit memos (partial-dispense
recompute provenance) and operator-authored free-form notes (cash-
drawer reconciliation context, off-LN refund records, etc.).

Partial-dispense endpoint:
  POST /api/v1/dca/settlements/{id}/partial-dispense
  body: PartialDispenseData {dispensed_fraction OR dispensed_sats, notes}

Recompute path (in distribution.apply_partial_dispense_and_redistribute):
  1. Refuse if any leg has status='completed' (Lightning can't claw back)
  2. Resolve new_gross from dispensed_fraction or dispensed_sats
  3. Linear-scale net/commission/fiat — preserves the original commission
     ratio exactly; only rounding may drift by 1 sat
  4. Re-stage-1 split using the CURRENT super_fee_pct (super may have
     changed the rate since the original landed)
  5. Build a memo capturing original values + reason + new values
  6. Void pending/failed legs (status → 'voided')
  7. Overwrite the settlement's monetary fields + prepend memo to notes
  8. Reset status to 'pending' → process_settlement re-runs distribution

Operator notes endpoint:
  POST /api/v1/dca/settlements/{id}/notes
  body: AppendSettlementNoteData {note}

Each operator note is timestamped (UTC) and tagged with the author's
user_id so the audit trail is accountable. Non-empty, max 2000 chars.

72/72 tests still pass. 30 routes total. The full-directory ruff number
ballooned to ~500 because it includes legacy transaction_processor.py
(orphaned, not imported anywhere) and other v1 cruft on the branch.
Files I actively maintain are clean.

Note: a richer queryable audit history (filter by author / time range /
action type / etc.) is being tracked as a separate future-work issue.
The notes-column approach here is the v1 audit story; the dedicated
history table will be additive.

Refs: aiolabs/satmachineadmin#9, closes #3 (in spirit, marked
once verified end-to-end)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:46:33 +02:00
e8dcbfe26e feat(v2): commission splits CRUD endpoints (P3c)
Adds 3 operator-scoped endpoints for managing the commission remainder
ruleset:

  GET    /api/v1/dca/commission-splits
                                       — operator's default ruleset
  GET    /api/v1/dca/commission-splits?machine_id=X
                                       — per-machine override (just the
                                          override, not the default)
  GET    /api/v1/dca/commission-splits?machine_id=X&effective=true
                                       — what the settlement processor
                                          actually applies (override if
                                          set, else operator default)
  PUT    /api/v1/dca/commission-splits — atomic replace; model validator
                                          enforces legs sum to 1.0
  DELETE /api/v1/dca/commission-splits — clear default (per-machine
                                          overrides still apply)
  DELETE /api/v1/dca/commission-splits?machine_id=X
                                       — clear per-machine override
                                          (falls back to default)

All routes verify operator owns the referenced machine (404 not 403 if
not). The DELETE path bypasses SetCommissionSplitsData's sum-to-1.0
validator by calling replace_commission_splits([]) directly, since an
empty ruleset is the correct "no rules" state — distribution.py logs a
warning and leaves operator_fee_sats in the machine wallet when this
happens.

28 routes registered total. 72/72 tests pass.

Refs: aiolabs/satmachineadmin#9

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:37:16 +02:00
b7f6f0a696 feat(v2): deposit CRUD + confirmation endpoints (P3b)
Adds 6 operator-scoped deposit endpoints:

  POST   /api/v1/dca/deposits                  — record fiat from an LP
                                                  (creator_user_id = the
                                                  operator who recorded)
  GET    /api/v1/dca/deposits                  — operator's deposits (all)
  GET    /api/v1/dca/deposits?client_id=X      — scoped to one LP
  GET    /api/v1/dca/deposits/{id}             — single
  PUT    /api/v1/dca/deposits/{id}             — edit (pending only)
  PUT    /api/v1/dca/deposits/{id}/status      — confirm/reject
  DELETE /api/v1/dca/deposits/{id}             — delete (pending only)

Cross-checks (client_id, machine_id) at create to prevent operators
binding deposits across machines incorrectly. Edits + deletes are
restricted to pending status so confirmed deposits become immutable
audit records (consistent with v1's existing behaviour from commit
28241e7).

Refs: aiolabs/satmachineadmin#9

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:36:04 +02:00
7226b8289d feat(v2): client CRUD + balance summary endpoints (P3a)
Adds 6 operator-scoped LP management endpoints:

  POST   /api/v1/dca/clients                  — register LP at a machine
  GET    /api/v1/dca/clients                  — operator's LPs (all)
  GET    /api/v1/dca/clients?machine_id=X     — scoped to one machine
  GET    /api/v1/dca/clients/{id}             — single LP
  PUT    /api/v1/dca/clients/{id}             — update mode/autoforward/etc
  DELETE /api/v1/dca/clients/{id}             — delete
  GET    /api/v1/dca/clients/{id}/balance     — fiat balance summary

Ownership transitively checked via the LP's machine — operators can
only see/modify LPs at machines they own. New _machine_owned_by and
_client_owned_by helpers consolidate the 404-not-403 ownership pattern.

Refs: aiolabs/satmachineadmin#9

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:35:15 +02:00
56be3e5c52 feat(v2): settlement distribution — three leg groups, super-fee write (P2)
After a settlement lands (P1a), this commit pays out the three leg
groups via LNbits internal transfers (create_invoice + pay_invoice with
internal=True). Wired synchronously from the invoice listener — latency
is one bitSpire-tx wide. process_settlement is idempotent (status guard)
so retries are safe.

distribution.py — three leg groups, in order:

  1. super_fee leg:
       platform_fee_sats → super_fee_wallet_id (if set)
       skip + warn if super fee % > 0 but wallet not configured
  2. operator_split legs:
       operator_fee_sats sliced per the operator's commission_splits
       ruleset (per-machine override or operator default)
       skip + warn if operator has no ruleset configured
  3. dca legs:
       net_sats distributed proportionally to active flow-mode LPs at
       this machine, each capped at the LP's remaining-fiat-balance-
       in-sats (preserves the v1 sync-mismatch fix from PR #2)
       skip if exchange_rate=0 (fallback path with missing rate)

Every leg lands a dca_payments row with the leg_type discriminator and
inherits Payment.tag "satmachine:{machine_npub}" so LNbits payment-
history filters work natively across machines + operators.

Atomicity model: LN payments cannot be rolled back. Each leg is
attempted independently; success/fail recorded on the dca_payments row.
The settlement is marked 'processed' only when every leg completed; any
failure marks 'errored' with a concatenated message but leaves successful
legs in place. Sats that don't pay out (failed legs, missing super
wallet, no commission ruleset, no LP coverage) remain in the machine's
wallet — visible to the operator on the dashboard.

calculations.py — extracted two pure helpers:

  split_two_stage_commission(commission_sats, super_fee_pct)
    Stage-1: super takes super_fee_pct (rounded); operator absorbs the
    rounding remainder so platform + operator == commission_sats exactly.

  allocate_operator_split_legs(operator_fee_sats, leg_pcts)
    Stage-2: distributes the remainder across N legs per pct rules. Last
    leg absorbs the rounding remainder so sum(legs) == operator_fee_sats.

50 new tests cover the plan's verification scenario:
  100 sats commission, super=30%, operator splits 50/30/20
  → super 30, operator 35/21/14. Sum 100 ✓
plus all the edge cases the plan called out (super=0, super=100,
single-leg, zero-fee, parametrised invariant on sums).

views_api.py adds the super-only platform-fee write endpoint:
  PUT /api/v1/dca/super-config  (check_super_user)

This is the only super-only endpoint in v2 — sets super_fee_pct and the
destination wallet for collecting the fee.

72/72 tests pass (22 calculation + 50 two-stage-split). 13 routes
registered against LNbits 1.4 (nostr-transport).

Refs: aiolabs/satmachineadmin#9

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:34:07 +02:00
10b79ae900 feat(v2): operator-scoped API surface — machines, settlements, payments (P1b)
Replaces the views_api.py stub with the v1 operator-scoped REST surface
needed for the P1 frontend tasks (machine onboarding by npub, settlement
review, payment-leg audit). All endpoints filter on the authenticated
user's id so two operators on the same LNbits instance can never see
each other's data.

Endpoints (12 routes):

Machines (CRUD):
  POST   /api/v1/dca/machines                  — add by npub + wallet_id
  GET    /api/v1/dca/machines                  — operator's fleet
  GET    /api/v1/dca/machines/{id}             — single (ownership check)
  PUT    /api/v1/dca/machines/{id}             — update (ownership check)
  DELETE /api/v1/dca/machines/{id}             — delete (ownership check)

Settlements (read-only at this phase):
  GET    /api/v1/dca/settlements               — operator-wide
  GET    /api/v1/dca/machines/{id}/settlements — per machine
  GET    /api/v1/dca/settlements/{id}          — single (ownership check)

Payments (leg-typed audit):
  GET    /api/v1/dca/payments?leg_type=…       — operator's payment legs

Super config (read-only here):
  GET    /api/v1/dca/super-config              — operators read the
                                                  platform fee they pay

Catch-all:
  /api/v1/dca/{...} → 503 with a precise message for not-yet-implemented
  endpoints (clients, deposits, commission splits, partial-tx,
  balance-settle, super-config write — all P2+).

All ownership checks live at the API boundary: if the route's resource
points to a machine the operator doesn't own, we 404 (not 403) so
operators can't probe for the existence of other operators' machines.

Verified routes register cleanly against LNbits 1.4 (nostr-transport).
22/22 calculation tests still green.

Refs: aiolabs/satmachineadmin#9

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:50:07 +02:00
937749f149 feat(v2): operator-scoped CRUD + stub legacy entry points
Replaces v1's super-only single-config CRUD with the v2 operator-scoped data
layer that matches the m005 schema:

- Machines: create/get/get_by_npub/list_for_operator/update/delete
- Clients: scoped per (machine, user). Adds list_for_operator (across an
  operator's fleet) and list_for_user (LP cross-operator view), plus
  get_flow_mode_clients_for_machine for the distribution algorithm.
- Deposits: now carry machine_id and creator_user_id; per-operator listing.
- Settlements: create_settlement_idempotent treats bitspire_event_id as the
  uniqueness key, returning the existing row on replay so subscription
  re-delivery is safe by construction. mark_settlement_status drives the
  pending → processed/partial/refunded/errored lifecycle.
- Commission splits: replace_commission_splits is an atomic per-scope
  replace; the SetCommissionSplitsData model already validates legs sum
  to 1.0 at the boundary. get_effective_commission_splits handles the
  per-machine-override-or-operator-default precedence.
- Payments: leg-typed (dca / super_fee / operator_split / settlement /
  autoforward / refund) with helpers for settlement/client/operator scopes.
- Balance summary: sums confirmed deposits minus completed dca legs.
- Telemetry: upsert_beacon_snapshot uses COALESCE so today's sparse
  kind-30078 payload doesn't clobber post-#43 fields when they start
  arriving. upsert_fleet_snapshot stores raw JSON until lamassu-next#42
  fixes the kind-30079 schema.
- Super config: singleton get/update.

Also stubs three legacy entry points so __init__.py imports cleanly while
the rest of P0/P1 is in flight:

- tasks.py: no-op stubs for wait_for_paid_invoices + hourly_transaction_polling.
  Real Nostr subscription manager lands in P1.
- views_api.py: a single /api/v1/dca/{...} catch-all returns 503 with a
  precise message. v2 endpoints land in P1+.
- views.py: drops the super-only check on the index page (v2 is
  operator-installable); platform-fee config moves to a super-only API in P1.

transaction_processor.py is left untouched but is now orphaned (no one
imports it) — gets a full rewrite in P1.

Refs: plan at ~/.claude/plans/snug-gliding-shamir.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:37:48 +02:00
28241e70c3 feat: add deposit edit and delete for pending deposits
Add PUT /api/v1/dca/deposits/{id} endpoint to update amount, currency,
and notes on pending deposits. Add DELETE endpoint to remove deposits
not yet inserted into the machine. Both endpoints reject confirmed
deposits. Frontend now shows edit/delete buttons only for pending rows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 16:00:04 +02:00
1b7374fa70 Removes test transaction UI button
Removes the test transaction button from the admin UI.

The test transaction endpoint is still available in the API for development and debugging purposes.
2025-11-03 22:23:10 +01:00
fe38e08d4e Adds manual transaction processing feature
Implements functionality to manually process specific Lamassu transactions by ID, bypassing dispense checks.

This allows administrators to handle transactions that may have failed due to dispense issues or were settled manually outside of the automated process.

The feature includes a new UI dialog for entering the transaction ID and an API endpoint to fetch and process the transaction, crediting wallets and distributing funds according to the DCA configuration.
2025-11-03 22:23:08 +01:00
c83ebf43ab 01 Refactor currency handling to store amounts in GTQ: Removed currency conversion utilities, updated models and API endpoints to directly handle GTQ amounts, and modified transaction processing logic for consistency. Enhanced frontend to reflect these changes, ensuring accurate display and submission of GTQ values across the application.
Refactor GTQ storage migration: Moved the conversion logic for centavo amounts to GTQ into a new migration function, m004_convert_to_gtq_storage, ensuring proper data type changes and updates across relevant tables. This enhances clarity and maintains the integrity of the migration process.
2025-07-06 00:13:03 +02:00
aa71321c84 00 Add currency conversion utilities and update models for GTQ handling: Introduced currency conversion functions for GTQ and centavos, updated API and database models to handle GTQ amounts directly, and modified API endpoints to streamline deposit and balance summary responses. Adjusted frontend to reflect changes in currency representation, ensuring consistency across the application. 2025-07-05 23:38:23 +02:00
7af0e47d48 Add max_daily_limit_gtq field to lamassu_config: Updated database schema and models to include max_daily_limit_gtq for configurable client limits. Enhanced API and frontend to support this new field, ensuring proper handling and validation in the user interface. 2025-07-05 16:57:04 +02:00
8871f24cec Refactor DCA API endpoints to use superuser authentication: Updated all relevant DCA-related API endpoints to require check_super_user instead of require_admin_key, enhancing security. Adjusted client-side API calls to remove wallet admin key usage, ensuring session-based superuser authentication is utilized. Updated documentation in CLAUDE.md to reflect these changes. 2025-06-26 13:36:29 +02:00
dfc2dd695c Remove test client creation functionality from admin extension
Client registration will now be handled by the DCA client extension.
The admin extension focuses solely on:
- Reading existing clients
- Managing deposits (pending → confirmed workflow)
- Monitoring DCA activity

Test client creation code preserved in 'feature/test-client-creation' branch.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-26 12:04:55 +02:00
466d2c74e3 Refactor DCA API endpoints to require admin key for access: Updated wallet dependency in multiple DCA-related endpoints to use require_admin_key instead of require_invoice_key, enhancing security and access control. Cleaned up code formatting for improved readability.
Update DCA API calls to use admin key: Changed references from `inkey` to `adminkey` in multiple DCA-related API requests to ensure proper access control and security compliance.
2025-06-22 11:19:18 +02:00
6db94179cc Replace [mM]yextension with [sS]at[mM]achine[aA]dmin 2025-06-20 22:16:58 +02:00
75dd03b15a Refactor MyExtension to DCA Admin: Update extension name and description in config.json, remove legacy MyExtension CRUD operations and related API endpoints, and adjust router tags. Clean up unused files and methods to streamline the codebase for DCA administration functionality.
Refactor DCA Admin page endpoints: Update description, remove unused CRUD operations and API endpoints related to MyExtension, and streamline the code for improved clarity and functionality.

Remove QR Code dialog from MyExtension index.html: streamline the UI by eliminating unused dialog components, enhancing code clarity and maintainability.
2025-06-20 22:00:41 +02:00
1af15b6e26 Add Lamassu transaction endpoints and UI integration: implement API endpoints for retrieving all processed Lamassu transactions and specific transaction details, including distribution information. Enhance frontend to display transaction data in a table format with export functionality and detailed views for distributions, improving user experience and data accessibility. 2025-06-20 01:37:31 +02:00
5f21a27f0e Add test transaction endpoint and UI integration: implement API for simulating transactions with mock data, including commission and discount calculations. Enhance frontend to trigger test transactions and display results in a dialog, improving user experience and testing capabilities. 2025-06-19 17:33:16 +02:00
a107f825af Add last poll tracking to Lamassu configuration: update database schema to include last_poll_time and last_successful_poll fields, modify CRUD operations to record poll times, and enhance transaction processing to utilize these timestamps for improved polling accuracy. 2025-06-18 15:56:55 +02:00
3c6262b309 Refactor transaction processing to utilize SSH for database queries: implement execute_ssh_query method, enhance error handling, and update connection testing steps. Modify API to fetch transactions via SSH configuration, improving security and reliability. 2025-06-18 15:37:00 +02:00
c4ab8c27ef Enhance SSH tunnel functionality in transaction processing: implement subprocess-based SSH tunnel as a fallback, improve error handling, and add detailed connection testing with step-by-step reporting. Update API to utilize new testing method and enhance UI to display detailed results. 2025-06-18 15:33:51 +02:00
1f7999a556 Add Lamassu database configuration: implement CRUD operations, polling tasks, and UI components for managing database settings. Introduce hourly transaction polling and manual poll functionality. 2025-06-18 10:56:05 +02:00
46b7a1450d Add temporary DCA client creation endpoint for testing and enhance quick deposit UI with new form for adding deposits. Clean up code formatting for consistency. 2025-06-17 20:09:53 +02:00
b3332e585a Refactor DCA client management: remove CRUD operations for clients and update UI to focus on deposit management. Introduce quick deposit form for existing clients. 2025-06-17 19:28:53 +02:00
7bafc67370 Add DCA admin extension with CRUD operations for clients and deposits, including UI components for managing deposits and client details. 2025-06-17 19:26:31 +02:00