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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Closesaiolabs/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>
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>
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>
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>
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>
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>
~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>
Completes the P3 operator-UX cluster. Surfaces settlements that didn't
process cleanly as a queryable worklist so operators can investigate +
retry without scanning the full settlement history.
New endpoints:
GET /api/v1/dca/settlements/stuck?threshold_minutes=30
Returns StuckSettlementsResponse with three buckets:
- errored: distribution failed; existing /retry endpoint handles
- stuck_pending: landed but never picked up (listener crashed
before invoking process_settlement)
- stuck_processing: claim taken but no completion in N minutes;
processor crashed mid-flight, processing_claim is set but no
terminal state landed
POST /api/v1/dca/settlements/{id}/force-reset
Operator escape hatch for genuinely stuck settlements. Flips
'pending'/'processing' → 'errored' so the /retry endpoint can take
over. Refuses unless the settlement is older than threshold_minutes
(default 30) so operators can't accidentally interrupt a
slow-but-running settlement. Age check uses created_at as proxy.
CRUD:
- get_stuck_settlements_for_operator(uid, threshold_minutes) joins
dca_settlements → dca_machines and returns the three lists
scoped per operator. No age filter on 'errored' (operators always
want to see those); age filter applies to 'pending'/'processing'.
- force_reset_stuck_settlement(id) UPDATEs 'pending'/'processing' to
'errored', clears processing_claim, sets a marker error_message.
The retry endpoint shipped in fix bundle 1 (commit 3ede66f) is the
intended downstream — operator sees stuck-processing row, hits force-
reset (flips to errored), then hits retry (flips to pending, voids
failed legs, re-runs process_settlement via the claim path).
34 routes registered. 72/72 tests pass.
Refs: aiolabs/satmachineadmin#9 — completes P3 operator-UX cluster
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the HIGH-severity security finding from the v2 branch review:
operator A could register a machine pointing at operator B's wallet_id
(or update their machine to do so), then drain B's wallet via the
settlement processor's pay_invoice call. LNbits' pay_invoice doesn't
enforce caller identity at the backend layer — wallet_id is trusted as
the source-of-truth for the source wallet.
Two-layer defence:
1. **API layer.** New _assert_wallet_owned_by helper in views_api.py
refuses any wallet_id from the request body that doesn't resolve to a
wallet owned by the authenticated operator. Applied on
api_create_machine and api_update_machine. Pattern lifted from the
existing api_settle_client_balance which already did this for
funding_wallet_id (260-265 in the original file).
2. **DB layer.** m007 adds a UNIQUE index on dca_machines.wallet_id —
even if a future endpoint forgets the API check, the DB rejects two
rows claiming the same wallet. CREATE UNIQUE INDEX is portable across
SQLite and PostgreSQL (ALTER TABLE ADD CONSTRAINT is not on SQLite).
Same commit also addresses concurrency findings H1+H2+H3 from the
architectural review (race conditions on process_settlement +
no retry path for errored settlements):
- m007 also adds processing_claim TEXT to dca_settlements.
- crud.claim_settlement_for_processing does optimistic-lock via
UPDATE ... SET status='processing', processing_claim=:token
WHERE id=:id AND status='pending' (portable; no UPDATE...RETURNING).
Read-back compares the token; only one concurrent caller wins.
- crud.reset_settlement_for_retry voids failed legs and flips
'errored' → 'pending' so process_settlement re-runs them. Completed
legs are LEFT IN PLACE — we never re-pay sats that already moved.
- crud.mark_settlement_status clears processing_claim on terminal
states so a fresh claim attempt won't see a stale token.
- distribution.process_settlement now uses the claim instead of the
status-read-and-check pattern. Concurrent listener re-fires +
partial-dispense recomputes can't double-pay legs.
- New endpoint:
POST /api/v1/dca/settlements/{id}/retry (operator-scoped)
Refuses if status != 'errored' (400). Resets, then re-runs
process_settlement via the claim path.
DcaSettlement gains a processing_claim: Optional[str] field. Visible to
operators in settlement detail; stale claims (status='processing' for
many minutes) are a "processor crashed mid-flight" signal — operator
can manually mark errored + retry.
32 routes registered. 72/72 tests pass.
Refs: aiolabs/satmachineadmin#9 — closes the v2-branch security finding
and HIGH-priority concurrency findings from the internal review.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the v1 feature request satmachineadmin#4 (balance settlement for
small remaining LP balances). Operator hits 'Settle' on an LP, specifies
the exchange rate they're willing to honor, and the system pays out the
remaining fiat balance in sats from the operator's chosen funding wallet.
Avoids the Zeno's-paradox of vanishing tiny proportional shares — small
balances no longer drag on forever; they get cleanly zeroed.
New endpoint:
POST /api/v1/dca/clients/{client_id}/settle
body: SettleBalanceData {funding_wallet_id, exchange_rate,
amount_fiat?, notes?}
Flow (distribution.settle_lp_balance):
1. Get LP's remaining balance summary
2. amount_fiat capped at remaining (defaults to full remaining)
3. amount_sats = round(amount_fiat * exchange_rate)
4. Internal transfer funding_wallet → client.wallet via
create_invoice(internal=True) + pay_invoice
5. Records leg_type='settlement' in dca_payments
Two ownership checks at the API boundary: client (via machine→operator)
and funding_wallet_id (via lnbits.core.crud.get_wallet → wallet.user
== current operator). 400 (not 404) if funding wallet isn't owned —
operators can identify their own wallets so leaking existence is fine.
Updated get_client_balance_summary to count both leg_type='dca' AND
leg_type='settlement' completed legs against the LP's remaining
balance. Without this update, settled amounts would leave the LP's
balance unchanged in the summary and re-fire on the next bitSpire tx.
Exchange rate is operator-supplied and required — explicit so there's
no ambiguity about what rate was used. Operator can use exchange spot,
market midpoint, or a favorable rate as a gesture; the rate is recorded
on the dca_payments row alongside amount_fiat for audit.
72/72 tests still pass. 31 routes total.
Refs: aiolabs/satmachineadmin#9, closes#4 (in spirit, marked once
verified end-to-end)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the v1 feature request satmachineadmin#3 (partial transaction
processing) and adds operator-authored audit notes on settlements.
Schema (m006_add_settlement_notes):
ALTER TABLE dca_settlements ADD COLUMN notes TEXT
The notes column is append-only (prepend with timestamp, never edit in
place). Stores both system-generated audit memos (partial-dispense
recompute provenance) and operator-authored free-form notes (cash-
drawer reconciliation context, off-LN refund records, etc.).
Partial-dispense endpoint:
POST /api/v1/dca/settlements/{id}/partial-dispense
body: PartialDispenseData {dispensed_fraction OR dispensed_sats, notes}
Recompute path (in distribution.apply_partial_dispense_and_redistribute):
1. Refuse if any leg has status='completed' (Lightning can't claw back)
2. Resolve new_gross from dispensed_fraction or dispensed_sats
3. Linear-scale net/commission/fiat — preserves the original commission
ratio exactly; only rounding may drift by 1 sat
4. Re-stage-1 split using the CURRENT super_fee_pct (super may have
changed the rate since the original landed)
5. Build a memo capturing original values + reason + new values
6. Void pending/failed legs (status → 'voided')
7. Overwrite the settlement's monetary fields + prepend memo to notes
8. Reset status to 'pending' → process_settlement re-runs distribution
Operator notes endpoint:
POST /api/v1/dca/settlements/{id}/notes
body: AppendSettlementNoteData {note}
Each operator note is timestamped (UTC) and tagged with the author's
user_id so the audit trail is accountable. Non-empty, max 2000 chars.
72/72 tests still pass. 30 routes total. The full-directory ruff number
ballooned to ~500 because it includes legacy transaction_processor.py
(orphaned, not imported anywhere) and other v1 cruft on the branch.
Files I actively maintain are clean.
Note: a richer queryable audit history (filter by author / time range /
action type / etc.) is being tracked as a separate future-work issue.
The notes-column approach here is the v1 audit story; the dedicated
history table will be additive.
Refs: aiolabs/satmachineadmin#9, closes#3 (in spirit, marked
once verified end-to-end)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds 3 operator-scoped endpoints for managing the commission remainder
ruleset:
GET /api/v1/dca/commission-splits
— operator's default ruleset
GET /api/v1/dca/commission-splits?machine_id=X
— per-machine override (just the
override, not the default)
GET /api/v1/dca/commission-splits?machine_id=X&effective=true
— what the settlement processor
actually applies (override if
set, else operator default)
PUT /api/v1/dca/commission-splits — atomic replace; model validator
enforces legs sum to 1.0
DELETE /api/v1/dca/commission-splits — clear default (per-machine
overrides still apply)
DELETE /api/v1/dca/commission-splits?machine_id=X
— clear per-machine override
(falls back to default)
All routes verify operator owns the referenced machine (404 not 403 if
not). The DELETE path bypasses SetCommissionSplitsData's sum-to-1.0
validator by calling replace_commission_splits([]) directly, since an
empty ruleset is the correct "no rules" state — distribution.py logs a
warning and leaves operator_fee_sats in the machine wallet when this
happens.
28 routes registered total. 72/72 tests pass.
Refs: aiolabs/satmachineadmin#9
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds 6 operator-scoped deposit endpoints:
POST /api/v1/dca/deposits — record fiat from an LP
(creator_user_id = the
operator who recorded)
GET /api/v1/dca/deposits — operator's deposits (all)
GET /api/v1/dca/deposits?client_id=X — scoped to one LP
GET /api/v1/dca/deposits/{id} — single
PUT /api/v1/dca/deposits/{id} — edit (pending only)
PUT /api/v1/dca/deposits/{id}/status — confirm/reject
DELETE /api/v1/dca/deposits/{id} — delete (pending only)
Cross-checks (client_id, machine_id) at create to prevent operators
binding deposits across machines incorrectly. Edits + deletes are
restricted to pending status so confirmed deposits become immutable
audit records (consistent with v1's existing behaviour from commit
28241e7).
Refs: aiolabs/satmachineadmin#9
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds 6 operator-scoped LP management endpoints:
POST /api/v1/dca/clients — register LP at a machine
GET /api/v1/dca/clients — operator's LPs (all)
GET /api/v1/dca/clients?machine_id=X — scoped to one machine
GET /api/v1/dca/clients/{id} — single LP
PUT /api/v1/dca/clients/{id} — update mode/autoforward/etc
DELETE /api/v1/dca/clients/{id} — delete
GET /api/v1/dca/clients/{id}/balance — fiat balance summary
Ownership transitively checked via the LP's machine — operators can
only see/modify LPs at machines they own. New _machine_owned_by and
_client_owned_by helpers consolidate the 404-not-403 ownership pattern.
Refs: aiolabs/satmachineadmin#9
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After a settlement lands (P1a), this commit pays out the three leg
groups via LNbits internal transfers (create_invoice + pay_invoice with
internal=True). Wired synchronously from the invoice listener — latency
is one bitSpire-tx wide. process_settlement is idempotent (status guard)
so retries are safe.
distribution.py — three leg groups, in order:
1. super_fee leg:
platform_fee_sats → super_fee_wallet_id (if set)
skip + warn if super fee % > 0 but wallet not configured
2. operator_split legs:
operator_fee_sats sliced per the operator's commission_splits
ruleset (per-machine override or operator default)
skip + warn if operator has no ruleset configured
3. dca legs:
net_sats distributed proportionally to active flow-mode LPs at
this machine, each capped at the LP's remaining-fiat-balance-
in-sats (preserves the v1 sync-mismatch fix from PR #2)
skip if exchange_rate=0 (fallback path with missing rate)
Every leg lands a dca_payments row with the leg_type discriminator and
inherits Payment.tag "satmachine:{machine_npub}" so LNbits payment-
history filters work natively across machines + operators.
Atomicity model: LN payments cannot be rolled back. Each leg is
attempted independently; success/fail recorded on the dca_payments row.
The settlement is marked 'processed' only when every leg completed; any
failure marks 'errored' with a concatenated message but leaves successful
legs in place. Sats that don't pay out (failed legs, missing super
wallet, no commission ruleset, no LP coverage) remain in the machine's
wallet — visible to the operator on the dashboard.
calculations.py — extracted two pure helpers:
split_two_stage_commission(commission_sats, super_fee_pct)
Stage-1: super takes super_fee_pct (rounded); operator absorbs the
rounding remainder so platform + operator == commission_sats exactly.
allocate_operator_split_legs(operator_fee_sats, leg_pcts)
Stage-2: distributes the remainder across N legs per pct rules. Last
leg absorbs the rounding remainder so sum(legs) == operator_fee_sats.
50 new tests cover the plan's verification scenario:
100 sats commission, super=30%, operator splits 50/30/20
→ super 30, operator 35/21/14. Sum 100 ✓
plus all the edge cases the plan called out (super=0, super=100,
single-leg, zero-fee, parametrised invariant on sums).
views_api.py adds the super-only platform-fee write endpoint:
PUT /api/v1/dca/super-config (check_super_user)
This is the only super-only endpoint in v2 — sets super_fee_pct and the
destination wallet for collecting the fee.
72/72 tests pass (22 calculation + 50 two-stage-split). 13 routes
registered against LNbits 1.4 (nostr-transport).
Refs: aiolabs/satmachineadmin#9
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the views_api.py stub with the v1 operator-scoped REST surface
needed for the P1 frontend tasks (machine onboarding by npub, settlement
review, payment-leg audit). All endpoints filter on the authenticated
user's id so two operators on the same LNbits instance can never see
each other's data.
Endpoints (12 routes):
Machines (CRUD):
POST /api/v1/dca/machines — add by npub + wallet_id
GET /api/v1/dca/machines — operator's fleet
GET /api/v1/dca/machines/{id} — single (ownership check)
PUT /api/v1/dca/machines/{id} — update (ownership check)
DELETE /api/v1/dca/machines/{id} — delete (ownership check)
Settlements (read-only at this phase):
GET /api/v1/dca/settlements — operator-wide
GET /api/v1/dca/machines/{id}/settlements — per machine
GET /api/v1/dca/settlements/{id} — single (ownership check)
Payments (leg-typed audit):
GET /api/v1/dca/payments?leg_type=… — operator's payment legs
Super config (read-only here):
GET /api/v1/dca/super-config — operators read the
platform fee they pay
Catch-all:
/api/v1/dca/{...} → 503 with a precise message for not-yet-implemented
endpoints (clients, deposits, commission splits, partial-tx,
balance-settle, super-config write — all P2+).
All ownership checks live at the API boundary: if the route's resource
points to a machine the operator doesn't own, we 404 (not 403) so
operators can't probe for the existence of other operators' machines.
Verified routes register cleanly against LNbits 1.4 (nostr-transport).
22/22 calculation tests still green.
Refs: aiolabs/satmachineadmin#9
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces 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>
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>
Removes the test transaction button from the admin UI.
The test transaction endpoint is still available in the API for development and debugging purposes.
Implements functionality to manually process specific Lamassu transactions by ID, bypassing dispense checks.
This allows administrators to handle transactions that may have failed due to dispense issues or were settled manually outside of the automated process.
The feature includes a new UI dialog for entering the transaction ID and an API endpoint to fetch and process the transaction, crediting wallets and distributing funds according to the DCA configuration.
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.
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>
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.
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.