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>