aboutsummaryrefslogtreecommitdiffstats
Commit message (Collapse)AuthorAgeFilesLines
* nwc: budget keysend, atomic dedup, serialize spends (S4/S6/S8)Adam Soltys2026-08-071-12/+66
| | | | | | | | | | | | | | | - pay_keysend never called checkBudget and never recorded to ${pubkey}:payments, so a budget-limited connection string was a full-drain credential. Budget-check it, honor max_fee, and record the debit so subsequent budgeted calls count it (S4). - Replace the non-atomic zScore-then-(unawaited)-zAdd dedup with an awaited SET NX claim, so two concurrent deliveries of the same signed event can't both reach handle() and pay twice; also reject future-dated created_at beyond a small skew (S6). - Serialize budget-affecting methods per app with an async mutex so a concurrent request can't read a stale spent-total and overspend (S8). Co-Authored-By: Claude Opus 4.8 <[email protected]>
* upload: require auth on /upload/:type; bump sharp past libvips CVEs (S7)Adam Soltys2026-08-072-2/+2
| | | | | | | | | | POST /upload/:type fed the raw uploaded image straight into sharp with no auth preValidation (unlike neighboring routes), reaching a native libvips decoder in the container that holds the JWT secret and CLN hot wallet. Require auth, and bump sharp 0.33.5 -> 0.35.3 (out of the vulnerable < 0.35.0 libvips range, GHSA-f88m-g3jw-g9cj et al.). Co-Authored-By: Claude Opus 4.8 <[email protected]>
* logging: redact auth headers; set cookie flags on session token (S5)Adam Soltys2026-08-072-3/+26
| | | | | | | | | | | The request logger persisted full req.headers to the "req" log, including the Authorization bearer token and the token cookie — both non-expiring JWTs, so log-read access became account takeover. Redact the authorization and cookie headers. Also set httpOnly/secure/sameSite on the token cookie so it isn't readable by injected JS or sent over plaintext HTTP. (Revocation is already handled by the evicted set.) Co-Authored-By: Claude Opus 4.8 <[email protected]>
* auth: fail closed on adminpass, scrub sample secrets (S3)Adam Soltys2026-08-074-8/+22
| | | | | | | | | | | | | | | | The master-password path (isAdmin = password === config.adminpass) logs in as any user, skipping password, 2FA, and captcha. Worse, when adminpass is unset an omitted password field made undefined === undefined true, authenticating as anyone. Require both the configured value and the supplied password to be non-empty and match exactly, across every adminpass comparison (login, recaptcha bypass, support email, freeze). Also scrub config.ts.sample: the sample shipped a working-looking JWT secret, two server nsec keys, and adminpass "pwa". Replace with fail-closed placeholders and regenerate instructions. Rotate any deployment that ever shipped the sample values. Co-Authored-By: Claude Opus 4.8 <[email protected]>
* auth: hash passwords at bcrypt cost 12 with transparent upgrade (S2)Adam Soltys2026-08-072-3/+18
| | | | | | | | | | 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-072-6/+18
| | | | | | | | | | | | | | 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]>
* lnurl: defer to the v3 registrar for names it has claimedAdam Soltys2026-08-041-15/+53
| | | | | | | | | | | | | | Serving local accounts unconditionally sent migrated names (whose legacy user record still exists) back to the old account, and left v3-only custom names 500ing — only npub1* names had a Cloudflare redirect. Resolution order is now: registrar-claimed -> registrar (response handed through, cached 60s, fail-open to local if the registrar is down), else local account, else 404. The migrated flag stays out of it: register.ts stamps it on essentially every account. No loop: we defer only for names the registrar HAS, and it never queries us for those. Co-Authored-By: Claude Fable 5 <[email protected]>
* lnurl: serve local accounts directly instead of deferring to v3 registrarAdam Soltys2026-08-041-19/+9
| | | | | | | | | | | | | | | | | A recent change made this coinos.io lnurlp endpoint defer any migrated-flagged account to the v3 names registrar and 404 when the registrar didn't have the name. But registration stamps that flag on essentially every account and never syncs names to the registrar, so this broke lightning-address resolution for nearly all accounts. The v3 registrar is the front for v3 accounts and falls back to this endpoint, so this endpoint is the fallback target: it must serve its own accounts directly and must not defer back (which also avoids a loop once the registrar's fallback is live). Serve the local account; 404 only when no local account exists. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01Dpqu6wizYe1c1g4k5Gd8JK
* 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-034-4/+8
| | | | | | | | | | | | 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
* LNURL: fall back to the v3 names registrar for unknown usersAdam Soltys2026-08-021-1/+21
| | | | | | | | | An address at this domain may now belong to a coinos v3 wallet rather than a local account. When the username isn't ours, ask the names registrar and pass its payRequest through unchanged — its callback is absolute, so the payer talks to it directly from there. Co-Authored-By: Claude Fable 5 <[email protected]>
* 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]>
* confirm: guard the first-seen deposit credit atomically (COINOS-2)Adam Soltys2026-07-311-0/+8
| | | | | | | | | | | | | | | | /confirm is unauthenticated, and its first-seen branch credited a deposit after a plain getPayment() read with no lock. Concurrent callers (walletnotify, catchUp, bulk sweeps, or anyone posting the endpoint) all read p == null for one deposit and each ran credit(), inflating both the pending balance and the spendable credit:<net>:<uid> fee accumulator — a fee waiver nobody paid for. Verified on regtest: 10 concurrent /confirm on one 100,000 sat deposit credited 1,000,000 pending and 4,000 fee credit. With the NX guard, exactly 100,000 and 400. Mirrors the lock the confirm stage already had. Reported-by: Muhammed Shekho (@SYR-ROOT) <[email protected]> Co-Authored-By: Claude Fable 5 <[email protected]>
* take: claim single-use authorizations atomically (COINOS-3)Adam Soltys2026-07-311-17/+38
| | | | | | | | | | | | | | | | | | | take() enforced single-use by reading authorization.claimed, checking it, then writing it back — with yielding awaits in between and no lock. N concurrent POST /take for one authorization all passed the gate, so each debited the authorizer and funded the pool. Verified on regtest: 10 concurrent takes redeemed one 10,000 sat single-use authorization 5 times (50,000 debited from the authorizer, 50,000 credited to the caller). With the SET NX claim, exactly one wins. Funding also runs inside a try that releases the claim on failure and only marks the record claimed once the money has landed — otherwise a funding that throws (insufficient balance, server limit) burns the authorization: claim key set, fund never funded, every later take skipping the funding block. 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]>
* mintdemux: present old (recovery) keysets as inactiveAdam Soltys2026-07-311-1/+7
| | | | | | | | The old mint's keysets are redeem/melt-only — wallets must never mint or swap into them. Force active:false in the merged /v1/keysets so mint.coinos.io advertises a single active sat keyset (the new one) instead of two. The old mint is panic-locked (mint/swap disabled) regardless; this fixes the client-facing view a reviewer flagged.
* mintdemux: transparent two-mint proxy for mint.coinos.ioAdam Soltys2026-07-311-63/+118
| | | | | | | | | | | | | Front the current mint (fresh keys, normal ops) as primary, and transparently route pre-shutdown ecash melts to the retired panic-mode mint so wallets can melt old ecash at mint.coinos.io without changing their URL: - /v1/keysets is the union of both mints' keysets - /v1/keys/<id> and melt execute route by the keyset owner - a melt quote is taken on the primary and the invoice remembered, so an old-keyset melt is re-quoted and executed on the recovery mint (where panic mode gates blacklisted funds) - /v1/checkstate is merged across both; CORS added for web wallets The recovery mint also stands alone at mintrecovery.coinos.io.
* 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-recovery: add meltguard + mintdemux proxies; ignore local *.bakAdam Soltys2026-07-303-0/+179
| | | | | | | | | | - meltguard: out-of-band CLNRest spend limiter (per-melt cap + cumulative hard-freeze) that sits between the panic-mode mint and the node - mintdemux: fronts the mint hostname, routing between the retired and current mint instances after the hostname swap - gitignore *.bak / *.bak-* so local backups (some hold secrets) never commit Co-Authored-By: Claude Opus 4.8 <[email protected]>
* mint/lightning: refuse caller-supplied invoices, tiered fees, log hygieneAdam Soltys2026-07-305-44/+158
| | | | | | | | | | | | | | | - 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 payer proofs: disclose zap fields, lightningd v26.06.2 in sampleAdam Soltys2026-07-302-12/+19
| | | | | | | | | | | createproof's default proof omits invreq_payer_note, and passing include replaces the default disclosure set — so for zap payments request both invreq_payer_note and invoice_amount, which NIP-177 verification requires. Verified end to end on v26.06.2: NWC pay over nip44 returns a proof that decodes valid with note + amount disclosed and preimage matching the payment hash. Co-Authored-By: Claude Fable 5 <[email protected]>
* bolt12 zaps: pay offers, NWC pay/receive, per-user offers, NIP-44Adam Soltys2026-07-309-106/+500
| | | | | | | | | | | | | | | | - 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: support fiat prices, converted at invoice-creation timeAdam Soltys2026-07-142-14/+49
| | | | | | | | | | | | | | POST /preimages now accepts fiat + optional currency (defaults to the merchant's) as an alternative to a sat price. The fiat amount is stored per entry and converted to sats at the moment a usePreimage invoice is created, so the price tracks the exchange rate instead of freezing at upload time. The invoice also takes on the entry's currency and rate so receipts display the fiat price exactly as the merchant set it. If no rate is available for the entry's currency, the preimage is pushed back onto the queue and creation fails rather than issuing an any-amount invoice. Co-Authored-By: Claude Fable 5 <[email protected]>
* preimages: enforce a merchant-set price per preimage batchAdam Soltys2026-07-133-19/+56
| | | | | | | | | | | | | 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-114-2/+131
|\ | | | | | | | | # Conflicts: # lib/payments.ts
| * invoices: sell merchant-supplied preimages via a usePreimage queueAdam Soltys2026-07-104-2/+131
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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]>
* | rates: source Iranian IRR/IRT from Wallex, decoupled from the tick loopAdam Soltys2026-07-101-19/+44
| | | | | | | | | | | | | | | | | | | | | | apiv2.nobitex.ir became unreachable from our egress, and it was fetched inline on every binance tick, so it failed and logged on every tick. Move the Iranian market-rate fetch onto its own 60s timer with a request timeout, cache the last-good value (falling back to the fixer rate after an hour), and throttle failure logging. Switch the source to Wallex (api.wallex.ir BTCTMN), which is reachable. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* | 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]>