aboutsummaryrefslogtreecommitdiffstats
Commit message (Collapse)AuthorAgeFilesLines
...
* | invoices: reject sub-300-sat on-chain invoice amountsAdam Soltys2026-07-101-0/+12
| | | | | | | | | | | | | | | | | | | | On-chain deposits below 300 sats are dropped by the dust filter in the confirm/credit path, so a specific-amount bitcoin/liquid invoice under that threshold can never be credited. Reject creating one at generation time and point the user at lightning. Any-amount (0) addresses are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* | invoices: rate-limit invoice create and update per IPAdam Soltys2026-07-101-2/+2
| | | | | | | | | | | | | | | | Add a per-IP rate limit (30/10s) to POST /invoice and POST /invoice/:id, matching the existing GET /invoice/:id limit, to throttle automated invoice-creation floods. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* | logging: redact password fields from the request logAdam Soltys2026-07-101-1/+12
| | | | | | | | | | | | | | | | The preHandler request logger wrote the full request body, which included plaintext passwords on auth requests. Redact password/confirm fields before logging. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* | payments: use the debited tip when crediting internal transfersAdam Soltys2026-07-103-7/+25
|/ | | | | | | | | | | | | | | | credit() now credits the tip the sender was actually debited (passed in by the internal-transfer callers) instead of independently re-reading the invoice. This closes a time-of-check/time-of-use gap where an invoice's tip could be changed between the sender debit and the receiver credit, letting a payer be credited a tip they were never charged. External receipts (lightning/ecash) keep reading the invoice tip, since there is no paired coinos debit to diverge from. Also harden POST /invoice/:id: validate the tip is a sane non-negative integer and reject tip changes on an already-settled invoice. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* lightningd: upgrade Core Lightning to v26.06.2Adam Soltys2026-07-041-1/+1
| | | | Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* rates: fix Iranian IRR/IRT feed after nobitex API host moveAdam Soltys2026-07-041-6/+19
| | | | | | | | | | The nobitex endpoint host changed (old host now NXDOMAIN) and the fetch was swallowed by an empty catch, silently blanking Iranian rates. Point at the current host, and since the API quotes in Rial despite the IRT pair name, set IRR from lastTradePrice and IRT = Rial/10. Log fetch failures instead of ignoring them. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* nwc: confirm 13194 info-event publish so it cannot be silently droppedAdam Soltys2026-06-281-3/+47
| | | | | | | | | | | | | | | | publishInfo sent the kind-13194 info event over the long-lived NWC socket fire-and-forget. The nostr lib Relay.send is async (await wait_connected; ws.send) but was called without await, and success was logged immediately. A send that rejected (e.g. ws in CLOSING state during a strfry reconnect) was silently dropped while the log claimed success, leaving NWC clients with "no info event" until a manual republish. Publish over a fresh short-lived socket and wait for the relay OK, retrying up to 3x with honest logging (warn on unconfirmed, err on give-up). ensureInfos receive-based detection is unchanged. Verified on prod: deleted both info events, restarted, both republished within seconds. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* auth: log evicted-account auth attempts with source IPAdam Soltys2026-06-281-1/+7
| | | | | | | | Emit a greppable EVICTED_AUTH line (username + cf-connecting-ip) when an evicted account's JWT is used, so the real-time auto-banner can block the source IP at the edge immediately rather than waiting for the hourly scan. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* auth: hard-evict banned accounts at the JWT layerAdam Soltys2026-06-271-0/+14
| | | | | | | | | | Add an `evicted` set check in the JWT strategy: an evicted account fails authentication on every request and every endpoint, regardless of source IP. The existing blacklist only blocks sends, so a compromised/abusive JWT could still authenticate and read/probe; eviction kills it outright. Matches on uid or username so a rename can't evade it. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* users: return generic 404 from /reset for non-adminsAdam Soltys2026-06-271-1/+10
| | | | | | | | | The admin-only password-reset endpoint returned "disabled" to non-admin callers, which confirmed both that the endpoint exists and that it is admin-gated. Return a generic 404 identical to an unregistered route instead; still log the rejected attempt server-side for monitoring. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* payments: normalize bolt11 to lowercase so failed uppercase sends get reconciledAdam Soltys2026-06-181-1/+12
| | | | | | | | | | | | | | | BOLT11 is case-insensitive, but an uppercase-pasted invoice was stored as the payment hash and the pending-set entry verbatim. check()'s startsWith("ln") test is case-sensitive, so it treated such pending entries as non-lightning and evicted them without reversing — a failed send would never be auto-refunded. - sendLightning: lowercase the invoice up front so the stored hash, the pending entry, and xpay all use the same form. - check(): lowercase before the startsWith("ln") test (defensive; also rescues any already-stored uppercase entries). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* payments: don't let check() reverse an in-flight lightning sendAdam Soltys2026-06-181-2/+19
| | | | | | | | | | | | | | | | check() skipped pending payments younger than 10s, but xpay retries for 30s (retry_for: 30). During that window listpays can transiently show all parts failed before the winning part lands, so check() could reverse a payment sendLightning was still driving; the part then completed and finalize() ran against an already-removed record. - check(): raise the skip threshold 10s -> 60s so sendLightning finishes finalize()/reverse() and removes the payment from `pending` before check() ever touches it. - finalize(): if the record is gone, raise a clear error instead of a cryptic null-deref. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* scripts: add dedup-invoices cleanup for already-duplicated :invoicesAdam Soltys2026-06-181-0/+86
| | | | | | | | | | The source fix (LPOS guard in generate()) stops NEW duplicate entries but doesn't remove the ones already accumulated. This one-time script scrubs them: scans every <id>:invoices list and removes duplicate ids, keeping each id's original creation-order position. Dry-run by default; pass --apply to write. Idempotent. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* invoices: don't re-list an invoice id on update (dedupe :invoices)Adam Soltys2026-06-181-1/+7
| | | | | | | | | | | generate() unconditionally lPush'd the invoice id to `${aid}:invoices`, but it's also called from the update() handler (tip/webhook changes, and repeated bolt12 offer creation) re-using the existing id — so each update re-appended the same id and the user's :invoices accumulated duplicate entries, rendering the same offer/invoice multiple times. Only push when the id isn't already in the list (LPOS guard). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* lnurl: SSRF-guard user-supplied URL fetches + validate zap eventsAdam Soltys2026-06-152-5/+97
| | | | | | | | | | | | | | | | The /lnurl/proxy, /decode and /encode handlers fetched an arbitrary user-supplied URL via got() and returned the body — an unauthenticated SSRF reaching internal services. Route those fetches through a new safeGot() that rejects hosts resolving to loopback/private/link-local/ CGNAT/metadata ranges, re-validates on each redirect hop, restricts the scheme to http(s), and caps timeout + response size. Also validate the NIP-57 zap request (kind 9734 + signature) before storing it, instead of trusting the client-supplied event. These endpoints can't be removed — they back lightning-address/LNURL parsing and resolution in the wallet UI. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Push broadcast txs directly to public explorersAdam Soltys2026-06-151-0/+16
| | | | | | | | | After broadcasting an on-chain send via bitcoind, also POST the raw tx to mempool.space and blockstream (fire-and-forget, non-blocking) so those explorers see it immediately instead of waiting ~8s for p2p propagation. Skipped on regtest/testnet. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* setup override warningAdam Soltys2026-06-141-0/+14
|
* scripts: add wiped-history audit and rebalance-economics modelAdam Soltys2026-06-132-0/+181
| | | | | | | | | audit-wiped-history.ts (read-only) finds accounts whose :payments index was wiped by the changeid bug — balance>0 with empty history in both db and arc. rebalance-economics.ts models whether the lightning->liquid bridge throughput is fee-positive against on-chain rebalance cost. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* users: add authenticated self-delete endpointAdam Soltys2026-06-132-0/+104
| | | | | | | | | | POST /delete (auth) wipes every key tied to the calling user, mirroring scripts/delall-legacy.ts: user records, payments/invoices, sub-accounts, NWC apps, nostr follow graph + nip5. Gated by typing the username and refuses while balance+pending > 10k sats so funds can't be destroyed by accident. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* payments: guard /replay double-credit race with SET NX idempotencyAdam Soltys2026-06-132-1/+16
| | | | | | | | | | getPayment() pre-check in the lightning listener/replay isn't atomic, so concurrent re-credits of the same settlement each ran incrBy. Claim each lightning/bolt12 settlement with an atomic SET NX on credited:<ref> (ref = preimage) and bail if already claimed. Also disable the /replay route that triggered it. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* payments: freeze matches uid as well as username (rename-proof)Adam Soltys2026-06-131-4/+6
| | | | | | | | | The blacklist (send freeze) only matched the current username, so a compromised account could shake the freeze by renaming itself. Match on either the lowercased username OR the immutable uid; add the uid to the `blacklist` set to freeze durably. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* deps: bump file-type 18->21 and uuid 8->11 (Dependabot)Adam Soltys2026-06-072-2/+2
| | | | | | | | | | | | | | | | | | | | Clears two Dependabot alerts: - file-type (medium): infinite loop in ASF parser on malformed input — reachable via avatar/image upload (fileTypeFromBuffer in lib/upload.ts). -> 21.3.4 - uuid (medium): missing buffer bounds check in v3/v5/v6 when buf is provided — we only ever call v4() with no buffer so not actually exposed, bumped anyway. -> 11.1.1 Both are major bumps but drop-in for our usage: project is already ESM ("type":"module") so file-type's ESM-only requirement is fine, and we use the stable named exports (fileTypeFromBuffer, v4). Verified live: v4() and fileTypeFromBuffer JPEG detection both work; full app compiles + healthy. Installed inside the app container (node_modules is root-owned, volume-mounted). The 4 remaining fastify-family alerts (incl. the critical http-proxy one) need a fastify 4->5 + 8 plugins lockstep upgrade — deferred to a dedicated window; real exposure is low behind Cloudflare/Caddy with localhost binding. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* lightning: watchdog only recycles on real backlog, not normal idleAdam Soltys2026-06-061-17/+24
| | | | | | | | | | | | | | | | | The zombie-listener watchdog (bd1ece38) treated "waitanyinvoice blocked > 5min while cl is alive" as a stall — but that's NORMAL during quiet periods, so it false-fired and needlessly recycled the cl listen socket every few minutes (~26x in 6h on 2026-06-07). Receives still worked, but it was churn + noise and would mask a genuine zombie. Fix: a blocked long-poll is only a zombie if cl actually has a paid invoice the listener isn't picking up. When blocked past the threshold, probe cl on the MAIN socket with waitanyinvoice(pay_index, 2s server-side timeout): if it returns an invoice there's a real backlog the wedged listener missed -> recycle; if it times out (CLN code 904 = nothing waiting) the listener is just idle and healthy -> leave it alone. Only recycle on a confirmed backlog. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* nwc: keep notifications opt-in (revert default-on)Adam Soltys2026-06-061-6/+5
| | | | | | | | | | Reverting the notify default-on from 8cb18821 — most users don't consume notifications and defaulting on would flood the relay with payment_sent/ payment_received for ~3k apps. Notifications stay opt-in: clients enable by sending notify:"true" when creating the NWC connection. The pay_invoice reply-reliability and rate-limit fixes from 8cb18821 are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* nwc: fix pay_invoice reply reliability, rate-limit drops, notificationsAdam Soltys2026-06-062-37/+87
| | | | | | | | | | | | | | | | | | | | | | | | | Three issues reported by an NWC client integrator (refs #80): (a) pay_invoice sync reply: the handler polled listpays for ~20s then returned a misleading "Payment timed out" error on slow routes even though the payment settled — client never got the preimage. sendLightning already runs xpay to completion and finalize() sets p.ref = preimage, so return that directly; keep a longer listpays loop only as a fallback, and on still-in-flight return an explicit "check with lookup_invoice" rather than implying failure. (b) rate limiting: raised per-pubkey cap 5/min -> 60/min (normal usage — a balance check + payment + a few lookup_invoice reconciles — trivially exceeded 5), and over-limit requests now get a NIP-47 RATE_LIMITED error reply instead of a silent drop that looked exactly like a lost reply. Also: every request path now always sends a 23195 reply (success, error, or handler-returned- nothing) so a client never hangs waiting. (c) notifications: payment_received/payment_sent are advertised in the info event but app.notify defaulted to false unless the client sent notify:"true" (only ~1% of apps had it on), so they never fired. Default notify ON; only disable when explicitly "false". (Existing apps still false — backfill TBD.) Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* payments: clarify deposit log (pending vs confirmed) to stop phantom ↵Adam Soltys2026-06-041-1/+9
| | | | | | | | | | | | | | double-receive completePayment logs once when an on-chain deposit is first seen (pending) and again from /confirm when it confirms, both as bare "received" — so a single liquid/bitcoin deposit appeared twice in the logs and looked like a double credit. It never was (the /confirm p.confirmed guard and the seen set prevent double-crediting); purely misleading log noise. Now logs "receiving (pending)" on first sight and "received" on confirmation. Instant lightning receives are confirmed=true so they still log a single "received". Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* nwc: pay_invoice honors request amount for amountless bolt11Adam Soltys2026-06-041-2/+14
| | | | | | | | | | | | | | | | pay_invoice derived the amount solely from the decoded invoice, so an amountless bolt11 yielded amount = round(undefined/1000) = NaN and sendLightning rejected it with "Invalid amount". NIP-47 allows the client to specify the amount (msat) in the request for exactly this case (AlbyHub supports it; Alby Go and others hit the error). Fall back to the request's `amount` field when the invoice carries none; invoice amount still takes precedence when present, and a still-missing amount returns a clear error instead of NaN. sendLightning/sendInternal already handle amountless invoices given a valid amount — the bug was only the handler dropping it. Fixes #83 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* nwc: fix make_invoice timestamps (ms -> seconds) breaking NWC clientsAdam Soltys2026-06-041-5/+8
| | | | | | | | | | | | | | | | | | generate() stores `created` in milliseconds (Date.now()), but the NWC make_invoice response returned it directly as created_at and computed expires_at = created_at + expiry. NWC timestamps are Unix SECONDS (NIP-47), so clients parsed a ~1.78e12 expires_at that overflowed their date types — e.g. ptcpay's .NET DateTimeOffset, whose max is 253402300799 (issue #84: "creating lightning invoices via NWC fails ... Valid values are between -62135596800 and 253402300799"). Convert created to seconds before use. The other NWC handlers were already correct (lookup_invoice reads seconds straight from cln; list_transactions divides p.created by 1000). Only make_invoice leaked milliseconds. Fixes #84 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* ln/lightning: watchdog recycles zombie receive-listener socketAdam Soltys2026-06-042-1/+81
| | | | | | | | | | | | | | | | | | | | | On 2026-06-04 cl restarted (autoheal, after its 15s healthcheck timed out), and the app's waitanyinvoice long-poll hung on the now-dead listen socket. listenerActive stayed true forever, so ensureListenerAlive never restarted it, and incoming lightning payments stopped crediting for ~2h until a manual app restart. The per-call RPC timeout in ln.ts can't catch this: waitanyinvoice is intentionally in NO_TIMEOUT (it's meant to block). Fix: ensureListenerAlive (runs every 120s) now also detects a stalled listener — active but blocked in waitanyinvoice for >5min. It probes cl via the main ln socket (getinfo); only if cl is actually alive does it treat the listen socket as a zombie, call lnListen.reset() (new: drops the proxy's socket so the next call reconnects), and re-arm. A listenerEpoch guard makes the wedged invocation bow out when its waitanyinvoice finally rejects, so we never run two listeners concurrently and never double-credit. Verified with a state-machine test covering normal receive, quiet-but-healthy (left alone), stall+cl-alive (recycled), stall+cl-down (left alone), and the no-double-listener race. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* analytics: missing-history audit + lightning->liquid arb finderAdam Soltys2026-06-042-0/+232
| | | | | | | | | | | | | | | | | audit-missing-history.ts: read-only audit classifying every user's payment history as clean / recoverable (record in arc not db) / lost (gone from both). Full-scan finding: of 6,330 users with history, 77% are "arc-only" which is the NORMAL archived state — the payments route reads via gf() (archive fallback) so those render fine in the app. True loss is 58 users / 59 records (0.02%). So there is no widespread history loss; "missing history" reports are almost always either dropped receive-callbacks (record never written, like apk403's 1100 sats) or one of the rare 59. ln-liquid-arb.ts: finds users funding via lightning and withdrawing via liquid (the pattern draining the liquid hot wallet / forcing threshold cuts). Reports per-user through-flow + net liquid drainers. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* analytics: --csv flag for month-over-month trend trackingAdam Soltys2026-06-041-1/+50
| | | | | | | | | | Appends one metrics row per run to a CSV (header written if file is new): date, users, MAU/WAU, payments, volume, custody, fee revenue, bolt12 recipients, and per-type volume/txn columns. Notice goes to stderr so it doesn't pollute the saved stdout report or --json output. Drives the new monthly snapshot cron (~/scripts/analytics-cron.sh). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* analytics: monthly insight reports + signup instrumentationAdam Soltys2026-06-033-0/+377
| | | | | | | | | | | | | | | | | | | | | scripts/analytics.ts — streams live db (SCAN, never KEYS) and reports for a trailing window (default 30d): MAU/WAU, new users, custody total + top holders, network volume by type (lightning/liquid/bitcoin/internal/bolt12/ fund), most-active and highest-volume users, and bolt12 recipients with a "regular" flag for the Ocean mining-reward stream. scripts/analytics-geo.ts — top countries from the request log. Counts only /login + /register events tied to a username (raw cf-ipcountry volume is dominated by datacenter/nostr traffic). Surfaces a distinct-IP-per-country column so proxy/VPN egress is visible, and filters a known credential-test bot IP. Honest result: US #1, then GB/CA/JP/DE/ES/BR. lib/register.ts — stamp `created` (ms epoch) and store `country` (CF/ ipregistry code) on new users, so future reports get real signup timing and geography from the db directly instead of log archaeology. Existing users predate this; backfill is out of scope. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* nwc: publish 13194 info event only when missing, recheck hourlyAdam Soltys2026-06-021-26/+65
| | | | | | | | | | | | | | | | | | NWC clients (Alby Go, Lightning Piggies) fetch the kind 13194 info event (capability advertisement) before they'll talk to the wallet; without it they report "no info event" and show an unknown balance. The events went missing after strfry restarted on 2026-05-29 and stayed gone for days, because the app only published them once in the relay's on-open handler and nothing re-asserted them. 13194 is a replaceable event meant to persist, so instead of blindly republishing, query the relay on connect and publish only the server pubkeys whose info event is actually missing (ensureInfo). Re-check hourly so a relay restart that leaves our socket intact also self-heals. Interval is cleared on close/reconnect alongside the heartbeat to avoid leaks. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* ln: time out short-lived RPCs so a hung cl reconnects without restartAdam Soltys2026-05-311-2/+64
| | | | | | | | | | | | | | | | | | A unix-domain RPC socket has no TCP keepalive, so if cl goes unresponsive while the socket stays open (zombie/half-open), an awaited call hangs forever — the "stuck spinner past /pay/bob, no error" symptom. Nothing threw, so the existing reconnect-on-error path never fired and only an app restart recovered it. Wrap short-lived calls in a 30s Promise.race timeout (matching health.ts). A timeout rejects with ETIMEDOUT, now part of isSocketDied, so the client is dropped and the next call reconnects — no process restart. Long-polls (waitanyinvoice et al), in-flight payments (pay/xpay/sendpay/keysend), and bolt12 round-trips (fetchinvoice/ sendinvoice/offer) are exempt: timing those out client-side would risk leaked debits or break intentional blocking. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* debit: exempt internal sends from server liquidity limitAdam Soltys2026-05-311-8/+16
| | | | | | | | | | | | freezeCheck only populates ${type}:limit for external asset types (lightning/fund/ecash/bitcoin/liquid/bolt12), so internal:limit stays 0 forever. The atomic limit check added in 609fcebe applied to all types, so amount > 0 blocked every internal coinos-to-coinos payment with "serverLimit 0". Internal sends are pure ledger moves that never touch an external wallet — there's no hot-wallet liquidity to reserve — so exempt them, matching the existing soft-freeze and withdraw-lock gates. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* sendLightning / finalize: drop chatty per-send INFO logsAdam Soltys2026-05-251-5/+7
| | | | | | | | | | | | | | | | | | Each successful lightning send was emitting 5 INFO lines: "<user> sent lightning <amount>" (debit, KEEP) "paying lightning invoice <pr> <amt> <fee>" (drop — redundant) "payment completed <id> <preimage>" (drop — implicit from absence of warns) "refunding fee <maxfee> <fee> <delta> <ref>" (drop — the warn for non-integer delta already covers anomalies) "sendLightning outcome <id> = finalized" (drop for "finalized" only — keep for the recovery-via-fallback variants since those are diagnostic) Net: only the "<user> sent lightning <amount>" line fires per healthy send, matching the other asset types' style. Anything that needed a fallback path or threw still surfaces via the existing warn/err lines. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
* sendLightning: stop warning on healthy multi-path paymentsAdam Soltys2026-05-251-11/+13
| | | | | | | | | | | | | | | | | | | | | | | | | | | | Each xpay with failed_parts > 0 that ultimately settled (multi-path retry — common, not concerning) was firing 3 WARN lines: "xpay returned (abnormal)" "xpay returned failed_parts without throwing" "xpay failed_parts but listpays complete — finalizing" These are all the SAME successful payment. The presence of failed_parts just means CLN tried multiple paths before one succeeded. Adam was seeing noise. Now: - Only warn on actually-missing preimage (xpay silently failed). - Drop the unconditional "without throwing" warn — the failed_parts branch handles the recovery silently in the success case. - Drop the "listpays complete — finalizing" warn — that's the success path, not a concern. - KEEP warns for the genuinely interesting cases: listpays says failed (we're about to reverse), listpays says pending (debit left for check() to reconcile), or finalize itself throws. Net effect: a healthy multi-path payment now emits only the standard "sendLightning outcome <id> = finalized-via-listpays-after-failed_parts" info line instead of 3 WARN lines. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
* finalize: don't crash on non-integer fee delta (db.incrBy NaN)Adam Soltys2026-05-251-5/+25
| | | | | | | | | | | | | | | | | | | | Triggered by a recurring "finalize threw despite no failed_parts: ERR value is not an integer or out of range" warning. Root cause: when xpay's response shape is unexpected (or invoice is zero-amount and decoded amount_msat is undefined), the computed p.fee becomes NaN. The subsequent db.incrBy(balance:<uid>, maxfee - NaN) throws because Redis can't increment by NaN. Symptom: leak guard fires "ref set in db despite throw" — payment is actually fine (p.ref persisted before the throw) but finalize never completes the fee refund. User loses up to maxfee sats (was already debited maxfee, gets nothing back). Fix: validate the computed fee is a finite integer; if not, keep the originally-reserved maxfee (zero refund, no balance corruption) and warn loudly. Same guard on the refund delta before db.incrBy. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
* sendLightning: silence success-case xpay log, re-read db before leak guardAdam Soltys2026-05-211-17/+21
| | | | | | | | | | | | | | | | Two cleanups based on a day of production data: 1. Only log the xpay response shape when something is abnormal (failed_parts > 0 or missing preimage). The success case (200+/day) was pure noise — answers Adam's "do we still need these xpay warnings". 2. Re-read the payment record from db before the leak guard fires. Several "LEAKED DEBIT" lines today were finalize() throwing on a late line (likely db.incrBy for the fee refund) AFTER db.set had already persisted p.ref. The payment is functionally settled in those cases — only flag as a real leak if ref is genuinely missing from the db record. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
* tighten LEAKED DEBIT guard: don't false-alarm on successful-via-fallback pathsAdam Soltys2026-05-201-7/+23
| | | | | | | | | | | | | | | | | The first deployment surfaced two LEAKED DEBIT lines that were actually clean finalizes via the new failed_parts listpays-fallback path — exactly the path that recovers payments that would have been stuck under the old code (joho33's case). The leak guard's `outcome !== "finalized"` check was too narrow: any path that finalizes the payment, regardless of how it got there, is a success. Bucket outcomes explicitly: - finalizedOutcomes — clean successes (finalize ran, ref set) - safeNonFinalized — reverse ran, OR debit left in pending for check() to reconcile (these aren't leaks) - anything else — actually stranded (finalize-threw-*, unknown) → err Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
* sendLightning/sendKeysend: instrument exit paths to surface stuck-debit casesAdam Soltys2026-05-201-12/+91
| | | | | | | | | | | | | | | | | | | | | | | Diagnosing the stuck "optimistic" state seen on payment 30976bc9 (joho33, 2026-05-19): confirmed=true with no preimage, not in the pending set, so check() couldn't reconcile. Most likely path: ln.xpay returned without throwing but with failed_parts > 0; the previous `if (!r.failed_parts)` guard skipped finalize, no catch ran, and the debit was left stranded. Adds: - Log the full xpay/keysend response shape (status, failed_parts, parts, amount_sent_msat, has_preimage) so we can see what CLN actually returned. - Handle the failed_parts branch explicitly — verify with listpays and finalize, reverse, or leave pending based on actual outcome. - Track an `outcome` string through every branch and log it before returning or throwing. - "LEAKED DEBIT" err line at the function-end if we return without a clean finalize — that's the exact stuck state we want zero of. grep `LEAKED DEBIT` in app logs to catch any future occurrences immediately. Same instrumentation pattern applied to sendKeysend. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
* withdraw-lock: refuse external sends when /locks/<type>.locked is presentAdam Soltys2026-05-191-0/+25
| | | | | | | | | | | | | | | | | | Out-of-band emergency stop. The companion nobal.sh creates a sentinel file on the host (bind-mounted at /locks in the container) when a wallet balance drops below its failsafe threshold; debit() checks for the file on every external send and refuses with "External withdrawals temporarily disabled". /locks/ALL.locked is a global override. Lives outside the db so a db-write vector (the May 18 /gateway pattern) can't disable it. Internal sends are exempt — receives and user-to-user transfers keep working with the site fully online during a lock. For bitcoin and liquid this is a secondary defense on top of daemon- level walletpassphrase rotation. For lightning, it's the only available mechanism since CLN has no walletlock equivalent. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
* freezeCheck: only count spendable channels in lnbalanceAdam Soltys2026-05-181-1/+9
| | | | | | | | | | | | | | | Previous calc summed our_amount_msat across every channel regardless of state, including ONCHAIN (channels that have been closed and are waiting for the on-chain sweep). Those funds are not actually usable for lightning sends, but were inflating lnbalance — and therefore inflating ${type}:limit — by the locked-up amount. With several closed channels pending sweep, the effective threshold floor was tens of megasats lower than the configured lightning:threshold suggested. Filter to CHANNELD_NORMAL (and CHANNELD_AWAITING_SPLICE so an in-flight splice doesn't briefly pin the limit to zero). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
* log when listpays verification itself errorsAdam Soltys2026-05-171-2/+10
| | | | | | | | | | | | | | When ln.listpays throws inside the post-failure verification path in sendLightning / sendKeysend, the outer catch was swallowing it silently. That meant a CLN outage during a failing payment was indistinguishable from clean failure-and-reverse in the logs. We still want to not-reverse in this case (the debit stays, pending stays in the reconciler set, check() retries when CLN is back), but the operator deserves to know the verification step failed. Logged via warnThrottled so a multi-hour outage doesn't flood the log. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
* sendLightning / sendKeysend: only reverse when CLN reports the payment failedAdam Soltys2026-05-171-2/+13
| | | | | | | | | | | | | | | | | | The catch blocks for ln.xpay and ln.keysend reversed the user's debit whenever the RPC call threw, regardless of the actual payment state in CLN. If the RPC errored transiently while the payment was still pending, the user's balance got refunded — and then the lightning payment could still complete, leaving the user with both the refund and the outgoing sats. Both call sites now mirror the safe check() logic: const failed = !pays.length || pays.every((p) => p.status === "failed"); if (completed) finalize ... else if (failed) reverse ... // else (pending) leave the debit; check() reconciles when CLN settles. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
* isolate per-chain failures in getAddressType and catchUpAdam Soltys2026-05-171-7/+26
| | | | | | | | | | | | | | | | | | | | | | Two related resilience fixes for when one chain backend (typically lq via the cs tunnel) is down or unreachable: - getAddressType: when lq.getAddressInfo throws on a connection error (ECONNREFUSED / ETIMEDOUT / "Unable to connect"), fail with "liquid temporarily unavailable" instead of the misleading "unrecognized address". Users get an actionable message and we can tell user error from infrastructure problems in the logs. - catchUp: wrap the per-chain listTransactions call in its own try/catch so a downed lq doesn't stop the bitcoin deposit-recovery loop (and vice versa). Logged via warnThrottled so a multi-hour outage doesn't spam the same error every tick. Also drops a redundant /confirm double-call comment from catchUp — with walletnotify reliable again, a single call is sufficient and avoids piling on the /confirm race the new atomic lock is preventing. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
* move UTXO lockUnspent from build() to sendOnchain()Adam Soltys2026-05-171-10/+9
| | | | | | | | | | | | | | build() is called from both /payments (the actual send path) and /fee (the fee-estimation endpoint). Locking UTXOs in build() meant that every fee quote was reserving inputs that never got broadcast, slowly leaking UTXO locks until a bitcoind restart cleared them — eventually starving the wallet of selectable inputs even when balance was healthy. The lock belongs at the point where we're actually about to broadcast, which is sendOnchain. Released in the catch path on broadcast failure; on success, the inputs become spent and the lock is moot. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
* mirror payment records to arc + atomic /confirm lock to prevent double-creditsAdam Soltys2026-05-172-4/+37
| | | | | | | | | | | | | | | | | | | | | | | | Follows up the Apr 29 double-credit incident. Two parallel callers of /confirm (walletnotify, catchUp, bulk sweeps) could each read p.confirmed=false and each run the multi() that increments balance — because main-db pointers were sometimes missing while arc still had them, gf()'s arc fallback returned stale state. Three pieces here: - routes/payments.ts: SET NX EX 60 lock around the /confirm credit block keyed by `confirmlock:${txid}:${vout}`. Concurrent callers short-circuit cleanly. The lock is released on success and on every early-return. - lib/payments.ts credit() + routes/payments.ts /confirm: mirror the payment/invoice records and the `payment:<txid>:<vout>` / `payment:<hash>` pointer keys into arc as soon as they're written. gf() will then find the prior credit via arc fallback even if the main-db copy is wiped by a future data-loss event. - routes/payments.ts hide `revertedDuplicate` payments from the user's payment list — matches the same filter added to lib/nwc.ts list_transactions so reverted records never surface in either rendering path. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
* nwc: handle stale client state cleanly and skip reverted-duplicate recordsAdam Soltys2026-05-171-1/+5
| | | | | | | | | | | | | | | | | | Three small NWC robustness fixes that surfaced from the Apr 29 double-credit cleanup: - fail loudly when an NWC request references a pubkey whose user record no longer exists (migrated/deleted account); previously crashed inside handle() on `user.id` - swallow `pubkey not found` and `user not found` in the per-event catch so deleted-app/migrated-user spam doesn't fill the error log — these are client-side staleness, not server faults - use gf() instead of g() when fetching payment records (consistent with the rest of the codebase: arc fallback for wiped main-db pointers) - filter `revertedDuplicate` payments out of list_transactions so clients never see the doubled records that were later reverted Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
* serialize limit check + reservation under per-asset mutexAdam Soltys2026-05-171-26/+81
| | | | | | | | | | | | | | | | | | | The previous check at debit() read ${type}:limit, compared amount, and deferred any decrement to the next freezeCheck tick (every 10s). That made the check non-atomic: a burst of concurrent withdrawals could each pass the same stale limit value before any reconciliation happened. Combined with the /gateway abuse, this allowed the hot wallet to be drained. withLimitLock() serializes the check + db.decrBy inside a per-type async mutex, and freezeCheck also takes the lock when overwriting limits so its 10s reconciliation can't race in-flight checks. Single-process only; distributed lock or TigerBeetle reservation needed if app ever scales out. Trade-off: decremented limits are not refunded on payment failure — they get reset by the next freezeCheck within 10s. Acceptable false-deny window in exchange for correctness against bursts. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>