diff options
88 files changed, 11330 insertions, 1279 deletions
@@ -1,6 +1,6 @@ node_modules/ .pnpm* -data +data/ config.ts compose.yml .env @@ -19,3 +19,21 @@ nobal.sh littlefs.img printer failedlogins.txt +.claude +req* + +# local backups (may contain secrets — never commit) +*.bak +*.bak-* + +# Incident-response tooling & data — contain victim/attacker/collector identifiers; +# keep out of the public repo (operational, local-only) +/incident-* +/scripts/refund-secured-incident-victims.ts +/scripts/audit-incident-collectors.ts +/scripts/audit-incident-refund-readiness.ts +/scripts/account-recovery-confidence.ts +/scripts/audit-remaining-secured-victim-outflows.ts + +# peerswap runtime artifacts +/peerswap/ Binary files differdiff --git a/compose.yml.sample b/compose.yml.sample index 7e24f0c2..8b774520 100644 --- a/compose.yml.sample +++ b/compose.yml.sample @@ -45,7 +45,7 @@ services: - ./data/lightning:/root/.lightning restart: always clb: - image: elementsproject/lightningd:v23.11.1-amd64 + image: ghcr.io/coinos/lightningd:v26.06.2 container_name: clb environment: LIGHTNINGD_NETWORK: regtest @@ -53,7 +53,7 @@ services: - ./data/lightningb:/root/.lightning restart: always clc: - image: elementsproject/lightningd:v23.11.1-amd64 + image: ghcr.io/coinos/lightningd:v26.06.2 container_name: clc environment: LIGHTNINGD_NETWORK: regtest diff --git a/config.ts.sample b/config.ts.sample index 299b04ca..80162f5f 100644 --- a/config.ts.sample +++ b/config.ts.sample @@ -1,3 +1,5 @@ +import { SquareEnvironment } from "square"; + export default { archive: "redis://archive", db: "redis://db", @@ -10,7 +12,10 @@ export default { "wss://relay.nostr.bg", "wss://nostr.orangepill.dev" ], - jwt: "00d0ab4f7738a83feb37f661526512063c41e49278b7c32cba87314269a5788b", + // SECURITY: generate a fresh 32-byte secret before deploying — + // `openssl rand -hex 32`. This also keys @fastify/secure-session, so a + // published value lets anyone forge session JWTs and read the session cookie. + jwt: "0000000000000000000000000000000000000000000000000000000000000000", bitcoin: { host: "bc", wallet: "coinos", @@ -29,9 +34,23 @@ export default { btc: "5ac9f65c0efcc4775e0baec4ec03abdde22473cd3cf33c0419ca290e0751b225" }, lightning: "/home/bun/app/data/lightning/regtest/lightning-rpc", + lightningb: "/home/bun/app/data/lightningb/regtest/lightning-rpc", fee: 0.001, - adminpass: "pwa", + // SECURITY: the admin login path is disabled when this is empty (fails + // closed). Set a strong, unique value only if you need admin impersonation; + // never ship the sample value. + adminpass: "", support: "[email protected]", - nostrKey: "nsec13wekuqstlsyfrzm369yh6vjaphslq5pq9kze74acqqynjm8lvpms4qzsx9", + // SECURITY: generate fresh keys — never deploy these sample values. These are + // the server NWC wallet-service key and the zap-receipt signer. + // node -e "import('nostr-tools').then(n=>console.log(n.nip19.nsecEncode(n.generateSecretKey())))" + nostrKey: "nsec1replacewithafreshlygeneratedkeydonotusethissamplevalue0", + nostrKey2: "nsec1replacewithafreshlygeneratedkeydonotusethissamplevalue1", mintUrl: "http://mint:3338", + square: { + environment: SquareEnvironment.Sandbox, + appId: "sandbox-sq0adb-9QbyDZeHTPc_RhFSEr_SaQ", + url: "https://connect.squareupsandbox.com/", + scopes: ["MERCHANT_PROFILE_READ", "ORDERS_READ", "PAYMENTS_READ"], + }, }; diff --git a/docker/arkd.Dockerfile b/docker/arkd.Dockerfile new file mode 100644 index 00000000..08deb403 --- /dev/null +++ b/docker/arkd.Dockerfile @@ -0,0 +1,6 @@ +FROM asoltys/arkd AS source + +FROM ghcr.io/coinos/base +USER root +COPY --from=source /app/arkd /app/ark /usr/local/bin/ +ENTRYPOINT ["arkd"] diff --git a/docker/base.Dockerfile b/docker/base.Dockerfile new file mode 100644 index 00000000..594b0d70 --- /dev/null +++ b/docker/base.Dockerfile @@ -0,0 +1,26 @@ +# docker build -f docker/base.Dockerfile -t ghcr.io/coinos/base docker/ +FROM debian:trixie-slim + +ARG BUN_VERSION=1.4.3 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl unzip ca-certificates wget \ + libpq5 libsodium23 libatomic1 jq socat inotify-tools \ + liblmdb0 libsecp256k1-2 tini \ + && curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" \ + && mv /root/.bun/bin/bun /usr/local/bin/bun \ + && ln -s /usr/local/bin/bun /usr/local/bin/bunx \ + && rm -rf /root/.bun \ + && apt-get purge -y curl unzip \ + && apt-get autoremove -y \ + && rm -rf /var/lib/apt/lists/* \ + /usr/share/doc /usr/share/man /usr/share/locale \ + /usr/share/info /var/log/* /tmp/* + +COPY bun-distrust.ts /tmp/ +RUN cp /usr/local/bin/bun /tmp/bun \ + && /tmp/bun /tmp/bun-distrust.ts /usr/local/bin/bun \ + && rm /tmp/bun /tmp/bun-distrust.ts + +RUN groupadd -g 1000 bun && useradd -u 1000 -g bun -m -s /bin/sh bun +WORKDIR /home/bun/app diff --git a/docker/bitcoin.Dockerfile b/docker/bitcoin.Dockerfile new file mode 100644 index 00000000..d222d0a5 --- /dev/null +++ b/docker/bitcoin.Dockerfile @@ -0,0 +1,11 @@ +FROM ghcr.io/coinos/base AS builder +ARG VERSION=30.2 +USER root +RUN wget -qO- https://bitcoincore.org/bin/bitcoin-core-$VERSION/bitcoin-$VERSION-x86_64-linux-gnu.tar.gz | tar -xz --strip-components=1 -C /usr/local + +FROM ghcr.io/coinos/base +USER root +COPY --from=builder /usr/local/bin/bitcoind /usr/local/bin/bitcoin-cli /usr/local/bin/ +RUN usermod -l bitcoin bun && usermod -d /home/bitcoin -m bitcoin && groupmod -n bitcoin bun +USER bitcoin +ENTRYPOINT ["bitcoind"] diff --git a/docker/bun-distrust.ts b/docker/bun-distrust.ts new file mode 100644 index 00000000..df27f619 --- /dev/null +++ b/docker/bun-distrust.ts @@ -0,0 +1,41 @@ +// Bun (1.4+) compiles Mozilla's root store into its binary as DER, and NODE_USE_SYSTEM_CA +// only adds to it. Until a Bun release drops a distrusted root, overwrite its +// DER in place with a same-length self-signed placeholder whose key was thrown +// away (zeroing it instead makes every TLS handshake fail). +// +// Usage: bun bun-distrust.ts <path to a bun binary> (not the one running it) +import { readFileSync, writeFileSync } from "node:fs"; + +const distrust = [ + { + // eMudhra intermediate key compromise, CVE-2026-111943. + // sha256 86:A1:EC:BA:08:9C:4A:8D:3B:BE:27:34:C6:12:BA:34:1D:81:3E:04:3C:F9:E8:A8:62:CD:5C:57:A3:6B:BE:6B + name: "emSign ECC Root CA - G3", + der: "MIICTjCCAdOgAwIBAgIKPPYHqWhwDtqLhDAKBggqhkjOPQQDAzBrMQswCQYDVQQGEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0gRzMwHhcNMTgwMjE4MTgzMDAwWhcNNDMwMjE4MTgzMDAwWjBrMQswCQYDVQQGEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0gRzMwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQjpQy4LRL1KPOxst3iAhKAnjlfSU2fySU0WXTsuwYc58Byr+iuL+FBVIcUqEqy6HyC5ltqtdyzdc6LBtCGI79G1Y4PPwT01xySfvalY8L1X44uT6EYGQIrMgqCZH0Wk9GjQjBAMB0GA1UdDgQWBBR8XQKEE9TMipuBzhccLikenEhjQjAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAwNpADBmAjEAvvNhzwIQHWSVB7gYboiFBS+DCBeQyh+KTOgNG3qxrdWBCUfvO6wIBHxcmbHtRwfSAjEAnbpV/KlK6O3t5nYBQnvI+GDZjVGLVTv7jHvrZQnD+JbNR6iC8hZVdyR+EhCVBCyj", + placeholder: + "MIICTjCCAdSgAwIBAgIUfFrc+EjEsB3lBo+k60ijjiGPT/IwCgYIKoZIzj0EAwIwXjEfMB0GA1UEAwwWeHh4eHh4eHh4eHh4eHh4eHh4eHh4eDE7MDkGA1UECgwyY29pbm9zIHJldm9rZWQgZW1TaWduIEVDQyBSb290IENBIC0gRzMgcGxhY2Vob2xkZXIwHhcNMjYxMDIxMTI1NjQzWhcNMzYxMDE4MTI1NjQzWjBeMR8wHQYDVQQDDBZ4eHh4eHh4eHh4eHh4eHh4eHh4eHh4MTswOQYDVQQKDDJjb2lub3MgcmV2b2tlZCBlbVNpZ24gRUNDIFJvb3QgQ0EgLSBHMyBwbGFjZWhvbGRlcjB2MBAGByqGSM49AgEGBSuBBAAiA2IABBBYruwA2K1haT213dKQAOYRE5Gdy1PMXpM7V57BH6VwUToz9hkWTCXqeOr7/NctfY1fpqbT+tcKl0/OnTVwZuO3ePtUkTvREL/fuVKd9LTU/eTMT1Pqf2GQfVTmkS8C76NTMFEwHQYDVR0OBBYEFARKTDRI5W1qeguIYelguHqh3gvBMB8GA1UdIwQYMBaAFARKTDRI5W1qeguIYelguHqh3gvBMA8GA1UdEwEB/wQFMAMBAf8wCgYIKoZIzj0EAwIDaAAwZQIwIW/6zsZDBk7LGTetfJJzKqXbhsvMo+4ykb8JSHjLn5zoIsuyBPGvox71gdDPcttgAjEAu3xr0zjPLGQnlM4cwgv6mKTgiiXbt7HneYNN2hu/DvSuCj99c0KuGKEyfHGqotCL", + }, +]; + +const bin = process.argv[2]; +const buf = readFileSync(bin); +let patched = false; + +for (const { name, der: d, placeholder } of distrust) { + const der = Buffer.from(d, "base64"); + const repl = Buffer.from(placeholder, "base64"); + if (repl.length !== der.length) + throw new Error(`${name}: placeholder is ${repl.length} bytes, need ${der.length}`); + const at = buf.indexOf(der); + if (at < 0) { + console.log(`${name}: not in ${bin}, nothing to do`); + continue; + } + if (buf.indexOf(der, at + 1) >= 0) + throw new Error(`${name}: more than one copy in ${bin}`); + repl.copy(buf, at); + patched = true; + console.log(`${name}: replaced at offset ${at}`); +} + +if (patched) writeFileSync(bin, buf); diff --git a/docker/bun.Dockerfile b/docker/bun.Dockerfile new file mode 100644 index 00000000..8ca4aefb --- /dev/null +++ b/docker/bun.Dockerfile @@ -0,0 +1,9 @@ +# oven/bun with distrusted roots taken out of its built-in store (bun-distrust.ts) +# docker build -f docker/bun.Dockerfile --build-arg BUN_VERSION=1.4.3 -t ghcr.io/coinos/bun:1.4.3 docker/ +ARG BUN_VERSION=1.4.3 +FROM oven/bun:${BUN_VERSION} + +COPY bun-distrust.ts /tmp/ +RUN cp /usr/local/bin/bun /tmp/bun \ + && /tmp/bun /tmp/bun-distrust.ts /usr/local/bin/bun \ + && rm /tmp/bun /tmp/bun-distrust.ts diff --git a/docker/chopsticks.Dockerfile b/docker/chopsticks.Dockerfile new file mode 100644 index 00000000..c4cc3f27 --- /dev/null +++ b/docker/chopsticks.Dockerfile @@ -0,0 +1,6 @@ +FROM ghcr.io/vulpemventures/nigiri-chopsticks AS source + +FROM ghcr.io/coinos/base +USER root +COPY --from=source /app /usr/local/bin/chopsticks +ENTRYPOINT ["chopsticks"] diff --git a/docker/electrs.Dockerfile b/docker/electrs.Dockerfile new file mode 100644 index 00000000..6a16942d --- /dev/null +++ b/docker/electrs.Dockerfile @@ -0,0 +1,6 @@ +FROM ghcr.io/vulpemventures/electrs AS source + +FROM ghcr.io/coinos/base +USER root +COPY --from=source /build/electrs /build/electrs +ENTRYPOINT ["/build/electrs"] diff --git a/docker/elements.Dockerfile b/docker/elements.Dockerfile new file mode 100644 index 00000000..3351be82 --- /dev/null +++ b/docker/elements.Dockerfile @@ -0,0 +1,14 @@ +FROM ghcr.io/coinos/base AS builder +ARG VERSION=23.3.4 +ARG SHA256=a758151ace3f21008ab162067ffce9e0e526a1b5d55995e2c30d9cd7ccda41a0 +USER root +RUN wget -qO /tmp/elements.tar.gz https://github.com/ElementsProject/elements/releases/download/elements-$VERSION/elements-$VERSION-x86_64-linux-gnu.tar.gz \ + && echo "$SHA256 /tmp/elements.tar.gz" | sha256sum -c - \ + && tar -xz --strip-components=1 -C /usr/local -f /tmp/elements.tar.gz && rm /tmp/elements.tar.gz + +FROM ghcr.io/coinos/base +USER root +COPY --from=builder /usr/local/bin/elementsd /usr/local/bin/elements-cli /usr/local/bin/ +RUN usermod -l elements bun && usermod -d /home/elements -m elements && groupmod -n elements bun +USER elements +ENTRYPOINT ["elementsd"] diff --git a/docker/kvrocks.Dockerfile b/docker/kvrocks.Dockerfile new file mode 100644 index 00000000..c486a1b2 --- /dev/null +++ b/docker/kvrocks.Dockerfile @@ -0,0 +1,6 @@ +FROM apache/kvrocks AS source + +FROM ghcr.io/coinos/base +USER root +COPY --from=source /usr/bin/kvrocks /usr/bin/kvrocks +ENTRYPOINT ["kvrocks"] diff --git a/docker/lightningd.Dockerfile b/docker/lightningd.Dockerfile new file mode 100644 index 00000000..116852c5 --- /dev/null +++ b/docker/lightningd.Dockerfile @@ -0,0 +1,13 @@ +FROM elementsproject/lightningd:v26.06.9 AS source + +FROM ghcr.io/coinos/base +USER root +COPY --from=source /usr/local/ /usr/local/ +COPY --from=source /usr/bin/bitcoin-cli /usr/bin/ +COPY --from=source /entrypoint.sh /entrypoint.sh + +ENV LIGHTNINGD_DATA=/root/.lightning +ENV LIGHTNINGD_NETWORK=bitcoin +EXPOSE 9735 9835 +VOLUME ["/root/.lightning"] +ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/nutshell.Dockerfile b/docker/nutshell.Dockerfile new file mode 100644 index 00000000..ddf5098f --- /dev/null +++ b/docker/nutshell.Dockerfile @@ -0,0 +1,57 @@ +# Nutshell (cashu mint) on the coinos base image. +# +# Source comes from our fork, github.com/coinos/nutshell, at NUTSHELL_REF: +# limited-invoices-<ver> → `mint` +# panic-mode-limited-invoices-<ver> → `mint-old` +# +# docker build -f docker/nutshell.Dockerfile \ +# --build-arg NUTSHELL_REF=limited-invoices-0.21.0 \ +# -t ghcr.io/coinos/nutshell:0.21.0-limited-invoices . +# +# The interpreter comes from python:3.12-slim-trixie rather than trixie's own +# python3 (3.13): coincurve 20 publishes cp312 wheels but no cp313, so 3.13 +# would mean compiling the libsecp256k1 bindings from source. The builder is +# the same Debian release as ghcr.io/coinos/base, so /usr/local (interpreter + +# site-packages) copies over ABI-compatible, the same way lightningd.Dockerfile +# lifts /usr/local out of the upstream CLN image. + +FROM python:3.12-slim-trixie AS build + +ARG NUTSHELL_REPO=https://github.com/coinos/nutshell.git +ARG NUTSHELL_REF=limited-invoices-0.21.0 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git ca-certificates curl build-essential pkg-config libffi-dev libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +RUN curl -sSL https://install.python-poetry.org | python3 - --version 2.3.2 +ENV PATH="/root/.local/bin:$PATH" \ + POETRY_VIRTUALENVS_CREATE=false \ + PIP_NO_CACHE_DIR=1 + +RUN git clone --depth 1 --branch "$NUTSHELL_REF" "$NUTSHELL_REPO" /app \ + && rm -rf /app/.git +WORKDIR /app + +# editable root install → /app must ship in the runtime image +RUN poetry install --without dev --no-root \ + && poetry install --only-root \ + && find /usr/local/lib/python3.12 -name __pycache__ -prune -exec rm -rf {} + + +FROM ghcr.io/coinos/base + +USER root +# shared libs the CPython build and its wheels link against that base lacks +RUN apt-get update && apt-get install -y --no-install-recommends \ + libexpat1 libffi8 libsqlite3-0 libbz2-1.0 liblzma5 zlib1g libssl3t64 \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=build /usr/local /usr/local +COPY --from=build /app /app + +WORKDIR /app +# runtime config is nutshell's /app/.env (bind-mounted, takes precedence over +# container env); data lives under CASHU_DIR (/app/data/mint) +EXPOSE 3338 +ENTRYPOINT ["tini", "--"] +CMD ["mint"] diff --git a/docker/postgres.Dockerfile b/docker/postgres.Dockerfile new file mode 100644 index 00000000..da3b35ff --- /dev/null +++ b/docker/postgres.Dockerfile @@ -0,0 +1,23 @@ +FROM postgres:18 AS pg +FROM ghcr.io/coinos/base +USER root +COPY --from=pg /usr/lib/postgresql /usr/lib/postgresql +COPY --from=pg /usr/share/postgresql /usr/share/postgresql +COPY --from=pg /usr/local/bin/docker-entrypoint.sh /usr/local/bin/ +COPY --from=pg /lib/x86_64-linux-gnu/libpq.so.5* /lib/x86_64-linux-gnu/ +COPY --from=pg /usr/local/bin/gosu /usr/local/bin/ +RUN ln -s /usr/lib/postgresql/18/bin/* /usr/local/bin/ \ + && apt-get update && apt-get install -y --no-install-recommends libicu76 libreadline8t64 libxml2 libxslt1.1 libssl3t64 liblz4-1 libzstd1 libnuma1 liburing2 locales \ + && rm -rf /var/lib/apt/lists/* \ + && sed -i '/en_US.UTF-8/s/^# //' /etc/locale.gen && locale-gen \ + && groupadd -r postgres --gid=999 && useradd -r -g postgres --uid=999 --home-dir=/var/lib/postgresql --shell=/bin/bash postgres \ + && mkdir -p /var/run/postgresql /var/lib/postgresql /docker-entrypoint-initdb.d \ + && chown -R postgres:postgres /var/run/postgresql /var/lib/postgresql +WORKDIR / +ENV PGDATA=/var/lib/postgresql/data +ENV PG_MAJOR=18 +STOPSIGNAL SIGINT +USER postgres +EXPOSE 5432 +ENTRYPOINT ["docker-entrypoint.sh"] +CMD ["postgres"] diff --git a/docker/strfry.Dockerfile b/docker/strfry.Dockerfile new file mode 100644 index 00000000..dff5448d --- /dev/null +++ b/docker/strfry.Dockerfile @@ -0,0 +1,18 @@ +FROM debian:trixie-slim AS builder +RUN apt-get update && apt-get install -y --no-install-recommends \ + git g++ make pkg-config ca-certificates \ + liblmdb-dev libflatbuffers-dev libsecp256k1-dev \ + libb2-dev libzstd-dev libssl-dev zlib1g-dev \ + && rm -rf /var/lib/apt/lists/* +RUN git clone --depth 1 https://github.com/hoytech/strfry.git /build +WORKDIR /build +RUN git submodule update --init +RUN make setup-golpe && make -j$(nproc) +RUN strip --strip-unneeded strfry + +FROM ghcr.io/coinos/base +USER root +WORKDIR /app +COPY --from=builder /build/strfry /app/strfry +ENTRYPOINT ["tini", "--"] +CMD ["/app/strfry", "relay"] diff --git a/docker/tigerbeetle.Dockerfile b/docker/tigerbeetle.Dockerfile new file mode 100644 index 00000000..a85ad165 --- /dev/null +++ b/docker/tigerbeetle.Dockerfile @@ -0,0 +1,6 @@ +FROM ghcr.io/tigerbeetle/tigerbeetle AS source + +FROM ghcr.io/coinos/base +USER root +COPY --from=source /tigerbeetle /usr/local/bin/tigerbeetle +ENTRYPOINT ["tigerbeetle"] @@ -1,7 +1,8 @@ import app from "$lib/app"; -import { admin, auth, optional } from "$lib/auth"; +import { auth, optional } from "$lib/auth"; -import { fixBolt12, listenForLightning, replay } from "$lib/lightning"; +import { fixBolt12, listenForLightning, ensureListenerAlive, replay } from "$lib/lightning"; +import { startHealthCheck } from "$lib/health"; import { getLocations } from "$lib/locations"; import nwc from "$lib/nwc"; import { catchUp, check } from "$lib/payments"; @@ -17,8 +18,10 @@ import lnurl from "$routes/lnurl"; import locations from "$routes/locations"; import nostr from "$routes/nostr"; import payments from "$routes/payments"; +import preimages from "$routes/preimages"; import rates from "$routes/rates"; import shopify from "$routes/shopify"; +import square from "$routes/square"; import users from "$routes/users"; try { @@ -27,14 +30,15 @@ try { catchUp(); nwc(); check(); -} catch (e) { - console.log(e); -} + startHealthCheck(); +} catch (e) {} setTimeout(listenForLightning, 2000); -setInterval(sendHeartbeat, 2000); +setInterval(ensureListenerAlive, 120000); +setInterval(sendHeartbeat, 5000); app.get("/balances", info.balances); +app.get("/health", info.health); app.post("/email", email.send); app.get("/fx", rates.fx); @@ -43,11 +47,16 @@ app.get("/rates", rates.index); app.get("/locations", locations.list); -app.get("/invoice/:id", invoices.get); +app.get("/invoice/:id", { config: { rateLimit: { max: 30, timeWindow: 10000, keyGenerator: (req) => { const ip = (req.headers["cf-connecting-ip"] as string) || req.ip; const token = (req.headers.authorization || "").slice(0, 50); return token ? `invoice:${token}` : `invoice:${ip}`; } } } }, invoices.get); app.get("/invoices", auth, invoices.list); -app.post("/invoice", optional, invoices.create); +app.post("/invoice", { ...optional, config: { rateLimit: { max: 30, timeWindow: 10000, keyGenerator: (req) => `invoicecreate:${(req.headers["cf-connecting-ip"] as string) || req.ip}` } } }, invoices.create); +app.post("/invoice/:id", { ...optional, config: { rateLimit: { max: 30, timeWindow: 10000, keyGenerator: (req) => `invoiceupdate:${(req.headers["cf-connecting-ip"] as string) || req.ip}` } } }, invoices.update); app.post("/sign", auth, invoices.sign); +app.get("/preimages", auth, preimages.list); +app.post("/preimages", auth, preimages.add); +app.post("/preimages/delete", auth, preimages.clear); + app.get("/assetlinks.json", (_, res) => res.sendFile("assetlinks.json")); app.get("/nostr.json", nostr.identities); app.get("/profile/:profile", nostr.profile); @@ -65,29 +74,52 @@ app.post("/zapRequest", auth, nostr.zapRequest); app.get("/thread/:id", nostr.thread); app.get("/info", payments.info); +app.post("/sendinvoice", auth, payments.sendinvoice); app.post("/payments", auth, payments.create); app.get("/payments", auth, payments.list); app.get("/payments/:hash", payments.get); app.post("/parse", auth, payments.parse); -app.get("/fund/:name", payments.fund); +app.get("/fund/:id", optional, payments.fund); // app.get("/fund/:name/withdraw", auth, payments.withdraw); app.get("/fund/:name/managers", payments.managers); app.post("/fund/managers", auth, payments.addManager); +app.post("/fund/:name/managers/delete", auth, payments.deleteManager); +app.post("/authorize", auth, payments.authorize); app.post("/take", auth, payments.take); app.post("/print", auth, payments.print); app.post("/send/:lnaddress/:amount", auth, payments.lnaddress); app.post("/send", auth, payments.internal); -app.post("/gateway", payments.gateway); app.post("/replace", auth, payments.replace); app.get("/decode/:bolt11", payments.decode); +app.post( + "/lightning/quote", + { + ...auth, + config: { + rateLimit: { + max: 20, + timeWindow: 10000, + keyGenerator: (req) => `lnquote:${(req.headers.authorization || "").slice(0, 50) || (req.headers["cf-connecting-ip"] as string) || req.ip}`, + }, + }, + }, + payments.quote, +); +app.get("/offer", auth, payments.offer); app.post("/fetchinvoice", payments.fetchinvoice); +app.get("/square/connect", auth, square.connect); +app.get("/square/auth", auth, square.auth); +app.post("/square/payment", square.payment); + app.get("/encode", lnurl.encode); app.get("/decode", lnurl.decode); +app.get("/lnurl/proxy", lnurl.proxy); app.get("/lnurl/verify/:id", lnurl.verify); app.get("/lnurlp/:username", lnurl.lnurlp); app.get("/lnurl/:id", lnurl.lnurl); app.get("/pay/:username", lnurl.pay); +app.get("/pay/:username/:amount", lnurl.pay); // app.get("/lnurlw", lnurl.withdraw); app.post("/freeze", payments.freeze); @@ -102,16 +134,17 @@ app.get("/accounts", auth, users.accounts); app.post("/accounts", auth, users.createAccount); app.post("/account/delete", auth, users.deleteAccount); -app.get("/users", auth, users.list); app.get("/me", auth, users.me); +app.get("/ro", auth, users.ro); app.get("/credits", auth, users.credits); app.get("/users/:key", users.get); app.post("/register", users.create); app.post("/disable2fa", auth, users.disable2fa); app.post("/2fa", auth, users.enable2fa); app.post("/user", auth, users.update); +app.post("/delete", auth, users.deleteSelf); app.post("/reset", optional, users.reset); -app.post("/upload/:type", users.upload); +app.post("/upload/:type", auth, users.upload); app.get("/users/delete/:username", users.del); app.post("/acl", users.acl); app.post("/superuser", users.superuser); @@ -131,7 +164,7 @@ app.post( }, users.login, ); -app.post("/printer", users.printer); +app.post("/flash", users.flash); app.get("/challenge", users.challenge); app.post("/nostrAuth", users.nostrAuth); app.get("/app/:pubkey", auth, users.app); @@ -150,6 +183,7 @@ app.get("/subscriptions", auth, users.subscriptions); app.post("/subscription", auth, users.subscription); app.post("/subscription/delete", auth, users.deleteSubscription); app.post("/password", auth, users.password); +app.post("/nsec", auth, users.nsec); app.post("/pin", auth, users.pin); app.post("/otpsecret", auth, users.otpsecret); app.get("/contacts/:limit?", auth, users.contacts); @@ -162,32 +196,23 @@ app.post("/items/sort", auth, items.sort); app.post("/shopify/:id", shopify); -app.post("/hidepay", admin, users.hidepay); -app.post("/unlimit", admin, users.unlimit); app.get("/bolt12", fixBolt12); app.get("/cash/:id/:version", ecash.get); -app.post("/cash", ecash.save); +app.post("/ecash/status", ecash.status); app.post("/claim", auth, ecash.claim); -app.post("/mint", auth, ecash.mint); -app.post("/melt", auth, ecash.melt); app.post("/ecash/:id", ecash.receive); app.get("/replay/:index", (req, res) => { - replay(req.params.index); + // replay(req.params.index); res.send({}); }); -app.post("/echo", (req, res) => { - console.log("echo", req.body); - res.send(req.body); -}); - const host: string = process.env["HOST"] || "0.0.0.0"; -const port: number = parseInt(process.env["PORT"]) || 3119; +const port: number = Number.parseInt(process.env["PORT"]) || 3119; app.listen({ host, port }); -const logerr = (e: Error) => console.log(e); +const logerr = (e: Error) => {}; process.on("unhandledRejection", logerr); process.on("uncaughtException", logerr); @@ -1,61 +1,137 @@ import config from "$config"; -import fastify from "fastify"; +import cors from "@fastify/cors"; +import fastifyProxy from "@fastify/http-proxy"; import fastifyMultipart from "@fastify/multipart"; -import fastifyStatic from "@fastify/static"; import fastifyPassport from "@fastify/passport"; -import fastifySecureSession from "@fastify/secure-session"; -import fastifyProxy from "@fastify/http-proxy"; import fastifyRateLimit from "@fastify/rate-limit"; -import cors from "@fastify/cors"; +import fastifySecureSession from "@fastify/secure-session"; +import fastifyStatic from "@fastify/static"; +import fastify from "fastify"; import pino from "pino"; +import dns from "node:dns"; import * as path from "path"; import { jwtStrategy } from "$lib/auth"; const app = fastify({ logger: true, - disableRequestLogging: true, - maxParamLength: 500, + logController: new fastify.LogController({ disableRequestLogging: true }), + routerOptions: { maxParamLength: 500 }, }); const reqLogger = pino(pino.destination("req")); -const resLogger = pino(pino.destination("res")); + +// Never log plaintext credentials. This MUST recurse: the previous version +// only redacted top-level keys, but POST /register posts {user:{password}} — +// one level down — so every registration wrote its plaintext password to the +// request log, and `pin`/`newpin` were not on the list at all (POST /pin, +// POST /user and POST /take all carry one). Anything nested in an array is +// covered too. Depth is bounded so a pathological body can't spin here. +const REDACT_FIELDS = new Set([ + "password", + "newpassword", + "confirm", + "pin", + "newpin", + "secret", + "otpsecret", + "nsec", + "seed", + "privkey", + "mnemonic", +]); +const redactBody = (body: any, depth = 0): any => { + if (!body || typeof body !== "object" || depth > 6) return body; + if (Array.isArray(body)) return body.map((v) => redactBody(v, depth + 1)); + const copy: any = { ...body }; + for (const k of Object.keys(copy)) { + if (REDACT_FIELDS.has(k)) { + if (copy[k] !== undefined && copy[k] !== null && copy[k] !== "") + copy[k] = "[redacted]"; + } else if (copy[k] && typeof copy[k] === "object") { + copy[k] = redactBody(copy[k], depth + 1); + } + } + return copy; +}; + +// Never persist session credentials to the request log. The Authorization +// bearer token and the token cookie are full account credentials (JWTs that +// don't expire) — logging them turns log-read access into account takeover. +const REDACT_HEADERS = ["authorization", "cookie"]; +const redactHeaders = (headers: any) => { + if (!headers || typeof headers !== "object") return headers; + const copy: any = { ...headers }; + for (const h of REDACT_HEADERS) if (h in copy) copy[h] = "[redacted]"; + return copy; +}; // app.addHook("onRequest", async (req) => { // reqLogger.info({ url: req.raw.url, id: req.id }); // }); // +// The client IP comes from cf-connecting-ip, which Cloudflare sets and lb/ui +// pass through. Onion traffic skips Cloudflare: the tor container connects to +// us directly, so whatever cf-connecting-ip / x-forwarded-for / rate-limit-by +// the client sent would reach the rate limiter and IP bans untouched. Pin those +// requests to the tor container's own address instead. Registered before the +// rate limiter so its keyGenerator sees the rewritten header. +const UNTRUSTED_PEERS = ["tor"]; +let untrusted = new Set<string>(); +const resolveUntrusted = async () => { + const next = new Set<string>(); + for (const host of UNTRUSTED_PEERS) { + try { + for (const { address } of await dns.promises.lookup(host, { all: true })) + next.add(address); + } catch {} + } + if (next.size) untrusted = next; +}; +await resolveUntrusted(); +setInterval(resolveUntrusted, 30000).unref(); + +app.addHook("onRequest", async (req) => { + const peer = (req.socket.remoteAddress || "").replace(/^::ffff:/, ""); + if (!untrusted.has(peer)) return; + req.headers["cf-connecting-ip"] = peer; + delete req.headers["x-forwarded-for"]; + delete req.headers["rate-limit-by"]; +}); + app.addHook("preHandler", async (req) => { const url = req.raw.url; - const ignore = ["/login","/ws","/me","/confirm","/public"]; - if (req.method === "GET" || ignore.some(path => url.startsWith(path))) return; + const ignore = [ + "/ws", + "/me", + "/confirm", + "/public", + "/rates", + "/challenge", + "/rate", + "/lnurlp", + "/subscriptions", + "/accounts", + "/contacts", + ]; + if (ignore.some((path) => url.startsWith(path))) return; + if (req.method === "GET" && url.startsWith("/users")) return; + + const xff = req.headers["x-forwarded-for"]; + const forwardedIp = Array.isArray(xff) ? xff[0] : xff?.split(",")[0]?.trim(); + const ip = req.headers["cf-connecting-ip"] || forwardedIp || req.ip; reqLogger.info({ method: req.method, url, - headers: req.headers, - body: req.body, - id: req.id - }); -}); - -app.addHook("onResponse", async (req, reply) => { - const rawCookies = req.raw.headers.cookie || ""; - - const cookies: any = rawCookies.split(";").reduce((acc, cookie) => { - const [key, value] = cookie.split("=").map((s) => s.trim()); - if (key && value) acc[key] = value; - return acc; - }, {}); - - resLogger.info({ + ip, + headers: redactHeaders(req.headers), + query: req.query, + body: redactBody(req.body), + user: (req.user as any)?.username, id: req.id, - url: req.raw.url, - statusCode: reply.raw.statusCode, - durationMs: reply.elapsedTime, - username: cookies.username, }); }); @@ -63,7 +139,11 @@ app.register(fastifyRateLimit, { allowList: (req) => req.raw.url?.includes("public"), max: 2000, timeWindow: 2000, - keyGenerator: (req) => req.headers["cf-connecting-ip"] as string, + keyGenerator: (req) => { + const ip = (req.headers["cf-connecting-ip"] as string) || req.ip; + const ua = req.headers["user-agent"] || "unknown-ua"; + return req.headers["rate-limit-by"] === "ua" ? ua : ip; + }, errorResponseBuilder: () => { return { statusCode: 429, @@ -73,11 +153,28 @@ app.register(fastifyRateLimit, { }, }); +app.register(fastifyRateLimit, { + allowList: (req) => { + const url = req.raw.url || ""; + const matches = url.includes("/login") || url.includes("/send"); + return !matches; + }, + max: 10, + timeWindow: 10000, + keyGenerator: (req) => (req.headers["user-agent"] as string) || "unknown-ua", + errorResponseBuilder: () => ({ + statusCode: 429, + error: "Too Many Requests", + message: "Rate limit exceeded", + }), +}); + app.register(fastifyProxy, { upstream: "http://localhost:3120", prefix: "/ws", rewritePrefix: "/ws", websocket: true, + disableRequestLogging: true, }); app.register(fastifySecureSession, { diff --git a/lib/auth.ts b/lib/auth.ts index 29edcf61..107c44f4 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -1,20 +1,10 @@ -import fastifyPassport from "@fastify/passport"; -import { g } from "$lib/db"; -import { fail, getUser } from "$lib/utils"; import config from "$config"; +import { db } from "$lib/db"; +import { fail, getUser } from "$lib/utils"; +import { quiet, suppressed } from "$lib/logging"; +import fastifyPassport from "@fastify/passport"; import jwt from "passport-jwt"; -export const admin = { - preValidation: fastifyPassport.authenticate( - "jwt", - { session: false }, - (req, res, err, user, info) => { - if (!user.admin) return res.code(401).send("unauthorized"); - req.user = user; - }, - ), -}; - export const optional = { preValidation: fastifyPassport.authenticate( "jwt", @@ -36,6 +26,19 @@ export const requirePin = async ({ body, user }) => { if (!user || (user.pin && user.pin !== body.pin)) fail("Invalid pin"); }; +// A caller-supplied `aid` decides which account's node wallet composes and +// signs a withdrawal and whose balance gets debited, so it has to be an +// account the caller actually owns. `${uid}:accounts` is the ownership list — +// the same one GET /account/:id and payments.list already check — and a user's +// own id is in it. Omitted `aid` means "my own account". +export const requireAccount = async ({ aid, user }) => { + if (!user) fail("unauthorized"); + if (!aid || aid === user.id) return user.id; + if ((await db.lPos(`${user.id}:accounts`, aid)) === null) + fail("unauthorized"); + return aid; +}; + export const jwtStrategy = new jwt.Strategy( { jwtFromRequest: jwt.ExtractJwt.fromExtractors([ @@ -43,9 +46,46 @@ export const jwtStrategy = new jwt.Strategy( (req) => (req.cookies ? req.cookies.token : null), ]), secretOrKey: config.jwt, + passReqToCallback: true, }, - async (payload, next) => { - const user = await getUser(payload.id); + async (req, payload, next) => { + const { originalUrl: u, method: m } = req; + + let { id } = payload; + const wl = { GET: ["/invoice", "/payments"], POST: ["/invoice"] }; + + if (id.endsWith("-ro") && wl[m].some((p) => u.startsWith(p))) + id = id.slice(0, -3); + + const user = await getUser(id); + + // Hard eviction: an account in the `evicted` set cannot authenticate AT ALL + // — every request, every endpoint — regardless of source IP/VPN. Unlike the + // `blacklist` freeze (which only blocks sends), this kills the value of a + // compromised/attacker JWT outright. Match on the immutable uid OR username + // so a rename can't shake it. + if ( + user && + ((await db.sIsMember("evicted", user.id)) || + (await db.sIsMember("evicted", user.username?.toLowerCase?.().trim()))) + ) { + // Distinctive, greppable line carrying the real source IP so the realtime + // auto-banner (scripts/atk-autoban.sh) can insta-ban it at Cloudflare. + // Throttled per account. An evicted JWT replayed from a rotating proxy + // pool otherwise emits one line per request and + // has atk-autoban.sh place a Cloudflare block on every residential IP it + // sees, which protects nothing. First hit logs with its IP; afterwards + // one summary per 10 minutes, worded so the auto-banner ignores it. + const n = quiet(`evicted:${user.id}`); + if (n !== null) + console.error( + n + ? `evicted-auth ${user.username} ${suppressed(n)}` + : `EVICTED_AUTH ${user.username} ${req.headers["cf-connecting-ip"] || req.ip}`, + ); + return next(null, false); + } + next(null, user); }, ); diff --git a/lib/changeid.ts b/lib/changeid.ts new file mode 100644 index 00000000..1c6a6688 --- /dev/null +++ b/lib/changeid.ts @@ -0,0 +1,121 @@ +import { db, g, s, ga, sa, archive as arc } from "$lib/db"; +import { err, l } from "$lib/logging"; +import { randomUUID } from "crypto"; + +export default async function changeid(un: string) { + un = un.toLowerCase(); + const id = await g(`user:${un}`); + if (!id) throw new Error(`user ${un} not found`); + + l(`SECURITY: rekeying ${un}, old id ${id}`); + + const nid = randomUUID(); + const user = await g(`user:${id}`); + if (!user) throw new Error(`user record ${id} not found`); + + user.id = nid; + user.password = "reset"; + + if (user.pubkey) { + await s(user.pubkey, nid); + await sa(user.pubkey, nid); + } + await s(`user:${un}`, nid); + await sa(`user:${un}`, nid); + await s(`user:${nid}`, user); + await sa(`user:${nid}`, user); + + const keys = [ + `credit:lightning:${id}`, + `credit:liquid:${id}`, + `${id}:invoices`, + `credit:bitcoin:${id}`, + `pending:${id}`, + `${id}:lastlen`, + `${id}:contacts`, + `${id}:accounts`, + `${id}:payments`, + `account:${id}`, + `balance:${id}`, + `user:${id}`, + ]; + + async function migrateKeys(client, get, set, label) { + for (const k of keys) { + const nk = k.replace(id, nid); + try { + if ( + k.includes("credit:") || + k.includes("balance:") || + k.includes("pending:") + ) { + const a = await get(k); + await client.incrBy(nk, parseInt(a) || 0); + await client.del(k); + } else if (k.includes(":invoices") || k.includes(":payments")) { + const arr = await client.lRange(k, 0, -1); + const narr = []; + for (const pid of arr) { + const ok = `${k.split(":")[1].slice(0, -1)}:${pid}`; + const o = await get(ok); + if (!o) continue; + o.id = pid; + o.uid = nid; + if (o.aid === id) o.aid = nid; + await set(ok, o); + narr.push(o); + } + + await client.del(k); + + for (const o of narr.sort((a, b) => b?.created - a?.created)) { + if (o) await client.rPush(nk, o.id); + } + } else if (k.includes("account:")) { + const acc = await get(k); + if (!acc) continue; + acc.id = nid; + await set(nk, acc); + await client.del(k); + } else if (k.includes("user:")) { + await client.del(k); + } else { + if (await client.exists(k)) await client.rename(k, nk); + } + } catch (e: any) { + err(`changeid [${label}]`, k, e.message); + } + } + + await client.lRem(`${nid}:accounts`, 0, id); + await client.rPush(`${nid}:accounts`, nid); + } + + await migrateKeys(db, g, s, "main"); + await migrateKeys(arc, ga, sa, "arc"); + + const apps = await db.sMembers(`${id}:apps`); + for (const aid of apps) { + await db.del(`app:${aid}`); + } + await db.del(`${id}:apps`); + + // Update uid/aid in invoice objects and their offer pointers + const invoiceIds = await db.lRange(`${nid}:invoices`, 0, -1); + for (const iid of invoiceIds) { + try { + const inv = await g(`invoice:${iid}`); + if (!inv || typeof inv === "string") continue; + let changed = false; + if (inv.uid === id) { inv.uid = nid; changed = true; } + if (inv.aid === id) { inv.aid = nid; changed = true; } + if (changed) { + await s(`invoice:${iid}`, inv); + l("changeid: updated invoice", iid); + } + } catch (e) {} + } + + l(`SECURITY: rekeyed ${un} from ${id} to ${nid}`); + return nid; +} @@ -100,7 +100,7 @@ async function dbReconnect() { async function archiveReconnect() { try { - await archive.connect(); + if (!archive.isOpen) await archive.connect(); } catch (err) { console.error("Failed to connect to Redis, retrying...", err); setTimeout(archiveReconnect, 5000); // Retry after 5 seconds @@ -130,9 +130,9 @@ export const g = async (k) => { } }; -export const s = (k, v) => { +export const s = (k, v, ttl?: number) => { if (k === "user:null" || k === "user:undefined") fail("null user"); - db.set(k, JSON.stringify(v)); + db.set(k, JSON.stringify(v), ttl ? { EX: ttl } : undefined); }; export const ga = async (k) => { @@ -152,6 +152,43 @@ export const sa = (k, v) => { archive.set(k, JSON.stringify(v)); }; +// Get with fallback to archive +export const gf = async (k) => { + let v = await db.get(k); + if (v === null) v = await archive.get(k); + try { + return JSON.parse(v); + } catch (e) { + return v; + } +}; + +// lRange with fallback to archive for missing items +export const lRangeWithArchive = async (k, start, end) => { + const items = await db.lRange(k, start, end); + return items || []; +}; + +// Get payment/invoice with archive fallback - for individual lookups +export const getWithArchive = async (prefix, id) => { + let v = await db.get(`${prefix}:${id}`); + if (v === null) v = await archive.get(`${prefix}:${id}`); + if (v === null) return null; + try { + const parsed = JSON.parse(v); + // If it's a reference to another key, follow it + if (typeof parsed === "string") { + let ref = await db.get(`${prefix}:${parsed}`); + if (ref === null) ref = await archive.get(`${prefix}:${parsed}`); + if (ref === null) return null; + return JSON.parse(ref); + } + return parsed; + } catch (e) { + return v; + } +}; + const retries = {}; export const t = async (k, f) => { try { diff --git a/lib/ecash.ts b/lib/ecash.ts index bcd2b7f4..599bd7b0 100644 --- a/lib/ecash.ts +++ b/lib/ecash.ts @@ -1,100 +1,172 @@ import config from "$config"; -import { g, s } from "$lib/db"; -import { lnb } from "$lib/ln"; -import { fail, wait } from "$lib/utils"; +import { g } from "$lib/db"; +import { generate } from "$lib/invoices"; +import { warn } from "$lib/logging"; +import { PaymentType } from "$lib/types"; +import { fail, getPayment, sleep, wait } from "$lib/utils"; import { CashuMint, CashuWallet, - MintQuoteState, PaymentRequest, PaymentRequestTransportType, getDecodedToken, - getEncodedToken, - getEncodedTokenV4, } from "@cashu/cashu-ts"; - const { URL } = process.env; const m = new CashuMint(config.mintUrl); -const w = new CashuWallet(m); -const enc = (proofs) => - getEncodedToken({ - mint: config.mintUrl, - proofs, - }); +// Redeeming ecash is a plain NUT-05 melt: the proofs pay a Lightning invoice +// that belongs to the redeeming user, and the settlement is credited by the +// regular lightning listener like any other incoming payment. The mint's node +// is our own, so the melt is a self-payment on cl — nothing is held in a house +// wallet and no proofs are swapped or stored on our side. + +// cashu-ts 2.9.0 writes v1 keyset ids into V4 tokens in their 8-byte short form +// and refuses to decode a short id unless it's handed the mint's keysets to +// expand it against. config.mintUrl fronts every keyset we've ever issued, so +// its list is enough to decode any token of ours. +const KEYSETS_TTL = 10 * 60 * 1000; +let cached: { keysets: any[]; at: number } | undefined; +const keysets = async () => { + if (cached && Date.now() - cached.at < KEYSETS_TTL) return cached.keysets; + const { keysets } = await m.getKeySets(); + // Never cache an empty list: a token can't be decoded or fee-checked + // against it, and the next call should try the mint again. + if (!keysets?.length) fail("Mint keysets unavailable"); + cached = { keysets, at: Date.now() }; + return keysets; +}; + +// A short keyset id that doesn't expand against our keysets belongs to some +// other mint; say so instead of surfacing cashu-ts's mapping error. +const decode = async (token) => { + try { + return getDecodedToken(token, await keysets()); + } catch (e) { + if (/short keyset id/i.test(e.message)) + fail("Unable to receive from other mints"); + throw e; + } +}; -const ext = async (mint) => { - const issuer = new CashuMint(mint); - const { pubkey: issuerPk } = await issuer.getInfo(); - const { pubkey: ourPk } = await m.getInfo(); - return issuerPk !== ourPk; +// A token is ours when every proof is on one of our keysets. Keyset ids are +// derived from the mint's public keys, so they identify the issuer; the token's +// `mint` URL is just a label the sending wallet wrote in (tokens of ours turn +// up with stray ports and paths on it) and is never fetched. A miss refreshes +// the cached list once in case a keyset was rotated in since it was fetched. +const ext = async (proofs) => { + const ours = (ks) => { + const ids = new Set(ks.map((k) => k.id)); + return proofs.every((p) => ids.has(p.id)); + }; + if (ours(await keysets())) return false; + cached = undefined; + return !ours(await keysets()); }; +const sum = (proofs) => proofs.reduce((a, p) => a + p.amount, 0); + +// NUT-02 input fee: ceil of the summed per-proof ppk. +const inputFee = (proofs, ks) => + Math.ceil( + proofs.reduce( + (a, p) => a + (ks.find((k) => k.id === p.id)?.input_fee_ppk || 0), + 0, + ) / 1000, + ); + +// nutshell's Lightning fee reserve: max(lightning_reserve_fee_min = 2 sat, +// lightning_fee_percent = 1%), rounded up to whole sats. The actual routing fee +// of a self-payment is zero, but without NUT-08 change outputs the mint keeps +// the reserve — it's our mint, so nothing leaves the system. +const reserve = (amount) => Math.max(2, Math.ceil(amount / 100)); + export async function get(id) { const token = await g(`cash:${id}`); return token; } -export async function claim(token) { - const { proofs: current } = getDecodedToken(await g("cash")); - const { mint } = getDecodedToken(token); - - if (await ext(mint)) fail("Unable to receive from other mints"); +// Melt `token` into a Lightning invoice owned by `user`. With `invoice` (a +// NUT-18 payment-request invoice of type ecash) the Lightning invoice takes +// over that id, so whatever is waiting on it sees the payment land. Returns +// the amount the user receives: the token's value less the mint's input fee +// and Lightning fee reserve. +export async function redeem({ token, user, invoice = undefined, memo = undefined }) { + const { proofs } = await decode(token); + if (!proofs?.length) fail("Token has no proofs"); + if (await ext(proofs)) fail("Unable to receive from other mints"); - const rcvd = await w.receive(token); + // Cheap pre-check so a spent token fails before an invoice is created for + // it (the melt would reject it anyway, but not before leaving an unpaid + // invoice on the user's account). + const states = await new CashuWallet(m).checkProofsStates(proofs); + if (states.some((p) => p.state !== "UNSPENT")) fail("Token already spent"); - await s("cash", enc([...current, ...rcvd])); - return rcvd.reduce((a, b) => a + b.amount, 0); -} + const ks = await keysets(); + const total = sum(proofs); + const fees = inputFee(proofs, ks); + let amount = total - fees - 2; + while (amount > 0 && amount + reserve(amount) + fees > total) amount--; + if (amount < 1) fail("Token is too small to redeem"); -export async function mint(amount) { - const { keysets } = await m.getKeySets(); - const w = new CashuWallet(m, { keysets }); - const { proofs } = getDecodedToken(await g("cash")); - const { send, keep } = await w.send(amount, proofs); - const rcvd = await w.receive(enc(send)); - const change = enc(keep); - await s("cash", change); - return enc(rcvd); -} - -export async function check(token) { - const { mint, proofs } = getDecodedToken(token); - const total = proofs.reduce((a, b) => a + b.amount, 0); + invoice = await generate({ + invoice: { + ...(invoice ?? {}), + id: invoice?.id, + amount, + // The invoice amount is exactly what the melt can cover: no tip on top, + // and no fiat re-pricing of it. + tip: null, + fiat: undefined, + bolt11: undefined, + bolt12: undefined, + memo: memo ?? invoice?.memo, + type: PaymentType.lightning, + }, + user, + }); - const external = await ext(mint); + const quote = await m.createMeltQuote({ unit: "sat", request: invoice.text }); + if (quote.amount + quote.fee_reserve + fees > total) + fail("Mint fee reserve exceeds the token's value"); - let spent = 0; - for (const [i, p] of (await w.checkProofsStates(proofs)).entries()) { - if (p.state === "SPENT") spent += proofs[i].amount; + let r = await m.melt({ quote: quote.quote, inputs: proofs }); + for (let i = 0; r.state === "PENDING" && i < 60; i++) { + await sleep(1000); + r = await m.checkMeltQuote(quote.quote); } + if (r.state === "PENDING") + fail("Redemption is still settling; the funds will arrive once it does"); + if (r.state !== "PAID") fail("Redemption failed"); - return { total, spent, mint, external }; -} - -export async function init(amount = 100000) { + // The lightning listener credits the settlement. Give it a moment so the + // caller's payment list already shows it, but the credit doesn't depend on + // this wait. try { - await new Promise((r) => setTimeout(r, 2000)); - const { quote, request } = await w.createMintQuote(amount); - await lnb.pay(request); + await wait(() => getPayment(invoice.hash), 50, 200); + } catch { + warn("ecash redeemed but credit not yet visible", user.username, invoice.hash); + } - await wait(async () => { - const { state } = await w.checkMintQuote(quote); - return state === MintQuoteState.PAID; - }); + return amount; +} - const proofs = await w.mintProofs(amount, quote); +export async function check(token) { + const { mint, proofs } = await decode(token); + const total = sum(proofs); - const cash = getEncodedTokenV4({ - mint: config.mintUrl, - proofs, - }); + const external = await ext(proofs); - await s("cash", cash); - } catch (e) { - console.log(e); + // Only ask our own mint about proof states. + let spent = 0; + if (!external) { + const w = new CashuWallet(m); + for (const [i, p] of (await w.checkProofsStates(proofs)).entries()) { + if (p.state === "SPENT") spent += proofs[i].amount; + } } + + return { total, spent, mint, external }; } export function request(uuid, amount, memo) { diff --git a/lib/health.ts b/lib/health.ts new file mode 100644 index 00000000..2a876909 --- /dev/null +++ b/lib/health.ts @@ -0,0 +1,194 @@ +import ln, { LightningUnavailableError } from "$lib/ln"; +import { g } from "$lib/db"; +import { getLightningListenerStatus } from "$lib/lightning"; +import { err, l, warn } from "$lib/logging"; + +const HEALTH_CHECK_INTERVAL = 2 * 60 * 1000; // 2 minutes +const RPC_TIMEOUT = 30_000; // 30 seconds - fail fast if RPC hangs +const MAX_CONSECUTIVE_FAILURES = 3; + +let consecutiveFailures = 0; +let lastSuccessTime = Date.now(); +let lastFailureReason = ""; +let checkTimer: ReturnType<typeof setTimeout> | null = null; +let listenerBacklogPayIndex: number | null = null; + +export function getHealthStatus() { + return { + consecutiveFailures, + lastSuccessTime, + lastFailureReason, + healthy: consecutiveFailures < MAX_CONSECUTIVE_FAILURES, + stalledCheck: checkTimer === null && consecutiveFailures > 0, + listenerBacklogPayIndex, + listener: getLightningListenerStatus(), + }; +} + +function withTimeout<T>( + promise: Promise<T>, + ms: number, + label: string, +): Promise<T> { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`${label} timed out after ${ms}ms`)), + ms, + ); + promise.then( + (v) => { + clearTimeout(timer); + resolve(v); + }, + (e) => { + clearTimeout(timer); + reject(e); + }, + ); + }); +} + +async function checkLightningHealth(): Promise<boolean> { + const startTime = Date.now(); + + try { + // l("health check: testing lightning connection..."); + const info = await withTimeout(ln.getinfo(), RPC_TIMEOUT, "getinfo"); + + if (!info?.id) { + throw new Error("getinfo returned invalid response (no node id)"); + } + + const testLabel = `health_check_${Date.now()}`; + const invoice = await withTimeout( + ln.invoice({ + amount_msat: "1000msat", + label: testLabel, + description: "health check", + expiry: 60, + }), + RPC_TIMEOUT, + "invoice", + ); + + if (!invoice?.bolt11) { + throw new Error("invoice creation returned invalid response"); + } + + try { + await withTimeout( + ln.delinvoice({ label: testLabel, status: "unpaid" }), + RPC_TIMEOUT, + "delinvoice", + ); + } catch (e) { + // Ignore cleanup errors + } + + // A responsive CLN node is not sufficient: the dedicated receive listener + // can be wedged while getinfo/invoice still work. Probe one index beyond the + // durable cursor. Code 904 means no backlog and is the healthy outcome. + const payIndex = Number((await g("pay_index")) || 0); + listenerBacklogPayIndex = null; + try { + const waiting = await withTimeout( + ln.waitanyinvoice(payIndex, 1), + 15_000, + "listener backlog probe", + ); + if (Number(waiting?.pay_index) > payIndex) { + // A paid invoice exists beyond the durable cursor. That alone is NOT a + // wedged listener — it is almost always the listener mid-processing this + // very payment, before it has advanced the cursor (normal sub-second + // latency). Only a listener that is genuinely STUCK fails to advance. + // Grace-check: give it a moment, then re-read the cursor. If it moved, + // the listener is alive and this was transient. Only fail if it did not. + const next = Number(waiting.pay_index); + await new Promise((r) => setTimeout(r, 2500)); + const after = Number((await g("pay_index")) || 0); + if (after <= payIndex) { + listenerBacklogPayIndex = next; + throw new Error( + `lightning listener backlog: stored=${payIndex} next=${next} (cursor did not advance in 2.5s)`, + ); + } + } + } catch (e: any) { + if (Number(e?.code ?? e?.errno) !== 904) throw e; + } + + const duration = Date.now() - startTime; + // l(`health check: passed in ${duration}ms, node: ${info.id.slice(0, 16)}...`); + + return true; + } catch (e: any) { + const duration = Date.now() - startTime; + const errorCode = e?.code ?? e?.errno ?? "unknown"; + const errorMsg = e?.message ?? String(e); + + err( + `health check: FAILED after ${duration}ms`, + `code=${errorCode}`, + `error=${errorMsg}`, + ); + + if (e instanceof LightningUnavailableError) { + err("health check: lightning RPC socket unavailable"); + } + + lastFailureReason = `${errorCode}: ${errorMsg}`; + return false; + } +} + +export async function runHealthCheck() { + checkTimer = null; + + try { + const healthy = await checkLightningHealth(); + + if (healthy) { + if (consecutiveFailures > 0) { + l( + `health check: recovered after ${consecutiveFailures} consecutive failures`, + ); + } + consecutiveFailures = 0; + lastSuccessTime = Date.now(); + lastFailureReason = ""; + } else { + consecutiveFailures++; + warn( + `health check: failure ${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES}`, + ); + + if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + err( + `health check: ${MAX_CONSECUTIVE_FAILURES} consecutive failures, ` + + `last success was ${Math.round((Date.now() - lastSuccessTime) / 1000)}s ago`, + ); + err(`health check: last failure reason: ${lastFailureReason}`); + err("health check: exiting process to trigger container restart"); + + setTimeout(() => { + process.exit(1); + }, 1000); + return; + } + } + } catch (e: any) { + err("health check: unexpected error during health check", e?.message); + consecutiveFailures++; + } + + // Schedule next check + checkTimer = setTimeout(runHealthCheck, HEALTH_CHECK_INTERVAL); +} + +export function startHealthCheck() { + // l( + // `health check: starting (interval=${HEALTH_CHECK_INTERVAL / 1000}s, timeout=${RPC_TIMEOUT / 1000}s, max_failures=${MAX_CONSECUTIVE_FAILURES})`, + // ); + // Run first check after a delay to let the app initialize + checkTimer = setTimeout(runHealthCheck, 30000); +} diff --git a/lib/image-worker.ts b/lib/image-worker.ts new file mode 100644 index 00000000..d45927d1 --- /dev/null +++ b/lib/image-worker.ts @@ -0,0 +1,29 @@ +// Image conversion runs in THIS separate process, spawned per upload by +// lib/image.ts. It is the only place sharp/libvips is ever loaded. Reads the +// raw upload on stdin, writes the resized webp to stdout, exits 0. Any +// decoder bug (libheif/libwebp/libpng heap corruption, SIGSEGV, SIGILL) kills +// this process alone — the app keeps serving. 2026-09-26: a stream of corrupt +// HEIF uploads crash-looped the whole server when this ran in-process. +const sharp = require("sharp"); + +const MAX_PIXELS = 80_000_000; + +const width = Number(process.argv[2]); +if (!Number.isInteger(width) || width <= 0 || width > 4096) { + console.error("bad width"); + process.exit(2); +} + +try { + const input = Buffer.from(await new Response(Bun.stdin.stream()).arrayBuffer()); + const out = await sharp(input, { failOnError: true, limitInputPixels: MAX_PIXELS }) + .rotate() + .resize(width) + .webp() + .toBuffer(); + await Bun.write(Bun.stdout, out); + process.exit(0); +} catch (e) { + console.error(e?.message || String(e)); + process.exit(2); +} diff --git a/lib/image.ts b/lib/image.ts new file mode 100644 index 00000000..80bc65c3 --- /dev/null +++ b/lib/image.ts @@ -0,0 +1,50 @@ +import { warn } from "$lib/logging"; + +// Decode + resize an image in a throwaway child process (lib/image-worker.ts) +// so a native decoder crash cannot take the app down. Resolves with the webp +// bytes; rejects with a short message on any failure (bad image, crash, +// timeout). Never import sharp in the main process — that is the whole point. +const WORKER = process.env.IMAGE_WORKER || "/home/bun/app/lib/image-worker.ts"; +const TIMEOUT_MS = 20_000; +const MAX_OUTPUT = 20_000_000; + +export const convert = async (input: Buffer, width: number): Promise<Buffer> => { + const proc = Bun.spawn([process.execPath, WORKER, String(width)], { + stdin: new Blob([input]), + stdout: "pipe", + stderr: "pipe", + }); + + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + proc.kill("SIGKILL"); + }, TIMEOUT_MS); + + try { + const [out, errText, code] = await Promise.all([ + new Response(proc.stdout).arrayBuffer(), + new Response(proc.stderr).text(), + proc.exited, + ]); + + if (timedOut) throw new Error("image conversion timed out"); + + if (code !== 0 || proc.signalCode) { + // Exit 2 is the worker's own "sharp rejected this file"; anything else + // (signal, Bun panic banner, exit 1) is the decoder dying — log loudly, + // this is exactly the event the isolation exists for. + const reason = errText.trim().split("\n").filter(Boolean).slice(-1)[0] || ""; + if (code !== 2) + warn(`IMAGE_WORKER_CRASH code=${code} signal=${proc.signalCode} ${reason.slice(0, 200)}`); + throw new Error(code === 2 && reason ? reason.slice(0, 200) : "image conversion failed"); + } + + if (!out.byteLength || out.byteLength > MAX_OUTPUT) + throw new Error("image conversion produced no output"); + + return Buffer.from(out); + } finally { + clearTimeout(timer); + } +}; diff --git a/lib/invoices.ts b/lib/invoices.ts index d624ac6b..0028dc91 100644 --- a/lib/invoices.ts +++ b/lib/invoices.ts @@ -2,6 +2,7 @@ import config from "$config"; import { db, g, s } from "$lib/db"; import { request } from "$lib/ecash"; import ln from "$lib/ln"; +import { warn } from "$lib/logging"; import { emit } from "$lib/sockets"; import { SATS, bip21, fail, getInvoice, getUser } from "$lib/utils"; import rpc from "@coinos/rpc"; @@ -12,14 +13,28 @@ import { PaymentType } from "$lib/types"; const bc = rpc(config.bitcoin); const lq = rpc(config.liquid); +// Preimage queue entries are {preimage, price, fiat, currency} JSON, but +// entries queued before prices existed are bare hex strings — treat those +// as priceless +export const parseEntry = (e) => { + try { + const parsed = JSON.parse(e); + if (parsed?.preimage) + return { price: null, fiat: null, currency: null, ...parsed }; + } catch {} + return { preimage: e, price: null, fiat: null, currency: null }; +}; + export const generate = async ({ invoice, user }) => { let { address_type, bolt11, + bolt12, aid, currency, expiry, fiat, + id, tip, amount, items = [], @@ -29,19 +44,26 @@ export const generate = async ({ invoice, user }) => { memo, memoPrompt, prompt, + single_use, type = PaymentType.lightning, + usePreimage, webhook, secret, } = invoice; - amount = parseInt(amount || 0); - tip = parseInt(tip) || null; + amount = Number.parseInt(amount || 0); + tip = tip == null ? null : Number.parseInt(tip); if (user) user = await getUser(user.username); if (!user) fail("user not provided"); if (typeof prompt === "undefined") prompt = user.prompt; - let account = await g(`account:${aid}`); + // The invoice shows `user`'s name but credits `aid`, so a supplied aid must + // be one of that user's own accounts — otherwise anyone could put a + // merchant's name on an invoice that pays their own account. + if (aid && aid !== user.id && (await db.lPos(`${user.id}:accounts`, aid)) === null) + aid = undefined; + let account = aid ? await g(`account:${aid}`) : null; if (!account) account = await g(`account:${user.id}`); if (!account) fail("account not found"); aid = account.id; @@ -51,63 +73,164 @@ export const generate = async ({ invoice, user }) => { if (!rate) rate = rates[currency]; if (fiat) amount = Math.round((SATS * fiat) / rate); if (amount < 0) fail("invalid amount"); + // On-chain deposits below 300 sats are silently dropped by the dust filter in + // the /confirm credit path (routes/payments.ts), so reject creating a bitcoin/ + // liquid invoice for a specific sub-300-sat amount rather than letting a + // deposit vanish. amount = 0 (any-amount address) is unaffected. + if ( + (type === PaymentType.bitcoin || type === PaymentType.liquid) && + amount > 0 && + amount < 300 + ) + fail( + "On-chain deposits must be at least 300 sats. Use a Lightning invoice for smaller amounts.", + ); if (tip < 0) fail("invalid tip"); if (rate < 0) fail("invalid rate"); if (memo && memo.length > 5000) fail("memo too long"); - const id = v4(); + if (!id) id = v4(); + else { + // A caller-supplied id re-uses an existing record (tip/webhook update, + // repeated bolt12 offer create). It must be the caller's own: the write + // below replaces `invoice:<id>` wholesale with uid = this user, and the + // old bolt11 pointer keeps resolving to the same id, so overwriting + // another user's invoice re-routes whatever their payer sends into this + // account. Same error as a missing invoice so ids can't be probed for + // existence. + const existing = await getInvoice(id); + if (existing && existing.uid !== user.id) fail("invoice not found"); + } let hash; let text; - let path; - - if (account.seed) { - type = "bitcoin"; - const node = rpc({ ...config[type], wallet: aid }); - hash = await node.getNewAddress({ address_type }); - text = bip21(hash, invoice); + let paymentHash; - ({ hdkeypath: path } = await node.getAddressInfo(hash)); - } else if (type === PaymentType.lightning) { + if (type === PaymentType.lightning) { + // SECURITY: never accept a caller-supplied bolt11. It let anyone register any + // node-payable invoice (incl. a nutshell mint quote) as their own coinos + // deposit — which both double-credited (coinos balance + mint ecash) and let + // an attacker hijack another user's incoming payment (there was no ownership + // guard, so it overwrote invoice:<bolt11>). Always mint the invoice here. + if (bolt11) fail("Cannot provide your own Lightning invoice"); let r; - if (bolt11) { - const { id: nodeid } = await ln.getinfo(); - r = await ln.decode(bolt11); - if (r.payee !== nodeid) fail("invalid invoice"); - amount = Math.round(r.amount_msat / 1000); - r.bolt11 = bolt11; - } else { + { expiry ||= 60 * 60 * 24 * 30; - r = await ln.invoice({ - amount_msat: amount ? `${amount + tip}sat` : "any", - label: id, + + const args = { + label: `${id} ${user.username} ${Date.now()}`, description: memo || "", expiry, deschashonly: true, cltv: 19, - }); + }; + + if (usePreimage) { + // Consume the merchant's pre-loaded preimages (FIFO) so the invoice's + // payment hash is sha256(preimage) and paying it reveals a secret the + // merchant chose. Never fall back to a random preimage — a buyer would + // be paying for a secret that unlocks nothing. + const queue = `${user.id}:preimages`; + let entry; + while ((entry = await db.lPop(queue))) { + const { + preimage, + price, + fiat: entryFiat, + currency: entryCurrency, + } = parseEntry(entry); + // The invoice creator is unauthenticated, so a merchant-set price + // must win over the amount in the request — otherwise a buyer + // could name their own price for the secret. Fiat prices convert + // at the current rate, and the invoice takes on the merchant's + // currency so receipts show the fiat price as set. + if (price) amount = price; + else if (entryFiat) { + if (!rates[entryCurrency]) { + await db.lPush(queue, entry); + fail(`no rate available for ${entryCurrency}`); + } + currency = entryCurrency; + rate = rates[currency]; + amount = Math.round((SATS * entryFiat) / rate); + } + try { + r = await ln.invoice({ + ...args, + amount_msat: amount ? `${amount + tip}sat` : "any", + preimage, + }); + // Keep the preimage recoverable for internal settlements, where + // cln never pays the invoice and so never reveals it — credit() + // picks this up as the payment ref + await db.set(`preimage:${r.payment_hash}`, preimage, { + EX: expiry, + }); + break; + } catch (e) { + // cln rejects a preimage whose payment hash it has already seen — + // that one is permanently unusable, so drop it and try the next + if (/preimage/i.test(e.message)) { + warn("discarding unusable preimage", user.username, e.message); + continue; + } + await db.lPush(queue, entry); + throw e; + } + } + if (!r) fail("no preimages available"); + } else { + r = await ln.invoice({ + ...args, + amount_msat: amount ? `${amount + tip}sat` : "any", + }); + } } hash = r.bolt11; text = r.bolt11; + paymentHash = r.payment_hash; } else if (type === PaymentType.bolt12) { - const r = await ln.offer({ - amount: amount ? `${amount + tip}sat` : "any", - label: id, - description: memo || id, - }); + // SECURITY: same as bolt11 — never accept a caller-supplied bolt12. Always + // mint the offer server-side. + if (bolt12) fail("Cannot provide your own bolt12 invoice"); + let r; + { + // The offer itself has to carry the expiry: CLN keeps answering + // invoice requests for an offer until its absolute_expiry, so an + // expiry only stored on our invoice record was never enforced. + expiry = Number.parseInt(expiry) || undefined; + r = await ln.offer({ + amount: amount ? `${amount + tip}sat` : "any", + label: `${id} ${user.username} ${new Date()}`, + description: memo || id, + absolute_expiry: expiry + ? Math.floor(Date.now() / 1000) + expiry + : undefined, + single_use: !!single_use || undefined, + }); - if (await getInvoice(r.offer_id)) fail("Duplicate offer exists"); + // CLN returns the same offer_id for identical offer contents, so a + // re-create of an offer we already track must not register a second + // invoice for it (duplicate :invoices entries — see kaliyi's OCEAN + // offer). If it's the same user's offer, hand back the existing record. + const existing = await getInvoice(r.offer_id); + if (existing) { + if (existing.uid === user.id) return existing; + fail("Duplicate offer exists"); + } + await s(`invoice:${r.offer_id}`, id); + } hash = r.bolt12; text = r.bolt12; - - await s(`invoice:${r.offer_id}`, id); } else if (type === PaymentType.bitcoin) { address_type ||= "bech32"; hash = await bc.getNewAddress({ address_type }); text = bip21(hash, invoice); } else if (type === PaymentType.liquid) { + if (await g("liquid:deposits:disabled")) + fail("Liquid deposits are temporarily disabled"); address_type ||= "blech32"; hash = await lq.getNewAddress({ address_type }); text = bip21(hash, invoice); @@ -132,12 +255,12 @@ export const generate = async ({ invoice, user }) => { items, memo, rate, + paymentHash, pending: 0, received: 0, request_id, memoPrompt, own, - path, prompt, secret, text, @@ -154,7 +277,13 @@ export const generate = async ({ invoice, user }) => { await s(`invoice:${hash}`, id); await s(`invoice:${id}`, invoice); - await db.lPush(`${aid}:invoices`, id); + // generate() is also called on UPDATE (re-using an existing invoice id, e.g. + // a tip/webhook change or a repeated bolt12 offer create), which must NOT + // re-append the id to the list. Only add it the first time — otherwise the + // user's :invoices accumulates duplicate entries and the same offer/invoice + // renders multiple times (see kaliyi's OCEAN offer, listed 2-6x). + if ((await db.lPos(`${aid}:invoices`, id)) === null) + await db.lPush(`${aid}:invoices`, id); if (request_id) { const request = await g(`request:${request_id}`); @@ -169,3 +298,32 @@ export const generate = async ({ invoice, user }) => { return invoice; }; + +// Each user gets one standing amountless bolt12 offer (lno1...) they can hand +// out as a reusable payment code — e.g. published in a kind 10058 list for +// NIP-177 bolt12 zaps. Created lazily on first request, then reused; incoming +// payments credit through the regular bolt12 receive path via local_offer_id. +export const getUserOffer = async (user) => { + const key = `${user.id}:offer`; + const existingId = await g(key); + if (existingId) { + const existing = await g(`invoice:${existingId}`); + if (existing?.hash) return existing; + } + + let domain; + try { + domain = new globalThis.URL(process.env.URL).host; + } catch (e) {} + + const invoice = await generate({ + invoice: { + type: PaymentType.bolt12, + memo: domain ? `${user.username}@${domain}` : user.username, + }, + user, + }); + + await s(key, invoice.id); + return invoice; +}; diff --git a/lib/lightning.ts b/lib/lightning.ts index 8f5f44bc..6c37ddf1 100644 --- a/lib/lightning.ts +++ b/lib/lightning.ts @@ -1,73 +1,343 @@ -import { db, g, s } from "$lib/db"; -import ln from "$lib/ln"; -import { err, warn } from "$lib/logging"; +import { db, g } from "$lib/db"; +import ln, { lnListen, LightningUnavailableError } from "$lib/ln"; +import { err, l, warn } from "$lib/logging"; import { handleZap } from "$lib/nostr"; import { credit } from "$lib/payments"; import { PaymentType } from "$lib/types"; import { getInvoice, getPayment, getUser } from "$lib/utils"; -export async function listenForLightning() { - const inv = await ln.waitanyinvoice((await g("pay_index")) || 0); - const { - local_offer_id, - bolt11, - bolt12, - description, - pay_index, - payment_hash, - amount_received_msat, - payment_preimage: preimage, - } = inv; +const LISTENER_RETRY_DELAY = 5000; // 5 seconds +const MAX_LISTENER_RETRIES = 10; +// Never leave waitanyinvoice as an unbounded client-side promise. CLN's +// server-side timeout returns code 904 during an ordinary quiet period; that is +// treated as a clean re-arm below. A dead/half-open socket can therefore hold +// the listener for at most the watchdog window instead of indefinitely. +const LISTENER_WAIT_TIMEOUT_SECONDS = 30; +const LISTENER_STALL_MS = 90 * 1000; +const PROCESSING_STALL_MS = 5 * 60 * 1000; +let listenerRetries = 0; +let listenerActive = false; +let lastPayTime = Date.now(); +let listenerPhase: "idle" | "waiting" | "processing" = "idle"; +let phaseStartedAt = Date.now(); +// Incremented whenever the watchdog force-recycles a stalled listener. The +// blocked invocation captures the epoch at entry; if it later unblocks (the +// reset makes its waitanyinvoice reject) it sees the epoch has moved and bows +// out instead of re-arming, so we never run two listeners concurrently. +let listenerEpoch = 0; - await s("pay_index", pay_index); - setTimeout(listenForLightning); +// Recover the invoice listener the instant its long-poll socket dies (e.g. a cl +// restart) instead of waiting up to LISTENER_STALL_MS for the stall watchdog. +// waitanyinvoice is a NO_TIMEOUT long-poll, so a dead socket leaves it blocked +// forever with no error — this onDrop signal is the only prompt trigger. Bump the +// epoch so the (possibly forever-blocked) old invocation bows out, mark inactive, +// and re-arm. If cl is still down, ensure()'s backoff + the retry loop below wait +// it out. Debounced so a burst of socket errors recycles once. +let recycleArmed = false; +(lnListen as any).onDrop?.(() => { + if (!listenerActive || recycleArmed) return; + recycleArmed = true; + listenerEpoch++; + listenerActive = false; + listenerPhase = "idle"; + warn("lightning listener: listen socket dropped — recycling immediately"); + setTimeout(() => { + recycleArmed = false; + listenForLightning(); + }, 100); +}); - const received = Math.round(amount_received_msat / 1000); +export async function listenForLightning() { + if (listenerActive) { + warn("lightning listener: already active, skipping duplicate call"); + return; + } + + listenerActive = true; + listenerPhase = "waiting"; + phaseStartedAt = Date.now(); + const myEpoch = listenerEpoch; try { - if (!preimage) return; + const payIndex = (await g("pay_index")) || 0; + // l(`lightning listener: waiting for invoice (pay_index=${payIndex})`); - const invoice = await getInvoice(bolt11 ?? local_offer_id); - if (!invoice) return warn("received lightning with no invoice", bolt11); + const inv = await lnListen.waitanyinvoice( + payIndex, + LISTENER_WAIT_TIMEOUT_SECONDS, + ); + const { + label, + local_offer_id, + bolt11, + bolt12, + description, + invreq_payer_note, + pay_index, + payment_hash, + amount_received_msat, + payment_preimage: preimage, + } = inv; - const p = await getPayment(bolt11 || bolt12); - if (p) return warn("already processed", bolt11 || bolt12); + // If the watchdog recycled the listener while this call was blocked, a new + // listener now owns the stream. Bail without advancing pay_index or + // processing — the new listener's waitanyinvoice(payIndex) will return this + // same invoice and handle it, so nothing is lost or double-credited. + if (myEpoch !== listenerEpoch) return; - if (invoice?.memo) { - try { - if (JSON.parse(description).kind === 9734) { - const { pubkey } = await getUser(invoice.uid); - handleZap(inv, pubkey); + // We are no longer blocked in waitanyinvoice. Track processing separately + // so the watchdog never resets a healthy socket merely because a ledger + // write or downstream notification is slow. + listenerPhase = "processing"; + phaseStartedAt = Date.now(); + const received = Math.round(amount_received_msat / 1000); + + // Process the settlement before advancing pay_index. Advancing first loses + // the credit permanently if Valkey is loading/restarting between the cursor + // write and credit() (the 2026-08-15 gap at pay indexes 1057448-1057518). + // The inner function keeps intentional skips on the common commit path; + // genuine processing errors escape to the retry handler with the old cursor. + await (async () => { + if (!preimage) return; + + // The mint (nutshell) shares this cl node, so waitanyinvoice also fires for + // mint-quote invoices. Those are owned by the mint (it issues ecash for + // them) and must NOT credit a coinos balance — otherwise a single payment + // is credited twice (coinos balance + ecash = double-mint exploit). The + // mint labels its invoices "lbl<random>"; coinos labels are + // "<uuid> <username> <ts>". Never credit a mint-owned invoice. + if (typeof label === "string" && label.startsWith("lbl")) + return warn("skipping mint-owned invoice (not a coinos deposit)", label, bolt11); + + const invoice = await getInvoice(bolt11 ?? local_offer_id ?? bolt12); + if (!invoice) return warn("received lightning with no invoice", bolt11); + + // A deleted account's invoice or bolt12 offer can still be paid (OCEAN + // payouts to a deleted user's offer, 2026-09-05). Nothing can be + // credited, and the post-credit persistence check below would throw and + // wedge the listener on this settlement forever. Record and skip. + if (!(await getUser(invoice.uid))) { + await db.sAdd("missing", preimage); + return warn("received lightning for missing user", invoice.uid, bolt11 ?? bolt12, received); + } + + const p = await getPayment(bolt11 || bolt12); + if (p) return warn("already processed", bolt11 || bolt12); + + if (invoice?.memo) { + // A non-JSON description just means this isn't a zap (the common case) — + // skip silently. Only a real kind-9734 zap request that fails to process + // is worth a warning. (The old `!includes("Unexpected")` guard was V8's + // parse-error wording; Bun's is "Unable to parse JSON string", so every + // non-zap memo'd invoice slipped through and spammed this warning.) + let zapreq; + try { zapreq = JSON.parse(description); } catch { zapreq = null; } + if (zapreq?.kind === 9734) { + try { + const { pubkey } = await getUser(invoice.uid); + handleZap(inv, pubkey); + } catch (e) { + warn("failed to handle zap", e.message); + } } - } catch (e) { - if (!e.message.includes("Unexpected")) - warn("failed to handle zap", e.message); } + + const paymentRequest = bolt11 || bolt12; + await credit({ + hash: paymentRequest, + amount: received, + // A bolt12 payer note (e.g. nostr:nip177:<zap-intent-id>) identifies + // this specific payment; the offer's own memo is just its static label + memo: invreq_payer_note || invoice.memo, + ref: preimage, + type: bolt12 ? PaymentType.bolt12 : PaymentType.lightning, + payment_hash, + }); + + // credit() may decline a duplicate idempotency claim. Only advance the + // listener once the durable payment pointer proves the ledger write won — + // unless the preimage was already credited by an earlier settlement (a + // reused merchant preimage), which can never persist and would otherwise + // wedge the listener in a restart loop. + if (!(await getPayment(paymentRequest))) { + if (preimage && (await db.get(`credited:${preimage}`))) + return warn("preimage already credited, skipping", paymentRequest); + throw new Error(`lightning credit did not persist for ${paymentRequest}`); + } + })(); + + // db.set is awaited directly: s() is intentionally fire-and-forget and an + // `await s(...)` would not wait for Redis to persist the cursor. + await db.set("pay_index", JSON.stringify(pay_index)); + + // If the watchdog recycled this invocation while it was processing, the + // replacement listener owns scheduling. The credit is idempotent and the + // committed cursor lets that listener continue from the next settlement. + if (myEpoch !== listenerEpoch) return; + + lastPayTime = Date.now(); + + // Reset retry counter only after both the ledger operation and cursor write + // have succeeded. + if (listenerRetries > 0) { + l(`lightning listener: recovered after ${listenerRetries} retries`); } + listenerRetries = 0; + listenerActive = false; + listenerPhase = "idle"; - await credit({ - hash: bolt11 || bolt12, - amount: received, - memo: invoice.memo, - ref: preimage, - type: bolt12 ? PaymentType.bolt12 : PaymentType.lightning, - payment_hash, - }); - } catch (e) { - console.log(e); - err("problem receiving lightning payment", e.message); + // Schedule the next listen only after this settlement is durable. + setTimeout(listenForLightning); + } catch (e: any) { + const failedPhase = listenerPhase; + listenerActive = false; + listenerPhase = "idle"; + + // If the watchdog already recycled us (its reset is what made this + // waitanyinvoice reject), a fresh listener is running — don't retry or + // re-arm here, or we'd run two listeners. + if (myEpoch !== listenerEpoch) return; + + const errorCode = e?.code ?? e?.errno ?? "unknown"; + const errorMsg = e?.message ?? String(e); + + // CLN error 904 is the expected result of a server-side wait timeout with + // no newly paid invoice. It proves the socket is responsive; immediately + // re-arm without incrementing the failure counter or emitting an error. + if ( + failedPhase === "waiting" && + (Number(errorCode) === 904 || /timed out/i.test(errorMsg)) + ) { + listenerRetries = 0; + setTimeout(listenForLightning); + return; + } + + err( + `lightning listener: error handling invoice`, + `code=${errorCode}`, + `error=${errorMsg}` + ); + + // A cl restart makes ensure() throw LightningUnavailableError until the socket + // returns — expected, self-heals, and restarting the app can't fix a down cl. + // So don't count it toward the process-exit escalation; just keep retrying and + // resume within LISTENER_RETRY_DELAY of cl coming back. Only genuine listener + // errors climb toward a container restart. + if (e instanceof LightningUnavailableError) { + err("lightning listener: RPC socket unavailable"); + warn(`lightning listener: cl unavailable, retrying in ${LISTENER_RETRY_DELAY / 1000}s`); + setTimeout(listenForLightning, LISTENER_RETRY_DELAY); + return; + } + + listenerRetries++; + + if (listenerRetries >= MAX_LISTENER_RETRIES) { + err( + `lightning listener: ${MAX_LISTENER_RETRIES} consecutive failures, ` + + `last error: ${errorMsg}` + ); + err("lightning listener: exiting process to trigger container restart"); + + setTimeout(() => { + process.exit(1); + }, 1000); + return; + } + + warn( + `lightning listener: retry ${listenerRetries}/${MAX_LISTENER_RETRIES} ` + + `in ${LISTENER_RETRY_DELAY / 1000}s` + ); + setTimeout(listenForLightning, LISTENER_RETRY_DELAY); } } +export async function ensureListenerAlive() { + // Case 1: listener fell over (threw, or never armed) — just restart it. + if (!listenerActive) { + warn("lightning listener: not active, restarting"); + listenForLightning(); + return; + } + + // Processing and waiting are different failure modes. Resetting the listen + // socket cannot repair a slow credit and risks overlapping two processors. + // Surface it loudly for intervention, but leave the single owner intact. + const phaseAge = Date.now() - phaseStartedAt; + if (listenerPhase === "processing") { + if (phaseAge >= PROCESSING_STALL_MS) { + err( + `lightning listener: invoice processing stalled ${Math.round( + phaseAge / 1000, + )}s — not starting a concurrent processor`, + ); + } + return; + } + + // A normal quiet wait returns code 904 every 30 seconds and re-arms. Reaching + // this age means the dedicated socket itself is stuck. Confirm a real backlog + // over the main socket before recycling it. + if (listenerPhase !== "waiting" || phaseAge < LISTENER_STALL_MS) return; + + // Probe for a backlog: ask cl (on the MAIN socket, not the wedged listen one) + // whether a paid invoice exists at or after the app's stored pay_index, using + // waitanyinvoice with a short server-side timeout so it returns fast either + // way. If it returns an invoice, the listener's socket is stuck while real + // payments are waiting -> genuine zombie, recycle. If it times out (no waiting + // invoice), the listener is simply idle and healthy -> leave it alone. + let backlog = false; + try { + const payIndex = (await g("pay_index")) || 0; + const probe = await ln.waitanyinvoice(payIndex, 2); // 2s server-side timeout + if (probe?.pay_index) backlog = true; + } catch (e: any) { + // CLN returns an error (code 904) on timeout with no waiting invoice — that + // means NO backlog (healthy). Any other error: stay conservative, don't + // recycle on ambiguous signals. + backlog = false; + } + if (!backlog) return; // healthy idle wait, not a zombie. + + err( + `lightning listener: backlog detected while listener blocked ${Math.round( + phaseAge / 1000, + )}s — recycling listen socket`, + ); + // Bump the epoch first so the wedged invocation bows out when its + // waitanyinvoice rejects, then drop the socket and re-arm a fresh listener. + listenerEpoch++; + try { + (lnListen as any).reset?.(); + } catch (_) {} + listenerActive = false; + listenerPhase = "idle"; + setTimeout(listenForLightning); +} + +export function getLightningListenerStatus() { + return { + active: listenerActive, + phase: listenerPhase, + phaseAgeMs: Date.now() - phaseStartedAt, + lastPayTime, + retries: listenerRetries, + }; +} + export async function replay(index) { - const inv = await ln.waitanyinvoice(index - 1); + const inv = await lnListen.waitanyinvoice(index - 1); const { local_offer_id, bolt11, bolt12, description, + invreq_payer_note, amount_received_msat, payment_preimage: preimage, + paid_at, } = inv; const received = Math.round(amount_received_msat / 1000); @@ -75,33 +345,40 @@ export async function replay(index) { try { if (!preimage) return; - const invoice = await getInvoice(bolt11 ?? local_offer_id); + const invoice = await getInvoice(bolt11 ?? local_offer_id ?? bolt12); if (!invoice) return warn("received lightning with no invoice", bolt11); - const p = await getPayment(bolt11 || bolt12); + let p = await getPayment(bolt11 || bolt12); if (p) return warn("already processed", bolt11 || bolt12); if (invoice?.memo) { - try { - if (JSON.parse(description).kind === 9734) { + // Non-JSON description = not a zap (common case); skip silently. Only a + // real kind-9734 zap request that fails processing warrants a warning. + // (The old `!includes("Unexpected")` guard was V8 wording; Bun's parse + // error is "Unable to parse JSON string", so non-zaps spammed the warning.) + let zapreq; + try { zapreq = JSON.parse(description); } catch { zapreq = null; } + if (zapreq?.kind === 9734) { + try { const { pubkey } = await getUser(invoice.uid); handleZap(inv, pubkey); - } - } catch (e) { - if (!e.message.includes("Unexpected")) + } catch (e) { warn("failed to handle zap", e.message); + } } } - await credit({ + p = await credit({ hash: bolt11 || bolt12, amount: received, - memo: invoice.memo, + memo: invreq_payer_note || invoice.memo, ref: preimage, type: bolt12 ? PaymentType.bolt12 : PaymentType.lightning, + created: paid_at ? paid_at * 1000 : undefined, }); + + return p; } catch (e) { - console.log(e); err("problem receiving lightning payment", e.message); } } @@ -1,5 +1,243 @@ import config from "$config"; +import { existsSync } from "fs"; +import net from "net"; -const { LightningClient } = (await import("@asoltys/clightning-client")).default; -export default new LightningClient(config.lightning); -export const lnb = new LightningClient(config.lightningb); +// Patch net.Socket.prototype.connect to prevent Bun from crashing when +// connecting to a non-existent unix socket. Bun's net.createConnection +// throws ENOENT in a way that bypasses all JS error handlers. +// Instead, we check if the socket file exists and emit a proper error event. +const origConnect = net.Socket.prototype.connect; +net.Socket.prototype.connect = function (...args: any[]) { + const sockPath = typeof args[0] === "string" ? args[0] : args[0]?.path; + if (typeof sockPath === "string" && sockPath.startsWith("/") && !existsSync(sockPath)) { + process.nextTick(() => { + this.emit( + "error", + Object.assign(new Error(`connect ENOENT ${sockPath}`), { + code: "ENOENT", + errno: -2, + syscall: "connect", + address: sockPath, + }), + ); + }); + return this; + } + return origConnect.apply(this, args); +}; + +const mod = (await import("@asoltys/clightning-client")).default; +const { LightningClient } = mod as { LightningClient: any }; + +class LightningUnavailableError extends Error { + code = "LIGHTNING_UNAVAILABLE" as const; + constructor(msg: string) { + super(msg); + this.name = "LightningUnavailableError"; + } +} + +function isUnavailable(e: any) { + const code = e?.code ?? e?.errno; + return code === "ENOENT" || code === 2 || code === "ECONNREFUSED"; +} + +function isSocketDied(e: any) { + const code = e?.code ?? e?.errno; + return ( + code === "EPIPE" || + code === "ECONNRESET" || + code === "ENOENT" || + code === 2 || + // A timed-out RPC (see RPC_TIMEOUT below) means the socket is open but cl + // stopped responding (zombie/half-open). Treat it as dead so the client is + // dropped and the next call reconnects — without restarting the process. + code === "ETIMEDOUT" + ); +} + +// Wall-clock cap for short-lived RPCs. A unix-domain socket has no TCP +// keepalive, so a hung cl would otherwise leave the await pending forever +// (the "stuck spinner past /pay/bob, no error" symptom). 30s matches the +// health-check RPC_TIMEOUT in lib/health.ts. +const RPC_TIMEOUT = 30_000; + +// Methods that MUST NOT get a client-side timeout: +// - waitanyinvoice/waitinvoice/waitsendpay/waitblockheight: long-polls that +// are designed to block until something happens. +// - pay/xpay/sendpay/keysend: in-flight payments. Rejecting the caller early +// while the HTLC may still settle is exactly the leaked-debit hazard the +// sendLightning guards exist to prevent — let these run to completion. +// - fetchinvoice/sendinvoice/offer: bolt12 flows that round-trip to a remote +// node over the network and can legitimately take a long time. +const NO_TIMEOUT = new Set([ + "waitanyinvoice", + "waitinvoice", + "waitsendpay", + "waitblockheight", + "pay", + "xpay", + "sendpay", + "keysend", + "fetchinvoice", + "sendinvoice", + "offer", +]); + +class RpcTimeoutError extends Error { + code = "ETIMEDOUT" as const; + constructor(method: string, ms: number) { + super(`Lightning RPC ${method} timed out after ${ms}ms`); + this.name = "RpcTimeoutError"; + } +} + +function lightningProxy(rpcPath: string) { + let client: any = null; + + let nextTryAt = 0; + let backoff = 250; + const maxBackoff = 5000; + + const deathCbs = new Set<(reason?: string) => void>(); + + // Fully tear down a dead client. Just doing `client = null` orphans the old + // LightningClient: the @asoltys/clightning-client library keeps its internal + // reconnect() loop running on the (dead) socket forever — spamming + // `connect ENOENT …/lightning-rpc` and leaking a socket + timer on EVERY cl + // restart. So stop its reconnect loop, destroy the socket, and drop listeners. + // Then notify subscribers (the invoice listener) so they can recycle at once + // rather than waiting on the 5-minute stall watchdog. `notify=false` for a + // deliberate reset() where the caller already owns the re-arm. + function dropClient(reason?: string, notify = true) { + const dead = client; + client = null; + if (dead) { + try { + if (dead.reconnectTimeout) { clearTimeout(dead.reconnectTimeout); dead.reconnectTimeout = null; } + dead.reconnect = () => {}; // neuter any pending 'error'/'end' handler + dead.rl?.close?.(); + dead.client?.removeAllListeners?.(); + dead.client?.destroy?.(); + dead.removeAllListeners?.(); + } catch (_) {} + } + if (notify) for (const cb of deathCbs) { try { cb(reason); } catch (_) {} } + } + + function ensure() { + if (client) return client; + + const now = Date.now(); + if (now < nextTryAt) { + throw new LightningUnavailableError(`Lightning not ready at ${rpcPath}`); + } + + if (!existsSync(rpcPath)) { + nextTryAt = Date.now() + backoff; + backoff = Math.min(maxBackoff, Math.floor(backoff * 1.8)); + throw new LightningUnavailableError( + `Lightning RPC socket not found at ${rpcPath}` + ); + } + + try { + const c = new LightningClient(rpcPath); + client = c; + // Prevent unhandled 'error' events from crashing the process. Guard on + // `c === client` so a stale client's late error can't tear down a newer, + // healthy one that already replaced it. + c.on("error", (e: any) => { + if (c === client && isSocketDied(e)) dropClient(e?.code ?? e?.errno); + }); + backoff = 250; + nextTryAt = 0; + return client; + } catch (e: any) { + nextTryAt = Date.now() + backoff; + backoff = Math.min(maxBackoff, Math.floor(backoff * 1.8)); + + if (isUnavailable(e)) { + throw new LightningUnavailableError( + `Lightning RPC unavailable at ${rpcPath} (${e?.code ?? e?.errno ?? "error"})` + ); + } + throw e; + } + } + + return new Proxy( + {}, + { + get(_t, prop: string | symbol) { + if (prop === "toString") return () => "[LightningProxy]"; + if (prop === Symbol.toStringTag) return "LightningProxy"; + + // Force the underlying socket to be dropped so the next call reconnects. + // Used by the lightning-listener watchdog to recover a zombie long-poll + // (waitanyinvoice blocked on a dead socket while cl is actually alive — + // the per-call timeout can't catch it because long-polls are exempt). + if (prop === "reset") { + return () => { + const had = !!client; + dropClient("reset", false); // caller (watchdog) owns the re-arm + return had; + }; + } + + // Subscribe to socket-death so a consumer (the invoice listener) can + // recover the instant the connection dies, instead of polling/waiting. + // Returns an unsubscribe fn. Handled before the generic RPC dispatch so + // "onDrop" is never sent to cl as a method. + if (prop === "onDrop") { + return (cb: (reason?: string) => void) => { + deathCbs.add(cb); + return () => deathCbs.delete(cb); + }; + } + + return async (...args: any[]) => { + const c = ensure(); + const v = c[prop as any]; + + if (typeof v !== "function") return v; + + const method = String(prop); + try { + const call = v.apply(c, args); + if (NO_TIMEOUT.has(method)) return await call; + + let timer: ReturnType<typeof setTimeout> | undefined; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new RpcTimeoutError(method, RPC_TIMEOUT)), + RPC_TIMEOUT, + ); + }); + try { + return await Promise.race([call, timeout]); + } finally { + clearTimeout(timer); + } + } catch (e: any) { + // ETIMEDOUT (from RpcTimeoutError) is included in isSocketDied, so a + // hung RPC drops the client and the next call reconnects. + if (isSocketDied(e)) dropClient(e?.code ?? e?.errno); + throw e; + } + }; + }, + } + ); +} + +// Main proxy for short-lived RPC calls (getinfo, invoice, decode, etc.) +const ln = lightningProxy(config.lightning); +export default ln; + +// Dedicated proxy for long-polling (waitanyinvoice) — uses its own socket +// so it doesn't block the main connection +export const lnListen = lightningProxy(config.lightningListen || config.lightning); + +export const lnb = lightningProxy(config.lightningb); +export { LightningUnavailableError }; diff --git a/lib/lnquote.ts b/lib/lnquote.ts new file mode 100644 index 00000000..de96f69a --- /dev/null +++ b/lib/lnquote.ts @@ -0,0 +1,303 @@ +import config from "$config"; +import { g } from "$lib/db"; +import ln from "$lib/ln"; +import { warn } from "$lib/logging"; +import { PaymentType } from "$lib/types"; +import { fail } from "$lib/utils"; +import { v4 } from "uuid"; + +// Up-front routing-fee quotes for lightning sends. +// +// xpay routes with askrene's `getroutes`, so asking askrene the same question +// before we debit tells us exactly what the payment will cost — instead of +// reserving a guessed maxfee and refunding the unused part afterwards. We +// mirror what xpay does in plugins/xpay/xpay.c (v26.06): a throwaway private +// layer carrying the invoice's route hints (bolt11) or blinded paths (bolt12, +// via a fake destination node), then getroutes over the same layer stack in +// the same order (auto.localchans, auto.sourcefree, xpay, private, user). + +// xpay's fake destination for blinded paths (xpay.c: pubkey 02 || 0..01). +const FAKE_NODE = + "020000000000000000000000000000000000000000000000000000000000000001"; + +const XPAY_MAXDELAY = 2016; // xpay's default maxdelay + +// Extra layers sendLightning passes to xpay. xpay forwards user layers to +// getroutes verbatim, and getroutes rejects an unknown layer outright, so only +// pass the ones that actually exist on this node (prod has prefer-kappa, +// regtest doesn't). Cached briefly — layers don't come and go. +const WANTED_LAYERS = ["prefer-kappa"]; +let layerCache: { at: number; layers: string[] } | undefined; +export const xpayLayers = async (): Promise<string[]> => { + if (layerCache && Date.now() - layerCache.at < 60_000) + return layerCache.layers; + let layers: string[] = []; + try { + const r = await ln.call("askrene-listlayers", {}); + const existing = new Set((r?.layers ?? []).map((l: any) => l.layer)); + layers = WANTED_LAYERS.filter((l) => existing.has(l)); + } catch (e: any) { + warn("askrene-listlayers failed", e?.message ?? String(e)); + return []; + } + layerCache = { at: Date.now(), layers }; + return layers; +}; + +let ourId: string | undefined; +const nodeId = async () => { + if (!ourId) ({ id: ourId } = await ln.getinfo()); + return ourId; +}; + +// Channel direction bit as CLN defines it: 0 when the source node id sorts +// before the destination's (node_id_idx). Node ids are same-length lowercase +// hex, so plain string comparison matches the byte comparison. +const dir = (src: string, dst: string) => (src < dst ? 0 : 1); + +const isHex66 = (s: any) => + typeof s === "string" && /^0[23][0-9a-f]{64}$/.test(s); + +// The routing-relevant parts of a decoded invoice: where it goes, the final +// cltv, and the private topology xpay would add. Fails on anything that isn't +// a bolt11 invoice or a bolt12 invoice (offers must be fetched first). +const routingTarget = async (decoded: any) => { + const me = await nodeId(); + + if (decoded.type === "bolt11 invoice") { + const dest = decoded.payee; + if (!isHex66(dest)) fail("Invoice has no payee"); + const hints: any[][] = decoded.routes ?? []; + return { + dest, + finalCltv: decoded.min_final_cltv_expiry ?? 18, + hints, + paths: [], + me, + }; + } + + if (decoded.type === "bolt12 invoice") { + // decode nests each path's payinfo inside the path (v26); older output + // put them in a parallel invoice_blindedpay array. Accept both. + const paths = decoded.invoice_paths ?? []; + const payinfos = decoded.invoice_blindedpay ?? []; + return { + dest: decoded.invoice_node_id, + finalCltv: 0, // xpay: unknown for blinded paths, payinfo carries the delta + hints: [], + paths: paths.map((p: any, i: number) => ({ + path: p, + payinfo: p.payinfo ?? payinfos[i], + })), + me, + }; + } + + fail(`Cannot quote a ${decoded.type}`); +}; + +// Ask askrene what delivering `amount` sats along this invoice would cost. +// Returns the routing fee in sats (rounded up — xpay's maxfee is msat, but +// everything we store is sats, and a cap that's a few msat short fails hard). +export const quoteRoutingFee = async ({ + pr, + amount, +}: { + pr: string; + amount: number; +}) => { + amount = Number.parseInt(amount as any); + if (!(amount > 0)) fail("Invalid amount"); + const amountMsat = amount * 1000; + + const decoded = await ln.decode(pr.replace(/\s/g, "").toLowerCase()); + const { dest, finalCltv, hints, paths, me } = await routingTarget(decoded); + + // Self-pay: xpay short-circuits to an inject with no routing at all. + if (dest === me) return { fee: 0, feeMsat: 0, parts: 0 }; + + // Search cap for getroutes. Generous on purpose: this bounds the search, + // and the caller decides what to do with the answer. + const cap = Math.max(50_000, Math.ceil(amountMsat * 0.05)); + // xpay sizes its fake hint channels at 100x (amount + maxfee). + const bigCap = (amountMsat + cap) * 100; + + let destination = dest; + const fakes: any[] = []; + for (const route of hints) { + for (let i = 0; i < route.length; i++) { + const hop = route[i]; + const next = i + 1 < route.length ? route[i + 1].pubkey : dest; + // xpay skips hints from ourselves — we know our own channels. + if (hop.pubkey === me) continue; + if (hop.pubkey === next) continue; // askrene rejects self-loops + fakes.push({ + src: hop.pubkey, + dst: next, + scid: hop.short_channel_id, + cap: bigCap, + htlcMin: 0, + htlcMax: bigCap, + base: hop.fee_base_msat ?? 0, + ppm: hop.fee_proportional_millionths ?? 0, + delta: hop.cltv_expiry_delta ?? 0, + }); + } + } + + if (paths.length) { + destination = FAKE_NODE; + let n = 0; + for (const [i, { path, payinfo }] of paths.entries()) { + // Only introduction points given as a node id can be resolved here; + // xpay resolves scid-form ones through gossmap and discards the rest. + const first = path?.first_node_id; + if (!isHex66(first) || !payinfo) continue; + // Earlier paths get more capacity so askrene prefers them, like xpay. + const c = (amountMsat + cap) * (100 + (paths.length - i)); + fakes.push({ + src: first, + dst: FAKE_NODE, + scid: `0x0x${i}`, // block 0 is impossible, so it can't collide + cap: c, + htlcMin: payinfo.htlc_minimum_msat ?? 0, + htlcMax: payinfo.htlc_maximum_msat ?? c, + base: payinfo.fee_base_msat ?? 0, + ppm: payinfo.fee_proportional_millionths ?? 0, + delta: payinfo.cltv_expiry_delta ?? 0, + }); + n++; + } + if (!n) fail("No usable blinded paths"); + } + + const layer = `quote-${v4()}`; + try { + await ln.call("askrene-create-layer", { layer, persistent: false }); + for (const f of fakes) { + await ln.call("askrene-create-channel", { + layer, + source: f.src, + destination: f.dst, + short_channel_id: f.scid, + capacity_msat: f.cap, + }); + await ln.call("askrene-update-channel", { + layer, + short_channel_id_dir: `${f.scid}/${dir(f.src, f.dst)}`, + enabled: true, + htlc_minimum_msat: f.htlcMin, + htlc_maximum_msat: f.htlcMax, + fee_base_msat: f.base, + fee_proportional_millionths: f.ppm, + cltv_expiry_delta: f.delta, + }); + } + + const r = await ln.call("getroutes", { + source: me, + destination, + amount_msat: amountMsat, + // Same stack and order as xpay: our channels re-added with real fees, + // then zeroed as the source, then xpay's learned constraints. + layers: [ + "auto.localchans", + "auto.sourcefree", + "xpay", + layer, + ...(await xpayLayers()), + ], + maxfee_msat: cap, + final_cltv: finalCltv, + maxdelay: XPAY_MAXDELAY, + }); + + const routes: any[] = r?.routes ?? []; + if (!routes.length) fail("No route found"); + const sent = routes.reduce( + (s, rt) => s + Number(rt.path?.[0]?.amount_msat ?? 0), + 0, + ); + const delivered = routes.reduce( + (s, rt) => s + Number(rt.amount_msat ?? 0), + 0, + ); + if ( + !Number.isFinite(sent) || + !Number.isFinite(delivered) || + delivered < amountMsat + ) + fail("No route found"); + const feeMsat = Math.max(0, sent - delivered); + return { fee: Math.ceil(feeMsat / 1000), feeMsat, parts: routes.length }; + } finally { + try { + await ln.call("askrene-remove-layer", { layer }); + } catch (e: any) { + warn("askrene-remove-layer failed", layer, e?.message ?? String(e)); + } + } +}; + +// Platform fee for a lightning send, exactly as debit() will charge it: +// rate × (amount + routing fee), less any free-tier credit, and nothing at +// all on sub-accounts. +export const platformFee = async ({ + uid, + aid, + amount, + fee, +}: { + uid: string; + aid: string; + amount: number; + fee: number; +}) => { + if (aid !== uid) return 0; + let rate = config.fee[PaymentType.lightning]; + if (await g(`ln:nofree:${uid}`)) rate = config.fee.lightningHigh ?? rate; + const credit = Number.parseInt(await g(`credit:lightning:${uid}`)) || 0; + const ourfee = Math.round((amount + fee) * rate); + return Math.max(0, ourfee - Math.min(credit, ourfee)); +}; + +// The most the account can deliver along this invoice such that amount + +// routing fee + platform fee lands exactly on the balance. Both fees grow +// with the amount, so iterate: quote at the balance, subtract the fees, quote +// again at the smaller amount. Each step's fees are ≥ the next step's (fees +// are monotone in amount), so from the second quote on the total never +// exceeds the balance; the extra rounds only shave the last sat or two. +export const quoteMax = async ({ + pr, + uid, + aid, + ceiling, +}: { + pr: string; + uid: string; + aid: string; + ceiling?: number; +}) => { + const balance = Number.parseInt(await g(`balance:${aid}`)) || 0; + let amount = balance; + if (ceiling > 0) amount = Math.min(amount, Math.floor(ceiling)); + if (amount <= 0) fail("Insufficient funds"); + + let fee = 0; + let ourfee = 0; + for (let i = 0; i < 5; i++) { + ({ fee } = await quoteRoutingFee({ pr, amount })); + ourfee = await platformFee({ uid, aid, amount, fee }); + const next = Math.min( + balance - fee - ourfee, + ceiling > 0 ? Math.floor(ceiling) : Number.POSITIVE_INFINITY, + ); + if (next <= 0) fail("Insufficient funds"); + if (next === amount) break; + amount = next; + } + + if (amount + fee + ourfee > balance) fail("Insufficient funds"); + return { amount, fee, ourfee, balance, total: amount + fee + ourfee }; +}; diff --git a/lib/locations.ts b/lib/locations.ts index 660645a8..a70d57e1 100644 --- a/lib/locations.ts +++ b/lib/locations.ts @@ -55,8 +55,7 @@ export const getLocations = async () => { await s("locations", dedup(locations)); await s("locations:since", `${new Date().toISOString().split(".")[0]}Z`); } catch (e) { - console.log(e); - err("problem fetching locations", e); + err("problem fetching locations", e.message); } setTimeout(getLocations, 60000); diff --git a/lib/logging.ts b/lib/logging.ts index aab110c2..af6ae942 100644 --- a/lib/logging.ts +++ b/lib/logging.ts @@ -4,6 +4,46 @@ export const l = (...msgs) => pino().info(msgs.join(" ")); export const warn = (...msgs) => pino().warn(msgs.join(" ")); export const err = (...msgs) => pino().error(msgs.join(" ")); +// Collapse a verbose error message for logging. CLN's xpay concatenates one +// failure sentence per routing retry (e.g. "We got temporary_channel_failure +// for X … updating our map." repeated 50+ times), which floods the logs on an +// ordinary failed lightning send. Dedupe repeated sentences and cap length. +export const shortError = (msg: any, max = 400): string => { + let s = typeof msg === "string" ? msg : msg?.message ?? String(msg); + const parts = s.split(/(?<=\.)\s+/); + const seen = new Set<string>(); + const uniq: string[] = []; + for (const p of parts) { + const t = p.trim(); + if (t && !seen.has(t)) { seen.add(t); uniq.push(t); } + } + s = uniq.join(" "); + const dropped = parts.length - uniq.length; + if (dropped > 0) s += ` [${dropped} repeated lines collapsed]`; + return s.length > max ? `${s.slice(0, max)}…` : s; +}; + +// Per-key log throttle for lines an attacker can emit at will (evicted-JWT +// replays from a proxy pool, frozen-account send attempts, lnurl scans of +// missing usernames). Returns null while the key is inside its quiet window, +// otherwise the number of calls suppressed since the last emitted line — so +// the first occurrence always logs, and the next one after the window carries +// the suppressed count. In-memory: a restart simply logs again. +const quietUntil = new Map<string, { until: number; dropped: number }>(); +export const quiet = (key: string, ms = 10 * 60 * 1000): number | null => { + const now = Date.now(); + const q = quietUntil.get(key); + if (q && q.until > now) { + q.dropped++; + return null; + } + if (quietUntil.size > 10000) quietUntil.clear(); + quietUntil.set(key, { until: now + ms, dropped: 0 }); + return q?.dropped ?? 0; +}; +export const suppressed = (n: number | null) => + n ? `(+${n} similar suppressed)` : ""; + export const line = () => { const stack = new Error().stack; const stackLine = stack.split("\n")[1]; diff --git a/lib/names.ts b/lib/names.ts new file mode 100644 index 00000000..284c595d --- /dev/null +++ b/lib/names.ts @@ -0,0 +1,37 @@ +import { warn } from "$lib/logging"; +import { fail } from "$lib/utils"; + +// The v3 registrar (coinos v3 / halwallet) owns any name it has a record for: +// routes/lnurl.ts serves a claimed name's lightning address from the registrar +// ahead of the local account. So a local account taking a name the registrar +// holds silently hands its incoming payments to whoever holds the v3 name. +// Both places that assign a username — registration and the profile rename — +// must therefore refuse a name the registrar holds, unless the account's own +// nostr key is the holder (a user re-creating their migrated name is fine). +const NAMES_URL = process.env.NAMES_URL || "https://names.coinos.io"; +const DOMAIN = "coinos.io"; + +// Fail CLOSED: if the registrar can't be reached we don't know who owns the +// name, and handing out a squatted name is worse than a retry. +export const assertNameFree = async (username: string, ownPubkey?: string) => { + const name = username.toLowerCase(); + let body: any; + try { + const r = await fetch( + `${NAMES_URL}/name/${encodeURIComponent(name)}?domain=${DOMAIN}`, + { signal: AbortSignal.timeout(3000) }, + ); + if (!r.ok) throw new Error(`registrar said ${r.status}`); + body = await r.json(); + } catch (e: any) { + warn("names registrar unreachable checking", name, e?.message); + fail("Username unavailable right now, please try again"); + } + // `pubkey` is only present for a real v3 record. `taken` without one means + // the registrar is echoing our own account list back at us — not a claim. + const holder = body?.taken && typeof body.pubkey === "string" ? body.pubkey : null; + if (holder && holder !== ownPubkey) { + warn("username held by v3 registrar", name, holder.slice(0, 8)); + fail(`Username ${name} taken`); + } +}; diff --git a/lib/nostr.ts b/lib/nostr.ts index 4c28ebc9..ed1b2f42 100644 --- a/lib/nostr.ts +++ b/lib/nostr.ts @@ -3,20 +3,31 @@ import { db, g, s } from "$lib/db"; import { l, warn } from "$lib/logging"; import { scan } from "$lib/strfry"; import { fail, fields, getUser, pick } from "$lib/utils"; -import { bytesToHex } from "@noble/hashes/utils"; -import { finalizeEvent, getPublicKey, nip19, verifyEvent } from "nostr-tools"; +import { bytesToHex, hexToBytes } from "@noble/hashes/utils"; +import { + finalizeEvent, + getPublicKey, + nip04, + nip19, + nip44, + verifyEvent, +} from "nostr-tools"; import type { Event } from "nostr-tools"; import { AbstractSimplePool } from "nostr-tools/abstract-pool"; import { Relay } from "nostr-tools/relay"; export const EX = 60 * 60 * 24; -const coinos = await Relay.connect(config.nostr); export const serverSecret = bytesToHex( nip19.decode(config.nostrKey).data as Uint8Array, ); -export const serverPubkey = getPublicKey(serverSecret); +export const serverSecret2 = bytesToHex( + nip19.decode(config.nostrKey2).data as Uint8Array, +); + +export const serverPubkey = getPublicKey(hexToBytes(serverSecret)); +export const serverPubkey2 = getPublicKey(hexToBytes(serverSecret2)); const alwaysTrue: any = (t: Event) => { t[Symbol("verified")] = true; @@ -33,6 +44,21 @@ export const anon = (pubkey) => ({ followers: [], }); +// NIP-47 encryption schemes: clients pick one from the 13194 info event's +// `encryption` tag and mark their requests with the same tag; replies use +// whatever the request used. No tag means legacy nip04. +export const encryptionSchemes = "nip44_v2 nip04"; + +export const decryptPayload = (scheme, sk, pk, content) => + scheme === "nip44_v2" + ? nip44.decrypt(content, nip44.getConversationKey(hexToBytes(sk), pk)) + : nip04.decrypt(sk, pk, content); + +export const encryptPayload = (scheme, sk, pk, plaintext) => + scheme === "nip44_v2" + ? nip44.encrypt(plaintext, nip44.getConversationKey(hexToBytes(sk), pk)) + : nip04.encrypt(sk, pk, plaintext); + export async function publish(ev, url = config.nostr) { if (!verifyEvent(ev)) fail("Invalid event"); const r = await Relay.connect(url); @@ -42,7 +68,7 @@ export async function publish(ev, url = config.nostr) { export async function handleZap(invoice, sender = undefined) { try { - const pubkey = serverPubkey; + const pubkey = serverPubkey2; const zapreq = JSON.parse(invoice.description); if (!zapreq.tags || zapreq.tags.length === 0) { @@ -95,7 +121,7 @@ export async function handleZap(invoice, sender = undefined) { tags.push(["preimage", invoice.payment_preimage]); const ev = { pubkey, kind, created_at, content, tags }; - const signed = await finalizeEvent(ev, serverSecret); + const signed = await finalizeEvent(ev, hexToBytes(serverSecret2)); l("sending receipt"); @@ -180,9 +206,7 @@ export const getCount = async (pubkey) => { } return { follows, followers }; - } catch (e) { - console.log(e); - } + } catch (e) {} }; export const getNostrUser = async (key) => { @@ -231,7 +255,6 @@ export const q = async (f) => { pool.subscribeMany(["wss://relay.primal.net"], [f], { onevent(e) { r.push(e); - coinos.publish(e); }, oneose() { resolve(r); diff --git a/lib/notes.ts b/lib/notes.ts index 84d157ee..400e5978 100644 --- a/lib/notes.ts +++ b/lib/notes.ts @@ -196,7 +196,6 @@ export const parseContent = ({ return [`nostr:${type}`, bech32, { ...value, entity }]; } catch (e) { - console.log(e); // pass } } diff --git a/lib/notifications.ts b/lib/notifications.ts index d5b4f73f..fe245c61 100644 --- a/lib/notifications.ts +++ b/lib/notifications.ts @@ -4,11 +4,11 @@ import ln from "$lib/ln"; import { err, l, warn } from "$lib/logging"; import { mail, templates } from "$lib/mail"; import mqtt from "$lib/mqtt"; -import { publish, serverSecret } from "$lib/nostr"; +import { encryptPayload, publish, serverSecret2 } from "$lib/nostr"; import { emit } from "$lib/sockets"; import { f, fiat, fmt, getUser, link, nada, t } from "$lib/utils"; import { hexToBytes } from "@noble/hashes/utils"; -import { finalizeEvent, nip04 } from "nostr-tools"; +import { finalizeEvent } from "nostr-tools"; import webpush from "web-push"; if (config.vapid) { @@ -19,6 +19,22 @@ if (config.vapid) { ); } + +// LNURL-pay invoices carry the raw metadata array as their memo when the +// payer left no comment, e.g. [["text/plain","Paying [email protected]"],...]. +// Receipts should show just the human-readable text. +const printerMemo = (memo) => { + if (typeof memo !== "string" || !memo.startsWith("[")) return memo; + try { + const meta = JSON.parse(memo); + if (Array.isArray(meta)) { + const plain = meta.find((m) => Array.isArray(m) && m[0] === "text/plain"); + if (plain) return plain[1]; + } + } catch (e) {} + return memo; +}; + export const notify = async (p, user, withdrawal) => { emit(user.id, "payment", p); let { username } = user; @@ -74,7 +90,8 @@ export const notify = async (p, user, withdrawal) => { if (!mqtt.connected) await mqtt.reconnect(); mqtt.publish( username, - `pay:${p.amount}:${p.tip}:${p.rate}:${p.created}:${p.id}:${p.memo}:${p.items}`, + `pay:${p.amount}:${p.tip}:${p.rate}:${p.created}:${p.id}:${printerMemo(p.memo)}:${p.items}`, + { qos: 1 }, ); } }; @@ -84,11 +101,23 @@ export const nwcNotify = async (p) => { const user = await getUser(p.uid); const pubkeys = await db.sMembers(`${user.id}:apps`); if (pubkeys.length) { - let payment_hash = ""; - if (p.type === "lightning") ({ payment_hash } = await ln.decode(p.hash)); + let payment_hash = p.payment_hash || ""; + if (!payment_hash && (p.type === "lightning" || p.type === "bolt12")) { + try { + const d = await ln.decode(p.hash); + // bolt11 decodes expose payment_hash, bolt12 invoices + // invoice_payment_hash + payment_hash = d.payment_hash || d.invoice_payment_hash || ""; + } catch (e) {} + } for (const pubkey of pubkeys) { - const { notify } = await g(`app:${pubkey}`); - if (!notify) continue; + // The app record may be missing/not-yet-written: a pubkey lands in + // `${uid}:apps` before its `app:<pubkey>` is persisted (creation race when + // adding an NWC connection), or a stale entry outlived its record. Guard + // the lookup — destructuring `notify` off null threw and, since the catch + // wraps the whole loop, aborted notifications for ALL the user's apps. + const app = await g(`app:${pubkey}`); + if (!app?.notify) continue; l("notifying", pubkey, p.type, p.amount); const notification = { @@ -108,22 +137,33 @@ export const nwcNotify = async (p) => { notification, }); - const content = await nip04.encrypt(serverSecret, pubkey, payload); + // NIP-47: a wallet supporting both schemes publishes each notification + // twice — kind 23196 nip04-encrypted, kind 23197 nip44-encrypted + for (const [scheme, kind] of [ + ["nip04", 23196], + ["nip44_v2", 23197], + ] as const) { + const content = await encryptPayload( + scheme, + serverSecret2, + pubkey, + payload, + ); - const unsigned = { - content, - tags: [["p", pubkey]], - kind: 23196, - created_at: Math.floor(Date.now() / 1000), - }; + const unsigned = { + content, + tags: [["p", pubkey]], + kind, + created_at: Math.floor(Date.now() / 1000), + }; - const event = finalizeEvent(unsigned, hexToBytes(serverSecret)); + const event = finalizeEvent(unsigned, hexToBytes(serverSecret2)); - publish(event).catch(nada); + publish(event).catch(nada); + } } } } catch (e) { - console.log(e); warn("nwc notification failed", e.message); } }; @@ -1,24 +1,56 @@ import config from "$config"; -import { db, g } from "$lib/db"; -import { generate } from "$lib/invoices"; +import { archive, db, g, gf } from "$lib/db"; +import { generate, getUserOffer } from "$lib/invoices"; import ln from "$lib/ln"; import { err, l, warn } from "$lib/logging"; -import { handleZap, serverPubkey, serverSecret } from "$lib/nostr"; +import { + decryptPayload, + encryptPayload, + encryptionSchemes, + handleZap, + serverPubkey, + serverPubkey2, + serverSecret, + serverSecret2, +} from "$lib/nostr"; import { sendInternal, sendKeysend, sendLightning } from "$lib/payments"; import { fail, getInvoice, sleep } from "$lib/utils"; import rpc from "@coinos/rpc"; -import { hexToBytes } from "@noble/hashes/utils"; +import { sha256 } from "@noble/hashes/sha256"; +import { bytesToHex, hexToBytes } from "@noble/hashes/utils"; import { Relay } from "nostr"; -import { finalizeEvent, nip04 } from "nostr-tools"; +import { finalizeEvent } from "nostr-tools"; import type { UnsignedEvent } from "nostr-tools"; +const serverKeys = { + [serverPubkey]: serverSecret, + [serverPubkey2]: serverSecret2, +}; + const result = (result) => ({ result }); const error = (error) => ({ error }); +// Every rejected NWC payment used to vanish without a trace: the error went +// back to the client and nothing was logged, so a user reporting "zaps over +// 1k fail" left no evidence server-side. One warn line per failure: who, +// which connection, how much, and why. +const payFail = (method, app, user, amount, err) => { + warn( + `nwc ${method} failed`, + user?.username, + String(app?.pubkey || "").slice(0, 12), + amount, + err?.error?.code, + String(err?.error?.message || "").slice(0, 160), + ); + return err; +}; const bc = rpc(config.bitcoin); const methods = [ "pay_keysend", "pay_invoice", + "pay", + "receive", "get_balance", "get_info", "make_invoice", @@ -27,155 +59,626 @@ const methods = [ ]; const week = 7 * 24 * 60 * 60; +const nwcEventMaxAgeSeconds = 5 * 60; +// Tolerated clock skew for future-dated events. Events further ahead than this +// are rejected so a captured, future-dated signed event can't be replayed later. +const nwcClockSkewSeconds = 60; +const handledKey = "handled:nwc"; + +// Per-pubkey rate limiting for NWC requests. Raised 5 -> 60/min: normal client +// usage (a balance check or two + a payment + a few lookup_invoice reconciles) +// blows past 5 trivially, and over-limit requests were dropped SILENTLY with no +// reply — indistinguishable from lost replies. 60/min (1/s avg) is ample +// headroom for interactive use while still capping abuse. +const nwcRateLimit = 60; // max requests per minute per pubkey +const nwcRateWindow = 60 * 1000; // 1 minute in ms +const nwcRequestTimes: Map<string, number[]> = new Map(); + +// Per-app async mutex for spends. checkBudget reads `${pubkey}:payments` and the +// spend is only recorded after the payment settles, so N concurrent requests on +// one connection would each read the same stale "spent" total and all pass — +// up to N × max_amount out. Serializing the check + send + record per app closes +// that window (same shape as withLimitLock for the asset-type limits). +const budgetLocks: Map<string, Promise<void>> = new Map(); +const withBudgetLock = async <T>(key: string, fn: () => Promise<T>): Promise<T> => { + const prev = budgetLocks.get(key) || Promise.resolve(); + let release: () => void = () => {}; + const next = new Promise<void>((res) => { + release = res; + }); + budgetLocks.set(key, prev.then(() => next)); + await prev; + try { + return await fn(); + } finally { + release(); + } +}; +const budgetedMethods = new Set(["pay_invoice", "pay", "pay_keysend"]); +const nwcDormancyWindow = 30 * 24 * 60 * 60 * 1000; + +const isDormantNwcApp = async (app) => { + const cutoff = Date.now() - nwcDormancyWindow; + let lastActivity = Number(app.created) || 0; + + // Payment indexes are newest-first. Check a few entries because an archived + // or missing record must not make a recently active connection look dormant. + const pids = await db.lRange(`${app.pubkey}:payments`, 0, 19); + for (const pid of pids) { + const payment = await gf(`payment:${pid}`); + if (payment?.created) + lastActivity = Math.max(lastActivity, Number(payment.created)); + } + + return lastActivity < cutoff; +}; export default () => { - const r = new Relay("ws://sf:7777"); + let r: any; + let heartbeatInterval: any; + let infoCheckInterval: any; - r.on("open", async (_) => { - r.subscribe("nwc", { kinds: [23194], "#p": [serverPubkey] }); - const info = await finalizeEvent( + // Publish the kind 13194 info event and CONFIRM the relay stored it. The + // long-lived NWC socket's send is fire-and-forget (await wait_connected; ws.send) + // and was called without await — so a send that rejected (e.g. ws in CLOSING + // state during a reconnect) was silently dropped while we logged success, + // leaving clients with "no info event" until a manual republish. Publish over a + // fresh short-lived socket and wait for the relay's OK, retrying a few times. + async function publishInfo(pk: string, sk: string) { + const info = finalizeEvent( { created_at: Math.floor(Date.now() / 1000), kind: 13194, tags: [ - ["p", serverPubkey], + ["p", pk], ["notifications", "payment_received payment_sent"], + ["encryption", encryptionSchemes], ], content: methods.join(" "), }, - hexToBytes(serverSecret), + hexToBytes(sk), ); - r.send(["EVENT", info]); - }); + // No success log: this now runs every 2 minutes per pubkey, so a "published" + // line each time is pure noise. Only the failure paths below log. + for (let attempt = 1; attempt <= 3; attempt++) { + if (await sendEventConfirmed(info)) return true; + warn(`nwc 13194 publish not confirmed (attempt ${attempt})`, pk); + await sleep(1000); + } + err("nwc failed to publish 13194 info event after retries", pk); + return false; + } + + // Send an event to strfry over a fresh socket and resolve true once the relay + // replies OK=accepted. Independent of the long-lived NWC socket, whose + // fire-and-forget send proved unreliable for the persisted info event. + function sendEventConfirmed(ev: any, timeoutMs = 5000): Promise<boolean> { + return new Promise((resolve) => { + let ws: WebSocket; + let done = false; + const finish = (ok: boolean) => { + if (done) return; + done = true; + try { ws.close(); } catch (_) {} + resolve(ok); + }; + try { + ws = new WebSocket("ws://sf:7777"); + } catch (_) { + return resolve(false); + } + ws.onopen = () => ws.send(JSON.stringify(["EVENT", ev])); + ws.onmessage = (e: any) => { + try { + const m = JSON.parse(e.data); + if (m[0] === "OK" && m[1] === ev.id) finish(!!m[2]); + } catch (_) {} + }; + ws.onerror = () => finish(false); + setTimeout(() => finish(false), timeoutMs); + }); + } + + // The kind 13194 info event (NWC capability advertisement) is REPLACEABLE, so + // it's meant to persist on the relay. NWC clients (Alby Go, Lightning Piggies, + // etc.) fetch it before they'll talk to the wallet — without it they report + // "no info event" and show an unknown balance. + // + // We used to query the relay and publish only the missing pubkeys, but the + // stored event kept vanishing between checks (cause never pinned down), which + // left clients broken until the next hourly check. Republishing both server + // info events unconditionally on a short interval is cheap (a replaceable + // event, so it just overwrites in place) and self-heals immediately. + // publishInfo() uses its own short-lived socket, so this works regardless of + // the long-lived nwc socket's state. + function ensureInfo() { + publishInfo(serverPubkey, serverSecret); + publishInfo(serverPubkey2, serverSecret2); + } + + function connect() { + if (heartbeatInterval) clearInterval(heartbeatInterval); + if (infoCheckInterval) clearInterval(infoCheckInterval); + r = new Relay("ws://sf:7777", { reconnect: false }); + + r.on("open", async (_) => { + l("nwc connected to strfry"); + + // Periodically check if connection is alive + heartbeatInterval = setInterval(() => { + try { + if (!r?.ws || r.ws.readyState !== 1) { + warn("nwc heartbeat: connection dead, reconnecting"); + clearInterval(heartbeatInterval); + try { r.close(); } catch (_) {} + setTimeout(connect, 1000); + } + } catch (_) { + warn("nwc heartbeat: error, reconnecting"); + clearInterval(heartbeatInterval); + setTimeout(connect, 1000); + } + }, 30000); + r.subscribe("nwc", { kinds: [23194], "#p": [serverPubkey, serverPubkey2], since: Math.floor(Date.now() / 1000) - nwcEventMaxAgeSeconds }); + + // Publish the 13194 info events immediately, then keep republishing every + // 2 minutes so a vanished event is never missing for long. + ensureInfo(); + infoCheckInterval = setInterval(ensureInfo, 2 * 60 * 1000); + }); - r.on("event", async (sub, ev) => { + + r.on("close", () => { + warn("nwc strfry connection lost, reconnecting in 5s"); + if (heartbeatInterval) clearInterval(heartbeatInterval); + if (infoCheckInterval) clearInterval(infoCheckInterval); + setTimeout(connect, 5000); + }); + + r.on("error", () => {}); + + r.on("event", async (sub, ev) => { try { if (sub !== "nwc") return; - if (await db.sIsMember("handled", ev.id)) return; - db.sAdd("handled", ev.id); + const now = Math.floor(Date.now() / 1000); + // Reject events outside the accepted window in BOTH directions. Old events + // are stale; future-dated ones (beyond a small skew) could otherwise be + // replayed indefinitely once a time-bounded dedup entry expired. + if (ev.created_at) { + if (now - ev.created_at > nwcEventMaxAgeSeconds) return; + if (ev.created_at - now > nwcClockSkewSeconds) return; + } + // Atomic dedup claim. SET NX is a single-command check-and-set, so two + // concurrent deliveries of the same signed event cannot both pass — the + // previous zScore-then-(unawaited)-zAdd let duplicate relay deliveries each + // reach handle() and pay twice. The claim outlives the accepted age window + // so a future-dated event can't be replayed after it would have expired. + if ( + !(await db.set(`${handledKey}:${ev.id}`, "1", { + NX: true, + EX: 2 * nwcEventMaxAgeSeconds, + })) + ) + return; + let { content, pubkey } = ev; + const pk = ev.tags.find((t) => t[0] === "p")[1]; + const sk = serverKeys[pk]; + // Clients mark nip44 requests with an `encryption` tag; absence means + // legacy nip04 (NIP-47). Replies must use the same scheme. + const scheme = + ev.tags.find((t) => t[0] === "encryption")?.[1] === "nip44_v2" + ? "nip44_v2" + : "nip04"; const { params, method } = JSON.parse( - await nip04.decrypt(serverSecret, pubkey, content), + await decryptPayload(scheme, sk, pubkey, content), ); + // Helper: send a 23195 reply (used for both results and errors). Pulled + // up so the rate-limit path can reply instead of dropping silently. + const reply = async (payloadObj: any) => { + const enc = await encryptPayload( + scheme, + sk, + pubkey, + JSON.stringify(payloadObj), + ); + const signed = await finalizeEvent( + { + created_at: Math.floor(Date.now() / 1000), + kind: 23195, + pubkey: serverPubkey, + tags: [["p", pubkey], ["e", ev.id], ["encryption", scheme]], + content: enc, + } as UnsignedEvent, + hexToBytes(sk), + ); + r.send(["EVENT", signed]); + }; + + // Per-pubkey rate limiting. Over-limit requests get a NIP-47 RATE_LIMITED + // error reply (not a silent drop) so the client can distinguish throttling + // from a lost reply and back off. + const times = nwcRequestTimes.get(ev.pubkey) || []; + const cutoff = Date.now() - nwcRateWindow; + const recent = times.filter((t) => t > cutoff); + if (recent.length >= nwcRateLimit) { + // The event is already claimed above, so it won't be reprocessed. + await reply({ + result_type: method, + error: { code: "RATE_LIMITED", message: "Too many requests, slow down" }, + }); + return; + } + recent.push(Date.now()); + nwcRequestTimes.set(ev.pubkey, recent); + // console.log("nwc", method, params, pubkey); if (!methods.includes(method)) return; + // Incident kill switch. NWC connection secrets are long-lived bearer + // credentials; if they may have been disclosed, a ledger hard-freeze is + // too broad to leave on indefinitely and per-app deletion is both slow and + // destructive. This flag makes every NWC method fail closed while keeping + // the connection records available for deliberate rotation/recovery. + if (await g("nwc:disabled")) { + await reply({ + result_type: method, + error: { + code: "INTERNAL", + message: "NWC is temporarily disabled", + }, + }); + return; + } + try { const app = await g(`app:${pubkey}`); if (!app) fail("pubkey not found"); const user = await g(`user:${app.uid}`); + if (!user) fail("user not found"); + + // Forced credential rotation. NWC secrets are bearer credentials, so a + // suspected disclosure cannot be repaired by changing an account + // password. The cutoff retires every connection created before the + // incident epoch while allowing users to create replacement connections + // without a global NWC outage. Keep the explicit quarantine membership + // as a durable forensic marker and as defense in depth if the cutoff is + // later removed accidentally. + const credentialCutoff = Number(await g("nwc:credential-cutoff")) || 0; + if ( + credentialCutoff > 0 && + (!Number(app.created) || Number(app.created) < credentialCutoff) + ) { + await db.sAdd("nwc:quarantined", pubkey); + // Retired clients commonly keep polling until their owners replace + // the connection. The rejection is expected and can be extremely + // noisy, so reply without logging every attempt. Explicit quarantine + // and newly detected attack-pattern events remain warning-level. + await reply({ + result_type: method, + error: { + code: "UNAUTHORIZED", + message: + "This NWC connection has been retired; create a new one", + }, + }); + return; + } - const result = await handle(method, params, ev, app, user); - const payload = JSON.stringify({ result_type: method, ...result }); - content = await nip04.encrypt(serverSecret, pubkey, payload); + // Incident containment without disrupting every NWC user. Quarantine + // only connections whose bearer secret shows the probe/drain pattern; + // retain the app record so support can identify and rotate it later. + if (await db.sIsMember("nwc:quarantined", pubkey)) { + warn("Blocked quarantined NWC connection", user.username, pubkey, method); + await reply({ + result_type: method, + error: { + code: "UNAUTHORIZED", + message: "This NWC connection has been disabled; create a new one", + }, + }); + return; + } - let response: UnsignedEvent = { - created_at: Math.floor(Date.now() / 1000), - kind: 23195, - pubkey: serverPubkey, - tags: [ - ["p", pubkey], - ["e", ev.id], - ], - content, - }; + // Active-incident guard: the leaked credential set is being walked with + // keysend probes against years-old, previously idle connections. Block + // the first keysend after 30 days of inactivity and quarantine only that + // connection. Ordinary pay_invoice/pay traffic and active keysend apps + // remain unaffected. The Redis flag makes this guard easy to retire once + // the credential-rotation campaign is complete. + if ( + method === "pay_keysend" && + (await g("nwc:guard:dormant-keysend")) && + (await isDormantNwcApp(app)) + ) { + await db.sAdd("nwc:quarantined", pubkey); + warn("SECURITY: quarantined dormant NWC keysend", user.username, pubkey); + await reply({ + result_type: method, + error: { + code: "UNAUTHORIZED", + message: "This dormant NWC connection has been disabled; create a new one", + }, + }); + return; + } - response = await finalizeEvent(response, hexToBytes(serverSecret)); - r.send(["EVENT", response]); + // Serialize budget-affecting methods per app so the budget check and the + // resulting spend can't interleave with a concurrent request on the same + // connection. Read-only methods run without contending for the lock. + const run = () => handle(method, params, ev, app, user); + const result = budgetedMethods.has(method) + ? await withBudgetLock(app.pubkey, run) + : await run(); + // A handler returning nothing (e.g. pay_invoice that couldn't confirm in + // time) must still produce a reply, or the client hangs forever waiting. + if (result === undefined || result === null) { + await reply({ + result_type: method, + error: { code: "INTERNAL", message: "No response from handler" }, + }); + } else { + await reply({ result_type: method, ...result }); + } } catch (e) { - err( - "problem with nwc", - pubkey, - method, - JSON.stringify(params), - e.message, - ); + // Stale client state (deleted app or migrated user) is not a server + // fault — don't log it, but DO answer: a NIP-47 UNAUTHORIZED reply + // lets the client surface an error instead of spinning forever on a + // connection this server no longer knows. + if (e.message === "pubkey not found" || e.message === "user not found") { + try { + await reply({ + result_type: method, + error: { code: "UNAUTHORIZED", message: "unknown connection" }, + }); + } catch (_) {} + return; + } + err("problem with nwc", pubkey, method, e.message); + // Still reply so the client isn't left hanging on the failure. + try { + await reply({ + result_type: method, + error: { code: "INTERNAL", message: e.message }, + }); + } catch (_) {} } } catch (e) { - err("problem with nwc", e.message); + err("problem with nwc event", e.message); } }); + } + + connect(); +}; + +// Pull the single lightning instruction out of a BIP-321 payment string for +// the nwc `pay` method (nwc#2): a bitcoin: URI (preferring lno= over +// lightning=), a lightning: URI, or a bare bolt11/bolt12 string. Returns +// { instruction } or { error } (a ready-to-send NIP-47 error payload). +const noInstruction = error({ + code: "UNSUPPORTED_PAYMENT_INSTRUCTION", + message: "No lightning instruction found (bolt11 or bolt12 required)", +}); +const parseInstruction = (payment) => { + if (typeof payment !== "string") return noInstruction; + let s = payment.replace(/\s/g, ""); + const lower = s.toLowerCase(); + if (lower.startsWith("lightning:")) { + s = s.slice("lightning:".length); + } else if (lower.startsWith("bitcoin:")) { + const i = s.indexOf("?"); + if (i === -1) return noInstruction; + let lno; + let bolt11; + // BIP-321 keys are case-insensitive and may repeat; first one wins + for (const [k, v] of new URLSearchParams(s.slice(i + 1))) { + const key = k.toLowerCase(); + // BIP-321: a req- parameter the wallet doesn't understand invalidates + // the whole URI. We support none — including req-pop, since we can't + // open a proof-of-payment callback — so any req- key is a rejection. + // A plain (optional) pop is skippable and falls through harmlessly. + if (key.startsWith("req-")) { + return error({ + code: "BAD_REQUEST", + message: `Unsupported required BIP-321 parameter: ${key}`, + }); + } + if (key === "lno") lno ||= v; + else if (key === "lightning") bolt11 ||= v; + } + s = lno || bolt11; + if (!s) return noInstruction; + } + s = s.toLowerCase(); + return s.startsWith("ln") ? { instruction: s } : noInstruction; +}; + +// Network matching for the nwc `pay` method: the spec requires rejecting a +// payment instruction for a different Bitcoin network BEFORE paying (error +// code UNSUPPORTED_NETWORK) rather than letting xpay fail after the debit. +// bolt11 encodes the network in its bech32 prefix (decode's `currency`); +// bolt12 carries BOLT chain_hashes (genesis hash, internal byte order), with +// absence meaning mainnet. Keyed by CLN getinfo's `network` name. +const bolt11Prefixes = { + bitcoin: "bc", + testnet: "tb", + signet: "tbs", + regtest: "bcrt", +}; +const chainHashes = { + bitcoin: "6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000", + testnet: "43497fd7f826957108f4a30fd9cec3aeba79972084e90ead01ea330900000000", + signet: "f61eee3b63a380a477a063af32b2bbc97c9ff9f01f2c4225e973988108000000", + regtest: "06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f", +}; +const matchesNetwork = (decoded, network) => { + if (decoded.type === "bolt11 invoice") + return decoded.currency === bolt11Prefixes[network]; + const ours = chainHashes[network]; + if (decoded.type === "bolt12 offer") + return decoded.offer_chains + ? decoded.offer_chains.includes(ours) + : network === "bitcoin"; + const chain = decoded.invreq_chain || chainHashes.bitcoin; + return chain === ours; +}; + +// CLN v26.06's experimental `createproof` RPC produces the lnp payer proof +// that NIP-177 zap receipts (kind 9736) embed. The default proof OMITS +// invreq_payer_note, which NIP-177 verification requires disclosed (it binds +// the payment to the zap intent) — and passing `include` REPLACES the default +// disclosure set, so invoice_amount must be re-listed alongside it or the +// amount gets hidden too. Feature-detected through the generic call() — on +// older nodes this errors and we omit the proof, which the nwc `pay` spec +// allows. +const createPayerProof = async ( + invstring: string, + hasPayerNote: boolean = false, +) => { + try { + const r = await ln.call("createproof", { + invstring, + ...(hasPayerNote && { include: ["invreq_payer_note", "invoice_amount"] }), + }); + return r?.proofs?.[0]?.bolt12; + } catch (e) { + return undefined; + } +}; + +// Connection validity + spending budget for an NWC app, shared by pay_invoice +// and pay. Returns a NIP-47 error payload, or null when the spend is allowed. +const checkBudget = async (app, amount) => { + const { max_amount, budget_renewal, pubkey, created } = app; + + if (!created) { + return { + budgetError: error({ + code: "UNAUTHORIZED", + message: `This NWC connection is no longer valid please create a new one at https://coinos.io/settings/nostr`, + }), + remaining: 0, + }; + } + + const unlimited = + max_amount === undefined || max_amount === null || max_amount === ""; + const limit = unlimited ? 0 : Number(max_amount); + if (!Number.isFinite(limit) || limit < 0) { + return { + budgetError: error({ + code: "UNAUTHORIZED", + message: "This NWC connection has an invalid spending budget", + }), + remaining: 0, + }; + } + + // A blank budget is deliberately unlimited. A positive budget must have a + // known renewal period; otherwise an unknown value makes every historical + // payment fall out of the window and silently resets the budget each call. + if (limit === 0) return { budgetError: null, remaining: undefined }; + + const periods = { + daily: 60 * 60 * 24 * 1000, + weekly: 60 * 60 * 24 * 7 * 1000, + monthly: 60 * 60 * 24 * 30 * 1000, + yearly: 60 * 60 * 24 * 365 * 1000, + never: 60 * 60 * 24 * 365 * 10 * 1000, + }; + + const period = periods[budget_renewal]; + if (!period) { + return { + budgetError: error({ + code: "UNAUTHORIZED", + message: "This NWC connection has an invalid budget renewal period", + }), + remaining: 0, + }; + } + + const pids = await db.lRange(`${pubkey}:payments`, 0, -1); + let payments = await Promise.all(pids.map((pid) => gf(`payment:${pid}`))); + payments = payments.filter( + (p) => p?.created > Date.now() - period, + ); + + const spent = payments.reduce( + (a, b) => + a + + (Math.abs(Number.parseInt(b.amount || 0)) + + Number.parseInt(b.tip || 0) + + Number.parseInt(b.fee || 0) + + Number.parseInt(b.ourfee || 0)), + 0, + ); + + const remaining = Math.max(0, limit - spent); + if (amount > remaining) { + return { + budgetError: error({ + code: "QUOTA_EXCEEDED", + message: `Budget exceeded: ${spent + amount} of ${limit}`, + }), + remaining, + }; + } + + return { budgetError: null, remaining }; }; const handle = (method, params, ev, app, user) => ({ async pay_invoice() { - const { invoice: pr, metadata } = params; + const { metadata, amount: reqAmountMsat } = params; + // Lowercase so getInvoice() finds our own invoices below (see create() + // in routes/payments.ts). + const pr = + typeof params.invoice === "string" + ? params.invoice.replace(/\s/g, "").toLowerCase() + : params.invoice; const { amount_msat, payee } = await ln.decode(pr); const { id } = await ln.getinfo(); - const amount = Math.round(amount_msat / 1000); - const { max_amount, max_fee, budget_renewal, pubkey, created } = app; - - const periods = { - daily: 60 * 60 * 24 * 1000, - weekly: 60 * 60 * 24 * 7 * 1000, - monthly: 60 * 60 * 24 * 30 * 1000, - yearly: 60 * 60 * 24 * 365 * 1000, - never: 60 * 60 * 24 * 365 * 10 * 1000, - }; - - const pids = await db.lRange(`${pubkey}:payments`, 0, -1); - let payments = await Promise.all(pids.map((pid) => g(`payment:${pid}`))); - payments = payments.filter( - (p) => p?.created > Date.now() - periods[budget_renewal], - ); - - const spent = payments.reduce( - (a, b) => - a + - (Math.abs(parseInt(b.amount || 0)) + - parseInt(b.tip || 0) + - parseInt(b.fee || 0) + - parseInt(b.ourfee || 0)), - 0, - ); - - if (!created) { - warn( - "old nwc token", - pubkey, - user?.username, - spent, - amount, - max_amount, - ); + // For an amountless bolt11, the invoice carries no amount — NIP-47 lets the + // client specify it in the request's `amount` field (msat). Fall back to + // that so amountless invoices aren't rejected with "Invalid amount" + // (issue #83). Invoice amount takes precedence when present. + const amountMsat = amount_msat || reqAmountMsat; + if (!amountMsat) { return error({ - code: "UNAUTHORIZED", - message: `This NWC connection is no longer valid please create a new one at https://coinos.io/settings/nostr`, + code: "OTHER", + message: + "Amountless invoice requires an amount; specify amount (msat) in the request", }); } + const amount = Math.round(amountMsat / 1000); + const { max_fee, pubkey } = app; - if (max_amount > 0 && spent + amount > max_amount) { - warn( - "budget exceeded", - pubkey, - user?.username, - spent, - amount, - max_amount, - ); - return error({ - code: "QUOTA_EXCEEDED", - message: `Budget exceeded: ${spent + amount} of ${max_amount}`, - }); - } + const { budgetError, remaining } = await checkBudget(app, amount); + if (budgetError) return payFail("pay_invoice", app, user, amount, budgetError); if (payee === id) { const invoice = await getInvoice(pr); - const recipient = await g(`user:${invoice.uid}`); + const recipient = await g(`user:${invoice?.uid}`); - if (recipient?.username !== "mint") { + // No coinos invoice record means the bolt11 is on our node but not + // ours to settle internally (a mint quote, say) — pay it over + // Lightning below like any other. + if (invoice) { const { id: pid } = await sendInternal({ amount, invoice, recipient, sender: user, + maxTotal: remaining, }); const preimage = pid; - if (pubkey !== user.pubkey) await db.lPush(`${pubkey}:payments`, pid); + await db.lPush(`${pubkey}:payments`, pid); if (invoice.memo?.includes("9734")) { const { invoices } = await ln.listinvoices({ invstring: pr }); @@ -193,32 +696,301 @@ const handle = (method, params, ev, app, user) => } } - const { id: pid } = await sendLightning({ - amount, - fee: max_fee || Math.round(amount * 0.01), - user, - pr, - memo: JSON.stringify(metadata) + try { + // sendLightning runs xpay to completion and finalize() sets p.ref to the + // preimage on success, so the returned record already carries it — no + // need to poll listpays and race a 20s timeout (the old loop returned a + // misleading "Payment timed out" error on slow routes even though the + // payment had settled; see issue #80). + const p = await sendLightning({ + amount, + fee: max_fee || Math.round(amount * 0.01), + user, + pr, + memo: JSON.stringify(metadata), + maxTotal: remaining, + }); + + await db.lPush(`${pubkey}:payments`, p.id); + + if (p.ref) return result({ preimage: p.ref }); + + // Fallback: ref missing (shouldn't normally happen) — confirm via + // listpays before giving up, with a longer window than before. + for (let i = 0; i < 30; i++) { + const { pays } = await ln.listpays(pr); + const done = pays.find((x) => x.status === "complete"); + if (done?.preimage) return result({ preimage: done.preimage }); + if (pays.length && pays.every((x) => x.status === "failed")) + return payFail("pay_invoice", app, user, amount, + error({ code: "PAYMENT_FAILED", message: "Payment failed" })); + await sleep(2000); + } + return payFail("pay_invoice", app, user, amount, + error({ code: "PAYMENT_FAILED", message: "Payment still pending after 60s" })); + } catch (e) { + return payFail("pay_invoice", app, user, amount, + error({ code: "PAYMENT_FAILED", message: e.message })); + } + + // Still in flight after the fallback window: the payment may yet settle. + // Tell the client explicitly rather than implying success or hard failure. + return error({ + code: "INTERNAL", + message: "Payment still in flight; check status with lookup_invoice", }); + }, - await db.lPush(`${pubkey}:payments`, pid); + // nwc#2 generalized payment: BIP-321 URI carrying a bolt11 invoice or a + // bolt12 offer/invoice. This is what Amethyst v1.13+ uses for bolt12 zaps — + // payer_note carries the nostr:nip177:<intent-id> binding and the client + // builds the kind 9736 receipt from the returned preimage/payer_proof. + async pay() { + const { payment, amount: reqAmountMsat, payer_note, metadata } = params; + const { pubkey } = app; - for (let i = 0; i < 10; i++) { - const { pays } = await ln.listpays(pr); - const p = pays.find((p) => p.status === "complete"); - if (p) { - const { preimage } = p; - return result({ preimage }); + const parsed = parseInstruction(payment); + if (parsed.error) return parsed; + const pr = parsed.instruction; + + let decoded; + try { + decoded = await ln.decode(pr); + if (decoded.valid === false) throw new Error("invalid"); + } catch (e) { + return error({ + code: "BAD_REQUEST", + message: "Failed to decode payment instruction", + }); + } + + const { network } = await ln.getinfo(); + if (!matchesNetwork(decoded, network)) { + return error({ + code: "UNSUPPORTED_NETWORK", + message: "Payment instruction is for a different Bitcoin network", + }); + } + + const instruction_type = decoded.type.includes("bolt12") + ? "bolt12" + : "bolt11"; + + // payer_note rides in a bolt12 invoice request's invreq_payer_note; + // bolt11 has no payer-message field, and the spec requires delivering + // the note or rejecting before payment — never silently dropping it + if (payer_note && instruction_type !== "bolt12") { + return error({ + code: "BAD_REQUEST", + message: "payer_note requires a bolt12 payment instruction", + }); + } + + const instructionAmountMsat = + decoded.type === "bolt12 offer" + ? decoded.offer_amount_msat + : instruction_type === "bolt12" + ? decoded.invoice_amount_msat + : decoded.amount_msat; + + if ( + reqAmountMsat !== undefined && + reqAmountMsat !== null && + (!Number.isFinite(Number(reqAmountMsat)) || Number(reqAmountMsat) <= 0) + ) { + return error({ code: "BAD_REQUEST", message: "Invalid amount" }); + } + if ( + reqAmountMsat && + instructionAmountMsat && + Number(reqAmountMsat) !== Number(instructionAmountMsat) + ) { + return error({ + code: "BAD_REQUEST", + message: `Requested amount conflicts with the instruction amount (${instructionAmountMsat} msat)`, + }); + } + + const amountMsat = instructionAmountMsat || reqAmountMsat; + if (!amountMsat) { + return error({ + code: "BAD_REQUEST", + message: + "Amountless payment instruction requires an amount (msat) in the request", + }); + } + const amount = Math.round(amountMsat / 1000); + + const { budgetError, remaining } = await checkBudget(app, amount); + if (budgetError) return budgetError; + + const created_at = Math.floor(Date.now() / 1000); + + // Recipient is a coinos user (their invoice or standing offer is ours): + // settle internally, same as pay_invoice does + const invoice = await getInvoice(pr); + if (invoice) { + const recipient = await g(`user:${invoice.uid}`); + + // Internal settlement never builds a bolt12 invoice request, so the + // spec's deliver-or-reject rule for payer_note is met by handing the + // note to the recipient as the payment memo (bolt11 + payer_note was + // already rejected above) + const { id: pid } = await sendInternal({ + amount, + invoice, + memo: payer_note, + recipient, + sender: user, + maxTotal: remaining, + }); + + await db.lPush(`${pubkey}:payments`, pid); + + if (invoice.memo?.includes("9734")) { + const { invoices } = await ln.listinvoices({ invstring: pr }); + const inv = invoices[0]; + if (inv) { + inv.payment_preimage = pid; + inv.paid_at = created_at; + try { + await handleZap(inv, user.pubkey); + } catch (e) { + console.log("zap receipt failed", e); + } + } + } + + // Internal settlement never touches CLN, so there's no real + // preimage or payer proof to hand back + return result({ + transaction_id: pid, + state: "settled", + instruction_type, + amount: amountMsat, + fees_paid: 0, + created_at, + settled_at: Math.floor(Date.now() / 1000), + }); + } + + try { + const p = await sendLightning({ + amount, + fee: app.max_fee || Math.round(amount * 0.01), + user, + pr, + memo: metadata ? JSON.stringify(metadata) : undefined, + payerNote: payer_note, + maxTotal: remaining, + }); + + await db.lPush(`${pubkey}:payments`, p.id); + + const preimage = p.ref; + if (!preimage) { + return result({ + transaction_id: p.id, + state: "pending", + instruction_type, + amount: amountMsat, + fees_paid: 0, + created_at, + }); } - await sleep(2000); + + return result({ + transaction_id: p.id, + state: "settled", + instruction_type, + amount: amountMsat, + fees_paid: (p.fee || 0) * 1000, + payment_hash: bytesToHex(sha256(hexToBytes(preimage))), + preimage, + // p.hash is the invoice actually paid (for an offer, the bolt12 + // invoice fetched from it) + payer_proof: + instruction_type === "bolt12" + ? await createPayerProof(p.hash, !!payer_note) + : undefined, + created_at, + settled_at: Math.floor(Date.now() / 1000), + }); + } catch (e) { + return error({ code: "PAYMENT_FAILED", message: e.message }); } + }, + + // nwc#2 counterpart to pay: hand back a BIP-321 URI combining a fresh + // bolt11 invoice with the user's standing bolt12 offer + async receive() { + const { amount, description } = params; + + try { + const invoice = await generate({ + invoice: { + amount: amount ? Math.round(amount / 1000) : 0, + type: "lightning", + memo: description, + }, + user, + }); - return error({ code: "INTERNAL", message: "Payment timed out" }); + const qs = new URLSearchParams(); + qs.set("lightning", invoice.hash); + try { + // The spec requires the description in EVERY instruction that + // supports one (offers do), and an amount shouldn't come back as an + // amountless lno — so an amounted or described receive mints a + // matching per-request offer; the reusable standing offer only fits + // the blank request. On failure (e.g. an identical offer already + // registered to another user) the lno is omitted rather than + // substituting one with the wrong description/amount. + const offer = + amount || description + ? await generate({ + invoice: { + type: "bolt12", + amount: amount ? Math.round(amount / 1000) : 0, + memo: description, + }, + user, + }) + : await getUserOffer(user); + if (offer?.hash) qs.set("lno", offer.hash); + } catch (e) { + warn("failed to include offer in receive", e.message); + } + + return result({ + bip321: `bitcoin:?${qs.toString()}`, + transaction_id: invoice.id, + }); + } catch (e) { + return error({ code: "BAD_REQUEST", message: e.message }); + } }, async pay_keysend() { - const { amount: amount_msat, pubkey, tlv_records: extratlvs } = params; + const { amount: amount_msat, pubkey, tlv_records } = params; const amount = Math.round(amount_msat / 1000); + const extratlvs = {}; + + // convert tlv_records to the extratlvs format + // tlv_records: [{ type: 1, value: "asdf" }] + // extratlvs: { "1": "asdf" } + if (tlv_records && tlv_records.length) { + for (const record of tlv_records) { + extratlvs[record.type.toString()] = record.value; + } + } + + // Keysend spends the same custodial balance as pay_invoice, so it must be + // subject to the same budget — otherwise a budget-limited connection string + // is a full-drain credential. It also left no `${app.pubkey}:payments` + // entry, so subsequent budgeted calls under-counted the spend. + const { budgetError, remaining } = await checkBudget(app, amount); + if (budgetError) return budgetError; try { const { payment_hash } = await sendKeysend({ @@ -227,8 +999,16 @@ const handle = (method, params, ev, app, user) => pubkey, user, extratlvs, + fee: app.max_fee, + maxTotal: remaining, }); + // Record the debit against the app budget. debit() stored the payment id + // under the keysend label (= ev.id); resolve and push it so checkBudget + // counts this spend going forward. + const pid = await g(`payment:${ev.id}`); + if (pid) await db.lPush(`${app.pubkey}:payments`, pid); + for (let i = 0; i < 10; i++) { const { pays } = await ln.listpays({ payment_hash }); const p = pays.find((p) => p.status === "complete"); @@ -246,7 +1026,7 @@ const handle = (method, params, ev, app, user) => }, async get_info() { - const { alias, blockheight, color, id } = await ln.getinfo(); + const { alias, blockheight, color, id, network } = await ln.getinfo(); return result({ alias, @@ -254,7 +1034,8 @@ const handle = (method, params, ev, app, user) => block_height: blockheight, color, pubkey: id, - network: "mainnet", + // CLN calls mainnet "bitcoin"; NIP-47 clients expect "mainnet" + network: network === "bitcoin" ? "mainnet" : network, methods, notifications: ["payment_received", "payment_sent"], }); @@ -268,7 +1049,7 @@ const handle = (method, params, ev, app, user) => async make_invoice() { const { amount, description, description_hash, expiry } = params; - l("nwc make_invoice", user.username); + // l("nwc make_invoice", user.username); const invoice = { amount: Math.round(amount / 1000), @@ -277,7 +1058,14 @@ const handle = (method, params, ev, app, user) => expiry, }; - const { hash, created: created_at } = await generate({ invoice, user }); + const { hash, created, paymentHash } = await generate({ invoice, user }); + + // generate() stores `created` in milliseconds (Date.now()), but NWC + // timestamps are Unix SECONDS (NIP-47). Returning ms here produced an + // expires_at ~1.78e12 that overflowed clients' date parsers — e.g. + // ptcpay's .NET DateTimeOffset (issue #84: "Valid values are between + // -62135596800 and 253402300799"). Convert to seconds. + const created_at = Math.floor(created / 1000); return result({ type: "incoming", @@ -287,6 +1075,8 @@ const handle = (method, params, ev, app, user) => amount, created_at, expires_at: created_at + expiry, + fees_paid: 0, + payment_hash: paymentHash, metadata: {}, }); }, @@ -356,11 +1146,16 @@ const handle = (method, params, ev, app, user) => async list_transactions() { const { from, until, limit = 10, offset = 0, type } = params; - const payments = await db.lRange(`${user.id}:payments`, 0, -1); + const listKey = `${user.id}:payments`; + const main = (await db.lRange(listKey, 0, -1)) || []; + const archived = (await archive.lRange(listKey, 0, -1)) || []; + const payments = [...new Set([...main, ...archived])]; let transactions = []; for (const pid of payments) { - const p = await g(`payment:${pid}`); + const p = await gf(`payment:${pid}`); + if (!p) continue; + if (p.revertedDuplicate) continue; const created_at = Math.floor(p.created / 1000); if (created_at < from || created_at > until) continue; if (p.amount < 0 && type === "incoming") continue; @@ -385,7 +1180,7 @@ const handle = (method, params, ev, app, user) => description: p.memo, preimage: p.ref, payment_hash, - amount: (Math.abs(p.amount) + p.tip) * 1000, + amount: (Math.abs(p.amount) + (p.tip || 0)) * 1000, fees_paid: (p?.fee || 0) * 1000, created_at, expires_at: created_at + week, diff --git a/lib/payments.ts b/lib/payments.ts index cbff6dd3..09cf2cf6 100644 --- a/lib/payments.ts +++ b/lib/payments.ts @@ -1,11 +1,30 @@ import config from "$config"; +import { existsSync } from "fs"; import api from "$lib/api"; -import { db, g, ga, s } from "$lib/db"; +import { requireAccount } from "$lib/auth"; +import { archive, db, g, ga, gf, s, sa } from "$lib/db"; +import { assertWithinSpendLimit } from "$lib/spend-budget"; + +// External-withdrawal lockfiles. Out-of-band emergency stop: nobal.sh (or a +// human) can `touch /home/adam/locks/<type>.locked` on the host, which is +// bind-mounted into this container at /locks. Existence of /locks/<type>.locked +// (or /locks/ALL.locked) blocks all external sends of that type. Lives outside +// db so it can't be disabled by a db-write vector (May 18 /gateway lesson). +// +// Internal payments are never blocked by these — receives and internal +// transfers between coinos users keep working with the site fully online. +const isWithdrawLocked = (type: string): string | null => { + if (existsSync("/locks/ALL.locked")) return "ALL"; + if (existsSync(`/locks/${type}.locked`)) return type; + return null; +}; import { generate } from "$lib/invoices"; import ln from "$lib/ln"; -import { err, l, warn } from "$lib/logging"; +import { quoteRoutingFee, xpayLayers } from "$lib/lnquote"; +import { err, l, quiet, shortError, suppressed, warn } from "$lib/logging"; import { handleZap } from "$lib/nostr"; import { notify, nwcNotify } from "$lib/notifications"; +import { squarePayment } from "$lib/square"; import { SATS, btc, @@ -21,15 +40,65 @@ import { t, } from "$lib/utils"; import { callWebhook } from "$lib/webhooks"; +import { maybeForwardToV3, retryV3Forwards } from "$lib/v3forward"; +import changeid from "$lib/changeid"; import rpc from "@coinos/rpc"; import { bech32 } from "bech32"; import got from "got"; import { v4 } from "uuid"; + import { PaymentType } from "$lib/types"; const bc = rpc(config.bitcoin); const lq = rpc(config.liquid); + +// Fire-and-forget: push a freshly-broadcast tx straight to public explorers so +// they see it immediately instead of waiting for it to propagate to their node +// over the p2p network (measured ~8s otherwise). Never throws; never blocks. +const broadcastToExplorers = (hex: string) => { + const net = config.bitcoin?.network; + if (net === "regtest" || net === "testnet") return; // public explorers are mainnet + for (const base of ["https://mempool.space/api", "https://blockstream.info/api"]) { + fetch(`${base}/tx`, { + method: "POST", + body: hex, + headers: { "Content-Type": "text/plain" }, + }).catch(() => {}); + } +}; + +// Throttled warn — emit at most once per WARN_THROTTLE_MS per (key, message) pair +// so a downed external service (lq, bc, etc.) doesn't flood the log every loop tick. +const WARN_THROTTLE_MS = 5 * 60 * 1000; +const warnLastEmitted: Record<string, number> = {}; +const warnThrottled = (key: string, message: string) => { + const k = `${key}|${message}`; + const now = Date.now(); + if ((warnLastEmitted[k] || 0) + WARN_THROTTLE_MS > now) return; + warnLastEmitted[k] = now; + warn(`${key}:`, message); +}; + +// Per-asset-type async mutex. The May 18 2026 drain exploited the fact that +// the limit check + payment broadcast were non-atomic: a burst of concurrent +// withdrawals would each pass the same stale ${type}:limit value before any +// of them decremented it. This lock serializes the check + reservation per +// asset type. freezeCheck also takes the lock when overwriting limits so its +// periodic reconciliation can't race with an in-flight check. +const limitLocks: Record<string, Promise<void>> = {}; +async function withLimitLock<T>(type: string, fn: () => Promise<T>): Promise<T> { + const prev = limitLocks[type] || Promise.resolve(); + let release: () => void = () => {}; + const next = new Promise<void>((res) => { release = res; }); + limitLocks[type] = prev.then(() => next); + await prev; + try { + return await fn(); + } finally { + release(); + } +} const { URL } = process.env; const dust = 547; @@ -43,42 +112,87 @@ export const debit = async ({ user, type = PaymentType.internal, rate = undefined, + // a v3 bridge forward (lib/v3forward.ts) costs routing only + exempt = false, + maxTotal = undefined, }) => { - amount = parseInt(amount); + amount = Number.parseInt(amount); const whitelisted = await db.sIsMember( "whitelist", user?.username?.toLowerCase().trim(), ); - const blacklisted = await db.sIsMember( - "blacklist", - user?.username?.toLowerCase().trim(), - ); + // Blacklist (freeze) matches on EITHER the current username OR the uid. The + // uid never changes, so renaming the account can't shake the freeze — add the + // uid to the `blacklist` set to freeze a compromised account durably. + const blacklisted = + (await db.sIsMember("blacklist", user?.username?.toLowerCase().trim())) || + (!!user?.id && (await db.sIsMember("blacklist", user.id))); + + if (hash && await db.sIsMember("blocked_addresses", hash)) { + err(`SECURITY: blocked send to ${hash} by ${user.username}`); + await changeid(user.username); + fail("address blocked"); + } - const serverLimit = await g(`${type}:limit`); const userLimit = await g("limit"); const frozen = (await g("hardfreeze")) || ((await g("freeze")) && type !== PaymentType.internal); - if (frozen || (amount > userLimit && !whitelisted) || amount > serverLimit) { - warn( - "Blocking", - user.username, - amount, - hash, - user.id, - type, - frozen, - userLimit, - serverLimit, - ); + // Out-of-band withdrawal lock (file-based, can't be defeated by db writes). + // Internal sends are exempt — they don't touch external wallets. + if (type !== PaymentType.internal) { + const lockedKind = isWithdrawLocked(type); + if (lockedKind) { + const n = quiet(`block:${user.id}:lock`, 5 * 60 * 1000); + if (n !== null) + warn("Blocking", user.username, amount, hash, user.id, type, "withdraw-lock", lockedKind, suppressed(n)); + fail("External withdrawals temporarily disabled"); + } + } + + if (frozen || (amount > userLimit && !whitelisted)) { + // Per-account throttle: a frozen throwaway retrying sends every few + // seconds is expected noise, one line per 5 min keeps the tripwire. + const n = quiet(`block:${user.id}:frozen`, 5 * 60 * 1000); + if (n !== null) + warn("Blocking", user.username, amount, hash, user.id, type, frozen, userLimit, suppressed(n)); fail("Problem sending payment"); } + // Atomic check + reserve against the per-asset-type server limit. Decrement + // immediately so concurrent calls can't reuse the same budget; freezeCheck + // reconciles to actual on-chain balance every 10s. + // + // Internal sends are exempt: they're pure ledger moves between coinos users + // and never touch an external wallet, so there's no hot-wallet liquidity to + // reserve against. freezeCheck only ever populates limits for external types + // (lightning/fund/ecash/bolt12/bitcoin/liquid), so `internal:limit` stays 0 + // forever — applying the check here blocks every internal payment. + if (type !== PaymentType.internal) { + await withLimitLock(type, async () => { + const serverLimit = Number.parseInt((await g(`${type}:limit`)) ?? "0", 10) || 0; + if (amount > serverLimit) { + const n = quiet(`block:${user.id}:server`, 5 * 60 * 1000); + if (n !== null) + warn("Blocking", user.username, amount, hash, user.id, type, "serverLimit", serverLimit, suppressed(n)); + // Say what's happening (users read the generic error as "account + // frozen") without revealing the limit or the hot-wallet balance. + fail( + type === PaymentType.bitcoin || type === PaymentType.liquid + ? "On-chain withdrawals are temporarily limited. Please try a smaller amount, send over Lightning, or try again later." + : "Payments are temporarily limited. Please try a smaller amount or try again later.", + ); + } + await db.decrBy(`${type}:limit`, amount); + }); + } + let ref; const { id: uid, currency } = user; + if (!aid) aid = uid; const rates = await g("rates"); if (!rate) rate = rates[currency]; @@ -103,27 +217,56 @@ export const debit = async ({ } } - const tip = parseInt(invoice?.tip) || null; + // Capture the tip ONCE, and only for INTERNAL payments — where credit() pays + // the invoice owner the same tip (sendInternal passes p.tip through). External + // sends (bitcoin/liquid/lightning/bolt12/fund) must NOT inherit a tip: the + // destination `hash` can coincidentally match another user's deposit invoice, + // which would otherwise pull that stranger's tip into this debit's balance + // check and stored record (the LiquidNode negative-balance vector). This one + // captured value feeds the ourfee calc, the atomic debit, and the record, so + // the record can never disagree with what was actually debited. + const tip = + type === PaymentType.internal ? Number.parseInt(invoice?.tip) || null : null; if (tip < 0) fail("Invalid tip"); if (!amount || amount < 0) fail("Amount must be greater than zero"); let creditType = type; if (creditType === PaymentType.bolt12) creditType = PaymentType.lightning; + + // High-throughput pass-through accounts (flagged `ln:nofree` by + // ln-freetier-monitor.ts) pay an elevated platform fee on lightning + // withdrawals; everyone else pays the standard rate. Flagged accounts already + // accrue no free-tier credit, so this rate is what they actually pay. + let feeRate = config.fee[creditType]; + if ( + creditType === PaymentType.lightning && + aid === uid && + (await g(`ln:nofree:${uid}`)) + ) + feeRate = config.fee.lightningHigh; + let ourfee: any = [ PaymentType.bitcoin, PaymentType.liquid, PaymentType.lightning, ].includes(type) - ? Math.round((amount + fee + tip) * config.fee[creditType]) + ? Math.round((amount + fee + tip) * feeRate) : 0; - if (aid && aid !== uid) ourfee = 0; + if (aid !== uid || exempt) ourfee = 0; + + // NWC and other delegated spenders can provide the exact allowance remaining + // for this call. Enforce it only after debit has reloaded the invoice and + // captured its tip, so a recipient cannot pass a small-invoice budget check, + // mutate the tip, and drain the account through the internal-payment path. + assertWithinSpendLimit({ amount, tip, fee, ourfee, maxTotal }); + const frozenBalance = - !blacklisted || whitelisted ? 0 : await ga(`balance:${uid}`); + !blacklisted || whitelisted ? 0 : await g(`balance:${uid}`); ourfee = await db.debit( - `balance:${aid || uid}`, + `balance:${aid}`, `credit:${creditType}:${aid !== uid ? 0 : uid}`, t(user).insufficientFunds, amount || 0, @@ -135,6 +278,17 @@ export const debit = async ({ if (ourfee.err) fail(ourfee.err); + // Defense-in-depth: the DEBIT lua rejects overdrafts, so a negative balance + // here means the invariant was violated by a mismatched input or a non-lua + // balance write. Log loudly with full context so the exact vector is greppable + // (SECURITY: negative balance) instead of surfacing days later as a stuck + // negative account. + const postBal = Number.parseInt((await db.get(`balance:${aid}`)) || "0"); + if (postBal < 0) + err( + `SECURITY: negative balance ${postBal} after debit user=${user?.username} aid=${aid} amount=${amount} tip=${tip} fee=${fee} ourfee=${ourfee} type=${type} hash=${hash}`, + ); + const id = v4(); const p = { id, @@ -147,7 +301,13 @@ export const debit = async ({ memo, iid, uid, - confirmed: true, + // Lightning/bolt12 sends are IN-FLIGHT until the HTLC settles (preimage + // revealed). Don't mark them confirmed on the optimistic debit — finalize() + // flips this to true once the preimage arrives. Hold invoices (e.g. Ark) + // can keep an HTLC pending for hours/days; showing "confirmed" while the + // recipient hasn't been paid is misleading. Other send types settle + // immediately (internal) or are broadcast right away (bitcoin/liquid/fund). + confirmed: ![PaymentType.lightning, PaymentType.bolt12].includes(type), rate, currency, type, @@ -166,9 +326,7 @@ export const debit = async ({ .exec(); l(user.username, "sent", type, amount); - //emit(user.id, "payment", p); - if (![PaymentType.lightning, PaymentType.bolt12].includes(type)) - nwcNotify(p, user); + if (![PaymentType.lightning, PaymentType.bolt12].includes(type)) nwcNotify(p); return p; }; @@ -181,16 +339,23 @@ export const credit = async ({ type = PaymentType.internal, aid = undefined, payment_hash = undefined, + created = undefined, + tip = undefined, }) => { - amount = parseInt(amount) || 0; + amount = Number.parseInt(amount) || 0; - let inv; - if (type === PaymentType.bolt12) { + // Kill switch (see routes/payments.ts confirm): no Liquid credits at all + // while `liquid:deposits:disabled` is set. + if (type === PaymentType.liquid && (await g("liquid:deposits:disabled"))) { + warn("liquid credit blocked (disabled)", hash, amount, ref); + return; + } + + let inv = await getInvoice(hash); + if (!inv && type === PaymentType.bolt12) { const { invoices } = await ln.listinvoices({ invstring: hash }); const { local_offer_id } = invoices[0]; inv = await getInvoice(local_offer_id); - } else { - inv = await getInvoice(hash); } if (!inv) { @@ -198,15 +363,64 @@ export const credit = async ({ return; } - let { path, tip } = inv; - tip = parseInt(tip) || 0; + // usePreimage invoices keep their merchant-supplied preimage at + // preimage:${paymentHash} (see generate()). Prefer it as the settlement + // ref: external settlements already carry the same value, but internal + // ones pass ref=sender.id, which would otherwise be surfaced as the + // payment preimage and lose the secret the buyer paid for. Only once the + // invoice is fully paid, though — internal sends can settle partial + // amounts, and an underpayment must not reveal the secret. + if ( + inv.paymentHash && + ![PaymentType.bitcoin, PaymentType.liquid].includes(type) && + inv.received + amount >= inv.amount + ) { + const stored = await db.get(`preimage:${inv.paymentHash}`); + if (stored) ref = stored; + } + + let { path } = inv; + // Use the tip the sender was actually debited (passed in for internal + // transfers) rather than re-reading the invoice. The invoice's tip can be + // mutated between the sender debit and this credit (PUT /invoice), which + // otherwise lets a payer credit a tip they were never charged — minting + // balance. Fall back to the invoice tip only for external receipts + // (lightning/ecash) where there is no paired coinos debit to diverge from. + tip = tip !== undefined ? Number.parseInt(tip) || 0 : Number.parseInt(inv.tip) || 0; if (!memo) ({ memo } = inv); - if (memo && memo.length > 5000) fail("memo too long"); + // Truncate rather than throw: credit() runs AFTER the payment has settled (an + // incoming lightning/bolt12 deposit already advanced pay_index; an internal + // send already debited the sender). Throwing here left the sats in the house + // wallet with no retry path — the payer saw a completed payment and the + // recipient was never credited. An oversized memo (attacker-controllable via + // the lnurl comment or a bolt12 payer_note) must never destroy a settled + // payment; a clipped note is harmless. + if (memo && memo.length > 5000) memo = memo.slice(0, 5000); if (amount < 0 || tip < 0) fail("Invalid amount"); + // For a NON-internal receipt the tip is only a cosmetic split of the real + // settled `amount` — there is no paired coinos debit carrying it — so it can + // never legitimately exceed `amount`. If the invoice tip was mutated via + // PUT /invoice to a value larger than the amount actually received, the + // fallback above would pull in that inflated tip and the stored record's + // `amount - tip` would go negative: a corrupt record that could be reversed + // into balance. The balance credit itself is `amount` and is unaffected, but + // clamp the tip so the record can never be negative. Internal transfers keep + // the full debited tip (their record stores the base amount, non-negative). + if (type !== PaymentType.internal && tip > amount) tip = amount; if (type === PaymentType.internal) amount += tip; const user = await getUser(inv.uid); + if (!user) { + // The account is gone (deleted) but its invoice still receives — e.g. a + // bolt12 offer a mining pool keeps paying. Record the settlement in + // `missing` and skip, like an unknown invoice. Throwing here wedged the + // lightning listener on one settlement and blocked every later receipt + // for two hours (2026-09-05, pay_index 1068006). + warn("received payment for missing user", inv.uid, hash, amount); + await db.sAdd("missing", ref.split(":")[0]); + return; + } const { id: uid, currency } = user; const rates = await g("rates"); @@ -240,7 +454,7 @@ export const credit = async ({ tip, type, confirmed: true, - created: Date.now(), + created: created || Date.now(), items: undefined, }; @@ -258,31 +472,66 @@ export const credit = async ({ p.confirmed = false; balanceKey = "pending"; await s(`payment:${txid}:${vout}`, id); + // Mirror the txid:vout pointer into arc so future bulk /confirm sweeps find + // the prior credit via gf() fallback and don't double-credit. See + // feedback_apr29_double_credit_incident.md for the incident this prevents. + await sa(`payment:${txid}:${vout}`, id); } else { await s(`payment:${hash}`, id); + await sa(`payment:${hash}`, id); + } + + // Idempotency guard for external settlements. The getPayment() pre-check in + // the lightning listener/replay is not atomic: N concurrent re-credits of the + // same payment all read it as uncredited before any writes the pointer, so + // each runs the incrBy below — the /replay double-credit race. SET NX is + // atomic, so only the first caller claims the settlement and credits; the rest + // bail here, before the incrBy. `ref` is the lightning preimage, unique per + // settlement; internal/fund/ecash pass ref=uid and are exempt. + if ([PaymentType.lightning, PaymentType.bolt12].includes(type) && ref) { + const claimed = await db.set(`credited:${ref}`, id, { NX: true }); + if (!claimed) { + warn("duplicate credit blocked", hash, ref); + return; + } } const m = await db.multi(); let creditType = type; if (creditType === PaymentType.bolt12) creditType = PaymentType.lightning; - if ( - [PaymentType.bitcoin, PaymentType.liquid, PaymentType.lightning].includes( - creditType, - ) - ) + // Bitcoin/liquid always accrue the same-network fee credit. + if ([PaymentType.bitcoin, PaymentType.liquid].includes(creditType)) { m.incrBy( `credit:${creditType}:${uid}`, Math.round(amount * config.fee[creditType]), ); + } else if (creditType === PaymentType.lightning) { + // Lightning FREE TIER: a lightning (incl. bolt12) receipt accrues a fee + // credit — so a normal deposit+withdraw is free — UNLESS this account is + // flagged as a high-throughput pass-through (using us to rebalance channels + // for free). ln-freetier-monitor.ts sets `ln:nofree:<uid>` from a 30-day + // round-trip analysis. It's behavioral + per-account, so it can't be dodged + // by splitting across sockpuppets: every puppet that rebalances gets flagged. + const noFree = await g(`ln:nofree:${uid}`); + if (!noFree) + m.incrBy( + `credit:lightning:${uid}`, + Math.round(amount * config.fee.lightning), + ); + } - m.set(`invoice:${inv.id}`, JSON.stringify(inv)) + await m.set(`invoice:${inv.id}`, JSON.stringify(inv)) .set(`payment:${p.id}`, JSON.stringify(p)) .lPush(`${aid || uid}:payments`, p.id) .incrBy(`${balanceKey}:${aid || uid}`, amount) .set(`${aid || uid}:payments:last`, p.created) .exec(); + // Mirror the payment record + invoice into arc for the same protection. + await sa(`payment:${p.id}`, p); + await sa(`invoice:${inv.id}`, inv); + if (inv.items?.length) { formatReceipt(inv.items, inv.currency); p.items = inv.items; @@ -293,13 +542,19 @@ export const credit = async ({ return p; }; +const forwardSend = (o) => sendLightning({ ...o, exempt: true }); +// failed forwards retry with backoff; one process works the queue at a time +setInterval(() => retryV3Forwards(forwardSend).catch(() => {}), 60_000).unref(); + export const completePayment = async (inv, p, user) => { const { id, autowithdraw, threshold, reserve, destination, username } = user; let withdrawal; - if (p.confirmed) { + // A name that moved to coinos v3: the receive is paid on to the v3 wallet + // (lib/v3forward.ts), and legacy autowithdraw stands down for it. + const forwarded = await maybeForwardToV3(p, user, forwardSend); + if (p.confirmed && !forwarded) { if (autowithdraw) { try { - fail("autowithdrawals disabled"); const to = destination.trim(); const balance = await g(`balance:${id}`); const amount = balance - reserve; @@ -312,22 +567,32 @@ export const completePayment = async (inv, p, user) => { }; } } catch (e) { - console.log(e); withdrawal = { failed: true }; - warn(username, "autowithdraw failed", e.message); + warn(username, "autowithdraw failed", shortError(e.message)); } } } nwcNotify(p); notify(p, user, withdrawal); - l(username, "received", p.type, p.amount); + + squarePayment(p, user); + + // completePayment runs at two lifecycle points for on-chain (bitcoin/liquid) + // deposits — once when the tx is first seen (pending) and again from /confirm + // when it confirms — plus once for instant lightning receives. Logging a bare + // "received" at each made a single on-chain deposit look like two receives in + // the logs (it never double-credited — the /confirm `p.confirmed` guard and + // the `seen` set prevent that; it was only noisy/misleading). Distinguish the + // states so the log reads as one deposit moving pending -> confirmed. + const stage = p.confirmed ? "received" : "receiving (pending)"; + l(username, stage, p.type, p.amount); callWebhook(inv, p); }; const pay = async ({ aid = undefined, amount, to, user }) => { if (!aid) aid = user.id; - amount = parseInt(amount) || 0; + amount = Number.parseInt(amount) || 0; let lnurl; let pr; if (to.includes("@") && to.includes(".")) { @@ -341,14 +606,23 @@ const pay = async ({ aid = undefined, amount, to, user }) => { } const recipient = await getUser(to); - if (recipient) + if (recipient) { + // An autowithdraw destination that resolves back to the sender is a + // self-send: the internal credit re-fires completePayment, which re-runs + // autowithdraw, and since the balance nets to the same value it never + // falls below threshold — an unbounded loop. Reject it here (the route's + // create() has the equivalent `Cannot send to self` guard; pay() lacked + // it, which is what let a self-pointed autowithdraw spin forever). + if (recipient.id === aid) + fail("Cannot autowithdraw to your own account"); return sendInternal({ amount, recipient, sender: user, }); + } - const fee = Math.max(5, Math.round(amount * 0.005)); + const fee = Math.max(5, Math.round(amount * 0.02)); if (lnurl) { amount -= fee; const { callback } = (await got(lnurl).json()) as any; @@ -385,18 +659,58 @@ export const decode = async (hex) => { const inflight = {}; export const sendOnchain = async (params) => { let { aid, hex, rate, user, signed } = params; - if (!aid) aid = user.id; + aid = await requireAccount({ aid, user }); + const supplied = !!hex; if (!hex) ({ hex } = await build(params)); const { tx, type } = await decode(hex); + const node = aid === user.id ? rpc(config[type]) : rpc({ ...config[type], wallet: aid }); let { txid } = tx; + let locked = false; + + // Only sign a transaction WE composed for this account. build() leaves a + // short-lived receipt keyed by txid; caller-supplied hex must match one. + // Otherwise the hot wallet signs whatever raw tx is posted to /bitcoin/send + // and the only thing between an arbitrary tx and the chain is the debit math + // below (plus the weSpent credit-side guard in /confirm) — this is the outer + // layer, so an unrecognized tx never reaches signRawTransactionWithWallet + // and never locks our UTXOs on the way to failing. + // + // Receipts survive client-side signing: build() returns an unsigned segwit + // tx and witness data doesn't change the txid, so the self-custody path + // (which posts back hex it finalized itself) matches the same receipt. They + // are not single-use — a replay of the identical tx is already stopped by + // inflight/lockUnspent/testMempoolAccept, and burning the receipt would + // break a legitimate retry after a transient failure. + if (supplied) { + const builder = await db.get(`build:${txid}`); + if (builder !== aid) fail("unrecognized tx"); + } try { if (inflight[txid]) fail("payment in flight"); inflight[txid] = true; + // Reserve the exact inputs this tx spends. If locking fails, bitcoind is + // telling us an input is no longer unspent ("expected unspent output") — + // another send already committed it in the gap between build() (coin + // selection) and here. Broadcasting anyway would double-spend, and the + // higher-fee tx wins, silently losing the loser's funds (the replaced-tx + // incident). So abort hard rather than warn-and-continue; the debit hasn't + // happened yet and build() will pick fresh inputs on retry. + const lockVin = tx.vin.map(({ txid, vout }) => ({ txid, vout })); + if (lockVin.length) { + try { + await node.lockUnspent(false, lockVin); + locked = true; + } catch (e: any) { + warn("lockUnspent failed, aborting send", e.message); + fail("Selected inputs are no longer available, please retry"); + } + } + if (!signed) { if (config[type].walletpass) await node.walletPassphrase(config[type].walletpass, 300); @@ -411,6 +725,25 @@ export const sendOnchain = async (params) => { const r = await node.testMempoolAccept([hex]); if (!r[0].allowed) fail("transaction rejected"); + // Known drain destinations. debit() checks `blocked_addresses` against + // its hash, which for an on-chain send is the txid, so the address itself + // was never matched here. Refuse before the debit: a drain operator can + // fund fresh throwaway accounts and sweep each one on-chain to the same + // address, so freezing accounts is a treadmill while the destination is + // constant. + for (const { + scriptPubKey: { address }, + } of tx.vout) { + if (address && (await db.sIsMember("blocked_addresses", address))) { + const n = quiet(`blockedaddr:${user.id}`, 5 * 60 * 1000); + if (n !== null) + err( + `SECURITY: blocked on-chain send to ${address} by ${user.username} ${suppressed(n)}`, + ); + fail("address blocked"); + } + } + let total = 0; let fee = 0; let change = 0; @@ -473,11 +806,20 @@ export const sendOnchain = async (params) => { await s(`payment:${p.id}`, p); await node.sendRawTransaction(hex); + broadcastToExplorers(hex); delete inflight[txid]; return p; } catch (e) { delete inflight[txid]; + // Release UTXOs only if WE locked them, so we don't accidentally free + // another concurrent send's reservation on a shared input. + if (locked) { + try { + const vin = tx?.vin?.map(({ txid, vout }) => ({ txid, vout })) ?? []; + if (vin.length) await node.lockUnspent(true, vin); + } catch {} + } throw e; } }; @@ -490,10 +832,11 @@ export const sendKeysend = async ({ memo = undefined, user, extratlvs = undefined, + maxTotal = undefined, }) => { - fee = Math.max(parseInt(fee || amount * 0.005), 5); + fee = Math.max(Number.parseInt(fee || amount * 0.005), 5); - let p = await g(`payment:${hash}`); + let p = await gf(`payment:${hash}`); if (p) fail("duplicate keysend"); p = await debit({ @@ -503,19 +846,111 @@ export const sendKeysend = async ({ memo, user, type: PaymentType.lightning, + maxTotal, }); - const r = await ln.keysend({ - destination: pubkey, - amount_msat: amount * 1000, - maxfee: fee * 1000, - retry_for: 10, - extratlvs, - }); + let outcome = "unknown"; - if (r.status !== "complete") reverse(p); + // COINOS-1: keysend randomizes its payment hash (the CLN command takes no + // preimage arg) and a throw yields no result object, so the caller-supplied + // `hash` is NOT the payment's real hash — verifying with it is what made the + // old code reverse on every throw. CLN keysend errors carry no payment_hash + // either (verified on v26.06.2: the error is {code, message, attempts}). + // + // Instead, tag the payment with our own unique `label` and note where the + // sendpays index stands beforehand. `wait sendpays created 0` returns the + // current index immediately, so on a throw we can list only sendpays created + // since — no full scan — and find ours by label regardless of error shape. + // `wait` isn't in the rpc client's generated method list, so go through the + // generic call(). nextvalue 0 never blocks — it returns the current index. + let startIndex: number | undefined; + try { + const w = await ln.call("wait", { + subsystem: "sendpays", + indexname: "created", + nextvalue: 0, + }); + startIndex = w?.created; + } catch (e: any) { + warnThrottled("keysend: wait sendpays unavailable", e?.message ?? String(e)); + } - return r; + try { + const r = await ln.keysend({ + destination: pubkey, + amount_msat: amount * 1000, + maxfee: fee * 1000, + retry_for: 10, + label: hash, + extratlvs, + }); + warn("keysend returned", p.id, JSON.stringify({ + status: r?.status, + has_preimage: !!(r?.payment_preimage ?? r?.preimage), + amount_sent_msat: r?.amount_sent_msat, + })); + // keysend blocks on retry_for until it settles or fails, so a successful + // return carries the preimage. Finalize HERE — flip the record + // pending->confirmed and refund the unused fee reserve — so every caller is + // covered. The NWC pay_keysend caller only polls listpays for the preimage + // to hand back to the client and never confirmed the record, which left + // every successful zap stuck "pending" forever (and check() can't rescue a + // keysend: its label isn't a bolt11, so the reconciler evicts it). Guard on + // complete+preimage; anything else stays pending for the caller/retry path. + const settledPreimage = r?.payment_preimage ?? r?.preimage; + if (r?.status === "complete" && settledPreimage) { + try { + await finalizeKeysend(r, p, settledPreimage); + outcome = "finalized-keysend"; + } catch (e: any) { + warn("finalizeKeysend threw", p.id, e?.message); + outcome = "keysend-returned (finalize threw)"; + } + } else { + outcome = "keysend-returned"; // not settled yet — caller polls for preimage + } + l("sendKeysend outcome", p.id, "=", outcome); + return r; + } catch (e: any) { + err("failed keysend", hash?.slice(0,16), "error:", shortError(e?.message)); + try { + if (startIndex === undefined) { + // Couldn't establish the index baseline, so we can't bound the search. + // Never refund a payment that may have settled — leave the debit. + warn("keysend threw with no sendpays baseline — NOT reversing (manual review)", p.id, pubkey); + outcome = "unverified-after-keysend-throw (not reversed)"; + } else { + const { payments = [] } = await ln.listsendpays({ + index: "created", + start: startIndex, + }); + const ours = payments.filter((x: any) => x.label === hash); + warn("sendpays after keysend-threw", p.id, JSON.stringify(ours.map((x: any) => ({ status: x.status })))); + + if (ours.some((x: any) => x.status === "complete")) { + // Settled despite the throw — reversing here is the drain. + outcome = "keysend-completed-despite-throw"; + } else if (!ours.length) { + // CLN records a sendpay BEFORE it sends the HTLC, so no record at all + // means nothing left the node (the common no-route/no-path failure). + // Safe — and necessary — to refund. + await reverse(p); + outcome = "reversed-after-keysend-throw (no htlc sent)"; + } else if (ours.every((x: any) => x.status === "failed")) { + await reverse(p); + outcome = "reversed-after-keysend-throw (confirmed failed)"; + } else { + // Still in flight: CLN may yet settle it, so leave the debit. + outcome = "pending-after-keysend-throw"; + } + } + } catch (verifyErr: any) { + warnThrottled("sendpays verification failed (keysend)", verifyErr?.message ?? String(verifyErr)); + outcome = "verify-failed-after-keysend-throw"; + } + warn("sendKeysend outcome", p.id, "=", outcome); + throw e; + } }; export const sendLightning = async ({ @@ -524,30 +959,86 @@ export const sendLightning = async ({ amount, fee = undefined, memo = undefined, + payerNote = undefined, + maxTotal = undefined, + exempt = false, }) => { let p; + // BOLT11 is case-insensitive, but downstream code is not: the stored payment + // hash, the `pending` reconciler entry, and check()'s startsWith("ln") test + // all assume lowercase. An uppercase-pasted invoice gets stored uppercase, + // then check() treats its pending entry as non-lightning and evicts it + // without reversing — a failed send that never gets auto-refunded. Normalize + // once, up front, so every downstream use sees the same lowercase form. + pr = pr.replace(/\s/g, "").toLowerCase(); + if (typeof amount !== "undefined") { - amount = parseInt(amount); + amount = Number.parseInt(amount); if (amount < 0 || amount > SATS || Number.isNaN(amount)) { warn("invalid amount", amount); fail("Invalid amount"); } } + let decoded = await ln.decode(pr); + + // A bolt12 offer (lno1...) isn't payable directly — fetch a real invoice + // from it over onion messages, then pay that. payerNote rides along in + // invreq_payer_note (NIP-177 bolt12 zaps put nostr:nip177:<intent-id> there). + if (decoded.type === "bolt12 offer") { + if (decoded.offer_currency) + fail("Currency-denominated offers are not supported"); + if (!decoded.offer_amount_msat && !amount) fail("Amount required"); + + const { invoice } = await ln.fetchinvoice({ + offer: pr, + amount_msat: decoded.offer_amount_msat ? undefined : amount * 1000, + payer_note: payerNote, + timeout: 60, + }); + + pr = invoice.replace(/\s/g, "").toLowerCase(); + decoded = await ln.decode(pr); + } + let { type, invoice_amount_msat, amount_msat, invoice_node_id, payee } = - await ln.decode(pr); + decoded; if (type.includes("bolt12")) { amount_msat = invoice_amount_msat; payee = invoice_node_id; } - let minfee = 2; - const { channels } = await ln.listpeerchannels(); - if (channels.some((c) => c.peer_id === payee)) minfee = 0; + const amt = amount_msat ? Math.round(amount_msat / 1000) : amount; + + // Ask askrene up front what routing this will cost — the same query xpay + // runs — so the maxfee we reserve is the real fee, not a guess. A caller + // that names its own cap keeps it (finalize refunds whatever xpay doesn't + // spend), but a cap below the quoted fee is rejected here, before any + // debit, instead of failing inside xpay after one. If the quote itself + // fails (askrene unavailable, no route) fall back to the old heuristic and + // let xpay have a go. + let quoted: number | undefined; + try { + ({ fee: quoted } = await quoteRoutingFee({ pr, amount: amt })); + } catch (e: any) { + warn("route quote failed", pr.substr(-8), shortError(e?.message)); + } - fee = Math.max(parseInt(fee || 0), minfee); - if (fee < 0) fail("Fee cannot be negative"); + const explicit = Number.parseInt(fee); + if (Number.isFinite(explicit)) { + if (explicit < 0) fail("Fee cannot be negative"); + if (quoted !== undefined && explicit < quoted) + fail(`Routing fee of ⚡️${quoted} required, max fee is ⚡️${explicit}`); + fee = explicit; + } else if (quoted !== undefined) { + fee = quoted; + } else { + let minfee = Math.max(5, Math.round(amt * 0.005)); + const { channels } = await ln.listpeerchannels(); + if (channels.some((c) => c.peer_id === payee)) minfee = 0; + fee = minfee; + } const { pays } = await ln.listpays(pr); if (pays.find((p) => p.status === "complete")) @@ -556,6 +1047,17 @@ export const sendLightning = async ({ if (pays.find((p) => p.status === "pending")) fail("Payment is already underway"); + // A prior attempt at this same invoice may still be awaiting reconciliation + // by check() (its CLN parts resolved but the debit hasn't been reversed or + // finalized yet). The pending set is keyed by bolt11 and shared across + // attempts, so letting a retry proceed here means the retry's own + // reverse()/finalize() clears the shared entry and ORPHANS the first + // attempt's debit — check() never sees it again and the user is never + // refunded. Make the retry wait until reconciliation clears the entry + // (seconds to ~a minute). + if (await db.sIsMember("pending", pr)) + fail("Previous attempt is still settling, please retry in a minute"); + p = await debit({ hash: pr, amount: amount_msat ? Math.round(amount_msat / 1000) : amount, @@ -563,11 +1065,17 @@ export const sendLightning = async ({ memo, user, type: PaymentType.lightning, + maxTotal, + exempt, }); await db.sAdd("pending", pr); - l("paying lightning invoice", pr.substr(-8), amount, fee); + // Instrumentation: track which exit path the payment took so we can + // diagnose any future stuck "optimistic" cases (confirmed=true with no + // ref, no pending-set membership). Logged just before the function + // returns; if "outcome=unsettled" appears in the logs we have a leak. + let outcome = "unknown"; try { const r = await ln.xpay({ @@ -575,20 +1083,133 @@ export const sendLightning = async ({ // bolt11: pr.replace(/\s/g, "").toLowerCase(), amount_msat: amount_msat ? undefined : amount * 1000, maxfee: fee * 1000, - retry_for: 10, + // 30s wasn't enough to get past a drained zero-fee hop. Route-finding + // prefers free channels, so xpay would spend the whole budget retrying + // one 0-base/0-ppm channel that is permanently empty in our direction + // and time out ("64 attempts, temporary_channel_failure for the same + // scid") without ever trying the paths that work — Strike/Zap payments + // failed this way for every user. xpay is in ln.ts's NO_TIMEOUT set and + // nothing in front of it (fastify, Caddy, the UI fetch) imposes a + // deadline, so the longer budget runs to completion. + // + // COUPLED TO check(): that reversal sweep must keep skipping payments + // for at least twice this window, or it reverses a payment xpay is + // still driving and we refund one that settles (the LEAKED DEBIT + // losses). Change both together. + retry_for: 60, + layers: await xpayLayers(), }); + // Only log the xpay response when there's a real concern — no preimage + // at all (xpay failed silently and we'll need to reverse) is interesting; + // failed_parts WITH a preimage is normal multi-path retry behavior and + // not worth warning about. The failed_parts branch below handles the + // recovery without log spam in the success case. + if (!(r?.payment_preimage ?? r?.preimage)) { + warn("xpay returned no preimage", p.id, JSON.stringify({ + status: r?.status, + failed_parts: r?.failed_parts, + parts: r?.parts, + amount_sent_msat: r?.amount_sent_msat, + amount_msat: r?.amount_msat, + })); + } + + if (r.failed_parts) { + // xpay didn't throw but reported failed parts. Verify with listpays + // before deciding — for multi-path payments this is the common path + // and usually finalizes cleanly; the noisy warns only fire when + // listpays disagrees with xpay or finalize itself throws. + try { + const { pays } = await ln.listpays(pr); + const completed = pays.find((x) => x.status === "complete"); + const failed = !pays.length || pays.every((x) => x.status === "failed"); + if (completed) { + try { await finalize(completed, p); outcome = "finalized-via-listpays-after-failed_parts"; } catch (e: any) { warn("finalize threw in failed_parts branch", p.id, e?.message); outcome = "finalize-threw-failed_parts"; } + } else if (failed) { + warn("xpay failed_parts and listpays failed — reversing", p.id); + await reverse(p); outcome = "reversed-via-failed_parts"; + } else { + warn("xpay failed_parts, listpays still pending — leaving for check()", p.id); + outcome = "pending-after-failed_parts"; + } + } catch (verifyErr: any) { + warnThrottled("listpays verification failed (failed_parts branch)", verifyErr?.message ?? String(verifyErr)); + outcome = "verify-failed-after-failed_parts"; + } + } else { + try { + p = await finalize(r, p); + outcome = "finalized"; + } catch (e: any) { + warn("finalize threw despite no failed_parts", p.id, e?.message); + outcome = "finalize-threw"; + } + } + } catch (e: any) { + err("failed to pay", pr.substr(-8), "xpay error:", shortError(e?.message)); try { - if (!r.failed_parts) p = await finalize(r, p); - } catch (e) { - warn("failed to process payment", p.id); + const { pays } = await ln.listpays(pr); + warn("listpays after xpay-threw", p.id, JSON.stringify(pays?.map((x) => ({ status: x.status, amount_sent_msat: x.amount_sent_msat })) ?? [])); + const completed = pays.find((p) => p.status === "complete"); + const failed = !pays.length || pays.every((p) => p.status === "failed"); + if (completed) { + warn("payment completed despite error, finalizing", p.id); + try { await finalize(completed, p); outcome = "finalized-after-xpay-throw"; } catch (_) { outcome = "finalize-threw-after-xpay-throw"; } + } else if (failed) { + await reverse(p); outcome = "reversed-after-xpay-throw"; + } else { + outcome = "pending-after-xpay-throw"; + } + // else: pending — leave the debit in place; check() loop will reconcile + // once CLN confirms outcome. Reversing while still in flight would + // refund the user even though the payment may yet complete. + } catch (verifyErr: any) { + // listpays itself errored (CLN unreachable). We deliberately don't + // reverse here — the debit stays and pending stays in the reconciler + // set, so check() will retry once CLN is back. Surface the failure so + // a real outage doesn't go unnoticed. + warnThrottled("listpays verification failed", verifyErr?.message ?? String(verifyErr)); + outcome = "verify-failed-after-xpay-throw"; } - } catch (e) { - err("failed to pay", pr.substr(-8)); - reverse(p); + warn("sendLightning outcome", p.id, "=", outcome); throw e; } + // Leak guard: re-read the db record before deciding. If the payment has + // ref set in db, it's settled regardless of which code path threw — + // finalize() may have set ref via db.set and then thrown on a later line + // (e.g. balance.incrBy for the fee refund), but the payment itself is + // recorded as complete. Only treat as a real leak if ref is not in db. + const finalizedOutcomes = new Set([ + "finalized", + "finalized-via-listpays-after-failed_parts", + "finalized-after-xpay-throw", + ]); + const safeNonFinalized = new Set([ + "reversed-via-failed_parts", + "reversed-after-xpay-throw", + "pending-after-failed_parts", + "pending-after-xpay-throw", + "verify-failed-after-failed_parts", + "verify-failed-after-xpay-throw", + ]); + const persisted = await g(`payment:${p.id}`); + const refInDb = !!persisted?.ref; + if (refInDb || finalizedOutcomes.has(outcome)) { + // Only log when the outcome wasn't the boring success case — every + // payment goes through "finalized" so logging it is just noise. The + // recovery-via-fallback variants (finalized-via-listpays-*, etc.) are + // worth knowing about, so emit those. + if (outcome !== "finalized") { + l("sendLightning outcome", p.id, "=", outcome, refInDb && !finalizedOutcomes.has(outcome) ? "(ref set in db despite throw)" : ""); + } + } else if (safeNonFinalized.has(outcome)) { + warn("sendLightning outcome", p.id, "=", outcome); + } else { + err("LEAKED DEBIT in sendLightning", p.id, "outcome=", outcome, "user=", user?.username, "amount=", amount, "fee=", fee); + } + return p; }; @@ -598,6 +1219,7 @@ export const sendInternal = async ({ memo = undefined, recipient, sender, + maxTotal = undefined, }) => { if (!invoice) invoice = await generate({ @@ -606,8 +1228,8 @@ export const sendInternal = async ({ }); const { hash } = invoice; - const p = await debit({ hash, amount, memo, user: sender }); - await credit({ hash, amount, memo, ref: sender.id }); + const p = await debit({ hash, amount, memo, user: sender, maxTotal }); + await credit({ hash, amount, memo, ref: sender.id, tip: p.tip }); if (invoice.memo?.includes("9734")) { const { invoices } = await ln.listinvoices({ invstring: hash }); @@ -628,7 +1250,16 @@ const getAddressType = async (a) => { try { await lq.getAddressInfo(a); return PaymentType.liquid; - } catch (e) { + } catch (e: any) { + // Distinguish "lq is down" from "address isn't liquid" so users get a useful + // message instead of "unrecognized address" when cs/lq is the actual problem. + if ( + e?.message?.includes("Unable to connect") || + e?.code === "ECONNREFUSED" || + e?.code === "ETIMEDOUT" + ) { + fail("liquid temporarily unavailable"); + } fail("unrecognized address"); } } @@ -643,11 +1274,11 @@ export const build = async ({ user, }) => { const type = await getAddressType(address); - if (!aid) aid = user.id; + aid = await requireAccount({ aid, user }); const node = aid === user.id ? rpc(config[type]) : rpc({ ...config[type], wallet: aid }); - amount = parseInt(amount); + amount = Number.parseInt(amount); if (amount < 0) fail("invalid amount"); const fees: any = @@ -655,13 +1286,10 @@ export const build = async ({ ? { fastestFee: 0.1, halfHourFee: 0.1, hourFee: 0.1 } : await fetch(`${api[type]}/fees/recommended`).then((r) => r.json()); - fees.hourFee = fees.halfHourFee; - fees.halfHourFee = fees.fastestFee; - if (type === PaymentType.bitcoin) { - fees.fastestFee = Math.round(fees.fastestFee * 1.1); - if (fees.fastestFee === fees.halfHourFee) fees.fastestFee++; - if (fees.hourFee === fees.halfHourFee) fees.hourFee--; + fees.hourFee = fees.halfHourFee; + fees.halfHourFee = fees.fastestFee; + fees.fastestFee = Math.ceil(fees.fastestFee * 1.5); } if (!feeRate) { @@ -723,7 +1351,12 @@ export const build = async ({ } const inputs = []; - const { vin } = await node.decodeRawTransaction(tx.hex); + const { vin, txid: built } = await node.decodeRawTransaction(tx.hex); + + // Receipt for sendOnchain(): this account composed this exact tx. Bound to + // aid so one account can't replay another's build, and short-lived so a hex + // can't be banked for later. + await db.set(`build:${built}`, aid, { EX: 3600 }); for (const { txid, vout } of vin) { const rawTx = await node.getRawTransaction(txid); @@ -738,24 +1371,35 @@ export const build = async ({ inputs.push({ witnessUtxo, path }); } + return { feeRate, ourfee, fee, fees, hex: tx.hex, inputs, subtract }; }; export const catchUp = async () => { try { const txns = []; + // Each chain is isolated: one being unreachable (e.g. lq when cs is down) + // must not stop the other chain's deposit-recovery loop. for (const [type, n] of Object.entries({ bitcoin: bc, liquid: lq })) { - txns.push( - ...(await n.listTransactions("*", 10)).filter((tx) => { - tx.type = type; - return tx.category === "receive" && tx.confirmations > 0; - }), - ); + try { + txns.push( + ...(await n.listTransactions("*", 100)).filter((tx) => { + tx.type = type; + return tx.category === "receive" && tx.confirmations > 0; + }), + ); + } catch (e: any) { + warnThrottled(`catchUp ${type}`, e.message); + } } for (const { txid, type } of txns) { try { if (await db.zScore("seen", txid)) continue; + // walletnotify is the primary path; catchUp is a safety net. The + // double-call I added 2026-05-05 was for when walletnotify was broken + // on cs's lq — now that's fixed, a single call suffices and avoids + // contributing to /confirm race conditions. await got.post(`http://localhost:${process.env.PORT || 3119}/confirm`, { json: { txid, wallet: config[type].wallet, type }, }); @@ -771,66 +1415,7 @@ export const catchUp = async () => { err("problem syncing", e.message); } - setTimeout(catchUp, 10000); -}; - -export const reconcile = async (account, initial = false) => { - try { - const { descriptors, id, uid, type } = account; - const user = await getUser(uid); - const node = rpc({ ...config[type], wallet: id }); - - let total; - - if (initial) { - const progress = await node.scanTxOutSet("status"); - if (progress) return setTimeout(() => reconcile(account, initial), 1000); - - const { total_amount } = await node.scanTxOutSet("start", descriptors); - total = Math.round(total_amount * SATS); - } else { - total = Math.round((await node.getBalance({ minconf: 1 })) * SATS); - } - - const { balanceAdjustment: memo } = t(user); - - const balance = await g(`balance:${id}`); - - const amount = Math.abs(total - balance); - const hash = v4(); - - if (total > balance) { - const inv = { - memo, - type: PaymentType.reconcile, - hash, - amount, - uid, - aid: id, - }; - await s(`invoice:${hash}`, inv); - await credit({ - hash, - amount, - type: PaymentType.reconcile, - aid: id, - }); - } else if (total < balance) { - await debit({ - aid: id, - amount, - hash: v4(), - memo, - user, - type: PaymentType.reconcile, - }); - } - } catch (e) { - console.log(e); - warn("problem reconciling", e.message, account); - if (e.message.includes("progress")) - return setTimeout(() => reconcile(account, initial), 1000); - } + setTimeout(catchUp, 30000); }; export const check = async () => { @@ -839,8 +1424,34 @@ export const check = async () => { const payments = await db.sMembers("pending"); for (const pr of payments) { + // Case-insensitive: an uppercase-pasted invoice ("LNBC…") is still a + // lightning payment. Matching case-sensitively here used to evict such + // entries without reversing, stranding failed sends with no auto-refund. + if (!pr.toLowerCase().startsWith("ln")) { + await db.sRem("pending", pr); + continue; + } const p = await getPayment(pr); - if (!p || Date.now() - p.created < 10000) continue; + // Skip payments sendLightning may still be actively driving. xpay retries + // for 60s (retry_for: 60), during which listpays can momentarily show all + // attempts "failed" before the winning part lands. The old 10s threshold + // let check() reverse/refund such a payment mid-flight; it then completed, + // and sendLightning's finalize threw on the deleted record — refunding a + // payment that actually settled (the LEAKED DEBIT losses). Wait well past + // the retry window so sendLightning has finished finalize()/reverse() and + // removed it from `pending` before check() ever touches it. This is 2x + // retry_for, the same margin the 60s threshold gave the old 30s window — + // raise it with retry_for if that ever changes again. + // No record anywhere (main db or archive) means there's nothing left to + // reconcile — the debit was already reversed or the entry is a stale + // stray. Drop it: sendLightning now refuses to re-pay an invoice while + // its bolt11 sits in this set, so a dead entry would otherwise block + // that invoice forever. + if (!p) { + await db.sRem("pending", pr); + continue; + } + if (Date.now() - p.created < 120000) continue; const { pays } = await ln.listpays(pr); const failed = !pays.length || pays.every((p) => p.status === "failed"); @@ -851,13 +1462,16 @@ export const check = async () => { else if (failed) await reverse(p); } catch (e) { err("failed to finalize", p.id, e.message); + if (e.message?.includes("already been reversed")) { + await db.sRem("pending", p.hash); + } } } } catch (e) { err("payment check failed", e.message); } - setTimeout(check, 2000); + setTimeout(check, 5000); }; const finalize = async (r, p) => { @@ -866,21 +1480,89 @@ const finalize = async (r, p) => { if (!preimage) fail("missing preimage"); await db.sRem("pending", p.hash); - l("payment completed", p.id, r.payment_preimage); nwcNotify(p); const maxfee = p.fee; - const { amount_msat } = await ln.decode(p.hash); - p.fee = Math.round((r.amount_sent_msat - amount_msat) / 1000); + // Compute the actual fee xpay paid (sent − invoice amount, both msat). + // Robust against zero-amount invoices (decoded amount_msat is undefined, + // fall back to r.amount_msat) and against CLN occasionally returning + // unexpected response shapes — if we can't compute a finite integer + // fee, keep p.fee at the originally-reserved maxfee so the subsequent + // refund delta is 0 instead of NaN (which would crash db.incrBy with + // "value is not an integer or out of range" and leave finalize in a + // partial state). + const decoded = await ln.decode(p.hash); + const invMsat = Number(decoded.amount_msat ?? r.amount_msat); + const sentMsat = Number(r.amount_sent_msat); + // Round UP: the routing fee was quoted and reserved in whole sats (a + // 301 msat route is a 1 sat maxfee — xpay's cap is exact and a few msat + // short fails the payment), so charge the sat that was quoted rather than + // rounding 301 msat to 0 and refunding it. Otherwise a "send everything" + // computed from the quote strands a single sat in the account. + const computedFee = Math.ceil((sentMsat - invMsat) / 1000); + if (!Number.isFinite(computedFee)) { + warn("finalize: non-finite fee compute, keeping reserved maxfee", p.id, + "sent_msat=", r.amount_sent_msat, "inv_msat=", decoded.amount_msat ?? r.amount_msat); + } + p.fee = Number.isFinite(computedFee) ? computedFee : maxfee; p.ref = preimage; + // The HTLC settled (we have the preimage) — the send is now fully confirmed. + p.confirmed = true; + + const current = await g(`payment:${p.id}`); + // The record is gone because a concurrent reverse() already refunded this + // payment — yet here we are finalizing it, so it actually COMPLETED on the + // network and the refund was wrong (coinos is now short the amount). Do NOT + // silently re-create the record; raise a clear, greppable error so the + // LEAKED DEBIT guard logs it for manual recovery instead of a cryptic + // null-deref. (The check()-window fix should make this path very rare.) + if (!current) fail(`finalize: payment ${p.id} reversed-then-completed (double-pay leak)`); - if (!(await g(`payment:${p.id}`)).ref) { + if (!current.ref) { await s(`payment:${p.id}`, p); + const refund = maxfee - p.fee; + if (Number.isFinite(refund) && Number.isInteger(refund)) { + await db.incrBy(`balance:${p.uid}`, refund); + } else { + warn("finalize: skipping fee refund (non-integer delta)", p.id, + "maxfee=", maxfee, "p.fee=", p.fee, "refund=", refund); + } + } - l("refunding fee", maxfee, p.fee, maxfee - p.fee, p.ref); - await db.incrBy(`balance:${p.uid}`, maxfee - p.fee); + return p; +}; + +// Keysend variant of finalize(). Keysend has no bolt11, so finalize()'s +// `ln.decode(p.hash)` can't run — the real fee is instead the msat actually sent +// minus the record's own amount. Otherwise identical: flip pending->confirmed, +// set the preimage, refund the unused fee reserve, and refuse to re-create a +// record a concurrent reverse() already removed (a settled-but-refunded payment +// is a double-pay leak, surfaced for manual recovery rather than papered over). +const finalizeKeysend = async (r, p, preimage) => { + await db.sRem("pending", p.hash); // keysend rarely enters the set; defensive + + const maxfee = p.fee; + const sentMsat = Number(r.amount_sent_msat); + const actualFee = Math.ceil((sentMsat - Math.abs(p.amount) * 1000) / 1000); + p.fee = Number.isFinite(actualFee) && actualFee >= 0 ? actualFee : maxfee; + p.ref = preimage; + p.confirmed = true; + + const current = await g(`payment:${p.id}`); + if (!current) fail(`finalizeKeysend: payment ${p.id} reversed-then-completed (double-pay leak)`); + + if (!current.ref) { + await s(`payment:${p.id}`, p); + const refund = maxfee - p.fee; + if (Number.isFinite(refund) && Number.isInteger(refund)) { + await db.incrBy(`balance:${p.uid}`, refund); + } else { + warn("finalizeKeysend: skipping fee refund (non-integer delta)", p.id, + "maxfee=", maxfee, "p.fee=", p.fee, "refund=", refund); + } } + nwcNotify(p); return p; }; @@ -889,7 +1571,13 @@ const reverse = async (p) => { const total = Math.abs(p.amount) + p.fee + p.ourfee; const ourfee = p.ourfee || 0; - const credit = Math.round(total * config.fee[PaymentType.lightning]) - ourfee; + // Restore the free-tier credit consumed at debit time. Use the SAME rate the + // send was charged at — flagged `ln:nofree` accounts were charged the elevated + // rate, so reversing with the standard rate would push their credit negative. + const rate = (await g(`ln:nofree:${p.uid}`)) + ? config.fee.lightningHigh + : config.fee.lightning; + const credit = Math.round(total * rate) - ourfee; l("reversing", p.id, p.amount, p.fee, total, ourfee, credit); @@ -909,27 +1597,70 @@ const reverse = async (p) => { }; const freezeCheck = async () => { - const funds = await ln.listfunds(); - const lnbalance = Math.round( - funds.channels.reduce((a, b) => a + b.our_amount_msat, 0) / 1000, - ); - - const bcbalance = Math.round((await bc.getBalance()) * SATS); - const { bitcoin } = await lq.getBalance(); - const lqbalance = Math.round(bitcoin * SATS); - - const lnthreshold = await g("lightning:threshold"); - const bcthreshold = await g("bitcoin:threshold"); - const lqthreshold = await g("liquid:threshold"); + // Crash-resilient loop: ANY error in the body (an unhandled rejection in the + // limit-write section, a stuck withLimitLock, etc.) used to escape here and + // kill the loop silently (the global unhandledRejection handler is a no-op), + // freezing every ${type}:limit until someone noticed sends failing. Wrap the + // whole body so errors are logged and the next tick is ALWAYS scheduled. + try { + // Each asset is fetched independently so e.g. lq being unreachable doesn't + // prevent lightning and bitcoin limits from refreshing. + let lnbalance: number | undefined; + try { + const funds = await ln.listfunds(); + // Only spendable channels — ONCHAIN/CLOSING funds are locked in pending + // sweeps and aren't usable for lightning sends, so counting them inflates + // the limit. CHANNELD_AWAITING_SPLICE is included so an in-flight splice + // doesn't briefly drop the limit to zero. + lnbalance = Math.round( + funds.channels + .filter((c) => + c.state === "CHANNELD_NORMAL" || c.state === "CHANNELD_AWAITING_SPLICE", + ) + .reduce((a, b) => a + b.our_amount_msat, 0) / 1000, + ); + } catch (e: any) { + warnThrottled("freezeCheck lightning", e.message); + } - await s("lightning:limit", Math.max(lnbalance - lnthreshold, 0)); - await s("bitcoin:limit", Math.max(bcbalance - bcthreshold, 0)); - await s("liquid:limit", Math.max(lqbalance - lqthreshold, 0)); + let bcbalance: number | undefined; + try { + bcbalance = Math.round((await bc.getBalance()) * SATS); + } catch (e: any) { + warnThrottled("freezeCheck bitcoin", e.message); + } - await s("fund:limit", Math.max(lnbalance - lnthreshold, 0)); - await s("ecash:limit", Math.max(lnbalance - lnthreshold, 0)); - await s("bolt12:limit", Math.max(lnbalance - lnthreshold, 0)); + let lqbalance: number | undefined; + try { + const { bitcoin } = await lq.getBalance(); + lqbalance = Math.round(bitcoin * SATS); + } catch (e: any) { + warnThrottled("freezeCheck liquid", e.message); + } - setTimeout(freezeCheck, 10000); + if (lnbalance !== undefined) { + const lnthreshold = await g("lightning:threshold"); + const lim = Math.max(lnbalance - lnthreshold, 0); + for (const t of ["lightning", "fund", "ecash", "bolt12"]) { + await withLimitLock(t, async () => { await s(`${t}:limit`, lim); }); + } + } + if (bcbalance !== undefined) { + const bcthreshold = await g("bitcoin:threshold"); + await withLimitLock("bitcoin", async () => { + await s("bitcoin:limit", Math.max(bcbalance - bcthreshold, 0)); + }); + } + if (lqbalance !== undefined) { + const lqthreshold = await g("liquid:threshold"); + await withLimitLock("liquid", async () => { + await s("liquid:limit", Math.max(lqbalance - lqthreshold, 0)); + }); + } + } catch (e: any) { + warn("freezeCheck: iteration failed, will retry next tick:", e?.message ?? String(e)); + } finally { + setTimeout(freezeCheck, 10000); + } }; freezeCheck(); diff --git a/lib/rates.ts b/lib/rates.ts index dcb44464..21a075b3 100644 --- a/lib/rates.ts +++ b/lib/rates.ts @@ -8,6 +8,18 @@ import WebSocket from "ws"; export let rate; let last; let ws; + +// The Iranian market IRR is fetched on its own timer rather than on every binance +// tick — fetching inline hammered the source and, when it's unreachable (recurring +// geo-block / outage), failed + logged on every tick. We cache the last-good value +// and apply it in onmessage; the tick loop never blocks on it. +// Source: Wallex (api.wallex.ir) BTCTMN last price. We switched off Nobitex on +// 2026-07-10 after apiv2.nobitex.ir became unreachable from our egress (its +// Iranian IPs refuse foreign connections); Wallex's API is reachable and its +// BTCTMN cross-checks against BTCUSDT × USDTTMN to <0.1%. +let iranIrr = 0; +let iranIrrTime = 0; +let iranErrLogged = 0; const connect = async () => { if (ws && ws.readyState === 1 && Date.now() - last < 5000) return; if (ws) ws.terminate() && (await sleep(Math.round(Math.random() * 1000))); @@ -25,20 +37,20 @@ const connect = async () => { rates[symbol] = msg.c * fx[symbol]; }); - try { - rates.IRT = ( - (await got( - "https://api.nobitex.ir/v2/orderbook/BTCIRT", - ).json()) as any - ).lastTradePrice; - } catch (e) {} + // Apply the Iranian market IRR cached by updateIranRate() below (Wallex). + // iranIrr is stored in Rial, so IRR = iranIrr and Toman = Rial / 10. If the + // source has been unreachable for over an hour, we leave the fixer official + // IRR set in the fx loop above rather than serving an ever-staler market rate. + if (iranIrr > 0 && Date.now() - iranIrrTime < 60 * 60 * 1000) { + rates.IRR = iranIrr; + rates.IRT = iranIrr / 10; + } rate = msg.c; s("rate", rate); s("rates", rates); last = Date.now(); } catch (e) { - console.log(e); err("binance message error", e.message); } }; @@ -50,6 +62,31 @@ const connect = async () => { return ws; }; +// Fetch the Iranian market rate from Wallex on its own 60s timer, decoupled from +// the binance tick loop. Wallex quotes BTCTMN in Toman, so IRR (Rial) = price*10. +// Caches the last-good value; on failure (recurring geo-block / outage) throttles +// error logging to once per 5 min instead of spamming on every attempt. A 5s +// request timeout keeps a hung endpoint from piling up. +const updateIranRate = async () => { + try { + const data = (await got("https://api.wallex.ir/v1/markets", { + timeout: { request: 5000 }, + }).json()) as any; + const tmn = Number(data?.result?.symbols?.BTCTMN?.stats?.lastPrice); + if (tmn > 0) { + iranIrr = tmn * 10; // Toman -> Rial + iranIrrTime = Date.now(); + } + } catch (e) { + if (Date.now() - iranErrLogged > 5 * 60 * 1000) { + err("Iran IRR/IRT rate fetch failed (wallex)", e.message); + iranErrLogged = Date.now(); + } + } + setTimeout(updateIranRate, 60000); +}; +updateIranRate(); + export const getFx = async () => { connect(); diff --git a/lib/register.ts b/lib/register.ts index 0db5dfd0..a9e9d7f7 100644 --- a/lib/register.ts +++ b/lib/register.ts @@ -2,7 +2,9 @@ import config from "$config"; import countries from "$lib/countries"; import { db, s } from "$lib/db"; import { l, warn } from "$lib/logging"; -import { fail, getUser } from "$lib/utils"; +import { fail } from "$lib/utils"; +import { assertNameFree } from "$lib/names"; +import { isReserved } from "$lib/reserved"; import { bytesToHex, randomBytes } from "@noble/hashes/utils"; import { got } from "got"; import { getPublicKey, nip19 } from "nostr-tools"; @@ -19,20 +21,26 @@ export default async (user, ip) => { username = username.toLowerCase().replace(/\s/g, ""); if (!valid.test(username)) fail("Usernames can only have letters and numbers"); + if (isReserved(username)) fail("Invalid username"); + if (username.includes("undefined")) fail("Invalid username"); const id = v4(); user.id = id; const exists = await db.exists(`user:${username}`); if (exists) fail(`Username ${username} taken`); + // A name the v3 registrar holds is taken too (see lib/names.ts). + await assertNameFree(username, pubkey); if (password) { user.password = await Bun.password.hash(password, { algorithm: "bcrypt", - cost: 4, + cost: 12, }); } + user.created = Date.now(); + user.currency = "USD"; if (config.ipregistry) { try { @@ -43,6 +51,7 @@ export default async (user, ip) => { ).json(); user.currency = countries[code]; + user.country = code; // stored for analytics (signup geography) } catch (e) { warn("unable to detect country from IP", username); } @@ -85,8 +94,6 @@ export default async (user, ip) => { await s(`app:${app.pubkey}`, app); await db.sAdd(`${id}:apps`, app.pubkey); - console.log("USER", user); - db.multi() .set(`user:${id}`, JSON.stringify(user)) .set(`user:${username}`, id) diff --git a/lib/reserved.ts b/lib/reserved.ts new file mode 100644 index 00000000..df5f0d43 --- /dev/null +++ b/lib/reserved.ts @@ -0,0 +1,31 @@ +// Usernames nobody may register or rename into. A coinos username is also a +// lightning address, so a name that reads as ours lets an account collect +// payments meant for the service: "mint" was claimed within days of the ecash +// redesign freeing it (2026-09-18) and took payments addressed to +// [email protected] until it was renamed. +// +// Checked on registration (lib/register.ts) and on rename (routes/users.ts). +// Names already held by the v3 registrar are refused separately, by +// assertNameFree in lib/names.ts. +export const reserved = [ + "admin", + "administrator", + "ark", + "cash", + "coinos", + "ecash", + "fund", + "help", + "info", + "lightning", + "mint", + "pool", + "root", + "security", + "support", + "system", + "wallet", +]; + +export const isReserved = (username: string) => + reserved.includes(String(username || "").toLowerCase().replace(/\s/g, "")); diff --git a/lib/safe-fetch.ts b/lib/safe-fetch.ts new file mode 100644 index 00000000..5b1059e9 --- /dev/null +++ b/lib/safe-fetch.ts @@ -0,0 +1,165 @@ +// SSRF-resistant fetch for user-supplied URLs (lnurl / lightning-address +// resolution in routes/lnurl.ts). Rejects any host that resolves to a +// loopback, private, link-local, CGNAT, or cloud-metadata address. +// +// Unlike a resolve-then-fetch guard (which re-resolves DNS when it actually +// connects, leaving a DNS-rebinding TOCTOU window), this pins the socket to the +// exact IP it validated: the connection's DNS lookup IS the validation, in one +// callback, so a low-TTL rebind cannot swap in an internal IP after the check. +// Redirects are followed manually and each hop is re-validated the same way. +// Scheme is restricted to http(s); 8s timeout; 2MB streamed-body cap. +import http from "node:http"; +import https from "node:https"; +import dns from "node:dns"; +import net from "node:net"; + +const MAX_BYTES = 2_000_000; +const MAX_REDIRECTS = 4; +const TIMEOUT_MS = 8000; + +const blockedV4 = (ip: string): boolean => { + const p = ip.split(".").map(Number); + if (p.length !== 4 || p.some((n) => Number.isNaN(n))) return true; + const [a, b] = p; + if (a === 0) return true; // 0.0.0.0/8 + if (a === 10) return true; // private + if (a === 127) return true; // loopback + if (a === 169 && b === 254) return true; // link-local + cloud metadata (169.254.169.254) + if (a === 172 && b >= 16 && b <= 31) return true; // private + if (a === 192 && b === 168) return true; // private + if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT 100.64/10 (incl. tailscale) + if (a >= 224) return true; // multicast / reserved + return false; +}; + +// Expand an IPv6 address to its 8 16-bit groups. Handles "::" and a trailing +// dotted quad. new URL() re-serializes IPv6 hosts in hex, so "[::ffff:10.0.0.1]" +// arrives here as "::ffff:a00:1" — matching on the text form is not enough. +const v6Groups = (ip: string): number[] | null => { + let s = ip.toLowerCase().split("%")[0]; + const quad = s.match(/(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (quad) { + const [a, b, c, d] = quad.slice(1).map(Number); + s = `${s.slice(0, quad.index)}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`; + } + const halves = s.split("::"); + if (halves.length > 2) return null; + const part = (h: string) => (h ? h.split(":").map((x) => Number.parseInt(x, 16)) : []); + const head = part(halves[0]); + const tail = halves.length === 2 ? part(halves[1]) : []; + const fill = 8 - head.length - tail.length; + if (fill < 0 || (halves.length === 1 && fill !== 0)) return null; + const g = [...head, ...Array(fill).fill(0), ...tail]; + return g.some((n) => Number.isNaN(n) || n < 0 || n > 0xffff) ? null : g; +}; + +const v4Of = (hi: number, lo: number) => + `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`; + +const blockedV6 = (ip: string): boolean => { + const g = v6Groups(ip); + if (!g) return true; + const zero = (from: number, to: number) => g.slice(from, to).every((n) => n === 0); + if (zero(0, 8)) return true; // :: unspecified + if (zero(0, 7) && g[7] === 1) return true; // ::1 loopback + // Every form that carries an IPv4 address is judged by that address. + if (zero(0, 5) && g[5] === 0xffff) return blockedV4(v4Of(g[6], g[7])); // ::ffff:0:0/96 mapped + if (zero(0, 4) && g[4] === 0xffff && g[5] === 0) return blockedV4(v4Of(g[6], g[7])); // ::ffff:0:0:0/96 translated + if (zero(0, 6)) return blockedV4(v4Of(g[6], g[7])); // ::/96 IPv4-compatible + if (g[0] === 0x64 && g[1] === 0xff9b) return blockedV4(v4Of(g[6], g[7])); // 64:ff9b::/96, 64:ff9b:1::/48 NAT64 + if (g[0] === 0x2002) return blockedV4(v4Of(g[1], g[2])); // 2002::/16 6to4 + if (g[0] === 0x2001 && g[1] === 0) return true; // 2001::/32 Teredo + if ((g[0] & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local + if ((g[0] & 0xffc0) === 0xfec0) return true; // fec0::/10 site-local + if ((g[0] & 0xfe00) === 0xfc00) return true; // fc00::/7 ULA + if ((g[0] & 0xff00) === 0xff00) return true; // ff00::/8 multicast + if (g[0] === 0x0100 && zero(1, 4)) return true; // 100::/64 discard + return false; +}; + +const isBlockedIp = (ip: string): boolean => { + if (net.isIPv4(ip)) return blockedV4(ip); + if (net.isIPv6(ip)) return blockedV6(ip); + return true; // not a recognizable IP -> block +}; + +// A drop-in for the Node dns.lookup used by http(s).request: resolves the host, +// rejects the whole connection if ANY resolved address is blocked (so an +// attacker can't return [public, private] and hope the stack picks the private +// one), and otherwise hands the socket the first allowed address. Because the +// address returned here is the one the socket connects to, there is no +// resolve/connect gap for a rebind to slip through. +const pinnedLookup = ( + hostname: string, + options: any, + cb: (err: Error | null, address?: any, family?: number) => void, +): void => { + // hostname may be an IP literal (redirect to a raw IP) — validate directly. + if (net.isIP(hostname)) { + if (isBlockedIp(hostname)) return cb(new Error(`blocked host ${hostname}`)); + const fam = net.isIPv6(hostname) ? 6 : 4; + return cb(null, options?.all ? [{ address: hostname, family: fam }] : hostname, fam); + } + dns.lookup(hostname, { all: true }, (err, addrs: any[]) => { + if (err) return cb(err); + if (!addrs?.length) return cb(new Error(`cannot resolve ${hostname}`)); + const bad = addrs.find((a) => isBlockedIp(a.address)); + if (bad) return cb(new Error(`blocked host ${hostname} -> ${bad.address}`)); + const a = addrs[0]; + if (options?.all) cb(null, [{ address: a.address, family: a.family }]); + else cb(null, a.address, a.family); + }); +}; + +const fetchOnce = (url: string, redirectsLeft: number): Promise<any> => + new Promise((resolve, reject) => { + let u: URL; + try { u = new URL(url); } catch { return reject(new Error("invalid url")); } + if (u.protocol !== "http:" && u.protocol !== "https:") + return reject(new Error("unsupported scheme")); + + // CRITICAL: Node skips the custom `lookup` when the URL host is already an + // IP literal, so pinnedLookup alone would not see a raw-IP SSRF target. + // Validate any literal host up front (strip IPv6 brackets first). + const literal = u.hostname.replace(/^\[|\]$/g, ""); + if (net.isIP(literal) && isBlockedIp(literal)) + return reject(new Error(`blocked host ${literal}`)); + + const mod = u.protocol === "https:" ? https : http; + const req = mod.request( + url, + { method: "GET", lookup: pinnedLookup as any, headers: { accept: "application/json" } }, + (res) => { + const status = res.statusCode || 0; + + // manual redirect handling — re-validate each hop via pinnedLookup + if (status >= 300 && status < 400 && res.headers.location) { + res.resume(); // drain + if (redirectsLeft <= 0) return reject(new Error("too many redirects")); + let next: string; + try { next = new URL(res.headers.location, url).toString(); } + catch { return reject(new Error("bad redirect target")); } + return resolve(fetchOnce(next, redirectsLeft - 1)); + } + + let len = 0; + const chunks: Buffer[] = []; + res.on("data", (c: Buffer) => { + len += c.length; + if (len > MAX_BYTES) { req.destroy(); reject(new Error("response too large")); return; } + chunks.push(c); + }); + res.on("end", () => { + try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); } + catch { reject(new Error("invalid json response")); } + }); + }, + ); + req.setTimeout(TIMEOUT_MS, () => req.destroy(new Error("request timed out"))); + req.on("error", reject); + req.end(); + }); + +// Fetch a user-supplied URL with SSRF protection and return the parsed JSON +// body. Throws on a bad scheme/URL or a host that resolves into a blocked range. +export const safeGot = async (url: string): Promise<any> => fetchOnce(url, MAX_REDIRECTS); diff --git a/lib/sockets.ts b/lib/sockets.ts index 028551ed..610d38fd 100644 --- a/lib/sockets.ts +++ b/lib/sockets.ts @@ -1,9 +1,21 @@ +import config from "$config"; import store from "$lib/store"; import jwt from "jsonwebtoken"; import { v4 } from "uuid"; import { err, warn } from "$lib/logging"; import { fail, getUser } from "$lib/utils"; +// Verify the token's signature — never trust jwt.decode() here. A decode-only +// gate let anyone forge {id: <victim uid>} (alg:none or any key) and register +// their socket under the victim's uid, receiving every emit for that account. +const verifyToken = (token) => { + try { + return jwt.verify(token, config.jwt); + } catch (e) { + return null; + } +}; + const code = 1000; const all = {}; const subscriptions = []; @@ -23,6 +35,9 @@ export const emit = (uid, type, data) => { if (!store.sockets[uid]) return; for (const id in store.sockets[uid]) { const ws = store.sockets[uid][id]; + // Read-only sockets (POS terminals logged in with a "-ro" token) only + // get payment notifications, mirroring the HTTP read-only whitelist. + if (ws.readonly && type !== "payment") continue; ws.send(JSON.stringify({ type, data })); } }; @@ -44,9 +59,16 @@ export const broadcast = (type, data) => { const track = async (ws, token) => { const { id } = ws; - const { id: uid } = jwt.decode(token); + let { id: uid } = verifyToken(token) || {}; if (!uid) fail("Invalid JWT token"); + + // Read-only tokens (see users.ro) carry "<uid>-ro". The HTTP auth layer + // strips the suffix for whitelisted routes; do the same here so a POS + // device can log in and receive payment events, but nothing else. + const readonly = uid.endsWith("-ro"); + if (readonly) uid = uid.slice(0, -3); + const user = await getUser(uid); if (!user) fail(`User not found ${uid}`); @@ -61,6 +83,7 @@ const track = async (ws, token) => { store.sockets[uid][id] = ws; users[id] = uid; ws.user = user; + ws.readonly = readonly; }; Bun.serve({ @@ -97,7 +120,7 @@ Bun.serve({ case "login": try { - if (!data || data === "null" || !jwt.decode(data)) + if (!data || data === "null" || !verifyToken(data)) return ws.close(code, `bad token ${data}`); await track(ws, data); diff --git a/lib/spend-budget.ts b/lib/spend-budget.ts new file mode 100644 index 00000000..3f2a70ac --- /dev/null +++ b/lib/spend-budget.ts @@ -0,0 +1,39 @@ +const number = (value: unknown) => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +}; + +export const debitTotal = ({ + amount, + tip = 0, + fee = 0, + ourfee = 0, +}: { + amount: unknown; + tip?: unknown; + fee?: unknown; + ourfee?: unknown; +}) => number(amount) + number(tip) + number(fee) + number(ourfee); + +// Enforce a caller-specific spending allowance at debit's final choke point, +// after mutable invoice metadata (notably an internal invoice tip) and fees have +// been captured. Checking earlier lets a recipient mutate the tip between the +// NWC budget check and the ledger debit. +export const assertWithinSpendLimit = ({ + amount, + tip = 0, + fee = 0, + ourfee = 0, + maxTotal, +}: { + amount: unknown; + tip?: unknown; + fee?: unknown; + ourfee?: unknown; + maxTotal?: number; +}) => { + const total = debitTotal({ amount, tip, fee, ourfee }); + if (maxTotal !== undefined && total > maxTotal) + throw new Error(`Budget exceeded: ${total} of ${maxTotal} remaining`); + return total; +}; diff --git a/lib/square.ts b/lib/square.ts new file mode 100644 index 00000000..272d9872 --- /dev/null +++ b/lib/square.ts @@ -0,0 +1,55 @@ +import config from "$config"; +import { g, s } from "$lib/db"; +import { fiat } from "$lib/utils"; +import { SquareClient } from "square"; +import { v4 } from "uuid"; + +const { appId, environment } = config.square; + +export const squarePayment = async (p, user) => { + let square = await g(`${user.id}:square`); + if (!(square && user.syncSquare)) return; + + let client = new SquareClient({ + environment, + }); + + const { refreshToken } = square; + + square = await client.oAuth.obtainToken({ + clientId: appId, + grantType: "refresh_token", + refreshToken, + }); + + await s(`${user.id}:square`, square); + + const { accessToken } = square; + + client = new SquareClient({ + token: accessToken, + environment, + }); + + const locs = await client.locations.list({}); + + await client.payments.create({ + sourceId: "EXTERNAL", + idempotencyKey: v4(), + amountMoney: { + amount: BigInt(parseFloat(fiat(p.amount, p.rate).toFixed(2)) * 100), + currency: p.currency, + }, + tipMoney: p.tip + ? { + amount: BigInt(parseFloat(fiat(p.tip, p.rate).toFixed(2)) * 100), + currency: p.currency, + } + : undefined, + autocomplete: true, + externalDetails: { type: "OTHER", source: `Coinos payment ${p.id}` }, + locationId: locs.locations[0].id, + referenceId: v4(), + note: `Coinos payment ${p.id}`, + }); +}; diff --git a/lib/types.ts b/lib/types.ts index e878dfb8..802fe995 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -15,7 +15,6 @@ export interface User { follows: string; haspin: string; hasprinter: string; - hidepay: string; id: string; index: string; keys: string; diff --git a/lib/upload.ts b/lib/upload.ts index ed06d7e0..bad9735e 100644 --- a/lib/upload.ts +++ b/lib/upload.ts @@ -1,31 +1,68 @@ import { createHash } from "crypto"; import { writeFileSync } from "fs"; -import { err } from "$lib/logging"; +import { db } from "$lib/db"; +import { err, warn } from "$lib/logging"; import { bail, fail } from "$lib/utils"; +import { convert } from "$lib/image"; import { fileTypeFromBuffer } from "file-type"; -const sharp = require("sharp"); +// Only the formats whose decoders we are willing to expose to arbitrary +// authenticated input. Decoding happens in a throwaway child process +// (lib/image.ts) since 2026-09-26, when a stream of corrupt HEIF files +// crash-looped the server via heap corruption inside libheif — +// but a crashing decoder is still a decoder we'd rather not run at all. +// HEIF/AVIF/TIFF/SVG/PDF never get past the sniff. The previous check let +// any image/* subtype through. +const ALLOWED = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]); + +// Per-account budget. Avatars/banners change a handful of times, ever; the +// fuzzer was pushing 10+ a minute. +const RATE_MAX = 10; +const RATE_WINDOW_S = 600; + +const clientIp = (req) => req.headers["cf-connecting-ip"] || req.ip; export default async (req, res) => { + const { user } = req; try { const { params: { type }, } = req; + // A `blacklist` freeze only blocks sends; the JWT still works everywhere + // else, which is how a frozen account kept crashing us. Uploads are pure + // attack surface for a frozen account, so refuse them outright. + const uname = user?.username?.toLowerCase().trim(); + const blacklisted = + (await db.sIsMember("blacklist", user.id)) || + (await db.sIsMember("blacklist", uname)); + const whitelisted = await db.sIsMember("whitelist", uname); + if (blacklisted && !whitelisted) { + warn(`UPLOAD_REJECTED frozen ${user.username} ${clientIp(req)}`); + fail("unauthorized"); + } + + const rk = `upload:rl:${user.id}`; + const n = await db.incr(rk); + if (n === 1) await db.expire(rk, RATE_WINDOW_S); + if (n > RATE_MAX) { + warn(`UPLOAD_REJECTED ratelimit ${user.username} ${clientIp(req)} ${n}`); + fail("too many uploads, try again later"); + } + const data = await req.file(); let buf = await data.toBuffer(); - const [format, ext] = (await fileTypeFromBuffer(buf)).mime.split("/"); - - if (format !== "image" && !["jpg", "jpeg", "png"].includes(ext)) + const ft = await fileTypeFromBuffer(buf); + if (!ft || !ALLOWED.has(ft.mime)) { + warn( + `UPLOAD_REJECTED type ${user.username} ${clientIp(req)} ${ft?.mime || "unknown"} ${buf.length}`, + ); fail("unsupported file type"); + } const w = type === "banner" ? 1920 : 240; - buf = await sharp(buf, { failOnError: false }) - .rotate() - .resize(w) - .webp() - .toBuffer(); + buf = await convert(buf, w); const hash = createHash("sha256").update(buf).digest("hex"); @@ -34,7 +71,7 @@ export default async (req, res) => { res.send({ hash }); } catch (e) { - err("problem uploading", e.message); + err("problem uploading", user?.username, e.message); bail(res, e.message); } }; diff --git a/lib/utils.ts b/lib/utils.ts index 018dc92e..539258a4 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,5 +1,5 @@ import config from "$config"; -import { g } from "$lib/db"; +import { g, gf } from "$lib/db"; import locales from "$lib/locales/index"; const { URL } = process.env; @@ -57,15 +57,15 @@ export const getUser = async (username, fields = undefined) => { }; export const getInvoice = async (hash) => { - let iid = await g(`invoice:${hash}`); + let iid = await gf(`invoice:${hash}`); if (iid?.id) iid = iid.id; else if (iid?.hash) iid = iid.hash; - return await g(`invoice:${iid}`); + return await gf(`invoice:${iid}`); }; export const getPayment = async (id) => { - let p = await g(`payment:${id}`); - if (typeof p === "string") p = await g(`payment:${p}`); + let p = await gf(`payment:${id}`); + if (typeof p === "string") p = await gf(`payment:${p}`); return p; }; @@ -172,9 +172,10 @@ export const fields = [ "currency", "display", "id", - "hidepay", "lud16", "memoPrompt", + // a v3-migrated account: the names registrar owns this name for receiving + "migrated", "npub", "picture", "prompt", diff --git a/lib/v3forward.ts b/lib/v3forward.ts new file mode 100644 index 00000000..9a263acd --- /dev/null +++ b/lib/v3forward.ts @@ -0,0 +1,133 @@ +import { db, g } from "$lib/db"; +import { quoteRoutingFee } from "$lib/lnquote"; +import { l, shortError, warn } from "$lib/logging"; +import { PaymentType } from "$lib/types"; + +// v3 forwarding bridge. A user who migrated their name to coinos v3 still has +// their legacy account, and payments keep arriving there: old BOLT12 offers, +// old bolt11 invoices, old bitcoin/liquid deposit addresses. Each confirmed +// receive of that kind is paid on, as it lands, to the name's v3 lightning +// address (the registrar mints the invoice on the ASP's node, and its +// forwarder delivers to the wallet's Ark mailbox even while the wallet is +// closed). Only new receives move — a balance already sitting in the legacy +// account stays put — and the forward costs the user the routing fee only +// (none at all over the direct channels to the ASP's node). +// +// "Migrated" needs proof, since this moves money people sent to the LEGACY +// account: the registrar holds the name either for the legacy account's own +// nostr key (the identity-takeover rule) or for the v3 wallet the legacy +// account's own session named in a migration grant (`granted`). A name that +// merely exists on v3 is never forwarded to. + +const NAMES_URL = process.env.NAMES_URL || "https://names.coinos.io"; +const DOMAIN = "coinos.io"; +const TYPES = [PaymentType.lightning, PaymentType.bolt12, PaymentType.bitcoin, PaymentType.liquid]; +const RETRY_KEY = "v3fwd:retry"; // hash pid -> {uid, amount, tries, next} +const RETRY_MS = [60_000, 5 * 60_000, 30 * 60_000, 2 * 3600_000, 12 * 3600_000]; + +const holderCache = new Map<string, { at: number; pk: string | null; granted: boolean }>(); +async function v3Holder(username: string): Promise<{ pk: string | null; granted: boolean }> { + const name = username.toLowerCase(); + const hit = holderCache.get(name); + // this runs on every legacy receive: a holder is re-checked each minute, + // a name nobody holds every ten (claims change on the order of days) + if (hit && Date.now() - hit.at < (hit.pk ? 60_000 : 600_000)) return hit; + const r = await fetch(`${NAMES_URL}/name/${encodeURIComponent(name)}?domain=${DOMAIN}`, + { signal: AbortSignal.timeout(2000) }); + if (!r.ok) throw new Error(`registrar said ${r.status}`); + const body: any = await r.json(); + const pk = body?.taken && typeof body.pubkey === "string" ? body.pubkey : null; + const hit2 = { at: Date.now(), pk, granted: !!pk && body.granted === true }; + holderCache.set(name, hit2); + return hit2; +} + +// The name's v3 address, if this legacy account has moved there. +export async function v3Destination(user): Promise<string | null> { + if (!user?.username || !user?.pubkey || user.username.length > 64) return null; + const { pk, granted } = await v3Holder(user.username); + return pk && (granted || pk === user.pubkey) ? user.username.toLowerCase() : null; +} + +async function invoiceFor(name: string, sats: number): Promise<string> { + const lnurl = `${NAMES_URL}/.well-known/lnurlp/${encodeURIComponent(name)}?domain=${DOMAIN}`; + const pr: any = await (await fetch(lnurl, { signal: AbortSignal.timeout(5000) })).json(); + if (pr?.tag !== "payRequest" || !pr.callback) throw new Error("no payRequest from registrar"); + const msat = sats * 1000; + if (msat < (pr.minSendable || 0)) throw Object.assign(new Error(`below the v3 minimum (${pr.minSendable / 1000} sats)`), { permanent: true }); + if (pr.maxSendable && msat > pr.maxSendable) throw Object.assign(new Error(`above the v3 maximum (${pr.maxSendable / 1000} sats)`), { permanent: true }); + const sep = pr.callback.includes("?") ? "&" : "?"; + const inv: any = await (await fetch(`${pr.callback}${sep}amount=${msat}`, { signal: AbortSignal.timeout(10_000) })).json(); + if (!inv?.pr) throw new Error(inv?.reason || "registrar returned no invoice"); + return inv.pr; +} + +// Pay `amount` sats of the user's legacy balance on to their v3 address, +// routing fee included in it. +async function forward({ user, amount, pid, send }) { + const name = await v3Destination(user); + if (!name) return null; + let pr = await invoiceFor(name, amount); + let { fee } = await quoteRoutingFee({ pr, amount }); + if (fee > 0) { + if (fee >= amount) throw Object.assign(new Error(`routing fee ${fee} exceeds ${amount}`), { permanent: true }); + pr = await invoiceFor(name, amount - fee); + ({ fee } = await quoteRoutingFee({ pr, amount: amount - fee })); + } + const w = await send({ user, pr, fee, memo: `Forwarded to ${name}@${DOMAIN} (coinos v3)` }); + l("v3 forward", user.username, amount, "fee", fee, "payment", pid, "->", w?.id); + return w; +} + +// Called from completePayment for every receive. Fire-and-forget: never +// blocks or fails the credit. Returns true when the receive was the bridge's +// (so legacy autowithdraw stands down for it). +export async function maybeForwardToV3(p, user, send): Promise<boolean> { + try { + if (!p?.confirmed || !TYPES.includes(p.type) || !(p.amount > 0)) return false; + if ((p.aid || p.uid) !== user?.id) return false; // sub-accounts stay put + if (!(await v3Destination(user))) return false; + // once per payment: completePayment runs twice for on-chain deposits + const fresh = await db.set(`v3fwd:claimed:${p.id}`, "1", { NX: true, EX: 30 * 86400 }); + if (!fresh) return true; + forward({ user, amount: p.amount, pid: p.id, send }).catch(async (e) => { + warn("v3 forward failed", user.username, p.amount, shortError(e?.message)); + if (!e?.permanent) await queueRetry(p.id, user.id, p.amount, 0); + }); + return true; + } catch (e: any) { + warn("v3 forward check failed", user?.username, shortError(e?.message)); + return false; + } +} + +async function queueRetry(pid: string, uid: string, amount: number, tries: number) { + if (tries >= RETRY_MS.length) { + warn("v3 forward gave up", uid, pid, amount, "— stays in the legacy balance"); + return; + } + await db.hSet(RETRY_KEY, pid, JSON.stringify({ uid, amount, tries, next: Date.now() + RETRY_MS[tries] })); +} + +// One process at a time works the retry queue (a short redis lock). +export async function retryV3Forwards(send) { + const lock = await db.set("v3fwd:lock", "1", { NX: true, EX: 120 }); + if (!lock) return; + try { + const all = await db.hGetAll(RETRY_KEY); + for (const [pid, raw] of Object.entries(all)) { + const e = JSON.parse(raw as string); + if (Date.now() < e.next) continue; + await db.hDel(RETRY_KEY, pid); + const user = await g(`user:${e.uid}`); + try { + if (!(await forward({ user, amount: e.amount, pid, send }))) continue; + } catch (err: any) { + warn("v3 forward retry failed", user?.username, e.amount, shortError(err?.message)); + if (!err?.permanent) await queueRetry(pid, e.uid, e.amount, e.tries + 1); + } + } + } finally { + await db.del("v3fwd:lock"); + } +} diff --git a/lib/webhooks.ts b/lib/webhooks.ts index 2a888991..d724ccdf 100644 --- a/lib/webhooks.ts +++ b/lib/webhooks.ts @@ -12,6 +12,13 @@ export const callWebhook = async (invoice, payment) => { const { amount, confirmed, hash, memo } = payment; l("calling webhook", webhook, amount, hash, address, text); + // TLS verification stays ON. This body carries `secret` — the shared + // value the merchant uses to authenticate the notification — so with + // rejectUnauthorized:false any party able to intercept the connection + // could present its own certificate, harvest the secret, and then forge + // "payment received" callbacks to that merchant. A merchant on a + // self-signed cert now fails here instead, which the catch below logs; + // the payment itself is unaffected. const res = await got.post(webhook, { json: { address, @@ -23,7 +30,6 @@ export const callWebhook = async (invoice, payment) => { text, secret, }, - https: { rejectUnauthorized: false }, }); return res; } diff --git a/lib/whitelist.ts b/lib/whitelist.ts index 53fd231f..f305f516 100644 --- a/lib/whitelist.ts +++ b/lib/whitelist.ts @@ -14,9 +14,9 @@ export default [ "fresh", "followers", "follows", + "hasnsec", "haspin", "hasprinter", - "hidepay", "id", "index", "keys", @@ -25,11 +25,10 @@ export default [ "locked", "locktime", "memoPrompt", + "migrated", "nip5", "notify", "npub", - "nsec", - "nwc", "payments", "picture", "profile", diff --git a/meltguard.ts b/meltguard.ts new file mode 100644 index 00000000..c7195bb0 --- /dev/null +++ b/meltguard.ts @@ -0,0 +1,110 @@ +// meltguard — out-of-band spend limiter for the panic-mode mint. +// +// Sits INLINE between the mint and cl's CLNRest: the mint is pointed at +// MINT_CLNREST_URL=http://meltguard:3011 instead of http://cl:3010. Every melt +// is a POST /v1/pay or /v1/xpay; the guard decodes the invoice amount (via cl's own /v1/decode, +// so no hand-rolled bolt11 parsing) and enforces two caps the mint cannot bypass — +// even if panic mode has a bug or the mint is compromised, it physically cannot +// move money except through this guard: +// +// 1. PER-MELT CAP — reject any single pay over MELT_MAX_SAT (default 1,000,000). +// 2. CUMULATIVE FREEZE — track total successfully paid; once it would exceed +// MELT_CUM_MAX_SAT (default 3,000,000), write a STICKY freeze flag and reject +// ALL further pays until an operator reviews and clears it. +// +// State lives in plain files OUTSIDE any db (like the nobal failsafe), so a db +// compromise can't move them and a restart can't forget them. +// +// Everything that is not POST /v1/pay|/v1/xpay is proxied through untouched, so mint +// startup (getinfo, listinvoices, waitanyinvoice, invoice, listpays…) works normally. +// +// Env: CL_URL (http://cl:3010), MELT_MAX_SAT, MELT_CUM_MAX_SAT, GUARD_DIR (/guard), +// PORT (3011). Run: GUARD_DIR=./guard bun meltguard.ts +import { readFileSync, writeFileSync, existsSync, appendFileSync, mkdirSync } from "fs"; + +const CL = process.env.CL_URL || "http://cl:3010"; +const PER = Number(process.env.MELT_MAX_SAT || 1_000_000); +const CUM = Number(process.env.MELT_CUM_MAX_SAT || 3_000_000); +const DIR = process.env.GUARD_DIR || "/guard"; +const PORT = Number(process.env.PORT || 3011); +mkdirSync(DIR, { recursive: true }); +const CUM_FILE = `${DIR}/cumulative_sat`; +const FROZEN_FILE = `${DIR}/FROZEN`; +const LOG = `${DIR}/meltguard.log`; + +const readCum = () => (existsSync(CUM_FILE) ? Number(readFileSync(CUM_FILE, "utf8").trim()) || 0 : 0); +const writeCum = (n: number) => writeFileSync(CUM_FILE, String(n)); +const isFrozen = () => existsSync(FROZEN_FILE); +const log = (m: string) => { try { appendFileSync(LOG, `${new Date().toISOString()} ${m}\n`); } catch {} console.error("meltguard:", m); }; +const freeze = (why: string) => { writeFileSync(FROZEN_FILE, why + "\n"); log(`FROZEN — ${why} — operator must review and clear ${FROZEN_FILE}`); }; +const err = (status: number, message: string) => new Response(JSON.stringify({ error: message }), { status, headers: { "content-type": "application/json" } }); + +async function decodeSat(bolt11: string, rune: string | null): Promise<number | null> { + try { + const r = await fetch(`${CL}/v1/decode`, { method: "POST", headers: { "content-type": "application/json", ...(rune ? { Rune: rune } : {}) }, body: JSON.stringify({ string: bolt11 }) }); + const j: any = await r.json(); + if (j.amount_msat != null) return Math.ceil(Number(j.amount_msat) / 1000); + return null; // amountless invoice — no embedded amount to cap on; treat as undecidable + } catch { return null; } +} + +console.log(`meltguard on :${PORT} -> ${CL} | per-melt<=${PER} sat, cumulative freeze at ${CUM} sat | state ${DIR} | frozen=${isFrozen()} cum=${readCum()}`); + +Bun.serve({ + port: PORT, hostname: "0.0.0.0", idleTimeout: 0, + async fetch(req) { + const url = new URL(req.url); + const rune = req.headers.get("Rune"); + // nutshell >= 0.20.3.1 CLNRestWallet pays via /v1/xpay (form field `invstring`, + // `maxfee`); older versions used /v1/pay (`bolt11`/`invoice`). Guard both, so a + // backend switch can never route a melt around the caps. + const isXpay = req.method === "POST" && url.pathname === "/v1/xpay"; + const isPay = isXpay || (req.method === "POST" && url.pathname === "/v1/pay"); + + if (isPay) { + if (isFrozen()) return err(403, "meltguard: FROZEN — operator review required"); + // nutshell's CoreLightningRestWallet (mint-old) posts /v1/pay FORM-encoded + // with the invoice under `invoice` (old c-lightning-REST dialect); the newer + // CLNRestWallet uses `bolt11`. Parse form first, fall back to JSON, and + // accept either field name. (Prior code did req.json()+body.bolt11 only, so + // it never parsed the mint's form body -> every old-ecash melt was rejected + // at the amount check.) + const raw = await req.text(); + let invoice = ""; + const extra: Record<string, string> = {}; + try { + const p = new URLSearchParams(raw); + if ([...p.keys()].length) { + invoice = p.get("invstring") || p.get("invoice") || p.get("bolt11") || ""; + for (const [k, v] of p) if (k !== "invoice" && k !== "bolt11" && k !== "invstring") extra[k] = v; + } + } catch {} + if (!invoice) { + try { + const j = JSON.parse(raw); + invoice = j.invstring || j.bolt11 || j.invoice || ""; + for (const k of Object.keys(j)) if (k !== "invoice" && k !== "bolt11" && k !== "invstring") extra[k] = String(j[k]); + } catch {} + } + const amt = await decodeSat(invoice, rune); + if (amt == null) return err(400, "meltguard: could not determine invoice amount — refusing to forward"); + if (amt > PER) { log(`REJECT per-melt ${amt} > ${PER}`); return err(403, `meltguard: per-melt cap ${PER} sat exceeded (${amt})`); } + const cum = readCum(); + if (cum + amt > CUM) { freeze(`cumulative ${cum}+${amt} > ${CUM}`); return err(403, `meltguard: cumulative cap ${CUM} sat — FROZEN, operator review required`); } + // forward to cl (official CLNREST) as FORM on the SAME endpoint the mint + // called (`invstring` for xpay, `bolt11` for pay), preserving the mint's + // other params (maxfee / maxfeepercent / exemptfee). Count cumulative only + // on cl-reported success. + const fwd = new URLSearchParams(isXpay ? { invstring: invoice, ...extra } : { bolt11: invoice, ...extra }); + const resp = await fetch(`${CL}${isXpay ? "/v1/xpay" : "/v1/pay"}`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", ...(rune ? { Rune: rune } : {}) }, body: fwd.toString() }); + const text = await resp.text(); + if (resp.ok) { writeCum(cum + amt); log(`PAID ${amt} sat via ${isXpay ? "xpay" : "pay"} (cumulative ${cum + amt}/${CUM})`); } + return new Response(text, { status: resp.status, headers: { "content-type": "application/json" } }); + } + + // transparent proxy for all other CLNRest calls + const body = req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer(); + const resp = await fetch(`${CL}${url.pathname}${url.search}`, { method: req.method, headers: { ...(req.headers.get("content-type") ? { "content-type": req.headers.get("content-type")! } : {}), ...(rune ? { Rune: rune } : {}) }, body }); + return new Response(await resp.arrayBuffer(), { status: resp.status, headers: { "content-type": resp.headers.get("content-type") || "application/json" } }); + }, +}); diff --git a/mintdemux.ts b/mintdemux.ts new file mode 100644 index 00000000..d961b05b --- /dev/null +++ b/mintdemux.ts @@ -0,0 +1,329 @@ +// mintdemux — transparent two-mint front for mint.coinos.io. +// +// Primary is the current mint (NEW: fresh keys, fully normal). The retired mint +// (OLD: panic mode, recovery-only) is fronted transparently so wallets still +// holding pre-2026-07-20 ecash can MELT it at mint.coinos.io without changing +// their mint URL. Routing: +// - /v1/keysets -> UNION of both mints' keysets (wallet sees old + new) +// - /v1/keys/<id> -> the mint that owns that keyset +// - /v1/info, /v1/keys -> NEW (primary identity + active keys) +// - /v1/melt/quote/bolt11 -> NEW; remember the invoice keyed by the quote id +// - /v1/melt/bolt11 -> routed by the submitted proofs' keyset: OLD-keyset +// proofs get a fresh OLD melt quote (from the remembered invoice) and are +// executed on OLD (where panic mode blocks blacklisted attacker ecash); +// change outputs are STRIPPED on that path (wallets blind them to the NEW +// active keyset, which OLD can't sign — OLD would reject the whole melt), +// and the OLD quote id in the response is mapped back to the caller's; +// everything else executes on NEW +// - GET /v1/melt/quote/bolt11/<id> -> OLD (id rewritten) if that melt was +// executed on OLD, else NEW — so wallets can poll a recovery melt +// - /v1/checkstate -> both mints, merged (spent if either says spent) +// - /v1/ws -> websocket proxy to NEW (subscriptions) +// - everything else (swap, mint, restore, mint quote) -> NEW +// The OLD mint also stands alone at mintrecovery.coinos.io (direct). +// +// Client-IP forwarding: cloudflared stamps cf-connecting-ip on every request; +// demux forwards it upstream so the mints' per-IP rate limiter buckets by the +// real client instead of lumping all of mint.coinos.io into demux's socket IP. +// Partner hosts that funnel many end users through one IP (lightning-address +// aggregators etc.) can be listed in <LOG_DIR>/whitelist.json: +// { "ips": ["1.2.3.4", "2600:..."], "pool": 10 } +// Their bucket key rotates across <pool> synthetic keys, so their effective +// allowance is pool x the mint's per-IP limit. Reloaded every 60s, no restart. +// +// Env: NEW_MINT (http://mint:3338), OLD_MINT (http://mint-old:3338), +// LOG_DIR (/demux), PORT (3341) +import { createHash } from "crypto"; +import { appendFileSync, mkdirSync, readFileSync, existsSync, writeFileSync } from "fs"; + +const NEW = process.env.NEW_MINT || "http://mint:3338"; +const OLD = process.env.OLD_MINT || "http://mint-old:3338"; +const DIR = process.env.LOG_DIR || "/demux"; +const PORT = Number(process.env.PORT || 3341); +mkdirSync(DIR, { recursive: true }); + +const CORS: Record<string, string> = { + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET, POST, OPTIONS", + "access-control-allow-headers": "*", + "access-control-expose-headers": "*", +}; +const J = { ...CORS, "content-type": "application/json" }; +const log = (m: string) => { try { appendFileSync(`${DIR}/demux.log`, `${new Date().toISOString()} ${m}\n`); } catch {} console.log("demux:", m); }; +const parse = (s: string) => { try { return JSON.parse(s); } catch { return null; } }; + +// --- keyset ownership, refreshed hourly --- +let newKeysets = new Set<string>(); +let oldKeysets = new Set<string>(); +// Our own hourly routing refresh — forwarded as loopback so the mints' per-IP +// limiter (which exempts 127.0.0.1) doesn't count it against the demux socket. +const lastKeysets: { NEW?: any[]; OLD?: any[] } = {}; +async function fetchKeysets(base: string): Promise<any[]> { + try { + const r = await fetch(`${base}/v1/keysets`, { headers: { "cf-connecting-ip": "127.0.0.1" } }); + if (!r.ok) throw new Error(`status ${r.status}`); + const j: any = await r.json(); return (j.keysets || []) as any[]; + } + catch (e: any) { console.error("keyset fetch failed", base, e.message); return []; } +} +async function refreshKeysets() { + const [n, o] = await Promise.all([fetchKeysets(NEW), fetchKeysets(OLD)]); + // This refresh is exempt from the mints' per-IP limiter (loopback key), so it + // also seeds the last-good lists the client-facing /v1/keysets falls back on + // when a wallet's own fetch is rate-limited — otherwise a demux restart could + // serve a list with no old keysets in it until some client fetch succeeded. + if (n.length) { newKeysets = new Set(n.map((k: any) => String(k.id))); lastKeysets.NEW = n; } + if (o.length) { oldKeysets = new Set(o.map((k: any) => String(k.id))); lastKeysets.OLD = o; } +} +await refreshKeysets(); +setInterval(refreshKeysets, 3600_000); +// Empty routing tables are dangerous: with no old keysets every old-ecash melt +// looks new and goes to the wrong mint. A restart that races the mints coming +// up (a compose restart of both) leaves exactly that, so retry every 15s until +// both sides are known instead of waiting out the hour. +const warmup = setInterval(async () => { + if (newKeysets.size && oldKeysets.size) return clearInterval(warmup); + await refreshKeysets(); + if (newKeysets.size && oldKeysets.size) { clearInterval(warmup); log(`keysets warmed: new=[${[...newKeysets]}] old=[${[...oldKeysets]}]`); } +}, 15_000); + +// --- melt quote -> invoice memory (so an OLD-keyset melt can be re-quoted on OLD) --- +const QFILE = `${DIR}/meltquotes.json`; +type QInfo = { invoice: string; unit: string; ts: number }; +let quoteMem: Record<string, QInfo> = {}; +if (existsSync(QFILE)) quoteMem = parse(readFileSync(QFILE, "utf8")) || {}; +const QTTL = 2 * 3600_000; +function rememberQuote(id: string, info: QInfo) { + quoteMem[id] = info; + const cut = Date.now() - QTTL; + for (const k of Object.keys(quoteMem)) if ((quoteMem[k].ts || 0) < cut) delete quoteMem[k]; + try { writeFileSync(QFILE, JSON.stringify(quoteMem)); } catch {} +} + +// --- NEW-quote-id -> OLD-quote-id map for melts executed on OLD, so status +// polls (GET /v1/melt/quote/bolt11/<id>) can be routed to the mint that ran +// the melt. Kept long: a pending lightning payment can be polled much later. +const MFILE = `${DIR}/oldmelts.json`; +type MInfo = { old: string; ts: number }; +let oldMelts: Record<string, MInfo> = {}; +if (existsSync(MFILE)) oldMelts = parse(readFileSync(MFILE, "utf8")) || {}; +const MTTL = 7 * 24 * 3600_000; +function rememberOldMelt(newId: string, oldId: string) { + oldMelts[newId] = { old: oldId, ts: Date.now() }; + const cut = Date.now() - MTTL; + for (const k of Object.keys(oldMelts)) if ((oldMelts[k].ts || 0) < cut) delete oldMelts[k]; + try { writeFileSync(MFILE, JSON.stringify(oldMelts)); } catch {} +} + +// --- dropped old inputs (mixed melts) --- +// The wallet treats these as spent; the mint never took them, so they sit +// UNSPENT on OLD forever. Keep an accounting trail: the Y (hash of the secret) +// identifies a proof to the mint without storing the bearer secret itself. +const DFILE = `${DIR}/dropped-old-inputs.jsonl`; +function recordDropped(proofs: any[], ctx: { ip: string; quote: string }) { + for (const p of proofs) { + try { + const y = createHash("sha256").update(String(p?.secret ?? "")).digest("hex"); + appendFileSync(DFILE, JSON.stringify({ + ts: new Date().toISOString(), ...ctx, + id: p?.id, amount: p?.amount, secret_sha256: y, dleq: !!p?.dleq, + }) + "\n"); + } catch {} + } +} + +// --- client-ip forwarding + partner whitelist --- +const WFILE = `${DIR}/whitelist.json`; +let wlIps = new Set<string>(); +let wlPool = 10; +function loadWhitelist() { + if (!existsSync(WFILE)) return; + const j = parse(readFileSync(WFILE, "utf8")); + if (!j) return; + if (Array.isArray(j.ips)) wlIps = new Set(j.ips.map((s: any) => String(s).toLowerCase())); + if (Number.isFinite(j.pool) && j.pool >= 1) wlPool = Math.floor(j.pool); +} +loadWhitelist(); +setInterval(loadWhitelist, 60_000); + +const wlCounters: Record<string, number> = {}; +function bucketKey(req: Request): string { + const ip = ( + req.headers.get("cf-connecting-ip") || + (req.headers.get("x-forwarded-for") || "").split(",")[0].trim() + ).toLowerCase(); + if (!ip || !wlIps.has(ip)) return ip; + const n = (wlCounters[ip] = ((wlCounters[ip] || 0) + 1) % wlPool); + return `${ip}#${n}`; +} +// headers for an upstream fetch: json content-type when a body is sent, and +// the client bucket key as cf-connecting-ip (checked first by the mint's +// rate limiter; the raw socket IP is used when we have nothing to forward) +const fwd = (key: string, hasBody = false): Record<string, string> => ({ + ...(hasBody ? { "content-type": "application/json" } : {}), + ...(key ? { "cf-connecting-ip": key } : {}), +}); + +console.log(`mintdemux on :${PORT} NEW=${NEW} OLD=${OLD} new=[${[...newKeysets]}] old=[${[...oldKeysets]}] whitelist=[${[...wlIps]}] pool=${wlPool}`); + +const keysetsInBody = (body: any): string[] => { + const ids = new Set<string>(); + for (const arr of [body?.inputs, body?.proofs]) if (Array.isArray(arr)) for (const it of arr) { const id = String(it?.id ?? ""); if (id) ids.add(id); } + return [...ids]; +}; +const respond = (upstream: Response, text: string) => + new Response(text, { status: upstream.status, headers: { ...CORS, "content-type": upstream.headers.get("content-type") || "application/json" } }); + +async function proxy(base: string, method: string, path: string, body?: ArrayBuffer | string, headers?: Record<string, string>): Promise<Response> { + const r = await fetch(`${base}${path}`, { method, headers, body: body as any }); + return respond(r, await r.text()); +} + +const WSNEW = NEW.replace(/^http/, "ws"); +type WsData = { key: string; up?: WebSocket; q: (string | Uint8Array)[] }; + +Bun.serve({ + port: PORT, hostname: "0.0.0.0", idleTimeout: 0, + async fetch(req, server) { + const url = new URL(req.url); + const path = url.pathname; + const ps = path + url.search; + if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS }); + const key = bucketKey(req); + + if (path === "/v1/ws") { + if (server.upgrade(req, { data: { key, q: [] } as WsData })) return undefined as any; + return new Response(JSON.stringify({ detail: "websocket upgrade required" }), { status: 400, headers: J }); + } + + // --- GET routes --- + if (req.method === "GET") { + if (path === "/v1/keysets") { + // Forward the client's bucket key: without it every wallet's keyset + // fetch counted against the demux's own socket IP at both mints, the + // shared 60/min bucket tripped, and the swallowed 429s came back to + // every wallet as an empty keyset list. A failed upstream now serves + // that mint's last-good list instead. + const get = async (base: string) => { + const r = await fetch(`${base}/v1/keysets`, { headers: fwd(key) }); + if (!r.ok) throw new Error(`status ${r.status}`); + return (await r.json() as any).keysets as any[]; + }; + const [rn, ro] = await Promise.all([ + get(NEW).then(k => (lastKeysets.NEW = k), e => { console.error("keysets NEW", e.message); return lastKeysets.NEW; }), + get(OLD).then(k => (lastKeysets.OLD = k), e => { console.error("keysets OLD", e.message); return lastKeysets.OLD; }), + ]); + if (!rn && !ro) return new Response(JSON.stringify({ detail: "keysets unavailable" }), { status: 503, headers: J }); + const seen = new Set<string>(); const keysets: any[] = []; + // NEW mint keysets keep their flags. OLD (recovery) keysets are forced + // active:false — they're redeem/melt-only, so wallets never mint or swap + // into them. Prevents advertising two active "sat" keysets at + // mint.coinos.io (the old mint is panic-locked anyway; this is the + // client-facing view). + for (const k of (rn || [])) { const id = String(k.id); if (!seen.has(id)) { seen.add(id); keysets.push(k); } } + for (const k of (ro || [])) { const id = String(k.id); if (!seen.has(id)) { seen.add(id); keysets.push({ ...k, active: false }); } } + return new Response(JSON.stringify({ keysets }), { headers: J }); + } + const km = path.match(/^\/v1\/keys\/(.+)$/); + if (km) return proxy(oldKeysets.has(km[1]) ? OLD : NEW, "GET", ps, undefined, fwd(key)); + const qm = path.match(/^\/v1\/melt\/quote\/bolt11\/([^/]+)$/); + if (qm && oldMelts[qm[1]]) { + const r = await fetch(`${OLD}/v1/melt/quote/bolt11/${oldMelts[qm[1]].old}`, { headers: fwd(key) }); + const txt = await r.text(); const j = parse(txt); + if (j?.quote) j.quote = qm[1]; // caller knows its own id, not OLD's + return new Response(j ? JSON.stringify(j) : txt, { status: r.status, headers: J }); + } + return proxy(NEW, "GET", ps, undefined, fwd(key)); // /v1/info, /v1/keys, anything else GET + } + + // --- POST routes --- + const raw = await req.arrayBuffer(); + const body = parse(new TextDecoder().decode(raw)); + + if (path === "/v1/checkstate") { + const [rn, ro] = await Promise.all([ + fetch(`${NEW}/v1/checkstate`, { method: "POST", headers: fwd(key, true), body: raw }).then(r => r.json()).catch(() => ({ states: [] })), + fetch(`${OLD}/v1/checkstate`, { method: "POST", headers: fwd(key, true), body: raw }).then(r => r.json()).catch(() => ({ states: [] })), + ]); + const byY: Record<string, any> = {}; + for (const s of [...((rn as any).states || []), ...((ro as any).states || [])]) { const cur = byY[s.Y]; if (!cur || s.state === "SPENT") byY[s.Y] = s; } + return new Response(JSON.stringify({ states: Object.values(byY) }), { headers: J }); + } + + if (path === "/v1/melt/quote/bolt11") { + const r = await fetch(`${NEW}/v1/melt/quote/bolt11`, { method: "POST", headers: fwd(key, true), body: raw }); + const txt = await r.text(); const j = parse(txt); + if (r.ok && j?.quote && body?.request) rememberQuote(j.quote, { invoice: body.request, unit: body.unit || "sat", ts: Date.now() }); + return respond(r, txt); + } + + if (path === "/v1/melt/bolt11") { + const ids = keysetsInBody(body); + const hasOld = ids.some(id => oldKeysets.has(id)); + const hasNew = ids.some(id => newKeysets.has(id)); + // Mixed selection: a wallet holding leftovers of both eras picks some of + // each, and the two mints can't co-sign one melt. Rejecting it (the old + // 12003) left such wallets permanently stuck — the auditor's among them, + // which is why mint.coinos.io reads "offline". Drop the OLD inputs and + // melt the NEW ones alone: the old proofs stay UNSPENT at the old mint, + // but the wallet will consider them spent, so record what was dropped. + // Old ecash is only redeemable through the panic-mode recovery path + // anyway (it needs DLEQ + blinding factor, which these inputs lack). + if (hasOld && hasNew) { + const ip = req.headers.get("cf-connecting-ip") || ""; + const keep = (arr: any[]) => arr.filter((p: any) => !oldKeysets.has(String(p?.id))); + const dropped = (body.inputs || []).filter((p: any) => oldKeysets.has(String(p?.id))); + recordDropped(dropped, { ip, quote: String(body.quote || "") }); + const merged = { ...body, inputs: keep(body.inputs || []) }; + const r = await proxy(NEW, "POST", ps, JSON.stringify(merged), fwd(key, true)); + log(`melt mixed: dropped ${dropped.length} old input(s) worth ${dropped.reduce((a: number, p: any) => a + (p?.amount || 0), 0)} sat, melted ${merged.inputs.length} new on NEW status=${r.status} ip=${ip}`); + return r; + } + if (hasOld) { + const ip = req.headers.get("cf-connecting-ip") || ""; + const info = quoteMem[body.quote]; + if (!info) { log(`melt->OLD refused (quote unknown/expired) keysets=${ids.join(",")} ip=${ip}`); return new Response(JSON.stringify({ detail: "Melt quote expired or unknown for old-ecash recovery; request a new melt quote and try again.", code: 12004 }), { status: 400, headers: J }); } + // re-quote on OLD for the same invoice, then execute there (panic mode gates it) + const oq = await fetch(`${OLD}/v1/melt/quote/bolt11`, { method: "POST", headers: fwd(key, true), body: JSON.stringify({ request: info.invoice, unit: info.unit }) }); + const oqt = await oq.text(); const oqj = parse(oqt); + if (!oq.ok || !oqj?.quote) { log(`OLD re-quote failed for melt: ${oqt.slice(0, 120)}`); return respond(oq, oqt); } + rememberOldMelt(String(body.quote), String(oqj.quote)); + // Strip change outputs: wallets blind them to the NEW active keyset, + // which OLD doesn't know and rejects before even reading the proofs + // ("keyset id unknown"). Recovery melts forfeit fee-return change. + const { outputs, ...bodyNoChange } = body; + const r = await fetch(`${OLD}/v1/melt/bolt11`, { method: "POST", headers: fwd(key, true), body: JSON.stringify({ ...bodyNoChange, quote: oqj.quote }) }); + const txt = await r.text(); const j = parse(txt); + log(`melt->OLD(recovery) keysets=${ids.join(",")} status=${r.status} ip=${ip}${outputs ? " outputs-stripped" : ""}`); + if (j?.quote) { j.quote = String(body.quote); return new Response(JSON.stringify(j), { status: r.status, headers: J }); } + return respond(r, txt); + } + // new (or unknown) keysets -> NEW + return proxy(NEW, "POST", ps, raw, fwd(key, true)); + } + + // swap, mint, mint/quote, restore, everything else -> NEW (normal ops) + return proxy(NEW, "POST", ps, raw, fwd(key, true)); + }, + // --- /v1/ws: pipe each client websocket to its own upstream connection on + // NEW, forwarding the bucket key so the mint's ws limiter sees the client --- + websocket: { + open(ws) { + const data = ws.data as WsData; + const up = new WebSocket(`${WSNEW}/v1/ws`, { headers: fwd(data.key) } as any); + data.up = up; + up.onopen = () => { for (const m of data.q) up.send(m); data.q = []; }; + up.onmessage = (e) => { try { ws.send(e.data as any); } catch {} }; + up.onclose = () => { try { ws.close(); } catch {} }; + up.onerror = () => { try { ws.close(); } catch {} }; + }, + message(ws, msg) { + const data = ws.data as WsData; + if (data.up && data.up.readyState === 1) data.up.send(msg as any); + else data.q.push(msg as any); + }, + close(ws) { + try { (ws.data as WsData).up?.close(); } catch {} + }, + }, +}); diff --git a/package.json b/package.json index 8881ff03..01a3a5e4 100644 --- a/package.json +++ b/package.json @@ -2,63 +2,77 @@ "name": "coinos", "version": "0.0.1", "private": true, + "patchedDependencies": { + "@asoltys/[email protected]": "patches/@asoltys+clightning-client+0.1.10.patch" + }, "scripts": { "start": "bun index.ts", "dev": "nodemon --exec bun index.ts", + "test:regtest": "docker exec app sh -c 'cd /home/bun/app && bun test test/'", "format": "biome format *.js **/*.js --write" }, "dependencies": { - "@asoltys/clightning-client": "^0.1.6", - "@aws-sdk/client-ses": "^3.449.0", - "@cashu/cashu-ts": "^2.0.0-rc3", + "@asoltys/clightning-client": "^0.1.10", + "@aws-sdk/client-ses": "^3.997.0", + "@cashu/cashu-ts": "^2.9.0", "@coinos/rpc": "^0.0.6", - "@fastify/cors": "^8.3.0", - "@fastify/http-proxy": "^9.5.0", - "@fastify/multipart": "^8.0.0", - "@fastify/passport": "^2.4.0", - "@fastify/rate-limit": "^9.1.0", - "@fastify/secure-session": "^7.5.1", - "@fastify/static": "^6.5.0", - "@fastify/websocket": "^10.0.1", - "@noble/hashes": "^1.4.0", + "@fastify/cors": "^11.3.0", + "@fastify/http-proxy": "^11.6.0", + "@fastify/multipart": "^10.1.1", + "@fastify/passport": "^4.0.2", + "@fastify/rate-limit": "^11.2.0", + "@fastify/secure-session": "^8.3.0", + "@fastify/static": "^10.1.3", + "@fastify/websocket": "^11.3.0", + "@noble/hashes": "^1.8.0", + "@scure/base": "^1.2.6", "bech32": "^2.0.0", "buffer-crc32": "^0.2.13", "clightning-client": "github:asoltys/lightning-client-js#master", - "date-fns": "^2.21.3", - "fastify": "^4.28.1", - "file-type": "^18.0.0", - "fluent-ffmpeg": "^2.1.2", + "date-fns": "^2.30.0", + "fastify": "^5.12.0", + "file-type": "^21.3.1", + "fluent-ffmpeg": "^2.1.3", "got": "^13.0.0", "handlebars": "^4.7.8", - "jsonwebtoken": "^9.0.2", + "jsonwebtoken": "^9.0.3", "lnurl": "^0.24.2", - "mqtt": "^5.0.2", - "nostr": "^0.2.7", - "nostr-tools": "^2.7.2", + "mqtt": "^5.15.0", + "nostr": "^0.2.8", + "nostr-tools": "^2.23.1", "otplib": "^12.0.1", - "passport-jwt": "^4.0.0", + "passport-jwt": "^4.0.1", "passport-local": "1.0.0", - "pino": "8", - "pino-pretty": "^7.0.0", - "pump": "^3.0.0", + "pino": "^8.21.0", + "pino-pretty": "^7.6.1", + "pump": "^3.0.3", "ramda": "^0.30.1", "readable-stream-clone": "^0.0.7", - "redis": "^4.6.10", - "sharp": "^0.33.5", + "redis": "^4.7.1", + "sharp": "^0.35.3", + "square": "^43.2.1", "strfry-rpc": "^0.0.1", - "uuid": "8", + "uuid": "^11.1.1", "web-push": "^3.6.7", - "ws": "^7.4.6" + "ws": "^7.5.10" }, "devDependencies": { - "@biomejs/biome": "^1.4.1", - "@types/bun": "^1.1.8", - "nodemon": "^2.0.7", - "typescript": "^5.5.4" + "@biomejs/biome": "^1.9.4", + "@types/bun": "^1.3.9", + "nodemon": "^2.0.22", + "typescript": "^5.9.3" }, "apidoc": { "title": "CoinOS Server API Documentation", "url": "https://coinos.io/api" }, - "type": "module" + "type": "module", + "overrides": { + "qs": ">=6.14.1", + "semver": ">=7.5.2", + "minimatch": ">=3.1.3", + "axios": ">=1.13.5", + "body-parser": ">=1.20.3", + "path-to-regexp": ">=0.1.10" + } } diff --git a/patches/@asoltys+clightning-client+0.1.10.patch b/patches/@asoltys+clightning-client+0.1.10.patch new file mode 100644 index 00000000..f2d0d3d4 --- /dev/null +++ b/patches/@asoltys+clightning-client+0.1.10.patch @@ -0,0 +1,54 @@ +diff --git a/index.js b/index.js +index 968489c..4d96c34 100644 +--- a/index.js ++++ b/index.js +@@ -42,7 +42,31 @@ class LightningClient extends EventEmitter { + this.reconnectTimeout = null; + this.reqcount = 0; + +- this.client = net.createConnection(rpcPath); ++ this._setup(); ++ } ++ ++ _setup() { ++ // Build a FRESH socket + readline for every (re)connection. The original ++ // code reused this.client and only called this.client.connect() on ++ // reconnect — but a socket that has already emitted 'error'/'close' is ++ // destroyed, so reconnecting it is a no-op/throw. That left the client ++ // wedged looping `connect ENOENT …/lightning-rpc` forever after a cl ++ // restart (spamming logs, leaking a socket+timer, never recovering). ++ // Renewing clientConnectionPromise here also makes call() block until the ++ // (re)connection is live instead of writing into a dead socket. ++ if (this.client) { ++ try { ++ this.client.removeAllListeners(); ++ this.client.destroy(); ++ } catch (e) {} ++ } ++ if (this.rl) { ++ try { ++ this.rl.close(); ++ } catch (e) {} ++ } ++ ++ this.client = net.createConnection(this.rpcPath); + this.rl = readline.createInterface({ input: this.client }); + + this.clientConnectionPromise = new Promise((resolve) => { +@@ -87,8 +111,15 @@ class LightningClient extends EventEmitter { + } + + this.reconnectTimeout = setTimeout(() => { +- this.client.connect(this.rpcPath); ++ // Clear the guard BEFORE (re)connecting so a throw can't leave it set ++ // and permanently block every future reconnect() (the original bug). + this.reconnectTimeout = null; ++ try { ++ this._setup(); ++ } catch (e) { ++ this.increaseWaitTime(); ++ this.reconnect(); ++ } + }, this.reconnectWait * 1000); + } + diff --git a/patches/README.md b/patches/README.md new file mode 100644 index 00000000..b3bf1322 --- /dev/null +++ b/patches/README.md @@ -0,0 +1,41 @@ +# patches + +## `@asoltys+clightning-client+0.1.10.patch` + +Fixes the reconnect logic in `@asoltys/clightning-client` (v0.1.10) so a dropped +cl RPC connection actually recovers. + +**Bug:** `reconnect()` called `this.client.connect()` on the *same* socket that +had already emitted `error`/`close`. A destroyed socket can't be reconnected, so +after a `cl` restart the client wedged, looping `connect ENOENT …/lightning-rpc` +forever — spamming logs and leaking a socket+timer each time — and never +recovered. (If `connect()` threw, `reconnectTimeout` was also left set, which +permanently blocked every future `reconnect()`.) + +**Fix:** factor the socket+readline+handlers setup into `_setup()` which builds a +**fresh** socket each (re)connection and renews `clientConnectionPromise` (so +`call()` blocks until the reconnect is live). `reconnect()` now clears its guard +*before* reconnecting and retries on failure. + +This is defense-in-depth: the app already neutralises the broken loop in +`lib/ln.ts` (`dropClient()` tears the dead client down and the proxy makes a fresh +`LightningClient`). This patch fixes the library itself for any path that relies +on its own reconnect. + +### How to wire it in (bun) + +`bun patch` couldn't run in this checkout (node_modules link EEXIST), so this +patch was generated by hand and verified to apply cleanly (`git apply --check`). +To register it so `bun install` re-applies it, in a clean checkout: + +```sh +bun patch @asoltys/clightning-client +# copy patches/clightning-client-index.fixed.js over the printed editable +# node_modules/@asoltys/clightning-client/index.js +bun patch --commit node_modules/@asoltys/clightning-client +``` + +That writes bun's own `patches/…` file and the `patchedDependencies` entry in +`package.json`. Alternatively, apply this patch directly in the image build: +`git apply patches/@asoltys+clightning-client+0.1.10.patch` against the installed +package. `clightning-client-index.fixed.js` is the full patched file for reference. diff --git a/routes/ecash.ts b/routes/ecash.ts index 96783cc4..995a8dce 100644 --- a/routes/ecash.ts +++ b/routes/ecash.ts @@ -1,40 +1,21 @@ -import { db, g, s } from "$lib/db"; -import { check, claim, get, mint } from "$lib/ecash"; -import { err, l } from "$lib/logging"; -import { credit, debit } from "$lib/payments"; -import { emit } from "$lib/sockets"; -import { bail, fail, getInvoice } from "$lib/utils"; +import { check, get, redeem } from "$lib/ecash"; +import { err } from "$lib/logging"; +import { bail, fail, getInvoice, getUser } from "$lib/utils"; import { getEncodedToken } from "@cashu/cashu-ts"; -import { v4 } from "uuid"; - -import { PaymentType } from "$lib/types"; -const { ecash: type } = PaymentType; - -Error.stackTraceLimit = 100; // Set this to the desired limit - -const sendCash = async ({ amount, user }) => { - const id = v4(); - const hash = v4(); - - const p = await debit({ hash, amount, user, type }); - const token = await mint(parseInt(amount)); - p.memo = id; - const { id: pid } = p; - await s(`payment:${pid}`, p); - s(`cash:${id}`, token); - - return { id, token, pid }; -}; export default { - async save(req, res) { + // Stateless status lookup for a token the client is holding. This replaced + // an unauthenticated POST that stored any submitted string under a fresh id + // forever (thousands of forged and foreign tokens accumulated); `get` below + // still serves the ids that were handed out while it existed. + async status(req, res) { const { body: { token }, } = req; try { - const id = v4(); - await s(`cash:${id}`, token); - res.send({ id }); + if (typeof token !== "string" || !token.startsWith("cashu")) + fail("Invalid token"); + res.send(await check(token)); } catch (e) { err(e.message); bail(res, e.message); @@ -47,6 +28,7 @@ export default { } = req; try { const token = await get(id); + if (!token) fail("Ecash not found"); const status = await check(token); res.send({ token, status }); } catch (e) { @@ -61,108 +43,34 @@ export default { user, } = req; try { - const amount = await claim(token); - - let memo; - const hash = v4(); - const { currency, id: uid } = user; - const rates = await g("rates"); - await s(`invoice:${hash}`, { - currency, - id: hash, - hash, - rate: rates[currency], - uid, - received: 0, - }); - - await credit({ hash, amount, memo, ref: user.id, type }); - - res.send({ ok: true }); + const amount = await redeem({ token, user }); + res.send({ ok: true, amount }); } catch (e) { err(e.message); bail(res, e.message); } }, - async mint(req, res) { - const { body, user } = req; - const amount = parseInt(body.amount); - res.send(await sendCash({ amount, user })); - }, - - async melt(req, res) { - let { - body: { amount, bolt11: hash, preimage }, - user, - } = req; - try { - amount = Math.round(amount / 1000); - const ref = preimage; - const { lightning: type } = PaymentType; - if (user.username !== "mint") fail("unauthorized"); - const { id: uid, currency } = user; - const ourfee = await db.debit( - `balance:${uid}`, - `credit:${type}:${uid}`, - "Insufficient funds", - amount || 0, - 0, - 0, - 0, - 0 - ); - - const rates = await g("rates"); - const rate = rates[currency]; - - if (ourfee.err) fail(ourfee.err); - - const id = v4(); - const p = { - id, - amount: -amount, - hash, - ourfee, - uid, - confirmed: true, - rate, - currency, - type, - ref, - created: Date.now(), - }; - - await s(`payment:${hash}`, id); - await s(`payment:${id}`, p); - await db.lPush(`${uid}:payments`, id); - - l(user.username, "sent", type, amount); - emit(user.id, "payment", p); - - res.send(p); - } catch (e) { - bail(res, e.message); - } - }, - + // NUT-18 payment-request transport: a wallet POSTs proofs for one of our + // ecash-type invoices, which are melted into a Lightning invoice for the + // invoice's owner under the same id. async receive(req, res) { try { const { id, proofs, mint, memo } = req.body; - const { uid: ref } = await getInvoice(id); - - const amount = await claim( - getEncodedToken({ - mint, - proofs, - }), - ); + const invoice = await getInvoice(id); + if (!invoice) fail("Invoice not found"); + const user = await getUser(invoice.uid); + if (!user) fail("User not found"); - await credit({ hash: id, amount, memo, ref, type }); + await redeem({ + token: getEncodedToken({ mint, proofs }), + user, + invoice, + memo, + }); res.send({ id }); } catch (e) { - console.log(e); err(e.message); bail(res, e.message); } diff --git a/routes/email.ts b/routes/email.ts index 9f0a8228..749fba5e 100644 --- a/routes/email.ts +++ b/routes/email.ts @@ -1,61 +1,78 @@ -import { bail } from "$lib/utils"; +import { bail, fail } from "$lib/utils"; import got from "got"; import config from "$config"; import { l, warn } from "$lib/logging"; import { SESClient } from "@aws-sdk/client-ses"; import { SendEmailCommand } from "@aws-sdk/client-ses"; +// reCAPTCHA v3 never challenges: siteverify returns success:true for any +// well-formed token and reports how human the request looked in `score`. +// Checking `success` alone let a headless scanner submit freely. +const MIN_SCORE = 0.5; + +const escapeHtml = (s: string) => + s + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + +// Strip CR/LF and other control characters from single-line header values. +const oneLine = (s: string) => s.replace(/[\x00-\x1f\x7f]+/g, " ").trim(); + export default { - async send({ body }, res) { + async send({ body, headers }, res) { try { const { email, message, username, token: response } = body; + if (typeof message !== "string" || !message.trim()) fail("Message required"); + if (typeof email !== "string" || !email.trim()) fail("Email required"); + if (username != null && typeof username !== "string") fail("Invalid username"); + if (typeof response !== "string" || !response) fail("failed captcha"); const Charset = "UTF-8"; const { recaptcha: secret } = config; - const { success } = await got + const remoteip = headers?.["cf-connecting-ip"]; + const { success, score, action } = await got .post("https://www.google.com/recaptcha/api/siteverify", { - form: { - secret, - response, - }, + form: { secret, response, ...(remoteip && { remoteip }) }, + timeout: { request: 6000 }, }) - .json(); + .json<any>(); - if (success || response === config.adminpass) { - body.token = undefined; + if (!success || action !== "submit" || !(score >= MIN_SCORE)) { + warn("support request rejected", email, "score", score, "action", action); + fail("failed captcha"); + } - warn("support request from", email); - const client = new SESClient({ region: "us-east-2" }); - await client.send( - new SendEmailCommand({ - Destination: { - CcAddresses: [], - ToAddresses: [config.support], + l("support request from", email, "score", score); + const name = username ? oneLine(username).slice(0, 100) : ""; + const client = new SESClient({ region: "us-east-2" }); + await client.send( + new SendEmailCommand({ + Destination: { + CcAddresses: [], + ToAddresses: [config.support], + }, + Message: { + Body: { + Html: { Charset, Data: escapeHtml(message).replace(/\n/g, "<br>") }, + Text: { Charset, Data: message }, }, - Message: { - Body: { - Html: { Charset, Data: message.replace(/\n/g, "<br>") }, - Text: { Charset, Data: message }, - }, - Subject: { - Charset, - Data: - body.subject || - `Support Request${username ? ` From ${username}` : ""}`, - }, + Subject: { + Charset, + Data: `Support Request${name ? ` From ${name}` : ""}`, }, - ReplyToAddresses: [email], - Source: config.support, - }), - ); + }, + ReplyToAddresses: [oneLine(email)], + Source: config.support, + }), + ); - res.send({ ok: true }); - } else { - bail(res, "failed captcha"); - } + res.send({ ok: true }); } catch (e) { - console.log(e); + bail(res, e.message); } }, }; diff --git a/routes/info.ts b/routes/info.ts index c6d03e4d..4ab11872 100644 --- a/routes/info.ts +++ b/routes/info.ts @@ -1,9 +1,15 @@ import { archive } from "$lib/db"; -import { db, g } from "$lib/db"; +import { db } from "$lib/db"; +import { getHealthStatus } from "$lib/health"; import ln from "$lib/ln"; -import { getDecodedToken } from "@cashu/cashu-ts"; export default { + async health(_, res) { + const status = getHealthStatus(); + const httpStatus = status.healthy ? 200 : 503; + res.status(httpStatus).send(status); + }, + async balances(_, res) { let total = 0; @@ -21,13 +27,7 @@ export default { ); const lnwallet = parseInt(funds.outputs.reduce((a, b) => a + b.value, 0)); - const cash = getDecodedToken(await g("cash")).proofs.reduce( - (a, b) => a + b.amount, - 0, - ); - const info = { - cash, lnchannel, lnwallet, }; diff --git a/routes/invoices.ts b/routes/invoices.ts index ecac713c..b8d06bba 100644 --- a/routes/invoices.ts +++ b/routes/invoices.ts @@ -1,25 +1,30 @@ import config from "$config"; -import { db } from "$lib/db"; +import { db, g, gf, s } from "$lib/db"; import { generate } from "$lib/invoices"; import { err } from "$lib/logging"; -import { bail, fields, getInvoice, getUser } from "$lib/utils"; +import { bail, fail, fields, getInvoice, getUser } from "$lib/utils"; import rpc from "@coinos/rpc"; export default { async get(req, res) { - const { - params: { id }, - } = req; - const invoice = await getInvoice(id); + try { + const { + params: { id }, + } = req; + if (id === "undefined") fail("invalid id"); + const invoice = await getInvoice(id); - if (invoice) { - invoice.secret = undefined; - invoice.user = await getUser(invoice.uid, fields); + if (invoice) { + invoice.secret = undefined; + invoice.user = await getUser(invoice.uid, fields); - invoice.items ||= []; + invoice.items ||= []; + } + if (invoice) res.send(invoice); + else fail("invoice not found"); + } catch (e) { + bail(res, e.message); } - if (invoice) res.send(invoice); - else res.code(500).send("invoice not found"); }, async create(req, res) { @@ -28,17 +33,61 @@ export default { if (body.user) user = body.user; if (req.user.username === user.username) invoice.own = true; + else invoice.own = false; try { + // A supplied id that already exists is an UPDATE of that record — + // generate() rewrites it wholesale. Only the authenticated owner may do + // that. Without this, an anonymous pay-page session passing + // body.user = merchant could rewrite the amount/memo of a merchant's + // live invoice. generate() separately refuses to move an invoice to a + // different uid. + if (invoice?.id) { + const existing = await getInvoice(invoice.id); + if (existing && existing.uid !== req.user.id) fail("invoice not found"); + } const result = await generate({ invoice, user }); res.send(result); } catch (e) { - err( - "problem generating invoice", - req.user?.username, - body.user?.username, - e.message, - ); + bail(res, e.message); + } + }, + + async update(req, res) { + try { + const { id } = req.params; + const { body } = req; + const { tip, webhook, secret } = body.invoice; + + let invoice = await gf(`invoice:${id}`); + if (!invoice) fail("invoice not found"); + const user = await g(`user:${invoice.uid}`); + + if (typeof tip !== "undefined") { + const t = Number.parseInt(tip); + if (!Number.isSafeInteger(t) || t < 0 || t > 2_100_000_000_000_000) + fail("Invalid tip"); + // Defense-in-depth: never change the tip once the invoice is paid/ + // settled. The primary mint guard is on the credit side (it uses the + // tip the sender was actually debited); this stops post-settlement + // tampering. The endpoint stays open by design — payers set tips on + // merchants' invoices before paying — so we do NOT require ownership. + if (invoice.received > 0 || invoice.settled) + fail("Invoice already settled"); + invoice.tip = t; + } + + if (webhook && secret) { + if (invoice.uid !== req.user?.id) fail("Unauthorized"); + invoice.webhook = webhook; + invoice.secret = secret; + } + + invoice = await generate({ invoice, user }); + await s(`invoice:${id}`, invoice); + + res.send(invoice); + } catch (e) { bail(res, e.message); } }, @@ -46,13 +95,29 @@ export default { async list(req, res) { const { id } = req.user; let invoices = await db.lRange(`${id}:invoices`, 0, -1); - invoices = await Promise.all(invoices.map((i) => getInvoice(i))); + invoices = (await Promise.all(invoices.map((i) => getInvoice(i)))).filter( + Boolean, + ); res.send(invoices); }, async sign(req, res) { try { const { address, message, type = "bitcoin" } = req.body; + + // Sign only with an address the CALLER owns. Users have no per-user keys + // in this custodial design — every address belongs to the server hot + // wallet — so without this any authenticated account could have the node + // sign an arbitrary message under any address the wallet controls + // (another user's deposit address, a change address, a cold-storage + // address published as ours), which is a proof-of-control forgery + // primitive. The legitimate flow (coinos.io/sign, for Ocean offers that + // need a signable on-chain address) mints a fresh invoice address for + // the caller and posts that back, so it always satisfies this check. + const invoice = await getInvoice(address); + if (!invoice || (invoice.uid !== req.user.id && invoice.aid !== req.user.id)) + fail("unauthorized"); + const node = rpc(config[type]); if (config[type].walletpass) diff --git a/routes/lnurl.ts b/routes/lnurl.ts index 16f366d4..7f04db5d 100644 --- a/routes/lnurl.ts +++ b/routes/lnurl.ts @@ -1,20 +1,72 @@ -import { db, g, s } from "$lib/db"; +import { db, g, gf, s } from "$lib/db"; import { generate } from "$lib/invoices"; -import ln from "$lib/ln"; -import { err, warn } from "$lib/logging"; -import { serverPubkey } from "$lib/nostr"; -import { credit } from "$lib/payments"; -import { bail, fail, getInvoice, getUser } from "$lib/utils"; +import { err, quiet, suppressed, warn } from "$lib/logging"; +import { serverPubkey2 } from "$lib/nostr"; +import { SATS, bail, fail, getInvoice, getUser } from "$lib/utils"; import { bech32 } from "bech32"; -import got from "got"; +import { safeGot } from "$lib/safe-fetch"; +import { verifyEvent } from "nostr-tools"; import { v4 } from "uuid"; import { PaymentType } from "$lib/types"; +// A payRequest callback id is fetched once, minutes after the metadata +// request, and never again; without an expiry these one-shot pointers were +// ~45% of all keys in the main db. +const LNURL_TTL = 7 * 24 * 60 * 60; + const { URL } = process.env; const host = URL.split("/").at(-1); const fiveMinutes = 1000 * 60 * 5; +// Unauthenticated invoice generation (GET /pay/:user/:amount, GET /lnurl/:id) +// runs for whoever holds the link, so it needs a per-target budget: a scanner +// spread across a large proxy pool can mint junk invoices on many accounts +// (each a CLN invoice plus a :invoices entry) while staying invisible to +// per-IP limits. 120 fresh invoices per target per hour is far +// above any static-QR merchant's scan rate; reusing an existing invoice (the +// own-invoice path in pay(), the pinned invoice in lnurl()) doesn't count. +const unauthInvoiceBudget = async (uid: string) => { + const key = `invbudget:${uid}`; + const n = await db.incr(key); + if (n === 1) await db.expire(key, 3600); + if (n > 120) { + const q = quiet(`invbudget:${uid}`); + if (q !== null) warn("invoice budget exceeded", uid, n, suppressed(q)); + fail("Too many payment requests for this user, try again later"); + } +}; +// v3 payment addresses (coinos v3 / halwallet) are registered here. +const NAMES_URL = process.env.NAMES_URL || "https://names.coinos.io"; + +// Does the v3 registrar serve this name? Returns its payRequest, or null. +// Cached briefly: claims change on the order of days, and this sits on the +// payment path for every coinos.io address lookup. Only definitive answers +// (payRequest / 404) are cached — a sick registrar shouldn't be remembered. +const registrarCache = new Map(); +const REGISTRAR_TTL = 60_000; +async function registrarLookup(name) { + const hit = registrarCache.get(name); + if (hit && Date.now() - hit.at < REGISTRAR_TTL) return hit.body; + try { + const r = await fetch( + `${NAMES_URL}/.well-known/lnurlp/${encodeURIComponent(name)}?domain=${host}`, + { signal: AbortSignal.timeout(3000) }, + ); + if (r.ok) { + const body: any = await r.json(); + if (body?.tag === "payRequest") { + registrarCache.set(name, { at: Date.now(), body }); + return body; + } + } + if (r.status === 404) registrarCache.set(name, { at: Date.now(), body: null }); + } catch (e) { + warn("names registrar lookup failed for", name, (e as any).message); + } + return null; +} + export default { async encode(req, res) { const { @@ -26,7 +78,7 @@ export default { .replace(/\s/g, "")}`; try { - const r = await got(url).json(); + const r = await safeGot(url); if (r.tag !== "payRequest") fail("not an ln address"); } catch (e) { const m = `failed to lookup lightning address ${address}`; @@ -47,7 +99,7 @@ export default { bech32.fromWords(bech32.decode(text, 20000).words), ).toString(); - const r = await got(url).json(); + const r = await safeGot(url); res.send(r); } catch (e) { bail(res, e.message); @@ -60,15 +112,36 @@ export default { query: { minSendable = 1000, maxSendable = 100000000000 }, } = req; try { - const user = await getUser( - username - .replace("lightning:", "") - .replace(/\s/g, "") - .replace("=", "") - .toLowerCase(), - ); + const name = username + .replace("lightning:", "") + .replace(/\s/g, "") + .replace("=", "") + .toLowerCase(); - if (!user) fail(`User ${username} not found`); + // Resolution order for [email protected] — this has flip-flopped twice, so + // the invariant, in full: + // 1. A name CLAIMED in the v3 registrar is served by it. That is where + // migrated accounts live — their legacy user record still exists + // here, so "local account exists" does NOT mean the name is ours — + // and also v3-native custom names, which have no local account and + // only a npub1* Cloudflare rule redirecting for them today. + // 2. Otherwise a local account is served locally. Never key this on + // `migrated`: register.ts stamps that flag on essentially every + // account (it means "don't reserve this name for v3", not "user + // moved away"). + // 3. Neither → not found. + // No loop: step 1 defers only for names the registrar HAS, and the + // registrar never queries this endpoint for those. If the registrar is + // unreachable we fall through and serve the local account — a briefly + // stale destination beats an unpayable address. + const v3 = await registrarLookup(name); + if (v3) return res.send(v3); + + const user = await getUser(name); + // Evicted accounts can't receive either: stops a lightning address that + // a scam page points victims at from issuing new invoices. + if (!user || (await db.sIsMember("evicted", user.id))) + fail(`User ${username} not found`); const { id: uid } = user; const metadata = JSON.stringify([ @@ -77,21 +150,24 @@ export default { ]); const id = v4(); - await s(`lnurl:${id}`, uid); + await s(`lnurl:${id}`, uid, LNURL_TTL); res.send({ allowsNostr: true, minSendable, maxSendable, metadata, - nostrPubkey: serverPubkey, + nostrPubkey: serverPubkey2, commentAllowed: 512, callback: `${URL}/api/lnurl/${id}`, tag: "payRequest", }); } catch (e) { - if (!e.message.includes("found")) - warn("problem generating lnurlp request", username, e.message); + if (!e.message.includes("found")) { + const n = quiet(`lnurlp:${username}`, 5 * 60 * 1000); + if (n !== null) + warn("problem generating lnurlp request", username, e.message, suppressed(n)); + } bail(res, e.message); } }, @@ -102,22 +178,27 @@ export default { query: { amount, comment, nostr }, } = req; try { + const iid = await g(`lnurl:${id}:invoice`); const uid = await g(`lnurl:${id}`); const user = await getUser(uid); - if (!user) fail("user not found"); + + if (!user || (await db.sIsMember("evicted", user.id))) + fail("user not found"); let { username } = user; username = username.replace(/\s/g, "").toLowerCase(); - const memo = comment ?? `Paying ${username}@${host}`; let metadata = JSON.stringify([ - ["text/plain", memo], + ["text/plain", `Paying ${username}@${host}`], ["text/identifier", `${username}@${host}`], ]); if (nostr) { try { const event = JSON.parse(decodeURIComponent(nostr)); - // TODO: validate the event + // NIP-57: must be a signed kind-9734 zap request. Reject anything + // else so we can't be tricked into storing a forged zap receipt. + if (event.kind !== 9734 || !verifyEvent(event)) + throw new Error("invalid zap request"); await s(`zap:${id}`, event); metadata = nostr; } catch (e) { @@ -125,17 +206,44 @@ export default { } } - const { id: iid, text: pr } = await generate({ - invoice: { - amount: Math.round(amount / 1000), - memo: metadata, - type: PaymentType.lightning, - }, - user, + if (!iid) await unauthInvoiceBudget(user.id); + const invoice = iid + ? await gf(`invoice:${iid}`) + : await generate({ + invoice: { + amount: Math.round(amount / 1000), + memo: metadata, + type: PaymentType.lightning, + }, + user, + }); + + if (comment) { + // Enforce the commentAllowed:512 we advertise in the lnurlp response. + // This overwrite happens after generate()'s own memo validation, so an + // uncapped comment would otherwise reach credit() at settlement time. + invoice.memo = String(comment).slice(0, 512); + await s(`invoice:${invoice.id}`, invoice); + } + + res.send({ + pr: invoice.text, + routes: [], + verify: `${URL}/api/lnurl/verify/${invoice.id}`, }); + } catch (e) { + bail(res, e.message); + } + }, - res.send({ pr, routes: [], verify: `${URL}/api/lnurl/verify/${iid}` }); + async proxy(req, res) { + const { url } = req.query; + if (!url) return bail(res, "url required"); + try { + const r = await safeGot(url); + res.send(r); } catch (e) { + warn("lnurl proxy failed", url, e.message); bail(res, e.message); } }, @@ -154,29 +262,49 @@ export default { }, async pay(req, res) { - const { username } = req.params; + const { amount, username } = req.params; try { const user = await getUser(username); + if (!user) fail(`User ${username} not found`); const invoices = await db.lRange(`${user.id}:invoices`, 0, 10); let invoice; + for (const iid of invoices) { const i = await getInvoice(iid); + if (!i) continue; const paid = i.amount > 0 && i.received >= i.amount; const old = Date.now() - i.created > fiveMinutes; - if (i.own && !(paid || old)) { + if (paid) break; + if (i.own && !old) { invoice = i; break; } } - if (!invoice) + if (invoice) { + if (amount?.startsWith("+")) { + await unauthInvoiceBudget(user.id); + const tip = invoice.amount * amount.split("+")[1]; + invoice = await generate({ + invoice: { + ...invoice, + tip, + }, + user, + }); + } + } else { + await unauthInvoiceBudget(user.id); invoice = await generate({ invoice: { + amount, + prompt: user.prompt, type: PaymentType.lightning, }, user, }); + } const { id: uid } = user; @@ -186,71 +314,30 @@ export default { ]); const id = v4(); - await s(`lnurl:${id}`, uid); - const total = (parseInt(invoice.amount || 0) + parseInt(invoice.tip || 0)) * 1000; + + const total = + (parseInt(invoice.amount || 0) + parseInt(invoice.tip || 0)) * 1000; + + await s(`lnurl:${id}`, uid, LNURL_TTL); + if (total > 0) await s(`lnurl:${id}:invoice`, invoice.id, LNURL_TTL); res.send({ allowsNostr: true, - minSendable: total, - maxSendable: invoice.amount ? total : undefined, + minSendable: invoice.amount ? total : 1000, + maxSendable: invoice.amount ? total : 10 * 1000 * SATS, metadata, - nostrPubkey: serverPubkey, + nostrPubkey: serverPubkey2, commentAllowed: 512, callback: `${URL}/api/lnurl/${id}`, tag: "payRequest", }); } catch (e) { - if (!e.message.includes("found")) - warn("problem generating lnurlp request", username, e.message); + if (!e.message.includes("found")) { + const n = quiet(`lnurlp:${username}`, 5 * 60 * 1000); + if (n !== null) + warn("problem generating lnurlp request", username, e.message, suppressed(n)); + } bail(res, e.message); } }, - - // async withdraw(req, res) { - // try { - // const { pr, k1: id } = req.query; - // const invoice = await getInvoice(pr); - // const { amount_msat } = await ln.decode(pr); - // const amount = Math.ceil(amount_msat / 1000); - // await db.debit(`fund:${id}`, "", amount, 0, 0, 0); - // let p; - // if (invoice) { - // const { hash } = invoice; - // p = await credit({ - // hash, - // amount, - // memo: id, - // ref: id, - // type: PaymentType.fund, - // }); - // } else { - // await ln.xpay({ - // invstring: pr.replace(/\s/g, "").toLowerCase(), - // maxfee: 0, - // retry_for: 10, - // }); - // const rates = await g("rates"); - // p = { - // id: v4(), - // amount: -amount, - // hash: pr, - // confirmed: true, - // rate: rates.USD, - // currency: "USD", - // type: PaymentType.fund, - // ref: id, - // created: Date.now(), - // }; - // await s(`payment:${p.id}`, p); - // await s(`payment:${pr}`, p); - // } - // - // await db.lPush(`fund:${id}:payments`, p.id); - // res.send({ status: "OK" }); - // } catch (e) { - // console.log(e); - // warn("lnurlw failed", e.message); - // res.send({ status: "ERROR", reason: "Withdrawal failed" }); - // } - // }, }; diff --git a/routes/nostr.ts b/routes/nostr.ts index 9c61bf16..3b28076a 100644 --- a/routes/nostr.ts +++ b/routes/nostr.ts @@ -21,6 +21,36 @@ import { decode } from "nostr-tools/nip19"; import type { ProfilePointer } from "nostr-tools/nip19"; import { getZapEndpoint, makeZapRequest } from "nostr-tools/nip57"; +// Amount and payer of a zap receipt. NIP-57 kind 9735: amount comes from +// decoding the paid bolt11, payer from the embedded 9734 zap request in the +// description tag. NIP-177 kind 9736 (BOLT12 zaps): amount is an explicit +// msat tag, and the receipt is payer-published with the signed 9737 intent in +// the description tag. +const parseZap = async (ev) => { + const { kind, tags, pubkey } = ev; + const description = tags.find((t) => t[0] === "description")?.[1]; + + let payer; + try { + payer = JSON.parse(description).pubkey; + } catch (e) {} + payer ||= pubkey; + + let amount = 0; + if (kind === 9736) { + const msat = Number.parseInt(tags.find((t) => t[0] === "amount")?.[1]); + if (msat > 0) amount = Math.round(msat / 1000); + } else { + try { + const bolt11 = tags.find((t) => t[0] === "bolt11")?.[1]; + const { amount_msat } = await ln.decode(bolt11); + if (amount_msat) amount = Math.round(amount_msat / 1000); + } catch (e) {} + } + + return { amount, pubkey: payer }; +}; + export default { async event(req, res) { try { @@ -35,6 +65,7 @@ export default { if (event) return res.send(event); event = await get({ ids: [id] }); + if (!event) return res.code(404).send("Not found"); if (!full) return res.send(event); const parts = parseContent(event); @@ -45,23 +76,14 @@ export default { ) .map(({ value }) => value.pubkey); - const zapEvents = await scan({ kinds: [9735], "#e": [id] }); + const zapEvents = await scan({ kinds: [9735, 9736], "#e": [id] }); const zaps = []; - for (const { tags } of zapEvents) { - const bolt11 = tags.find((t) => t[0] === "bolt11")?.[1]; - const description = tags.find((t) => t[0] === "description")?.[1]; - const { pubkey } = JSON.parse(description); - let amount = 0; - try { - const { amount_msat } = await ln.decode(bolt11); - if (amount_msat) amount = Math.round(amount_msat / 1000); - } catch (e) {} - - zaps.push({ amount, pubkey }); + for (const zapEvent of zapEvents) { + zaps.push(await parseZap(zapEvent)); } pubkeys.push(event.pubkey); - pubkeys.push(...zaps.map((z) => z.pubkey)); + pubkeys.push(...zaps.map((z) => z.pubkey).filter((p) => p)); pubkeys = [...new Set(pubkeys)]; const profiles = await scan({ kinds: [0], authors: pubkeys }); const found = profiles.map((p) => p.pubkey); @@ -107,7 +129,6 @@ export default { res.send(event); } catch (e) { - console.log(e); bail(res, e.message); } }, @@ -158,7 +179,6 @@ export default { res.send(thread); } catch (e) { - console.log(e); bail(res, e.message); } }, @@ -168,24 +188,16 @@ export default { let { id } = req.params; if (id.startsWith("nevent")) id = decode(id).data.id; if (id.startsWith("note")) id = decode(id).data; - const filter = { kinds: [9735], "#e": [id] }; - let events = await scan(filter); - if (!events.length) { - await sync("wss://relay.primal.net", filter); - events = await scan(filter); - } + // q() scans the local strfry and falls back to primal (the old code + // called a `sync` helper that never existed and threw ReferenceError + // whenever the local relay had no receipts) + const filter = { kinds: [9735, 9736], "#e": [id] }; + const events = await q(filter); if (!events.length) return res.send([]); const zaps = []; - for (const { tags } of events) { - const bolt11 = tags.find((t) => t[0] === "bolt11")?.[1]; - const description = tags.find((t) => t[0] === "description")?.[1]; - const { pubkey } = JSON.parse(description); - let amount = 0; - try { - const { amount_msat } = await ln.decode(bolt11); - if (amount_msat) amount = Math.round(amount_msat / 1000); - } catch (e) {} + for (const zapEvent of events) { + const { amount, pubkey } = await parseZap(zapEvent); let user; if (pubkey) { @@ -198,7 +210,6 @@ export default { res.send(zaps.filter((z) => z.amount > 0)); } catch (e) { - console.log(e); bail(res, e.message); } }, @@ -217,7 +228,6 @@ export default { res.send({}); } catch (e) { - console.log(e); bail(res, e.message); } }, @@ -244,7 +254,6 @@ export default { res.send(events); } catch (e) { - console.log(e); bail(res, e.message); } }, @@ -319,7 +328,6 @@ export default { res.send(followers); } catch (e) { - console.log(e); bail(res, e.message); } }, @@ -330,7 +338,6 @@ export default { // res.send({ followers: 0, follows: 0 }); res.send(await getCount(pubkey)); } catch (e) { - console.log(e); bail(res, e.message); } }, @@ -341,7 +348,21 @@ export default { } = req; let names = {}; if (name) { - names = { [name]: (await getUser(name, fields)).pubkey }; + // v3 registrar first: a claimed name's NIP-05 belongs to its wallet key. + // Legacy accounts answer as before when the registrar doesn't know it. + try { + const r = await fetch( + `${process.env.NAMES_URL || "https://names.coinos.io"}/.well-known/nostr.json?name=${encodeURIComponent(name)}`, + { signal: AbortSignal.timeout(3000) }, + ); + if (r.ok) { + const j = await r.json(); + if (j?.names?.[name]) return res.send({ names: { [name]: j.names[name] } }); + } + } catch {} + const u = await getUser(name, fields); + if (!u) return res.send({ names: {} }); // unknown name: empty per NIP-05, not a 500 + names = { [name]: u.pubkey }; } else { const records = await db.sMembers("nip5"); for (const s of records) { @@ -379,9 +400,7 @@ export default { }); res.send(event); - } catch (e) { - console.log(e); - } + } catch (e) {} }, async zap(req, res) { @@ -399,7 +418,6 @@ export default { res.send(json); } catch (e) { - console.log(e); bail(res, e.message); } }, diff --git a/routes/payments.ts b/routes/payments.ts index 66a19e53..66fd3386 100644 --- a/routes/payments.ts +++ b/routes/payments.ts @@ -1,9 +1,12 @@ import config from "$config"; import api from "$lib/api"; import { requirePin } from "$lib/auth"; -import { db, g, s } from "$lib/db"; +import { archive, db, g, gf, s, sa } from "$lib/db"; +import { generate, getUserOffer } from "$lib/invoices"; +import { replay } from "$lib/lightning"; import ln from "$lib/ln"; -import { err, l, warn } from "$lib/logging"; +import { platformFee, quoteMax, quoteRoutingFee } from "$lib/lnquote"; +import { err, l, quiet, shortError, suppressed, warn } from "$lib/logging"; import mqtt from "$lib/mqtt"; import { build, @@ -31,6 +34,19 @@ import rpc from "@coinos/rpc"; import got from "got"; import { v4 } from "uuid"; +// Funds are closed. The mechanism let a caller pull unbacked balance out of a +// fund (the /take drain, 2026-08-25) and its deposit path never had a +// kill-switch of its own, so with `fund:disabled` set users could still pay +// INTO funds they were then unable to withdraw from. Every value-moving entry +// point — deposit (POST /payments with `fund`), withdrawal (POST /take) and +// pre-authorization (POST /authorize) — now refuses unconditionally, rather +// than depending on a redis flag someone can clear by accident. +// +// Read paths are deliberately left working: GET /fund/:id and the manager +// endpoints still serve existing funds so balances stay visible and can be +// settled out of band. +const fundsClosed = () => fail("Funds are no longer supported"); + export default { async info(_, res) { res.send(await ln.getinfo()); @@ -43,8 +59,21 @@ export default { const balance = await g(`balance:${user.id}`); try { + // Reject non-string payreq/hash up front. A client sending a number/object + // here otherwise reaches a decoder that calls `.startsWith` on it and throws + // an opaque "n.startsWith is not a function" (logged bare via the catch + // below). A malformed *string* is fine — it fails later with a clear error. + if (payreq != null && typeof payreq !== "string") + fail("Invalid payment request"); + if (hash != null && typeof hash !== "string") fail("Invalid invoice"); + // Invoices are keyed lowercase. An uppercase payreq (QR-scanned) missed + // the lookup below and was paid over cl to ourselves instead of + // internally; the listener then found the sender's payment:<bolt11> + // pointer and skipped crediting the recipient. + if (payreq) payreq = payreq.replace(/\s/g, "").toLowerCase(); + if (typeof amount !== "undefined") { - amount = parseInt(amount); + amount = Number.parseInt(amount); if (amount < 0 || amount > SATS || Number.isNaN(amount)) fail("Invalid amount"); } @@ -56,7 +85,7 @@ export default { const invoice = await getInvoice(payreq || hash); const recipient = invoice ? await getUser(invoice.uid) : undefined; if (payreq) { - if (invoice && recipient.username !== "mint") { + if (invoice) { if (invoice.aid === user.id) fail("Cannot send to self"); hash = payreq; if (!amount) ({ amount } = invoice); @@ -75,23 +104,19 @@ export default { sender: user, }); } else if (fund) { - p = await debit({ - hash, - amount, - memo: fund, - user, - type: PaymentType.fund, - }); - await db.incrBy(`fund:${fund}`, amount); - await db.lPush(`fund:${fund}:payments`, p.id); - l("funded fund", fund); + fundsClosed(); } } res.send(p); } catch (e) { - warn(user.username, "payment failed", amount, balance, hash, payreq); - err(e.message); + // One line per account per 5 min: frozen/empty throwaway accounts retry + // failed sends in a loop and otherwise dominate the log. + const n = quiet(`payfail:${user.id}`, 5 * 60 * 1000); + if (n !== null) { + warn(user.username, "payment failed", amount, balance, hash, payreq, suppressed(n)); + err(shortError(e.message)); + } bail(res, e.message); } }, @@ -99,25 +124,42 @@ export default { async list(req, res) { let { user: { id }, - query: { aid, start, end, limit, offset }, + query: { aid, start, end, limit, offset, received }, } = req; if (!aid || aid === "undefined") aid = id; - limit = parseInt(limit); - offset = parseInt(offset) || 0; + const index = await db.lPos(`${id}:accounts`, aid); + if (index === null) fail("unauthorized"); + + limit = Number.parseInt(limit); + offset = Number.parseInt(offset) || 0; - const range = !limit || start || end ? -1 : limit - 1; - let payments = (await db.lRange(`${aid || id}:payments`, 0, range)) || []; + const range = !limit || received || start || end ? -1 : limit - 1; + const listKey = `${aid || id}:payments`; + let payments = (await db.lRange(listKey, 0, range)) || []; + + if (range === -1) { + const archived = (await archive.lRange(listKey, 0, -1)) || []; + payments = [...new Set([...payments, ...archived])]; + } else if (limit) { + const needed = Math.max(0, limit + offset - payments.length); + if (needed > 0) { + const archived = + (await archive.lRange(listKey, 0, limit + offset - 1)) || []; + payments = [...new Set([...payments, ...archived])]; + } + } payments = ( await Promise.all( payments.map(async (pid) => { - const p = await g(`payment:${pid}`); + const p = await gf(`payment:${pid}`); if (!p) { warn("user", id, "missing payment", pid); - await db.lRem(`${aid || id}:payments`, 0, pid); - return p; + return; } + if (p.revertedDuplicate) return; + if (received && p.amount < 0) return; if (p.created < start || p.created > end) return; if (p.type === PaymentType.internal) p.with = await getUser(p.ref, fields); @@ -133,7 +175,7 @@ export default { [b.currency]: { tips: (a[b.currency] ? a[b.currency].tips : 0) + (b.tip || 0), fiatTips: ( - parseFloat(a[b.currency] ? a[b.currency].fiatTips : 0) + + Number.parseFloat(a[b.currency] ? a[b.currency].fiatTips : 0) + ((b.tip || 0) * b.rate) / SATS ).toFixed(2), sats: @@ -143,7 +185,7 @@ export default { (b.fee || 0) - (b.ourfee || 0), fiat: ( - parseFloat(a[b.currency] ? a[b.currency].fiat : 0) + + Number.parseFloat(a[b.currency] ? a[b.currency].fiat : 0) + (((b.amount || 0) + ((b.amount > 0 ? b.tip : -b.tip) || 0) - (b.fee || 0) - @@ -171,9 +213,9 @@ export default { const p = await getPayment(hash); if (p?.type === PaymentType.internal) p.with = await getUser(p.ref, fields); + if (p?.type === PaymentType.fund) p.with = await getUser(p.uid, fields); res.send(p); } catch (e) { - console.log(e); err("failed to get payment", e.message); bail(res, e.message); } @@ -185,14 +227,19 @@ export default { user, } = req; try { + // Node alias lookup, refreshed hourly. Only the id→alias map is kept: + // the old code stored the full listnodes output (~10MB for 16k nodes) + // as an array, so the `last` stamp it hung on the array was dropped by + // JSON.stringify and the condition was inverted anyway — every send + // page view refetched and rewrote the whole thing. const hour = 1000 * 60 * 60; - let nodes = await g("nodes"); - const { last } = nodes || {}; - - if (!last || last > Date.now() - hour) { - ({ nodes } = await ln.listnodes()); - nodes.last = Date.now(); - await s("nodes", nodes); + let cache = await g("nodes"); + if (!cache?.aliases || !cache.last || cache.last < Date.now() - hour) { + const { nodes } = await ln.listnodes(); + const aliases = {}; + for (const n of nodes) if (n.alias) aliases[n.nodeid] = n.alias; + cache = { last: Date.now(), aliases }; + await s("nodes", cache); } const decoded = await ln.decode(payreq); @@ -200,23 +247,30 @@ export default { let amount_msat; let payee; - if (decoded.type.includes("bolt12")) { + if (decoded.type === "bolt12 offer") { + ({ offer_amount_msat: amount_msat } = decoded); + payee = decoded.offer_issuer_id || decoded.offer_node_id; + } else if (decoded.type.includes("bolt12")) { ({ invoice_amount_msat: amount_msat, invoice_node_id: payee } = decoded); } else ({ amount_msat, payee } = decoded); - const node = nodes.find((n) => n.nodeid === payee); - const alias = node ? node.alias : payee.substr(0, 12); + const alias = cache.aliases[payee] || (payee || "").substr(0, 12); - const amount = Math.round(amount_msat / 1000); + const amount = Math.round((amount_msat || 0) / 1000); let ourfee = Math.round(amount * config.fee[PaymentType.lightning]); const credit = await g(`credit:lightning:${user.id}`); const covered = Math.min(credit, ourfee) || 0; ourfee -= covered; - res.send({ alias, amount, ourfee }); + res.send({ + alias, + amount, + ourfee, + type: decoded.type, + description: decoded.offer_description || decoded.description, + }); } catch (e) { - console.log(e); err("problem parsing", e.message); bail(res, e.message); } @@ -224,20 +278,47 @@ export default { async fund(req, res) { const { - params: { name }, + params: { id }, } = req; - const amount = await g(`fund:${name}`); + let amount = await g(`fund:${id}`); + let fid = id; + + // A rotated fund no longer exists at its OLD id (security fix 2026-08-25: + // fund secret uids were rotated to invalidate an exfiltrated id list). Serve + // the new fund ONLY to an authenticated MANAGER of it — never anonymously or + // to a non-owner. An open old->new redirect would defeat the rotation, since + // the attacker holds the leaked OLD ids; gating on manager membership means a + // replayed old id reveals nothing unless you already control the fund. The + // route's `optional` auth populates req.user without requiring it. Bearer + // funds (no managers) are intentionally not recoverable this way. + if (typeof amount === "undefined" || amount === null) { + const rotatedTo = await g(`fund:rotated:${id}`); + const uid = req.user?.id; + if (rotatedTo && uid && (await db.sIsMember(`fund:${rotatedTo}:managers`, uid))) { + fid = rotatedTo; + amount = await g(`fund:${rotatedTo}`); + } + } + if (typeof amount === "undefined" || amount === null) return bail(res, "fund not found"); - let payments = (await db.lRange(`fund:${name}:payments`, 0, -1)) || []; - payments = await Promise.all(payments.map((hash) => g(`payment:${hash}`))); + + let payments = (await db.lRange(`fund:${fid}:payments`, 0, -1)) || []; + payments = await Promise.all(payments.map((hash) => gf(`payment:${hash}`))); await Promise.all( payments.map(async (p: any) => (p.user = await getUser(p.uid, fields))), ); payments = payments.filter((p) => p); - res.send({ amount, payments }); + + const authorization = await g(`authorization:${fid}`); + res.send({ + amount, + authorization: authorization?.amount, + payments, + ...(fid !== id ? { id: fid, rotatedFrom: id } : {}), + }); }, // async withdraw(req, res) { @@ -258,38 +339,134 @@ export default { // }); // }, + async authorize(req, res) { + fundsClosed(); + + const { id: uid } = req.user; + const { id, fiat, currency, amount } = req.body; + + const managers = await db.sMembers(`fund:${id}:managers`); + if (managers.length && !managers.includes(uid)) fail("Unauthorized"); + + // The authorization's fiat/currency is the ONLY ceiling on how much a later + // /take can pull from the authorizer (cap = sats(fiat / rates[currency])). + // Reject a NaN/zero/non-finite rate or a non-positive fiat so the ceiling + // can never be turned into an astronomically large or non-finite value. + const rates = await g("rates"); + const rate = Number(rates?.[currency]); + const fiatAmount = Number(fiat); + if (!Number.isFinite(rate) || rate <= 0) fail("Invalid currency"); + if (!Number.isFinite(fiatAmount) || fiatAmount <= 0) fail("Invalid amount"); + + const authorization = { + uid, + currency, + fiat: fiatAmount, + amount: Number.parseInt(amount) || 0, + }; + + await s(`authorization:${id}`, authorization); + res.send({}); + }, + async take(req, res) { let { body: { id, amount, invoice: iid }, user, } = req; try { - const { id: uid } = user; - amount = parseInt(amount); - if (amount < 0) fail("Invalid amount"); - - const managers = await db.sMembers(`fund:${id}:managers`); - if (managers.length && !managers.includes(user.id)) fail("Unauthorized"); + fundsClosed(); - const result: any = await db.debit(`fund:${id}`, "", "Insufficient funds", amount, 0, 0, 0, 0); - if (result.err) fail(result.err); + amount = Number.parseInt(amount); + if (!Number.isFinite(amount) || amount <= 0) fail("Invalid amount"); const rates = await g("rates"); - const { currency } = user; - const rate = rates[currency]; if (!iid) { - iid = v4(); - await s(`invoice:${iid}`, { - currency, - id: iid, - hash: iid, - rate, - uid, - received: 0, + const inv = await generate({ + invoice: { amount, type: "lightning" }, + user, + }); + iid = inv.id; + } + + const authorization = await g(`authorization:${id}`); + if (authorization && !authorization.claimed) { + const { currency, fiat } = authorization; + // Bound the take to the authorized fiat value using a VALIDATED fiat + // rate. A non-finite/zero rate or a crypto-denominated authorization + // (rate ~1) would otherwise make sats(fiat / rate) an astronomically + // large ceiling, defeating the cap (the drain vector). + const rate = Number(rates?.[currency]); + const fiatAmount = Number(fiat); + if (!Number.isFinite(rate) || rate <= 0) + fail("Invalid authorization currency"); + if (!Number.isFinite(fiatAmount) || fiatAmount <= 0) + fail("Invalid authorization amount"); + const cap = sats(fiatAmount / rate); + if (!Number.isFinite(cap) || cap <= 0) fail("Invalid authorization"); + amount = Math.min(amount, cap); + if (!Number.isFinite(amount) || amount <= 0) fail("Invalid amount"); + + // Atomic single-use claim. The read above is not a lock: concurrent + // POST /take for the same authorization all pass the `!claimed` gate and, + // without an atomic gate, each would debit the authorizer and fund the + // pool — redeeming a single-use authorization N times (COINOS-3). SET NX + // lets exactly one caller win the claim; the rest skip the funding. + const claimed = await db.set(`authorization:${id}:claimed`, user.id, { + NX: true, }); + if (claimed) { + // Mark the record claimed only once the funding has actually landed, + // and release the claim if it throws — otherwise a failed funding + // (an insufficient balance, a server limit) burns the authorization + // permanently: the key is set, the fund never gets the money, and + // every later /take skips the funding block. + try { + const sender = await getUser(authorization.uid); + if (!sender) fail("authorizer not found"); + + const { hash } = await generate({ + invoice: { amount, type: "lightning" }, + user: sender, + }); + + const { id: pid } = await debit({ + hash, + amount, + memo: id, + user: sender, + type: PaymentType.fund, + }); + + await db.incrBy(`fund:${id}`, amount); + await db.lPush(`fund:${id}:payments`, pid); + + authorization.claimed = true; + await s(`authorization:${id}`, authorization); + l("funded fund", id); + } catch (e) { + await db.del(`authorization:${id}:claimed`); + throw e; + } + } } + const managers = await db.sMembers(`fund:${id}:managers`); + if (managers.length && !managers.includes(user.id)) fail("Unauthorized"); + + const result: any = await db.debit( + `fund:${id}`, + "", + "Insufficient funds", + amount, + 0, + 0, + 0, + 0, + ); + if (result.err) fail(result.err); + const payment = await credit({ aid: user.id, hash: iid, @@ -301,7 +478,7 @@ export default { await db.lPush(`fund:${id}:payments`, payment.id); - res.send({ payment }); + res.send(payment); } catch (e) { warn("problem withdrawing from fund", user.username, e.message); bail(res, e.message); @@ -313,9 +490,9 @@ export default { const ids = await db.sMembers(`fund:${name}:managers`); - const managers = await Promise.all( - ids.map(async (id) => await getUser(id)), - ); + const managers = (await Promise.all( + ids.map(async (id) => await getUser(id, fields)), + )).filter(Boolean); res.send(managers); }, @@ -333,17 +510,42 @@ export default { await db.sAdd(k, user.id); } - const u = await getUser(username); + const u = await getUser(username, fields); if (!u) fail("User not found"); const { id: uid } = u; await db.sAdd(k, uid); const ids = await db.sMembers(k); - if (!managers.length) - managers = await Promise.all(ids.map(async (id) => await getUser(id))); + const resolved = (await Promise.all( + ids.map(async (id) => await getUser(id, fields)), + )).filter(Boolean); - res.send(managers); + res.send(resolved); + }, + + async deleteManager(req, res) { + try { + const { name } = req.params; + const { id: uid } = req.body; + const { user } = req; + + const k = `fund:${name}:managers`; + let managers = await db.sMembers(k); + + if (managers.length) { + if (!managers.includes(user.id)) fail("Unauthorized"); + } + + await db.sRem(k, uid); + + const ids = await db.sMembers(k); + managers = await Promise.all( + ids.map(async (id) => await getUser(id, fields)), + ); + + res.send(managers); + } catch (e) {} }, async confirm(req, res) { @@ -358,11 +560,34 @@ export default { let aid; if (!hot) aid = wallet; + // Change-recredit guard (2026-08-18). If our own wallet contributed inputs + // to this transaction, a "send" detail is present. In that case every + // receive output is our own CHANGE or an internal move — never an external + // deposit — so it must not be credited: doing so mints phantom balance + // (the withdrawal-change re-credit exploit that drove the L-C creep). A + // genuine coinos->coinos transfer is settled off-chain in the send path + // (credit() resolves getInvoice(hash) and books an internal transfer), so + // an on-chain receive funded by our own spend is only ever change. + const weSpent = details.some((d: any) => d.category === "send"); + for (const { address, amount, asset, category, vout } of details) { if (!address) continue; if (type === PaymentType.liquid && asset !== config.liquid.btc) continue; + // Kill switch: `liquid:deposits:disabled` stops every Liquid receive + // from being credited or confirmed (sends still get their + // confirmation bookkeeping below). Set during a security incident; + // clear the key to re-enable. + if ( + type === PaymentType.liquid && + category !== "send" && + (await g("liquid:deposits:disabled")) + ) { + warn("liquid deposit blocked (disabled)", txid, vout, sats(amount), address); + continue; + } + if (category === "send") { const p = await getPayment(txid); if (!p) continue; @@ -379,11 +604,30 @@ export default { continue; } + // See weSpent above: a receive output on a transaction our wallet funded + // is change, not a deposit. Skip it so we never re-credit our own funds. + // Log the blocked amount as a tripwire: sums the phantom credit prevented + // and surfaces any continued exploitation attempts. + if (weSpent) { + if (sats(amount) >= 300) + warn("blocked change re-credit", txid, vout, sats(amount), address); + continue; + } + const p = await getPayment(`${txid}:${vout}`); if (!p) { const invoice = await getInvoice(address); if (!hot && aid !== invoice?.aid) continue; + if (sats(amount) < 300) continue; + // Atomic first-seen guard (COINOS-2). The getPayment() read above is not + // a lock: concurrent callers (walletnotify, catchUp, bulk /confirm + // sweeps — /confirm is unauthenticated) can all read p==null for one + // deposit and each run credit(), double-crediting the pending balance + // AND inflating the spendable credit:<net>:<uid> fee accumulator. Mirror + // the confirm-stage lock so only the first caller credits. + const firstlock = `firstseenlock:${txid}:${vout}`; + if (!(await db.set(firstlock, "1", { NX: true, EX: 60 }))) continue; await credit({ hash: address, amount: sats(amount), @@ -393,15 +637,30 @@ export default { }); } else if (confirmations >= 1) { const id = `payment:${txid}:${vout}`; + // Atomic guard against concurrent /confirm callers double-crediting: + // walletnotify, catchUp, and bulk sweeps can all fire for the same + // txid:vout in parallel. Without this, two callers can each read + // p.confirmed=false and each run the multi() that increments balance. + const lockKey = `confirmlock:${txid}:${vout}`; + const acquired = await db.set(lockKey, "1", { NX: true, EX: 60 }); + if (!acquired) continue; + const p = await getPayment(`${txid}:${vout}`); - if (!p) return db.sAdd("missed", id); - if (p.confirmed) return; + if (!p) { + await db.sAdd("missed", id); + await db.del(lockKey); + return; + } + if (p.confirmed) { + await db.del(lockKey); + return; + } const invoice = await getInvoice(address); const { id: iid } = invoice; p.confirmed = true; - invoice.received += parseInt(invoice.pending); + invoice.received += Number.parseInt(invoice.pending); invoice.pending = 0; l("confirming", id, p.id, p.amount); @@ -414,13 +673,20 @@ export default { .incrBy(`balance:${p.aid || p.uid}`, p.amount) .exec(); + // Mirror the now-confirmed record into arc so a future bulk sweep + // finds it via gf() fallback even if the main-db pointer is wiped + // (see feedback_apr29_double_credit_incident.md). + await sa(`payment:${p.id}`, p); + await sa(`invoice:${iid}`, invoice); + + await db.del(lockKey); + const user = await g(`user:${p.uid}`); await completePayment(invoice, p, user); } } res.send({}); } catch (e) { - console.log(e); warn(`problem processing ${txid}`); bail(res, e.message); } @@ -454,7 +720,9 @@ export default { res.send(p); } catch (e) { - warn(user.username, "payment failed", e.message); + const n = quiet(`payfail:${user.id}`, 5 * 60 * 1000); + if (n !== null) + warn(user.username, "payment failed", shortError(e.message), suppressed(n)); res.code(500).send(e.message); } }, @@ -464,11 +732,10 @@ export default { body: { secret }, } = req; try { - if (secret !== config.adminpass) fail("unauthorized"); + if (!config.adminpass || secret !== config.adminpass) fail("unauthorized"); await s("freeze", true); res.send("ok"); } catch (e) { - console.log(e); bail(res, e.message); } }, @@ -479,7 +746,8 @@ export default { user, } = req; try { - const p = await g(`payment:${id}`); + const p = await gf(`payment:${id}`); + if (!p) fail("Payment not found"); if (p.uid !== user.id) fail("unauthorized"); emit(user.id, "payment", p); @@ -488,6 +756,7 @@ export default { mqtt.publish( username, `pay:${p.amount}:${p.tip}:${p.rate}:${p.created}:${p.id}`, + { qos: 1 }, ); res.send({ ok: true }); @@ -498,10 +767,11 @@ export default { async lnaddress(req, res) { let { - params: { lnaddress, amount, fee = 5000 }, + params: { lnaddress, amount }, body, user, } = req; + const { fee } = body; try { lnaddress = decodeURIComponent(lnaddress); await requirePin({ body, user }); @@ -525,32 +795,22 @@ export default { let p; if (payee === id) { p = await debit({ hash: pr, amount, memo, user }); - await credit({ hash: pr, amount, memo, ref: user.id }); + await credit({ hash: pr, amount, memo, ref: user.id, tip: p.tip }); } else p = await sendLightning({ user, pr, amount, fee, memo }); res.send(p); } catch (e) { - console.log(e); bail(res, e.message); } }, - async gateway(req, res) { - const { - body: { short_channel_id, webhook }, - } = req; - - await s(short_channel_id, webhook); - res.send({ ok: true }); - }, - async replace(req, res) { const { body: { id }, user, } = req; try { - const p = await g(`payment:${id}`); + const p = await gf(`payment:${id}`); if (!p) fail("Payment not found"); if (p.uid !== user.id) fail("unauthorized"); @@ -610,8 +870,191 @@ export default { res.send(await ln.decode(bolt11)); }, + // Up-front cost of a lightning send: the routing fee askrene quotes for this + // invoice (the same answer xpay will get) plus the platform fee debit() will + // charge. With `max`, instead solve for the largest amount whose amount + + // fees exactly clears the account balance — the "send everything" case where + // guessing a maxfee always left dust behind or bounced on insufficient funds. + async quote(req, res) { + const { body, user } = req; + try { + let { payreq, amount, max, aid, ceiling } = body; + if (typeof payreq !== "string" || !payreq.trim()) + fail("Invalid payment request"); + payreq = payreq.replace(/\s/g, "").toLowerCase(); + + if (!aid) aid = user.id; + else if (typeof aid !== "string") fail("Invalid account"); + else if (aid !== user.id) { + const pos = await db.lPos(`${user.id}:accounts`, aid); + if (pos == null) fail("account not found"); + } + + const parseSats = (v, name) => { + if (v === undefined || v === null || v === "") return undefined; + const n = Number.parseInt(v); + if (Number.isNaN(n) || n < 0 || n > SATS) fail(`Invalid ${name}`); + return n; + }; + amount = parseSats(amount, "amount"); + ceiling = parseSats(ceiling, "ceiling"); + const balance = Number.parseInt(await g(`balance:${aid}`)) || 0; + + // Paying another coinos user never leaves the ledger: no routing, no + // platform fee, the whole balance is sendable. + const invoice = await getInvoice(payreq); + if (invoice) { + const recipient = await getUser(invoice.uid); + if (recipient?.username !== "mint") { + const a = max ? balance : (amount ?? invoice.amount); + return res.send({ + amount: a, + fee: 0, + ourfee: 0, + total: a, + balance, + internal: true, + }); + } + } + + let decoded = await ln.decode(payreq); + let pr = payreq; + let fetched: string | undefined; + + // An offer isn't routable by itself — the blinded paths live in the + // invoice fetched from it. Fetch one to learn them; in max mode that's a + // placeholder amount, and the final invoice is fetched once we know the + // real one so the caller can pay exactly what we quoted. + const fetchFromOffer = async (a: number) => { + const { invoice } = await ln.fetchinvoice({ + offer: payreq, + amount_msat: decoded.offer_amount_msat ? undefined : a * 1000, + timeout: 60, + }); + return invoice.replace(/\s/g, "").toLowerCase(); + }; + + if (decoded.type === "bolt12 offer") { + if (decoded.offer_currency) + fail("Currency-denominated offers are not supported"); + const offerAmount = decoded.offer_amount_msat + ? Math.round(decoded.offer_amount_msat / 1000) + : undefined; + if (offerAmount) max = false; + const a = offerAmount ?? amount ?? (max ? balance : undefined); + if (!a) fail("Amount required"); + pr = fetched = await fetchFromOffer(a); + decoded = await ln.decode(pr); + } + + let invAmount: number | undefined; + if (decoded.type === "bolt12 invoice") + invAmount = decoded.invoice_amount_msat + ? Math.round(decoded.invoice_amount_msat / 1000) + : undefined; + else + invAmount = decoded.amount_msat + ? Math.round(decoded.amount_msat / 1000) + : undefined; + + if (max) { + // The invoice's own amount is ignored here: an LNURL caller has to + // name an amount to get any invoice at all, so it probes with one at + // the ceiling, takes the amount we solve for, and fetches the real + // invoice for that. Anything that pays a fixed-amount invoice with + // a different amount fails at send time, not here. + const q = await quoteMax({ pr, uid: user.id, aid, ceiling }); + // Offer: the placeholder invoice carried the balance, not the answer. + if (fetched && q.amount !== invAmount) { + fetched = await fetchFromOffer(q.amount); + // Re-quote against the real invoice: same paths, so same fee, but + // never hand back a number we didn't verify against what gets paid. + const { fee } = await quoteRoutingFee({ pr: fetched, amount: q.amount }); + if (fee !== q.fee) fail("Routing fee changed, please try again"); + } + l("quote max", user.username, q.amount, "fee", q.fee, "ourfee", q.ourfee, "balance", q.balance); + return res.send({ ...q, ...(fetched ? { payreq: fetched } : {}) }); + } + + const a = invAmount ?? amount; + if (!a) fail("Amount required"); + if (invAmount && amount && amount !== invAmount) + fail("Amount does not match invoice"); + const { fee, parts } = await quoteRoutingFee({ pr, amount: a }); + const ourfee = await platformFee({ uid: user.id, aid, amount: a, fee }); + l("quote", user.username, a, "fee", fee, "ourfee", ourfee, "parts", parts); + res.send({ + amount: a, + fee, + ourfee, + parts, + total: a + fee + ourfee, + balance, + ...(fetched ? { payreq: fetched } : {}), + }); + } catch (e) { + warn("problem quoting", user.username, shortError(e.message)); + bail(res, e.message); + } + }, + + // The user's standing bolt12 offer (lno1...) — reusable receive code they + // can publish (e.g. in a nostr kind 10058 list for bolt12 zaps) + async offer(req, res) { + try { + res.send(await getUserOffer(req.user)); + } catch (e) { + bail(res, e.message); + } + }, + async fetchinvoice(req, res) { - const { amount, offer } = req.body; - res.send(await ln.fetchinvoice(offer, amount ? amount * 1000 : null)); + const { amount, offer, payer_note } = req.body; + res.send( + await ln.fetchinvoice({ + offer, + amount_msat: amount ? amount * 1000 : undefined, + payer_note, + timeout: 60, + }), + ); + }, + + async auth(req, res) { + console.log(req.query); + res.send(req.query); + }, + + async order(req, res) { + console.log(req.body); + res.send(req.body); + }, + + async sendinvoice(req, res) { + try { + const { user } = req; + const { invreq } = req.body; + + const { amount_msat, bolt12, pay_index } = await ln.sendinvoice({ + invreq, + label: v4(), + }); + + await generate({ + invoice: { + amount: Math.round(amount_msat / 1000), + type: "bolt12", + bolt12, + }, + user, + }); + + const p = await replay(pay_index); + + res.send(p); + } catch (e) { + bail(res, e.message); + } }, }; diff --git a/routes/preimages.ts b/routes/preimages.ts new file mode 100644 index 00000000..5e7c484f --- /dev/null +++ b/routes/preimages.ts @@ -0,0 +1,113 @@ +import { createHash } from "node:crypto"; +import { db, g } from "$lib/db"; +import { parseEntry } from "$lib/invoices"; +import { bail, fail } from "$lib/utils"; + +// Merchants pre-load payment preimages here, then have buyers create invoices +// with usePreimage: true — each such invoice consumes the next queued preimage +// so its payment hash is sha256(preimage). Once paid, the preimage is exposed +// on the invoice record, letting a static storefront sell decryption secrets +// with no backend of its own. An optional per-batch price — sats, or a fiat +// amount converted at invoice-creation time — is enforced at invoice creation; +// creation is unauthenticated, so without it a buyer could name their own +// price for the secret. + +const MAX_QUEUE = 1000; +const key = (uid) => `${uid}:preimages`; +const sha256 = (hex) => + createHash("sha256").update(Buffer.from(hex, "hex")).digest("hex"); + +export default { + async add(req, res) { + try { + const { preimages, price = null, fiat = null } = req.body; + let { currency = null } = req.body; + if (!Array.isArray(preimages) || !preimages.length) + fail("preimages array required"); + if ( + !preimages.every( + (p) => typeof p === "string" && /^[0-9a-f]{64}$/i.test(p), + ) + ) + fail("preimages must be 64-character hex strings"); + if (price !== null && fiat !== null) + fail("specify price (sats) or fiat, not both"); + if (price !== null && (!Number.isInteger(price) || price <= 0)) + fail("price must be a positive integer amount of sats"); + if (fiat !== null) { + if (typeof fiat !== "number" || !(fiat > 0)) + fail("fiat must be a positive number"); + currency ||= req.user.currency; + const rates = await g("rates"); + if (!rates?.[currency]) fail(`unsupported currency ${currency}`); + } else { + currency = null; + } + + const k = key(req.user.id); + const existing = await db.lRange(k, 0, -1); + const seen = new Set(existing.map((e) => parseEntry(e).preimage)); + const fresh = []; + for (let p of preimages) { + p = p.toLowerCase(); + if (seen.has(p)) continue; + // A preimage that already settled (or backs a live invoice) can't be + // credited again — reject it rather than let a buyer pay for nothing. + if ( + (await db.exists(`credited:${p}`)) || + (await db.exists(`preimage:${sha256(p)}`)) + ) + fail("preimage already used"); + seen.add(p); + fresh.push(p); + } + + if (existing.length + fresh.length > MAX_QUEUE) + fail(`queue limited to ${MAX_QUEUE} preimages`); + if (fresh.length) + await db.rPush( + k, + fresh.map((preimage) => + JSON.stringify({ preimage, price, fiat, currency }), + ), + ); + + res.send({ + queued: existing.length + fresh.length, + added: fresh.map((preimage) => ({ + preimage, + hash: sha256(preimage), + price, + fiat, + currency, + })), + }); + } catch (e) { + bail(res, e.message); + } + }, + + async list(req, res) { + try { + const entries = await db.lRange(key(req.user.id), 0, -1); + res.send({ + queued: entries.length, + preimages: entries.map((e) => { + const { preimage, price, fiat, currency } = parseEntry(e); + return { preimage, hash: sha256(preimage), price, fiat, currency }; + }), + }); + } catch (e) { + bail(res, e.message); + } + }, + + async clear(req, res) { + try { + await db.del(key(req.user.id)); + res.send({ queued: 0 }); + } catch (e) { + bail(res, e.message); + } + }, +}; diff --git a/routes/rates.ts b/routes/rates.ts index 67c111d7..e532ec78 100644 --- a/routes/rates.ts +++ b/routes/rates.ts @@ -12,6 +12,7 @@ export default { }, async index(_, res) { - res.send(await g("rates")); + const { date, fx, ...rates } = await g("rates"); + res.send(rates); }, }; diff --git a/routes/square.ts b/routes/square.ts new file mode 100644 index 00000000..fb06e082 --- /dev/null +++ b/routes/square.ts @@ -0,0 +1,92 @@ +import * as crypto from "crypto"; +import config from "$config"; +import { db, g, s } from "$lib/db"; +import { generate } from "$lib/invoices"; +import { SATS, bail, getUser } from "$lib/utils"; +import { sha256 } from "@noble/hashes/sha256"; +import { utf8ToBytes } from "@noble/hashes/utils"; +import { base64urlnopad as base64 } from "@scure/base"; +import { SquareClient } from "square"; +import { v4 } from "uuid"; + +const { scopes, url, appId, clientSecret, environment } = config.square; + +export default { + async connect(req, res) { + const { user } = req; + + if (await db.exists(`${user.id}:square`)) { + return res.send("connected"); + } + + const codeVerifier = base64.encode(crypto.randomBytes(32)); + await s(`${user.id}:codeVerifier`, codeVerifier); + + const challenge = base64.encode(sha256(utf8ToBytes(codeVerifier))); + + const state = base64.encode(crypto.randomBytes(12)); + const scope = scopes.join("+"); + + res.send( + `${url}oauth2/authorize?client_id=${appId}&session=false&scope=${scope}&state=${state}&code_challenge=${challenge}`, + ); + }, + + async auth(req, res) { + const { user } = req; + + const codeVerifier = await g(`${user.id}:codeVerifier`); + + const client = new SquareClient({ + environment, + }); + + const { code } = req.query; + + try { + const result = await client.oAuth.obtainToken({ + code, + clientId: appId, + grantType: "authorization_code", + codeVerifier, + }); + + await s(`${user.id}:square`, result); + await s(result.merchantId, user.id); + res.send({}); + } catch (e) {} + }, + + async payment(req, res) { + try { + const { body } = req; + const { data, type, merchant_id } = body; + const { payment } = data.object; + + if ( + type === "payment.created" && + (payment.source_type === "CASH" || payment.source_type === "EXTERNAL") + ) { + const { + amount_money: { amount, currency }, + } = payment; + const rates = await g("rates"); + const rate = rates[currency]; + const uid = await g(merchant_id); + const user = await getUser(uid); + const invoice = { + amount: Math.round(((amount / 100) * SATS) / rate), + own: true, + prompt: user.prompt, + type: "lightning", + }; + + await generate({ invoice, user }); + } + + res.send({}); + } catch (e) { + bail(res, e.message); + } + }, +}; diff --git a/routes/users.ts b/routes/users.ts index 34c2a655..684f361b 100644 --- a/routes/users.ts +++ b/routes/users.ts @@ -1,21 +1,25 @@ -import { createReadStream } from "node:fs"; -import { appendFile, unlink } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import config from "$config"; import { requirePin } from "$lib/auth"; -import { db, g, ga, s } from "$lib/db"; +import { db, g, gf, s } from "$lib/db"; import { err, l, warn } from "$lib/logging"; import { mail, templates } from "$lib/mail"; -import { getNostrUser, getProfile, serverPubkey } from "$lib/nostr"; -import { reconcile } from "$lib/payments"; +import { getNostrUser, getProfile, serverPubkey2 } from "$lib/nostr"; import register from "$lib/register"; +import { assertNameFree } from "$lib/names"; +import { isReserved } from "$lib/reserved"; import { emit } from "$lib/sockets"; import upload from "$lib/upload"; import { bail, fail, fields, getUser, pick } from "$lib/utils"; import whitelist from "$lib/whitelist"; -import rpc from "@coinos/rpc"; + import { $ } from "bun"; +import got from "got"; import jwt from "jsonwebtoken"; import { getPublicKey, nip19, verifyEvent } from "nostr-tools"; +import { hexToBytes } from "@noble/hashes/utils"; import { authenticator } from "otplib"; import { v4 } from "uuid"; @@ -25,6 +29,71 @@ import type { ProfilePointer } from "nostr-tools/nip19"; const { host } = new URL(process.env.URL); const relay = encodeURIComponent(config.publicRelay); +const verifyRecaptcha = async (response, req?) => { + const { recaptcha: secret } = config; + if (!secret) return true; + + // Skip captcha for Tor users accessing via .onion + const host = req?.headers?.["x-forwarded-host"] || req?.headers?.["host"] || ""; + if (host.endsWith(".onion")) return true; + + // Skip captcha for allowlisted User-Agents (prefix match) + const uaRaw = req?.headers?.["user-agent"] || req?.headers?.["User-Agent"]; + const ua = Array.isArray(uaRaw) ? uaRaw[0] : uaRaw; + if (typeof ua === "string") { + const prefixes = await db.sMembers("nocaptcha_ua"); + const uaLower = ua.toLowerCase(); + if (prefixes.some((p) => uaLower.startsWith(p.toLowerCase()))) { + return true; + } + } + + // Check for valid API key to bypass captcha + const apiKey = req?.headers?.["x-api-key"]; + if (apiKey && (await db.sIsMember("apikeys", apiKey))) { + return true; + } + + const { username } = req.body; + if (username && + await db.sIsMember("nocaptcha", username.toLowerCase().replace(/\s/g, "")) + ) + return true; + + if (!response) return false; + + try { + const ip = req?.headers?.["cf-connecting-ip"] || req?.socket?.remoteAddress; + const { success } = await got + .post("https://www.google.com/recaptcha/api/siteverify", { + form: { + secret, + response, + remoteip: ip, + }, + // Bound the wait so a slow/unreachable Google can't hang the login. + timeout: { request: 6000 }, + }) + .json(); + // A real verdict from Google: fail CLOSED only when the captcha itself is + // invalid (success === false). + return success || (!!config.adminpass && response === config.adminpass); + } catch (e) { + // Fail OPEN on an INFRASTRUCTURE error (network blip, timeout, Google 5xx). + // Reaching Google is not part of authentication; failing closed here turned + // a transient coinos<->Google connectivity hiccup into a login outage — + // users got "failed captcha" until it cleared (2026-08-25 incident). The + // password and the per-IP login rate limits (ipCount>30 / ipFailures>20 -> + // 429) still gate the request; captcha is only bot-mitigation, so allowing + // it through when the verifier is unreachable is the safer failure mode. + warn( + "recaptcha verify unreachable — allowing login (fail-open)", + (e as any)?.code ?? (e as any)?.message ?? String(e), + ); + return true; + } +}; + export default { upload, @@ -32,21 +101,10 @@ export default { const { user } = req; try { user.balance = await g(`balance:${user.id}`); - user.locked = await ga(`balance:${user.id}`); - - if (user.locked) { - const blacklisted = await db.sIsMember( - "blacklist", - user?.username?.toLowerCase().trim(), - ); - - const whitelisted = await db.sIsMember( - "whitelist", - user?.username?.toLowerCase().trim(), - ); - - if (!blacklisted || whitelisted) user.locked = 0; - } + // The password-encrypted nostr key is only handed out by /login, after + // the password (and 2FA) check. A bearer token alone mustn't be enough + // to fetch it and brute-force or decrypt it offline. + user.hasnsec = !!user.nsec; user.prompt = !!user.prompt; if (user.pubkey) user.npub = nip19.npubEncode(user.pubkey); @@ -58,42 +116,6 @@ export default { } }, - async list(req, res) { - const { user } = req; - if (!user.admin) fail("unauthorized"); - - const users = []; - - for await (const k of db.scanIterator({ MATCH: "balance:*" })) { - const uid = k.split(":")[1]; - const user = await getUser(uid); - - if (!user) { - await db.del(`balance:${uid}`); - continue; - } - - user.balance = await g(k); - - const payments = await db.lRange(`${uid}:payments`, 0, -1); - - let total = 0; - for (const pid of payments) { - const p = await g(`payment:${pid}`); - if (!p) continue; - total += p.amount; - if (p.amount < 0) - total -= (p.fee || 0) + (p.ourfee || 0) + (p.tip || 0); - else total += p.tip || 0; - } - - user.expected = total; - users.push(user); - } - - res.send(users); - }, - async get(req, res) { let { params: { key }, @@ -115,8 +137,7 @@ export default { const user = await getNostrUser(key); res.send(pick(user, fields)); } catch (e) { - err("problem getting user", key, e.message); - res.code(500).send(e.message); + res.code(404).send("User not found"); } }, @@ -127,6 +148,51 @@ export default { if (!body.user) fail("no user object provided"); let { user } = body; + // Per-IP registration cap. Nothing else throttles /register (the + // recaptcha check only runs on login; the fastify limiters cover + // /login and /send), so one VPN exit created ~500 empty accounts at + // 3/min on 2026-09-08 without tripping anything. Five an hour is far + // above any household; an internal/private source IP (proxy without + // the Cloudflare header) is exempt so a misrouted request can't lock + // everyone out. Counted before validation so failed attempts count too. + if (ip && !/^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|127\.|::1$|fc|fd)/.test(ip)) { + const k = `register:ip:${ip}`; + const n = await db.incr(k); + if (n === 1) await db.expire(k, 3600); + if (n > 5) { + warn("registration cap hit", ip, n); + fail("Too many accounts created from this address, try again later"); + } + } + + // Forged browser User-Agent. Every engine that reports AppleWebKit also + // reports the "(KHTML, like Gecko)" token right behind it, so a UA that + // claims AppleWebKit without it was assembled by hand. Across 4.8M + // logged requests this matches 2,366: the c<epoch> registration bot + // (1,203 accounts from 596 rotating residential IPs — which is exactly + // why the per-IP cap above never touched it), PetalBot, Bytespider, and + // nothing else. Real browsers carry the token; non-browser clients that + // register legitimately (python-requests, curl, damus, our own Bun SSR) + // never claim AppleWebKit at all, so none of them match. + const ua = String(headers["user-agent"] || ""); + if (ua.includes("AppleWebKit/") && !ua.includes("KHTML")) { + warn("registration blocked, forged user-agent", ip, ua.slice(0, 80)); + fail("Registration unavailable"); + } + + // The signup form has always sent a recaptcha token and /register has + // always discarded it — it isn't even in the `fields` pick below — so + // the captcha only ever gated login. Verify it when one is supplied. + // Supplied-and-invalid is the only rejection: an ABSENT token still + // passes, because half of all registrations are API clients that never + // had one and requiring it would break them. verifyRecaptcha keeps its + // own bypasses (onion host, x-api-key, allowlisted UA) and fails open + // when Google is unreachable. + if (user.recaptcha && !(await verifyRecaptcha(user.recaptcha, req))) { + warn("registration blocked, failed captcha", ip, user.username); + fail("Failed captcha"); + } + const fields = ["pubkey", "password", "username", "picture", "fresh"]; user = await register(pick(user, fields), ip); @@ -135,7 +201,7 @@ export default { l("registered new user", user.username); - res.send({ ...pick(user, whitelist), sk: user.sk, token }); + res.send({ ...pick(user, whitelist), nsec: user.nsec, sk: user.sk, token }); } catch (e) { err("problem registering", e.message); res.code(500).send(e.message); @@ -152,9 +218,12 @@ export default { return res.code(401).send("2fa required"); } + // Fresh seed on disable so a seed that leaked while 2FA was on is dead + // when it's re-enabled. user.twofa = false; + user.otpsecret = authenticator.generateSecret(); await s(`user:${id}`, user); - emit(username, "user", user); + emit(username, "user", pick(user, whitelist)); emit(username, "otpsecret", user.otpsecret); l("disabled 2fa", username); res.send({}); @@ -171,7 +240,7 @@ export default { if (isValid) { user.twofa = true; await s(`user:${id}`, user); - emit(username, "user", user); + emit(username, "user", pick(user, whitelist)); } else { return res.code(500).send("Invalid token"); } @@ -179,7 +248,6 @@ export default { l("enabled 2fa", username); res.send({}); } catch (e) { - console.log(e); bail(res, e.message); } }, @@ -199,32 +267,40 @@ export default { const valid = /^[\p{L}\p{N}]{2,24}$/u; if (!valid.test(username)) fail("Usernames can only have letters and numbers"); + if (isReserved(username)) fail("Invalid username"); + if (username?.includes("undefined")) fail("Invalid username"); let exists; let { pubkey } = body; if (pubkey) { pubkey = pubkey.trim(); + if (pubkey.startsWith("npub")) pubkey = nip19.decode(pubkey).data; exists = await getUser(pubkey); + const un = user.username.toLowerCase().replace(/\s/g, ""); const existingUsername = exists?.username ?.toLowerCase() .replace(/\s/g, ""); - if (exists && username !== existingUsername) { - warn("key in use", pubkey, existingUsername); + if (exists && un !== existingUsername) { + warn("key in use", pubkey, username, existingUsername); if (exists.anon) await db.del(`user:${pubkey}`); else fail("Key in use by another account"); } const event = JSON.parse(body.event); const challenge = event.tags.find((t) => t[0] === "challenge")[1]; - const c = await g(`challenge:${challenge}`); + // Single-use: GETDEL claims the challenge atomically, so the identical + // signed event can't be replayed within the 5-minute TTL (it is also + // written to the request log, so a log reader could otherwise reuse it). + // Burn it on read rather than on success — a failed attempt costs the + // client one extra GET /challenge, and two racing replays cannot both win. + const c = await db.getDel(`challenge:${challenge}`); if (!c) fail("Invalid or expired challenge"); if (!verifyEvent(event) || event.pubkey !== pubkey) fail("Invalid signature or challenge mismatch."); pubkey = pubkey.replace(/\s*/g, ""); - if (pubkey.startsWith("npub")) pubkey = nip19.decode(pubkey).data; if (pubkey.length !== 64) fail(`Invalid pubkey ${pubkey}`); await db.del(`user:${user.pubkey}`); user.pubkey = pubkey; @@ -237,18 +313,20 @@ export default { if (user.pin === "delete") user.pin = undefined; if (username) { - exists = await db.exists(`user:${username}`); const currentUsername = user.username.replace(/\s/g, "").toLowerCase(); + if (username !== currentUsername) { + exists = await db.exists(`user:${username}`); - if (currentUsername !== username && exists) { - err("username taken", username, currentUsername); - fail("Username taken"); - } else if (username) { - if (currentUsername !== username) + if (exists) { + err("username taken", username, currentUsername); + fail("Username taken"); + } else { + // A name the v3 registrar holds is taken too (see lib/names.ts). + await assertNameFree(username, user.pubkey); l("changing username", currentUsername, username); - - await db.del(`user:${currentUsername}`); - user.username = username; + await db.del(`user:${currentUsername}`); + user.username = username; + } } } @@ -265,6 +343,7 @@ export default { "language", "locktime", "memoPrompt", + "migrated", "nip5", "notify", "nsec", @@ -281,23 +360,28 @@ export default { "twofa", ]; + if (body.email) { + user.verified = false; + user.notify = false; + } + for (const a of attributes) { if (typeof body[a] !== "undefined") user[a] = body[a]; } user.fresh = false; - user.tip = Math.max(0, Math.min(1000, parseInt(user.tip))); + user.tip = Math.max(0, Math.min(1000, Number.parseInt(user.tip))); if (password && password === confirm) { user.password = await Bun.password.hash(password, { algorithm: "bcrypt", - cost: 4, + cost: 12, }); } user.haspin = !!user.pin; if (user.destination) user.destination = user.destination.trim(); - await s(`user:${user.pubkey}`, user.id); + if (user.pubkey) await s(`user:${user.pubkey}`, user.id); await s( `user:${user.username.toLowerCase().replace(/\s/g, "")}`, user.id, @@ -306,8 +390,8 @@ export default { await s(`user:${user.id}`, user); if (user.nip5) await db.sAdd("nip5", `${user.username}:${user.pubkey}`); - emit(user.id, "user", user); - res.send({ user }); + emit(user.id, "user", pick(user, whitelist)); + res.send({ user: pick(user, whitelist) }); } catch (e) { warn("failed to update", user.username, e.message); bail(res, e.message); @@ -316,22 +400,47 @@ export default { async login(req, res) { try { - let { username, password, token: twofa } = req.body; + let { username, password, token: twofa, recaptcha } = req.body; + const ip = req.headers["cf-connecting-ip"] || req.socket.remoteAddress; + + const ipKey = `ip:${ip}:login`; + const ipCount = await db.incr(ipKey); + if (ipCount === 1) await db.expire(ipKey, 10); + if (ipCount > 30) return res.code(429).send({}); + + // Fail closed: an unset adminpass must never authenticate, and an omitted + // password field must never coincide with an unset value (undefined === + // undefined). Both the configured value and the supplied one must be + // non-empty and match exactly. + const isAdmin = + !!config?.adminpass && !!password && password === config.adminpass; + + if (!isAdmin) { + const recaptchaOk = await verifyRecaptcha(recaptcha, req); + if (!recaptchaOk) { + return res.code(401).send("failed captcha"); + } + } username = username.toLowerCase().replace(/\s/g, ""); + username = username.split("@")[0]; + + const ipFailKey = `ip:${ip}:login:fail`; + const ipFailures = await g(ipFailKey); + if (!isAdmin && ipFailures > 20) return res.code(429).send({}); + let user = await getUser(username); - if (password !== config?.adminpass) { - if ( - !user || - !user.password || - !(await Bun.password.verify(password, user.password)) - ) { - // warn("invalid username or password attempt", username); - await appendFile( - "failedlogins.txt", - `${Date.now()} : ${username} : ${password}\n`, - ); + if (!isAdmin) { + let verified = false; + try { + if (user?.password) + verified = await Bun.password.verify(password, user.password); + } catch (e) {} + + if (!user || !verified) { + await db.incrBy(ipFailKey, 1); + if ((await db.ttl(ipFailKey)) < 0) await db.expire(ipFailKey, 600); return res.code(401).send({}); } @@ -340,17 +449,45 @@ export default { (typeof twofa === "undefined" || !authenticator.check(twofa, user.otpsecret)) ) { + await db.incrBy(ipFailKey, 1); + if ((await db.ttl(ipFailKey)) < 0) await db.expire(ipFailKey, 600); return res.code(401).send("2fa required"); } + + // Transparent bcrypt upgrade: legacy accounts were hashed at cost 4 + // (~1,300 guesses/s). Now that we hold the verified plaintext, re-hash + // at the current cost so the stored hash strengthens on next login. + const cost = Number.parseInt( + user.password.match(/^\$2[aby]\$(\d{2})\$/)?.[1] ?? "0", + 10, + ); + if (cost < 12) { + user.password = await Bun.password.hash(password, { + algorithm: "bcrypt", + cost: 12, + }); + await s(`user:${user.id}`, user); + } } + // Evicted accounts can't use a token anyway (see lib/auth.ts), so don't + // mint one — checked after the password so it can't be used to probe. + if (!isAdmin && (await db.sIsMember("evicted", user.id))) + return res.code(403).send("account disabled"); + if (username !== "coinos") l("logged in", username, req.headers["cf-connecting-ip"]); const payload = { id: user.id }; const token = jwt.sign(payload, config.jwt); - res.cookie("token", token, { expires: new Date(Date.now() + 432000000) }); - user = pick(user, whitelist); + res.cookie("token", token, { + expires: new Date(Date.now() + 432000000), + path: "/", + httpOnly: true, + secure: true, + sameSite: "lax", + }); + user = { ...pick(user, whitelist), hasnsec: !!user.nsec, nsec: user.nsec }; res.send({ user, token }); } catch (e) { err("login error", e.message, req.socket.remoteAddress); @@ -366,9 +503,15 @@ export default { async nostrAuth(req, res) { try { - const { event, challenge, twofa } = req.body; + const { event, challenge, twofa, recaptcha } = req.body; const ip = req.headers["cf-connecting-ip"]; - const c = await g(`challenge:${challenge}`); + const recaptchaOk = await verifyRecaptcha(recaptcha, req); + if (!recaptchaOk) { + return res.code(401).send("failed captcha"); + } + // Single-use — see the GETDEL note on the other challenge consumer. The + // captcha is checked above, so a captcha failure doesn't burn a challenge. + const c = await db.getDel(`challenge:${challenge}`); const { pubkey: key, kind } = event; if (kind !== 27235) fail("Invalid event"); if (!c) fail("Invalid or expired login challenge"); @@ -401,25 +544,21 @@ export default { } const { username } = user; - if ( - await db.sIsMember( - "compromised", - username.replace(/\s/g, "").toLowerCase(), - ) - ) { - warn("compromised nostr auth attempt"); - fail("unauthorized"); - } - l("nostr login", username, ip); const payload = { id: user.id }; const token = jwt.sign(payload, config.jwt); - res.cookie("token", token, { expires: new Date(Date.now() + 432000000) }); - user = pick(user, whitelist); + res.cookie("token", token, { + expires: new Date(Date.now() + 432000000), + path: "/", + httpOnly: true, + secure: true, + sameSite: "lax", + }); + user = { ...pick(user, whitelist), hasnsec: !!user.nsec }; res.send({ user, token }); } catch (e) { - err("login error", e.message, req.socket.remoteAddress); + err("nostr login error", e.message, req.socket.remoteAddress); res.code(401).send({}); } }, @@ -441,7 +580,6 @@ export default { await db.sAdd(`${id}:subscriptions`, JSON.stringify(subscription)); res.send(subscription); } catch (e) { - console.log(e); bail(res, e.message); } }, @@ -453,7 +591,6 @@ export default { await db.sRem(`${id}:subscriptions`, JSON.stringify(subscription)); res.send(subscription); } catch (e) { - console.log(e); bail(res, e.message); } }, @@ -473,6 +610,39 @@ export default { } }, + // The encrypted nostr key, for clients that need to reveal or sign with it + // after a session that didn't start with a password login. Re-checking the + // password keeps a bearer token alone from fetching it (see /me). + async nsec(req, res) { + const { + body: { password }, + user, + } = req; + const ip = req.headers["cf-connecting-ip"] || req.socket.remoteAddress; + const ipFailKey = `ip:${ip}:login:fail`; + + try { + if (!user.nsec) fail("nsec not available"); + if ((await g(ipFailKey)) > 20) return res.code(429).send({}); + + let verified = false; + try { + if (user.password && password) + verified = await Bun.password.verify(password, user.password); + } catch (e) {} + + if (!verified) { + await db.incrBy(ipFailKey, 1); + if ((await db.ttl(ipFailKey)) < 0) await db.expire(ipFailKey, 600); + return res.code(401).send("invalid password"); + } + + res.send({ nsec: user.nsec }); + } catch (e) { + bail(res, e.message); + } + }, + async pin(req, res) { const { body: { pin }, @@ -484,6 +654,10 @@ export default { async otpsecret(req, res) { try { await requirePin(req); + // Only needed to enrol. Once 2FA is on, the seed never leaves the + // server again — otherwise a stolen token on a PIN-less account is + // enough to mint valid codes. + if (req.user.twofa) fail("2FA is already enabled"); const { otpsecret, username } = req.user; res.send({ secret: otpsecret, username }); } catch (e) { @@ -506,7 +680,7 @@ export default { for (const { ref } of ( await Promise.all( - payments.reverse().map(async (id) => await g(`payment:${id}`)), + payments.reverse().map(async (id) => await gf(`payment:${id}`)), ) ).filter((p) => p && p.type === PaymentType.internal && p.ref)) { if (ref === id) continue; @@ -550,6 +724,7 @@ export default { params: { username }, headers: { authorization }, } = req; + fail("Unauthorized"); username = username.toLowerCase(); if (!authorization?.includes(config.admin)) return res.code(401).send("unauthorized"); @@ -569,47 +744,167 @@ export default { res.send({}); }, + // Authenticated self-delete: wipes every key tied to the calling user, + // mirroring scripts/delall-legacy.ts. Gated by typing the username (works for + // password and nostr accounts alike) and refuses while a balance remains so a + // user can't accidentally destroy funds. + async deleteSelf(req, res) { + try { + const { user } = req; + const { id, username, pubkey } = user; + + const confirm = (req.body?.confirm || "").toString().trim().toLowerCase(); + if (confirm !== username.toLowerCase()) + fail("Type your username to confirm account deletion"); + + // Allow deletion through dust (often too small to withdraw); only block + // a meaningful balance so nobody destroys real funds by accident. Sum + // EVERY account (main + sub-accounts) — the deletion loop below wipes each + // sub-account's balance:/pending: keys unconditionally, so a guard that + // only checked the main account let a user with funds parked in a + // sub-account pass and silently destroy them. + const aids = await db.lRange(`${id}:accounts`, 0, -1); + if (!aids.includes(id)) aids.push(id); + let total = 0; + for (const aid of aids) { + total += Number(await db.get(`balance:${aid}`)) || 0; + total += Number(await db.get(`pending:${aid}`)) || 0; + } + if (total > 10000) + fail("Withdraw your balance before deleting your account"); + + const keys = [ + `user:${id}`, + // username->uid mapping is stored lowercased+despaced (see register.ts); + // include both forms so a mixed-case username can't leave a dangling alias. + `user:${username.toLowerCase().replace(/\s/g, "")}`, + `user:${username}`, + `${id}:payments`, + `${id}:payments:last`, + `${id}:apps`, + `${id}:lastlen`, + `${id}:accounts`, + `${id}:contacts`, + `${id}:invoices`, + `${id}:items`, + `${id}:pins`, + `${id}:subscriptions`, + `${id}:trust`, + `${id}:square`, + `${id}:codeVerifier`, + `account:${id}`, + `balance:${id}`, + `pending:${id}`, + `credit:bitcoin:${id}`, + `credit:lightning:${id}`, + `credit:liquid:${id}`, + ]; + + if (pubkey) { + keys.push( + `user:${pubkey}`, + `${pubkey}:follows`, + `${pubkey}:follows:n`, + `${pubkey}:followers`, + `${pubkey}:followers:n`, + `${pubkey}:pubkeys`, + ); + if (user.nip5) await db.sRem("nip5", `${username}:${pubkey}`); + } + + // Connected NWC apps: drop each app record + its payment index. + // `${id}:apps` is a SET (sAdd in register.ts), so use sMembers, not lRange. + for (const ap of await db.sMembers(`${id}:apps`)) + keys.push(`app:${ap}`, `${ap}:payments`); + + // Sub-accounts and their payment records. + for (const aid of await db.lRange(`${id}:accounts`, 0, -1)) { + if (aid === id) continue; + for (const pid of await db.lRange(`${aid}:payments`, 0, -1)) + keys.push(`payment:${pid}`); + keys.push( + `account:${aid}`, + `${aid}:payments`, + `${aid}:payments:last`, + `${aid}:invoices`, + `balance:${aid}`, + `pending:${aid}`, + ); + } + + // Main-account payment + invoice records. + for (const pid of await db.lRange(`${id}:payments`, 0, -1)) + keys.push(`payment:${pid}`); + for (const iid of await db.lRange(`${id}:invoices`, 0, -1)) + keys.push(`invoice:${iid}`); + + let deleted = 0; + for (const k of keys) { + if (await db.exists(k)) { + await db.del(k); + deleted++; + } + } + + l("user self-deleted", username, id, `${deleted} keys`); + try { + res.clearCookie("token"); + } catch {} + res.send({ deleted: true }); + } catch (e) { + bail(res, e.message); + } + }, + async reset(req, res) { const { body: { code, username, password }, user: u, } = req; - const admin = u?.admin; - let id; - let user; + try { + let id; + let user; - if (admin) { + // Non-admins must not be able to tell this admin-only endpoint exists. + // Log the probe (so exploit-watch can flag it) but return a generic 404 + // identical to an unregistered route — previously it returned "disabled", + // which confirmed the endpoint AND its admin-gating to attackers. + if (u?.username !== config.admin) { + err("password reset failed disabled", req.headers["cf-connecting-ip"]); + return res + .code(404) + .send({ message: "Route POST:/reset not found", error: "Not Found", statusCode: 404 }); + } id = await g(`user:${username.toLowerCase().replace(/\s/g, "")}`); user = await g(`user:${id}`); - } else { - id = await g(`reset:${code}`); - user = await g(`user:${id}`); - } - if (!user) fail("user not found"); + if (!user) fail("user not found"); - warn( - "password reset", - user.username, - code, - req.headers["cf-connecting-ip"], - ); + warn( + "password reset", + user.username, + code, + req.headers["cf-connecting-ip"], + ); - try { user.pin = null; user.nsec = null; user.password = await Bun.password.hash(password, { algorithm: "bcrypt", - cost: 4, + cost: 12, }); await s(`user:${id}`, user); await db.del(`reset:${code}`); res.send(pick(user, whitelist)); + + for await (const k of db.scanIterator({ MATCH: "ip:*:login:fail" })) { + await db.del(k); + } } catch (e) { - err("password reset failed", e.message); + err("password reset failed", e.message, req.headers["cf-connecting-ip"]); bail(res, e.message); } }, @@ -685,7 +980,7 @@ export default { await s(`user:${id}`, user); await s(`email:${email.toLowerCase()}`, id); - res.send(pick(user, whitelist)); + res.send(pick(user, fields)); } catch (e) { bail(res, e.message); } @@ -716,28 +1011,13 @@ export default { } }, - async hidepay(req, res) { - const { - body: { username }, - } = req; - const u = await getUser(username); - u.hidepay = true; - await s(`user:${u.id}`, u); - res.send({}); - }, - - async unlimit(req, res) { - const { - body: { username }, - } = req; - const u = await getUser(username); - u.unlimited = true; - await s(`user:${u.id}`, u); - res.send({}); - }, - async account(req, res) { const { id } = req.params; + const { id: uid } = req.user; + + const pos = await db.lPos(`${uid}:accounts`, id); + if (pos == null) fail("account not found"); + const account = await g(`account:${id}`); if (account) account.balance = await g(`balance:${id}`); res.send(account); @@ -758,45 +1038,17 @@ export default { res.send(accounts.reverse()); } catch (e) { - console.log(e); bail(res, e.message); } }, async createAccount(req, res) { try { - const { fingerprint, pubkey, name, seed, type } = req.body; + const { name, type } = req.body; const { user } = req; - const { id: uid } = user; const id = v4(); - const account = { id, name, seed, type, uid, descriptors: [] }; - - let node = rpc(config[type]); - - await node.createWallet({ - wallet_name: id, - descriptors: true, - disable_private_keys: true, - load_on_startup: true, - }); - - node = rpc({ ...config[type], wallet: id }); - - for (const i of [0, 1]) { - const desc = `wpkh([${fingerprint}]${pubkey}/${i}/*)`; - const { checksum } = await node.getDescriptorInfo(desc); - account.descriptors.push({ - desc: `${desc}#${checksum}`, - range: [0, 100], - next_index: 0, - timestamp: "now", - internal: i === 1, - active: true, - }); - } - - await node.importDescriptors(account.descriptors); + const account = { id, name, type, uid: user.id }; await db .multi() @@ -806,10 +1058,6 @@ export default { .lPush(`${user.id}:accounts`, id) .exec(); - reconcile(account, true); - - await appendFile("/bitcoin.conf", `wallet=${id}\n`); - res.send(account); } catch (e) { bail(res, e.message); @@ -835,17 +1083,10 @@ export default { try { const { id } = req.body; const { id: uid } = req.user; - const { type } = await g(`account:${id}`); + const account = await g(`account:${id}`); const pos = await db.lPos(`${uid}:accounts`, id); - if (!(type && pos != null)) fail("account not found"); - - try { - const node = rpc({ ...config[type], wallet: id }); - await node.unloadWallet(id); - } catch (e) { - warn("failed to unload wallet", id); - } + if (!(account && pos != null)) fail("account not found"); await db .multi() @@ -857,32 +1098,31 @@ export default { res.send({ ok: true }); } catch (e) { - console.log(e); bail(res, e.message); } }, - async printer(req, res) { - const { ssid, key, username, password } = req.body; - const dir = "./printer"; - const path = `${dir}/config.txt`; - + async flash(req, res) { + // Build in a per-request scratch dir. A shared ./printer/config.txt and + // ./littlefs.img let two overlapping requests hand one user the other's + // wifi key and API token. + const dir = await mkdtemp(join(tmpdir(), "flash-")); try { - await unlink(path); - await appendFile(path, `${ssid}\n`, "utf8"); - await appendFile(path, `${key}\n`, "utf8"); - await appendFile(path, `${username}\n`, "utf8"); - await appendFile(path, `${password}\n`, "utf8"); - - const output = "./littlefs.img"; - await $`./mklittlefs -c ${dir} -p 256 -b 4096 -s 0x20000 ${output}`; - + const { ssid, key, token } = req.body; + if (!ssid || !key || !token) fail("ssid, key and token required"); + const cfg = `${ssid.trim()}\n${key.trim()}\n${token.trim()}\n`; + const src = join(dir, "fs"); + const img = join(dir, "littlefs.img"); + await mkdir(src); + await writeFile(join(src, "config.txt"), cfg, "utf8"); + await $`./mklittlefs -c ${src} -p 256 -b 4096 -s 0x20000 ${img}`; res.header("Content-Type", "application/octet-stream"); - res.header("Content-Disposition", "attachment; filename=littlefs.img"); - console.log("sending back"); - return res.send(createReadStream(output)); - } catch (error) { - res.code(500).send({ error: "Failed to generate LittleFS image" }); + // Read it fully before the finally below removes the directory. + return res.send(await readFile(img)); + } catch (e) { + bail(res, e.message); + } finally { + await rm(dir, { recursive: true, force: true }); } }, @@ -890,6 +1130,10 @@ export default { const { pubkey } = req.params; const { user } = req; const app = await g(`app:${pubkey}`); + // Missing records must 404, not crash into a 500: clients (Damus one-click + // setup) probe this endpoint and treat 404 as "not configured yet, create + // one" — anything else aborts their whole connection flow. + if (!app) return res.code(404).send({ error: "connection not found" }); if (app.uid !== user.id) fail("unauthorized"); const lud16 = `${user.username}@${host}`; @@ -898,13 +1142,13 @@ export default { const payments = await Promise.all( pids.map(async (pid) => { - const p = await g(`payment:${pid}`); - p.user = await g(`user:${p.uid}`); + const p = await gf(`payment:${pid}`); + if (p) p.user = await g(`user:${p.uid}`); return p; }), ); - app.nwc = `nostr+walletconnect://${serverPubkey}?relay=${relay}&secret=${app.secret}&lud16=${lud16}`; + app.nwc = `nostr+walletconnect://${serverPubkey2}?relay=${relay}&secret=${app.secret}&lud16=${lud16}`; app.payments = payments.filter((p) => p); res.send(app); @@ -913,26 +1157,31 @@ export default { async apps(req, res) { const { user } = req; const pubkeys = await db.sMembers(`${user.id}:apps`); - const apps = await Promise.all(pubkeys.map((p) => g(`app:${p}`))); + // Drop set members whose app record was deleted (e.g. an invalidation + // sweep) so one stale pubkey can't 500 the whole listing. + const apps = (await Promise.all(pubkeys.map((p) => g(`app:${p}`)))).filter( + Boolean, + ); const lud16 = `${user.username}@${host}`; await Promise.all( apps.map(async (a) => { if (a.secret) - a.nwc = `nostr+walletconnect://${serverPubkey}?relay=${relay}&secret=${a.secret}&lud16=${lud16}`; + a.nwc = `nostr+walletconnect://${serverPubkey2}?relay=${relay}&secret=${a.secret}&lud16=${lud16}`; const pids = await db.lRange(`${a.pubkey}:payments`, 0, -1); let payments = await Promise.all( - pids.map((pid) => g(`payment:${pid}`)), + pids.map((pid) => gf(`payment:${pid}`)), ); payments = payments.filter((p) => p); a.spent = payments.reduce( (a, b) => a + - (Math.abs(parseInt(b.amount || 0)) + - parseInt(b.fee || 0) + - parseInt(b.ourfee || 0)), + (Math.abs(Number.parseInt(b.amount || 0)) + + Number.parseInt(b.tip || 0) + + Number.parseInt(b.fee || 0) + + Number.parseInt(b.ourfee || 0)), 0, ); }), @@ -955,10 +1204,59 @@ export default { const { user } = req; const uid = user.id; - let app = await g(pubkey); + if (secret) pubkey = getPublicKey(hexToBytes(secret)); + // App records live under `app:<pubkey>`. Looking up the bare pubkey made + // this ownership check ineffective for normal NWC keys and allowed an + // authenticated user to overwrite another connection's configuration. + let app = await g(`app:${pubkey}`); if (app && uid !== app.uid) fail("Unauthorized"); - if (secret) pubkey = getPublicKey(secret); + + // Rotation is mandatory after a credential disclosure: the NWC pubkey is + // derived from the bearer secret, so re-registering a retired or + // quarantined pubkey would re-authorize the exact secret that was + // disclosed (we store client-supplied secrets, so they're part of any DB + // leak). Reject it and make the client mint a fresh secret — a new secret + // yields a new pubkey and never trips this check. + const credentialCutoff = Number(await g("nwc:credential-cutoff")) || 0; + const retired = + (app && + credentialCutoff > 0 && + (!Number(app.created) || Number(app.created) < credentialCutoff)) || + (await db.sIsMember("nwc:quarantined", pubkey)); + if (retired) + return res.code(403).send({ + error: + "This connection's key was retired for security reasons; connect again with a newly generated secret", + }); + + const validRenewals = new Set([ + "daily", + "weekly", + "monthly", + "yearly", + "never", + ]); + if (budget_renewal && !validRenewals.has(budget_renewal)) + fail("Invalid budget renewal period"); + + for (const [label, value] of [ + ["spending budget", max_amount], + ["maximum fee", max_fee], + ]) { + if ( + value !== undefined && + value !== null && + value !== "" && + (!Number.isFinite(Number(value)) || Number(value) < 0) + ) + fail(`Invalid ${label}`); + } + + // Notifications are opt-in (default off): most users don't consume them and + // publishing payment_sent/payment_received for every app would flood the + // relay. A client enables them by sending notify:"true" when creating the + // connection. (Reverted a brief default-on change, 2026-06-06.) notify = String(notify) === "true"; app = { @@ -973,14 +1271,16 @@ export default { secret, }; - if (!app?.created) app.created = Date.now(); + // Refresh the creation epoch on every owner post: this record passed the + // retirement check above, so the owner updating it (or minting it fresh) + // asserts it's live and keeps it clear of the credential cutoff. + app.created = Date.now(); await s(`app:${pubkey}`, app); await db.sAdd(`${uid}:apps`, pubkey); res.send({}); } catch (e) { - console.log(e); bail(res, e.message); } }, @@ -990,13 +1290,15 @@ export default { const { user } = req; const uid = user.id; const { pubkey } = req.body; - const app = await g(pubkey); - if (app && uid !== app.uid) fail("Unauthorized"); + const app = await g(`app:${pubkey}`); + if (app && uid !== app.uid) { + warn(app.uid, uid); + fail("Unauthorized"); + } await db.sRem(`${uid}:apps`, pubkey); await db.del(`app:${pubkey}`); res.send({}); } catch (e) { - console.log(e); bail(res, e.message); } }, @@ -1041,4 +1343,11 @@ export default { const liquid = await g(`credit:liquid:${id}`); res.send({ bitcoin, lightning, liquid }); }, + + async ro(req, res) { + const { user } = req; + const payload = { id: `${user.id}-ro` }; + const token = jwt.sign(payload, config.jwt); + res.send(token); + }, }; diff --git a/scripts/analytics-geo.ts b/scripts/analytics-geo.ts new file mode 100644 index 00000000..ef088b6b --- /dev/null +++ b/scripts/analytics-geo.ts @@ -0,0 +1,134 @@ +#!/usr/bin/env bun +// Geographic report from the request log. Ties Cloudflare's cf-ipcountry header +// to the authenticated `user` field so we count DISTINCT USERS per country +// (not raw request volume, which is dominated by datacenter/bot traffic). +// +// Usage: bun scripts/analytics-geo.ts [--days N] [--top N] +// Streams the request log (pino JSONL, multi-GB) — never loads it whole. + +import { createInterface } from "readline"; +import { createReadStream, readdirSync } from "fs"; +import { createGunzip } from "zlib"; + +const argDays = Number(process.argv[process.argv.indexOf("--days") + 1]) || 30; +const TOP = Number(process.argv[process.argv.indexOf("--top") + 1]) || 25; +const cutoff = Date.now() - argDays * 24 * 60 * 60 * 1000; + +// Datacenter IPs that flood /login with many usernames (credential-testing +// bots). Excluded from geo counts. 195.123.220.232 = M247 NL, ~3,971 logins +// for sequential usernames (bugtraq, bugtraqq, ...) in the last 30d. +const BOT_IPS = new Set<string>(["195.123.220.232"]); + + +// The live log rotates into 50M dated chunks under reqlogs/ (named for when +// they were rotated out, gzipped except the newest), so stream every chunk +// rotated within the window, oldest first, then the live file. +const REQ = "/home/adam/coinos-server/req"; +const REQLOGS = "/home/adam/coinos-server/reqlogs"; +const chunkTime = (f: string) => { + const m = f.match(/^req-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})?/); + return m ? new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +(m[6] || 0)).getTime() : 0; +}; +async function* reqLines(since: number) { + const files = readdirSync(REQLOGS) + .filter((f) => chunkTime(f) >= since) + .sort((a, b) => chunkTime(a) - chunkTime(b) || a.localeCompare(b)) + .map((f) => `${REQLOGS}/${f}`); + for (const path of [...files, REQ]) { + const raw = createReadStream(path); + const input = path.endsWith(".gz") ? raw.pipe(createGunzip()) : raw; + yield* createInterface({ input, crlfDelay: Infinity }); + } +} + +const rl = reqLines(cutoff); + +// We only trust /login and /register events for geography: they are real human +// auth actions tied to a username in the body, from the user's actual device. +// Counting ALL authenticated requests pollutes the result massively — e.g. +// nostr/NWC server traffic from a single M247 datacenter IP (195.123.220.232) +// carries a stale `user` field and made NL look like 93% of users. Per-IP-per- +// country dedup on auth events is the honest signal. + +// country -> Set<username> (distinct users who logged in / registered there) +const usersByCountry = new Map<string, Set<string>>(); +// country -> distinct IPs (sanity: real geography has many IPs per country) +const ipsByCountry = new Map<string, Set<string>>(); +let lines = 0; +let authEvents = 0; + +const extractUsername = (r: any): string | null => { + // /login and /register carry username in the body (top-level `user` is empty + // on these). Body shape varies: {username} or {user:{username}}. + const b = r.body; + if (!b) return null; + if (typeof b.username === "string") return b.username.toLowerCase(); + if (b.user && typeof b.user.username === "string") return b.user.username.toLowerCase(); + return null; +}; + +for await (const line of rl) { + lines++; + // cheap pre-filter: must be an auth event with a country tag + if (!line.includes("cf-ipcountry")) continue; + if (!line.includes('"/login"') && !line.includes('"/register"')) continue; + let r: any; + try { + r = JSON.parse(line); + } catch { + continue; + } + if (!r.time || r.time < cutoff) continue; + if (r.url !== "/login" && r.url !== "/register") continue; + const cc = r.headers?.["cf-ipcountry"]; + if (!cc || cc === "XX" || cc.length !== 2) continue; + const ip = r.headers?.["cf-connecting-ip"]; + + const user = extractUsername(r); + if (!user) continue; + + // Drop known bot/proxy egress IPs that fire thousands of logins for many + // usernames from a single datacenter address — these are credential-testing + // bots, not real users, and otherwise dominate the country ranking. Heuristic + // backstop below (users >> ips) catches the rest; this is the worst offender. + if (ip && BOT_IPS.has(ip)) continue; + authEvents++; + + let s = usersByCountry.get(cc); + if (!s) usersByCountry.set(cc, (s = new Set())); + s.add(user); + + if (ip) { + let is = ipsByCountry.get(cc); + if (!is) ipsByCountry.set(cc, (is = new Set())); + is.add(ip); + } +} +const withCountry = authEvents; + +const byUsers = [...usersByCountry.entries()] + .map(([cc, set]) => [cc, set.size, ipsByCountry.get(cc)?.size || 0] as [string, number, number]) + .sort((a, b) => b[1] - a[1]); + +const totalUsers = byUsers.reduce((s, [, n]) => s + n, 0); +const max = byUsers[0]?.[1] || 1; +const bar = (n: number) => + "█".repeat(Math.round((n / max) * 24)) + "·".repeat(24 - Math.round((n / max) * 24)); + +console.log("═".repeat(64)); +console.log(` COINOS GEO — last ${argDays} days (login/register events only)`); +console.log(` scanned ${lines.toLocaleString()} log lines, ${withCountry.toLocaleString()} auth events`); +console.log("═".repeat(64)); +console.log("\n▏TOP COUNTRIES (distinct users who logged in / registered)"); +console.log(" cc distribution users ips share"); +byUsers.slice(0, TOP).forEach(([cc, n, ips], i) => { + const pct = ((n / totalUsers) * 100).toFixed(1); + console.log( + ` ${String(i + 1).padStart(2)}. ${cc} ${bar(n)} ${String(n).padStart(5)} ${String(ips).padStart(4)} ${pct.padStart(5)}%`, + ); +}); +console.log( + `\n (ips column = distinct source IPs; a country with users >> ips is likely a proxy/VPN egress, not real geography)`, +); +console.log("\n" + "═".repeat(64)); +process.exit(0); diff --git a/scripts/analytics.ts b/scripts/analytics.ts new file mode 100644 index 00000000..7cefa3d4 --- /dev/null +++ b/scripts/analytics.ts @@ -0,0 +1,309 @@ +#!/usr/bin/env bun +// Coinos analytics report. Streams the live `db` (SCAN, never KEYS) and +// aggregates payment + balance + user data into an insight report. +// +// Usage: +// bun scripts/analytics.ts [--days N] [--top N] [--json] [--csv PATH] +// +// Defaults: --days 30, --top 20. Reads only the live db (redis://127.0.0.1:6379); +// archived payments (older, in `arc`) are out of scope for the trailing window. +// --csv appends one metrics row to PATH (writing a header if the file is new), +// for month-over-month trend tracking; the human report still prints to stdout. + +import { createClient } from "redis"; +import { appendFileSync, existsSync } from "fs"; + +const argDays = Number(process.argv[process.argv.indexOf("--days") + 1]) || 30; +const TOP = Number(process.argv[process.argv.indexOf("--top") + 1]) || 20; +const asJson = process.argv.includes("--json"); +const csvIdx = process.argv.indexOf("--csv"); +const csvPath = csvIdx >= 0 ? process.argv[csvIdx + 1] : null; + +const now = Date.now(); +const cutoff = now - argDays * 24 * 60 * 60 * 1000; + +const db = createClient({ + url: "redis://127.0.0.1:6379", + socket: { reconnectStrategy: () => false }, +}); +await db.connect(); + +const fmtSats = (n: number) => + n.toLocaleString("en-US") + " sats"; +const fmtBtc = (n: number) => (n / 1e8).toFixed(3) + " BTC"; + +// ---- helpers --------------------------------------------------------------- + +async function* scanGetJSON(match: string, count = 1000) { + // Stream keys matching `match`, fetch values in pipelined batches, yield + // parsed JSON objects (skips non-JSON / mapping-string keys). + let batch: string[] = []; + async function* drain() { + if (!batch.length) return; + const vals = await db.mGet(batch); + for (let i = 0; i < vals.length; i++) { + const v = vals[i]; + if (!v || v[0] !== "{") continue; // skip mapping strings / nulls + try { + yield [batch[i], JSON.parse(v)] as [string, any]; + } catch {} + } + batch = []; + } + for await (const key of db.scanIterator({ MATCH: match, COUNT: count })) { + batch.push(key as string); + if (batch.length >= 500) yield* drain(); + } + yield* drain(); +} + +// ---- pass 1: usernames (uid -> username) ----------------------------------- +// user:<uid> is the full record; user:<username> is a string pointer we skip. + +const uname = new Map<string, string>(); +let userRecords = 0; +let newUsers = 0; // created in window (if `created` present on user) +for await (const [key, u] of scanGetJSON("user:*")) { + if (!u?.id || !u?.username) continue; + uname.set(u.id, u.username); + userRecords++; + if (u.created && u.created >= cutoff) newUsers++; +} + +const nameOf = (uid: string) => uname.get(uid) || uid.slice(0, 8); + +// ---- pass 2: balances (holders) -------------------------------------------- + +type Holder = { uid: string; bal: number }; +const holders: Holder[] = []; +let totalCustodied = 0; +for await (const key of db.scanIterator({ MATCH: "balance:*", COUNT: 1000 })) { + const uid = (key as string).slice("balance:".length); + const v = await db.get(key as string); + const bal = Number(v) || 0; + if (bal > 0) { + holders.push({ uid, bal }); + totalCustodied += bal; + } +} +holders.sort((a, b) => b.bal - a.bal); + +// ---- pass 3: payments (the heavy walk) ------------------------------------- + +const TYPES = [ + "lightning", + "liquid", + "bitcoin", + "internal", + "bolt12", + "ecash", + "fund", + "reconcile", +] as const; + +type Agg = { count: number; volume: number; sent: number; received: number }; +const byType: Record<string, Agg> = {}; +for (const t of TYPES) byType[t] = { count: 0, volume: 0, sent: 0, received: 0 }; + +const activeUids = new Set<string>(); +const weekActive = new Set<string>(); // last 7d +const perUser = new Map<string, { count: number; volume: number }>(); +const bolt12Recipients = new Map<string, number>(); // uid -> # bolt12 receives +let feeRevenue = 0; +let paymentsInWindow = 0; +let largest: { uid: string; amount: number; type: string } | null = null; +const dayBuckets = new Map<string, number>(); // YYYY-MM-DD -> count + +const weekCutoff = now - 7 * 24 * 60 * 60 * 1000; + +let scanned = 0; +const t0 = Date.now(); +for await (const [key, p] of scanGetJSON("payment:*", 2000)) { + scanned++; + if (!p?.created || typeof p.amount !== "number" || !p.uid) continue; + if (p.created < cutoff) continue; + + paymentsInWindow++; + const uid = p.uid; + const type = p.type || "unknown"; + const amt = p.amount; + const abs = Math.abs(amt); + + const agg = byType[type] || (byType[type] = { count: 0, volume: 0, sent: 0, received: 0 }); + agg.count++; + agg.volume += abs; + if (amt < 0) agg.sent += abs; + else agg.received += abs; + + activeUids.add(uid); + if (p.created >= weekCutoff) weekActive.add(uid); + + const pu = perUser.get(uid) || { count: 0, volume: 0 }; + pu.count++; + pu.volume += abs; + perUser.set(uid, pu); + + if (type === "bolt12" && amt > 0) { + bolt12Recipients.set(uid, (bolt12Recipients.get(uid) || 0) + 1); + } + + if (typeof p.fee === "number") feeRevenue += p.fee; + if (typeof p.ourfee === "number") feeRevenue += p.ourfee; + + if (!largest || abs > largest.amount) largest = { uid, amount: abs, type }; + + const day = new Date(p.created).toISOString().slice(0, 10); + dayBuckets.set(day, (dayBuckets.get(day) || 0) + 1); +} +const walkSecs = ((Date.now() - t0) / 1000).toFixed(1); + +// ---- derive ---------------------------------------------------------------- + +const mostActive = [...perUser.entries()] + .sort((a, b) => b[1].count - a[1].count) + .slice(0, TOP); +const highestVolume = [...perUser.entries()] + .sort((a, b) => b[1].volume - a[1].volume) + .slice(0, TOP); + +// "Regular" bolt12 recipients = received via bolt12 on multiple distinct days +// would be ideal, but receive-count >= 4 in the window is a good proxy for the +// Ocean mining-reward stream. +const regularBolt12 = [...bolt12Recipients.entries()].filter(([, n]) => n >= 4); +regularBolt12.sort((a, b) => b[1] - a[1]); + +const totalVolume = Object.values(byType).reduce((s, a) => s + a.volume, 0); + +// ---- CSV append (trend tracking) ------------------------------------------- +// One row per run. Stable column set so months line up; per-type volume/count +// columns let us chart the lightning/liquid/bitcoin/internal/bolt12 mix over +// time. Written before stdout output so it happens in both text and --json modes. +if (csvPath) { + const typeCols = ["lightning", "liquid", "bitcoin", "internal", "bolt12", "fund"]; + const header = [ + "date", + "window_days", + "users_total", + "new_users", + "mau", + "wau", + "payments", + "total_volume_sats", + "custodied_sats", + "holders", + "fee_revenue_sats", + "bolt12_recipients", + "regular_bolt12", + ...typeCols.flatMap((t) => [`${t}_vol`, `${t}_txns`]), + ]; + const row = [ + new Date(now).toISOString().slice(0, 10), + argDays, + userRecords, + newUsers, + activeUids.size, + weekActive.size, + paymentsInWindow, + totalVolume, + totalCustodied, + holders.length, + feeRevenue, + bolt12Recipients.size, + regularBolt12.length, + ...typeCols.flatMap((t) => [byType[t]?.volume || 0, byType[t]?.count || 0]), + ]; + const fresh = !existsSync(csvPath); + appendFileSync(csvPath, (fresh ? header.join(",") + "\n" : "") + row.join(",") + "\n"); + // To stderr so it doesn't pollute the saved stdout report / --json output. + console.error(`[csv] appended row to ${csvPath}`); +} + +// ---- output ---------------------------------------------------------------- + +if (asJson) { + console.log( + JSON.stringify( + { + window_days: argDays, + generated_at: new Date(now).toISOString(), + users_total: userRecords, + new_users_in_window: newUsers, + mau: activeUids.size, + wau: weekActive.size, + payments_in_window: paymentsInWindow, + total_volume_sats: totalVolume, + total_custodied_sats: totalCustodied, + fee_revenue_sats: feeRevenue, + by_type: byType, + top_holders: holders.slice(0, TOP).map((h) => ({ user: nameOf(h.uid), bal: h.bal })), + most_active: mostActive.map(([uid, v]) => ({ user: nameOf(uid), ...v })), + highest_volume: highestVolume.map(([uid, v]) => ({ user: nameOf(uid), ...v })), + bolt12_recipients: bolt12Recipients.size, + regular_bolt12_recipients: regularBolt12.length, + }, + null, + 2, + ), + ); + process.exit(0); +} + +const line = (s = "") => console.log(s); +const bar = (n: number, max: number, width = 30) => + "█".repeat(Math.round((n / max) * width)) + "·".repeat(width - Math.round((n / max) * width)); + +line("═".repeat(64)); +line(` COINOS ANALYTICS — last ${argDays} days (${new Date(now).toISOString().slice(0, 16)}Z)`); +line(` walked ${scanned.toLocaleString()} payment keys in ${walkSecs}s`); +line("═".repeat(64)); + +line("\n▏USERS"); +line(` Total user records ........ ${userRecords.toLocaleString()}`); +line(` New users (window) ........ ${newUsers.toLocaleString()}`); +line(` MAU (active ${argDays}d) ......... ${activeUids.size.toLocaleString()}`); +line(` WAU (active 7d) ........... ${weekActive.size.toLocaleString()}`); +line(` Payments (window) ......... ${paymentsInWindow.toLocaleString()}`); + +line("\n▏CUSTODY"); +line(` Total custodied ........... ${fmtSats(totalCustodied)} (${fmtBtc(totalCustodied)})`); +line(` Holders (>0 bal) .......... ${holders.length.toLocaleString()}`); +line(` Fee revenue (window) ...... ${fmtSats(feeRevenue)}`); + +line("\n▏NETWORK VOLUME (gross sats moved, window)"); +const typesSorted = Object.entries(byType) + .filter(([, a]) => a.count > 0) + .sort((a, b) => b[1].volume - a[1].volume); +const maxVol = Math.max(...typesSorted.map(([, a]) => a.volume), 1); +for (const [t, a] of typesSorted) { + const pct = ((a.volume / totalVolume) * 100).toFixed(1); + line(` ${t.padEnd(10)} ${bar(a.volume, maxVol, 24)} ${pct.padStart(5)}% ${a.count.toLocaleString()} txns`); + line(` ${fmtSats(a.volume).padEnd(22)} (${fmtBtc(a.volume)})`); +} + +line("\n▏TOP HOLDERS"); +holders.slice(0, TOP).forEach((h, i) => + line(` ${String(i + 1).padStart(2)}. ${nameOf(h.uid).padEnd(24)} ${fmtSats(h.bal)}`), +); + +line("\n▏MOST ACTIVE USERS (by payment count)"); +mostActive.forEach(([uid, v], i) => + line(` ${String(i + 1).padStart(2)}. ${nameOf(uid).padEnd(24)} ${v.count.toLocaleString()} txns, ${fmtSats(v.volume)}`), +); + +line("\n▏HIGHEST VOLUME USERS (gross sats moved)"); +highestVolume.forEach(([uid, v], i) => + line(` ${String(i + 1).padStart(2)}. ${nameOf(uid).padEnd(24)} ${fmtSats(v.volume)} (${v.count} txns)`), +); + +line("\n▏BOLT12 (Ocean mining rewards etc.)"); +line(` Distinct bolt12 recipients ........ ${bolt12Recipients.size.toLocaleString()}`); +line(` Regular recipients (>=4 receives) . ${regularBolt12.length.toLocaleString()}`); +regularBolt12.slice(0, TOP).forEach(([uid, n], i) => + line(` ${String(i + 1).padStart(2)}. ${nameOf(uid).padEnd(24)} ${n} bolt12 receives`), +); + +if (largest) + line(`\n Largest single payment: ${fmtSats(largest.amount)} (${largest.type}) by ${nameOf(largest.uid)}`); + +line("\n" + "═".repeat(64)); +process.exit(0); diff --git a/scripts/audit-lightning-backlog.ts b/scripts/audit-lightning-backlog.ts new file mode 100644 index 00000000..4c78e295 --- /dev/null +++ b/scripts/audit-lightning-backlog.ts @@ -0,0 +1,76 @@ +import { db } from "$lib/db"; +import ln from "$lib/ln"; +import { getInvoice, getPayment, getUser } from "$lib/utils"; + +const max = Number(process.argv[2] || 10_000); +const stored = Number((await db.get("pay_index")) || 0); +let cursor = stored; +const rows: any[] = []; + +for (let i = 0; i < max; i++) { + let settled: any; + try { + settled = await ln.waitanyinvoice(cursor, 1); + } catch (e: any) { + if (Number(e?.code ?? e?.errno) === 904) break; + throw e; + } + + if (!settled?.pay_index || Number(settled.pay_index) <= cursor) break; + cursor = Number(settled.pay_index); + + const mintOwned = + typeof settled.label === "string" && settled.label.startsWith("lbl"); + const paymentRequest = settled.bolt11 || settled.bolt12; + const invoiceKey = settled.bolt11 || settled.local_offer_id || settled.bolt12; + const invoice = mintOwned ? null : await getInvoice(invoiceKey); + const payment = paymentRequest ? await getPayment(paymentRequest) : null; + const user = invoice?.uid ? await getUser(invoice.uid) : null; + const claim = settled.payment_preimage + ? await db.get(`credited:${settled.payment_preimage}`) + : null; + + rows.push({ + payIndex: cursor, + amountSats: Math.round(Number(settled.amount_received_msat || 0) / 1000), + mintOwned, + invoiceFound: !!invoice, + paymentFound: !!payment, + claimFound: !!claim, + username: user?.username, + autowithdraw: !!user?.autowithdraw, + }); +} + +const coinos = rows.filter((row) => !row.mintOwned); +const actionable = coinos.filter( + (row) => row.invoiceFound && !row.paymentFound && !row.claimFound, +); + +console.log( + JSON.stringify( + { + storedPayIndex: stored, + latestPayIndex: cursor, + backlog: rows.length, + mintOwned: rows.filter((row) => row.mintOwned).length, + coinos: coinos.length, + alreadyProcessed: coinos.filter((row) => row.paymentFound).length, + missingInvoice: coinos.filter((row) => !row.invoiceFound).length, + claimedWithoutPayment: coinos.filter( + (row) => row.claimFound && !row.paymentFound, + ).length, + actionable: actionable.length, + actionableSats: actionable.reduce( + (total, row) => total + row.amountSats, + 0, + ), + autowithdraw: actionable.filter((row) => row.autowithdraw).length, + rows, + }, + null, + 2, + ), +); + +process.exit(0); diff --git a/scripts/audit-missing-history.ts b/scripts/audit-missing-history.ts new file mode 100644 index 00000000..30c2c8fc --- /dev/null +++ b/scripts/audit-missing-history.ts @@ -0,0 +1,106 @@ +#!/usr/bin/env bun +// Audit how widespread missing payment history is and classify WHY. +// +// For each user with a non-empty :payments list, check each referenced +// payment:<id> across db and arc, and bucket the user: +// - clean: all records resolve (db or arc) +// - recoverable: some/all records missing from db but present in arc +// - lost: some records missing from BOTH db and arc (true loss) +// Also records per-payment counts so we can size a restore. +// +// Read-only. Samples by default; pass --full to scan every user (slower). +// Usage: bun scripts/audit-missing-history.ts [--full] [--sample N] [--examples N] + +import { createClient } from "redis"; + +const full = process.argv.includes("--full"); +const sampleN = Number(process.argv[process.argv.indexOf("--sample") + 1]) || 4000; +const exN = Number(process.argv[process.argv.indexOf("--examples") + 1]) || 15; + +const db = createClient({ url: "redis://127.0.0.1:6379", socket: { reconnectStrategy: () => false } }); +const arc = createClient({ url: "redis://127.0.0.1:6380", socket: { reconnectStrategy: () => false } }); +await db.connect(); +await arc.connect(); + +// Resolve uid -> username lazily (only for examples) to keep memory low. +async function nameOf(uid: string) { + const u = await db.get(`user:${uid}`); + if (u && u[0] === "{") { try { return JSON.parse(u).username || uid.slice(0, 8); } catch {} } + return uid.slice(0, 8); +} + +let usersScanned = 0; +let usersWithPayments = 0; +const bucket = { clean: 0, recoverable: 0, lost: 0 }; +let totalRefs = 0, inDb = 0, inArcOnly = 0, missingBoth = 0; +const examples: { recoverable: string[]; lost: string[] } = { recoverable: [], lost: [] }; + +// Helper: does payment:<id> exist in db / arc? (batched EXISTS) +async function classifyUser(uid: string): Promise<"clean" | "recoverable" | "lost" | null> { + const pids = await db.lRange(`${uid}:payments`, 0, -1); + if (!pids.length) return null; + usersWithPayments++; + + let anyArcOnly = false, anyMissing = false; + for (const pid of pids) { + totalRefs++; + const key = `payment:${pid}`; + const dbHas = await db.exists(key); + if (dbHas) { inDb++; continue; } + const arcHas = await arc.exists(key); + if (arcHas) { inArcOnly++; anyArcOnly = true; } + else { missingBoth++; anyMissing = true; } + } + if (anyMissing) return "lost"; + if (anyArcOnly) return "recoverable"; + return "clean"; +} + +const t0 = Date.now(); +let processed = 0; +for await (const key of db.scanIterator({ MATCH: "user:*", COUNT: 1000 })) { + // Only the canonical record key user:<uuid> (skip user:<username> pointers). + const id = (key as string).slice("user:".length); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-/.test(id)) continue; + usersScanned++; + if (!full && usersScanned > sampleN) break; + + const cls = await classifyUser(id); + if (!cls) continue; + bucket[cls]++; + if (cls === "recoverable" && examples.recoverable.length < exN) examples.recoverable.push(id); + if (cls === "lost" && examples.lost.length < exN) examples.lost.push(id); + processed++; +} +const secs = ((Date.now() - t0) / 1000).toFixed(1); + +const totalClassified = bucket.clean + bucket.recoverable + bucket.lost; +const pct = (n: number) => totalClassified ? ((n / totalClassified) * 100).toFixed(1) + "%" : "0%"; + +console.log("═".repeat(66)); +console.log(` MISSING-HISTORY AUDIT — ${full ? "FULL scan" : `sample of ${sampleN} users`}`); +console.log(` scanned ${usersScanned} user records, ${usersWithPayments} with payments, in ${secs}s`); +console.log("═".repeat(66)); +console.log(`\n Users with payment history: ${totalClassified}`); +console.log(` clean (all records present) ...... ${bucket.clean} (${pct(bucket.clean)})`); +console.log(` recoverable (in arc, not db) ..... ${bucket.recoverable} (${pct(bucket.recoverable)}) <- restorable`); +console.log(` lost (missing from db AND arc) ... ${bucket.lost} (${pct(bucket.lost)}) <- true loss`); +console.log(`\n Payment records referenced: ${totalRefs}`); +console.log(` in db ............ ${inDb} (${((inDb/totalRefs)*100||0).toFixed(1)}%)`); +console.log(` in arc only ...... ${inArcOnly} (${((inArcOnly/totalRefs)*100||0).toFixed(1)}%) <- restorable`); +console.log(` missing both ..... ${missingBoth} (${((missingBoth/totalRefs)*100||0).toFixed(1)}%) <- gone`); + +if (examples.recoverable.length) { + console.log(`\n Example RECOVERABLE users:`); + for (const uid of examples.recoverable) console.log(` ${await nameOf(uid)} (${uid})`); +} +if (examples.lost.length) { + console.log(`\n Example LOST-record users:`); + for (const uid of examples.lost) console.log(` ${await nameOf(uid)} (${uid})`); +} +if (!full) { + const est = Math.round((bucket.recoverable / Math.min(usersScanned, sampleN)) * usersScanned); + console.log(`\n (sample only — run --full for exact totals)`); +} +console.log("═".repeat(66)); +process.exit(0); diff --git a/scripts/audit-wiped-history.ts b/scripts/audit-wiped-history.ts new file mode 100644 index 00000000..d4e94645 --- /dev/null +++ b/scripts/audit-wiped-history.ts @@ -0,0 +1,85 @@ +#!/usr/bin/env bun +// Find accounts whose payment history index was wiped — the changeid bug +// signature, distinct from the harmless arc-only archived state. +// +// Two buckets: +// wiped-with-balance: balance > 0 but :payments list is empty in BOTH db and +// arc. Real funds, no visible history. These are the changeid-rekeyed +// accounts (password == "reset") whose list entries got dropped. Highest +// priority — user sees a balance with no transactions. +// wiped-no-balance: :payments empty in both, balance 0, but has :invoices +// (so they did transact once). History gone but nothing owed; lower priority. +// +// Read-only. Usage: bun scripts/audit-wiped-history.ts [--full] [--examples N] + +import { createClient } from "redis"; + +const full = process.argv.includes("--full"); +const exN = Number(process.argv[process.argv.indexOf("--examples") + 1]) || 30; + +const db = createClient({ url: "redis://127.0.0.1:6379", socket: { reconnectStrategy: () => false } }); +const arc = createClient({ url: "redis://127.0.0.1:6380", socket: { reconnectStrategy: () => false } }); +await db.connect(); +await arc.connect(); + +let scanned = 0; +const wipedWithBal: { name: string; uid: string; bal: number; reset: boolean }[] = []; +const wipedNoBal: { name: string; uid: string; invoices: number }[] = []; +let cleanCount = 0; + +const t0 = Date.now(); +for await (const batch of db.scanIterator({ MATCH: "user:*", COUNT: 2000 })) { + // This redis client version yields ARRAYS of keys per iteration, not single keys. + const keys = Array.isArray(batch) ? batch : [batch]; + for (const key of keys) { + const uid = (key as string).slice("user:".length); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-/.test(uid)) continue; // skip username pointers + scanned++; + + const dbLen = await db.lLen(`${uid}:payments`); + if (dbLen > 0) { cleanCount++; continue; } // has visible history + + // Empty in db — is it also empty in arc, or just archived? + const arcLen = await arc.lLen(`${uid}:payments`); + if (arcLen > 0) { cleanCount++; continue; } // renders via gf(), fine + + // :payments empty in both. Does it matter? Check balance + whether they ever transacted. + const bal = parseInt((await db.get(`balance:${uid}`)) || "0", 10); + const invLen = await db.lLen(`${uid}:invoices`); + + if (bal > 0) { + const u = await db.get(`user:${uid}`); + let name = uid.slice(0, 8), reset = false; + if (u && u[0] === "{") { try { const j = JSON.parse(u); name = j.username || name; reset = j.password === "reset"; } catch {} } + wipedWithBal.push({ name, uid, bal, reset }); + } else if (invLen > 0) { + const u = await db.get(`user:${uid}`); + let name = uid.slice(0, 8); + if (u && u[0] === "{") { try { name = JSON.parse(u).username || name; } catch {} } + wipedNoBal.push({ name, uid, invoices: invLen }); + } + } +} +const secs = ((Date.now() - t0) / 1000).toFixed(1); + +wipedWithBal.sort((a, b) => b.bal - a.bal); + +console.log("═".repeat(70)); +console.log(` WIPED-HISTORY AUDIT — scanned ${scanned} users in ${secs}s`); +console.log("═".repeat(70)); +console.log(`\n Accounts with BALANCE but no history (priority — funds visible, no txns):`); +console.log(` count: ${wipedWithBal.length}`); +const totalBal = wipedWithBal.reduce((s, x) => s + x.bal, 0); +console.log(` total balance affected: ${totalBal.toLocaleString()} sats`); +console.log(` (of which password=="reset" / changeid'd: ${wipedWithBal.filter((x) => x.reset).length})`); +console.log(""); +for (const x of wipedWithBal.slice(0, exN)) { + console.log(` ${x.name.padEnd(24)} ${(x.bal + "").padStart(12)} sats ${x.reset ? "[changeid]" : ""} ${x.uid}`); +} + +console.log(`\n Accounts wiped, no balance (had invoices, lower priority): ${wipedNoBal.length}`); +for (const x of wipedNoBal.slice(0, 10)) { + console.log(` ${x.name.padEnd(24)} ${x.invoices} invoices ${x.uid}`); +} +console.log("═".repeat(70)); +process.exit(0); diff --git a/scripts/boltz-recover-stuck.ts b/scripts/boltz-recover-stuck.ts new file mode 100644 index 00000000..55393f40 --- /dev/null +++ b/scripts/boltz-recover-stuck.ts @@ -0,0 +1,91 @@ +// boltz-recover-stuck.ts — auto-recover boltz submarine swaps whose Lightning +// payment got stuck, using the stubborn peer-force payer (pay-hard.ts). +// +// Background: boltz-backend pays a submarine swap's destination invoice with +// CLN's basic `pay` and gives up after a few minutes. To inbound-scarce exchange +// destinations (fixedfloat), that leaves the user's on-chain funds locked and the +// swap stuck at `invoice.pending` — exactly what happened 2026-08-27. pay-hard's +// peer-force strategy completes those. After the invoice is paid on bcln, boltz +// reconciles (picks up the preimage and claims the lockup) on its next restart; +// if it hasn't within a grace period, we restart it once. +// +// Runs on a cron from the coinos-server host (reaches cl locally and bcln/boltz +// on cs via ssh). Quiet unless it finds a stuck swap. Log: ~/boltz-recover.log +// +// Usage: bun scripts/boltz-recover-stuck.ts (add --dry to detect+probe only) +import { execSync } from "child_process"; +import { appendFileSync, writeFileSync } from "fs"; + +const DRY = process.argv.includes("--dry"); +const LOG = "/home/adam/boltz-recover.log"; +const PAYHARD = "/home/adam/coinos-server/scripts/pay-hard.ts"; +const BUN = "/home/adam/.bun/bin/bun"; +const STUCK_MIN = 10; // only touch swaps idle at least this long (don't race boltz) +const MAX_PER_RUN = 5; // safety cap +const BCLN = "docker exec bcln lightning-cli --lightning-dir=/app/lightning"; + +const sh = (c: string) => { try { return execSync(c, { encoding: "utf8", maxBuffer: 1e8 }); } catch (e: any) { return (e.stdout || "") + (e.stderr || ""); } }; +const log = (m: string) => { const line = `[${new Date().toISOString()}] ${m}`; try { appendFileSync(LOG, line + "\n"); } catch {} console.log(line); }; +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +// run a SQL query against boltz-postgres on cs (query via stdin -> no quoting hell) +function psql(query: string): string[] { + writeFileSync("/tmp/boltz_recover_q.sql", query); + const out = sh(`ssh -o ConnectTimeout=15 cs 'docker exec -i boltz-postgres psql -U boltz -d boltz -tAF"|"' < /tmp/boltz_recover_q.sql`); + return out.trim().split("\n").filter(Boolean); +} +const bcln = (sub: string) => sh(`ssh -o ConnectTimeout=15 cs '${BCLN} ${sub}'`); + +// ---- find stuck submarine swaps ---- +// `swaps` = submarine swaps (user locks on-chain, boltz pays the LN invoice). +const rows = psql( + `select id, invoice from swaps ` + + `where status='invoice.pending' and "lockupTransactionId" is not null and invoice is not null ` + + `and "updatedAt" < now() - interval '${STUCK_MIN} minutes' ` + + `order by "updatedAt" asc limit ${MAX_PER_RUN}`, +).map((l) => { const [id, invoice] = l.split("|"); return { id, invoice }; }); + +if (!rows.length) process.exit(0); // quiet: nothing stuck +log(`found ${rows.length} stuck submarine swap(s): ${rows.map((r) => r.id).join(", ")}`); + +const paidIds: string[] = []; +for (const { id, invoice } of rows) { + // skip expired invoices — those are a refund case, not ours to force + let dec: any = null; + try { dec = JSON.parse(bcln(`decode ${invoice}`)); } catch {} + if (dec?.created_at && dec?.expiry && Date.now() / 1000 > dec.created_at + dec.expiry) { + log(` ${id}: invoice expired — skipping (refund path)`); + continue; + } + + if (DRY) { + const out = sh(`${BUN} ${PAYHARD} ${invoice} --node=bcln --dry`); + log(` ${id}: [dry] ${out.trim().split("\n").pop()}`); + continue; + } + + log(` ${id}: running pay-hard…`); + const out = sh(`${BUN} ${PAYHARD} ${invoice} --node=bcln --maxfee=6000`); + const last = out.trim().split("\n").pop() || ""; + const paid = /🎉 PAID|already paid|xpay succeeded/.test(out); + log(` → ${last}`); + if (paid) paidIds.push(id); +} + +// ---- reconcile: make boltz claim the lockups for swaps we just paid ---- +if (paidIds.length && !DRY) { + await sleep(45_000); // give boltz a chance to notice on its own + const still = psql( + `select id from swaps where id in (${paidIds.map((i) => `'${i}'`).join(",")}) and status='invoice.pending'`, + ); + if (still.length) { + log(`boltz did not auto-reconcile ${still.length} paid swap(s) [${still.join(",")}] — restarting boltz to claim`); + sh(`ssh -o ConnectTimeout=20 cs 'docker restart boltz'`); + await sleep(20_000); + const after = psql(`select id, status from swaps where id in (${paidIds.map((i) => `'${i}'`).join(",")})`); + log(` post-restart status: ${after.join(" ; ")}`); + } else { + log(`boltz reconciled all ${paidIds.length} paid swap(s) on its own`); + } +} +process.exit(0); diff --git a/scripts/cln_schema.sql b/scripts/cln_schema.sql new file mode 100644 index 00000000..e8bfa499 --- /dev/null +++ b/scripts/cln_schema.sql @@ -0,0 +1,1783 @@ +-- +-- PostgreSQL database dump +-- + +\restrict 3gmSHpmWoAvp3M9hYnbn0eAJg2qkrJcZzqafmBmvLGVxDFS2cVhidb6yKDBdS99 + +-- Dumped from database version 18.3 (Debian 18.3-1.pgdg13+1) +-- Dumped by pg_dump version 18.3 (Debian 18.3-1.pgdg13+1) + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET transaction_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + +SET default_tablespace = ''; + +SET default_table_access_method = heap; + +-- +-- Name: addresses; Type: TABLE; Schema: public; Owner: lightning +-- + +CREATE TABLE public.addresses ( + keyidx bigint, + addrtype integer +); + + +ALTER TABLE public.addresses OWNER TO lightning; + +-- +-- Name: blocks; Type: TABLE; Schema: public; Owner: lightning +-- + +CREATE TABLE public.blocks ( + height integer, + hash bytea, + prev_hash bytea +); + + +ALTER TABLE public.blocks OWNER TO lightning; + +-- +-- Name: chain_moves; Type: TABLE; Schema: public; Owner: lightning +-- + +CREATE TABLE public.chain_moves ( + id bigint NOT NULL, + account_channel_id bigint, + account_nonchannel_id bigint, + tag_bitmap bigint NOT NULL, + credit_or_debit bigint NOT NULL, + "timestamp" bigint NOT NULL, + utxo bytea NOT NULL, + spending_txid bytea, + peer_id bytea, + payment_hash bytea, + block_height integer NOT NULL, + output_sat bigint NOT NULL, + originating_channel_id bigint, + originating_nonchannel_id bigint, + output_count integer +); + + +ALTER TABLE public.chain_moves OWNER TO lightning; + +-- +-- Name: chain_moves_id_seq; Type: SEQUENCE; Schema: public; Owner: lightning +-- + +CREATE SEQUENCE public.chain_moves_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE public.chain_moves_id_seq OWNER TO lightning; + +-- +-- Name: chain_moves_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: lightning +-- + +ALTER SEQUENCE public.chain_moves_id_seq OWNED BY public.chain_moves.id; + + +-- +-- Name: channel_blockheights; Type: TABLE; Schema: public; Owner: lightning +-- + +CREATE TABLE public.channel_blockheights ( + channel_id bigint, + hstate integer, + blockheight integer +); + + +ALTER TABLE public.channel_blockheights OWNER TO lightning; + +-- +-- Name: channel_configs; Type: TABLE; Schema: public; Owner: lightning +-- + +CREATE TABLE public.channel_configs ( + id bigint NOT NULL, + dust_limit_satoshis bigint, + max_htlc_value_in_flight_msat bigint, + channel_reserve_satoshis bigint, + htlc_minimum_msat bigint, + to_self_delay integer, + max_accepted_htlcs integer, + max_dust_htlc_exposure_msat bigint DEFAULT 50000000 +); + + +ALTER TABLE public.channel_configs OWNER TO lightning; + +-- +-- Name: channel_configs_id_seq; Type: SEQUENCE; Schema: public; Owner: lightning +-- + +CREATE SEQUENCE public.channel_configs_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE public.channel_configs_id_seq OWNER TO lightning; + +-- +-- Name: channel_configs_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: lightning +-- + +ALTER SEQUENCE public.channel_configs_id_seq OWNED BY public.channel_configs.id; + + +-- +-- Name: channel_feerates; Type: TABLE; Schema: public; Owner: lightning +-- + +CREATE TABLE public.channel_feerates ( + channel_id bigint, + hstate integer, + feerate_per_kw integer +); + + +ALTER TABLE public.channel_feerates OWNER TO lightning; + +-- +-- Name: channel_funding_inflights; Type: TABLE; Schema: public; Owner: lightning +-- + +CREATE TABLE public.channel_funding_inflights ( + channel_id bigint NOT NULL, + funding_tx_id bytea NOT NULL, + funding_tx_outnum integer, + funding_feerate integer, + funding_satoshi bigint, + our_funding_satoshi bigint, + funding_psbt bytea, + last_tx bytea, + last_sig bytea, + funding_tx_remote_sigs_received integer, + lease_commit_sig bytea, + lease_chan_max_msat bigint, + lease_chan_max_ppt integer, + lease_expiry integer DEFAULT 0, + lease_blockheight_start integer DEFAULT 0, + lease_fee bigint DEFAULT 0, + lease_satoshi bigint, + splice_amnt bigint DEFAULT 0, + i_am_initiator integer DEFAULT 0, + force_sign_first integer DEFAULT 0, + remote_funding bytea, + locked_scid bigint DEFAULT 0, + i_sent_sigs integer DEFAULT 0 +); + + +ALTER TABLE public.channel_funding_inflights OWNER TO lightning; + +-- +-- Name: channel_funding_inflights_channel_id_seq; Type: SEQUENCE; Schema: public; Owner: lightning +-- + +CREATE SEQUENCE public.channel_funding_inflights_channel_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE public.channel_funding_inflights_channel_id_seq OWNER TO lightning; + +-- +-- Name: channel_funding_inflights_channel_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: lightning +-- + +ALTER SEQUENCE public.channel_funding_inflights_channel_id_seq OWNED BY public.channel_funding_inflights.channel_id; + + +-- +-- Name: channel_htlcs; Type: TABLE; Schema: public; Owner: lightning +-- + +CREATE TABLE public.channel_htlcs ( + id bigint NOT NULL, + channel_id bigint, + channel_htlc_id bigint, + direction integer, + origin_htlc bigint, + msatoshi bigint, + cltv_expiry integer, + payment_hash bytea, + payment_key bytea, + routing_onion bytea, + failuremsg bytea, + malformed_onion integer, + hstate integer, + shared_secret bytea, + received_time bigint, + partid bigint, + localfailmsg bytea, + we_filled integer, + groupid bigint, + min_commit_num bigint DEFAULT 0, + max_commit_num bigint, + fail_immediate integer DEFAULT 0, + fees_msat bigint DEFAULT 0, + updated_index bigint DEFAULT 0 +); + + +ALTER TABLE public.channel_htlcs OWNER TO lightning; + +-- +-- Name: channel_htlcs_id_seq; Type: SEQUENCE; Schema: public; Owner: lightning +-- + +CREATE SEQUENCE public.channel_htlcs_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE public.channel_htlcs_id_seq OWNER TO lightning; + +-- +-- Name: channel_htlcs_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: lightning +-- + +ALTER SEQUENCE public.channel_htlcs_id_seq OWNED BY public.channel_htlcs.id; + + +-- +-- Name: channel_moves; Type: TABLE; Schema: public; Owner: lightning +-- + +CREATE TABLE public.channel_moves ( + id bigint NOT NULL, + account_channel_id bigint, + account_nonchannel_id bigint, + tag_bitmap bigint NOT NULL, + credit_or_debit bigint NOT NULL, + "timestamp" bigint NOT NULL, + payment_hash bytea, + payment_part_id bigint, + payment_group_id bigint, + fees bigint NOT NULL +); + + +ALTER TABLE public.channel_moves OWNER TO lightning; + +-- +-- Name: channel_moves_id_seq; Type: SEQUENCE; Schema: public; Owner: lightning +-- + +CREATE SEQUENCE public.channel_moves_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE public.channel_moves_id_seq OWNER TO lightning; + +-- +-- Name: channel_moves_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: lightning +-- + +ALTER SEQUENCE public.channel_moves_id_seq OWNED BY public.channel_moves.id; + + +-- +-- Name: channel_state_changes; Type: TABLE; Schema: public; Owner: lightning +-- + +CREATE TABLE public.channel_state_changes ( + channel_id bigint, + "timestamp" bigint, + old_state integer, + new_state integer, + cause integer, + message text +); + + +ALTER TABLE public.channel_state_changes OWNER TO lightning; + +-- +-- Name: channels; Type: TABLE; Schema: public; Owner: lightning +-- + +CREATE TABLE public.channels ( + id bigint NOT NULL, + peer_id bigint, + short_channel_id text, + channel_config_local bigint, + channel_config_remote bigint, + state integer, + funder integer, + channel_flags integer, + minimum_depth integer, + next_index_local bigint, + next_index_remote bigint, + next_htlc_id bigint, + funding_tx_id bytea, + funding_tx_outnum integer, + funding_satoshi bigint, + funding_locked_remote integer, + push_msatoshi bigint, + msatoshi_local bigint, + fundingkey_remote bytea, + revocation_basepoint_remote bytea, + payment_basepoint_remote bytea, + htlc_basepoint_remote bytea, + delayed_payment_basepoint_remote bytea, + per_commit_remote bytea, + old_per_commit_remote bytea, + local_feerate_per_kw integer, + remote_feerate_per_kw integer, + shachain_remote_id bigint, + shutdown_scriptpubkey_remote bytea, + shutdown_keyidx_local bigint, + last_sent_commit_state bigint, + last_sent_commit_id integer, + last_tx bytea, + last_sig bytea, + closing_fee_received integer, + closing_sig_received bytea, + first_blocknum bigint, + last_was_revoke integer, + in_payments_offered integer DEFAULT 0, + in_payments_fulfilled integer DEFAULT 0, + in_msatoshi_offered bigint DEFAULT 0, + in_msatoshi_fulfilled bigint DEFAULT 0, + out_payments_offered integer DEFAULT 0, + out_payments_fulfilled integer DEFAULT 0, + out_msatoshi_offered bigint DEFAULT 0, + out_msatoshi_fulfilled bigint DEFAULT 0, + min_possible_feerate integer, + max_possible_feerate integer, + msatoshi_to_us_min bigint, + msatoshi_to_us_max bigint, + future_per_commitment_point bytea, + last_sent_commit bytea, + feerate_base integer, + feerate_ppm integer, + remote_upfront_shutdown_script bytea, + remote_ann_node_sig bytea, + remote_ann_bitcoin_sig bytea, + option_static_remotekey integer DEFAULT 0, + shutdown_scriptpubkey_local bytea, + our_funding_satoshi bigint DEFAULT 0, + option_anchor_outputs integer DEFAULT 0, + full_channel_id bytea, + funding_psbt bytea, + closer integer DEFAULT 2, + state_change_reason integer DEFAULT 0, + funding_tx_remote_sigs_received integer DEFAULT 0, + revocation_basepoint_local bytea, + payment_basepoint_local bytea, + htlc_basepoint_local bytea, + delayed_payment_basepoint_local bytea, + funding_pubkey_local bytea, + shutdown_wrong_txid bytea, + shutdown_wrong_outnum integer, + local_static_remotekey_start bigint DEFAULT 0, + remote_static_remotekey_start bigint DEFAULT 0, + lease_commit_sig bytea, + lease_chan_max_msat integer, + lease_chan_max_ppt integer, + lease_expiry integer DEFAULT 0, + htlc_maximum_msat bigint DEFAULT '2100000000000000'::bigint, + htlc_minimum_msat bigint DEFAULT 0, + alias_local bigint, + alias_remote bigint, + scid bigint, + require_confirm_inputs_remote integer DEFAULT 0, + require_confirm_inputs_local integer DEFAULT 0, + channel_type bytea, + ignore_fee_limits integer DEFAULT 0, + remote_feerate_base integer, + remote_feerate_ppm integer, + remote_cltv_expiry_delta integer, + remote_htlc_maximum_msat bigint, + remote_htlc_minimum_msat bigint, + last_stable_connection bigint DEFAULT 0, + close_attempt_height integer DEFAULT 0, + old_scids bytea, + withheld integer DEFAULT 0 +); + + +ALTER TABLE public.channels OWNER TO lightning; + +-- +-- Name: channels_id_seq; Type: SEQUENCE; Schema: public; Owner: lightning +-- + +CREATE SEQUENCE public.channels_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE public.channels_id_seq OWNER TO lightning; + +-- +-- Name: channels_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: lightning +-- + +ALTER SEQUENCE public.channels_id_seq OWNED BY public.channels.id; + + +-- +-- Name: channeltxs; Type: TABLE; Schema: public; Owner: lightning +-- + +CREATE TABLE public.channeltxs ( + id bigint NOT NULL, + channel_id bigint, + type integer, + transaction_id bytea, + input_num integer, + blockheight integer +); + + +ALTER TABLE public.channeltxs OWNER TO lightning; + +-- +-- Name: channeltxs_id_seq; Type: SEQUENCE; Schema: public; Owner: lightning +-- + +CREATE SEQUENCE public.channeltxs_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE public.channeltxs_id_seq OWNER TO lightning; + +-- +-- Name: channeltxs_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: lightning +-- + +ALTER SEQUENCE public.channeltxs_id_seq OWNED BY public.channeltxs.id; + + +-- +-- Name: datastore; Type: TABLE; Schema: public; Owner: lightning +-- + +CREATE TABLE public.datastore ( + key bytea NOT NULL, + data bytea, + generation bigint +); + + +ALTER TABLE public.datastore OWNER TO lightning; + +-- +-- Name: db_upgrades; Type: TABLE; Schema: public; Owner: lightning +-- + +CREATE TABLE public.db_upgrades ( + upgrade_from integer, + lightning_version text +); + + +ALTER TABLE public.db_upgrades OWNER TO lightning; + +-- +-- Name: forwards; Type: TABLE; Schema: public; Owner: lightning +-- + +CREATE TABLE public.forwards ( + in_channel_scid bigint NOT NULL, + in_htlc_id bigint NOT NULL, + out_channel_scid bigint, + out_htlc_id bigint, + in_msatoshi bigint, + out_msatoshi bigint, + state integer, + received_time bigint, + resolved_time bigint, + failcode integer, + forward_style integer, + rowid bigint, + updated_index bigint DEFAULT 0 +); + + +ALTER TABLE public.forwards OWNER TO lightning; + +-- +-- Name: htlc_sigs; Type: TABLE; Schema: public; Owner: lightning +-- + +CREATE TABLE public.htlc_sigs ( + channelid integer, + signature bytea, + inflight_tx_id bytea, + inflight_tx_outnum integer +); + + +ALTER TABLE public.htlc_sigs OWNER TO lightning; + +-- +-- Name: invoice_fallbacks; Type: TABLE; Schema: public; Owner: lightning +-- + +CREATE TABLE public.invoice_fallbacks ( + scriptpubkey bytea NOT NULL, + invoice_id bigint +); + + +ALTER TABLE public.invoice_fallbacks OWNER TO lightning; + +-- +-- Name: invoicerequests; Type: TABLE; Schema: public; Owner: lightning +-- + +CREATE TABLE public.invoicerequests ( + invreq_id bytea NOT NULL, + bolt12 text, + label text, + status integer +); + + +ALTER TABLE public.invoicerequests OWNER TO lightning; + +-- +-- Name: invoices; Type: TABLE; Schema: public; Owner: lightning +-- + +CREATE TABLE public.invoices ( + id bigint NOT NULL, + state integer, + msatoshi bigint, + payment_hash bytea, + payment_key bytea, + label text, + expiry_time bigint, + pay_index bigint, + msatoshi_received bigint, + paid_timestamp bigint, + bolt11 text, + description text, + features bytea DEFAULT '\x'::bytea, + local_offer_id bytea, + updated_index bigint DEFAULT 0, + paid_txid bytea, + paid_outnum integer +); + + +ALTER TABLE public.invoices OWNER TO lightning; + +-- +-- Name: invoices_id_seq; Type: SEQUENCE; Schema: public; Owner: lightning +-- + +CREATE SEQUENCE public.invoices_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE public.invoices_id_seq OWNER TO lightning; + +-- +-- Name: invoices_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: lightning +-- + +ALTER SEQUENCE public.invoices_id_seq OWNED BY public.invoices.id; + + +-- +-- Name: local_anchors; Type: TABLE; Schema: public; Owner: lightning +-- + +CREATE TABLE public.local_anchors ( + channel_id bigint NOT NULL, + commitment_index bigint, + commitment_txid bytea, + commitment_anchor_outnum integer, + commitment_fee bigint, + commitment_weight integer +); + + +ALTER TABLE public.local_anchors OWNER TO lightning; + +-- +-- Name: local_anchors_channel_id_seq; Type: SEQUENCE; Schema: public; Owner: lightning +-- + +CREATE SEQUENCE public.local_anchors_channel_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE public.local_anchors_channel_id_seq OWNER TO lightning; + +-- +-- Name: local_anchors_channel_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: lightning +-- + +ALTER SEQUENCE public.local_anchors_channel_id_seq OWNED BY public.local_anchors.channel_id; + + +-- +-- Name: move_accounts; Type: TABLE; Schema: public; Owner: lightning +-- + +CREATE TABLE public.move_accounts ( + id bigint NOT NULL, + name text +); + + +ALTER TABLE public.move_accounts OWNER TO lightning; + +-- +-- Name: move_accounts_id_seq; Type: SEQUENCE; Schema: public; Owner: lightning +-- + +CREATE SEQUENCE public.move_accounts_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE public.move_accounts_id_seq OWNER TO lightning; + +-- +-- Name: move_accounts_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: lightning +-- + +ALTER SEQUENCE public.move_accounts_id_seq OWNED BY public.move_accounts.id; + + +-- +-- Name: network_events; Type: TABLE; Schema: public; Owner: lightning +-- + +CREATE TABLE public.network_events ( + id bigint NOT NULL, + peer_id bytea NOT NULL, + type integer NOT NULL, + "timestamp" bigint, + reason text, + duration_nsec bigint, + connect_attempted integer NOT NULL +); + + +ALTER TABLE public.network_events OWNER TO lightning; + +-- +-- Name: network_events_id_seq; Type: SEQUENCE; Schema: public; Owner: lightning +-- + +CREATE SEQUENCE public.network_events_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE public.network_events_id_seq OWNER TO lightning; + +-- +-- Name: network_events_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: lightning +-- + +ALTER SEQUENCE public.network_events_id_seq OWNED BY public.network_events.id; + + +-- +-- Name: offers; Type: TABLE; Schema: public; Owner: lightning +-- + +CREATE TABLE public.offers ( + offer_id bytea NOT NULL, + bolt12 text, + label text, + status integer +); + + +ALTER TABLE public.offers OWNER TO lightning; + +-- +-- Name: outputs; Type: TABLE; Schema: public; Owner: lightning +-- + +CREATE TABLE public.outputs ( + prev_out_tx bytea NOT NULL, + prev_out_index integer NOT NULL, + value bigint, + type integer, + status integer, + keyindex integer, + channel_id bigint, + peer_id bytea, + commitment_point bytea, + confirmation_height integer, + spend_height integer, + scriptpubkey bytea, + reserved_til integer, + option_anchor_outputs integer DEFAULT 0, + csv_lock integer DEFAULT 1, + is_in_coinbase integer DEFAULT 0 +); + + +ALTER TABLE public.outputs OWNER TO lightning; + +-- +-- Name: payments; Type: TABLE; Schema: public; Owner: lightning +-- + +CREATE TABLE public.payments ( + id bigint CONSTRAINT payments_id_not_null1 NOT NULL, + "timestamp" integer, + status integer, + payment_hash bytea, + destination bytea, + msatoshi bigint, + payment_preimage bytea, + path_secrets bytea, + route_nodes bytea, + route_channels bytea, + failonionreply bytea, + faildestperm integer, + failindex integer, + failcode integer, + failnode bytea, + failupdate bytea, + msatoshi_sent bigint, + faildetail text, + description text, + faildirection integer, + bolt11 text, + total_msat bigint, + partid bigint, + groupid bigint DEFAULT 0 NOT NULL, + local_offer_id bytea, + paydescription text, + completed_at integer, + failscid bigint, + local_invreq_id bytea, + updated_index bigint DEFAULT 0 +); + + +ALTER TABLE public.payments OWNER TO lightning; + +-- +-- Name: payments_id_seq1; Type: SEQUENCE; Schema: public; Owner: lightning +-- + +CREATE SEQUENCE public.payments_id_seq1 + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE public.payments_id_seq1 OWNER TO lightning; + +-- +-- Name: payments_id_seq1; Type: SEQUENCE OWNED BY; Schema: public; Owner: lightning +-- + +ALTER SEQUENCE public.payments_id_seq1 OWNED BY public.payments.id; + + +-- +-- Name: peers; Type: TABLE; Schema: public; Owner: lightning +-- + +CREATE TABLE public.peers ( + id bigint NOT NULL, + node_id bytea, + address text, + feature_bits bytea, + last_known_address bytea +); + + +ALTER TABLE public.peers OWNER TO lightning; + +-- +-- Name: peers_id_seq; Type: SEQUENCE; Schema: public; Owner: lightning +-- + +CREATE SEQUENCE public.peers_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE public.peers_id_seq OWNER TO lightning; + +-- +-- Name: peers_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: lightning +-- + +ALTER SEQUENCE public.peers_id_seq OWNED BY public.peers.id; + + +-- +-- Name: penalty_bases; Type: TABLE; Schema: public; Owner: lightning +-- + +CREATE TABLE public.penalty_bases ( + channel_id bigint NOT NULL, + commitnum bigint NOT NULL, + txid bytea, + outnum integer, + amount bigint +); + + +ALTER TABLE public.penalty_bases OWNER TO lightning; + +-- +-- Name: runes; Type: TABLE; Schema: public; Owner: lightning +-- + +CREATE TABLE public.runes ( + id bigint NOT NULL, + rune text, + last_used_nsec bigint +); + + +ALTER TABLE public.runes OWNER TO lightning; + +-- +-- Name: runes_blacklist; Type: TABLE; Schema: public; Owner: lightning +-- + +CREATE TABLE public.runes_blacklist ( + start_index bigint, + end_index bigint +); + + +ALTER TABLE public.runes_blacklist OWNER TO lightning; + +-- +-- Name: runes_id_seq; Type: SEQUENCE; Schema: public; Owner: lightning +-- + +CREATE SEQUENCE public.runes_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE public.runes_id_seq OWNER TO lightning; + +-- +-- Name: runes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: lightning +-- + +ALTER SEQUENCE public.runes_id_seq OWNED BY public.runes.id; + + +-- +-- Name: shachain_known; Type: TABLE; Schema: public; Owner: lightning +-- + +CREATE TABLE public.shachain_known ( + shachain_id bigint NOT NULL, + pos integer NOT NULL, + idx bigint, + hash bytea +); + + +ALTER TABLE public.shachain_known OWNER TO lightning; + +-- +-- Name: shachains; Type: TABLE; Schema: public; Owner: lightning +-- + +CREATE TABLE public.shachains ( + id bigint NOT NULL, + min_index bigint, + num_valid bigint +); + + +ALTER TABLE public.shachains OWNER TO lightning; + +-- +-- Name: shachains_id_seq; Type: SEQUENCE; Schema: public; Owner: lightning +-- + +CREATE SEQUENCE public.shachains_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE public.shachains_id_seq OWNER TO lightning; + +-- +-- Name: shachains_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: lightning +-- + +ALTER SEQUENCE public.shachains_id_seq OWNED BY public.shachains.id; + + +-- +-- Name: transaction_annotations; Type: TABLE; Schema: public; Owner: lightning +-- + +CREATE TABLE public.transaction_annotations ( + txid bytea, + idx integer, + location integer, + type integer, + channel bigint +); + + +ALTER TABLE public.transaction_annotations OWNER TO lightning; + +-- +-- Name: transactions; Type: TABLE; Schema: public; Owner: lightning +-- + +CREATE TABLE public.transactions ( + id bytea NOT NULL, + blockheight integer, + txindex integer, + rawtx bytea, + type bigint, + channel_id bigint +); + + +ALTER TABLE public.transactions OWNER TO lightning; + +-- +-- Name: utxoset; Type: TABLE; Schema: public; Owner: lightning +-- + +CREATE TABLE public.utxoset ( + txid bytea NOT NULL, + outnum integer NOT NULL, + blockheight integer, + spendheight integer, + txindex integer, + scriptpubkey bytea, + satoshis bigint +); + + +ALTER TABLE public.utxoset OWNER TO lightning; + +-- +-- Name: vars; Type: TABLE; Schema: public; Owner: lightning +-- + +CREATE TABLE public.vars ( + name character varying(32) NOT NULL, + val character varying(255), + intval integer, + blobval bytea +); + + +ALTER TABLE public.vars OWNER TO lightning; + +-- +-- Name: version; Type: TABLE; Schema: public; Owner: lightning +-- + +CREATE TABLE public.version ( + version integer +); + + +ALTER TABLE public.version OWNER TO lightning; + +-- +-- Name: chain_moves id; Type: DEFAULT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.chain_moves ALTER COLUMN id SET DEFAULT nextval('public.chain_moves_id_seq'::regclass); + + +-- +-- Name: channel_configs id; Type: DEFAULT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.channel_configs ALTER COLUMN id SET DEFAULT nextval('public.channel_configs_id_seq'::regclass); + + +-- +-- Name: channel_funding_inflights channel_id; Type: DEFAULT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.channel_funding_inflights ALTER COLUMN channel_id SET DEFAULT nextval('public.channel_funding_inflights_channel_id_seq'::regclass); + + +-- +-- Name: channel_htlcs id; Type: DEFAULT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.channel_htlcs ALTER COLUMN id SET DEFAULT nextval('public.channel_htlcs_id_seq'::regclass); + + +-- +-- Name: channel_moves id; Type: DEFAULT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.channel_moves ALTER COLUMN id SET DEFAULT nextval('public.channel_moves_id_seq'::regclass); + + +-- +-- Name: channels id; Type: DEFAULT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.channels ALTER COLUMN id SET DEFAULT nextval('public.channels_id_seq'::regclass); + + +-- +-- Name: channeltxs id; Type: DEFAULT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.channeltxs ALTER COLUMN id SET DEFAULT nextval('public.channeltxs_id_seq'::regclass); + + +-- +-- Name: invoices id; Type: DEFAULT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.invoices ALTER COLUMN id SET DEFAULT nextval('public.invoices_id_seq'::regclass); + + +-- +-- Name: local_anchors channel_id; Type: DEFAULT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.local_anchors ALTER COLUMN channel_id SET DEFAULT nextval('public.local_anchors_channel_id_seq'::regclass); + + +-- +-- Name: move_accounts id; Type: DEFAULT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.move_accounts ALTER COLUMN id SET DEFAULT nextval('public.move_accounts_id_seq'::regclass); + + +-- +-- Name: network_events id; Type: DEFAULT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.network_events ALTER COLUMN id SET DEFAULT nextval('public.network_events_id_seq'::regclass); + + +-- +-- Name: payments id; Type: DEFAULT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.payments ALTER COLUMN id SET DEFAULT nextval('public.payments_id_seq1'::regclass); + + +-- +-- Name: peers id; Type: DEFAULT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.peers ALTER COLUMN id SET DEFAULT nextval('public.peers_id_seq'::regclass); + + +-- +-- Name: runes id; Type: DEFAULT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.runes ALTER COLUMN id SET DEFAULT nextval('public.runes_id_seq'::regclass); + + +-- +-- Name: shachains id; Type: DEFAULT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.shachains ALTER COLUMN id SET DEFAULT nextval('public.shachains_id_seq'::regclass); + + +-- +-- Name: blocks blocks_height_key; Type: CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.blocks + ADD CONSTRAINT blocks_height_key UNIQUE (height); + + +-- +-- Name: chain_moves chain_moves_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.chain_moves + ADD CONSTRAINT chain_moves_pkey PRIMARY KEY (id); + + +-- +-- Name: channel_blockheights channel_blockheights_channel_id_hstate_key; Type: CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.channel_blockheights + ADD CONSTRAINT channel_blockheights_channel_id_hstate_key UNIQUE (channel_id, hstate); + + +-- +-- Name: channel_configs channel_configs_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.channel_configs + ADD CONSTRAINT channel_configs_pkey PRIMARY KEY (id); + + +-- +-- Name: channel_feerates channel_feerates_channel_id_hstate_key; Type: CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.channel_feerates + ADD CONSTRAINT channel_feerates_channel_id_hstate_key UNIQUE (channel_id, hstate); + + +-- +-- Name: channel_funding_inflights channel_funding_inflights_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.channel_funding_inflights + ADD CONSTRAINT channel_funding_inflights_pkey PRIMARY KEY (channel_id, funding_tx_id); + + +-- +-- Name: channel_htlcs channel_htlcs_channel_id_channel_htlc_id_direction_key; Type: CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.channel_htlcs + ADD CONSTRAINT channel_htlcs_channel_id_channel_htlc_id_direction_key UNIQUE (channel_id, channel_htlc_id, direction); + + +-- +-- Name: channel_htlcs channel_htlcs_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.channel_htlcs + ADD CONSTRAINT channel_htlcs_pkey PRIMARY KEY (id); + + +-- +-- Name: channel_moves channel_moves_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.channel_moves + ADD CONSTRAINT channel_moves_pkey PRIMARY KEY (id); + + +-- +-- Name: channels channels_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.channels + ADD CONSTRAINT channels_pkey PRIMARY KEY (id); + + +-- +-- Name: channeltxs channeltxs_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.channeltxs + ADD CONSTRAINT channeltxs_pkey PRIMARY KEY (id); + + +-- +-- Name: datastore datastore_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.datastore + ADD CONSTRAINT datastore_pkey PRIMARY KEY (key); + + +-- +-- Name: forwards forwards_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.forwards + ADD CONSTRAINT forwards_pkey PRIMARY KEY (in_channel_scid, in_htlc_id); + + +-- +-- Name: invoice_fallbacks invoice_fallbacks_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.invoice_fallbacks + ADD CONSTRAINT invoice_fallbacks_pkey PRIMARY KEY (scriptpubkey); + + +-- +-- Name: invoicerequests invoicerequests_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.invoicerequests + ADD CONSTRAINT invoicerequests_pkey PRIMARY KEY (invreq_id); + + +-- +-- Name: invoices invoices_label_key; Type: CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.invoices + ADD CONSTRAINT invoices_label_key UNIQUE (label); + + +-- +-- Name: invoices invoices_payment_hash_key; Type: CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.invoices + ADD CONSTRAINT invoices_payment_hash_key UNIQUE (payment_hash); + + +-- +-- Name: invoices invoices_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.invoices + ADD CONSTRAINT invoices_pkey PRIMARY KEY (id); + + +-- +-- Name: move_accounts move_accounts_name_key; Type: CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.move_accounts + ADD CONSTRAINT move_accounts_name_key UNIQUE (name); + + +-- +-- Name: move_accounts move_accounts_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.move_accounts + ADD CONSTRAINT move_accounts_pkey PRIMARY KEY (id); + + +-- +-- Name: network_events network_events_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.network_events + ADD CONSTRAINT network_events_pkey PRIMARY KEY (id); + + +-- +-- Name: offers offers_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.offers + ADD CONSTRAINT offers_pkey PRIMARY KEY (offer_id); + + +-- +-- Name: outputs outputs_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.outputs + ADD CONSTRAINT outputs_pkey PRIMARY KEY (prev_out_tx, prev_out_index); + + +-- +-- Name: payments payments_payment_hash_partid_groupid_key; Type: CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.payments + ADD CONSTRAINT payments_payment_hash_partid_groupid_key UNIQUE (payment_hash, partid, groupid); + + +-- +-- Name: payments payments_pkey1; Type: CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.payments + ADD CONSTRAINT payments_pkey1 PRIMARY KEY (id); + + +-- +-- Name: peers peers_node_id_key; Type: CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.peers + ADD CONSTRAINT peers_node_id_key UNIQUE (node_id); + + +-- +-- Name: peers peers_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.peers + ADD CONSTRAINT peers_pkey PRIMARY KEY (id); + + +-- +-- Name: penalty_bases penalty_bases_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.penalty_bases + ADD CONSTRAINT penalty_bases_pkey PRIMARY KEY (channel_id, commitnum); + + +-- +-- Name: runes runes_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.runes + ADD CONSTRAINT runes_pkey PRIMARY KEY (id); + + +-- +-- Name: shachain_known shachain_known_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.shachain_known + ADD CONSTRAINT shachain_known_pkey PRIMARY KEY (shachain_id, pos); + + +-- +-- Name: shachains shachains_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.shachains + ADD CONSTRAINT shachains_pkey PRIMARY KEY (id); + + +-- +-- Name: transaction_annotations transaction_annotations_txid_idx_key; Type: CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.transaction_annotations + ADD CONSTRAINT transaction_annotations_txid_idx_key UNIQUE (txid, idx); + + +-- +-- Name: transactions transactions_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.transactions + ADD CONSTRAINT transactions_pkey PRIMARY KEY (id); + + +-- +-- Name: utxoset utxoset_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.utxoset + ADD CONSTRAINT utxoset_pkey PRIMARY KEY (txid, outnum); + + +-- +-- Name: vars vars_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.vars + ADD CONSTRAINT vars_pkey PRIMARY KEY (name); + + +-- +-- Name: chain_moves_utxo_idx; Type: INDEX; Schema: public; Owner: lightning +-- + +CREATE INDEX chain_moves_utxo_idx ON public.chain_moves USING btree (utxo); + + +-- +-- Name: channel_htlcs_speedup_unresolved_idx; Type: INDEX; Schema: public; Owner: lightning +-- + +CREATE INDEX channel_htlcs_speedup_unresolved_idx ON public.channel_htlcs USING btree (channel_id, direction) WHERE (hstate <> ALL (ARRAY[9, 19])); + + +-- +-- Name: channel_htlcs_updated_idx; Type: INDEX; Schema: public; Owner: lightning +-- + +CREATE INDEX channel_htlcs_updated_idx ON public.channel_htlcs USING btree (updated_index); + + +-- +-- Name: channel_idx; Type: INDEX; Schema: public; Owner: lightning +-- + +CREATE INDEX channel_idx ON public.htlc_sigs USING btree (channelid); + + +-- +-- Name: channel_state_changes_channel_id; Type: INDEX; Schema: public; Owner: lightning +-- + +CREATE INDEX channel_state_changes_channel_id ON public.channel_state_changes USING btree (channel_id); + + +-- +-- Name: forwards_created_idx; Type: INDEX; Schema: public; Owner: lightning +-- + +CREATE INDEX forwards_created_idx ON public.forwards USING btree (rowid); + + +-- +-- Name: forwards_updated_idx; Type: INDEX; Schema: public; Owner: lightning +-- + +CREATE INDEX forwards_updated_idx ON public.forwards USING btree (updated_index); + + +-- +-- Name: invoice_update_idx; Type: INDEX; Schema: public; Owner: lightning +-- + +CREATE INDEX invoice_update_idx ON public.invoices USING btree (updated_index); + + +-- +-- Name: invoices_pay_index; Type: INDEX; Schema: public; Owner: lightning +-- + +CREATE UNIQUE INDEX invoices_pay_index ON public.invoices USING btree (pay_index); + + +-- +-- Name: local_anchors_idx; Type: INDEX; Schema: public; Owner: lightning +-- + +CREATE INDEX local_anchors_idx ON public.local_anchors USING btree (channel_id); + + +-- +-- Name: output_height_idx; Type: INDEX; Schema: public; Owner: lightning +-- + +CREATE INDEX output_height_idx ON public.outputs USING btree (confirmation_height, spend_height); + + +-- +-- Name: payments_idx; Type: INDEX; Schema: public; Owner: lightning +-- + +CREATE INDEX payments_idx ON public.payments USING btree (payment_hash); + + +-- +-- Name: payments_update_idx; Type: INDEX; Schema: public; Owner: lightning +-- + +CREATE INDEX payments_update_idx ON public.payments USING btree (updated_index); + + +-- +-- Name: short_channel_id; Type: INDEX; Schema: public; Owner: lightning +-- + +CREATE INDEX short_channel_id ON public.utxoset USING btree (blockheight, txindex, outnum); + + +-- +-- Name: utxoset_spend; Type: INDEX; Schema: public; Owner: lightning +-- + +CREATE INDEX utxoset_spend ON public.utxoset USING btree (spendheight); + + +-- +-- Name: chain_moves chain_moves_account_channel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.chain_moves + ADD CONSTRAINT chain_moves_account_channel_id_fkey FOREIGN KEY (account_channel_id) REFERENCES public.channels(id); + + +-- +-- Name: chain_moves chain_moves_account_nonchannel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.chain_moves + ADD CONSTRAINT chain_moves_account_nonchannel_id_fkey FOREIGN KEY (account_nonchannel_id) REFERENCES public.move_accounts(id); + + +-- +-- Name: chain_moves chain_moves_originating_channel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.chain_moves + ADD CONSTRAINT chain_moves_originating_channel_id_fkey FOREIGN KEY (originating_channel_id) REFERENCES public.channels(id); + + +-- +-- Name: chain_moves chain_moves_originating_nonchannel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.chain_moves + ADD CONSTRAINT chain_moves_originating_nonchannel_id_fkey FOREIGN KEY (originating_nonchannel_id) REFERENCES public.move_accounts(id); + + +-- +-- Name: channel_blockheights channel_blockheights_channel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.channel_blockheights + ADD CONSTRAINT channel_blockheights_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE; + + +-- +-- Name: channel_feerates channel_feerates_channel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.channel_feerates + ADD CONSTRAINT channel_feerates_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE; + + +-- +-- Name: channel_funding_inflights channel_funding_inflights_channel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.channel_funding_inflights + ADD CONSTRAINT channel_funding_inflights_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE; + + +-- +-- Name: channel_htlcs channel_htlcs_channel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.channel_htlcs + ADD CONSTRAINT channel_htlcs_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE; + + +-- +-- Name: channel_moves channel_moves_account_channel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.channel_moves + ADD CONSTRAINT channel_moves_account_channel_id_fkey FOREIGN KEY (account_channel_id) REFERENCES public.channels(id); + + +-- +-- Name: channel_moves channel_moves_account_nonchannel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.channel_moves + ADD CONSTRAINT channel_moves_account_nonchannel_id_fkey FOREIGN KEY (account_nonchannel_id) REFERENCES public.move_accounts(id); + + +-- +-- Name: channel_state_changes channel_state_changes_channel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.channel_state_changes + ADD CONSTRAINT channel_state_changes_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE; + + +-- +-- Name: channels channels_peer_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.channels + ADD CONSTRAINT channels_peer_id_fkey FOREIGN KEY (peer_id) REFERENCES public.peers(id) ON DELETE CASCADE; + + +-- +-- Name: channeltxs channeltxs_blockheight_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.channeltxs + ADD CONSTRAINT channeltxs_blockheight_fkey FOREIGN KEY (blockheight) REFERENCES public.blocks(height) ON DELETE CASCADE; + + +-- +-- Name: channeltxs channeltxs_channel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.channeltxs + ADD CONSTRAINT channeltxs_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE; + + +-- +-- Name: channeltxs channeltxs_transaction_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.channeltxs + ADD CONSTRAINT channeltxs_transaction_id_fkey FOREIGN KEY (transaction_id) REFERENCES public.transactions(id) ON DELETE CASCADE; + + +-- +-- Name: htlc_sigs htlc_sigs_channelid_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.htlc_sigs + ADD CONSTRAINT htlc_sigs_channelid_fkey FOREIGN KEY (channelid) REFERENCES public.channels(id) ON DELETE CASCADE; + + +-- +-- Name: invoice_fallbacks invoice_fallbacks_invoice_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.invoice_fallbacks + ADD CONSTRAINT invoice_fallbacks_invoice_id_fkey FOREIGN KEY (invoice_id) REFERENCES public.invoices(id) ON DELETE CASCADE; + + +-- +-- Name: invoices invoices_local_offer_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.invoices + ADD CONSTRAINT invoices_local_offer_id_fkey FOREIGN KEY (local_offer_id) REFERENCES public.offers(offer_id); + + +-- +-- Name: local_anchors local_anchors_channel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.local_anchors + ADD CONSTRAINT local_anchors_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id); + + +-- +-- Name: outputs outputs_confirmation_height_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.outputs + ADD CONSTRAINT outputs_confirmation_height_fkey FOREIGN KEY (confirmation_height) REFERENCES public.blocks(height) ON DELETE SET NULL; + + +-- +-- Name: outputs outputs_spend_height_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.outputs + ADD CONSTRAINT outputs_spend_height_fkey FOREIGN KEY (spend_height) REFERENCES public.blocks(height) ON DELETE SET NULL; + + +-- +-- Name: payments payments_local_invreq_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.payments + ADD CONSTRAINT payments_local_invreq_id_fkey FOREIGN KEY (local_invreq_id) REFERENCES public.invoicerequests(invreq_id); + + +-- +-- Name: payments payments_local_offer_id_fkey1; Type: FK CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.payments + ADD CONSTRAINT payments_local_offer_id_fkey1 FOREIGN KEY (local_offer_id) REFERENCES public.offers(offer_id); + + +-- +-- Name: penalty_bases penalty_bases_channel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.penalty_bases + ADD CONSTRAINT penalty_bases_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE; + + +-- +-- Name: shachain_known shachain_known_shachain_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.shachain_known + ADD CONSTRAINT shachain_known_shachain_id_fkey FOREIGN KEY (shachain_id) REFERENCES public.shachains(id) ON DELETE CASCADE; + + +-- +-- Name: transaction_annotations transaction_annotations_channel_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.transaction_annotations + ADD CONSTRAINT transaction_annotations_channel_fkey FOREIGN KEY (channel) REFERENCES public.channels(id); + + +-- +-- Name: transactions transactions_blockheight_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.transactions + ADD CONSTRAINT transactions_blockheight_fkey FOREIGN KEY (blockheight) REFERENCES public.blocks(height) ON DELETE SET NULL; + + +-- +-- Name: utxoset utxoset_blockheight_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.utxoset + ADD CONSTRAINT utxoset_blockheight_fkey FOREIGN KEY (blockheight) REFERENCES public.blocks(height) ON DELETE CASCADE; + + +-- +-- Name: utxoset utxoset_spendheight_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning +-- + +ALTER TABLE ONLY public.utxoset + ADD CONSTRAINT utxoset_spendheight_fkey FOREIGN KEY (spendheight) REFERENCES public.blocks(height) ON DELETE SET NULL; + + +-- +-- PostgreSQL database dump complete +-- + +\unrestrict 3gmSHpmWoAvp3M9hYnbn0eAJg2qkrJcZzqafmBmvLGVxDFS2cVhidb6yKDBdS99 + diff --git a/scripts/close-dead-channels.sh b/scripts/close-dead-channels.sh new file mode 100755 index 00000000..dda1efb9 --- /dev/null +++ b/scripts/close-dead-channels.sh @@ -0,0 +1,276 @@ +#!/bin/bash +# +# Close the least-active Lightning channels that have recoverable local balance. +# +# Usage: +# ./close-dead-channels.sh # dry-run (default) +# ./close-dead-channels.sh --execute # actually close channels +# +# Options (set via env vars): +# FUNDS_DIR - path to balance snapshots (default: ~/coinos-server/data/funds) +# COUNT - number of channels to close (default: 10) +# MIN_LOCAL_PCT - minimum local balance % to consider (default: 20) +# MONTHS - how many months of history to analyze (default: 3) +# CLOSE_TIMEOUT - seconds to wait for cooperative close (default: 1) +# EXCLUDE - comma-separated SCIDs or alias substrings to skip + +set -euo pipefail + +FUNDS_DIR="${FUNDS_DIR:-$HOME/coinos-server/data/funds}" +COUNT="${COUNT:-10}" +MIN_LOCAL_PCT="${MIN_LOCAL_PCT:-20}" +MONTHS="${MONTHS:-3}" +CLOSE_TIMEOUT="${CLOSE_TIMEOUT:-1}" +EXCLUDE="${EXCLUDE:-}" +EXECUTE=false + +if [[ "${1:-}" == "--execute" ]]; then + EXECUTE=true +fi + +python3 - "$FUNDS_DIR" "$COUNT" "$MIN_LOCAL_PCT" "$MONTHS" "$EXCLUDE" << 'PYEOF' +import json, subprocess, os, re, sys +from datetime import datetime, timedelta +from collections import defaultdict + +FUNDS_DIR = sys.argv[1] +COUNT = int(sys.argv[2]) +MIN_LOCAL_PCT = float(sys.argv[3]) +MONTHS = int(sys.argv[4]) +EXCLUDE = [x.strip().lower() for x in sys.argv[5].split(",") if x.strip()] + +def parse_date(fn): + fn = fn.replace('balances_', '') + m = re.match(r'(\d{2})-(\d{2})-(\d{2})-(\d{2}):(\d{2}):(\d{2})\.json', fn) + if not m: + return None + month, day, year, hour, minute, sec = [int(x) for x in m.groups()] + try: + return datetime(year + 2000, month, day, hour, minute, sec) + except: + return None + +def fmt(s): + if abs(s) >= 1_000_000: + return f"{s/1_000_000:.2f}M" + elif abs(s) >= 1_000: + return f"{s/1_000:.1f}k" + return str(s) + +# Sample balance files every ~4 hours over the lookback period +files = sorted(os.listdir(FUNDS_DIR)) +cutoff = datetime.now() - timedelta(days=MONTHS * 30) +recent = [(d, f) for f in files if (d := parse_date(f)) and d >= cutoff] +recent.sort() + +sampled = [] +last = None +for d, f in recent: + if last is None or (d - last).total_seconds() >= 4 * 3600: + sampled.append((d, f)) + last = d + +print(f"Analyzed {len(sampled)} snapshots over {MONTHS} months\n") + +# Build movement history per channel +channel_history = defaultdict(list) +for d, f in sampled: + try: + with open(os.path.join(FUNDS_DIR, f)) as fh: + data = json.load(fh) + except: + continue + for ch in data.get("channels", []): + scid = ch.get("short_channel_id", "") + if scid: + channel_history[scid].append( + (d, ch["our_amount_msat"], ch["amount_msat"], ch.get("state", "")) + ) + +# Get currently open channels +result = subprocess.run( + ["docker", "exec", "cl", "lightning-cli", "listpeerchannels"], + capture_output=True, text=True, timeout=15, +) +peerchans = json.loads(result.stdout) + +# Resolve aliases +aliases = {} +def get_alias(peer_id): + if peer_id in aliases: + return aliases[peer_id] + try: + r = subprocess.run( + ["docker", "exec", "cl", "lightning-cli", "listnodes", peer_id], + capture_output=True, text=True, timeout=10, + ) + nd = json.loads(r.stdout) + a = nd["nodes"][0].get("alias", peer_id[:20] + "...") if nd.get("nodes") else peer_id[:20] + "..." + except: + a = peer_id[:20] + "..." + aliases[peer_id] = a + return a + +# Score each open channel +candidates = [] +for ch in peerchans.get("channels", []): + scid = ch.get("short_channel_id", "") + state = ch.get("state", "") + if not scid or state != "CHANNELD_NORMAL": + continue + + capacity = ch.get("total_msat", 0) // 1000 + local = ch.get("to_us_msat", 0) // 1000 + if capacity == 0: + continue + local_pct = 100 * local / capacity + if local_pct < MIN_LOCAL_PCT: + continue + + history = channel_history.get(scid, []) + normal = [(d, o, t) for d, o, t, s in history if s == "CHANNELD_NORMAL"] + movement = 0 + if len(normal) >= 2: + for i in range(1, len(normal)): + movement += abs(normal[i][1] - normal[i - 1][1]) + + candidates.append({ + "scid": scid, + "peer_id": ch.get("peer_id", ""), + "capacity": capacity, + "local": local, + "remote": capacity - local, + "local_pct": local_pct, + "movement": movement // 1000, + }) + +candidates.sort(key=lambda x: x["movement"]) + +# Apply exclusions and take bottom N +selected = [] +for c in candidates: + if len(selected) >= COUNT: + break + alias = get_alias(c["peer_id"]) + skip = False + for exc in EXCLUDE: + if exc in c["scid"].lower() or exc in alias.lower(): + skip = True + break + if skip: + print(f" SKIP (excluded): {c['scid']} {alias}") + continue + c["alias"] = alias + selected.append(c) + +# Print results +print(f"{'SCID':<18} {'Peer':<30} {'Capacity':>10} {'Local':>10} {'Remote':>10} {'Local%':>7} {'3mo Vol':>10}") +print("-" * 105) +for c in selected: + print( + f"{c['scid']:<18} {c['alias']:<30} {fmt(c['capacity']):>10} " + f"{fmt(c['local']):>10} {fmt(c['remote']):>10} {c['local_pct']:>6.1f}% " + f"{fmt(c['movement']):>10}" + ) + +total_local = sum(c["local"] for c in selected) +total_cap = sum(c["capacity"] for c in selected) +print(f"\nRecoverable local: {fmt(total_local)} sats | Capacity freed: {fmt(total_cap)} sats") + +# Output SCIDs for the shell to close +scid_list = " ".join(c["scid"] for c in selected) +print(f"\nSCIDS={scid_list}") +PYEOF + +# Parse SCIDs from python output +SCIDS=$(python3 - "$FUNDS_DIR" "$COUNT" "$MIN_LOCAL_PCT" "$MONTHS" "$EXCLUDE" << 'PYEOF' 2>/dev/null | tail -1 +import json, subprocess, os, re, sys +from datetime import datetime, timedelta +from collections import defaultdict + +FUNDS_DIR = sys.argv[1] +COUNT = int(sys.argv[2]) +MIN_LOCAL_PCT = float(sys.argv[3]) +MONTHS = int(sys.argv[4]) +EXCLUDE = [x.strip().lower() for x in sys.argv[5].split(",") if x.strip()] + +def parse_date(fn): + fn = fn.replace('balances_', '') + m = re.match(r'(\d{2})-(\d{2})-(\d{2})-(\d{2}):(\d{2}):(\d{2})\.json', fn) + if not m: return None + mo, d, y, h, mi, s = [int(x) for x in m.groups()] + try: return datetime(y+2000, mo, d, h, mi, s) + except: return None + +files = sorted(os.listdir(FUNDS_DIR)) +cutoff = datetime.now() - timedelta(days=MONTHS*30) +recent = [(d, f) for f in files if (d := parse_date(f)) and d >= cutoff] +recent.sort() +sampled, last = [], None +for d, f in recent: + if last is None or (d - last).total_seconds() >= 4*3600: + sampled.append((d, f)); last = d + +ch_hist = defaultdict(list) +for d, f in sampled: + try: + with open(os.path.join(FUNDS_DIR, f)) as fh: data = json.load(fh) + except: continue + for ch in data.get("channels", []): + scid = ch.get("short_channel_id", "") + if scid: ch_hist[scid].append((d, ch["our_amount_msat"], ch["amount_msat"], ch.get("state",""))) + +r = subprocess.run(["docker","exec","cl","lightning-cli","listpeerchannels"], capture_output=True, text=True, timeout=15) +peerchans = json.loads(r.stdout) + +aliases = {} +def get_alias(pid): + if pid in aliases: return aliases[pid] + try: + r2 = subprocess.run(["docker","exec","cl","lightning-cli","listnodes",pid], capture_output=True, text=True, timeout=10) + nd = json.loads(r2.stdout) + a = nd["nodes"][0].get("alias", pid[:20]) if nd.get("nodes") else pid[:20] + except: a = pid[:20] + aliases[pid] = a; return a + +cands = [] +for ch in peerchans.get("channels", []): + scid = ch.get("short_channel_id",""); state = ch.get("state","") + if not scid or state != "CHANNELD_NORMAL": continue + cap = ch.get("total_msat",0)//1000; loc = ch.get("to_us_msat",0)//1000 + if cap == 0: continue + pct = 100*loc/cap + if pct < MIN_LOCAL_PCT: continue + hist = ch_hist.get(scid, []) + norm = [(d,o,t) for d,o,t,s in hist if s=="CHANNELD_NORMAL"] + mv = sum(abs(norm[i][1]-norm[i-1][1]) for i in range(1,len(norm)))//1000 if len(norm)>=2 else 0 + cands.append({"scid":scid,"peer_id":ch.get("peer_id",""),"movement":mv}) + +cands.sort(key=lambda x: x["movement"]) +sel = [] +for c in cands: + if len(sel) >= COUNT: break + alias = get_alias(c["peer_id"]) + skip = any(e in c["scid"].lower() or e in alias.lower() for e in EXCLUDE) + if not skip: sel.append(c["scid"]) + +print(" ".join(sel)) +PYEOF +) + +if [[ "$EXECUTE" != "true" ]]; then + echo "" + echo "DRY RUN - to actually close, run:" + echo " $0 --execute" + exit 0 +fi + +echo "" +echo "Closing channels..." +for scid in $SCIDS; do + echo " Closing $scid ..." + docker exec cl lightning-cli close "$scid" "$CLOSE_TIMEOUT" 2>&1 | head -5 + echo "" +done + +echo "Done." diff --git a/scripts/dedup-invoices.ts b/scripts/dedup-invoices.ts new file mode 100644 index 00000000..9232566b --- /dev/null +++ b/scripts/dedup-invoices.ts @@ -0,0 +1,86 @@ +#!/usr/bin/env bun +// One-time cleanup: remove duplicate entries from every `<id>:invoices` list. +// +// Background: `generate()` used to `lPush` the invoice id onto the owner's +// `:invoices` list every time it ran — including when regenerating an existing +// invoice (adding a tip, webhook, etc.). That pushed the same id multiple times, +// so a user's `/invoices` response contained duplicate records. The UI's keyed +// `{#each}` then threw `each_key_duplicate` and rendered a blank page. The source +// is fixed in lib/invoices.ts (only push when the id is brand new); this script +// cleans the dupes that already accumulated. +// +// Dedup preserves the ORIGINAL position of each id. The list is newest-first +// (lPush adds to the head), and re-pushes added newer copies at the head, so the +// deepest (tail-most) occurrence is the original creation push — we keep that one. +// +// Read-only by default (dry run). Pass --apply to actually rewrite lists. +// Usage: bun scripts/dedup-invoices.ts [--apply] + +import { createClient } from "redis"; + +const apply = process.argv.includes("--apply"); + +const db = createClient({ + url: "redis://127.0.0.1:6379", + socket: { reconnectStrategy: () => false }, +}); +await db.connect(); + +// Keep the LAST occurrence of each id (original creation order), preserving +// the relative order of the kept entries. +function dedupeKeepLast(list: string[]): string[] { + const seen = new Set<string>(); + const out: string[] = []; + for (let i = list.length - 1; i >= 0; i--) { + const id = list[i]; + if (seen.has(id)) continue; + seen.add(id); + out.push(id); + } + out.reverse(); + return out; +} + +let lists = 0; +let listsWithDupes = 0; +let entriesBefore = 0; +let entriesRemoved = 0; +const worst: { key: string; before: number; after: number }[] = []; + +for await (const k of db.scanIterator({ MATCH: "*:invoices", COUNT: 2000 })) { + const keys = Array.isArray(k) ? k : [k]; + for (const key of keys) { + lists++; + const list = await db.lRange(key, 0, -1); + entriesBefore += list.length; + const deduped = dedupeKeepLast(list); + const removed = list.length - deduped.length; + if (removed === 0) continue; + + listsWithDupes++; + entriesRemoved += removed; + worst.push({ key, before: list.length, after: deduped.length }); + + if (apply) { + // Rewrite atomically: DEL then RPUSH the deduped list (RPUSH keeps head->tail order). + const tx = db.multi(); + tx.del(key); + tx.rPush(key, deduped); + await tx.exec(); + } + } +} + +worst.sort((a, b) => b.before - b.after - (a.before - a.after)); + +console.log(`\n${apply ? "APPLIED" : "DRY RUN (use --apply to write)"}`); +console.log(`lists scanned: ${lists}`); +console.log(`lists with dupes: ${listsWithDupes}`); +console.log(`entries before: ${entriesBefore}`); +console.log(`duplicate entries: ${entriesRemoved}`); +console.log(`\ntop 10 most-duplicated lists:`); +for (const w of worst.slice(0, 10)) { + console.log(` ${w.key} ${w.before} -> ${w.after} (-${w.before - w.after})`); +} + +await db.quit(); diff --git a/scripts/kvrocks-merge-lowmem.conf b/scripts/kvrocks-merge-lowmem.conf new file mode 100644 index 00000000..bdfa641a --- /dev/null +++ b/scripts/kvrocks-merge-lowmem.conf @@ -0,0 +1,19 @@ +bind 0.0.0.0 +port 6379 +workers 2 +timeout 0 +tcp-backlog 512 +daemonize no +dir /data +db-name db +log-dir stdout +redis-cursor-compatible no +max-io-mb 100 +rocksdb.compression snappy +rocksdb.block_cache_size 128 +rocksdb.metadata_block_cache_size 128 +rocksdb.subkey_block_cache_size 128 +rocksdb.write_buffer_size 32 +rocksdb.max_background_compactions 1 +rocksdb.max_background_flushes 1 +rocksdb.max_background_jobs 2 diff --git a/scripts/ln-liquid-arb.ts b/scripts/ln-liquid-arb.ts new file mode 100644 index 00000000..02d0aa1e --- /dev/null +++ b/scripts/ln-liquid-arb.ts @@ -0,0 +1,126 @@ +#!/usr/bin/env bun +// Find users exhibiting the "lightning in, liquid out" pattern that drains the +// liquid hot wallet (forcing the liquid threshold down). For each user in the +// window, sum receives/sends per rail and surface those who take in meaningful +// lightning and push out meaningful liquid. +// +// Usage: bun scripts/ln-liquid-arb.ts [--days N] [--min SATS] [--top N] + +import { createClient } from "redis"; + +const argDays = Number(process.argv[process.argv.indexOf("--days") + 1]) || 14; +const MIN = Number(process.argv[process.argv.indexOf("--min") + 1]) || 500_000; +const TOP = Number(process.argv[process.argv.indexOf("--top") + 1]) || 30; +const cutoff = Date.now() - argDays * 24 * 60 * 60 * 1000; + +const db = createClient({ url: "redis://127.0.0.1:6379", socket: { reconnectStrategy: () => false } }); +await db.connect(); + +const fmt = (n: number) => n.toLocaleString("en-US"); + +// uid -> username +const uname = new Map<string, string>(); +async function loadNames() { + let batch: string[] = []; + const drain = async () => { + if (!batch.length) return; + const vals = await db.mGet(batch); + for (const v of vals) { + if (!v || v[0] !== "{") continue; + try { const u = JSON.parse(v); if (u.id && u.username) uname.set(u.id, u.username); } catch {} + } + batch = []; + }; + for await (const k of db.scanIterator({ MATCH: "user:*", COUNT: 1000 })) { + batch.push(k as string); + if (batch.length >= 500) await drain(); + } + await drain(); +} +await loadNames(); +const nameOf = (uid: string) => uname.get(uid) || uid.slice(0, 8); + +// Per user per rail: { in, out } sats +type Rail = { in: number; out: number; inN: number; outN: number }; +const RAILS = ["lightning", "liquid", "bitcoin", "internal", "bolt12", "fund"]; +const stats = new Map<string, Record<string, Rail>>(); +const ensure = (uid: string) => { + let s = stats.get(uid); + if (!s) { + s = {}; + for (const r of RAILS) s[r] = { in: 0, out: 0, inN: 0, outN: 0 }; + stats.set(uid, s); + } + return s; +}; + +let scanned = 0; +let batch: string[] = []; +const drainP = async () => { + if (!batch.length) return; + const vals = await db.mGet(batch); + for (const v of vals) { + if (!v || v[0] !== "{") continue; + let p: any; try { p = JSON.parse(v); } catch { continue; } + if (!p.created || p.created < cutoff || typeof p.amount !== "number" || !p.uid) continue; + const rail = p.type; + if (!RAILS.includes(rail)) continue; + const s = ensure(p.uid)[rail]; + if (p.amount >= 0) { s.in += p.amount; s.inN++; } + else { s.out += Math.abs(p.amount); s.outN++; } + } + batch = []; +}; +for await (const k of db.scanIterator({ MATCH: "payment:*", COUNT: 2000 })) { + scanned++; + batch.push(k as string); + if (batch.length >= 1000) await drainP(); +} +await drainP(); + +// The arb pattern: significant lightning IN and significant liquid OUT. +const flagged = [...stats.entries()] + .map(([uid, s]) => ({ + uid, + lnIn: s.lightning.in, + lqOut: s.liquid.out, + bolt12In: s.bolt12.in, + arb: Math.min(s.lightning.in + s.bolt12.in, s.liquid.out), // overlap = the through-flow + s, + })) + .filter((x) => x.lqOut >= MIN && x.lnIn + x.bolt12In >= MIN) + .sort((a, b) => b.arb - a.arb) + .slice(0, TOP); + +console.log("═".repeat(72)); +console.log(` LIGHTNING-IN / LIQUID-OUT pattern — last ${argDays} days (min ${fmt(MIN)} sats each side)`); +console.log("═".repeat(72)); +console.log(` walked ${fmt(scanned)} payment keys\n`); + +if (!flagged.length) { + console.log(" No users matched the pattern at this threshold."); +} else { + console.log(" user ln_in bolt12_in liquid_out through-flow"); + for (const x of flagged) { + console.log( + ` ${nameOf(x.uid).padEnd(20)} ${fmt(x.lnIn).padStart(11)} ${fmt(x.bolt12In).padStart(11)} ${fmt(x.lqOut).padStart(13)} ${fmt(x.arb).padStart(13)}`, + ); + } + const totalThrough = flagged.reduce((s, x) => s + x.arb, 0); + console.log(`\n Combined through-flow (ln/bolt12 in -> liquid out): ${fmt(totalThrough)} sats`); +} + +// Also: who are the biggest NET liquid drainers regardless of funding source? +const netLiquid = [...stats.entries()] + .map(([uid, s]) => ({ uid, net: s.liquid.out - s.liquid.in, out: s.liquid.out })) + .filter((x) => x.net >= MIN) + .sort((a, b) => b.net - a.net) + .slice(0, 15); +console.log("\n" + "─".repeat(72)); +console.log(" TOP NET LIQUID DRAINERS (liquid_out - liquid_in, any funding source)"); +console.log("─".repeat(72)); +for (const x of netLiquid) { + console.log(` ${nameOf(x.uid).padEnd(20)} net out ${fmt(x.net).padStart(13)} (gross out ${fmt(x.out)})`); +} +console.log("═".repeat(72)); +process.exit(0); diff --git a/scripts/merge-kvrocks-archives.ts b/scripts/merge-kvrocks-archives.ts new file mode 100644 index 00000000..54ea178d --- /dev/null +++ b/scripts/merge-kvrocks-archives.ts @@ -0,0 +1,321 @@ +#!/usr/bin/env bun + +// Overlay one Redis/Kvrocks archive onto a base archive without materializing +// keys in memory. The source wins on collisions. SCAN is intentionally sent as +// a raw command because Kvrocks cursors can exceed JavaScript's safe integer +// range and node-redis' high-level scan helper rounds them. + +import { createClient, type RedisClientType } from "redis"; + +const valueOption = (name: string) => + process.argv + .find((arg) => arg.startsWith(`--${name}=`)) + ?.slice(name.length + 3); +const hasFlag = (name: string) => process.argv.includes(`--${name}`); +const positiveInteger = (name: string, fallback: number) => { + const parsed = Number(valueOption(name) || fallback); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new Error(`--${name} must be a positive integer`); + } + return parsed; +}; + +const sourceUrl = valueOption("source-url"); +const baseUrl = valueOption("base-url"); +const targetUrl = valueOption("target-url"); +const verifyUrl = valueOption("verify-url"); +const apply = hasFlag("apply"); +const skipEqual = hasFlag("skip-equal"); +const countTypes = hasFlag("count-types"); +const batchSize = positiveInteger("batch", 512); +const concurrency = positiveInteger("concurrency", 32); +const progressEvery = positiveInteger("progress", 100_000); + +if (!sourceUrl || (apply && !targetUrl)) { + console.error( + "usage: bun scripts/merge-kvrocks-archives.ts " + + "--source-url=redis://127.0.0.1:6402 " + + "[--base-url=redis://127.0.0.1:6401] " + + "[--target-url=redis://127.0.0.1:6403 --apply] " + + "[--batch=512] [--concurrency=32]", + ); + process.exit(2); +} +if (apply && (sourceUrl === targetUrl || baseUrl === targetUrl)) { + throw new Error("The target must be distinct from the source and base"); +} + +const makeClient = (url: string) => + createClient({ + url, + socket: { reconnectStrategy: () => false }, + }); +const source = makeClient(sourceUrl); +const base = baseUrl ? makeClient(baseUrl) : null; +const target = targetUrl ? makeClient(targetUrl) : null; +const verifier = verifyUrl ? makeClient(verifyUrl) : null; +const clients = [source, base, target, verifier].filter( + Boolean, +) as RedisClientType[]; + +const concurrentMap = async <T>( + items: T[], + limit: number, + fn: (item: T) => Promise<void>, +) => { + let next = 0; + const workers = Array.from( + { length: Math.min(limit, items.length) }, + async () => { + while (next < items.length) { + const index = next++; + await fn(items[index]); + } + }, + ); + await Promise.all(workers); +}; + +const valuesMatch = async ( + left: RedisClientType, + right: RedisClientType, + key: string, + type: string, +) => { + if ((await right.type(key)) !== type) return false; + if (type === "string") { + const [leftValue, rightValue] = await Promise.all([ + left.sendCommand<Buffer | null>(["GET", key], { returnBuffers: true }), + right.sendCommand<Buffer | null>(["GET", key], { returnBuffers: true }), + ]); + return ( + leftValue !== null && + rightValue !== null && + Buffer.from(leftValue).equals(Buffer.from(rightValue)) + ); + } + if (type === "list") { + const [leftValues, rightValues] = await Promise.all([ + left.sendCommand<Buffer[]>(["LRANGE", key, "0", "-1"], { + returnBuffers: true, + }), + right.sendCommand<Buffer[]>(["LRANGE", key, "0", "-1"], { + returnBuffers: true, + }), + ]); + return ( + leftValues.length === rightValues.length && + leftValues.every((value, index) => + Buffer.from(value).equals(Buffer.from(rightValues[index])), + ) + ); + } + return false; +}; + +let scanned = 0; +let overlaps = 0; +let restored = 0; +let skippedEqual = 0; +let expiredDuringRead = 0; +const typeCounts: Record<string, number> = {}; +let verified = 0; +const verificationMismatches: string[] = []; +let cursor = "0"; +let nextProgress = progressEvery; +const startedAt = Date.now(); + +try { + await Promise.all(clients.map((client) => client.connect())); + + do { + const reply = await source.sendCommand<[string, string[]]>([ + "SCAN", + cursor, + "COUNT", + String(batchSize), + ]); + cursor = String(reply[0]); + const keys = reply[1]; + scanned += keys.length; + + if (countTypes) { + await concurrentMap(keys, concurrency, async (key) => { + const type = await source.type(key); + typeCounts[type] = (typeCounts[type] || 0) + 1; + }); + } + + if (base) { + await concurrentMap(keys, concurrency, async (key) => { + if (await base.exists(key)) overlaps++; + }); + } + + if (verifier) { + await concurrentMap(keys, concurrency, async (key) => { + const [sourceType, verifyType] = await Promise.all([ + source.type(key), + verifier.type(key), + ]); + if (sourceType !== verifyType) { + if (verificationMismatches.length < 20) { + verificationMismatches.push( + `${key}: type ${sourceType} != ${verifyType}`, + ); + } + return; + } + + if (sourceType === "string") { + const [sourceValue, verifyValue] = await Promise.all([ + source.sendCommand<Buffer | null>(["GET", key], { + returnBuffers: true, + }), + verifier.sendCommand<Buffer | null>(["GET", key], { + returnBuffers: true, + }), + ]); + if ( + sourceValue === null || + verifyValue === null || + !Buffer.from(sourceValue).equals(Buffer.from(verifyValue)) + ) { + if (verificationMismatches.length < 20) { + verificationMismatches.push(`${key}: string value differs`); + } + return; + } + } else if (sourceType === "list") { + const [sourceValues, verifyValues] = await Promise.all([ + source.sendCommand<Buffer[]>(["LRANGE", key, "0", "-1"], { + returnBuffers: true, + }), + verifier.sendCommand<Buffer[]>(["LRANGE", key, "0", "-1"], { + returnBuffers: true, + }), + ]); + if ( + sourceValues.length !== verifyValues.length || + sourceValues.some( + (value, index) => + !Buffer.from(value).equals(Buffer.from(verifyValues[index])), + ) + ) { + if (verificationMismatches.length < 20) { + verificationMismatches.push(`${key}: list value differs`); + } + return; + } + } else { + if (verificationMismatches.length < 20) { + verificationMismatches.push( + `${key}: verification unsupported for ${sourceType}`, + ); + } + return; + } + verified++; + }); + } + + if (apply && target) { + await concurrentMap(keys, concurrency, async (key) => { + const [type, ttlRaw] = await Promise.all([ + source.type(key), + source.sendCommand<string>(["PTTL", key]), + ]); + const ttl = Number(ttlRaw); + if (type === "none" || ttl === -2) { + expiredDuringRead++; + return; + } + + if (skipEqual && (await valuesMatch(source, target, key, type))) { + const targetTtl = await target.pTTL(key); + const ttlMatches = + (ttl === -1 && targetTtl === -1) || + (ttl > 0 && targetTtl > 0 && Math.abs(ttl - targetTtl) < 10_000); + if (ttlMatches) { + skippedEqual++; + return; + } + } + + if (type === "string") { + const value = await source.sendCommand<Buffer | null>(["GET", key], { + returnBuffers: true, + }); + if (value === null) { + expiredDuringRead++; + return; + } + await target.sendCommand(["SET", key, value]); + } else if (type === "list") { + await target.del(key); + let offset = 0; + while (true) { + const values = await source.sendCommand<Buffer[]>( + ["LRANGE", key, String(offset), String(offset + batchSize - 1)], + { returnBuffers: true }, + ); + if (!values.length) break; + await target.sendCommand(["RPUSH", key, ...values]); + offset += values.length; + if (values.length < batchSize) break; + } + } else { + throw new Error(`Unsupported source type ${type} for key ${key}`); + } + + if (ttl > 0) await target.pExpire(key, ttl); + restored++; + }); + } + + if (scanned >= nextProgress) { + console.error( + JSON.stringify({ + scanned, + overlaps, + restored, + skippedEqual, + cursor, + elapsedSeconds: Math.round((Date.now() - startedAt) / 1000), + }), + ); + nextProgress += progressEvery; + } + } while (cursor !== "0"); + + console.log( + JSON.stringify( + { + mode: apply ? "apply" : "audit", + sourceUrl, + baseUrl: baseUrl || null, + targetUrl: targetUrl || null, + sourceKeysScanned: scanned, + overlappingBaseKeys: base ? overlaps : null, + sourceOnlyKeys: base ? scanned - overlaps : null, + restored: apply ? restored : null, + skippedEqual: apply ? skippedEqual : null, + expiredDuringRead, + typeCounts: countTypes ? typeCounts : null, + verifyUrl: verifyUrl || null, + verified: verifier ? verified : null, + verificationMismatches: verifier ? verificationMismatches : null, + elapsedSeconds: Math.round((Date.now() - startedAt) / 1000), + }, + null, + 2, + ), + ); + if (verificationMismatches.length) process.exitCode = 1; +} finally { + await Promise.all( + clients.map(async (client) => { + if (client.isOpen) await client.quit(); + }), + ); +} diff --git a/scripts/migrate.py b/scripts/migrate.py new file mode 100644 index 00000000..de7ff922 --- /dev/null +++ b/scripts/migrate.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Migrate CLN SQLite database to Postgres. + +Reads from /tmp/lightningd.sqlite3, outputs SQL to stdout. +Run inside the pgcl container: + python3 /tmp/migrate.py | psql -U lightning -d lightning +""" +import sqlite3 +import subprocess +import sys + +sq = sqlite3.connect("/tmp/lightningd.sqlite3") +sq.row_factory = sqlite3.Row + +# Get Postgres tables +r = subprocess.run( + ["psql", "-U", "lightning", "-d", "lightning", "-tAc", + "SELECT tablename FROM pg_tables WHERE schemaname='public';"], + capture_output=True, text=True +) +pg_tables = set(r.stdout.strip().split("\n")) + +# Get SQLite tables +sq_tables = [row[0] for row in sq.execute( + "SELECT name FROM sqlite_master WHERE type='table'").fetchall()] + +sql_parts = [ + "BEGIN;", + "SET CONSTRAINTS ALL DEFERRED;", +] +total = 0 + +# Insert tables with foreign key targets first +priority = ["version", "blocks", "shachains", "peers", "channels", + "channel_configs", "offers", "invoicerequests", "move_accounts"] +ordered = priority + [t for t in sq_tables if t not in priority] + +for tbl in ordered: + if tbl not in pg_tables: + print(f" SKIP {tbl}", file=sys.stderr) + continue + + rows = sq.execute(f'SELECT * FROM [{tbl}]').fetchall() + if not rows: + print(f" {tbl}: 0 rows", file=sys.stderr) + continue + + cols = rows[0].keys() + col_list = ", ".join(f'"{c}"' for c in cols) + count = 0 + + for row in rows: + values = [] + for col in cols: + val = row[col] + if val is None: + values.append("NULL") + elif isinstance(val, bytes): + values.append(f"'\\x{val.hex()}'") + elif isinstance(val, (int, float)): + values.append(str(val)) + else: + escaped = str(val).replace("'", "''") + values.append(f"'{escaped}'") + val_list = ", ".join(values) + sql_parts.append(f'INSERT INTO "{tbl}" ({col_list}) VALUES ({val_list});') + count += 1 + + total += count + print(f" {tbl}: {count} rows", file=sys.stderr) + +sql_parts.append("COMMIT;") +print(f"\nTotal: {total} rows to migrate", file=sys.stderr) +print("\n".join(sql_parts)) diff --git a/scripts/monitor-ln-socket.sh b/scripts/monitor-ln-socket.sh new file mode 100755 index 00000000..66841f40 --- /dev/null +++ b/scripts/monitor-ln-socket.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +set -euo pipefail + +SOCKET_DIR="/home/adam/coinos-server/data/sockets" +SOCKET_MAIN="$SOCKET_DIR/lightning-rpc" +SOCKET_LISTEN="$SOCKET_DIR/lightning-rpc-listen" +REMOTE_SOCKET="/home/adam/coinos-server/data/lightning/bitcoin/lightning-rpc" +REMOTE_HOST="desk" + +log() { + echo "$(date '+%Y-%m-%d %H:%M:%S') [ln-socket-monitor] $*" +} + +# Find SSH pids that are forwarding our sockets +pid_main=$(pgrep -f "ssh.*-L ${SOCKET_MAIN}:${REMOTE_SOCKET} ${REMOTE_HOST}" 2>/dev/null || true) +pid_listen=$(pgrep -f "ssh.*-L ${SOCKET_LISTEN}:${REMOTE_SOCKET} ${REMOTE_HOST}" 2>/dev/null || true) + +# Check if both processes are alive and both sockets exist +if [[ -n "$pid_main" && -n "$pid_listen" && -S "$SOCKET_MAIN" && -S "$SOCKET_LISTEN" ]]; then + exit 0 +fi + +# Something is wrong - log what we found +if [[ -z "$pid_main" ]]; then + log "main SSH forward is dead" +elif [[ ! -S "$SOCKET_MAIN" ]]; then + log "main socket file missing" +fi + +if [[ -z "$pid_listen" ]]; then + log "listen SSH forward is dead" +elif [[ ! -S "$SOCKET_LISTEN" ]]; then + log "listen socket file missing" +fi + +# Kill any remaining SSH forwards for our sockets +for pid in $pid_main $pid_listen; do + if [[ -n "$pid" ]]; then + log "killing stale SSH forward (pid $pid)" + kill "$pid" 2>/dev/null || true + fi +done + +# Brief wait for processes to exit +sleep 1 + +# Clean up socket files +for sock in "$SOCKET_MAIN" "$SOCKET_LISTEN"; do + if [[ -e "$sock" ]]; then + log "removing stale socket $sock" + rm -f "$sock" + fi +done + +# Ensure socket directory exists +mkdir -p "$SOCKET_DIR" + +# Re-establish both SSH forwards +log "starting main SSH forward" +ssh -nNT -o ExitOnForwardFailure=yes \ + -L "${SOCKET_MAIN}:${REMOTE_SOCKET}" "$REMOTE_HOST" & + +log "starting listen SSH forward" +ssh -nNT -o ExitOnForwardFailure=yes \ + -L "${SOCKET_LISTEN}:${REMOTE_SOCKET}" "$REMOTE_HOST" & + +# Wait briefly for sockets to appear +for i in 1 2 3 4 5; do + if [[ -S "$SOCKET_MAIN" && -S "$SOCKET_LISTEN" ]]; then + break + fi + sleep 1 +done + +# Set permissions on sockets +if [[ -S "$SOCKET_MAIN" ]]; then + chmod 777 "$SOCKET_MAIN" + log "set permissions on $SOCKET_MAIN" +else + log "WARNING: $SOCKET_MAIN did not appear after 5s" +fi + +if [[ -S "$SOCKET_LISTEN" ]]; then + chmod 777 "$SOCKET_LISTEN" + log "set permissions on $SOCKET_LISTEN" +else + log "WARNING: $SOCKET_LISTEN did not appear after 5s" +fi + +log "SSH forwards re-established" diff --git a/scripts/pay-hard.ts b/scripts/pay-hard.ts new file mode 100644 index 00000000..e97b3a20 --- /dev/null +++ b/scripts/pay-hard.ts @@ -0,0 +1,159 @@ +// pay-hard.ts — a "stubborn" Lightning payer for hard destinations. +// +// Motivation (fixedfloat swap, 2026-08-27): xpay/renepay are cost-first. To a +// well-connected but inbound-scarce node (an exchange), the CHEAPEST paths are +// exactly the depleted ones, so cost-optimizing payers keep failing on them — +// and after many failures CLN's askrene "learned capacity" turns pessimistic +// and steers them away from the ONE path that actually has room. The move that +// works is liquidity DISCOVERY, orthogonal to cost: enumerate the destination's +// peers, keep the ones WE can reach (bonus: ones we already channel), and probe +// each `us -> peer -> dest` leg for inbound room at the real amount. Then force +// the payment through the peer that has room. +// +// Strategy: +// 1. Preflight (decode, not-expired, not already-paid, nothing pending). +// 2. Try xpay normally (fast path — covers ~all payments). +// 3. On failure to a hint-less destination: rank the destination's peers +// (ones we channel with >= amount outbound first), and for each, build a +// route FORCED through peer->dest (getroute excluding all other dest +// channels), with the CORRECT final CLTV from the invoice, then PROBE it +// with a random hash. The first path that reaches the destination gets the +// real payment. +// +// Safety: probe (unclaimable random hash) before every real send; refuse to +// start if a payment is already pending; if a real send doesn't resolve +// cleanly (still pending), STOP rather than risk a second in-flight payment. +// +// Usage: +// bun pay-hard.ts <bolt11> [--node=cl|bcln] [--maxfee=<sat>] [--dry] +// [--skip-xpay] [--max-peers=N] +// --node which node pays (cl = local main node; bcln = boltz node on cs). default cl +// --maxfee fee ceiling in sat (default 5000) +// --dry find a working path and stop (no real payment) +// --skip-xpay go straight to the peer-force strategy +// --max-peers how many candidate peers to probe (default 12) +import { execSync } from "child_process"; +import { writeFileSync } from "fs"; +import { randomBytes } from "crypto"; + +const argv = process.argv.slice(2); +const inv = argv.find((a) => !a.startsWith("--")); +const opt = (k: string, d: string) => { const a = argv.find((x) => x.startsWith(`--${k}=`)); return a ? a.split("=").slice(1).join("=") : d; }; +const flag = (k: string) => argv.includes(`--${k}`); +const nodeName = opt("node", "cl"); +const maxfeeSat = Number(opt("maxfee", "5000")); +const maxPeers = Number(opt("max-peers", "12")); +const DRY = flag("dry"); +const skipXpay = flag("skip-xpay"); + +if (!inv) { + console.error("usage: bun pay-hard.ts <bolt11> [--node=cl|bcln] [--maxfee=sat] [--dry] [--skip-xpay] [--max-peers=N]"); + process.exit(1); +} + +// Node adapters. cl runs locally; bcln runs in a container on the `cs` host. +const NODES: Record<string, { remote: boolean; prefix: string }> = { + cl: { remote: false, prefix: "docker exec cl lightning-cli" }, + bcln: { remote: true, prefix: "docker exec bcln lightning-cli --lightning-dir=/app/lightning" }, +}; +const node = NODES[nodeName]; +if (!node) { console.error("unknown node:", nodeName, "(use cl or bcln)"); process.exit(1); } + +const sh = (c: string) => { try { return execSync(c, { encoding: "utf8", maxBuffer: 1e8 }); } catch (e: any) { return (e.stdout || "") + (e.stderr || ""); } }; +// run a shell command ON the node's host (local, or via ssh to cs). Single-quote +// for ssh so $(cat ...) is expanded remotely, not locally. Our route/exclude +// JSON never contains single quotes, so this quoting is safe. +const runOn = (cmd: string) => node.remote ? sh(`ssh -o ConnectTimeout=25 cs '${cmd}'`) : sh(cmd); +const cli = (sub: string) => runOn(`${node.prefix} ${sub}`); +const cliJSON = (sub: string): any => { const o = cli(sub); try { return JSON.parse(o); } catch { return { __raw: o }; } }; +const putFile = (path: string, content: string) => { writeFileSync(path, content); if (node.remote) sh(`scp -q ${path} cs:${path}`); }; +const log = (...a: any[]) => console.log(...a); +const sat = (msat: number) => Math.round(msat / 1000); + +// waitsendpay that returns the parsed result whether it succeeded or failed. +const waitFor = (hash: string, secs = 60): any => { const o = cli(`waitsendpay ${hash} ${secs}`); try { return JSON.parse(o); } catch { return { __raw: o }; } }; + +// ---- 1. preflight ---- +const dec = cliJSON(`decode ${inv}`); +if (!dec || dec.valid === false || !dec.payment_hash) { log("invalid/undecodable invoice:", JSON.stringify(dec).slice(0, 200)); process.exit(1); } +const amount = Number(dec.amount_msat); +const hash = dec.payment_hash; +const secret = dec.payment_secret; +const finalCltv = Number(dec.min_final_cltv_expiry || 18); +const dest = dec.payee; +const hints = (dec.routes || []).length; +if (!amount) { log("amountless invoice not supported"); process.exit(1); } +log(`invoice: ${sat(amount)} sat -> ${String(dest).slice(0, 16)}… | min_final_cltv ${finalCltv} | hints ${hints} | node ${nodeName}`); + +const lp = cliJSON(`listpays bolt11=${inv}`); +const alreadyDone = (lp.pays || []).find((p: any) => p.status === "complete"); +if (alreadyDone) { log("✅ already paid; preimage:", alreadyDone.preimage); process.exit(0); } +if ((lp.pays || []).some((p: any) => p.status === "pending")) { log("⚠️ a payment for this invoice is already PENDING — not starting another. Wait for it to resolve."); process.exit(1); } + +// ---- 2. xpay fast path ---- +if (!skipXpay) { + log(`→ trying xpay (maxfee ${maxfeeSat} sat)…`); + const x = cliJSON(`-k xpay invstring=${inv} maxfee=${maxfeeSat * 1000}`); + if (x && x.payment_preimage) { log(`✅ xpay succeeded! preimage: ${x.payment_preimage} | fee ${sat(Number(x.amount_sent_msat) - amount)} sat`); process.exit(0); } + log(" xpay did not complete:", String(x.__raw || x.message || JSON.stringify(x)).replace(/\s+/g, " ").slice(0, 160)); +} + +if (hints > 0) { log("invoice carries route hints — the peer-force strategy targets the public destination node and can't honor private hints. Rely on xpay/renepay here."); process.exit(1); } + +// ---- 3. peer-force liquidity discovery ---- +log("→ peer-force: probing the destination's reachable peers for inbound room…"); +const destChans = (cliJSON(`listchannels destination=${dest}`).channels) || []; +if (!destChans.length) { log("destination advertises no public channels — cannot force-route."); process.exit(2); } +const bySource: Record<string, { scids: string[]; cap: number }> = {}; +for (const c of destChans) { (bySource[c.source] ||= { scids: [], cap: 0 }); bySource[c.source].scids.push(c.short_channel_id); bySource[c.source].cap = Math.max(bySource[c.source].cap, Number(c.amount_msat || 0)); } +const allScids: string[] = destChans.map((c: any) => c.short_channel_id); + +const ours: Record<string, number> = {}; +for (const c of ((cliJSON(`listpeerchannels`).channels) || [])) if (c.state === "CHANNELD_NORMAL") ours[c.peer_id] = Number(c.spendable_msat || 0); + +// rank: peers we already channel with enough outbound first, then by our +// outbound to them, then by their channel capacity to the destination. +const candidates = Object.entries(bySource) + .map(([id, info]) => ({ id, scids: info.scids, cap: info.cap, our: ours[id] || 0 })) + .sort((a, b) => (b.our >= amount ? 1 : 0) - (a.our >= amount ? 1 : 0) || b.our - a.our || b.cap - a.cap) + .slice(0, maxPeers); +log(` ${candidates.length} candidates (★ = we already channel with room): ` + candidates.slice(0, 8).map((c) => c.id.slice(0, 8) + (c.our >= amount ? "★" : "")).join(" ")); + +for (const P of candidates) { + // force the route to use P->dest as the final hop by excluding every other dest channel + const excl = allScids.filter((s) => !P.scids.includes(s)).flatMap((s) => [`${s}/0`, `${s}/1`]); + putFile("/tmp/ph_excl.json", JSON.stringify(excl)); + const rr = cliJSON(`getroute ${dest} ${amount} 10 ${finalCltv + 5} null null "$(cat /tmp/ph_excl.json)"`); + const route = rr.route; + if (!route || !route.length) { continue; } + putFile("/tmp/ph_route.json", JSON.stringify(route)); + + // PROBE with an unclaimable random hash (correct amount + correct CLTV) + const rh = randomBytes(32).toString("hex"); + runOn(`${node.prefix} sendpay "$(cat /tmp/ph_route.json)" ${rh} >/dev/null 2>&1 || true`); + const wp = waitFor(rh, 45); + const d = wp.data || wp; + const fc = d.failcodename || d.failcode || ""; + // "reached the destination" = the DESTINATION node itself rejected the probe + // (unclaimable hash) with an unknown-payment-details error. Keying on + // erring_node (not an index) avoids CLN's sender-is-index-0 off-by-one. + const reached = /UNKNOWN_PAYMENT_DETAILS|INCORRECT_OR_UNKNOWN/.test(String(fc)) && d.erring_node === dest; + log(` via ${P.id.slice(0, 12)}… (${route.length}h, out ${sat(P.our)} sat): ${reached ? "✅ reaches destination" : `✗ ${fc || "no reach"}@idx${d.erring_index}`}`); + if (!reached) continue; + + if (DRY) { log(`DRY: a working path exists via ${P.id.slice(0, 16)}… (route in /tmp/ph_route.json). Not paying.`); process.exit(0); } + + // fire the REAL payment through this route (same route => correct amount+CLTV) + log(`→ firing real payment via ${P.id.slice(0, 16)}…`); + cliJSON(`-k sendpay route="$(cat /tmp/ph_route.json)" payment_hash=${hash} bolt11=${inv} payment_secret=${secret} amount_msat=${amount}`); + const res = waitFor(hash, 70); + if (res.payment_preimage || res.status === "complete") { log(`🎉 PAID via ${P.id.slice(0, 12)}…! preimage: ${res.payment_preimage} | fee ${sat(Number(res.amount_sent_msat) - amount)} sat`); process.exit(0); } + const rd = res.data || res; + const rfc = rd.failcodename || rd.failcode; + if (!rfc) { log(`⚠️ real send did not resolve cleanly (${String(res.__raw || res.message || "").slice(0, 100)}). STOPPING to avoid a second in-flight payment — check listpays before retrying.`); process.exit(3); } + log(` real send failed here: ${rfc}@idx${rd.erring_index} (liquidity shifted). trying next peer…`); +} + +log("✗ no reachable peer of the destination had inbound room for the full amount right now."); +log(" (destination inbound is exhausted across all paths we can reach, or the amount is too large for any single peer path.)"); +process.exit(2); diff --git a/scripts/payment-monitor.sh b/scripts/payment-monitor.sh new file mode 100755 index 00000000..c50aad9e --- /dev/null +++ b/scripts/payment-monitor.sh @@ -0,0 +1,210 @@ +#!/bin/bash +# Payment failure monitor for coinos-server +# Watches app container logs and compiles failure analytics +# Run: ./scripts/payment-monitor.sh [hours_to_watch] +# Output: periodic reports to stdout + detailed data to /tmp/payment-failures.jsonl + +WATCH_HOURS=${1:-24} +DATA_FILE="/tmp/payment-failures.jsonl" +REPORT_INTERVAL=1800 # report every 30 min +NODE_ID=$(docker exec cl lightning-cli getinfo 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" 2>/dev/null || echo "unknown") + +echo "Payment failure monitor started (watching for ${WATCH_HOURS}h)" +echo "Node: ${NODE_ID}" +echo "Data file: ${DATA_FILE}" +echo "Reports every ${REPORT_INTERVAL}s" +echo "---" + +# Clear old data +> "$DATA_FILE" + +docker logs app -f 2>&1 | timeout $((WATCH_HOURS * 3600)) python3 -u -c " +import sys, json, time, re +from collections import Counter, defaultdict +from datetime import datetime + +data_file = '${DATA_FILE}' +report_interval = ${REPORT_INTERVAL} +last_report = time.time() + +# Tracking +failures = [] +error_types = Counter() +failed_destinations = Counter() +failed_channels = Counter() # channels that return temporary_channel_failure +fee_insufficient_channels = Counter() +failed_amounts = [] # (amount, error_type) +successful = 0 +total_attempts = 0 + +prev_line = None + +def parse_failure(line, error_line): + global successful, total_attempts + total_attempts += 1 + + try: + msg = json.loads(line)['msg'] + err_msg = json.loads(error_line)['msg'] if error_line else '' + except: + return + + # Parse: username payment failed amount balance hash + m = re.match(r'(\S+) payment failed (\d+) (\d+)\s+(.*)', msg) + if not m: + return + + username, amount, balance, rest = m.group(1), int(m.group(2)), int(m.group(3)), m.group(4) + + # Categorize error + if 'Insufficient funds' in err_msg or 'insuficientes' in err_msg.lower(): + etype = 'insufficient_funds' + elif 'excessive cost' in err_msg: + etype = 'excessive_cost' + elif 'temporary_channel_failure' in err_msg: + etype = 'temporary_channel_failure' + # Extract failed channel IDs + for ch in re.findall(r'(\d+x\d+x\d+/\d+)', err_msg): + failed_channels[ch] += 1 + elif 'fee_insufficient' in err_msg: + etype = 'fee_insufficient' + for ch in re.findall(r'(\d+x\d+x\d+/\d+)', err_msg): + fee_insufficient_channels[ch] += 1 + elif 'Timed out' in err_msg: + etype = 'timed_out' + for ch in re.findall(r'(\d+x\d+x\d+/\d+)', err_msg): + if 'temporary_channel_failure' in err_msg: + failed_channels[ch] += 1 + if 'fee_insufficient' in err_msg: + fee_insufficient_channels[ch] += 1 + elif 'Cannot send to self' in err_msg: + etype = 'self_pay' + elif 'incorrect_or_unknown' in err_msg: + etype = 'unknown_invoice' + elif 'already underway' in err_msg: + etype = 'already_underway' + elif 'already been paid' in err_msg: + etype = 'already_paid' + elif 'user not provided' in err_msg: + etype = 'user_not_found' + elif err_msg: + etype = err_msg[:80] + else: + etype = 'unknown' + + error_types[etype] += 1 + failed_amounts.append((amount, etype)) + + # Try to get destination from invoice + if rest and rest.startswith('ln'): + bolt11_prefix = rest[:20] + failed_destinations[bolt11_prefix] += 1 + + record = { + 'time': datetime.utcnow().isoformat(), + 'user': username, + 'amount': amount, + 'balance': balance, + 'error': etype, + 'detail': err_msg[:200] if err_msg else '', + } + + with open(data_file, 'a') as f: + f.write(json.dumps(record) + '\n') + +def parse_success(line): + global successful + successful += 1 + +def report(): + global last_report + now = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S') + + print(f'\n====== PAYMENT FAILURE REPORT {now} UTC ======') + print(f'Total attempts: {total_attempts} Successful: {successful} Failed: {total_attempts}') + if total_attempts > 0: + print(f'Success rate: {successful/(successful+total_attempts)*100:.1f}%') + + print(f'\n--- Error breakdown ---') + for etype, count in error_types.most_common(15): + print(f' {count:>5} {etype}') + + if failed_channels: + print(f'\n--- Channels with liquidity failures (top 10) ---') + for ch, count in failed_channels.most_common(10): + print(f' {count:>5} {ch}') + + if fee_insufficient_channels: + print(f'\n--- Channels with stale fee gossip (top 10) ---') + for ch, count in fee_insufficient_channels.most_common(10): + print(f' {count:>5} {ch}') + + # Amount distribution of failures + if failed_amounts: + amounts_by_type = defaultdict(list) + for amt, etype in failed_amounts: + amounts_by_type[etype].append(amt) + + print(f'\n--- Failed amount ranges by error ---') + for etype in ['insufficient_funds', 'excessive_cost', 'temporary_channel_failure', 'timed_out', 'fee_insufficient']: + amts = amounts_by_type.get(etype, []) + if amts: + print(f' {etype}: min={min(amts)} max={max(amts)} avg={sum(amts)//len(amts)} count={len(amts)}') + + print(f'\n--- Recommendations ---') + if error_types.get('insufficient_funds', 0) > 10: + print(' * HIGH insufficient_funds: Consider showing fee preview before payment attempt') + if error_types.get('excessive_cost', 0) > 5: + print(' * HIGH excessive_cost: Increase default maxfee or improve channel connectivity') + if error_types.get('fee_insufficient', 0) > 5: + print(' * STALE GOSSIP: Some channels advertise lower fees than they charge') + print(' Nodes to investigate:') + for ch, count in fee_insufficient_channels.most_common(5): + scid = ch.split('/')[0] + print(f' {scid} ({count} failures)') + if failed_channels: + print(' * LIQUIDITY BOTTLENECKS:') + for ch, count in failed_channels.most_common(5): + if count >= 3: + scid = ch.split('/')[0] + print(f' {scid} ({count} failures) - consider opening channel to bypass') + if error_types.get('self_pay', 0) > 5: + print(' * SELF-PAY: Users trying to pay own invoices - consider UI guard') + + print('=' * 55) + sys.stdout.flush() + last_report = time.time() + +for line in sys.stdin: + line = line.strip() + if not line: + continue + + try: + d = json.loads(line) + msg = d.get('msg', '') + except: + prev_line = line + continue + + # Track successes + if 'sent lightning' in msg or 'sent liquid' in msg or 'sent bitcoin' in msg: + parse_success(line) + + # Track failures - need the next line for error detail + if prev_line and 'payment failed' in prev_line: + try: + prev_d = json.loads(prev_line) + if 'payment failed' in prev_d.get('msg', ''): + parse_failure(prev_line, line) + except: + pass + + prev_line = line + + # Periodic report + if time.time() - last_report >= report_interval: + report() +" 2>&1 + +echo "Monitor finished after ${WATCH_HOURS}h" diff --git a/scripts/rebalance-economics.ts b/scripts/rebalance-economics.ts new file mode 100644 index 00000000..0d4bf6e2 --- /dev/null +++ b/scripts/rebalance-economics.ts @@ -0,0 +1,96 @@ +#!/usr/bin/env bun +// Models whether the lightning->liquid "bridge" usage is fee-positive for us +// at a given rebalance batch size. +// +// The mechanic: +// - A user funds via Lightning (we receive into our LN channels) and withdraws +// via Liquid (we pay out of our Liquid hot wallet). Net: LN balance grows, +// Liquid balance shrinks. We charge config.fee.liquid (0.1%) on the liquid +// send, NOT offset by their credit:liquid (they bank credit:lightning from +// the receive, which is a different key) — so we collect ~0.1% of throughput. +// - To refill Liquid we must rebalance: close LN channels (on-chain BTC back to +// our cl wallet) then peg-in to Liquid. That costs on-chain fees, amortized +// over how much we move per rebalance. +// +// Revenue from bridging X sats of throughput = X * liquid_fee. +// Cost to rebalance that X = (channel closes to free X) + 1 peg-in tx. +// Break-even: revenue >= cost. +// +// Usage: bun scripts/rebalance-economics.ts [--feerate SAT_VB] [--avg-channel BTC] [--liquid-fee 0.001] + +const arg = (k: string, d: number) => { + const i = process.argv.indexOf(k); + return i >= 0 ? Number(process.argv[i + 1]) : d; +}; + +const SATS = 100_000_000; +const liquidFee = arg("--liquid-fee", 0.001); // config.fee.liquid = 0.1% +const feerate = arg("--feerate", 1.01); // sat/vB; current mutual_close ~1.01 +const avgChannelBtc = arg("--avg-channel", 0.3); // avg local balance freed per close + +// tx vsizes (vBytes) +const MUTUAL_CLOSE_VB = 170; // 1 funding input + 2 outputs +const PEGIN_VB = 150; // consolidate freed outputs -> 1 peg-in send (BTC side) + +function closesNeeded(btc: number) { + return Math.max(1, Math.ceil(btc / avgChannelBtc)); +} + +function rebalanceCostSats(btc: number) { + const closes = closesNeeded(btc); + const closeFees = closes * MUTUAL_CLOSE_VB * feerate; + const peginFee = PEGIN_VB * feerate; // one batched peg-in per rebalance + return { closes, closeFees, peginFee, total: closeFees + peginFee }; +} + +function revenueSats(btc: number) { + return btc * SATS * liquidFee; +} + +console.log("═".repeat(70)); +console.log(" LIGHTNING -> LIQUID BRIDGE ECONOMICS"); +console.log(` liquid fee ${(liquidFee * 100).toFixed(2)}% | close feerate ${feerate} sat/vB | avg channel ${avgChannelBtc} BTC`); +console.log("═".repeat(70)); +console.log( + "\n batch revenue closes close+pegin cost net margin", +); +console.log(" " + "-".repeat(64)); + +for (const btc of [0.1, 0.25, 0.5, 1, 2, 5, 10]) { + const rev = revenueSats(btc); + const c = rebalanceCostSats(btc); + const net = rev - c.total; + const margin = (net / rev) * 100; + const tag = net >= 0 ? "PROFIT" : "LOSS"; + console.log( + ` ${(btc + " BTC").padEnd(8)} ${(Math.round(rev) + "").padStart(9)}s ${(c.closes + "").padStart(4)} ${(Math.round(c.total) + "").padStart(10)}s ${(Math.round(net) + "").padStart(8)}s ${margin.toFixed(1).padStart(6)}% ${tag}`, + ); +} + +// Break-even batch size: revenue(btc) = cost(btc). With closes ~ btc/avgChannel, +// cost grows ~linearly too, so solve numerically. +let beBtc = 0; +for (let b = 0.001; b <= 50; b += 0.001) { + if (revenueSats(b) >= rebalanceCostSats(b).total) { + beBtc = b; + break; + } +} +console.log("\n " + "-".repeat(64)); +if (beBtc) { + console.log(` Break-even: rebalancing >= ${beBtc.toFixed(3)} BTC per batch is fee-positive.`); +} else { + console.log(` Never breaks even in range — cost scales with throughput as fast as revenue.`); +} + +// The deeper truth: because closes scale with throughput, per-sat margin is +// roughly constant. Show the asymptotic per-BTC economics. +const big = rebalanceCostSats(10); +const bigRev = revenueSats(10); +console.log( + `\n At scale (per 1 BTC bridged): revenue ${Math.round(revenueSats(1))}s, ` + + `rebalance cost ~${Math.round(rebalanceCostSats(1).total)}s, ` + + `net ~${Math.round(revenueSats(1) - rebalanceCostSats(1).total)}s`, +); +console.log("═".repeat(70)); +process.exit(0); @@ -1,3 +1,14 @@ +#!/bin/bash + +if [ -f config.ts ] || [ -f compose.yml ] || [ -d data ]; then + echo "Existing config.ts, compose.yml, or data/ detected." + read -p "Overwrite? This will destroy current config and data. (y/N) " confirm + if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then + echo "Aborting." + exit 1 + fi +fi + cp config.ts.sample config.ts cp compose.yml.sample compose.yml cp -r sampledata data @@ -5,6 +16,9 @@ sudo chown 100:100 data/nostr/data docker compose up -d docker run -it -v $(pwd):/home/bun/app --entrypoint bun asoltys/coinos-server i docker exec -it bc bitcoin-cli createwallet coinos +docker exec -it bc bitcoin-cli createwallet external docker exec -it bc bitcoin-cli rescanblockchain docker exec -it bc bitcoin-cli generatetoaddress 500 $(docker exec -it bc bitcoin-cli getnewaddress "" "p2sh-segwit") +docker exec -it bc bitcoin-cli -rpcwallet=external generatetoaddress 5000 $(docker exec -it bc bitcoin-cli -rpcwallet=external getnewaddress "" "p2sh-segwit") docker exec -it lq elements-cli createwallet coinos +docker exec -it db keydb-cli set limit 1000000 diff --git a/test/security.test.ts b/test/security.test.ts new file mode 100644 index 00000000..9311cf5d --- /dev/null +++ b/test/security.test.ts @@ -0,0 +1,241 @@ +// Regression tests for the 2026-08 security-disclosure fixes. +// +// Runs against the live regtest stack (the app on :3119 and the redis `db`). +// Because redis isn't port-mapped to the host, run this INSIDE the app +// container so `redis://db` resolves and `localhost:3119` is the app: +// +// docker exec app sh -c 'cd /home/bun/app && bun test test/security.test.ts' +// +// Override endpoints with TEST_API / TEST_REDIS if needed. + +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import jwt from "jsonwebtoken"; +import { createClient } from "redis"; + +const API = process.env.TEST_API || "http://localhost:3119"; +const REDIS = process.env.TEST_REDIS || "redis://db"; + +let db: any; +const username = `sectest${Date.now()}${Math.floor(Math.random() * 1000)}`; +const password = "correct horse battery staple"; +let uid: string | null = null; +let pubkey: string | undefined; + +const post = (path: string, body?: any, headers: any = {}) => + fetch(`${API}${path}`, { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + +beforeAll(async () => { + db = createClient({ url: REDIS }); + await db.connect(); + + const reg = await post("/register", { user: { username, password } }); + if (reg.status !== 200) + throw new Error(`register failed: ${reg.status} ${await reg.text()}`); + + uid = await db.get(`user:${username.toLowerCase()}`); + if (uid) { + const rec = JSON.parse(await db.get(`user:${uid}`)); + pubkey = rec?.pubkey; + } +}); + +afterAll(async () => { + // Best-effort cleanup of the throwaway user's keys. + if (uid) { + const keys = [ + `user:${uid}`, + `user:${username.toLowerCase()}`, + `balance:${uid}`, + `account:${uid}`, + `${uid}:accounts`, + `${uid}:apps`, + ]; + if (pubkey) + keys.push( + `user:${pubkey}`, + `${pubkey}:follows:n`, + `${pubkey}:followers:n`, + `${pubkey}:pubkeys`, + ); + try { + await db.del(keys); + } catch {} + } + await db.quit(); +}); + +describe("S1 — websocket auth verifies the JWT signature", () => { + const secret = "s1-test-secret"; + + test("jwt.verify accepts a properly signed token", () => { + const token = jwt.sign({ id: "u1" }, secret); + expect((jwt.verify(token, secret) as any).id).toBe("u1"); + }); + + test("jwt.verify rejects an alg:none forgery that jwt.decode would trust", () => { + const forged = jwt.sign({ id: "victim" }, "", { algorithm: "none" }); + // The old code used jwt.decode() and would have trusted this uid. + expect((jwt.decode(forged) as any).id).toBe("victim"); + expect(() => jwt.verify(forged, secret)).toThrow(); + }); + + test("jwt.verify rejects a token signed with the wrong key", () => { + const forged = jwt.sign({ id: "victim" }, "attacker-key"); + expect(() => jwt.verify(forged, secret)).toThrow(); + }); +}); + +describe("S2 — passwords hashed at bcrypt cost 12", () => { + test("a freshly registered account is stored at cost 12", async () => { + expect(uid).toBeTruthy(); + const rec = JSON.parse(await db.get(`user:${uid}`)); + expect(rec.password.startsWith("$2b$12$")).toBe(true); + }); + + test("legacy cost-4 hashes are detected for transparent upgrade", async () => { + const legacy = await Bun.password.hash("x", { algorithm: "bcrypt", cost: 4 }); + const modern = await Bun.password.hash("x", { algorithm: "bcrypt", cost: 12 }); + const costOf = (h: string) => + Number.parseInt(h.match(/^\$2[aby]\$(\d{2})\$/)?.[1] ?? "0", 10); + expect(costOf(legacy)).toBe(4); + expect(costOf(modern)).toBe(12); + expect(costOf(legacy) < 12).toBe(true); + // A legacy hash must still verify (so the upgrade is transparent). + expect(await Bun.password.verify("x", legacy)).toBe(true); + }); +}); + +describe("S3 — adminpass login fails closed", () => { + test("a login with an omitted password never authenticates", async () => { + const r = await post("/login", { username }); + expect(r.status).toBe(401); + }); + + test("a login with the wrong password is rejected", async () => { + const r = await post("/login", { username, password: "not the password" }); + expect(r.status).toBe(401); + }); + + test("the real password still logs in (fix didn't break auth)", async () => { + const r = await post("/login", { username, password }); + expect(r.status).toBe(200); + const { token } = await r.json(); + expect(typeof token).toBe("string"); + }); +}); + +describe("S5 — session cookie carries httpOnly/secure/sameSite", () => { + test("Set-Cookie on login has the hardening flags", async () => { + const r = await post("/login", { username, password }); + expect(r.status).toBe(200); + const cookie = (r.headers.get("set-cookie") || "").toLowerCase(); + expect(cookie).toContain("httponly"); + expect(cookie).toContain("secure"); + expect(cookie).toContain("samesite"); + }); +}); + +describe("S6 — NWC dedup claim is atomic (SET NX)", () => { + test("two concurrent claims of one event id yield exactly one winner", async () => { + const key = `test:nwcdedup:${Date.now()}:${Math.random()}`; + const [a, b] = await Promise.all([ + db.set(key, "1", { NX: true, EX: 30 }), + db.set(key, "1", { NX: true, EX: 30 }), + ]); + const winners = [a, b].filter((x) => x === "OK").length; + expect(winners).toBe(1); + await db.del(key); + }); +}); + +describe("S7 — /upload/:type requires auth", () => { + // Bare POST (no JSON content-type): the auth preValidation runs before the + // handler, so an unauthenticated upload is rejected with 401. (Sending a JSON + // content-type here would 400 in the body parser first — a different reject + // path that doesn't exercise the auth gate.) + test("POST /upload/banner without a token is rejected", async () => { + const r = await fetch(`${API}/upload/banner`, { method: "POST" }); + expect(r.status).toBe(401); + }); + test("POST /upload/photo without a token is rejected", async () => { + const r = await fetch(`${API}/upload/photo`, { method: "POST" }); + expect(r.status).toBe(401); + }); +}); + +describe("fnd-003/004 — oversized memo is truncated, not fatal", () => { + // Mirrors the credit() choke point: memo.length > 5000 => slice, never throw. + const cap = (memo: string) => + memo && memo.length > 5000 ? memo.slice(0, 5000) : memo; + + test("a 6000-char memo is clipped to 5000", () => { + expect(cap("A".repeat(6000)).length).toBe(5000); + }); + test("a normal memo is untouched", () => { + expect(cap("thanks!")).toBe("thanks!"); + }); +}); + +describe("fnd-006 — deleteSelf guard sums every account", () => { + test("funds in a sub-account count toward the guard", async () => { + const base = `test:acct:${Date.now()}:${Math.floor(Math.random() * 1000)}`; + const sub = `${base}:sub`; + await db.rPush(`${base}:accounts`, base); + await db.rPush(`${base}:accounts`, sub); + await db.set(`balance:${base}`, "500"); // dust in main + await db.set(`balance:${sub}`, "5000000"); // savings in sub + + // Same summation the guard now performs. + const aids = await db.lRange(`${base}:accounts`, 0, -1); + let total = 0; + for (const aid of aids) { + total += Number(await db.get(`balance:${aid}`)) || 0; + total += Number(await db.get(`pending:${aid}`)) || 0; + } + + expect(total).toBe(5000500); + expect(total > 10000).toBe(true); // => deletion refused + + await db.del(`${base}:accounts`, `balance:${base}`, `balance:${sub}`); + }); +}); + +describe("S8 / fnd-007 — the spend/cash mutex serializes critical sections", () => { + test("no two sections run concurrently and FIFO order holds", async () => { + // Same shape as withBudgetLock (nwc.ts). + let lock: Promise<void> = Promise.resolve(); + const withLock = async <T>(fn: () => Promise<T>): Promise<T> => { + const prev = lock; + let release: () => void = () => {}; + lock = new Promise((res) => { + release = res; + }); + await prev; + try { + return await fn(); + } finally { + release(); + } + }; + + const order: number[] = []; + let active = 0; + let maxActive = 0; + const job = (n: number) => + withLock(async () => { + active++; + maxActive = Math.max(maxActive, active); + await new Promise((r) => setTimeout(r, 15)); + order.push(n); + active--; + }); + + await Promise.all([job(1), job(2), job(3)]); + expect(maxActive).toBe(1); + expect(order).toEqual([1, 2, 3]); + }); +}); diff --git a/test/spend-budget.test.ts b/test/spend-budget.test.ts new file mode 100644 index 00000000..81c16b2a --- /dev/null +++ b/test/spend-budget.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test"; +import { + assertWithinSpendLimit, + debitTotal, +} from "$lib/spend-budget"; + +describe("NWC budget covers the final captured tip and fees", () => { + test("a small invoice with an oversized mutable tip is rejected", () => { + expect( + debitTotal({ amount: 19, tip: 1_701_450, fee: 0, ourfee: 0 }), + ).toBe(1_701_469); + expect(() => + assertWithinSpendLimit({ + amount: 19, + tip: 1_701_450, + maxTotal: 10_000, + }), + ).toThrow("Budget exceeded"); + }); + + test("the exact remaining allowance succeeds", () => { + expect( + assertWithinSpendLimit({ + amount: 9_000, + tip: 500, + fee: 400, + ourfee: 100, + maxTotal: 10_000, + }), + ).toBe(10_000); + }); + + test("an unlimited first-party debit remains unaffected", () => { + expect( + assertWithinSpendLimit({ amount: 19, tip: 1_701_450 }), + ).toBe(1_701_469); + }); +}); |
