aboutsummaryrefslogtreecommitdiffstats
path: root/lib
Commit message (Collapse)AuthorAgeFilesLines
* bolt12: put the requested expiry and single-use on the offer itselfAdam Soltys6 days1-0/+9
| | | | | | | | The expiry was only stored on our invoice record, so CLN kept issuing invoices for the offer after it had expired. Pass it as absolute_expiry, and accept single_use to cap an offer at one payment. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* users: keep the encrypted nostr key and 2FA seed away from bearer tokensAdam Soltys6 days1-1/+1
| | | | | | | | /me and user emits return a hasnsec flag instead of the encrypted key, which is now only sent with a password login or registration. /otpsecret refuses once 2FA is enabled, and disabling 2FA rotates the seed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Remove response loggerAdam Soltys6 days1-19/+0
| | | | Co-Authored-By: Claude Opus 5.5 <[email protected]>
* payments: lowercase payreq before looking up our own invoicesAdam Soltys8 days1-1/+7
| | | | | | | | | An uppercase bolt11 for a coinos invoice missed the getInvoice lookup, so it was paid over lightning to our own node instead of internally. The listener then saw the sender's payment pointer for that bolt11 and skipped crediting the recipient. Normalize in POST /payments and nwc pay_invoice. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* invoices: a supplied account must belong to the invoice's userAdam Soltys8 days1-1/+6
| | | | | | | | | | generate() took any existing account id as the credit target, so an invoice could carry one user's name while paying into someone else's account. A supplied aid now has to be in the invoice user's own account list; otherwise the invoice credits that user's main account. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KCxLg8s1g8yBwjRwPN4uJR
* Evicted accounts can't receive or log in; limit errors say what's happeningAdam Soltys10 days1-1/+7
| | | | | | | | | | | An evicted account no longer gets lightning-address invoices (stops a scam page's address from minting new ones) or a login token. A send over the server limit now says withdrawals are temporarily limited instead of a generic "Problem sending payment" that read as a frozen account. (Already live since the 2026-10-01 restart.) Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_011bvzKaZosXGHkigcGcSzAR
* v3 bridge: a migrated name's legacy receives are paid on to its v3 walletAdam Soltys10 days2-2/+147
| | | | | | | | | | | | | | | | | | A user who moved their name to coinos v3 keeps their legacy account, and old BOLT12 offers, bolt11 invoices and bitcoin/liquid deposit addresses keep paying into it. Each confirmed receive of those kinds is now forwarded as it lands to the name's v3 lightning address (registrar invoice on the ASP node; the v3 forwarder delivers to the Ark mailbox even with the wallet closed). Only new receives move; existing balances stay. Routing cost only: the platform fee is waived (debit exempt), and the direct channels to the ASP node quote 0. Proof required: the registrar holds the name for the legacy account's own key, or for the wallet its session named in a migration grant (/name granted flag). Autowithdraw stands down for forwarded receives; failures retry with backoff (v3fwd:retry, one process at a time) and otherwise stay put. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_011bvzKaZosXGHkigcGcSzAR
* payments: refuse on-chain sends to blocked addresses before debitingAdam Soltys2026-09-291-0/+19
| | | | | | | | | debit() checks blocked_addresses against the payment hash, which for an on-chain send is the txid, so a blocked destination address was never matched. Check each output address of the built transaction in sendOnchain and fail before any debit. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* logging: throttle log lines that clients can trigger at willAdam Soltys2026-09-293-7/+46
| | | | | | | | | | | | Evicted-JWT replays, blocked sends from frozen or limited accounts and failed payment retries each logged one line per request, so a looping client could flood the log, and an evicted token replayed across a proxy pool made atk-autoban.sh block every IP it saw. Add quiet()/suppressed() in lib/logging.ts: the first occurrence per key logs as before, repeats within the window are counted and reported on the next line that gets through. The evicted-auth summary line is worded so the auto-banner ignores it. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* invoices: don't let a supplied id overwrite another user's invoiceAdam Soltys2026-09-291-0/+11
| | | | | | | | | | | | generate() writes invoice:<id> wholesale with the caller's uid, and the old bolt11 pointer keeps resolving to that id, so passing someone else's invoice id re-pointed their invoice at the caller's account. Invoice ids aren't secret (LUD-21 verify URLs, invoice pages, receipts), so refuse an existing id that belongs to a different user, both in generate() and in POST /invoice for anonymous pay-page callers. Both fail with "invoice not found" so ids can't be probed for existence. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* upload: decode images in a throwaway child process and tighten input checksAdam Soltys2026-09-263-11/+127
| | | | | | | | | | | | | | | Malformed HEIF uploads were corrupting the heap inside libvips/libheif and taking the whole app down with them, since sharp ran in-process. Move the decode into a per-upload child (lib/image-worker.ts, spawned by lib/image.ts with a hard timeout) so a decoder crash fails one request instead of the server, and never load sharp in the main process. Also: only accept jpeg/png/webp/gif as sniffed by file-type instead of any image/* subtype, decode with failOnError and a pixel cap, refuse uploads from blacklisted accounts (a freeze previously only blocked sends), and rate-limit uploads per account. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* app: don't trust client IP headers on onion requestsAdam Soltys2026-09-251-0/+30
| | | | | | | | The tor container connects directly, bypassing Cloudflare, so the client could set cf-connecting-ip, x-forwarded-for and rate-limit-by to anything. Pin those requests to the tor container's address before the rate limiter runs. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* safe-fetch: decode IPv6 hosts before checking for private addressesAdam Soltys2026-09-251-10/+46
| | | | | | | | | URL parsing serializes IPv6 hosts in hex, so an IPv4-mapped address never matched the dotted-quad pattern and passed as public. Expand the address and judge every form that embeds an IPv4 address by that address, and block the remaining non-public IPv6 ranges. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* lightning: don't wedge the listener on an already-credited preimageAdam Soltys2026-09-231-2/+8
| | | | | | | | Skip settlements whose preimage was already credited instead of retrying until the process exits, and reject previously used preimages when they are queued. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* users: reserve service names on registration and renameAdam Soltys2026-09-212-2/+33
| | | | | | | | | | | | 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]>
* 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]>
* lightning: quote the routing fee up front and add a max-send quote endpointAdam Soltys2026-09-212-8/+340
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-161-2/+2
| | | | | | | | | 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]>
* ecash: redeem tokens by melting to the user's own invoiceAdam Soltys2026-09-162-160/+148
| | | | | | | | | | | 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-141-1/+18
| | | | | | | | | | | | 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-141-19/+64
| | | | | | | | | | | | | | | 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-1312-34/+180
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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
* 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
* 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
* 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-271-1/+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
* deps: upgrade fastify v4 to v5 line to fix dependabot alertsAdam Soltys2026-08-221-2/+2
| | | | | | | | | | | | 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-222-41/+143
| | | | | | | | | | | | | | | | 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-223-18/+211
| | | | | | | | | | | | | | | | | | | | | | | | | 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
* 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]>
* 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]>
* 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]>
* payments: never lose a settled deposit to an oversized memo (S9/S10)Adam Soltys2026-08-071-1/+8
| | | | | | | | | | | | | | 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]>
* logging: redact auth headers; set cookie flags on session token (S5)Adam Soltys2026-08-071-1/+12
| | | | | | | | | | | 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: hash passwords at bcrypt cost 12 with transparent upgrade (S2)Adam Soltys2026-08-071-1/+1
| | | | | | | | | | Registration, password change, and admin reset all hashed at bcrypt cost 4 (16 rounds, ~1,300 guesses/s/thread) — the single factor gating the custodial balance and the nip49 passphrase for the stored nostr key. Raise all three sites to cost 12, and on successful login re-hash any legacy cost-4 hash now that we hold the verified plaintext. Co-Authored-By: Claude Opus 4.8 <[email protected]>
* sockets: verify JWT signatures on the websocket auth path (S1)Adam Soltys2026-08-071-2/+14
| | | | | | | | | | | | | | The websocket auth gate used jwt.decode(), which parses the payload but never checks the signature — anyone could forge {id: <victim uid>} (alg:none or any key), register their socket under the victim's uid, and receive every emit for that account. Switch both track() and the "login" gate to jwt.verify(token, config.jwt), rejecting forged/altered tokens. Also project the full-record socket emits through pick(user, whitelist) so a "user" event can never carry the bcrypt hash, TOTP secret, or PIN — matching the shape /login and /me already return. Co-Authored-By: Claude Opus 4.8 <[email protected]>
* nwc: republish 13194 info events unconditionally every 2 minutesAdam Soltys2026-08-041-53/+18
| | | | | | | | | | | | | The stored replaceable 13194 info event was intermittently disappearing from the relay, leaving NWC clients (Alby etc.) with "no info event" until the next hourly existence-check happened to republish it. Drop the check-then-publish dance and just republish both server info events every 2 minutes — a replaceable event overwrites in place, so it's cheap and self-heals immediately. Silence the per-publish success log since it now fires on every interval. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01Dpqu6wizYe1c1g4k5Gd8JK
* Let a migrated account hand its address to coinos v3Adam Soltys2026-08-032-0/+3
| | | | | | | | | | | | A `migrated` flag on the user, settable through /user and visible on the public user endpoint. It changes exactly one thing: lnurlp for that username defers to the names registrar, so payments to [email protected] arrive in the person's v3 wallet. The account itself is untouched — same username, same history, still usable here — which is why this is a flag and not a rename or a shutdown. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
* payments: guard tip credit and self-referential autowithdrawAdam Soltys2026-08-031-1/+20
| | | | | | | | | | | | | | | Clamp a non-internal receipt's tip to the settled amount so a tip mutated larger than the amount actually received can't produce a negative, reversible payment record on the recipient. Also reject an autowithdraw whose destination resolves back to the sender: that self-send re-fires completePayment and, since the balance nets out, never drops below threshold, looping unbounded. The route's create() already has the equivalent self-send guard; the internal pay() helper lacked it. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01Dpqu6wizYe1c1g4k5Gd8JK
* sendKeysend: verify a thrown keysend by label, not the caller's hash (COINOS-1)Adam Soltys2026-07-311-14/+55
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | sendKeysend debited, ran ln.keysend, and on a throw decided whether to refund by calling listpays({payment_hash: hash}) with the CALLER's id (a Nostr ev.id on the NWC path). keysend randomizes its own payment hash — the CLN command takes no preimage arg — so that lookup could never match: it returned [], `failed` was always true, and reverse() ran on EVERY throw. A keysend that threw but actually settled was re-credited while the sats had left the node, a repeatable hot-wallet drain. Nothing corrected it either: unlike sendLightning, keysends are never enrolled in `pending`, so check() never revisits them. Recovering the real hash from the error payload doesn't work — verified on CLN v26.06.2 that keysend errors carry no payment_hash under any key (the error is {code, message, attempts}), so that approach never reverses and instead eats the user's money on every ordinary routing failure. Instead tag the payment with our own unique label and record where the sendpays index stands first (`wait sendpays created 0` returns the current index without blocking), so a throw can list only sendpays created since — no full scan — and match ours by label whatever the error looks like. CLN writes the sendpay record BEFORE sending the HTLC, so no record at all proves nothing left the node and the debit is safe to reverse. Verified both directions on regtest: an unroutable keysend is refunded, and a settled one is correctly detected and left alone. Known gap: a keysend still in flight when the RPC throws stays debited, since keysends are not enrolled in `pending` for check() to reconcile. Reported-by: Muhammed Shekho (@SYR-ROOT) <[email protected]> Co-Authored-By: Claude Fable 5 <[email protected]>
* nwc: conform pay/receive to the BIP-321 methods spec (nwc#2)Adam Soltys2026-07-311-15/+116
| | | | | | | | | | | | | | | | | | | | Tightens our implementation of the BIP-321-based NWC methods proposed in https://github.com/nostr-wallet-connect/nwc/pull/2 — the successor to pay_invoice/make_invoice that clients are asked to transition to for bolt12 zaps in https://github.com/nostr-protocol/nips/pull/2421: - reject cross-network payment instructions before paying (UNSUPPORTED_NETWORK) instead of failing after the debit - reject unknown req- BIP-321 params, including req-pop since we can't open proof-of-payment callbacks - reject a request amount that conflicts with the instruction amount - payer_note is delivered or rejected, never dropped: bolt11+note is rejected, internal settlements hand the note over as the payment memo - receive mints a per-request bolt12 offer carrying the requested description/amount instead of reusing the standing amountless offer - get_info reports the node's actual network instead of "mainnet" Co-Authored-By: Claude Fable 5 <[email protected]>
* zaps: stop spurious "failed to handle zap" warnings on non-zap invoicesAdam Soltys2026-07-301-10/+21
| | | | | | | | | | | The zap dispatch parsed the invoice description and, when it wasn't JSON, suppressed the error only if the message contained "Unexpected" — V8's parse- error wording. On Bun the message is "Unable to parse JSON string", so every paid invoice with a non-JSON memo logged a warning. Parse-then-check instead: a non-JSON description is simply not a zap and is skipped silently; warn only when an actual kind-9734 zap request fails to process. Co-Authored-By: Claude Opus 4.8 <[email protected]>
* nwc: guard missing app record in the notification loopAdam Soltys2026-07-301-2/+7
| | | | | | | | | A pubkey can be in `${uid}:apps` before its `app:<pubkey>` record is written (NWC-connection creation race) or can outlive its record. Destructuring `notify` off the null lookup threw, and since the catch wraps the whole loop it aborted notifications for ALL of the user's apps. Skip null/absent apps and continue. Co-Authored-By: Claude Opus 4.8 <[email protected]>
* mint/lightning: refuse caller-supplied invoices, tiered fees, log hygieneAdam Soltys2026-07-304-41/+147
| | | | | | | | | | | | | | | - invoices: reject caller-supplied bolt11/bolt12; the server always mints its own. Closes the invoice-registration path behind the shared-node credit and payment-hijack vectors. - lightning: skip mint-owned invoices (lbl* label) in the paid-invoice listener. - payments: lightning free-tier fee credit, withheld for flagged high-throughput (ln:nofree) accounts which pay an elevated rate; the failed-send reversal restores credit at the same rate. freezeCheck made crash-resilient. Lightning and bolt12 sends stay pending until the preimage settles. - payments (route): validate payment-request / invoice input types. - logging: collapse repeated xpay failure lines into one. Co-Authored-By: Claude Opus 4.8 <[email protected]>