Source the operator pubkey + fee config from LNbits via the get_machine_config
kind-21000 RPC (spirekeeper#41) right after list_wallets, instead of the
operator pubkey coming only from VITE_OPERATOR_PUBKEYS (env). A seed-only
machine (blank .env) had an empty operator allowlist → the fees/operator-config
services disabled themselves → permanent "awaiting configuration". Now it pulls
its config over the already-authenticated channel and configures itself with
zero per-machine provisioning — closing bitspire#70 P1.
- LnbitsClient.getMachineConfig() → sendRpc('get_machine_config') + the
MachineConfigResponse / FeeConfigWire types.
- lightning.ts, only when VITE_OPERATOR_PUBKEYS is empty (env override still
wins): set CONFIG.operatorPubkeys from operator_pubkey (re-enables the
services), and persist fee_config via the existing applyFeeConfig IPC (mapping
snake_case → camelCase) so atm.ts's awaiting-fees gate clears immediately —
robust to the replaceable kind-30078 not being fetchable from the relay. The
live kind-30078 subscription still handles mid-run fee updates.
- Soft-fail: older spirekeeper (no RPC) or a transport error falls back to the
env/kind-30078 path.
lnbits + machine typecheck clean; lnbits suite 29 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the cash-in LNURL-withdraw creation with the secure create_withdraw
RPC (aiolabs/spirekeeper#31/#32). The ATM now sends only the hardware-attested
gross principal_sats; the operator side verifies the signer, derives fee + NET,
and stamps the link's attribution (source/nostr_sender_pubkey) from the VERIFIED
sender. Closes the dev-stack weakness where the ATM set the withdraw amount +
extra itself (could understate the fee / forge attribution).
- LnbitsClient.createWithdraw(walletId, {principal_sats, fiat_amount?, fiat_code?,
title?, wait_time?, client_ref?}) -> {link_id, lnurl, net_sats, principal_sats,
fee_sats}. Non-idempotent (mints a link) -> not retry-wrapped.
- lightning.ts generateLnurlWithdraw: createWithdrawLink -> createWithdraw; the
ATM no longer computes amount/fee/extra. LNURL-session map re-keyed on link_id
(the secure response carries no unique_hash); settlement-watch half unchanged
(subscribe_payments tag:'withdraw', link_id).
Server RPC is live on the dev stack (spirekeeper#32 registered create_withdraw),
so this is ready for the joint cash-in test. typecheck 12/12, full suite + prod
build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The retry half of the 2026-05-26 error-handling agreement (aiolabs/bitspire#52).
`withRetry` retries an operation per the disposition of the error it throws —
LnbitsRpcError.retryPolicy (operator_signer_unavailable/rate_limited →
backoff, internal_error → retry-once) plus transport timeouts — and rethrows
terminal/unknown errors immediately.
Applied ONLY to idempotent reads (getWallet/getBalance/listWallets/getPayment/
decodePayment + the lnurlw read methods). create_invoice / pay_invoice /
lnurlw_create_link are deliberately NOT wrapped — a blind retry would mint a
duplicate or double-pay; their errors surface for flow-level handling. This is
why the switch lives at the per-call read layer, not as a blanket client retry.
Safe to land before lnbits emits error_code: an absent code already maps to
internal_error (retry-once), so reads get one transparent retry on a transient
blip with no behaviour change otherwise. 10 tests (backoff/terminal/timeout/
unknown/onRetry).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements the error-handling layer agreed in the 2026-05-26 cross-session
handshake (aiolabs/bitspire#52). LnbitsClient now rejects ERROR responses
with a typed LnbitsRpcError carrying the machine-readable code + its retry
disposition, so callers (and the state machine, Phase D.3) branch on
disposition rather than string-matching the human-readable message.
- error-codes.ts: LnbitsErrorCode (14 codes, signer/transport/app classes)
mirroring the lnbits canonical enum; retryPolicyFor() classifier;
LnbitsRpcError.fromResponse().
- error_code is optional-additive on the wire: an absent or unknown code
maps to internal_error (retry-once), so this is safe to land before lnbits
emits codes — no string-matching, no special parser paths.
- invoice_already_paid is flagged terminal-idempotent (isIdempotentSuccess)
for the cash-out resume-after-reboot case.
Part of Phase D, aiolabs/bitspire#52.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduce a Signer interface (signEvent / nip44Encrypt / nip44Decrypt +
sync pubkey) with an in-process LocalSigner backed by an nsec, and route
every signing/encryption call site through it. Behaviour is unchanged —
LocalSigner wraps the same MachineIdentity the code used directly before.
This is Phase A of the bunker migration (aiolabs/bitspire#52): it puts the
seam in place so Phase B can drop in a NIP-46 BunkerSigner at the bootstrap
without touching any call site. The whole chain becomes async (the bunker
path is a relay round-trip; LocalSigner resolves immediately).
Sites moved onto the signer:
- packages/nostr-client: createSignedEvent / createAuthEvent (now async),
NostrClient config (signer not identity), AUTH challenge handler.
- packages/lnbits: LnbitsClient.initialize(nostr, signer); kind-21000 RPC
encrypt + sign + reply-decrypt; handleReply is now async (event-id dedup
still runs synchronously before the awaited decrypt, so replay safety and
per-subscription hash dedup are preserved).
- apps/machine: lightning.ts builds a LocalSigner and exposes it on
LightningServices; operator-config / operator-fees / availability beacon /
maintenance beacon / fund-atm all sign + encrypt via the signer.
NIP-42 auth (kind 22242) is included — under the bunker it must be in the
spire policy (aiolabs/spirekeeper#26, already merged).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Under path B (NOSTR_TRANSPORT_ROSTER_REQUIRED=true), lnbits's
roster-lookup override routes create_invoice to the operator's
wallet, but the subsequent subscribe_payments was scoping its
filter to the ATM's pre-override wallet_id. The dispatcher
AND-filters payment_hash + wallet_id, so the settlement on the
operator wallet was invisible to the subscription — bitspire
stayed in "Watching invoice" forever, dispense never fired.
Omit wallet_id on the single-invoice watcher: lnbits already
resolves the wallet from get_standalone_payment(payment_hash)
and ownership-checks against the auth'd account. Works pre/post-
override; payment_hash is the natural primary key for "wait for
THIS invoice" anyway.
Cash-out subscription site at services/lightning.ts:1008-1010
(production caller) + watchInvoice convenience helper at
packages/lnbits/src/client.ts:286-313 both flipped.
LNURL-withdraw subscription at services/lightning.ts:720
(filter: tag+link_id) is the symmetric case but pending lnbits
confirmation that the tag+link_id branch of _resolve_owner_wallet_id
exists alongside the payment_hash branch.
Coordination: ~/dev/coordination/log.md 2026-05-31T18:35Z (joint
smoke surfaced the bug), 18:40Z (bitspire diagnosis), 18:50Z
(lnbits narrowed the fix shape + confirmed path-2 works against
deployed lnbits today).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes aiolabs/lamassu-next#50. Builds on #49 (commit 0dcbe44):
isAuthenticServerEvent now Schnorr-verifies inbound events, so ev.id
is by construction the id of a server-signed event and can be used
as a dedup key without risk of attacker pre-poisoning.
Two layers:
1. Client-global `seenEventIds` (Set<string>, FIFO cap 1000) in
`LnbitsClient.handleReply`. Skips events whose id has been seen.
Catches exact-replay — relay re-delivers the same bytes after
reconnect, or any other source that emits a bit-identical event.
Without #49's guard, an attacker could pre-poison this set with
chosen ids; with the guard, every entry is a server-signed event.
2. Per-subscription `seenPaymentHashes` (Set<string>, FIFO cap 500)
on `ActiveSubscription`. Skips `onPush` invocations whose payment
hash has been seen on the same subscription. Catches logical
duplicates — server fan-out across two relays produces two
different ev.ids carrying the same payment_hash, which the
client-global ev.id layer can't dedup but the per-sub hash layer
does. Scoped per-sub so independent subscriptions seeing the same
hash for their own reasons still fire.
Why this matters in production: without dedup, a relay rebroadcast of
a settlement push would invoke `onPush` twice. The state machine's
`watchInvoice` callback resolves on the first push and unsubscribes,
but a race between the second push and the unsubscribe round-trip
could land a second `dispenseCash()` for the same cash-out — customer
walks away with double the cash. Per-sub `payment_hash` is the only
field guaranteed-unique per settlement (the customer's payment hash
is fixed at invoice creation), so it's the right dedup key.
5 new tests:
- forged event doesn't poison `seenEventIds` (proves guard + dedup
interact: a refactor that removes either #49's guard or this
patch's `seenEventIds.add()` ordering would fail this test)
- exact-replay: same event injected twice, callback fires once
- distinct ev.ids with same payment_hash, callback fires once
(per-sub hash dedup; ev.id dedup doesn't apply)
- distinct payment_hashes fire normally (negative dedup case)
- per-sub isolation: same hash on two subs fires both callbacks
Total: 11/11 lnbits package tests pass. Workspace typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to commit 0dcbe44 (closesaiolabs/lamassu-next#49) addressing
two review notes from the bitspire session:
1. Integration test gap. The four unit tests on isAuthenticServerEvent
cover the predicate in isolation — a refactor that dropped the
`if (!isAuthenticServerEvent(...)) return` line from handleReply
would still pass them silently. Adds two integration tests that
exercise the full handleReply wiring through a mock NostrClient
that captures the subscribe callback:
- Forged event injected through the callback → pre-registered
pending entry's resolve is NOT called (asserts handleReply
short-circuited).
- Legitimate server-signed reply (NIP-44 v2 encrypted with
real keys) → pending entry's resolve IS called with the
decoded payload (positive sanity).
2. `@internal` JSDoc on isAuthenticServerEvent. The helper is exported
only so the unit tests can reach it directly; production callers
should go through handleReply. The annotation makes the intent
explicit and discourages accidental wider use.
6/6 tests pass. Workspace typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LnbitsClient.handleReply matched replies by request_id alone — no
verification that the inbound event was actually signed by the
configured LNbits server pubkey. The relay's subscription `authors`
filter is relay-honour, not relay-enforced; a malicious or buggy
relay could forward an event with the server's pubkey in the body
but signed by a different key (or with a tampered body whose id no
longer matches).
Adds `isAuthenticServerEvent(ev, expectedPubkey)`:
- `ev.pubkey === expectedPubkey` (explicit, not relying on filter)
- `verifyEvent(ev)` (catches sig/id/content tampering)
handleReply calls it before passing the event to decryptContentV2.
NIP-44 v2 already binds ciphertext to sender via ECDH, so a relay
without the server's nsec can't forge decryptable content — but
verifying the outer event keeps `ev.id` trustworthy for any
downstream dedup/logging code and matches the symmetric defence on
the server side (`nostr_transport/relay_pool.py:~320`) and on the
lnbits-bunker-client side (`aiolabs/lnbits` commit 4ebcd959,
`NsecBunkerAdminClient._match_response`).
Test `packages/lnbits/src/__tests__/client.test.ts` covers:
- legitimate server-signed event accepted
- event whose pubkey field doesn't match config rejected
- forged event (signed by attacker, pubkey overwritten to server)
rejected — recomputed id no longer matches stored id
- event with tampered content (id mismatch) rejected
Gotcha worth noting: `finalizeEvent` stamps `event[verifiedSymbol] = true`
to cache the verification result, and `{...ev}` spread copies symbol-
keyed properties. So forged/tampered events constructed via spread
inherit the cached `true` and `verifyEvent` short-circuits. The test
JSON-round-trips through `stripVerifiedCache` to drop the cache.
Closesaiolabs/lamassu-next#49.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a 5-minute expiration tag to every outbound RPC envelope. Belt-
and-suspenders with the handler-side max_age check (aiolabs/lnbits
e4b5bcd7) — the tag lets compliant relays drop expired events at the
relay layer before they reach LNbits, while the handler's own
time-bounds check defends against a stripped tag.
Closesaiolabs/satmachineadmin#15 (S1 / G4 — no replay window on RPC
events) on the ATM emission side.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New TypeScript workspace package targeting aiolabs/lnbits's
nostr-native-transport (kind-21000 NIP-44 v2 RPC). Designed as a
drop-in peer of @bitSpire/lightning's LightningPubClient so the
machine app can swap backends with minimal churn (3b).
packages/lnbits/
package.json workspace + nostr-tools deps
tsconfig.json extends root config
src/types.ts wire envelope, Payment, WalletInfo,
SubscribePayments filter / ack / push,
WithdrawLink, PayLink, callback types
src/client.ts LnbitsClient with:
getWallet / getBalance / listWallets
createInvoice / payInvoice
getPayment / decodePayment
subscribePayments / unsubscribe
watchInvoice (resolve-on-paid)
watchWallet (cancel-fn)
createWithdrawLink / getWithdrawLink
listWithdrawLinks / updateWithdrawLink
deleteWithdrawLink
getWithdrawLinkUniqueHashes
src/index.ts public exports
Key differences vs LightningPubClient:
- Auth: signing pubkey IS the credential (no authIdentifier/appId
in the request envelope).
- Subscriptions: one `subscribe_payments` RPC handles all three
cases (payment_hash, wallet_id, tag+link_id). Server emits an
ack first, then push events sharing subscription_id. unsubscribe
teardown emits a closed-push immediately followed by ACK.
- Sharding: outbound responses ≤40K plaintext arrive as a single
event. Larger responses come back as multiple kind-21000 events
sharing a `shardsId`; client reassembly is TODO (lazy: ATM RPCs
rarely return that much data).
- CLINK/ndebit/noffer is NOT covered — LNbits has no CLINK. For
the ATM that means cash-in uses lnurlw + subscribe_payments
instead of ndebit (wired in 3b).
Reuses @bitSpire/nostr-client's encryptContentV2 / decryptContentV2
(NIP-44 v2 via nostr-tools/nip44). No new crypto code.
tsconfig.json path mapping added so `@bitSpire/lnbits` imports
resolve to ./packages/lnbits/src across the monorepo.
Verified: pnpm typecheck clean (13/13 turbo tasks).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>