get_machine_config kind-21000 RPC — deliver operator pubkey + fee config over the transport (bitspire#70 P1) #41

Closed
opened 2026-07-02 19:15:24 +00:00 by padreug · 1 comment
Owner

Summary

Add a get_machine_config RPC on the kind-21000 nostr transport so a paired ATM can pull its operator pubkey + fee config (+ fiat, cassettes) over the already-authenticated channel, instead of the operator pubkey being provisioned into the machine's .env and the fee config being learned only from an operator-signed kind-30078 broadcast.

This is the server half of bitspire#70 P1. The client half is aiolabs/bitspire (see that repo's companion issue). It's what lets a seed-only machine (blank .env, scanned spire-seed) leave "awaiting configuration" with zero provisioning.

Why (design rationale — so this isn't re-litigated)

A paired ATM was getting stuck on "awaiting configuration": the ATM only trusts fee config (kind-30078) signed by an operator pubkey it holds in VITE_OPERATOR_PUBKEYS, but the pairing seed doesn't carry that pubkey, so a seed-only machine has an empty operator allowlist → the Fees service disables itself. The obvious fix is "add operator_npub to the seed" — but that would be the 3rd/4th npub stuffed into the pairing blob, and a review of comparable systems says that's the wrong direction:

  • NIP-47 (NWC): the connection URI is only {wallet-service pubkey, relay, secret}. Capabilities are discovered after connect via the kind-13194 info event — nothing else goes in the URI.
  • NIP-46: bunker:// is only {signer pubkey, relays, one-shot secret}; the client learns the actual identity via get_public_key post-connect.
  • Lightning.Pub (our own lineage): pairing blob = an nprofile (server pubkey + relays) + a one-shot token; then GetServiceState streams config down including admin_npub (the operator identity). It delivers the operator key over the wire, not in the pairing blob.

The seed's irreducible floor is two channel anchorslnbits_npub+relays (to reach/auth LNbits) and spire_npub+bunker_secret (to redeem the NIP-46 bunker; can't leave, you can't connect to a bunker without its pubkey). The operator pubkey is not a channel anchor — it's config/policy — so it belongs on the LNbits channel once open, exactly like LP's admin_npub.

Trust: delivering the operator pubkey over the transport means LNbits introduces the operator identity. On this stack that doesn't widen the trust base — spirekeeper/satmachineadmin is an LNbits extension in the same trust domain as the transport server, and the ATM already trusts LNbits with the wallet and server-side fee computation (create_withdraw). So the operator-signature-on-kind-30078 check is largely redundant here; config-over-transport aligns config trust with the trust already extended for money.

Why it's clean server-side (the enabling invariant)

The transport is already per-machine authenticated. An inbound kind-21000 event is signature-verified, and sender_pubkey is the ATM's bunker-minted spire key — which is dca_machines.machine_npub. So at dispatch time the server resolves the exact machine → wallet → operator purely from the verified sender, with no client-supplied trust. A get_machine_config reply can therefore be scoped to that machine with zero new auth primitive.

Evidence:

  • machine_npub = spire_pubkey_hex, granted kind-21000 signing at pair: pairing.py (pair_spire, policy grant), views_api.py (api_pair_machine, machine_npub = result.spire_pubkey_hex)
  • roster resolves sender → machine: nostr_transport_roster.py (resolve()get_machine_by_atm_pubkey_hex), crud.py (get_machine_by_atm_pubkey_hex)
  • verified sender + roster override in dispatch/auth: lnbits nostr_transport/dispatcher.py, auth.py, roster.py

Proposed implementation

Register a new RPC from spirekeeper's extension init (next to register_with_lnbits / register_roster_resolver in nostr_transport_roster.py), via lnbits' register_rpc(name, handler, auth_level):

get_machine_config  (auth: roster-resolved / AUTH_ACCOUNT)
  handler(request):
    machine   = get_machine_by_atm_pubkey_hex(request.sender_pubkey)   # crud.py
    super_cfg = get_super_config()
    fee       = build_fee_payload(super_cfg, machine)                   # fee_transport.py (pure, already used by the kind-30078 path)
    operator  = get_account(machine.operator_user_id).pubkey           # nostr_publish.py resolve_operator_signer uses the same
    return {
      operator_pubkey,           # the trust anchor absent from the seed
      fee_config: fee.to_wire_dict(),
      fiat_code: machine.fiat_code,
      cassettes: <optional>,
      machine_npub, wallet_id,
      created_at: <monotonic watermark>   # so the client's existing replay guards keep working
    }

Notes:

  • Reuses existing pure builders (build_fee_payload) and the same operator-pubkey resolution as the publish path — no new signer path.
  • Reply confidentiality is free: the transport already NIP-44 v2 encrypts the reply to the sender; no operator-key encryption round-trip needed.
  • No DB migration, no seed-shape change.
  • created_at watermark: include a monotonic value (e.g. the super-config / machine updated_at) so the bitspire client can keep its applyFeeConfig replay-watermark guards unchanged.

Migration / compatibility

  • Additive. Keep the kind-30078 operator-signed publish path (dual-run) — production ATMs on main and the current bitspire consumer still expect it. The RPC is the new pull path; the push remains for live mid-run fee updates.
  • Live updates: the RPC covers boot/reconnect; for push-on-change, the existing kind-30078 emit (or a lightweight "config changed, re-fetch" signal) still applies. A minimal first cut can lean on the ATM re-pulling on reconnect/boot.

Process

Money-adjacent extension → feature-branch + PR (hand off to merge via the Forgejo UI). Companion client issue: aiolabs/bitspire (P1 consumer). Related: bitspire#70, this repo's #37/#38.

## Summary Add a `get_machine_config` RPC on the kind-21000 nostr transport so a paired ATM can **pull its operator pubkey + fee config (+ fiat, cassettes) over the already-authenticated channel**, instead of the operator pubkey being provisioned into the machine's `.env` and the fee config being learned only from an operator-signed kind-30078 broadcast. This is the server half of bitspire#70 **P1**. The client half is aiolabs/bitspire (see that repo's companion issue). It's what lets a **seed-only** machine (blank `.env`, scanned `spire-seed`) leave "awaiting configuration" with **zero provisioning**. ## Why (design rationale — so this isn't re-litigated) A paired ATM was getting stuck on "awaiting configuration": the ATM only trusts fee config (kind-30078) signed by an operator pubkey it holds in `VITE_OPERATOR_PUBKEYS`, but the pairing seed doesn't carry that pubkey, so a seed-only machine has an empty operator allowlist → the Fees service disables itself. The obvious fix is "add `operator_npub` to the seed" — but that would be the **3rd/4th npub** stuffed into the pairing blob, and a review of comparable systems says that's the wrong direction: - **NIP-47 (NWC)**: the connection URI is *only* `{wallet-service pubkey, relay, secret}`. Capabilities are discovered *after* connect via the kind-13194 `info` event — nothing else goes in the URI. - **NIP-46**: `bunker://` is *only* `{signer pubkey, relays, one-shot secret}`; the client learns the actual identity via `get_public_key` post-connect. - **Lightning.Pub (our own lineage)**: pairing blob = an `nprofile` (server pubkey + relays) + a one-shot token; then `GetServiceState` streams config down **including `admin_npub`** (the operator identity). It delivers the operator key over the wire, not in the pairing blob. The seed's irreducible floor is **two channel anchors** — `lnbits_npub`+`relays` (to reach/auth LNbits) and `spire_npub`+`bunker_secret` (to redeem the NIP-46 bunker; can't leave, you can't connect to a bunker without its pubkey). The **operator pubkey is not a channel anchor — it's config/policy** — so it belongs on the LNbits channel once open, exactly like LP's `admin_npub`. **Trust:** delivering the operator pubkey over the transport means LNbits *introduces* the operator identity. On this stack that doesn't widen the trust base — spirekeeper/satmachineadmin **is** an LNbits extension in the same trust domain as the transport server, and the ATM already trusts LNbits with the wallet and server-side fee computation (`create_withdraw`). So the operator-signature-on-kind-30078 check is largely redundant *here*; config-over-transport aligns config trust with the trust already extended for money. ## Why it's clean server-side (the enabling invariant) The transport is **already per-machine authenticated**. An inbound kind-21000 event is signature-verified, and `sender_pubkey` is the ATM's bunker-minted spire key — which *is* `dca_machines.machine_npub`. So at dispatch time the server resolves the exact machine → wallet → operator purely from the verified sender, with no client-supplied trust. A `get_machine_config` reply can therefore be scoped to *that* machine with zero new auth primitive. Evidence: - `machine_npub = spire_pubkey_hex`, granted kind-21000 signing at pair: `pairing.py` (`pair_spire`, policy grant), `views_api.py` (`api_pair_machine`, `machine_npub = result.spire_pubkey_hex`) - roster resolves sender → machine: `nostr_transport_roster.py` (`resolve()` → `get_machine_by_atm_pubkey_hex`), `crud.py` (`get_machine_by_atm_pubkey_hex`) - verified sender + roster override in dispatch/auth: lnbits `nostr_transport/dispatcher.py`, `auth.py`, `roster.py` ## Proposed implementation Register a new RPC from spirekeeper's extension init (next to `register_with_lnbits` / `register_roster_resolver` in `nostr_transport_roster.py`), via lnbits' `register_rpc(name, handler, auth_level)`: ``` get_machine_config (auth: roster-resolved / AUTH_ACCOUNT) handler(request): machine = get_machine_by_atm_pubkey_hex(request.sender_pubkey) # crud.py super_cfg = get_super_config() fee = build_fee_payload(super_cfg, machine) # fee_transport.py (pure, already used by the kind-30078 path) operator = get_account(machine.operator_user_id).pubkey # nostr_publish.py resolve_operator_signer uses the same return { operator_pubkey, # the trust anchor absent from the seed fee_config: fee.to_wire_dict(), fiat_code: machine.fiat_code, cassettes: <optional>, machine_npub, wallet_id, created_at: <monotonic watermark> # so the client's existing replay guards keep working } ``` Notes: - **Reuses existing pure builders** (`build_fee_payload`) and the same operator-pubkey resolution as the publish path — no new signer path. - **Reply confidentiality is free**: the transport already NIP-44 v2 encrypts the reply to the sender; no operator-key encryption round-trip needed. - **No DB migration, no seed-shape change.** - **`created_at` watermark**: include a monotonic value (e.g. the super-config / machine `updated_at`) so the bitspire client can keep its `applyFeeConfig` replay-watermark guards unchanged. ## Migration / compatibility - **Additive.** Keep the kind-30078 operator-signed publish path (dual-run) — production ATMs on `main` and the current bitspire consumer still expect it. The RPC is the new pull path; the push remains for live mid-run fee updates. - Live updates: the RPC covers boot/reconnect; for push-on-change, the existing kind-30078 emit (or a lightweight "config changed, re-fetch" signal) still applies. A minimal first cut can lean on the ATM re-pulling on reconnect/boot. ## Process Money-adjacent extension → **feature-branch + PR** (hand off to merge via the Forgejo UI). Companion client issue: aiolabs/bitspire (P1 consumer). Related: bitspire#70, this repo's #37/#38.
Author
Owner

Companion client issue: aiolabs/bitspire#71.

Companion client issue: aiolabs/bitspire#71.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/spirekeeper#41
No description provided.