aboutsummaryrefslogtreecommitdiffstats
Commit message (Collapse)AuthorAgeFilesLines
* funds: close deposits, withdrawals and authorizationsAdam Soltys2026-09-211-17/+16
| | | | | | | | | | | | | | | | The fund mechanism is retired. Deposits (POST /payments with `fund`), withdrawals (POST /take) and pre-authorizations (POST /authorize) now refuse unconditionally instead of consulting the `fund:disabled` flag, so the door cannot be reopened by clearing a key. The deposit path never had a kill-switch of its own, so with the flag set users could still pay into funds they were unable to withdraw from. Read paths are left intact: GET /fund/:id and the manager endpoints still serve existing funds so balances stay visible and can be settled out of band. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* users: reserve service names on registration and renameAdam Soltys2026-09-213-4/+35
| | | | | | | | | | | | A username is also a lightning address, so a name that reads as the service collects payments meant for it. "mint" was registered days after the ecash redesign freed it and received payments addressed to [email protected]; the reserved list held only "ecash", and was duplicated in two places. One shared list now, checked on both paths, covering the service-sounding names an account should never be able to take. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* mintdemux: melt the new half of a mixed selection instead of refusingAdam Soltys2026-09-211-7/+54
| | | | | | | | | | | | | | | | | A wallet holding leftovers of both eras selects proofs from each, and the two mints cannot co-sign one melt, so such wallets were stuck for good behind the "melt them separately" error. Drop the old inputs and melt the new ones. The old proofs stay unspent at the old mint while the wallet treats them as spent, so each one is recorded with its keyset, amount, secret hash and whether it carried DLEQ; they were only redeemable through the recovery path anyway. Also stop the routing tables from going empty. The hourly refresh is exempt from the mints' per-ip limiter, so it now seeds the last-good keyset lists the client-facing /v1/keysets falls back on, and a start that races the mints coming up retries every 15s rather than waiting out the hour -- an empty old list makes every old-ecash melt look new and routes it to the wrong mint. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* ecash: identify our own tokens by keyset, not by the mint urlAdam Soltys2026-09-211-20/+28
| | | | | | | | | | | | | | The mint url in a cashu token is a label the sending wallet writes in, and tokens of ours turn up carrying stray ports and paths. Fetching it to compare pubkeys therefore rejected perfectly good tokens, and meant every /cash entry point made an outbound request to a user-supplied address. Keyset ids are derived from the mint's public keys, so a token whose proofs are all on our keysets is ours whatever url it names; a foreign one still fails, and now with a clear message instead of cashu-ts's short-id mapping error. A miss refreshes the cached keyset list once, in case of a rotation. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* docker: build nutshell on the coinos base imageAdam Soltys2026-09-211-0/+57
| | | | | | | | | | | | Multi-stage build: poetry installs the mint into a python:3.12-slim-trixie stage, then /usr/local and /app are copied onto ghcr.io/coinos/base, the same way the lightningd image is assembled. Source is cloned from the coinos nutshell fork at NUTSHELL_REF, and the image runs `mint` directly under tini instead of `poetry run mint`. Python 3.12 rather than trixie's 3.13 because coincurve ships no cp313 wheel. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* lightning: quote the routing fee up front and add a max-send quote endpointAdam Soltys2026-09-214-8/+482
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Users trying to empty their balance over lightning had to guess a maxfee that covered both an unknown routing fee and the platform fee, and the 0.5% floor we imposed on that guess made an exact "send everything" impossible. xpay routes with askrene's getroutes, so ask askrene the same question before we debit and the fee is known exactly. lib/lnquote.ts mirrors what xpay does in plugins/xpay/xpay.c (v26.06): a throwaway private layer carrying the invoice's route hints (bolt11) or blinded paths via xpay's fake destination node (bolt12), then getroutes over the same layer stack in the same order. The fee is the sum of what leaves our node minus what the destination receives, rounded up to a whole sat since that is what xpay's maxfee reserves. POST /lightning/quote returns the routing fee, the platform fee exactly as debit() charges it (free-tier credit, ln:nofree rate, none on sub-accounts) and the total. With `max` it iterates quote / subtract fees / requote so amount + both fees lands exactly on the balance; an offer gets a probe invoice then the real one for the solved amount, and an LNURL caller can pass `ceiling` and a probe invoice the same way. sendLightning now quotes for every send. With no explicit fee the quote becomes the maxfee; an explicit fee below the quote is rejected before any debit instead of failing inside xpay after one; the old 0.5% floor on explicit fees is gone. If the quote itself fails we fall back to the heuristic and let xpay try. finalize rounds the settled fee up to the whole sat that was quoted and reserved; rounding a 301 msat fee to 0 refunded a sat that the quote had already accounted for and stranded it in the account. The prefer-kappa layer is only passed to xpay when it exists on the node; getroutes rejects unknown layers outright, which broke every regtest send. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* lnurl: expire payRequest callback ids after seven daysAdam Soltys2026-09-162-5/+10
| | | | | | | | | A callback id is read once, minutes after the metadata request, and never again, but the pointer keys were written without an expiry and had grown to roughly 45% of all keys in the main database. Let the set helper take a TTL and apply one to both lnurl pointer keys. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* mintdemux: forward the client's rate-limit key on keyset fetchesAdam Soltys2026-09-161-4/+25
| | | | | | | | | | Keyset fetches counted against the demux's own socket IP at both mints, so the shared per-IP bucket tripped under load and wallets received an empty keyset list. Forward the client's key, serve each mint's last-good list when its upstream fails, and mark the demux's own hourly refresh as loopback so the mints' limiter exempts it. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* ecash: redeem tokens by melting to the user's own invoiceAdam Soltys2026-09-167-291/+170
| | | | | | | | | | | Redeeming ecash is now a plain NUT-05 melt into a Lightning invoice owned by the redeeming user, credited by the regular lightning listener. Drop the house proof pool and its lock, the /mint and /melt routes, and the special-casing of a mint account in NWC and payment routing. Tokens are decoded against the mint's keyset list so short v1 ids resolve, and an empty keyset list is never cached. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* printer: publish receipts at QoS 1 and clean LNURL metadata memosAdam Soltys2026-09-142-1/+19
| | | | | | | | | | | | Receipts were published at QoS 0, so a printer that was briefly offline (WiFi blip, keepalive timeout) never received them. QoS 1 lets the broker queue them for printers that hold a persistent session. LNURL-pay invoices carry the raw metadata array as their memo when the payer leaves no comment; unwrap the text/plain entry before printing. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_011cCouc5ASRXHDp5esLciEK
* ecash: make claims work against the v1 keyset mintAdam Soltys2026-09-142-25/+67
| | | | | | | | | | | | | | | cashu-ts 2.9.0 only derives v0 keyset ids, so CashuWallet rejected the mint's current keyset and receive() could never run. Build the wallet from keys preloaded off our own mint instead of verifying, decode tokens with the keysets (2.9.0 shortens v1 ids in the tokens it encodes, and won't expand them otherwise), and treat an unreadable house-pool value as empty rather than throwing — the pool key had been found holding a bare integer, which surfaced as "n.startsWith is not a function" on every redeem. The pool write is awaited so a dropped set can't strand freshly swapped proofs. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_0166Ci2XdCair1B9ych7w8dq
* security: drop the admin endpoint surface and land pending hardeningAdam Soltys2026-09-1318-100/+280
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Removes the admin-gated endpoints and the account flags they set, plus a batch of hardening that had accumulated uncommitted in the tree. These were deployed and verified together, so they land as one change. admin surface: - drop POST /hidepay, POST /unlimit and GET /users, their handlers, and the now-unused admin preValidation export - `unlimited` was only ever written, never read; `hidepay` only appeared in output projections. Both flags go, along with their whitelist, public-fields and type entries. request log: - redactBody now recurses. It only masked top-level keys, so a body that nests credentials one level down wrote them to the log in plaintext, and pin fields were not on the list at all. payments: - sendOnchain/build gate `aid` through requireAccount, so a caller can only spend from an account it owns - sendOnchain signs only a tx we composed, matched against a short-lived per-account build receipt - raise xpay retry_for to 60s; route-finding preferred a drained zero-fee hop and burned the whole budget on it. check()'s reversal window keeps its 2x margin. ecash / invoices / webhooks: - resolve a token's mint through the IP-pinning fetcher instead of plain fetch, closing an unauthenticated SSRF path into the internal network - reject non-positive amounts before the balance write - /sign requires the caller to own the address, rather than signing under any address the hot wallet controls - webhook delivery no longer disables TLS verification; the body carries the merchant's shared secret registration: - refuse a username the v3 registrar holds unless the caller's own nostr key is the holder (lib/names.ts) liquid: - honour the deposit kill switch at invoice generation and at credit elements image: - pin 23.3.4 and verify the release tarball checksum Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_0112TGi9GGNWqji1XQ5jZRzc
* ln listener: skip a settlement whose account is gone before creditingAdam Soltys2026-09-051-0/+9
| | | | | | | | | | credit() now returns without writing for a missing user, but the listener's persistence check after credit() then threw and kept the cursor stuck. Check the account up front and skip like an unknown invoice, recording the preimage in `missing`. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01VMi2Y1oKPZ3TeeFmLej47y
* credit: skip settlements for a deleted account instead of throwingAdam Soltys2026-09-051-0/+10
| | | | | | | | | | | | getUser(inv.uid) returns nothing when the account was deleted but its invoice or bolt12 offer still receives (OCEAN payouts to a deleted user's offer). The destructure on the next line threw inside the lightning listener, which never advanced pay_index and crash-looped the app every 50s, blocking all incoming lightning for two hours. Record the preimage in `missing` and return, as an unknown invoice already does. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01VMi2Y1oKPZ3TeeFmLej47y
* flash: build each config image in its own temp dirAdam Soltys2026-09-041-6/+16
| | | | | | | | | | The handler wrote every request's config.txt and littlefs.img to the same paths, so two overlapping requests could hand one user the other's wifi key and API token. Use a per-request mkdtemp dir, read the image into memory, and remove the dir when done. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01VMi2Y1oKPZ3TeeFmLej47y
* sockets: accept read-only tokens on login, deliver payment events onlyAdam Soltys2026-09-041-1/+12
| | | | | | | | | | | | POS terminals log into the websocket with the "<uid>-ro" token that the web flasher pre-fills. track() looked that id up as a user, failed, and closed the socket a second later, dropping the invoice subscription, so devices were listening about one second in six. Strip the suffix the way lib/auth.ts does for HTTP, flag the socket read-only, and forward only "payment" events to it. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_014gVYGi29FjTeLdNTiutkZx
* meltguard: guard /v1/xpay and parse form-encoded melt bodiesAdam Soltys2026-09-041-8/+38
| | | | | | | | | | | | nutshell >= 0.20.3.1 melts via POST /v1/xpay (form field invstring), while older CLNRest wallets post /v1/pay form-encoded under `invoice` or `bolt11`. The guard only parsed JSON `bolt11`, so form bodies never yielded an amount and were rejected, and an xpay backend would have bypassed the caps entirely. Accept both endpoints and all three field names, forward as form on the same endpoint preserving fee params. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_014uxZLvYS4gH9UqUX2Ey8TV
* payments: finalize settled keysends in sendKeysendAdam Soltys2026-09-041-1/+54
| | | | | | | | | | | | | | keysend blocks until it settles, so a successful return already carries the preimage. Previously only the caller was expected to confirm the record, and the NWC pay_keysend path never did, leaving successful keysends stuck pending forever (check() can't rescue them since the label isn't a bolt11). Add finalizeKeysend(): flip pending->confirmed, derive the real fee from amount_sent_msat, refund the unused fee reserve, and refuse to re-create a record a concurrent reverse() already removed. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_014uxZLvYS4gH9UqUX2Ey8TV
* flash: create printer dir and return errors instead of unhandled 500Adam Soltys2026-09-041-7/+13
| | | | | | | | | | The web flasher config endpoint wrote ./printer/config.txt without creating the directory, so on a fresh deploy it threw ENOENT and the JSON error body was flashed to devices as the LittleFS partition. Create the directory, validate inputs, and route errors through bail. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_014gVYGi29FjTeLdNTiutkZx
* fund: authenticated recovery of a rotated fund's old linkAdam Soltys2026-08-282-5/+30
| | | | | | | | | | | | | | | When a fund's secret id has been rotated (invalidating an exfiltrated id list), its old id 404s. Let a legit owner who bookmarked the old link find the fund again — but reveal the new id ONLY to an authenticated manager of the new fund, via a `fund:rotated:<oldid>` -> newid map. An anonymous caller or a non-owner gets the same "fund not found" as before, so an attacker replaying a leaked old id learns nothing (an open old->new redirect would defeat the rotation). The /fund/:id route now runs `optional` auth so req.user is available without requiring it; bearer funds with no managers are intentionally not recoverable this way. rotate-funds.ts records the mapping on any future rotation. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01DKkev76U4YRnRAqM6jqbae
* scripts: stubborn peer-force LN payer + boltz stuck-swap recoveryAdam Soltys2026-08-272-0/+250
| | | | | | | | | | | | | | | | | | | | | Add scripts/pay-hard.ts: a payment helper for well-connected but inbound-scarce destinations (exchange nodes) that cost-first payers (xpay/renepay) can't reach. When the normal xpay attempt fails, it enumerates the destination's peers, keeps the ones we can reach (preferring peers we already channel with enough outbound), and for each builds a route forced through peer->dest with the invoice's correct final CLTV, probes it with an unclaimable random hash, and pays through the first path that reaches the destination. Probe-before-send, refuses to start with a payment already pending, and stops rather than risk a second in-flight payment. Works against either the main node (cl) or the boltz node (bcln). Add scripts/boltz-recover-stuck.ts: a cron watcher that finds submarine swaps stuck at invoice.pending (lockup confirmed, invoice unexpired, idle past a threshold) and runs pay-hard against bcln to complete them, then lets boltz reconcile (restarting it only if it hasn't picked up the preimage within a grace period). Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01DKkev76U4YRnRAqM6jqbae
* health: tolerate a transient receive-listener lag, fail only if it's stuckAdam Soltys2026-08-271-4/+15
| | | | | | | | | | | | | | | | The listener-backlog probe fired a failure whenever a paid invoice existed beyond the durable cursor — but that is almost always the listener mid- processing that very payment, a normal sub-second lag, not a wedged listener. Those transient catches produced spurious "listener backlog" health failures that then recovered on their own. Grace-check instead: on detecting a paid invoice beyond the cursor, wait briefly and re-read the cursor; if it advanced, the listener is alive and this was transient (pass). Only fail if the cursor did not move at all — the actual "stuck listener" condition the probe exists to catch. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01DKkev76U4YRnRAqM6jqbae
* lnurl: pin SSRF-guard fetches to the validated IP (close DNS-rebinding)Adam Soltys2026-08-271-51/+92
| | | | | | | | | | | | | | | | | | | | | | | | | | The prior guard resolved the target host, checked the IP against the private/loopback/link-local/CGNAT/metadata blocklist, then let got fetch the URL — which re-resolves DNS independently when it connects, leaving a rebinding window where a low-TTL host can pass the check as public and connect as internal. Reimplement safeGot on node http/https with a custom lookup that validates and returns the exact IP the socket then connects to, so the validation and the connection target are one and the same (no resolve/connect gap). Redirects are followed manually and every hop is re-validated the same way; IP-literal hosts (which bypass the lookup hook) are checked up front; the 2MB cap is enforced on the streamed body rather than a spoofable content-length header. Scheme stays restricted to http(s) with an 8s timeout. safeGot's contract is unchanged, so the three lnurl callers are untouched. Verified in-container: every internal target (loopback, docker-internal, 169.254 metadata, CGNAT, ULA, IP-literal, octal-literal) is blocked by the guard, a server-side redirect into the metadata endpoint is blocked at the hop, and legit external resolution (including http->https redirects) still works with correct TLS against the real hostname over the pinned IP. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01DKkev76U4YRnRAqM6jqbae
* harden: remove debug /echo endpoint; widen LN health probe timeoutAdam Soltys2026-08-272-6/+1
| | | | | | | | | | | | | | | Remove the unauthenticated POST /echo route — a request-body reflector with no place in production; it served only to echo attacker probe payloads. Raise the health-check listener-backlog probe timeout from 5s to 15s. The waitanyinvoice probe normally returns in ~1s but can intermittently take ~8s while lightningd is briefly busy; the 5s cap turned those transient spikes into health-check failures (and, at 3 consecutive, an unnecessary process exit / container restart). 15s absorbs the busy spikes while a genuinely wedged RPC still fails the check. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01DKkev76U4YRnRAqM6jqbae
* login: fail-open recaptcha verify on infrastructure errorsAdam Soltys2026-08-251-2/+17
| | | | | | | | | | | | | | | | | | verifyRecaptcha failed closed on ANY error from the Google siteverify call, so a transient coinos<->Google network blip (or a Google slowdown) turned every non-admin login into a "failed captcha" 401 until connectivity recovered — a self-inflicted login outage from an external dependency. Distinguish a real verdict from an infrastructure failure: fail CLOSED only on a genuine success:false from Google; on a thrown error (network, timeout, 5xx) fail OPEN and log it. Reaching Google is not part of authentication — the password and the per-IP login rate limits still gate the request, and captcha is only bot-mitigation, so allowing a login through when the verifier is unreachable is the safer failure mode. Also bound the call with a 6s timeout so a slow verifier can't hang the login. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01XiwgmQKwKnRxUmVDd4xSf7
* take/authorize: kill-switch + validate fund-claim amountsAdam Soltys2026-08-251-4/+37
| | | | | | | | | | | | | | | | | | The /take fund-claim path could pay out unbacked balance: the only ceiling on a take is sats(fiat / rates[currency]) from the authorization, and a crypto-denominated authorization (rate ~1) or a non-finite/zero rate turns that ceiling astronomically large, so a take is no longer bounded by the authorized fiat value. Combined with the fund debit/credit running per-call, an authorizer account could be drained far beyond intent. - Add a `fund:disabled` redis kill-switch to both /authorize and /take that fails the fund/authorize/take mechanism closed while it is audited. - Reject non-finite/zero rates, non-fiat (crypto) authorization currencies, and non-positive/NaN fiat and amounts, so the fiat cap can no longer be defeated. - Require the authorizer account to exist before debiting it. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01XiwgmQKwKnRxUmVDd4xSf7
* nwc: 404 missing connections + require key rotation to revive retired onesAdam Soltys2026-08-221-2/+31
| | | | | | | | | | | | | | | | | | | | | | | Damus's one-click setup derives its NWC secret deterministically from the user's nsec, so after the credential invalidation its re-mint reused the same retired pubkey and could never recover; meanwhile GET /app/:pubkey crashed into a 500 on missing records instead of the 404 the client needs to know to re-create the connection. We store client-supplied connection secrets (verified: all 2744 Damus app records in prod carry one), so a disclosed DB means disclosed secrets and re-authorizing the same pubkey would re-authorize the leaked bearer credential. POST /app therefore rejects any pubkey that is quarantined or predates nwc:credential-cutoff with an explicit 403 telling the client to mint a fresh secret; a fresh secret yields a fresh pubkey and passes. Owner posts refresh the record's created epoch so live connections stay clear of the cutoff, and GET /apps drops stale set members instead of 500ing the listing. Damus needs a versioned derivation salt so one-click can mint a genuinely new secret; PR submitted separately. Co-Authored-By: Claude Fable 5 <[email protected]>
* gitignore: exclude incident-response tooling, data, and peerswap artifactsAdam Soltys2026-08-221-0/+12
| | | | | | | | | Incident-response scripts and the victim list carry account identifiers and must never reach the public repo; peerswap/ holds runtime panic logs. Ignore them so they stay local-only and can't be committed by accident. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01XiwgmQKwKnRxUmVDd4xSf7
* scripts: add lightning-backlog audit + kvrocks archive merge toolingAdam Soltys2026-08-223-0/+416
| | | | | | | | | | | | | audit-lightning-backlog.ts walks settled invoices from the stored pay_index to surface receives the listener may have missed crediting (companion to the listener backlog probe). merge-kvrocks-archives.ts overlays one Kvrocks/Redis archive onto a base without materializing keys in memory (raw SCAN to survive cursors beyond JS safe-integer range), with kvrocks-merge-lowmem.conf as the low-memory server config for the merge. All parameterized via CLI flags; no embedded hosts or secrets. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01XiwgmQKwKnRxUmVDd4xSf7
* deps: upgrade fastify v4 to v5 line to fix dependabot alertsAdam Soltys2026-08-223-11/+11
| | | | | | | | | | | | Fastify 4.x is EOL with no backported fixes; all six open alerts (critical http-proxy header stripping, static path traversal + auth bypass, fastify body-validation bypass / host spoofing / DoS) are only patched on the v5 line. Bump fastify to 5.12.0 and all @fastify/* plugins to their v5-compatible majors, and migrate the two options v5 deprecated (disableRequestLogging -> logController, maxParamLength -> routerOptions). Co-Authored-By: Claude Fable 5 <[email protected]>
* log: redact secret and otpsecret from request bodiesAdam Soltys2026-08-221-1/+1
| | | | | | | | Extend the request-log redaction set beyond password/confirm so NWC connection secrets and TOTP seeds never reach the req/res logs. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01XiwgmQKwKnRxUmVDd4xSf7
* ln: bound receive-listener wait, phase-aware watchdog, re-enable health checkAdam Soltys2026-08-223-42/+144
| | | | | | | | | | | | | | | | Give waitanyinvoice an explicit 30s server-side timeout so a dead/half-open socket can no longer hold the listener indefinitely; CLN's 904 timeout during a quiet period is treated as a clean re-arm without counting as a failure. Split the listener into waiting vs processing phases so the watchdog never resets a healthy socket merely because a ledger write is slow, and never starts a second concurrent processor — a stalled processing phase is surfaced loudly instead. Process the settlement and confirm the durable payment pointer BEFORE advancing pay_index, so a store load/restart between the cursor write and credit() cannot drop a received payment. Expose listener status and add a backlog probe to the health check, and re-enable startHealthCheck(). Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01XiwgmQKwKnRxUmVDd4xSf7
* nwc: enforce per-connection spend budgets + flag-gated incident containmentAdam Soltys2026-08-225-20/+279
| | | | | | | | | | | | | | | | | | | | | | | | | Thread the remaining allowance through every spend path (pay_invoice, pay, pay_keysend, internal transfer) as maxTotal, and have debit() assert the spend stays within it after it reloads the invoice and captures the tip — so a delegated NWC credential can no longer exceed its configured budget, and a mutated tip can't slip a small-budget check and drain via the internal path. checkBudget now returns the remaining allowance alongside any error; a blank budget is treated as deliberately unlimited and a positive budget requires a known renewal period. Add operator-controlled containment for a suspected credential disclosure, all gated behind Redis flags with no hardcoded scope: nwc:disabled fail every method closed nwc:credential-cutoff retire connections created before an epoch nwc:quarantined block an individual disclosed connection nwc:guard:dormant-keysend quarantine the first keysend on a long-idle app Fix an ownership check that read the bare pubkey instead of app:<pubkey>, which let an authenticated user overwrite another connection's config, and validate budget/fee/renewal inputs. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01XiwgmQKwKnRxUmVDd4xSf7
* confirm: never re-credit receive outputs on wallet-funded txsAdam Soltys2026-08-221-0/+20
| | | | | | | | | | | | | | | | | | A transaction our own wallet contributed inputs to carries a "send" detail; every receive output on it is then our own change or an internal move, never an external deposit. Crediting such an output mints phantom balance against funds already in custody — the misclassification an external report flagged in the on-chain send path (wallet ownership is not account ownership). Guard the credit at the settlement layer, which is the correct boundary and covers both bitcoin and liquid uniformly: if the tx has any send detail, skip every receive output (log a tripwire for amounts >= 300 sats so continued probing is visible). Rejecting these at send time instead would break legitimate withdrawals, whose change routinely lands on a reused deposit address that resolves to an invoice. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01XiwgmQKwKnRxUmVDd4xSf7
* nostr: resolve NIP-05 via the v3 registrar first; empty (not 500) on unknown ↵Adam Soltys2026-08-171-1/+15
| | | | | | | | | | | name A claimed name's NIP-05 belongs to its wallet key in the registrar, so query NAMES_URL/.well-known/nostr.json first and fall back to the local lookup for legacy accounts. Return an empty names object for an unknown name per NIP-05 instead of throwing a 500. Co-Authored-By: Claude Opus 4.8 <[email protected]>
* nwc: reply UNAUTHORIZED on unknown connection instead of returning silentlyAdam Soltys2026-08-171-2/+13
| | | | | | | | A NIP-47 request whose pubkey/user no longer exists (deleted app or migrated user) was silently dropped, leaving the client spinning. Send an UNAUTHORIZED error reply so the client can surface it. Still not logged (not a server fault). Co-Authored-By: Claude Opus 4.8 <[email protected]>
* mintdemux: forward client IP for per-IP rate limiting + partner whitelist + ↵Adam Soltys2026-08-171-14/+86
| | | | | | | | | | | | | /v1/ws proxy Forward cf-connecting-ip (or first x-forwarded-for) upstream so the mints' per-IP rate limiter buckets by the real client instead of demux's socket IP. Partner hosts that funnel many users through one IP can be listed in whitelist.json with a pool size; their bucket key rotates across pool synthetic keys for a proportionally larger allowance. Reloaded every 60s. Also proxy /v1/ws (subscriptions) through to the new mint. Co-Authored-By: Claude Opus 4.8 <[email protected]>
* deps: patch @asoltys/clightning-client to fix cl reconnectAdam Soltys2026-08-164-0/+98
| | | | | | | | | | | Register patches/@asoltys+clightning-client+0.1.10.patch via patchedDependencies so bun install re-applies it. The library's reconnect() reused an already-dead socket and never recovered after a cl restart (looping connect ENOENT, leaking a socket+timer); the patch rebuilds a fresh socket + readline each reconnection and clears its retry guard before reconnecting. Companion to the app-side dropClient()/listener-recovery fixes. Co-Authored-By: Claude Opus 4.8 <[email protected]>
* ln: tear down dead cl clients fully + recover invoice listener on socket deathAdam Soltys2026-08-152-10/+74
| | | | | | | | | | | | | | | | | Replace bare `client = null` with dropClient(): stop the clightning-client library's internal reconnect loop (clear reconnectTimeout, neuter reconnect, destroy the socket, drop listeners). A dead client was otherwise orphaned and spun forever retrying `connect ENOENT`, spamming logs and leaking a socket+timer on every cl restart. Guard the error handler on `c === client` so a stale client's late error can't drop a newer, healthy one. Add lnListen.onDrop so the invoice listener recycles immediately when its long-poll socket dies (bump epoch, re-arm) instead of waiting up to 5 minutes for the stall watchdog. Stop counting LightningUnavailableError toward the listener's process.exit escalation, so a cl restart no longer makes the app self-exit — it retries until cl returns. Co-Authored-By: Claude Opus 4.8 <[email protected]>
* payments: prevent same-invoice retry from orphaning an unreconciled debitAdam Soltys2026-08-081-1/+21
| | | | | | | | | | | | | | | | | The pending reconciliation set is keyed by bolt11, shared across attempts at the same invoice. A retry made after a prior attempt's CLN parts resolved (but before check() reconciled its debit) would pass the listpays-only guard, and the retry's own reverse()/finalize() then removed the shared entry — permanently hiding the first attempt's debit from check(), so a failed send was never auto-refunded. - sendLightning: refuse to pay while the invoice's bolt11 is still in the pending set; the caller is told to retry shortly. - check(): an entry whose payment record no longer exists in db or archive is dropped from the set instead of skipped, so a dead entry can't block its invoice forever under the new guard. Co-Authored-By: Claude Fable 5 <[email protected]>
* mintdemux: fix old-ecash recovery meltsAdam Soltys2026-08-071-5/+38
| | | | | | | | | | | | | Melts routed to the retired mint were failing before their proofs were even read: wallets blind their fee-return change outputs to the current active keyset, which the old mint doesn't know and rejects outright. Strip change outputs on that path (recovery melts forfeit change). Also map the caller's melt quote id to the old mint's re-quoted id so melt responses and GET status polls report against the id the wallet knows, and log the connecting IP on the recovery path. Co-Authored-By: Claude Fable 5 <[email protected]>
* test: regtest regression suite for the security-disclosure fixesAdam Soltys2026-08-072-0/+242
| | | | | | | | | | | 16 tests covering S1 (jwt.verify vs forged tokens), S2 (bcrypt cost 12 + legacy detection), S3 (adminpass fails closed on omitted/empty password), S5 (session cookie flags), S6 (atomic SET NX dedup), S7 (/upload auth), fnd-003/004 (memo truncation), fnd-006 (deleteSelf sub-account guard), and S8/fnd-007 (spend/cash mutex serialization). Black-box HTTP + redis against the live regtest stack; run with `bun run test:regtest`. Co-Authored-By: Claude Opus 4.8 <[email protected]>
* deps: refresh bun.lockb with sharp 0.35.3 (S7/S19)Adam Soltys2026-08-071-0/+0
| | | | | | | | | Regenerated via `bun install` inside the app container (the bind-mounted node_modules is root-owned, so host-side bun install hits EEXIST). Pins sharp at the CVE-fixed 0.35.3 in the lockfile so a frozen-lockfile build resolves the fixed tree. Co-Authored-By: Claude Opus 4.8 <[email protected]>
* ecash: serialize the shared cash-key read-modify-write (S13)Adam Soltys2026-08-071-11/+36
| | | | | | | | | | | | The house Cashu holdings live as one token under redis `cash`. claim() and mint() decoded the current proofs, swapped at the mint, then wrote back — with no lock. Two concurrent claims read the same proofs and the later write clobbered the earlier, so already-swapped proofs vanished from the token while both users stayed credited (an insolvency leak, triggerable via the unauthenticated /ecash/:id callback). Wrap both read-swap-write sequences in an in-process mutex. Co-Authored-By: Claude Opus 4.8 <[email protected]>
* users: sum sub-account balances in the deleteSelf guard (S12)Adam Soltys2026-08-071-4/+13
| | | | | | | | | | The "withdraw your balance before deleting" guard summed only the main account, but the deletion loop wipes every sub-account's balance:/pending: keys unconditionally. A user with dust in the main account and savings in a sub-account passed the guard and silently lost the sub-account funds. Sum balance+pending across all ${id}:accounts before refusing. Co-Authored-By: Claude Opus 4.8 <[email protected]>
* payments: never lose a settled deposit to an oversized memo (S9/S10)Adam Soltys2026-08-072-2/+12
| | | | | | | | | | | | | | credit() ran fail("memo too long") on memos >5000 chars, but it runs AFTER settlement: an incoming lightning/bolt12 deposit has already advanced pay_index, and an internal send has already debited the sender. The throw was only logged, replay() hit the same throw, and the sats sat in the house wallet uncredited. The memo is attacker-controllable and was uncapped via the lnurl comment param and the bolt12 payer_note. Truncate the memo in credit() instead of throwing (a clipped note is harmless), and cap the lnurl comment to the advertised 512 at ingress. Co-Authored-By: Claude Opus 4.8 <[email protected]>
* nwc: budget keysend, atomic dedup, serialize spends (S4/S6/S8)Adam Soltys2026-08-071-12/+66
| | | | | | | | | | | | | | | - pay_keysend never called checkBudget and never recorded to ${pubkey}:payments, so a budget-limited connection string was a full-drain credential. Budget-check it, honor max_fee, and record the debit so subsequent budgeted calls count it (S4). - Replace the non-atomic zScore-then-(unawaited)-zAdd dedup with an awaited SET NX claim, so two concurrent deliveries of the same signed event can't both reach handle() and pay twice; also reject future-dated created_at beyond a small skew (S6). - Serialize budget-affecting methods per app with an async mutex so a concurrent request can't read a stale spent-total and overspend (S8). Co-Authored-By: Claude Opus 4.8 <[email protected]>
* upload: require auth on /upload/:type; bump sharp past libvips CVEs (S7)Adam Soltys2026-08-072-2/+2
| | | | | | | | | | POST /upload/:type fed the raw uploaded image straight into sharp with no auth preValidation (unlike neighboring routes), reaching a native libvips decoder in the container that holds the JWT secret and CLN hot wallet. Require auth, and bump sharp 0.33.5 -> 0.35.3 (out of the vulnerable < 0.35.0 libvips range, GHSA-f88m-g3jw-g9cj et al.). Co-Authored-By: Claude Opus 4.8 <[email protected]>
* logging: redact auth headers; set cookie flags on session token (S5)Adam Soltys2026-08-072-3/+26
| | | | | | | | | | | The request logger persisted full req.headers to the "req" log, including the Authorization bearer token and the token cookie — both non-expiring JWTs, so log-read access became account takeover. Redact the authorization and cookie headers. Also set httpOnly/secure/sameSite on the token cookie so it isn't readable by injected JS or sent over plaintext HTTP. (Revocation is already handled by the evicted set.) Co-Authored-By: Claude Opus 4.8 <[email protected]>
* auth: fail closed on adminpass, scrub sample secrets (S3)Adam Soltys2026-08-074-8/+22
| | | | | | | | | | | | | | | | The master-password path (isAdmin = password === config.adminpass) logs in as any user, skipping password, 2FA, and captcha. Worse, when adminpass is unset an omitted password field made undefined === undefined true, authenticating as anyone. Require both the configured value and the supplied password to be non-empty and match exactly, across every adminpass comparison (login, recaptcha bypass, support email, freeze). Also scrub config.ts.sample: the sample shipped a working-looking JWT secret, two server nsec keys, and adminpass "pwa". Replace with fail-closed placeholders and regenerate instructions. Rotate any deployment that ever shipped the sample values. Co-Authored-By: Claude Opus 4.8 <[email protected]>