aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore20
-rwxr-xr-xbun.lockbbin209762 -> 242536 bytes
-rw-r--r--compose.yml.sample4
-rw-r--r--config.ts.sample25
-rw-r--r--docker/arkd.Dockerfile6
-rw-r--r--docker/base.Dockerfile26
-rw-r--r--docker/bitcoin.Dockerfile11
-rw-r--r--docker/bun-distrust.ts41
-rw-r--r--docker/bun.Dockerfile9
-rw-r--r--docker/chopsticks.Dockerfile6
-rw-r--r--docker/electrs.Dockerfile6
-rw-r--r--docker/elements.Dockerfile14
-rw-r--r--docker/kvrocks.Dockerfile6
-rw-r--r--docker/lightningd.Dockerfile13
-rw-r--r--docker/nutshell.Dockerfile57
-rw-r--r--docker/postgres.Dockerfile23
-rw-r--r--docker/strfry.Dockerfile18
-rw-r--r--docker/tigerbeetle.Dockerfile6
-rw-r--r--index.ts77
-rw-r--r--lib/app.ts159
-rw-r--r--lib/auth.ts72
-rw-r--r--lib/changeid.ts121
-rw-r--r--lib/db.ts43
-rw-r--r--lib/ecash.ts200
-rw-r--r--lib/health.ts194
-rw-r--r--lib/image-worker.ts29
-rw-r--r--lib/image.ts50
-rw-r--r--lib/invoices.ts226
generated by cgit v1.2.3 (git 2.51.0) at 2026-10-29 12:32:44 +0000 mmary='file diffstat' width='100%'>
-rw-r--r--lib/ln.ts244
-rw-r--r--lib/lnquote.ts303
-rw-r--r--lib/locations.ts3
-rw-r--r--lib/logging.ts40
-rw-r--r--lib/names.ts37
-rw-r--r--lib/nostr.ts43
-rw-r--r--lib/notes.ts1
-rw-r--r--lib/notifications.ts74
-rw-r--r--lib/nwc.ts1033
-rw-r--r--lib/payments.ts1101
-rw-r--r--lib/rates.ts53
-rw-r--r--lib/register.ts15
-rw-r--r--lib/reserved.ts31
-rw-r--r--lib/safe-fetch.ts165
-rw-r--r--lib/sockets.ts27
-rw-r--r--lib/spend-budget.ts39
-rw-r--r--lib/square.ts55
-rw-r--r--lib/types.ts1
-rw-r--r--lib/upload.ts59
-rw-r--r--lib/utils.ts13
-rw-r--r--lib/v3forward.ts133
-rw-r--r--lib/webhooks.ts8
-rw-r--r--lib/whitelist.ts5
-rw-r--r--meltguard.ts110
-rw-r--r--mintdemux.ts329
-rw-r--r--package.json80
-rw-r--r--patches/@asoltys+clightning-client+0.1.10.patch54
-rw-r--r--patches/README.md41
-rw-r--r--routes/ecash.ts146
-rw-r--r--routes/email.ts91
-rw-r--r--routes/info.ts16
-rw-r--r--routes/invoices.ts103
-rw-r--r--routes/lnurl.ts269
-rw-r--r--routes/nostr.ts98
-rw-r--r--routes/payments.ts631
-rw-r--r--routes/preimages.ts113
-rw-r--r--routes/rates.ts3
-rw-r--r--routes/square.ts92
-rw-r--r--routes/users.ts771
-rw-r--r--scripts/analytics-geo.ts134
-rw-r--r--scripts/analytics.ts309
-rw-r--r--scripts/audit-lightning-backlog.ts76
-rw-r--r--scripts/audit-missing-history.ts106
-rw-r--r--scripts/audit-wiped-history.ts85
-rw-r--r--scripts/boltz-recover-stuck.ts91
-rw-r--r--scripts/cln_schema.sql1783
-rwxr-xr-xscripts/close-dead-channels.sh276
-rw-r--r--scripts/dedup-invoices.ts86
-rw-r--r--scripts/kvrocks-merge-lowmem.conf19
-rw-r--r--scripts/ln-liquid-arb.ts126
-rw-r--r--scripts/merge-kvrocks-archives.ts321
-rw-r--r--scripts/migrate.py74
-rwxr-xr-xscripts/monitor-ln-socket.sh90
-rw-r--r--scripts/pay-hard.ts159
-rwxr-xr-xscripts/payment-monitor.sh210
-rw-r--r--scripts/rebalance-economics.ts96
-rwxr-xr-xsetup.sh14
-rw-r--r--test/security.test.ts241
-rw-r--r--test/spend-budget.test.ts38
88 files changed, 11330 insertions, 1279 deletions
diff --git a/.gitignore b/.gitignore
index 8fcee169..62d629bd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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/
diff --git a/bun.lockb b/bun.lockb
index 480a5a31..1a005144 100755
--- a/bun.lockb
+++ b/bun.lockb
Binary files differ
diff --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: "support@coinos.io",
- 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"]
diff --git a/index.ts b/index.ts
index a401d3d3..17f776ec 100644
--- a/index.ts
+++ b/index.ts
@@ -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);
diff --git a/lib/app.ts b/lib/app.ts
index 1beedcd4..b2c3c1a7 100644
--- a/lib/app.ts
+++ b/lib/app.ts
@@ -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;
+}
diff --git a/lib/db.ts b/lib/db.ts
index 56921adb..e03fb810 100644
--- a/lib/db.ts
+++ b/lib/db.ts
@@ -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);
}
}
diff --git a/lib/ln.ts b/lib/ln.ts
index c4ab40fd..e9c4af6a 100644
--- a/lib/ln.ts
+++ b/lib/ln.ts
@@ -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) => {
}