aboutsummaryrefslogtreecommitdiffstats
path: root/lib/payments.ts
Commit message (Collapse)AuthorAgeFilesLines
* 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 days1-2/+14
| | | | | | | | | | | | | | | | | | 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-291-4/+12
| | | | | | | | | | | | 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]>
* lightning: quote the routing fee up front and add a max-send quote endpointAdam Soltys2026-09-211-8/+37
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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]>
* security: drop the admin endpoint surface and land pending hardeningAdam Soltys2026-09-131-7/+55
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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
* 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
* 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
* nwc: enforce per-connection spend budgets + flag-gated incident containmentAdam Soltys2026-08-221-2/+16
| | | | | | | | | | | | | | | | | | | | | | | | | 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
* 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]>
* 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]>
* 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]>
* mint/lightning: refuse caller-supplied invoices, tiered fees, log hygieneAdam Soltys2026-07-301-27/+107
| | | | | | | | | | | | | | | - 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]>
* bolt12 zaps: pay offers, NWC pay/receive, per-user offers, NIP-44Adam Soltys2026-07-301-1/+23
| | | | | | | | | | | | | | | | - sendLightning fetches an invoice from lno1 offers (payer_note passthrough for NIP-177 zap intents) and pays it through the existing bolt12 path - NWC: new nwc#2 pay/receive methods (BIP-321 URIs, payer proofs stubbed until CLN 26.06 createproof), nip44_v2 alongside nip04 with per-request scheme detection, dual 23196/23197 notifications, 13194 refreshed when its content or encryption tag goes stale - standing per-user bolt12 offer via GET /offer; incoming bolt12 payments record invreq_payer_note as memo - parse/fetchinvoice routes handle offers; zap display parses kind 9736 receipts (amount tag + embedded intent) and drops a call to a sync() helper that never existed Co-Authored-By: Claude Fable 5 <[email protected]>
* preimages: enforce a merchant-set price per preimage batchAdam Soltys2026-07-131-2/+5
| | | | | | | | | | | | | Invoice creation is unauthenticated, so buyers could name their own price for a queued preimage (create the usePreimage invoice with amount: 1). POST /preimages now takes an optional price (sats) that is stored per entry and overrides the requested amount when the preimage is consumed; omitting it keeps the old any-amount behavior. Also gate the stored-preimage reveal in credit() on the invoice being fully paid, since internal sends can settle partial amounts. Bare-hex entries queued before this change still parse (treated as priceless). Co-Authored-By: Claude Fable 5 <[email protected]>
* Merge remote-tracking branch 'origin/master'Adam Soltys2026-07-111-0/+13
|\ | | | | | | | | # Conflicts: # lib/payments.ts
| * invoices: sell merchant-supplied preimages via a usePreimage queueAdam Soltys2026-07-101-0/+13
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Merchants POST /preimages (auth) to pre-load 32-byte hex preimages into a FIFO queue; creating an invoice with usePreimage: true consumes the next one so the invoice's payment hash is sha256(preimage). Paying the invoice reveals the merchant-chosen secret to the buyer — via their own wallet on external settlements, and via GET /invoice/:id once settled — enabling backend-less digital download stores (encrypted files on static hosting, preimage + public string = decryption key). - GET /preimages lists the queue with payment hashes so the merchant can map files to future invoices; POST /preimages/delete clears it - empty queue fails invoice creation rather than issuing a random preimage the buyer would pay for but couldn't decrypt with - preimages cln rejects as already used are discarded and the next tried - consumed preimages persist at preimage:${payment_hash} (TTL = invoice expiry) and credit() prefers them as the settlement ref, so internal coinos-to-coinos payments reveal the real preimage instead of the sender's uid Co-Authored-By: Claude Fable 5 <[email protected]>
* | payments: use the debited tip when crediting internal transfersAdam Soltys2026-07-101-3/+10
|/ | | | | | | | | | | | | | | | 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]>
* 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]>
* 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]>
* payments: guard /replay double-credit race with SET NX idempotencyAdam Soltys2026-06-131-0/+15
| | | | | | | | | | 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]>
* 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]>
* 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-171-1/+10
| | | | | | | | | | | | | | | | | | | | | | | | 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]>
* 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]>
* prevent duplicate utxo selectionAdam Soltys2026-04-261-1/+17
|
* check listpays before reversingAdam Soltys2026-04-251-3/+12
|
* lnurl proxyAdam Soltys2026-03-271-1/+11
|
* catch reverse errorsAdam Soltys2026-03-171-2/+2
|
* adjust ln minfeeregtest-dataAdam Soltys2026-03-151-4/+5
|
* fix pending payment cleanup and login password verificationAdam Soltys2026-03-061-0/+4
| | | | | | | Skip non-lightning entries in pending payments check and handle malformed password hashes gracefully during login. Co-Authored-By: Claude Opus 4.6 <[email protected]>
* handle socket errorsAdam Soltys2026-02-251-16/+20
|
* health checkAdam Soltys2026-02-061-59/+0
|
* consult archive for older paymentsAdam Soltys2026-01-301-2/+2
|
* relay reconnect logicAdam Soltys2026-01-301-0/+3
|