| Commit message (Collapse) | Author | Age | Files | Lines |
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Adds a regtest Fulcrum service matching what prod runs, replacing the old
electrs + ews bridge as the data/realtime backend for the Hal browser wallet.
It connects to `bc` and gets instant block/mempool notifications over bitcoind's
ZMQ (hashblock/hashtx), serving them to the browser over an Electrum WebSocket
(:50003) — sub-second deposit + confirmation updates on regtest.
- docker/fulcrum.Dockerfile: Fulcrum on ghcr.io/coinos/base (shares layers with
the rest of the stack instead of a separate image tree).
- compose.fulcrum.yml: opt-in overlay (`-f compose.fulcrum.yml up -d`), config +
datadir under the gitignored data/ (seeded from sampledata by setup.sh).
- sampledata/fulcrum/fulcrum.conf: regtest config pointed at `bc`, ZMQ enabled.
- sampledata/bitcoin/bitcoin.conf: add zmqpubhashblock/hashtx (18508/18509) so
Fulcrum can subscribe for instant notifications.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
The source fix (LPOS guard in generate, 2d2f9856) 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.
Scope on a recent prod snapshot: 2,785 of 8,735 lists had dupes
(16,363 duplicate entries).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
| |
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 (kaliyi's OCEAN
offer showed 2x, another 6x). Only push when the id isn't already in the
list (LPOS guard).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
| |
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]>
|
| | |
|
| |
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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 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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
| |
Unauthenticated arbitrary db.set abuse vector. Exploited to inflate user
balances and override safety controls (bitcoin:limit, limit, hardfreeze,
freeze) in real time, leading to a hot-wallet drain. Route and handler
both fully removed.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|