Compare commits

...

29 commits

Author SHA1 Message Date
a5ab02e4b6 Merge pull request 'fix(pairing): default bunker_relay to the spire's public event relay, not localhost' (#35) from fix/pair-bunker-relay-default into main
Some checks failed
ci.yml / Merge pull request 'fix(pairing): default bunker_relay to the spire's public event relay, not localhost' (#35) from fix/pair-bunker-relay-default into main (push) Failing after 0s
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
Reviewed-on: #35
2026-06-22 15:21:12 +00:00
b55fc8bc1c fix(pairing): default bunker_relay to the spire's public event relay, not localhost
Some checks failed
ci.yml / fix(pairing): default bunker_relay to the spire's public event relay, not localhost (pull_request) Failing after 0s
The seed minted via the Pair UI baked an unreachable bunker relay into
bunker_url. The UI form has no bunker_relay field, so pair_spire fell back to
its default `settings.lnbits_nsec_bunker_url` — which on a deployed instance is
the INTERNAL relay lnbits uses to reach the co-located bunker (e.g.
ws://127.0.0.1:5000/nostrrelay/demo). The remote ATM can't reach localhost, so
connectNewSeed hangs -> BunkerTimeoutError "Signer Unreachable". (Flagged by
bitspire on the demo; the localhost-relay /pair gotcha the coord thread called out.)

Default bunker_relay to the spire's own public event relay (relays[0]) instead:
the bunker lives on the same operator nostrrelay the spire publishes its events
to, so that URL is machine-reachable. An explicit `bunker_relay` still overrides
for split-relay deploys. An empty override now falls back to the same default
rather than raising.

Regression test: with no (or empty) bunker_relay, bunker_url embeds relays[0]
and contains no 127.0.0.1.

NOTE: relays[0] is a pragmatic default; whether the seed should carry multiple
relays / be sourced from the operator's nostrclient relay is a follow-up.
2026-06-22 17:18:24 +02:00
d0d20b0f94 Merge pull request 'fix: guard every machine_npub deref against unpaired machines (500 + cassette-consumer crash)' (#33) from fix/unpaired-machine-npub-guards into main
Some checks failed
ci.yml / Merge pull request 'fix: guard every machine_npub deref against unpaired machines (500 + cassette-consumer crash)' (#33) from fix/unpaired-machine-npub-guards into main (push) Failing after 0s
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
Reviewed-on: #33
2026-06-22 14:58:03 +00:00
8dad72a00d fix: complete the unpaired-machine sweep + regression test
Some checks failed
ci.yml / fix: complete the unpaired-machine sweep + regression test (pull_request) Failing after 0s
Full sweep of every machine_npub deref found one more reachable crash:
_record_rejected (tasks.py) logs machine_npub[:12], and the
assert_nostr_attribution guard now routes an unpaired machine there, so
None[:12] -> TypeError. Fall back to machine.id.

Every other deref is safe by the attribution-gate invariant: a settlement only
flows past assert_nostr_attribution (now rejecting unpaired) for a paired
machine, so the downstream distribution / parse-path / "landed" logs can't see
None; the collision-loop display already uses `(m.machine_npub or m.id)`.

Adds tests/test_unpaired_machine_guards.py: attribution rejects an unpaired
machine with the domain SettlementAttributionError (not AttributeError), and
build_state_d_tags skips it. New tests + every guard-affected suite pass.

(Two pre-existing test_pair_endpoint failures — #29 drift: fake_pair lacks
bunker_relay, and the test DB lacks super_config — are out of scope; filed
separately.)
2026-06-22 16:55:33 +02:00
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
622c1be5d3 Merge pull request 'feat(cash-in): secure create_withdraw nostr-transport RPC (#31)' (#32) from feat/secure-cashin-rpc into main
Some checks failed
ci.yml / Merge pull request 'feat(cash-in): secure `create_withdraw` nostr-transport RPC (#31)' (#32) from feat/secure-cashin-rpc into main (push) Failing after 0s
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
Reviewed-on: #32
2026-06-22 13:54:43 +00:00
f67cb49bc3 fix(cash-in): return bech32 LNURL, not the raw URL
Some checks failed
ci.yml / fix(cash-in): return bech32 LNURL, not the raw URL (pull_request) Failing after 0s
`Lnurl.__str__` is the underlying URL, so `str(lnurl)` returned
`http://<baseurl>/withdraw/...` instead of the bech32 `LNURL1…` — wallets
need the encoded LNURL-withdraw (lud01). Use `str(lnurl.bech32)` and add
`lnurl_url` (the raw URL) alongside, mirroring withdraw's _populate_lnurl
field convention. (Note: the encoded URL still derives from LNBITS_BASEURL —
that must be an externally reachable https URL for a real wallet to claim.)
2026-06-22 15:32:44 +02:00
9abf695fd5 feat(cash-in): super_config.max_cash_in_sats per-tx cap + UI (#31)
Some checks failed
ci.yml / feat(cash-in): super_config.max_cash_in_sats per-tx cap + UI (#31) (pull_request) Failing after 0s
Wires the server-side per-transaction cash-in ceiling the `create_withdraw`
handler already enforces (it read the value defensively via getattr; this
makes it a first-class config field).

- migrations.py m012: ADD COLUMN super_config.max_cash_in_sats INTEGER (NULL
  = no cap).
- models.py: SuperConfig.max_cash_in_sats + UpdateSuperConfigData field with a
  >= 0 validator.
- super-fee dialog: a "Max cash-in per transaction (sats)" input; blank sends
  null (the PUT skips null, preserving the current value — set 0 to reject
  every cash-in). crud `update_super_config` and the PUT endpoint flow the
  field through automatically (dynamic dict update; check_super_user gated).

Why a sats cap and not the bunker ACL: the ACL / usage caps (#28) gate call
*rate*, not *sats*, and `principal_sats` is necessarily ATM-attested — so a
single in-rate call could request an arbitrarily large payout. This bounds a
compromised/buggy machine to one capped transaction.

Verified on the dev stack: m012 runs, the model round-trips the column
(GET returns the set value), and a negative value is rejected.
2026-06-22 12:51:59 +02:00
607b71e796 feat(cash-in): secure create_withdraw nostr-transport RPC (#31)
Some checks failed
ci.yml / feat(cash-in): secure `create_withdraw` nostr-transport RPC (#31) (pull_request) Failing after 0s
Adds a server-side cash-in RPC so the ATM no longer supplies the withdraw
amount, fee, or attribution. The ATM sends a bunker-signed kind-21000
`create_withdraw` with just the gross `principal_sats` (the hardware-
attested fiat value); the handler derives everything else SERVER-SIDE:

- attribution = the VERIFIED transport `sender_pubkey` (never read from the
  body), matched to an active machine on the authenticated wallet;
- fee = round(principal × super_cash_in) + round(principal × operator_cash_in),
  per-leg rounding so it matches parse_settlement exactly (fee_mismatch=0);
- net = principal − fee → the withdraw amount the customer receives;
- stamps `extra={source:bitspire, type:cash_in, principal_sats, fee_sats,
  nostr_sender_pubkey:<verified>, nostr_event_id}` onto the link.

The customer claims the NET link; the payout carries the stamped extra
(aiolabs/withdraw#3) and `_handle_payment` records the cash_in settlement
(spirekeeper#30) with cryptographic attribution — closing the vector where
`lnurlw_create_link` let the ATM set amount/fee/attribution freely.

Registered via `register_rpc("create_withdraw", …, AUTH_WALLET)` (extensions
register RPCs directly — withdraw already does). Soft-fails on lnbits without
`register_rpc`. Per-tx cap reads `super_config.max_cash_in_sats` defensively
(getattr) — the config field/UI is a fast-follow.

Wire schema pinned in #31. Depends on #30 (consumer-side settlement fix).
2026-06-22 12:21:23 +02:00
56ac4a69e9 Merge pull request 'fix(settlements): process cash-in (outbound) payments, not just cash-out' (#30) from fix/cash-in-settlement into main
Some checks failed
ci.yml / Merge pull request 'fix(settlements): process cash-in (outbound) payments, not just cash-out' (#30) from fix/cash-in-settlement into main (push) Failing after 0s
Reviewed-on: #30
2026-06-22 10:19:44 +00:00
7b55dc152b fix(settlements): process cash-in (outbound) payments, not just cash-out
Some checks failed
ci.yml / fix(settlements): process cash-in (outbound) payments, not just cash-out (pull_request) Failing after 0s
The `_handle_payment` cash-in branch existed but had never been exercised
end-to-end — bitSpire cash-in payouts only started reaching it once the
withdraw extension learned to stamp `source=bitspire` on an LNURL-withdraw
payout (aiolabs/withdraw#3). With that wired up, the first real cash-in
exposed two bugs:

1. `payment.sat` is signed by protocol direction — negative for an
   outbound (cash-in) payout. It was passed straight to `parse_settlement`
   as `wire_sats`, which enforces `wire_sats >= 0`, so every cash-in was
   rejected ("wire_sats must be >= 0, got -75795"). A settlement's
   `wire_sats` is a magnitude (direction lives in `tx_type`); pass
   `abs(payment.sat)`. Same in `_record_rejected`.

2. `_record_rejected` hard-coded `tx_type="cash_out"`, so a rejected
   cash-in showed the wrong direction in the operator dashboard. The
   parsed tx_type isn't available on the rejection path, but the
   authenticated protocol direction is — derive it: outbound → cash_in,
   inbound → cash_out.

Verified on the dev stack: a stamped cash-in now lands a `cash_in`
settlement (net 75795, principal 82386, fee 6591), pays the super its 3%
(2472 sats), and correctly skips the DCA leg (principal stays in the
operator's wallet as liquidity from the cash-in customer).
2026-06-21 17:27:58 +02:00
490605333c Merge pull request 'feat(pairing,ui): optional machine_npub + bunker_relay override + fee decimal-input UX' (#29) from feat/optional-machine-npub-pairing-ux into main
Some checks failed
ci.yml / Merge pull request 'feat(pairing,ui): optional machine_npub + bunker_relay override + fee decimal-input UX' (#29) from feat/optional-machine-npub-pairing-ux into main (push) Failing after 0s
Reviewed-on: #29
2026-06-21 13:41:57 +00: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
47b7efc53c Merge pull request 'docs(pairing): TTL + token-revoke now enforced post-bind (nsecbunkerd#27)' (#28) from docs/ttl-revoke-enforced-post-bind into main
Some checks failed
ci.yml / Merge pull request 'docs(pairing): TTL + token-revoke now enforced post-bind (nsecbunkerd#27)' (#28) from docs/ttl-revoke-enforced-post-bind into main (push) Failing after 0s
Reviewed-on: #28
2026-06-20 10:05:36 +00:00
b193f6262d docs(pairing): TTL + token-revoke now enforced post-bind (nsecbunkerd#27)
Some checks failed
ci.yml / docs(pairing): TTL + token-revoke now enforced post-bind (nsecbunkerd#27) (pull_request) Failing after 0s
nsecbunkerd#27 (deployed 2026-06-19) reverses the #24 finding: the
sign-time ACL now evaluates token lifecycle live on every request
(checkIfPubkeyAllowed step 4 joins through a liveWhere filter;
applyToken stopped photocopying grants into SigningConditions). So:

- duration_hours / token expiresAt now bounds an ESTABLISHED binding —
  an expired token stops signing post-bind, not just at connect. The
  prior docstring (connect-window-only, pointing at the now-closed
  nsecbunkerd#24) is corrected.
- Token-revoke is no longer a post-redeem no-op (closes the #22
  mechanism bunker-side). revoke_spire keeps using revoke_key_user
  because that's the subject-level ban cutting the whole binding, not
  just one token's grant — rationale updated, behavior unchanged.

Doc/comment only; 20 pairing tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 23:23:11 +02:00
cc8d786331 Merge pull request 'fix(fleet-ui): pair-dialog v-else compiler error + TTL docstring correction' (#27) from fix/pair-dialog-and-ttl-doc into main
Some checks failed
ci.yml / Merge pull request 'fix(fleet-ui): pair-dialog v-else compiler error + TTL docstring correction' (#27) from fix/pair-dialog-and-ttl-doc into main (push) Failing after 0s
Reviewed-on: #27
2026-06-18 23:01:15 +00:00
554b2e2e17 docs(pairing): correct duration_hours TTL docstring
Some checks failed
ci.yml / docs(pairing): correct duration_hours TTL docstring (pull_request) Failing after 0s
duration_hours stamps Token.expiresAt, but nsecbunkerd reads expiresAt
only in validateToken at connect/redeem time — the sign-time ACL never
checks it (materialised SigningConditions carry no expiry; the policy
join filters revokedAt only). So TTL bounds only the un-redeemed connect
window, not an established binding; revoke_key_user is the real post-bind
cutoff. Same ACL-ordering class as the revoke finding (#22). Tracked at
aiolabs/nsecbunkerd#24.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 00:59:29 +02:00
76090ab5da fix(fleet-ui): wrap pair-dialog steps in template v-if/v-else
The Pair dialog had two interleaved v-if/v-else sibling pairs
(q-card-section + q-card-actions per step). Vue requires v-else to
immediately follow its v-if sibling, so the second v-else (actions)
trailed a v-else (section) — illegal, throwing compiler error 30
("v-else has no adjacent v-if") and breaking the entire Vue mount.
Wrap each step's section+actions in one <template v-if> / <template
v-else> so there's exactly one adjacent pair. Verified with
@vue/compiler-dom and a live pair/revoke round-trip against regtest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 00:59:29 +02:00
22678dfb4f feat(pairing): authorize kind-22242 (NIP-42 AUTH) in spire policy (#52)
Some checks failed
ci.yml / feat(pairing): authorize kind-22242 (NIP-42 AUTH) in spire policy (#52) (pull_request) Failing after 0s
ci.yml / feat(pairing): authorize kind-22242 (NIP-42 AUTH) in spire policy (#52) (push) Failing after 0s
bitspire#52 consumer review (2026-06-18) enumerated the kinds the spire
signs as its OWN identity and found NIP-42 relay AUTH (kind 22242) missing
from SPIRE_POLICY_RULES — a silent bunker reject the moment a relay
challenges with AUTH. It must be bunker-signed (AUTH proves control of
spire_pubkey, which only the bunker holds; can't use the local client_nsec).

Adds 22242. Records the confirmed set in the policy comment: live = 21000 +
30078 + 22242; CLINK 21001-21003 dormant but kept; nip04 unused (v1 path is
dead code). New test locks the required-kinds contract so 22242 can't
silently regress.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:29:43 +00:00
a18f653ca7 feat(ui): Fleet Pair / Revoke spire UI (#9/#12)
Some checks failed
ci.yml / feat(ui): Fleet Pair / Revoke spire UI (#9/#12) (push) Failing after 0s
Operator-facing front for POST /machines/{id}/pair + /revoke (#21/#23):
  - Pairing chip per machine row (paired / not-paired + paired-at tooltip).
  - 'Pair' (qr_code_2) opens a dialog -> relays + optional duration_hours
    -> POST /pair -> renders the seed_url as <lnbits-qrcode> + copy, shows
    the bunker-minted spire npub. Re-pair relabels.
  - 'Revoke' (link_off, shown when paired) -> confirm -> POST /revoke ->
    updates the row, reports revoked_count (>=1 cut / 0 never-bound).
  - Row reflects the bunker-minted identity immediately (machine_npub <-
    spire_pubkey_hex, paired_at).

Quasar-UMD conventions: explicit close tags, ${ } delimiters, :style.
JS syntax-checked, conforms to .prettierrc; 210 backend tests unaffected.
Needs a manual browser smoke (superuser-gated page).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:29:33 +00:00
846d5d9d0f Merge pull request 'feat(pairing): optional token TTL + revoke endpoint (#9/#12, #22)' (#23) from feat/pairing-revoke-ttl into main
Some checks failed
ci.yml / Merge pull request 'feat(pairing): optional token TTL + revoke endpoint (#9/#12, #22)' (#23) from feat/pairing-revoke-ttl into main (push) Failing after 0s
Reviewed-on: #23
2026-06-18 17:09:01 +00:00
4db5c3de4e Merge branch 'main' into feat/pairing-revoke-ttl
Some checks failed
ci.yml / Merge branch 'main' into feat/pairing-revoke-ttl (pull_request) Failing after 0s
2026-06-18 17:08:44 +00:00
32959d1533 Merge pull request 'feat(pairing): seed-URL pairing — operator-side producer (S0 / #9)' (#21) from feat/seed-url-pairing into main
Some checks failed
ci.yml / Merge pull request 'feat(pairing): seed-URL pairing — operator-side producer (S0 / #9)' (#21) from feat/seed-url-pairing into main (push) Failing after 0s
Reviewed-on: #21
2026-06-18 17:03:34 +00: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
9c5f07c72e refactor(pairing): use lnbits' public ensure_policy, drop fork duplicate (#9)
Some checks failed
ci.yml / refactor(pairing): use lnbits' public ensure_policy, drop fork duplicate (#9) (pull_request) Failing after 0s
Adopts aiolabs/lnbits#55 (merged b5fba561): pair_spire now calls the
public ensure_policy(client, name='spirekeeper-spire', rules=...,
methods_no_kind=...) instead of spirekeeper's cache-free
_ensure_spire_policy copy. #55 re-keyed _POLICY_ID_CACHE on
(admin_pubkey, policy_name), so the shared helper no longer returns the
wrong (lnbits-default) id for a non-default policy name — the exact
reason the duplicate existed. Net -45 LOC, one less fork-divergent
reimplementation to keep in sync.

Requires lnbits >= the #55 merge (ensure_policy importable) — already
true on dev/demo.

Tests: FakeBunker gains admin_pubkey; an autouse fixture clears lnbits'
_POLICY_ID_CACHE between tests (the shared helper caches, unlike the old
local one). 203 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:31:21 +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
a77f5bcb5c feat(pairing): bunker pairing service — mint per-spire key + seed URL (#9)
Operator-side producer for seed-URL pairing (S0/#9, model A1). pair_spire()
orchestrates the nsecbunkerd admin chain via lnbits' NsecBunkerAdminClient:
  create_new_key(spire-<id>) -> _ensure_spire_policy -> create_new_token ->
  get_key_tokens -> package the <npub>#secret token into a bunker:// URL +
  base64url seed URL {spire_npub, spire_pubkey, bunker_url, relays}.

The spire later self-signs all its events as that bunker-held key; lnbits'
path-B roster maps the npub to the operator wallet — no nsec on the spire.
spirekeeper does steps 1-4 only; the NIP-46 connect/bind happens spire-side
(bitspire#52) with the spire's own client keypair.

Scoped policy 'spirekeeper-spire': sign_event 21000/21001-3/30078 + nip44
(kind-less via add_policy_rule). Local _ensure_spire_policy (no cache)
avoids lnbits' admin-pubkey-keyed _ensure_policy cache (policy-name-blind).

9 unit tests with a fake bunker (orchestration, policy reconcile, seed/
bunker:// wire shape, error paths); npub<->hex via lnbits' real helpers.
200 tests green.

Known gaps (lnbits NsecBunkerAdminClient): no token-expiry param, no revoke
RPC — re-pair works; 'revoke spire access' deferred to a bunker follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 23:17:48 +02:00
bb473f5385 feat(pairing): m010 schema — bunker pairing columns on dca_machines
Schema checkpoint for seed-URL pairing (S0 / #9; spire-side bitspire#52),
model A1 — the spire's signing key lives in the operator's nsecbunkerd,
not on the spire's disk.

dca_machines gains:
  - bunker_spire_key_name — the spire's key name in the bunker
    (spire-<machine_id>); used to re-issue connect tokens on re-pair.
  - paired_at — last successful pair; NULL = never paired.

Both nullable, idempotent column-probe add (m009 pattern). Machine model
gains the matching optional fields. Validated on the regtest dev db
(columns present, migrations clean); 191 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:47:42 +02:00
c651d53419 assets: replace extension logo with AIO mark (256x256)
Some checks failed
ci.yml / assets: replace extension logo with AIO mark (256x256) (push) Failing after 0s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 18:10:29 +02:00
16 changed files with 1641 additions and 40 deletions

View file

@ -4,6 +4,7 @@ from fastapi import APIRouter
from lnbits.tasks import create_permanent_unique_task from lnbits.tasks import create_permanent_unique_task
from loguru import logger from loguru import logger
from .cashin_transport import register_create_withdraw_rpc
from .crud import db from .crud import db
from .nostr_transport_roster import register_with_lnbits as register_roster_with_lnbits from .nostr_transport_roster import register_with_lnbits as register_roster_with_lnbits
from .tasks import wait_for_cassette_state_events, wait_for_paid_invoices from .tasks import wait_for_cassette_state_events, wait_for_paid_invoices
@ -13,9 +14,7 @@ from .views_api import spirekeeper_api_router
logger.info("spirekeeper v2 loaded") logger.info("spirekeeper v2 loaded")
spirekeeper_ext: APIRouter = APIRouter( spirekeeper_ext: APIRouter = APIRouter(prefix="/spirekeeper", tags=["DCA Admin"])
prefix="/spirekeeper", tags=["DCA Admin"]
)
spirekeeper_ext.include_router(spirekeeper_generic_router) spirekeeper_ext.include_router(spirekeeper_generic_router)
spirekeeper_ext.include_router(spirekeeper_api_router) spirekeeper_ext.include_router(spirekeeper_api_router)
@ -57,6 +56,12 @@ def spirekeeper_start():
# wallet, not an auto-created machine wallet. Soft-fails on lnbits # wallet, not an auto-created machine wallet. Soft-fails on lnbits
# versions that don't yet expose `register_roster_resolver`. # versions that don't yet expose `register_roster_resolver`.
register_roster_with_lnbits() register_roster_with_lnbits()
# Secure cash-in (#31): register the `create_withdraw` nostr-transport RPC
# so the ATM requests a server-priced, server-stamped cash-in withdraw link
# over the bunker-signed transport — amount/fee/attribution derived
# server-side, never client-supplied. Soft-fails if `register_rpc` isn't
# exposed by this lnbits.
register_create_withdraw_rpc()
__all__ = [ __all__ = [

View file

@ -126,6 +126,14 @@ def assert_nostr_attribution(machine: Machine, extra: dict) -> None:
"missing nostr_sender_pubkey on Payment.extra — invoice was not " "missing nostr_sender_pubkey on Payment.extra — invoice was not "
"issued through the nostr-transport path" "issued through the nostr-transport path"
) )
if not machine.machine_npub:
# Unpaired machine (machine_npub None — nullable since #29/m011). It has
# no identity to attribute a settlement to; reject cleanly rather than
# let normalize_public_key(None) raise an uncaught AttributeError.
raise SettlementAttributionError(
f"machine {machine.id} is unpaired (no machine_npub); "
"a settlement cannot be attributed to it"
)
from lnbits.utils.nostr import normalize_public_key from lnbits.utils.nostr import normalize_public_key
try: try:

135
cashin_transport.py Normal file
View file

@ -0,0 +1,135 @@
"""
Secure cash-in: a `create_withdraw` nostr-transport RPC (aiolabs/spirekeeper#31).
Mirrors withdraw's `lnurlw_create_link`, but cash-in-semantic. The ATM sends the
gross `principal_sats` (the hardware-attested fiat value) over the bunker-signed
kind-21000 transport; the operator side derives the fee, the NET withdraw
amount, and the attribution **server-side** none of it client-supplied. The
customer claims the NET link; the payout carries the stamped `extra`
(aiolabs/withdraw#3) so `_handle_payment` records the `cash_in` settlement with
cryptographic attribution (the verified transport sender), exactly like the
cash-out path.
Why this exists: `lnurlw_create_link` takes `min/max_withdrawable` and `extra`
straight from the client body, so an authenticated-but-malicious/buggy ATM could
set the gross amount (no fee), forge `nostr_sender_pubkey`, or request an
arbitrary amount. This RPC closes that by computing everything server-side.
"""
from __future__ import annotations
from loguru import logger
from .crud import get_machine_by_atm_pubkey_hex, get_super_config
_RPC_NAME = "create_withdraw"
async def handle_create_withdraw(auth, request) -> dict:
"""nostr-transport RPC handler. `auth` is a WalletTypeInfo (the operator
wallet, roster-resolved from the verified sender); `request` is a
NostrRpcRequest with `body`, `sender_pubkey` (verified), and `event_id`."""
# Import withdraw lazily so registration never hard-depends on the withdraw
# extension being importable at startup; a missing dep fails the request,
# not the daemon.
try:
from withdraw.crud import create_withdraw_link
from withdraw.helpers import create_lnurl_from_baseurl
from withdraw.models import CreateWithdrawData
except ImportError as exc:
raise ValueError(
"withdraw extension unavailable; cannot mint a cash-in link"
) from exc
body = request.body or {}
principal_sats = body.get("principal_sats")
if not isinstance(principal_sats, int) or principal_sats <= 0:
raise ValueError("principal_sats must be a positive integer")
# Attribution is the VERIFIED transport sender — never read it from the body.
sender = (request.sender_pubkey or "").lower()
if not sender:
raise ValueError("missing verified sender_pubkey")
machine = await get_machine_by_atm_pubkey_hex(sender)
if machine is None:
raise ValueError("no active machine for this signer")
# Defence in depth: the roster already resolved `auth.wallet` from `sender`;
# confirm it's the machine's own wallet before minting a payout link on it.
if machine.wallet_id != auth.wallet.id:
raise ValueError("machine wallet does not match the authenticated wallet")
super_config = await get_super_config()
if super_config is None:
raise ValueError("super_config not initialised")
# Per-tx cap (server-side; the bunker ACL / usage caps cannot see sats).
cap = getattr(super_config, "max_cash_in_sats", None)
if cap is not None and principal_sats > cap:
raise ValueError(
f"principal_sats {principal_sats} exceeds max_cash_in_sats {cap}"
)
# Round each leg independently so the split matches parse_settlement exactly
# (platform_fee + operator_fee == fee_sats → fee_mismatch_sats = 0).
platform_fee = round(
principal_sats * float(super_config.super_cash_in_fee_fraction)
)
operator_fee = round(principal_sats * float(machine.operator_cash_in_fee_fraction))
fee_sats = platform_fee + operator_fee
net_sats = principal_sats - fee_sats
if net_sats <= 0:
raise ValueError("fee >= principal; nothing to withdraw")
data = CreateWithdrawData(
title=body.get("title") or f"bitSpire Cash-In {machine.id}",
min_withdrawable=net_sats,
max_withdrawable=net_sats,
uses=1,
wait_time=int(body.get("wait_time") or 1),
is_unique=False,
extra={
"source": "bitspire",
"type": "cash_in",
"principal_sats": principal_sats,
"fee_sats": fee_sats,
"nostr_sender_pubkey": sender,
"nostr_event_id": body.get("client_ref") or request.event_id,
},
)
link = await create_withdraw_link(data, auth.wallet.id)
lnurl = create_lnurl_from_baseurl(link)
logger.info(
f"spirekeeper: create_withdraw machine={machine.id} "
f"principal={principal_sats} fee={fee_sats} (super={platform_fee} "
f"op={operator_fee}) net={net_sats} link={link.id}"
)
return {
"link_id": link.id,
# `Lnurl.__str__` is the raw URL — wallets need the bech32 LNURL1…
# (lud01). Mirror withdraw's `_populate_lnurl` field convention.
"lnurl": str(lnurl.bech32),
"lnurl_url": str(lnurl.url),
"net_sats": net_sats,
"principal_sats": principal_sats,
"fee_sats": fee_sats,
}
def register_create_withdraw_rpc() -> None:
"""Register `create_withdraw` with the lnbits nostr transport. Soft-fails if
the transport doesn't expose `register_rpc` (older lnbits)."""
try:
from lnbits.core.services.nostr_transport.dispatcher import ( # type: ignore
AUTH_WALLET,
register_rpc,
)
except ImportError:
logger.warning(
"spirekeeper: nostr-transport register_rpc unavailable; "
"'create_withdraw' not registered (secure cash-in over RPC disabled)"
)
return
register_rpc(_RPC_NAME, handle_create_withdraw, AUTH_WALLET)
logger.info("spirekeeper: registered nostr-transport RPC 'create_withdraw'")

View file

@ -141,8 +141,10 @@ def _state_d_tag(atm_pubkey_hex: str) -> str:
def build_state_d_tags_for_machines(machines: list[Machine]) -> list[str]: def build_state_d_tags_for_machines(machines: list[Machine]) -> list[str]:
"""Bootstrap-consumer subscription filter helper: returns the full """Bootstrap-consumer subscription filter helper: returns the full
`#d=[...]` list for all known ATMs an operator subscribes to.""" `#d=[...]` list for all known PAIRED ATMs an operator subscribes to.
return [_state_d_tag(_atm_hex_pubkey(m)) for m in machines] Unpaired machines (machine_npub is None nullable since #29/m011) have no
state-beacon d-tag yet, so skip them rather than crash `_atm_hex_pubkey`."""
return [_state_d_tag(_atm_hex_pubkey(m)) for m in machines if m.machine_npub]
# ============================================================================= # =============================================================================

54
crud.py
View file

@ -180,6 +180,11 @@ async def get_machine_by_atm_pubkey_hex(atm_pubkey_hex: str) -> Machine | None:
target = atm_pubkey_hex.lower() target = atm_pubkey_hex.lower()
machines = await list_all_active_machines() machines = await list_all_active_machines()
for m in machines: for m in machines:
# Unpaired machines (machine_npub is None — nullable since #29/m011)
# have no identity to match and would raise AttributeError in
# normalize_public_key (not caught below); skip them.
if not m.machine_npub:
continue
try: try:
if normalize_public_key(m.machine_npub).lower() == target: if normalize_public_key(m.machine_npub).lower() == target:
return m return m
@ -202,6 +207,55 @@ async def update_machine(machine_id: str, data: UpdateMachineData) -> Machine |
return await get_machine(machine_id) return await get_machine(machine_id)
async def set_machine_pairing(
machine_id: str,
*,
machine_npub: str,
bunker_spire_key_name: str,
paired_at: datetime,
) -> Machine | None:
"""Persist the result of a (re-)pair: the bunker-minted spire identity
becomes the machine's npub (so lnbits' path-B roster routes it), and we
record the bunker key name + pair time. Stored as lowercase hex the
roster + collision guard normalise either form, hex is canonical."""
await db.execute(
"""
UPDATE spirekeeper.dca_machines
SET machine_npub = :npub,
bunker_spire_key_name = :key_name,
paired_at = :paired_at,
updated_at = :updated_at
WHERE id = :id
""",
{
"npub": machine_npub.lower(),
"key_name": bunker_spire_key_name,
"paired_at": paired_at,
"updated_at": datetime.now(),
"id": machine_id,
},
)
return await get_machine(machine_id)
async def set_machine_unpaired(machine_id: str) -> Machine | None:
"""Mark a machine unpaired after revoking its spire's bunker access
(POST /revoke). Clears `paired_at`; keeps `machine_npub` +
`bunker_spire_key_name` for audit / re-pair. The bunker-side
`KeyUser.revokedAt` (set by `revoke_spire`) is what actually stops the
spire signing this just records the operator-visible state."""
await db.execute(
"""
UPDATE spirekeeper.dca_machines
SET paired_at = NULL,
updated_at = :updated_at
WHERE id = :id
""",
{"updated_at": datetime.now(), "id": machine_id},
)
return await get_machine(machine_id)
async def delete_machine(machine_id: str) -> None: async def delete_machine(machine_id: str) -> None:
await db.execute( await db.execute(
"DELETE FROM spirekeeper.dca_machines WHERE id = :id", "DELETE FROM spirekeeper.dca_machines WHERE id = :id",

View file

@ -82,7 +82,7 @@ async def m001_satmachine_v2_initial(db):
CREATE TABLE IF NOT EXISTS spirekeeper.dca_machines ( CREATE TABLE IF NOT EXISTS spirekeeper.dca_machines (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
operator_user_id TEXT NOT NULL, operator_user_id TEXT NOT NULL,
machine_npub TEXT NOT NULL UNIQUE, machine_npub TEXT UNIQUE,
wallet_id TEXT NOT NULL, wallet_id TEXT NOT NULL,
name TEXT, name TEXT,
location TEXT, location TEXT,
@ -735,3 +735,128 @@ async def m009_split_fee_fractions_by_direction(db):
await db.execute( await db.execute(
"ALTER TABLE spirekeeper.super_config DROP COLUMN super_fee_fraction" "ALTER TABLE spirekeeper.super_config DROP COLUMN super_fee_fraction"
) )
async def m010_add_machine_bunker_pairing(db):
"""Add NIP-46 bunker-pairing columns to dca_machines for seed-URL
pairing (S0 / aiolabs/spirekeeper#9; spire-side aiolabs/bitspire#52).
Under the chosen model (A1, decided 2026-06-16), the spire's signing
key lives inside the operator's nsecbunkerd rather than on the spire's
disk. `pair_machine` mints a per-spire key in the bunker, issues a
scoped NIP-46 connect token, and hands the spire a one-shot seed URL
embedding a `bunker://` connection. The spire then self-signs all its
events (kind-21000 RPC, kind-30078 beacon/cassette-state) as its own
bunker-held key; lnbits' path-B roster routes that npub to the
operator's wallet.
("spire" = a bitSpire machine; the legacy Lamassu term was "ATM".)
- bunker_spire_key_name the spire's key name inside the bunker
(`spire-<machine_id>`). Used to re-issue a connect token on
re-pair and (once the admin client grows a revoke RPC) to revoke
spire access.
- paired_at timestamp of the last successful pair. NULL = the
machine row exists but no bunker key has been minted yet.
Both nullable: machines created before this migration, and registered
-but-never-paired machines, carry NULL until first pair. Idempotent
column-probe pattern (same shape as m009).
"""
additions = [
("dca_machines", "bunker_spire_key_name", "TEXT"),
("dca_machines", "paired_at", "TIMESTAMP"),
]
for table, col, coltype in additions:
try:
await db.fetchone(f"SELECT {col} FROM spirekeeper.{table} LIMIT 1")
continue
except Exception:
pass
await db.execute(
f"ALTER TABLE spirekeeper.{table} ADD COLUMN {col} {coltype}"
)
async def m011_machine_npub_nullable(db):
"""Make dca_machines.machine_npub nullable so an operator can register a
machine *unpaired* (no npub) and have its identity minted by the bunker
at pairing time (model A1, aiolabs/spirekeeper#9). The npub is only
supplied up front on the development self-key path (a machine that holds
its own signing key). UNIQUE stays NULLs don't collide, so any number
of unpaired machines coexist.
Pre-public-launch: no back-compat shim. Existing rows are preserved by
the rebuild; the column simply loses NOT NULL.
"""
if db.type != "SQLITE":
# Postgres / Cockroach can drop the constraint in place.
await db.execute(
"ALTER TABLE spirekeeper.dca_machines "
"ALTER COLUMN machine_npub DROP NOT NULL"
)
return
# SQLite can't drop NOT NULL in place — rebuild the table (same pattern
# as m008/m009), preserving every row + the indexes.
await db.execute(
f"""
CREATE TABLE spirekeeper.dca_machines_new (
id TEXT PRIMARY KEY,
operator_user_id TEXT NOT NULL,
machine_npub TEXT UNIQUE,
wallet_id TEXT NOT NULL,
name TEXT,
location TEXT,
fiat_code TEXT NOT NULL DEFAULT 'GTQ',
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
operator_cash_in_fee_fraction DECIMAL(10,4) NOT NULL DEFAULT 0.0000,
operator_cash_out_fee_fraction DECIMAL(10,4) NOT NULL DEFAULT 0.0000,
bunker_spire_key_name TEXT,
paired_at TIMESTAMP
)
"""
)
await db.execute(
"""
INSERT INTO spirekeeper.dca_machines_new
(id, operator_user_id, machine_npub, wallet_id, name, location,
fiat_code, is_active, created_at, updated_at,
operator_cash_in_fee_fraction, operator_cash_out_fee_fraction,
bunker_spire_key_name, paired_at)
SELECT id, operator_user_id, machine_npub, wallet_id, name, location,
fiat_code, is_active, created_at, updated_at,
operator_cash_in_fee_fraction, operator_cash_out_fee_fraction,
bunker_spire_key_name, paired_at
FROM spirekeeper.dca_machines
"""
)
await db.execute("DROP TABLE spirekeeper.dca_machines")
await db.execute(
"ALTER TABLE spirekeeper.dca_machines_new RENAME TO dca_machines"
)
await db.execute(
"CREATE INDEX IF NOT EXISTS dca_machines_operator_idx "
"ON dca_machines (operator_user_id)"
)
await db.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS dca_machines_wallet_id_uq "
"ON dca_machines (wallet_id)"
)
async def m012_add_max_cash_in_sats(db):
"""Server-side per-transaction cash-in ceiling (aiolabs/spirekeeper#31).
The secure `create_withdraw` RPC derives fee/net/attribution server-side,
but `principal_sats` is necessarily ATM-attested (only the hardware knows
how much cash went in). The bunker ACL / usage caps gate call *rate*, not
*sats*, so a single in-rate call could request an arbitrarily large payout.
`max_cash_in_sats` bounds that: the handler rejects a cash-in whose
principal exceeds it. NULL = no cap.
"""
await db.execute(
"ALTER TABLE spirekeeper.super_config ADD COLUMN max_cash_in_sats INTEGER"
)

View file

@ -28,7 +28,11 @@ class CreateMachineData(BaseModel):
not against any fee total. See aiolabs/satmachineadmin#37 / #38. not against any fee total. See aiolabs/satmachineadmin#37 / #38.
""" """
machine_npub: str # Optional: blank = register the machine UNPAIRED — the bunker mints its
# identity at pairing (model A1, the normal path). Supplying an npub here
# is the development self-key path (a machine that holds its own signing
# key); see views_api.api_create_machine.
machine_npub: str | None = None
wallet_id: str wallet_id: str
name: str | None = None name: str | None = None
location: str | None = None location: str | None = None
@ -48,7 +52,7 @@ class CreateMachineData(BaseModel):
class Machine(BaseModel): class Machine(BaseModel):
id: str id: str
operator_user_id: str operator_user_id: str
machine_npub: str machine_npub: str | None # NULL until paired (or supplied on the dev self-key path)
wallet_id: str wallet_id: str
name: str | None name: str | None
location: str | None location: str | None
@ -56,6 +60,9 @@ class Machine(BaseModel):
is_active: bool is_active: bool
operator_cash_in_fee_fraction: float = 0.0 operator_cash_in_fee_fraction: float = 0.0
operator_cash_out_fee_fraction: float = 0.0 operator_cash_out_fee_fraction: float = 0.0
# NIP-46 bunker pairing (S0 / #9). NULL until the spire is first paired.
bunker_spire_key_name: str | None = None
paired_at: datetime | None = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@ -78,6 +85,29 @@ class UpdateMachineData(BaseModel):
return round(float(v), 4) return round(float(v), 4)
class PairMachineData(BaseModel):
"""Body for POST /machines/{id}/pair (S0 / #9). `relays` are the relays
the spire will use for its own events (kind-21000/30078) typically the
operator's nostrrelay. `bunker_relay` overrides the relay embedded in the
seed's `bunker://` URL (the relay the spire uses to *reach* the bunker);
when omitted it defaults to `settings.lnbits_nsec_bunker_url`. Set it when
the relay lnbits uses to reach the bunker differs from the one the spire
must reach e.g. an internal docker hostname (`ws://lnbits:5001/`) vs a
LAN/public URL (`ws://192.168.0.32:5001/`), or any split-relay deploy.
`duration_hours` optionally time-bounds the spire's connect token
(None = non-expiring)."""
relays: list[str]
bunker_relay: str | None = None
duration_hours: int | None = None
@validator("duration_hours")
def _positive_duration(cls, v):
if v is not None and v <= 0:
raise ValueError("duration_hours must be positive when set")
return v
# ============================================================================= # =============================================================================
# DCA Clients — LP registrations, scoped per (machine, user). # DCA Clients — LP registrations, scoped per (machine, user).
# ============================================================================= # =============================================================================
@ -452,6 +482,10 @@ class SuperConfig(BaseModel):
super_cash_in_fee_fraction: float = 0.0 super_cash_in_fee_fraction: float = 0.0
super_cash_out_fee_fraction: float = 0.0 super_cash_out_fee_fraction: float = 0.0
super_fee_wallet_id: str | None super_fee_wallet_id: str | None
# Per-transaction cash-in ceiling in sats (#31). The bunker ACL gates call
# rate, not sats, so this bounds a single ATM-attested principal. NULL = no
# cap.
max_cash_in_sats: int | None = None
updated_at: datetime updated_at: datetime
@ -459,6 +493,7 @@ class UpdateSuperConfigData(BaseModel):
super_cash_in_fee_fraction: float | None = None super_cash_in_fee_fraction: float | None = None
super_cash_out_fee_fraction: float | None = None super_cash_out_fee_fraction: float | None = None
super_fee_wallet_id: str | None = None super_fee_wallet_id: str | None = None
max_cash_in_sats: int | None = None
@validator( @validator(
"super_cash_in_fee_fraction", "super_cash_in_fee_fraction",
@ -471,6 +506,14 @@ class UpdateSuperConfigData(BaseModel):
raise ValueError("super fee fraction must be between 0 and 1") raise ValueError("super fee fraction must be between 0 and 1")
return round(float(v), 4) return round(float(v), 4)
@validator("max_cash_in_sats")
def _cap_non_negative(cls, v):
if v is None:
return v
if v < 0:
raise ValueError("max_cash_in_sats must be >= 0")
return int(v)
# ============================================================================= # =============================================================================
# Operator UX action carriers — partial-tx and balance-settlement features. # Operator UX action carriers — partial-tx and balance-settlement features.

277
pairing.py Normal file
View file

@ -0,0 +1,277 @@
"""Seed-URL pairing for bitSpire machines (S0 / aiolabs/spirekeeper#9), model A1.
Mints a per-spire signing key *inside the operator's nsecbunkerd*, issues a
scoped NIP-46 connect token, and builds the one-shot **seed URL** the spire
redeems at first boot. The spire then self-signs all of its own events
(kind-21000 cash RPC, kind-30078 beacon + cassette-state, CLINK 21001-21003)
as that bunker-held key; lnbits' path-B roster (`nostr_transport/roster.py`)
maps the spire npub to the operator's wallet. No nsec ever lands on the
spire's disk.
Division of labour (vs. lnbits' `RemoteBunkerSigner.provision`, which is the
reference for the admin chain):
spirekeeper (here) spire, at first boot (bitspire#52)
1. create_new_key 5. NIP-46 connect redeem the token with a
2. ensure_policy freshly-generated *client* keypair; bunker
3. create_new_token binds (client_pubkey spire key). The
4. get_key_tokens seed client_nsec stays on the spire; the
(package token in URL) signing key never leaves the bunker.
We deliberately do NOT run the connect/eager-bind step here: the spire is the
NIP-46 client, so the binding must happen spire-side with the spire's own
client keypair. spirekeeper only mints + packages.
Seed URL wire format (contract shared with bitspire#52):
spire-seed:v1:<base64url(json)> json = {
"v": 1,
"spire_npub": "npub1…", # the bunker-minted spire identity
"spire_pubkey": "<64-hex>", # same key, hex (consumer convenience)
"bunker_url": "bunker://<spire_pubkey>?relay=<bunker_relay>&secret=<sec>",
"relays": ["wss://…"], # relays for the spire's own events
}
"""
from __future__ import annotations
import base64
import json
from urllib.parse import quote
from lnbits.core.services.nsec_bunker import (
NsecBunkerAdminClient,
NsecBunkerError,
NsecBunkerNotConfiguredError,
npub_to_hex,
)
from lnbits.core.signers.remote_bunker import ensure_policy
from lnbits.settings import settings
from pydantic import BaseModel
from .models import Machine
SEED_URL_SCHEME = "spire-seed:v1:"
# Policy granted to every spire's connect token. Scoped to exactly what a
# bitSpire signs as itself:
# - 21000 nostr-transport cash RPC envelope to lnbits
# - 22242 NIP-42 relay AUTH — the spire authenticates to its relays
# (must be bunker-signed: AUTH proves control of spire_pubkey,
# which only the bunker holds; can't be done with client_nsec)
# - 21001-21003 CLINK Offer / Debit / Manage (dormant on dev; kept)
# - 30078 NIP-78 beacon + bitspire-cassettes-state hello-event
# Kind-scoped rules go in create_new_policy; kind-less methods (nip44, for
# encrypting cassette-state to the operator) are added via add_policy_rule
# because nsecbunkerd's create_new_policy chokes on null `kind`
# (rule.kind.toString()). Mirrors lnbits' DEFAULT_POLICY_* split. nip04 is
# deliberately absent — the v1/nip04 path is dead code (bitspire#52).
#
# Kind set confirmed against the spire's signing sites in bitspire#52
# (2026-06-18): live = 21000 + 30078 + 22242; CLINK 21001-21003 dormant but
# kept; nip04 unused. Under-granting = silent bunker reject, so err toward
# inclusion (low blast radius — only widens what a spire signs as its OWN key).
SPIRE_POLICY_NAME = "spirekeeper-spire"
SPIRE_POLICY_RULES = [
{"method": "sign_event", "kind": 21000},
{"method": "sign_event", "kind": 22242}, # NIP-42 relay AUTH (bitspire#52)
{"method": "sign_event", "kind": 21001},
{"method": "sign_event", "kind": 21002},
{"method": "sign_event", "kind": 21003},
{"method": "sign_event", "kind": 30078},
]
SPIRE_POLICY_METHODS_NO_KIND = ["nip44_encrypt", "nip44_decrypt"]
class PairingError(Exception):
"""Pairing could not be completed (bunker unreachable, misconfigured,
or returned an unusable response). The caller maps this to a 4xx/5xx;
no machine state is mutated on failure."""
class PairResult(BaseModel):
"""Output of a successful pair. The API layer persists
`bunker_spire_key_name` + `spire_npub` ( machine_npub) + `paired_at`,
and returns `seed_url` to the operator (QR + copy)."""
spire_npub: str
spire_pubkey_hex: str
bunker_key_name: str
bunker_url: str
seed_url: str
class RevokeResult(BaseModel):
"""Output of revoke. `revoked_count` >= 1 = the spire's signing access
is cut (KeyUser.revokedAt set); 0 = nothing was bound (token minted but
the spire never connected)."""
revoked_count: int
def spire_key_name(machine_id: str) -> str:
"""The spire's key name in the bunker keystore. Stable across re-pairs
so re-issuing a token reuses the same underlying key (create_new_key
is replace-by-name on the bunker side)."""
return f"spire-{machine_id}"
def _recover_token(tokens: list[dict], client_name: str) -> str:
"""Pull the freshly-issued `<npub>#<secret>` token out of the bunker's
`get_key_tokens` response. Match by client name when the bunker
serializes it; otherwise fall back to the most-recent entry (same
defensiveness as lnbits' provision())."""
matching = [
t
for t in tokens
if t.get("clientName") == client_name or t.get("client_name") == client_name
] or tokens
if not matching:
raise PairingError("bunker returned no tokens after create_new_token")
token = matching[-1].get("token")
if not isinstance(token, str) or "#" not in token:
raise PairingError(f"bunker returned a malformed token: {token!r}")
return token
def build_seed_url(
*, spire_npub: str, spire_pubkey_hex: str, bunker_url: str, relays: list[str]
) -> str:
payload = {
"v": 1,
"spire_npub": spire_npub,
"spire_pubkey": spire_pubkey_hex,
"bunker_url": bunker_url,
"relays": relays,
}
blob = (
base64.urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode())
.decode()
.rstrip("=")
)
return SEED_URL_SCHEME + blob
async def pair_spire(
machine: Machine,
*,
relays: list[str],
admin_client: NsecBunkerAdminClient,
bunker_relay: str | None = None,
keystore_passphrase: str | None = None,
duration_hours: int | None = None,
) -> PairResult:
"""Mint a bunker-held key + scoped connect token for `machine` and
return the seed URL the spire redeems at first boot.
`duration_hours` (optional, aiolabs/lnbits#54 item 2) stamps `expiresAt`
on the spire's connect token, bounding the established binding's lifetime.
Since aiolabs/nsecbunkerd#27 (deployed 2026-06-19) the sign-time ACL
evaluates token lifecycle on EVERY request (`checkIfPubkeyAllowed` step 4
joins through a `liveWhere` filter; `applyToken` no longer photocopies
grants), so an expired token stops signing post-bind, not just at connect.
The spire must re-pair to keep signing once the token lapses. None =
non-expiring (the only invalidation path is then `revoke_spire`).
`admin_client` must already be connected (the caller owns the
`async with NsecBunkerAdminClient.from_settings()` context) keeps
connection lifecycle out of the orchestration so this is unit-testable
with a fake client.
`relays` are the relays the spire uses for its *own* events
(kind-21000/30078) typically the operator's public nostrrelay; supplied by
the API layer. `bunker_relay` (the relay baked into `bunker_url`, where the
spire reaches the bunker) defaults to `relays[0]`; `keystore_passphrase`
defaults to the lnbits bunker setting. Both injectable for tests.
Raises PairingError on any bunker failure; no state is persisted here
(the API layer persists on success).
"""
passphrase = (
keystore_passphrase
if keystore_passphrase is not None
else settings.lnbits_nsec_bunker_keystore_passphrase
)
if not passphrase:
raise PairingError(
"LNBITS_NSEC_BUNKER_KEYSTORE_PASSPHRASE is not set — "
"cannot mint a spire key"
)
if not relays:
raise PairingError("at least one relay is required for the seed URL")
# The relay baked into `bunker_url` is where the *spire* (the remote ATM)
# reaches the bunker, so it must be a machine-reachable public URL — NOT
# `settings.lnbits_nsec_bunker_url`, which is how the co-located lnbits
# reaches the bunker (typically ws://127.0.0.1, unreachable from the ATM —
# the localhost-relay /pair gotcha bitspire flagged). Default to the spire's
# own event relay (the bunker lives on the same operator relay the spire
# publishes to); an explicit `bunker_relay` overrides for split-relay deploys.
relay = bunker_relay if bunker_relay else relays[0]
key_name = spire_key_name(machine.id)
client_name = f"spire-client-{machine.id}"
try:
spire_npub = await admin_client.create_new_key(key_name, passphrase)
spire_pubkey_hex = npub_to_hex(spire_npub)
policy_id = await ensure_policy(
admin_client,
name=SPIRE_POLICY_NAME,
rules=SPIRE_POLICY_RULES,
methods_no_kind=SPIRE_POLICY_METHODS_NO_KIND,
)
await admin_client.create_new_token(
key_name, client_name, policy_id, duration_hours=duration_hours
)
tokens = await admin_client.get_key_tokens(key_name)
except NsecBunkerNotConfiguredError as exc:
raise PairingError(f"nsecbunkerd is not configured: {exc}") from exc
except NsecBunkerError as exc:
raise PairingError(f"bunker admin RPC failed during pairing: {exc}") from exc
token = _recover_token(tokens, client_name)
_, _, secret = token.partition("#")
bunker_url = (
f"bunker://{spire_pubkey_hex}?relay={quote(relay, safe='')}"
f"&secret={quote(secret, safe='')}"
)
seed_url = build_seed_url(
spire_npub=spire_npub,
spire_pubkey_hex=spire_pubkey_hex,
bunker_url=bunker_url,
relays=relays,
)
return PairResult(
spire_npub=spire_npub,
spire_pubkey_hex=spire_pubkey_hex,
bunker_key_name=key_name,
bunker_url=bunker_url,
seed_url=seed_url,
)
async def revoke_spire(machine: Machine, *, admin_client: NsecBunkerAdminClient) -> int:
"""Revoke a spire's bunker access (the "Revoke spire access" UX,
aiolabs/spirekeeper#9/#12).
Calls `revoke_key_user` (sets `KeyUser.revokedAt`) the subject-level
sticky ban that's checked at step 2 of `checkIfPubkeyAllowed`, beating
every grant. This cuts the WHOLE binding regardless of how many tokens
were issued to the spire, which is the right semantics for "revoke this
spire." (Since aiolabs/nsecbunkerd#27 token-revoke also works post-bind —
the sign-time ACL now evaluates `Token.revokedAt`/`expiresAt` live every
request, closing the #22 no-op — but per-token revoke only cuts one
token's grant, so `revoke_key_user` remains the correct full-deauth call.)
Returns the number of KeyUsers revoked: >= 1 means the spire's signing
access is now cut; 0 means nothing was bound (token minted but the
spire never connected). Raises PairingError on any bunker failure.
"""
try:
return await admin_client.revoke_key_user(spire_key_name(machine.id))
except NsecBunkerNotConfiguredError as exc:
raise PairingError(f"nsecbunkerd is not configured: {exc}") from exc
except NsecBunkerError as exc:
raise PairingError(f"bunker admin RPC failed during revoke: {exc}") from exc

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Before After
Before After

View file

@ -103,7 +103,8 @@ window.app = Vue.createApp({
data: { data: {
super_cash_in_fee_fraction: 0, super_cash_in_fee_fraction: 0,
super_cash_out_fee_fraction: 0, super_cash_out_fee_fraction: 0,
super_fee_wallet_id: '' super_fee_wallet_id: '',
max_cash_in_sats: null
} }
}, },
@ -191,6 +192,14 @@ window.app = Vue.createApp({
saving: false, saving: false,
data: {} data: {}
}, },
pairDialog: {
show: false,
saving: false,
machine: null,
relays: '',
durationHours: null,
result: null
},
machineDetail: { machineDetail: {
show: false, show: false,
loading: false, loading: false,
@ -568,13 +577,50 @@ window.app = Vue.createApp({
this.superConfig?.super_cash_in_fee_fraction ?? 0, this.superConfig?.super_cash_in_fee_fraction ?? 0,
super_cash_out_fee_fraction: super_cash_out_fee_fraction:
this.superConfig?.super_cash_out_fee_fraction ?? 0, this.superConfig?.super_cash_out_fee_fraction ?? 0,
super_fee_wallet_id: this.superConfig?.super_fee_wallet_id || '' super_fee_wallet_id: this.superConfig?.super_fee_wallet_id || '',
max_cash_in_sats: this.superConfig?.max_cash_in_sats ?? null
} }
this.superFeeDialog.show = true this.superFeeDialog.show = true
}, },
// Guard the decimal-vs-percent trap shared by the super + operator fee
// forms: fees are decimal fractions (3% = 0.03), capped at 0.15. A value
// > 0.15 almost always means a percent was typed (3 instead of 0.03).
// Returns false + shows a clear toast so the operator never sees a raw 400.
_assertFeesDecimal(...fracs) {
if (fracs.some((v) => !Number.isFinite(v) || v < 0 || v > 0.15)) {
Quasar.Notify.create({
type: 'negative',
message: 'Enter each fee as a decimal fraction (e.g. 3% = 0.03)',
caption:
'Range 00.15. A value above 0.15 usually means a percent was typed (3 instead of 0.03).'
})
return false
}
return true
},
async submitSuperFee() { async submitSuperFee() {
const d = this.superFeeDialog.data const d = this.superFeeDialog.data
if (!this._assertFeesDecimal(
Number(d.super_cash_in_fee_fraction),
Number(d.super_cash_out_fee_fraction)
)) return
// Blank cap field -> null (the PUT skips null, so the existing value is
// preserved rather than cleared — set 0 to reject every cash-in).
const cap =
d.max_cash_in_sats === '' ||
d.max_cash_in_sats === null ||
d.max_cash_in_sats === undefined
? null
: Number(d.max_cash_in_sats)
if (cap !== null && (!Number.isInteger(cap) || cap < 0)) {
Quasar.Notify.create({
type: 'negative',
message: 'Max cash-in must be a non-negative whole number of sats'
})
return
}
this.superFeeDialog.saving = true this.superFeeDialog.saving = true
try { try {
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(
@ -582,7 +628,8 @@ window.app = Vue.createApp({
{ {
super_cash_in_fee_fraction: Number(d.super_cash_in_fee_fraction), super_cash_in_fee_fraction: Number(d.super_cash_in_fee_fraction),
super_cash_out_fee_fraction: Number(d.super_cash_out_fee_fraction), super_cash_out_fee_fraction: Number(d.super_cash_out_fee_fraction),
super_fee_wallet_id: (d.super_fee_wallet_id || '').trim() || null super_fee_wallet_id: (d.super_fee_wallet_id || '').trim() || null,
max_cash_in_sats: cap
} }
) )
this.superConfig = data this.superConfig = data
@ -691,13 +738,17 @@ window.app = Vue.createApp({
async submitAddMachine() { async submitAddMachine() {
const body = this._cleanMachineForm(this.addMachineDialog.data) const body = this._cleanMachineForm(this.addMachineDialog.data)
if (!body.machine_npub || !body.wallet_id) { if (!body.wallet_id) {
Quasar.Notify.create({ Quasar.Notify.create({
type: 'negative', type: 'negative',
message: 'machine_npub and wallet_id are required' message: 'A wallet is required'
}) })
return return
} }
if (!this._assertFeesDecimal(
Number(body.operator_cash_in_fee_fraction) || 0,
Number(body.operator_cash_out_fee_fraction) || 0
)) return
this.addMachineDialog.saving = true this.addMachineDialog.saving = true
try { try {
const {data} = await LNbits.api.request('POST', MACHINES_PATH, null, body) const {data} = await LNbits.api.request('POST', MACHINES_PATH, null, body)
@ -705,7 +756,7 @@ window.app = Vue.createApp({
this.addMachineDialog.show = false this.addMachineDialog.show = false
Quasar.Notify.create({ Quasar.Notify.create({
type: 'positive', type: 'positive',
message: `Machine ${data.name || data.machine_npub.slice(0, 12)} added` message: `Machine ${data.name || (data.machine_npub || 'unpaired').slice(0, 12)} added`
}) })
} catch (e) { } catch (e) {
this._notifyError(e, 'Failed to add machine') this._notifyError(e, 'Failed to add machine')
@ -733,6 +784,10 @@ window.app = Vue.createApp({
async submitEditMachine() { async submitEditMachine() {
const d = this.editMachineDialog.data const d = this.editMachineDialog.data
if (!this._assertFeesDecimal(
Number(d.operator_cash_in_fee_fraction) || 0,
Number(d.operator_cash_out_fee_fraction) || 0
)) return
this.editMachineDialog.saving = true this.editMachineDialog.saving = true
try { try {
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(
@ -764,7 +819,7 @@ window.app = Vue.createApp({
Quasar.Dialog.create({ Quasar.Dialog.create({
title: 'Delete machine?', title: 'Delete machine?',
message: message:
`This removes <b>${machine.name || machine.machine_npub.slice(0, 12)}</b>` + `This removes <b>${machine.name || (machine.machine_npub || 'unpaired').slice(0, 12)}</b>` +
' from your fleet. Existing settlements and payment history are preserved' + ' from your fleet. Existing settlements and payment history are preserved' +
' — only the machine row itself is removed. Continue?', ' — only the machine row itself is removed. Continue?',
html: true, html: true,
@ -781,6 +836,93 @@ window.app = Vue.createApp({
}) })
}, },
// -----------------------------------------------------------------
// Pair / revoke spire (S0 / #9, #12)
// -----------------------------------------------------------------
openPairDialog(machine) {
this.pairDialog.machine = machine
this.pairDialog.relays = ''
this.pairDialog.durationHours = null
this.pairDialog.result = null
this.pairDialog.show = true
},
async submitPair() {
const relays = (this.pairDialog.relays || '')
.split(/[\s,]+/)
.map(s => s.trim())
.filter(Boolean)
if (!relays.length) {
Quasar.Notify.create({
type: 'negative',
message: 'At least one relay is required'
})
return
}
const body = {relays}
if (this.pairDialog.durationHours) {
body.duration_hours = Number(this.pairDialog.durationHours)
}
this.pairDialog.saving = true
try {
const {data} = await LNbits.api.request(
'POST',
`${MACHINES_PATH}/${this.pairDialog.machine.id}/pair`,
null,
body
)
this.pairDialog.result = data
// The bunker-minted key becomes the machine identity; reflect it +
// the paired state in the row immediately.
const m = this.machines.find(x => x.id === this.pairDialog.machine.id)
if (m) {
m.machine_npub = data.spire_pubkey_hex
m.bunker_spire_key_name = data.bunker_key_name
m.paired_at = new Date().toISOString()
}
Quasar.Notify.create({
type: 'positive',
message: 'Spire paired — hand the seed URL to the device'
})
} catch (e) {
this._notifyError(e, 'Pairing failed')
} finally {
this.pairDialog.saving = false
}
},
confirmRevokeMachine(machine) {
Quasar.Dialog.create({
title: 'Revoke spire access?',
message:
`This cuts <b>${machine.name || (machine.machine_npub || 'unpaired').slice(0, 12)}</b>'s` +
' signing access at the bunker — the spire can no longer submit' +
' cash-outs until you re-pair it. Continue?',
html: true,
cancel: true,
persistent: true
}).onOk(async () => {
try {
const {data} = await LNbits.api.request(
'POST',
`${MACHINES_PATH}/${machine.id}/revoke`,
null
)
const m = this.machines.find(x => x.id === machine.id)
if (m) m.paired_at = null
Quasar.Notify.create({
type: data.revoked_count >= 1 ? 'positive' : 'warning',
message:
data.revoked_count >= 1
? 'Spire access revoked'
: 'Nothing was bound (the spire never connected)'
})
} catch (e) {
this._notifyError(e, 'Revoke failed')
}
})
},
// ----------------------------------------------------------------- // -----------------------------------------------------------------
// Machine detail dialog (P9b) // Machine detail dialog (P9b)
// ----------------------------------------------------------------- // -----------------------------------------------------------------
@ -1420,7 +1562,7 @@ window.app = Vue.createApp({
// Helpers // Helpers
// ----------------------------------------------------------------- // -----------------------------------------------------------------
shortNpub(npub) { shortNpub(npub) {
if (!npub) return '' if (!npub) return 'unpaired'
if (npub.length <= 16) return npub if (npub.length <= 16) return npub
return npub.slice(0, 8) + '…' + npub.slice(-6) return npub.slice(0, 8) + '…' + npub.slice(-6)
}, },
@ -1506,7 +1648,7 @@ window.app = Vue.createApp({
_cleanMachineForm(d) { _cleanMachineForm(d) {
return { return {
machine_npub: (d.machine_npub || '').trim(), machine_npub: (d.machine_npub || '').trim() || null,
wallet_id: d.wallet_id, wallet_id: d.wallet_id,
name: (d.name || '').trim() || null, name: (d.name || '').trim() || null,
location: (d.location || '').trim() || null, location: (d.location || '').trim() || null,

View file

@ -130,7 +130,11 @@ async def _handle_payment(payment: Payment) -> None:
data = parse_settlement( data = parse_settlement(
machine=machine, machine=machine,
payment_hash=payment.payment_hash, payment_hash=payment.payment_hash,
wire_sats=payment.sat, # `payment.sat` is signed by protocol direction (negative for an
# outbound cash-in payout, positive for an inbound cash-out
# receipt). The settlement's `wire_sats` is a magnitude — direction
# is carried separately by `tx_type` — so pass the absolute value.
wire_sats=abs(payment.sat),
extra=extra, extra=extra,
super_config=super_config, super_config=super_config,
) )
@ -205,7 +209,8 @@ async def _record_rejected(payment: Payment, machine: Machine, exc: Exception) -
data = CreateDcaSettlementData( data = CreateDcaSettlementData(
machine_id=machine.id, machine_id=machine.id,
payment_hash=payment.payment_hash, payment_hash=payment.payment_hash,
wire_sats=payment.sat, # Magnitude, not the signed `payment.sat` (negative for outbound).
wire_sats=abs(payment.sat),
fiat_amount=0.0, fiat_amount=0.0,
fiat_code=machine.fiat_code, fiat_code=machine.fiat_code,
exchange_rate=0.0, exchange_rate=0.0,
@ -213,11 +218,11 @@ async def _record_rejected(payment: Payment, machine: Machine, exc: Exception) -
fee_sats=0, fee_sats=0,
platform_fee_sats=0, platform_fee_sats=0,
operator_fee_sats=0, operator_fee_sats=0,
# tx_type is unknown for rejection paths; default to cash_out # The parsed tx_type is unavailable on the rejection path, but the
# (the only direction currently wired). When S8 lands the # authenticated protocol direction is: an outbound payment is a
# listener will branch on tx_type from extra, and this default # cash-in, an inbound one a cash-out. Use that so a rejected row shows
# gets revisited. # the right direction instead of always reading "cash-out".
tx_type="cash_out", tx_type="cash_in" if not payment.is_in else "cash_out",
) )
rejected = await create_settlement_idempotent( rejected = await create_settlement_idempotent(
data, initial_status="rejected", error_message=str(exc) data, initial_status="rejected", error_message=str(exc)
@ -230,7 +235,10 @@ async def _record_rejected(payment: Payment, machine: Machine, exc: Exception) -
return return
logger.error( logger.error(
f"spirekeeper: rejected settlement {rejected.id} " f"spirekeeper: rejected settlement {rejected.id} "
f"(machine={machine.machine_npub[:12]}..., " # An unpaired machine (machine_npub None) reaches here now that
# assert_nostr_attribution rejects it — fall back to the id so the
# log line doesn't crash on None[:12].
f"(machine={(machine.machine_npub or machine.id)[:12]}..., "
f"payment_hash={payment.payment_hash[:12]}...): {exc}" f"payment_hash={payment.payment_hash[:12]}...): {exc}"
) )

View file

@ -131,6 +131,17 @@
<div :style="{fontWeight: 500}" v-text="props.row.name || 'Unnamed'"></div> <div :style="{fontWeight: 500}" v-text="props.row.name || 'Unnamed'"></div>
<div :style="{fontSize: '0.8em', opacity: 0.6}" <div :style="{fontSize: '0.8em', opacity: 0.6}"
v-text="props.row.location || 'No location set'"></div> v-text="props.row.location || 'No location set'"></div>
<q-chip v-if="props.row.paired_at"
dense size="sm" color="green-2" text-color="green-9"
icon="link" :style="{marginTop: '2px'}">
paired
<q-tooltip>Bunker key minted; paired ${ new Date(props.row.paired_at).toLocaleString() }</q-tooltip>
</q-chip>
<q-chip v-else
dense size="sm" color="grey-3" text-color="grey-8"
icon="link_off" :style="{marginTop: '2px'}">
not paired
</q-chip>
</q-td> </q-td>
<q-td key="machine_npub"> <q-td key="machine_npub">
<code :style="{fontSize: '0.85em'}" <code :style="{fontSize: '0.85em'}"
@ -156,6 +167,17 @@
@click="openEditMachineDialog(props.row)"> @click="openEditMachineDialog(props.row)">
<q-tooltip>Edit</q-tooltip> <q-tooltip>Edit</q-tooltip>
</q-btn> </q-btn>
<q-btn flat dense round size="sm" icon="qr_code_2"
color="teal"
@click="openPairDialog(props.row)">
<q-tooltip>${ props.row.paired_at ? 'Re-pair (new seed URL)' : 'Pair (seed URL)' }</q-tooltip>
</q-btn>
<q-btn v-if="props.row.paired_at"
flat dense round size="sm" icon="link_off"
color="orange-8"
@click="confirmRevokeMachine(props.row)">
<q-tooltip>Revoke spire access</q-tooltip>
</q-btn>
<q-btn flat dense round size="sm" icon="delete" <q-btn flat dense round size="sm" icon="delete"
color="red-7" color="red-7"
@click="confirmDeleteMachine(props.row)"> @click="confirmDeleteMachine(props.row)">
@ -770,13 +792,13 @@
<q-input <q-input
v-model="addMachineDialog.data.machine_npub" v-model="addMachineDialog.data.machine_npub"
label="Machine npub (hex or bech32)" label="Machine npub — DEVELOPMENT ONLY (blank = normal bunker pairing)"
hint="64-char hex pubkey or npub1... bech32 string" hint="⚠ Leave blank for normal operation: the bunker mints this machine's key when you pair it (no nsec ever lands on the machine). Only fill this to register a machine that holds its OWN signing key — development / self-signing. Hex or npub1…"
color="orange"
class="q-mb-md" class="q-mb-md"
dense outlined dense outlined
:rules="[ :rules="[
v => !!v || 'Required', v => !v || v.length >= 32 || 'Looks too short — use a full hex/npub, or leave blank'
v => (v && v.length >= 32) || 'Looks too short'
]"></q-input> ]"></q-input>
<q-select <q-select
@ -797,7 +819,7 @@
<q-input <q-input
v-model.number="addMachineDialog.data.operator_cash_in_fee_fraction" v-model.number="addMachineDialog.data.operator_cash_in_fee_fraction"
label="Operator cash-in fee % (decimal, 0..0.15)" label="Operator cash-in fee (decimal fraction, 0-0.15)"
hint="Your per-machine cut on cash-in. Sits on top of the platform fee; cap is 15% total per direction." hint="Your per-machine cut on cash-in. Sits on top of the platform fee; cap is 15% total per direction."
type="number" step="0.0001" min="0" max="0.15" type="number" step="0.0001" min="0" max="0.15"
class="q-mb-md" class="q-mb-md"
@ -805,7 +827,7 @@
<q-input <q-input
v-model.number="addMachineDialog.data.operator_cash_out_fee_fraction" v-model.number="addMachineDialog.data.operator_cash_out_fee_fraction"
label="Operator cash-out fee % (decimal, 0..0.15)" label="Operator cash-out fee (decimal fraction, 0-0.15)"
hint="Your per-machine cut on cash-out. Sits on top of the platform fee; cap is 15% total per direction." hint="Your per-machine cut on cash-out. Sits on top of the platform fee; cap is 15% total per direction."
type="number" step="0.0001" min="0" max="0.15" type="number" step="0.0001" min="0" max="0.15"
class="q-mb-md" class="q-mb-md"
@ -821,6 +843,101 @@
</q-card> </q-card>
</q-dialog> </q-dialog>
<!-- =============================================================== -->
<!-- PAIR SPIRE DIALOG — mint bunker key + one-shot seed URL (S0/#9) -->
<!-- =============================================================== -->
<q-dialog v-model="pairDialog.show" persistent>
<q-card :style="{minWidth: '480px', maxWidth: '95vw'}">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">Pair spire</div>
<q-space></q-space>
<q-btn icon="close" flat round dense v-close-popup></q-btn>
</q-card-section>
<!-- Step 1 — configure + generate -->
<template v-if="!pairDialog.result">
<q-card-section>
<p class="text-caption q-mb-md" :style="{opacity: 0.7}">
Mints a dedicated signing key for
<b v-text="(pairDialog.machine && pairDialog.machine.name) || 'this spire'"></b>
inside the operator bunker and issues a one-shot seed URL. The
spire's key never touches its disk; its cash-outs route to this
machine's wallet. Re-pairing issues a fresh seed.
</p>
<q-input
v-model="pairDialog.relays"
label="Relay(s) for the spire's events"
hint="One per line. The same relay the spire publishes to (its VITE_RELAY_URL), e.g. wss://your-host/nostrrelay/<id>"
type="textarea" autogrow
class="q-mb-md"
dense outlined></q-input>
<q-input
v-model.number="pairDialog.durationHours"
label="Token lifetime in hours (optional)"
hint="Blank = non-expiring. Set e.g. 720 (30 days) to force periodic re-pairing."
type="number" min="1"
class="q-mb-md"
dense outlined></q-input>
</q-card-section>
<q-card-actions align="right" class="text-primary">
<q-btn flat label="Cancel" v-close-popup></q-btn>
<q-btn
color="primary" label="Generate seed URL" icon="vpn_key"
:loading="pairDialog.saving"
@click="submitPair"></q-btn>
</q-card-actions>
</template>
<!-- Step 2 — show the seed URL -->
<template v-else>
<q-card-section>
<q-banner dense rounded class="bg-green-1 text-grey-9 q-mb-md">
<template v-slot:avatar>
<q-icon name="check_circle" color="green"></q-icon>
</template>
Paired. Scan this on the spire at first boot, or paste the seed URL
into <code>provision-atm</code>. Shown once — copy it now.
</q-banner>
<div class="row justify-center q-mb-md">
<lnbits-qrcode
:value="pairDialog.result.seed_url"
:options="{width: 280}"></lnbits-qrcode>
</div>
<q-input
v-model="pairDialog.result.seed_url"
label="Seed URL"
type="textarea" autogrow readonly
class="q-mb-sm"
dense outlined>
<template v-slot:append>
<q-btn flat dense round icon="content_copy"
@click="copy(pairDialog.result.seed_url)">
<q-tooltip>Copy seed URL</q-tooltip>
</q-btn>
</template>
</q-input>
<div class="text-caption" :style="{opacity: 0.7}">
Spire identity:
<code :style="{fontSize: '0.85em'}"
v-text="shortNpub(pairDialog.result.spire_npub)"></code>
<q-btn flat dense round size="xs" icon="content_copy"
@click="copy(pairDialog.result.spire_npub)">
<q-tooltip>Copy npub</q-tooltip>
</q-btn>
</div>
</q-card-section>
<q-card-actions align="right" class="text-primary">
<q-btn flat label="Done" color="primary" v-close-popup></q-btn>
</q-card-actions>
</template>
</q-card>
</q-dialog>
<!-- =============================================================== --> <!-- =============================================================== -->
<!-- MACHINE DETAIL DIALOG — settlements list + per-row actions --> <!-- MACHINE DETAIL DIALOG — settlements list + per-row actions -->
<!-- =============================================================== --> <!-- =============================================================== -->
@ -1251,12 +1368,12 @@
typically a wallet you (the super) own. typically a wallet you (the super) own.
</p> </p>
<q-input v-model.number="superFeeDialog.data.super_cash_in_fee_fraction" <q-input v-model.number="superFeeDialog.data.super_cash_in_fee_fraction"
label="Cash-in fee % (decimal, 0..0.15)" label="Cash-in fee (decimal fraction, 0-0.15)"
hint="0.03 = 3% of principal on cash-in transactions" hint="0.03 = 3% of principal on cash-in transactions"
type="number" step="0.0001" min="0" max="0.15" type="number" step="0.0001" min="0" max="0.15"
class="q-mb-md" dense outlined></q-input> class="q-mb-md" dense outlined></q-input>
<q-input v-model.number="superFeeDialog.data.super_cash_out_fee_fraction" <q-input v-model.number="superFeeDialog.data.super_cash_out_fee_fraction"
label="Cash-out fee % (decimal, 0..0.15)" label="Cash-out fee (decimal fraction, 0-0.15)"
hint="0.03 = 3% of principal on cash-out transactions" hint="0.03 = 3% of principal on cash-out transactions"
type="number" step="0.0001" min="0" max="0.15" type="number" step="0.0001" min="0" max="0.15"
class="q-mb-md" dense outlined></q-input> class="q-mb-md" dense outlined></q-input>
@ -1264,6 +1381,11 @@
label="Super fee destination wallet_id" label="Super fee destination wallet_id"
hint="LNbits wallet that collects the platform fee" hint="LNbits wallet that collects the platform fee"
class="q-mb-md" dense outlined></q-input> class="q-mb-md" dense outlined></q-input>
<q-input v-model.number="superFeeDialog.data.max_cash_in_sats"
label="Max cash-in per transaction (sats — blank = no cap)"
hint="Server-side ceiling on a single cash-in's principal. The ATM attests the amount; this bounds a compromised/buggy machine to one capped tx."
type="number" step="1" min="0"
class="q-mb-md" dense outlined></q-input>
</q-card-section> </q-card-section>
<q-card-actions align="right"> <q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup></q-btn> <q-btn flat label="Cancel" v-close-popup></q-btn>
@ -1510,12 +1632,12 @@
<q-input v-model="editMachineDialog.data.fiat_code" <q-input v-model="editMachineDialog.data.fiat_code"
label="Fiat code" class="q-mb-md" dense outlined></q-input> label="Fiat code" class="q-mb-md" dense outlined></q-input>
<q-input v-model.number="editMachineDialog.data.operator_cash_in_fee_fraction" <q-input v-model.number="editMachineDialog.data.operator_cash_in_fee_fraction"
label="Operator cash-in fee % (decimal, 0..0.15)" label="Operator cash-in fee (decimal fraction, 0-0.15)"
hint="Sits on top of the platform cash-in fee. Cap 15% total per direction." hint="Sits on top of the platform cash-in fee. Cap 15% total per direction."
type="number" step="0.0001" min="0" max="0.15" type="number" step="0.0001" min="0" max="0.15"
class="q-mb-md" dense outlined></q-input> class="q-mb-md" dense outlined></q-input>
<q-input v-model.number="editMachineDialog.data.operator_cash_out_fee_fraction" <q-input v-model.number="editMachineDialog.data.operator_cash_out_fee_fraction"
label="Operator cash-out fee % (decimal, 0..0.15)" label="Operator cash-out fee (decimal fraction, 0-0.15)"
hint="Sits on top of the platform cash-out fee. Cap 15% total per direction." hint="Sits on top of the platform cash-out fee. Cap 15% total per direction."
type="number" step="0.0001" min="0" max="0.15" type="number" step="0.0001" min="0" max="0.15"
class="q-mb-md" dense outlined></q-input> class="q-mb-md" dense outlined></q-input>

167
tests/test_pair_endpoint.py Normal file
View file

@ -0,0 +1,167 @@
"""Wiring tests for POST /machines/{id}/pair (S0 / #9).
The pairing *service* is covered in test_pairing.py with a fake bunker;
here we only exercise the endpoint glue ownership, the empty-relays
guard, the post-mint collision guard, persistence of the bunker-minted
hex npub, and error mapping by monkeypatching the module-level deps.
"""
import asyncio
from datetime import datetime, timezone
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from lnbits.utils.nostr import hex_to_npub
from .. import views_api
from ..models import Machine, PairMachineData
from ..pairing import PairingError, PairResult
_NOW = datetime(2026, 6, 16, tzinfo=timezone.utc)
_SPIRE_HEX = "522a4538f1df96508d9ee8b14072344dd4a566acfe03c25a92a39179c6fca891"
_SPIRE_NPUB = hex_to_npub(_SPIRE_HEX)
def _machine(npub: str = "placeholder") -> Machine:
return Machine(
id="m1",
operator_user_id="op1",
machine_npub=npub,
wallet_id="w1",
name="sintra",
location=None,
fiat_code="EUR",
is_active=True,
created_at=_NOW,
updated_at=_NOW,
)
class _FakeAdmin:
@classmethod
def from_settings(cls):
return cls()
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
def _result() -> PairResult:
return PairResult(
spire_npub=_SPIRE_NPUB,
spire_pubkey_hex=_SPIRE_HEX,
bunker_key_name="spire-m1",
bunker_url="bunker://x?relay=r&secret=s", # pragma: allowlist secret
seed_url="spire-seed:v1:abc",
)
def _wire(monkeypatch, *, pair="ok"):
state: dict = {"persisted": None, "collision": None}
async def fake_owned(machine_id, user_id):
return _machine()
async def fake_pair(machine, *, relays, admin_client, duration_hours=None):
if pair == "error":
raise PairingError("boom")
return _result()
async def fake_collision(npub):
state["collision"] = npub
async def fake_persist(
machine_id, *, machine_npub, bunker_spire_key_name, paired_at
):
state["persisted"] = (machine_id, machine_npub, bunker_spire_key_name)
return _machine(npub=machine_npub)
monkeypatch.setattr(views_api, "_machine_owned_by", fake_owned)
monkeypatch.setattr(views_api, "NsecBunkerAdminClient", _FakeAdmin)
monkeypatch.setattr(views_api, "pair_spire", fake_pair)
monkeypatch.setattr(views_api, "_assert_no_pubkey_collision", fake_collision)
monkeypatch.setattr(views_api, "set_machine_pairing", fake_persist)
return state
def _call(relays):
user = SimpleNamespace(id="op1")
return asyncio.run(
views_api.api_pair_machine("m1", PairMachineData(relays=relays), user)
)
def test_pair_persists_hex_npub_and_returns_seed(monkeypatch):
state = _wire(monkeypatch)
result = _call(["wss://r"])
assert result.seed_url == "spire-seed:v1:abc"
# collision guard ran on the bunker-minted hex, and we persisted it as npub
assert state["collision"] == _SPIRE_HEX
assert state["persisted"] == ("m1", _SPIRE_HEX, "spire-m1")
def test_pair_empty_relays_rejected(monkeypatch):
_wire(monkeypatch)
with pytest.raises(HTTPException) as ei:
_call([])
assert ei.value.status_code == 400
def test_pair_failure_maps_to_bad_gateway(monkeypatch):
state = _wire(monkeypatch, pair="error")
with pytest.raises(HTTPException) as ei:
_call(["wss://r"])
assert ei.value.status_code == 502
# nothing persisted on failure
assert state["persisted"] is None
def _wire_revoke(monkeypatch, *, revoke="ok", count=2):
state = {"unpaired": None}
async def fake_owned(machine_id, user_id):
return _machine()
async def fake_revoke(machine, *, admin_client):
if revoke == "error":
raise PairingError("boom")
return count
async def fake_unpaired(machine_id):
state["unpaired"] = machine_id
return _machine()
monkeypatch.setattr(views_api, "_machine_owned_by", fake_owned)
monkeypatch.setattr(views_api, "NsecBunkerAdminClient", _FakeAdmin)
monkeypatch.setattr(views_api, "revoke_spire", fake_revoke)
monkeypatch.setattr(views_api, "set_machine_unpaired", fake_unpaired)
return state
def _call_revoke():
user = SimpleNamespace(id="op1")
return asyncio.run(views_api.api_revoke_machine("m1", user))
def test_revoke_cuts_access_and_marks_unpaired(monkeypatch):
state = _wire_revoke(monkeypatch, count=2)
result = _call_revoke()
assert result.revoked_count == 2
assert state["unpaired"] == "m1"
def test_revoke_zero_when_nothing_bound(monkeypatch):
_wire_revoke(monkeypatch, count=0)
assert _call_revoke().revoked_count == 0
def test_revoke_failure_maps_to_bad_gateway(monkeypatch):
state = _wire_revoke(monkeypatch, revoke="error")
with pytest.raises(HTTPException) as ei:
_call_revoke()
assert ei.value.status_code == 502
assert state["unpaired"] is None # not persisted on failure

332
tests/test_pairing.py Normal file
View file

@ -0,0 +1,332 @@
"""Unit tests for the seed-URL pairing service (S0 / #9, model A1).
The bunker admin client is faked these exercise the orchestration
(create_new_key -> ensure-policy -> create_new_token -> get_key_tokens),
the policy reconciliation, and the seed-URL / bunker:// wire shape, with
no live nsecbunkerd. npub<->hex round-trips through lnbits' real helpers
so the parsing is exercised for real.
Async is driven via asyncio.run (this venv has no pytest-asyncio), matching
the rest of the suite.
"""
import asyncio
import base64
import json
from datetime import datetime, timezone
import pytest
from lnbits.core.services.nsec_bunker import NsecBunkerError
from lnbits.utils.nostr import hex_to_npub
from ..models import Machine
from ..pairing import (
SEED_URL_SCHEME,
SPIRE_POLICY_METHODS_NO_KIND,
SPIRE_POLICY_NAME,
SPIRE_POLICY_RULES,
PairingError,
build_seed_url,
pair_spire,
revoke_spire,
spire_key_name,
)
_NOW = datetime(2026, 6, 16, tzinfo=timezone.utc)
_SPIRE_HEX = "522a4538f1df96508d9ee8b14072344dd4a566acfe03c25a92a39179c6fca891"
_SPIRE_NPUB = hex_to_npub(_SPIRE_HEX)
_RELAYS = ["wss://lnbits.demo.aiolabs.dev/nostrrelay/demo"]
_BUNKER_RELAY = "wss://bunker.internal/relay"
_PASSPHRASE = "keystore-pass" # pragma: allowlist secret
@pytest.fixture(autouse=True)
def _clear_policy_cache():
# lnbits' ensure_policy caches resolved policy ids on
# (admin_pubkey, name); clear between tests so each FakeBunker's
# canned policy state is honoured rather than a stale cached id.
from lnbits.core.signers import remote_bunker
remote_bunker._POLICY_ID_CACHE.clear()
yield
def _machine(mid: str = "m1") -> Machine:
return Machine(
id=mid,
operator_user_id="op1",
machine_npub="placeholder",
wallet_id="w1",
name="sintra",
location=None,
fiat_code="EUR",
is_active=True,
created_at=_NOW,
updated_at=_NOW,
)
class FakeBunker:
"""Records calls; returns canned bunker responses."""
admin_pubkey = "fake-admin-pubkey"
# pragma: allowlist secret
def __init__(self, *, policies=None, token_secret="s3cr3t", revoke_count=1):
self._policies = policies or []
self._token_secret = token_secret
self._revoke_count = revoke_count
self.calls: list[tuple] = []
self._next_policy_id = 7
async def create_new_key(self, name, passphrase):
self.calls.append(("create_new_key", name, passphrase))
return _SPIRE_NPUB
async def get_policies(self):
self.calls.append(("get_policies",))
return list(self._policies)
async def create_new_policy(self, name, rules):
self.calls.append(("create_new_policy", name, rules))
pid = self._next_policy_id
self._policies.append({"id": pid, "name": name, "rules": list(rules)})
return pid
async def add_policy_rule(self, policy_id, rule):
self.calls.append(("add_policy_rule", policy_id, rule))
async def create_new_token(
self, key_name, client_name, policy_id, duration_hours=None
):
self.calls.append(
("create_new_token", key_name, client_name, policy_id, duration_hours)
)
async def revoke_key_user(self, key_name):
self.calls.append(("revoke_key_user", key_name))
return self._revoke_count
async def get_key_tokens(self, key_name):
self.calls.append(("get_key_tokens", key_name))
return [
{
"clientName": f"spire-client-{key_name.split('-', 1)[1]}",
"token": f"{_SPIRE_NPUB}#{self._token_secret}",
}
]
def named(self, name):
return [c for c in self.calls if c[0] == name]
def _pair(bunker, machine=None):
return asyncio.run(
pair_spire(
machine or _machine(),
relays=_RELAYS,
admin_client=bunker,
bunker_relay=_BUNKER_RELAY,
keystore_passphrase=_PASSPHRASE,
)
)
def test_pair_happy_path_mints_key_policy_token():
bunker = FakeBunker(token_secret="abc123") # pragma: allowlist secret
result = _pair(bunker)
assert ("create_new_key", "spire-m1", _PASSPHRASE) in bunker.calls
assert result.bunker_key_name == spire_key_name("m1") == "spire-m1"
assert result.spire_npub == _SPIRE_NPUB
assert result.spire_pubkey_hex == _SPIRE_HEX
created = bunker.named("create_new_policy")
assert created and created[0][1] == SPIRE_POLICY_NAME
token_call = bunker.named("create_new_token")[0]
assert token_call[1] == "spire-m1" # key_name
assert token_call[2] == "spire-client-m1" # client_name
assert token_call[3] == 7 # policy_id from the fake's create_new_policy
def test_bunker_url_carries_pubkey_relay_secret():
result = _pair(FakeBunker(token_secret="topsecret")) # pragma: allowlist secret
assert result.bunker_url.startswith(f"bunker://{_SPIRE_HEX}?")
assert "relay=wss%3A%2F%2Fbunker.internal%2Frelay" in result.bunker_url
assert "secret=topsecret" in result.bunker_url
def test_seed_url_decodes_to_contract():
result = _pair(FakeBunker(token_secret="zzz")) # pragma: allowlist secret
assert result.seed_url.startswith(SEED_URL_SCHEME)
blob = result.seed_url[len(SEED_URL_SCHEME) :]
payload = json.loads(base64.urlsafe_b64decode(blob + "=" * (-len(blob) % 4)))
assert payload == {
"v": 1,
"spire_npub": _SPIRE_NPUB,
"spire_pubkey": _SPIRE_HEX,
"bunker_url": result.bunker_url,
"relays": _RELAYS,
}
def test_fresh_policy_adds_kindless_nip44_rules():
bunker = FakeBunker() # no existing policies
_pair(bunker)
added = [c[2]["method"] for c in bunker.named("add_policy_rule")]
# kind-scoped rules went in via create_new_policy; only the kind-less
# nip44 methods are reconciled in via add_policy_rule.
assert set(added) == set(SPIRE_POLICY_METHODS_NO_KIND)
def test_existing_policy_reused_not_recreated():
existing = [
{
"id": 42,
"name": SPIRE_POLICY_NAME,
"rules": [dict(r) for r in SPIRE_POLICY_RULES],
}
]
bunker = FakeBunker(policies=existing)
_pair(bunker)
assert not bunker.named("create_new_policy") # reused, not recreated
assert bunker.named("create_new_token")[0][3] == 42 # used existing id
added = [c[2]["method"] for c in bunker.named("add_policy_rule")]
assert set(added) == set(SPIRE_POLICY_METHODS_NO_KIND)
def test_fully_provisioned_policy_adds_nothing():
rules = [dict(r) for r in SPIRE_POLICY_RULES] + [
{"method": m, "kind": None} for m in SPIRE_POLICY_METHODS_NO_KIND
]
bunker = FakeBunker(policies=[{"id": 9, "name": SPIRE_POLICY_NAME, "rules": rules}])
_pair(bunker)
assert not bunker.named("add_policy_rule")
assert not bunker.named("create_new_policy")
def test_malformed_token_raises():
bunker = FakeBunker()
async def _bad_tokens(key_name):
_ = key_name
return [{"token": "no-hash-here"}]
bunker.get_key_tokens = _bad_tokens
with pytest.raises(PairingError, match="malformed token"):
_pair(bunker)
def test_bunker_relay_defaults_to_spire_event_relay():
"""No explicit bunker_relay -> the relay baked into bunker_url is the spire's
own public event relay (relays[0]), NOT lnbits's internal bunker URL. This
is the localhost-relay /pair gotcha: a UI-minted seed (the form has no
bunker_relay field) must embed a machine-reachable relay, not ws://127.0.0.1.
An empty bunker_relay falls back to the same default."""
from urllib.parse import quote
for empty in (None, ""):
result = asyncio.run(
pair_spire(
_machine(),
relays=_RELAYS,
admin_client=FakeBunker(token_secret="s"), # pragma: allowlist secret
bunker_relay=empty,
keystore_passphrase=_PASSPHRASE,
)
)
assert f"relay={quote(_RELAYS[0], safe='')}" in result.bunker_url
assert "127.0.0.1" not in result.bunker_url
def test_missing_relay_or_passphrase_raises():
with pytest.raises(PairingError, match="PASSPHRASE"):
asyncio.run(
pair_spire(
_machine(),
relays=_RELAYS,
admin_client=FakeBunker(),
bunker_relay=_BUNKER_RELAY,
keystore_passphrase="",
)
)
with pytest.raises(PairingError, match="relay is required"):
asyncio.run(
pair_spire(
_machine(),
relays=[],
admin_client=FakeBunker(),
bunker_relay=_BUNKER_RELAY,
keystore_passphrase=_PASSPHRASE,
)
)
def test_build_seed_url_roundtrip():
url = build_seed_url(
spire_npub=_SPIRE_NPUB,
spire_pubkey_hex=_SPIRE_HEX,
bunker_url="bunker://x?relay=r&secret=s",
relays=_RELAYS,
)
blob = url[len(SEED_URL_SCHEME) :]
payload = json.loads(base64.urlsafe_b64decode(blob + "=" * (-len(blob) % 4)))
assert payload["spire_pubkey"] == _SPIRE_HEX
assert payload["relays"] == _RELAYS
def test_pair_threads_duration_hours():
bunker = FakeBunker()
asyncio.run(
pair_spire(
_machine(),
relays=_RELAYS,
admin_client=bunker,
bunker_relay=_BUNKER_RELAY,
keystore_passphrase=_PASSPHRASE,
duration_hours=720,
)
)
# create_new_token tuple is (name, key, client, policy_id, duration_hours)
assert bunker.named("create_new_token")[0][4] == 720
def test_pair_default_duration_is_none():
bunker = FakeBunker()
_pair(bunker) # no duration_hours
assert bunker.named("create_new_token")[0][4] is None
def test_revoke_spire_calls_revoke_key_user():
# revoke goes through revoke_key_user (KeyUser.revokedAt) — the subject-
# level ban that cuts the whole binding, not just one token's grant.
# (Token-revoke also works post-bind since nsecbunkerd#27, but only
# severs a single token; revoke_key_user is the full-deauth call.)
bunker = FakeBunker(revoke_count=2)
count = asyncio.run(revoke_spire(_machine(), admin_client=bunker))
assert count == 2
assert bunker.named("revoke_key_user") == [("revoke_key_user", "spire-m1")]
assert not bunker.named("revoke_token") # never token-revoke
def test_revoke_spire_maps_bunker_error():
bunker = FakeBunker()
async def _boom(key_name):
raise NsecBunkerError("nope")
bunker.revoke_key_user = _boom
with pytest.raises(PairingError, match="revoke"):
asyncio.run(revoke_spire(_machine(), admin_client=bunker))
def test_policy_authorizes_required_signing_kinds():
# Kinds the spire signs as its OWN identity, confirmed against the
# consumer signing sites in bitspire#52 (2026-06-18). A missing kind is a
# silent bunker reject. 22242 = NIP-42 relay AUTH (must be bunker-signed —
# it proves control of spire_pubkey). nip04 stays out (v1 path is dead).
kinds = {r["kind"] for r in SPIRE_POLICY_RULES if r["method"] == "sign_event"}
assert {21000, 30078, 22242} <= kinds
assert "nip04_encrypt" not in SPIRE_POLICY_METHODS_NO_KIND
assert "nip04_decrypt" not in SPIRE_POLICY_METHODS_NO_KIND

View file

@ -0,0 +1,57 @@
"""
Regression: `machine_npub` is nullable (#29/m011 register-unpaired flow), so
every consumer that derives a Nostr identity from it must handle `None` rather
than crash `normalize_public_key(None)` (AttributeError: 'NoneType' has no
'startswith') or `machine_npub[:12]` (TypeError). See PR #33 — an unpaired
machine on the demo broke the platform-fee update (500) and the cassette
consumer.
These cover the pure-function guards; the DB-backed loops
(get_machine_by_atm_pubkey_hex, the super-config republish loop) are exercised
on the dev stack with an unpaired active machine.
"""
from datetime import datetime, timezone
import pytest
from ..bitspire import SettlementAttributionError, assert_nostr_attribution
from ..cassette_transport import build_state_d_tags_for_machines
from ..models import Machine
_PAIRED_HEX = "82341f882b6eabcbd6b1c2da5cd14df14b8e91dd0e6da41a72b78ad8f3a7d3b9"
def _machine(npub: str | None) -> Machine:
now = datetime.now(timezone.utc)
return Machine(
id="unpaired1",
operator_user_id="op1",
machine_npub=npub,
wallet_id="w1",
name="unpaired",
location=None,
fiat_code="EUR",
is_active=True,
created_at=now,
updated_at=now,
)
def test_attribution_rejects_unpaired_machine_cleanly():
"""An unpaired machine must raise the domain SettlementAttributionError
(which the listener records as 'rejected'), not an uncaught AttributeError
from normalize_public_key(None)."""
with pytest.raises(SettlementAttributionError):
assert_nostr_attribution(
_machine(None),
{"source": "bitspire", "nostr_sender_pubkey": _PAIRED_HEX},
)
def test_cassette_d_tags_skip_unpaired_machine():
"""build_state_d_tags_for_machines must skip unpaired machines rather than
crash _atm_hex_pubkey on a None npub the cassette-consumer loop crash."""
tags = build_state_d_tags_for_machines([_machine(_PAIRED_HEX), _machine(None)])
assert len(tags) == 1 # only the paired machine contributes a d-tag
assert all("None" not in t for t in tags)

View file

@ -5,12 +5,18 @@
# LNbits instance can never see each other's machines, settlements, or # LNbits instance can never see each other's machines, settlements, or
# clients. The super-only platform-fee write endpoint lands in P2. # clients. The super-only platform-fee write endpoint lands in P2.
from datetime import datetime, timezone
from http import HTTPStatus from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from lnbits.core.crud import get_wallet from lnbits.core.crud import get_wallet
from lnbits.core.crud.users import get_account_by_pubkey from lnbits.core.crud.users import get_account_by_pubkey
from lnbits.core.models import User from lnbits.core.models import User
from lnbits.core.services.nsec_bunker import (
NsecBunkerAdminClient,
NsecBunkerError,
NsecBunkerNotConfiguredError,
)
from lnbits.decorators import check_super_user, check_user_exists from lnbits.decorators import check_super_user, check_user_exists
from lnbits.utils.nostr import normalize_public_key from lnbits.utils.nostr import normalize_public_key
@ -23,6 +29,13 @@ from .cassette_transport import (
publish_to_atm, publish_to_atm,
) )
from .fee_transport import publish_fee_config from .fee_transport import publish_fee_config
from .pairing import (
PairResult,
PairingError,
RevokeResult,
pair_spire,
revoke_spire,
)
from .crud import ( from .crud import (
append_settlement_note, append_settlement_note,
count_completed_legs_for_settlement, count_completed_legs_for_settlement,
@ -55,6 +68,8 @@ from .crud import (
lp_is_onboarded, lp_is_onboarded,
replace_commission_splits, replace_commission_splits,
reset_settlement_for_retry, reset_settlement_for_retry,
set_machine_pairing,
set_machine_unpaired,
update_cassette_config, update_cassette_config,
update_dca_client, update_dca_client,
update_deposit, update_deposit,
@ -80,6 +95,7 @@ from .models import (
DcaPayment, DcaPayment,
DcaSettlement, DcaSettlement,
Machine, Machine,
PairMachineData,
PartialDispenseData, PartialDispenseData,
PublishCassettesPayload, PublishCassettesPayload,
SetCommissionSplitsData, SetCommissionSplitsData,
@ -232,7 +248,7 @@ async def _assert_super_config_cap_safe(
HTTPStatus.BAD_REQUEST, HTTPStatus.BAD_REQUEST,
( (
f"super cash-in fee {effective_in:.4f} would exceed cap " f"super cash-in fee {effective_in:.4f} would exceed cap "
f"on machine {m.id} ({m.name or m.machine_npub[:12]}): " f"on machine {m.id} ({m.name or (m.machine_npub or m.id)[:12]}): "
f"+ operator {op_in:.4f} = " f"+ operator {op_in:.4f} = "
f"{total_in:.4f} > {MAX_FEE_FRACTION_PER_DIRECTION}" f"{total_in:.4f} > {MAX_FEE_FRACTION_PER_DIRECTION}"
), ),
@ -242,7 +258,7 @@ async def _assert_super_config_cap_safe(
HTTPStatus.BAD_REQUEST, HTTPStatus.BAD_REQUEST,
( (
f"super cash-out fee {effective_out:.4f} would exceed cap " f"super cash-out fee {effective_out:.4f} would exceed cap "
f"on machine {m.id} ({m.name or m.machine_npub[:12]}): " f"on machine {m.id} ({m.name or (m.machine_npub or m.id)[:12]}): "
f"+ operator {op_out:.4f} = " f"+ operator {op_out:.4f} = "
f"{total_out:.4f} > {MAX_FEE_FRACTION_PER_DIRECTION}" f"{total_out:.4f} > {MAX_FEE_FRACTION_PER_DIRECTION}"
), ),
@ -259,7 +275,13 @@ async def api_create_machine(
data: CreateMachineData, user: User = Depends(check_user_exists) data: CreateMachineData, user: User = Depends(check_user_exists)
) -> Machine: ) -> Machine:
await _assert_wallet_owned_by(data.wallet_id, user.id) await _assert_wallet_owned_by(data.wallet_id, user.id)
await _assert_no_pubkey_collision(data.machine_npub) # machine_npub is optional: blank = register UNPAIRED — the bunker mints
# the identity at pairing (the normal path). An npub supplied up front is
# the development self-key path; only then do we collision-check + publish
# a fee config now (an unpaired machine has no target yet, so it gets its
# config at pairing instead — see api_pair_machine).
if data.machine_npub:
await _assert_no_pubkey_collision(data.machine_npub)
await _assert_machine_fee_cap_safe( await _assert_machine_fee_cap_safe(
data.operator_cash_in_fee_fraction, data.operator_cash_in_fee_fraction,
data.operator_cash_out_fee_fraction, data.operator_cash_out_fee_fraction,
@ -268,10 +290,98 @@ async def api_create_machine(
# Layer 2 (#39): publish initial fee config to the ATM so it can # Layer 2 (#39): publish initial fee config to the ATM so it can
# unblock past its `awaiting-fees` maintenance gate. Soft-fails on # unblock past its `awaiting-fees` maintenance gate. Soft-fails on
# transport errors — machine creation has already succeeded. # transport errors — machine creation has already succeeded.
if machine.machine_npub:
super_config = await get_super_config()
if super_config is not None:
await publish_fee_config(machine, super_config, user.id)
return machine
@spirekeeper_api_router.post(
"/api/v1/dca/machines/{machine_id}/pair", response_model=PairResult
)
async def api_pair_machine(
machine_id: str,
data: PairMachineData,
user: User = Depends(check_user_exists),
) -> PairResult:
"""Seed-URL pairing (S0 / #9, model A1). Mints a per-spire signing key
inside the operator's nsecbunkerd and returns the one-shot seed URL the
spire redeems at first boot. The bunker-minted key becomes the machine's
npub, so lnbits' path-B roster routes the spire's cash-out RPCs to this
operator's wallet — no nsec ever lands on the spire.
Re-pair is supported (re-issues a token for the same spire key).
`duration_hours` (optional) time-bounds the token; revoke via the
sibling `POST .../revoke` endpoint."""
machine = await _machine_owned_by(machine_id, user.id)
if not data.relays:
raise HTTPException(HTTPStatus.BAD_REQUEST, "at least one relay is required")
try:
async with NsecBunkerAdminClient.from_settings() as client:
result = await pair_spire(
machine,
relays=data.relays,
admin_client=client,
bunker_relay=data.bunker_relay,
duration_hours=data.duration_hours,
)
except NsecBunkerNotConfiguredError as exc:
raise HTTPException(
HTTPStatus.SERVICE_UNAVAILABLE,
f"nsecbunkerd is not configured on this LNbits instance: {exc}",
) from exc
except (PairingError, NsecBunkerError) as exc:
raise HTTPException(HTTPStatus.BAD_GATEWAY, f"pairing failed: {exc}") from exc
# The bunker-minted identity becomes the machine npub — run the same
# collision guard as create before persisting (fresh keys ~never collide,
# but defence-in-depth keeps the no-collision invariant intact).
await _assert_no_pubkey_collision(result.spire_pubkey_hex)
await set_machine_pairing(
machine_id,
machine_npub=result.spire_pubkey_hex,
bunker_spire_key_name=result.bunker_key_name,
paired_at=datetime.now(timezone.utc),
)
# Now that the machine has a bunker identity, publish its fee config so
# the spire can clear its `awaiting-fees` gate. For a machine created
# unpaired, this is the first time it has a target. Soft-fails (mirrors
# create); pairing has already succeeded.
super_config = await get_super_config() super_config = await get_super_config()
if super_config is not None: if super_config is not None:
await publish_fee_config(machine, super_config, user.id) paired = await _machine_owned_by(machine_id, user.id)
return machine await publish_fee_config(paired, super_config, user.id)
return result
@spirekeeper_api_router.post(
"/api/v1/dca/machines/{machine_id}/revoke", response_model=RevokeResult
)
async def api_revoke_machine(
machine_id: str,
user: User = Depends(check_user_exists),
) -> RevokeResult:
"""Revoke a spire's bunker access — the "Revoke spire access" UX
(#9/#12). Cuts the spire's signing ability at the bunker
(`KeyUser.revokedAt` via `revoke_key_user`; token-revoke alone is a
no-op once the token is redeemed see #22), then marks the machine
unpaired. `revoked_count` >= 1 = access cut; 0 = nothing was bound."""
machine = await _machine_owned_by(machine_id, user.id)
try:
async with NsecBunkerAdminClient.from_settings() as client:
revoked_count = await revoke_spire(machine, admin_client=client)
except NsecBunkerNotConfiguredError as exc:
raise HTTPException(
HTTPStatus.SERVICE_UNAVAILABLE,
f"nsecbunkerd is not configured on this LNbits instance: {exc}",
) from exc
except (PairingError, NsecBunkerError) as exc:
raise HTTPException(HTTPStatus.BAD_GATEWAY, f"revoke failed: {exc}") from exc
await set_machine_unpaired(machine_id)
return RevokeResult(revoked_count=revoked_count)
@spirekeeper_api_router.get("/api/v1/dca/machines", response_model=list[Machine]) @spirekeeper_api_router.get("/api/v1/dca/machines", response_model=list[Machine])
@ -971,6 +1081,12 @@ async def api_update_super_config(
) )
if super_fractions_changed: if super_fractions_changed:
for machine in await list_all_active_machines(): for machine in await list_all_active_machines():
# Unpaired machines (machine_npub is None — nullable since #29/m011)
# have no Nostr identity to publish a fee-config beacon to. Skip
# them; they pick up the current fee config when they pair
# (api_pair_machine publishes on success).
if not machine.machine_npub:
continue
await publish_fee_config(machine, config, machine.operator_user_id) await publish_fee_config(machine, config, machine.operator_user_id)
return config return config
@ -1037,6 +1153,14 @@ async def api_publish_machine_cassettes(
500 anything else from the publish path 500 anything else from the publish path
""" """
machine = await _machine_owned_by(machine_id, user.id) machine = await _machine_owned_by(machine_id, user.id)
if not machine.machine_npub:
# Unpaired machine (machine_npub None — nullable since #29/m011) has no
# ATM identity to publish a cassette config to. Fail fast with a clean
# 400 instead of crashing publish_to_atm's normalize_public_key(None).
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"machine is not paired — pair it before publishing cassette config",
)
existing = await list_cassette_configs_for_machine(machine_id) existing = await list_cassette_configs_for_machine(machine_id)
existing_positions = {row.position for row in existing} existing_positions = {row.position for row in existing}