Some checks failed
ci.yml / feat(transport): get_machine_config RPC — server-delivered machine config (#41) (pull_request) Failing after 0s
A paired ATM pulls its operator pubkey + fee config over the already-
authenticated kind-21000 transport, instead of the operator pubkey being
provisioned into the machine's .env and the fee config learned only from an
operator-signed kind-30078 broadcast. This lets a seed-only machine (blank
.env, scanned spire-seed) leave "awaiting configuration" with zero per-machine
provisioning — closing the gap surfaced by bitspire#70. Client half: bitspire#71.
The transport is already per-machine authenticated (the ATM signs with its
bunker-minted spire key == dca_machines.machine_npub), so the handler resolves
the exact machine → operator from the verified request.sender_pubkey — same
lookup as cashin_transport, no client-supplied trust — and returns only the
caller's own config. Returns {operator_pubkey, fee_config, fiat_code,
machine_npub, wallet_id, created_at}; fee_config is None until the operator has
a super-config. Reuses build_fee_payload + get_account; AUTH_ACCOUNT; registers
soft-failing (older lnbits) like the roster hook. kind-30078 push stays
dual-run for live mid-run updates.
6 tests; full suite 235 pass; black + ruff clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
103 lines
4.6 KiB
Python
103 lines
4.6 KiB
Python
"""
|
|
Server-delivered machine config: a `get_machine_config` nostr-transport RPC
|
|
(aiolabs/spirekeeper#41, client half aiolabs/bitspire#71 — bitspire#70 P1).
|
|
|
|
A paired ATM pulls its **operator pubkey + fee config** over the already-
|
|
authenticated kind-21000 transport, 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 lets a seed-only machine (blank
|
|
`.env`, scanned `spire-seed`) leave "awaiting configuration" with zero
|
|
per-machine provisioning.
|
|
|
|
Why it's safe with no client-supplied trust: the transport is already
|
|
per-machine authenticated — the ATM signs its kind-21000 requests with its
|
|
bunker-minted spire key, which is `dca_machines.machine_npub`. So the handler
|
|
resolves the exact machine → operator purely from the verified
|
|
`request.sender_pubkey` (same lookup as `cashin_transport`), and only ever
|
|
returns the caller's OWN config. The reply is NIP-44 v2 encrypted to the sender
|
|
by the transport, so no operator-key encryption is needed here.
|
|
|
|
Delivering the operator pubkey re-enables the ATM's fees/operator-config/
|
|
management services (which disable themselves with an empty operator allowlist);
|
|
delivering the fee config synchronously means the machine doesn't depend on the
|
|
replaceable kind-30078 event being fetchable from the relay. The kind-30078
|
|
publish path stays (dual-run) for live mid-run fee updates.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from lnbits.core.crud.users import get_account
|
|
from loguru import logger
|
|
|
|
from .crud import get_machine_by_atm_pubkey_hex, get_super_config
|
|
from .fee_transport import build_fee_payload
|
|
|
|
_RPC_NAME = "get_machine_config"
|
|
|
|
|
|
async def handle_get_machine_config(auth, request) -> dict:
|
|
"""nostr-transport RPC handler. `auth` is the roster-resolved auth context
|
|
(unused — the machine is identified from the signature, not the wallet);
|
|
`request` is a NostrRpcRequest with `sender_pubkey` (verified).
|
|
|
|
Returns `{operator_pubkey, fee_config, fiat_code, machine_npub, wallet_id,
|
|
created_at}`. `fee_config` is None until the operator has a super-config.
|
|
Raises ValueError (→ transport ERROR reply) for an unpaired sender or an
|
|
operator with no Nostr pubkey on file."""
|
|
# Identity 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("sender pubkey is not a paired machine")
|
|
|
|
account = await get_account(machine.operator_user_id)
|
|
if account is None or not account.pubkey:
|
|
raise ValueError("operator has no Nostr pubkey on file")
|
|
|
|
# Freshness watermark for the ATM's fee-config replay guard: the latest
|
|
# updated_at across the two inputs to the fee config (per-machine fractions
|
|
# + super-config), so any config change advances it — and a subsequent
|
|
# kind-30078 push with a newer created_at still supersedes it.
|
|
watermark = int(machine.updated_at.timestamp())
|
|
|
|
fee_config = None
|
|
super_config = await get_super_config()
|
|
if super_config is not None:
|
|
fee_config = build_fee_payload(super_config, machine).to_wire_dict()
|
|
watermark = max(watermark, int(super_config.updated_at.timestamp()))
|
|
|
|
logger.info(
|
|
f"spirekeeper: get_machine_config machine={machine.id} "
|
|
f"operator={account.pubkey[:12]}… fee_config="
|
|
f"{'present' if fee_config else 'none'} created_at={watermark}"
|
|
)
|
|
return {
|
|
"operator_pubkey": account.pubkey,
|
|
"fee_config": fee_config,
|
|
"fiat_code": machine.fiat_code,
|
|
"machine_npub": machine.machine_npub,
|
|
"wallet_id": machine.wallet_id,
|
|
"created_at": watermark,
|
|
}
|
|
|
|
|
|
def register_machine_config_rpc() -> None:
|
|
"""Register `get_machine_config` with the lnbits nostr transport. Soft-fails
|
|
if the transport doesn't expose `register_rpc` (older lnbits) — spirekeeper
|
|
still boots; ATMs then fall back to env/kind-30078 config."""
|
|
try:
|
|
from lnbits.core.services.nostr_transport.dispatcher import ( # type: ignore
|
|
AUTH_ACCOUNT,
|
|
register_rpc,
|
|
)
|
|
except ImportError:
|
|
logger.warning(
|
|
"spirekeeper: nostr-transport register_rpc unavailable; "
|
|
"'get_machine_config' not registered (bitspire#70 P1 pull disabled)"
|
|
)
|
|
return
|
|
register_rpc(_RPC_NAME, handle_get_machine_config, AUTH_ACCOUNT)
|
|
logger.info("spirekeeper: registered nostr-transport RPC 'get_machine_config'")
|