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
-rw-r--r--lib/lightning.ts383
-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: "[email protected]",
- nostrKey: "nsec13wekuqstlsyfrzm369yh6vjaphslq5pq9kze74acqqynjm8lvpms4qzsx9",
+ // SECURITY: generate fresh keys — never deploy these sample values. These are
+ // the server NWC wallet-service key and the zap-receipt signer.
+ // node -e "import('nostr-tools').then(n=>console.log(n.nip19.nsecEncode(n.generateSecretKey())))"
+ nostrKey: "nsec1replacewithafreshlygeneratedkeydonotusethissamplevalue0",
+ nostrKey2: "nsec1replacewithafreshlygeneratedkeydonotusethissamplevalue1",
mintUrl: "http://mint:3338",
+ square: {
+ environment: SquareEnvironment.Sandbox,
+ appId: "sandbox-sq0adb-9QbyDZeHTPc_RhFSEr_SaQ",
+ url: "https://connect.squareupsandbox.com/",
+ scopes: ["MERCHANT_PROFILE_READ", "ORDERS_READ", "PAYMENTS_READ"],
+ },
};
diff --git a/docker/arkd.Dockerfile b/docker/arkd.Dockerfile
new file mode 100644
index 00000000..08deb403
--- /dev/null
+++ b/docker/arkd.Dockerfile
@@ -0,0 +1,6 @@
+FROM asoltys/arkd AS source
+
+FROM ghcr.io/coinos/base
+USER root
+COPY --from=source /app/arkd /app/ark /usr/local/bin/
+ENTRYPOINT ["arkd"]
diff --git a/docker/base.Dockerfile b/docker/base.Dockerfile
new file mode 100644
index 00000000..594b0d70
--- /dev/null
+++ b/docker/base.Dockerfile
@@ -0,0 +1,26 @@
+# docker build -f docker/base.Dockerfile -t ghcr.io/coinos/base docker/
+FROM debian:trixie-slim
+
+ARG BUN_VERSION=1.4.3
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ curl unzip ca-certificates wget \
+ libpq5 libsodium23 libatomic1 jq socat inotify-tools \
+ liblmdb0 libsecp256k1-2 tini \
+ && curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" \
+ && mv /root/.bun/bin/bun /usr/local/bin/bun \
+ && ln -s /usr/local/bin/bun /usr/local/bin/bunx \
+ && rm -rf /root/.bun \
+ && apt-get purge -y curl unzip \
+ && apt-get autoremove -y \
+ && rm -rf /var/lib/apt/lists/* \
+ /usr/share/doc /usr/share/man /usr/share/locale \
+ /usr/share/info /var/log/* /tmp/*
+
+COPY bun-distrust.ts /tmp/
+RUN cp /usr/local/bin/bun /tmp/bun \
+ && /tmp/bun /tmp/bun-distrust.ts /usr/local/bin/bun \
+ && rm /tmp/bun /tmp/bun-distrust.ts
+
+RUN groupadd -g 1000 bun && useradd -u 1000 -g bun -m -s /bin/sh bun
+WORKDIR /home/bun/app
diff --git a/docker/bitcoin.Dockerfile b/docker/bitcoin.Dockerfile
new file mode 100644
index 00000000..d222d0a5
--- /dev/null
+++ b/docker/bitcoin.Dockerfile
@@ -0,0 +1,11 @@
+FROM ghcr.io/coinos/base AS builder
+ARG VERSION=30.2
+USER root
+RUN wget -qO- https://bitcoincore.org/bin/bitcoin-core-$VERSION/bitcoin-$VERSION-x86_64-linux-gnu.tar.gz | tar -xz --strip-components=1 -C /usr/local
+
+FROM ghcr.io/coinos/base
+USER root
+COPY --from=builder /usr/local/bin/bitcoind /usr/local/bin/bitcoin-cli /usr/local/bin/
+RUN usermod -l bitcoin bun && usermod -d /home/bitcoin -m bitcoin && groupmod -n bitcoin bun
+USER bitcoin
+ENTRYPOINT ["bitcoind"]
diff --git a/docker/bun-distrust.ts b/docker/bun-distrust.ts
new file mode 100644
index 00000000..df27f619
--- /dev/null
+++ b/docker/bun-distrust.ts
@@ -0,0 +1,41 @@
+// Bun (1.4+) compiles Mozilla's root store into its binary as DER, and NODE_USE_SYSTEM_CA
+// only adds to it. Until a Bun release drops a distrusted root, overwrite its
+// DER in place with a same-length self-signed placeholder whose key was thrown
+// away (zeroing it instead makes every TLS handshake fail).
+//
+// Usage: bun bun-distrust.ts <path to a bun binary> (not the one running it)
+import { readFileSync, writeFileSync } from "node:fs";
+
+const distrust = [
+ {
+ // eMudhra intermediate key compromise, CVE-2026-111943.
+ // sha256 86:A1:EC:BA:08:9C:4A:8D:3B:BE:27:34:C6:12:BA:34:1D:81:3E:04:3C:F9:E8:A8:62:CD:5C:57:A3:6B:BE:6B
+ name: "emSign ECC Root CA - G3",
+ der: "MIICTjCCAdOgAwIBAgIKPPYHqWhwDtqLhDAKBggqhkjOPQQDAzBrMQswCQYDVQQGEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0gRzMwHhcNMTgwMjE4MTgzMDAwWhcNNDMwMjE4MTgzMDAwWjBrMQswCQYDVQQGEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0gRzMwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQjpQy4LRL1KPOxst3iAhKAnjlfSU2fySU0WXTsuwYc58Byr+iuL+FBVIcUqEqy6HyC5ltqtdyzdc6LBtCGI79G1Y4PPwT01xySfvalY8L1X44uT6EYGQIrMgqCZH0Wk9GjQjBAMB0GA1UdDgQWBBR8XQKEE9TMipuBzhccLikenEhjQjAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAwNpADBmAjEAvvNhzwIQHWSVB7gYboiFBS+DCBeQyh+KTOgNG3qxrdWBCUfvO6wIBHxcmbHtRwfSAjEAnbpV/KlK6O3t5nYBQnvI+GDZjVGLVTv7jHvrZQnD+JbNR6iC8hZVdyR+EhCVBCyj",
+ placeholder:
+ "MIICTjCCAdSgAwIBAgIUfFrc+EjEsB3lBo+k60ijjiGPT/IwCgYIKoZIzj0EAwIwXjEfMB0GA1UEAwwWeHh4eHh4eHh4eHh4eHh4eHh4eHh4eDE7MDkGA1UECgwyY29pbm9zIHJldm9rZWQgZW1TaWduIEVDQyBSb290IENBIC0gRzMgcGxhY2Vob2xkZXIwHhcNMjYxMDIxMTI1NjQzWhcNMzYxMDE4MTI1NjQzWjBeMR8wHQYDVQQDDBZ4eHh4eHh4eHh4eHh4eHh4eHh4eHh4MTswOQYDVQQKDDJjb2lub3MgcmV2b2tlZCBlbVNpZ24gRUNDIFJvb3QgQ0EgLSBHMyBwbGFjZWhvbGRlcjB2MBAGByqGSM49AgEGBSuBBAAiA2IABBBYruwA2K1haT213dKQAOYRE5Gdy1PMXpM7V57BH6VwUToz9hkWTCXqeOr7/NctfY1fpqbT+tcKl0/OnTVwZuO3ePtUkTvREL/fuVKd9LTU/eTMT1Pqf2GQfVTmkS8C76NTMFEwHQYDVR0OBBYEFARKTDRI5W1qeguIYelguHqh3gvBMB8GA1UdIwQYMBaAFARKTDRI5W1qeguIYelguHqh3gvBMA8GA1UdEwEB/wQFMAMBAf8wCgYIKoZIzj0EAwIDaAAwZQIwIW/6zsZDBk7LGTetfJJzKqXbhsvMo+4ykb8JSHjLn5zoIsuyBPGvox71gdDPcttgAjEAu3xr0zjPLGQnlM4cwgv6mKTgiiXbt7HneYNN2hu/DvSuCj99c0KuGKEyfHGqotCL",
+ },
+];
+
+const bin = process.argv[2];
+const buf = readFileSync(bin);
+let patched = false;
+
+for (const { name, der: d, placeholder } of distrust) {
+ const der = Buffer.from(d, "base64");
+ const repl = Buffer.from(placeholder, "base64");
+ if (repl.length !== der.length)
+ throw new Error(`${name}: placeholder is ${repl.length} bytes, need ${der.length}`);
+ const at = buf.indexOf(der);
+ if (at < 0) {
+ console.log(`${name}: not in ${bin}, nothing to do`);
+ continue;
+ }
+ if (buf.indexOf(der, at + 1) >= 0)
+ throw new Error(`${name}: more than one copy in ${bin}`);
+ repl.copy(buf, at);
+ patched = true;
+ console.log(`${name}: replaced at offset ${at}`);
+}
+
+if (patched) writeFileSync(bin, buf);
diff --git a/docker/bun.Dockerfile b/docker/bun.Dockerfile
new file mode 100644
index 00000000..8ca4aefb
--- /dev/null
+++ b/docker/bun.Dockerfile
@@ -0,0 +1,9 @@
+# oven/bun with distrusted roots taken out of its built-in store (bun-distrust.ts)
+# docker build -f docker/bun.Dockerfile --build-arg BUN_VERSION=1.4.3 -t ghcr.io/coinos/bun:1.4.3 docker/
+ARG BUN_VERSION=1.4.3
+FROM oven/bun:${BUN_VERSION}
+
+COPY bun-distrust.ts /tmp/
+RUN cp /usr/local/bin/bun /tmp/bun \
+ && /tmp/bun /tmp/bun-distrust.ts /usr/local/bin/bun \
+ && rm /tmp/bun /tmp/bun-distrust.ts
diff --git a/docker/chopsticks.Dockerfile b/docker/chopsticks.Dockerfile
new file mode 100644
index 00000000..c4cc3f27
--- /dev/null
+++ b/docker/chopsticks.Dockerfile
@@ -0,0 +1,6 @@
+FROM ghcr.io/vulpemventures/nigiri-chopsticks AS source
+
+FROM ghcr.io/coinos/base
+USER root
+COPY --from=source /app /usr/local/bin/chopsticks
+ENTRYPOINT ["chopsticks"]
diff --git a/docker/electrs.Dockerfile b/docker/electrs.Dockerfile
new file mode 100644
index 00000000..6a16942d
--- /dev/null
+++ b/docker/electrs.Dockerfile
@@ -0,0 +1,6 @@
+FROM ghcr.io/vulpemventures/electrs AS source
+
+FROM ghcr.io/coinos/base
+USER root
+COPY --from=source /build/electrs /build/electrs
+ENTRYPOINT ["/build/electrs"]
diff --git a/docker/elements.Dockerfile b/docker/elements.Dockerfile
new file mode 100644
index 00000000..3351be82
--- /dev/null
+++ b/docker/elements.Dockerfile
@@ -0,0 +1,14 @@
+FROM ghcr.io/coinos/base AS builder
+ARG VERSION=23.3.4
+ARG SHA256=a758151ace3f21008ab162067ffce9e0e526a1b5d55995e2c30d9cd7ccda41a0
+USER root
+RUN wget -qO /tmp/elements.tar.gz https://github.com/ElementsProject/elements/releases/download/elements-$VERSION/elements-$VERSION-x86_64-linux-gnu.tar.gz \
+ && echo "$SHA256 /tmp/elements.tar.gz" | sha256sum -c - \
+ && tar -xz --strip-components=1 -C /usr/local -f /tmp/elements.tar.gz && rm /tmp/elements.tar.gz
+
+FROM ghcr.io/coinos/base
+USER root
+COPY --from=builder /usr/local/bin/elementsd /usr/local/bin/elements-cli /usr/local/bin/
+RUN usermod -l elements bun && usermod -d /home/elements -m elements && groupmod -n elements bun
+USER elements
+ENTRYPOINT ["elementsd"]
diff --git a/docker/kvrocks.Dockerfile b/docker/kvrocks.Dockerfile
new file mode 100644
index 00000000..c486a1b2
--- /dev/null
+++ b/docker/kvrocks.Dockerfile
@@ -0,0 +1,6 @@
+FROM apache/kvrocks AS source
+
+FROM ghcr.io/coinos/base
+USER root
+COPY --from=source /usr/bin/kvrocks /usr/bin/kvrocks
+ENTRYPOINT ["kvrocks"]
diff --git a/docker/lightningd.Dockerfile b/docker/lightningd.Dockerfile
new file mode 100644
index 00000000..116852c5
--- /dev/null
+++ b/docker/lightningd.Dockerfile
@@ -0,0 +1,13 @@
+FROM elementsproject/lightningd:v26.06.9 AS source
+
+FROM ghcr.io/coinos/base
+USER root
+COPY --from=source /usr/local/ /usr/local/
+COPY --from=source /usr/bin/bitcoin-cli /usr/bin/
+COPY --from=source /entrypoint.sh /entrypoint.sh
+
+ENV LIGHTNINGD_DATA=/root/.lightning
+ENV LIGHTNINGD_NETWORK=bitcoin
+EXPOSE 9735 9835
+VOLUME ["/root/.lightning"]
+ENTRYPOINT ["/entrypoint.sh"]
diff --git a/docker/nutshell.Dockerfile b/docker/nutshell.Dockerfile
new file mode 100644
index 00000000..ddf5098f
--- /dev/null
+++ b/docker/nutshell.Dockerfile
@@ -0,0 +1,57 @@
+# Nutshell (cashu mint) on the coinos base image.
+#
+# Source comes from our fork, github.com/coinos/nutshell, at NUTSHELL_REF:
+# limited-invoices-<ver> → `mint`
+# panic-mode-limited-invoices-<ver> → `mint-old`
+#
+# docker build -f docker/nutshell.Dockerfile \
+# --build-arg NUTSHELL_REF=limited-invoices-0.21.0 \
+# -t ghcr.io/coinos/nutshell:0.21.0-limited-invoices .
+#
+# The interpreter comes from python:3.12-slim-trixie rather than trixie's own
+# python3 (3.13): coincurve 20 publishes cp312 wheels but no cp313, so 3.13
+# would mean compiling the libsecp256k1 bindings from source. The builder is
+# the same Debian release as ghcr.io/coinos/base, so /usr/local (interpreter +
+# site-packages) copies over ABI-compatible, the same way lightningd.Dockerfile
+# lifts /usr/local out of the upstream CLN image.
+
+FROM python:3.12-slim-trixie AS build
+
+ARG NUTSHELL_REPO=https://github.com/coinos/nutshell.git
+ARG NUTSHELL_REF=limited-invoices-0.21.0
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ git ca-certificates curl build-essential pkg-config libffi-dev libpq-dev \
+ && rm -rf /var/lib/apt/lists/*
+
+RUN curl -sSL https://install.python-poetry.org | python3 - --version 2.3.2
+ENV PATH="/root/.local/bin:$PATH" \
+ POETRY_VIRTUALENVS_CREATE=false \
+ PIP_NO_CACHE_DIR=1
+
+RUN git clone --depth 1 --branch "$NUTSHELL_REF" "$NUTSHELL_REPO" /app \
+ && rm -rf /app/.git
+WORKDIR /app
+
+# editable root install → /app must ship in the runtime image
+RUN poetry install --without dev --no-root \
+ && poetry install --only-root \
+ && find /usr/local/lib/python3.12 -name __pycache__ -prune -exec rm -rf {} +
+
+FROM ghcr.io/coinos/base
+
+USER root
+# shared libs the CPython build and its wheels link against that base lacks
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ libexpat1 libffi8 libsqlite3-0 libbz2-1.0 liblzma5 zlib1g libssl3t64 \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY --from=build /usr/local /usr/local
+COPY --from=build /app /app
+
+WORKDIR /app
+# runtime config is nutshell's /app/.env (bind-mounted, takes precedence over
+# container env); data lives under CASHU_DIR (/app/data/mint)
+EXPOSE 3338
+ENTRYPOINT ["tini", "--"]
+CMD ["mint"]
diff --git a/docker/postgres.Dockerfile b/docker/postgres.Dockerfile
new file mode 100644
index 00000000..da3b35ff
--- /dev/null
+++ b/docker/postgres.Dockerfile
@@ -0,0 +1,23 @@
+FROM postgres:18 AS pg
+FROM ghcr.io/coinos/base
+USER root
+COPY --from=pg /usr/lib/postgresql /usr/lib/postgresql
+COPY --from=pg /usr/share/postgresql /usr/share/postgresql
+COPY --from=pg /usr/local/bin/docker-entrypoint.sh /usr/local/bin/
+COPY --from=pg /lib/x86_64-linux-gnu/libpq.so.5* /lib/x86_64-linux-gnu/
+COPY --from=pg /usr/local/bin/gosu /usr/local/bin/
+RUN ln -s /usr/lib/postgresql/18/bin/* /usr/local/bin/ \
+ && apt-get update && apt-get install -y --no-install-recommends libicu76 libreadline8t64 libxml2 libxslt1.1 libssl3t64 liblz4-1 libzstd1 libnuma1 liburing2 locales \
+ && rm -rf /var/lib/apt/lists/* \
+ && sed -i '/en_US.UTF-8/s/^# //' /etc/locale.gen && locale-gen \
+ && groupadd -r postgres --gid=999 && useradd -r -g postgres --uid=999 --home-dir=/var/lib/postgresql --shell=/bin/bash postgres \
+ && mkdir -p /var/run/postgresql /var/lib/postgresql /docker-entrypoint-initdb.d \
+ && chown -R postgres:postgres /var/run/postgresql /var/lib/postgresql
+WORKDIR /
+ENV PGDATA=/var/lib/postgresql/data
+ENV PG_MAJOR=18
+STOPSIGNAL SIGINT
+USER postgres
+EXPOSE 5432
+ENTRYPOINT ["docker-entrypoint.sh"]
+CMD ["postgres"]
diff --git a/docker/strfry.Dockerfile b/docker/strfry.Dockerfile
new file mode 100644
index 00000000..dff5448d
--- /dev/null
+++ b/docker/strfry.Dockerfile
@@ -0,0 +1,18 @@
+FROM debian:trixie-slim AS builder
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ git g++ make pkg-config ca-certificates \
+ liblmdb-dev libflatbuffers-dev libsecp256k1-dev \
+ libb2-dev libzstd-dev libssl-dev zlib1g-dev \
+ && rm -rf /var/lib/apt/lists/*
+RUN git clone --depth 1 https://github.com/hoytech/strfry.git /build
+WORKDIR /build
+RUN git submodule update --init
+RUN make setup-golpe && make -j$(nproc)
+RUN strip --strip-unneeded strfry
+
+FROM ghcr.io/coinos/base
+USER root
+WORKDIR /app
+COPY --from=builder /build/strfry /app/strfry
+ENTRYPOINT ["tini", "--"]
+CMD ["/app/strfry", "relay"]
diff --git a/docker/tigerbeetle.Dockerfile b/docker/tigerbeetle.Dockerfile
new file mode 100644
index 00000000..a85ad165
--- /dev/null
+++ b/docker/tigerbeetle.Dockerfile
@@ -0,0 +1,6 @@
+FROM ghcr.io/tigerbeetle/tigerbeetle AS source
+
+FROM ghcr.io/coinos/base
+USER root
+COPY --from=source /tigerbeetle /usr/local/bin/tigerbeetle
+ENTRYPOINT ["tigerbeetle"]
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) => {
}
return { follows, followers };
- } catch (e) {
- console.log(e);
- }
+ } catch (e) {}
};
export const getNostrUser = async (key) => {
@@ -231,7 +255,6 @@ export const q = async (f) => {
pool.subscribeMany(["wss://relay.primal.net"], [f], {
onevent(e) {
r.push(e);
- coinos.publish(e);
},
oneose() {
resolve(r);
diff --git a/lib/notes.ts b/lib/notes.ts
index 84d157ee..400e5978 100644
--- a/lib/notes.ts
+++ b/lib/notes.ts
@@ -196,7 +196,6 @@ export const parseContent = ({
return [`nostr:${type}`, bech32, { ...value, entity }];
} catch (e) {
- console.log(e);
// pass
}
}
diff --git a/lib/notifications.ts b/lib/notifications.ts
index d5b4f73f..fe245c61 100644
--- a/lib/notifications.ts
+++ b/lib/notifications.ts
@@ -4,11 +4,11 @@ import ln from "$lib/ln";
import { err, l, warn } from "$lib/logging";
import { mail, templates } from "$lib/mail";
import mqtt from "$lib/mqtt";
-import { publish, serverSecret } from "$lib/nostr";
+import { encryptPayload, publish, serverSecret2 } from "$lib/nostr";
import { emit } from "$lib/sockets";
import { f, fiat, fmt, getUser, link, nada, t } from "$lib/utils";
import { hexToBytes } from "@noble/hashes/utils";
-import { finalizeEvent, nip04 } from "nostr-tools";
+import { finalizeEvent } from "nostr-tools";
import webpush from "web-push";
if (config.vapid) {
@@ -19,6 +19,22 @@ if (config.vapid) {
);
}
+
+// LNURL-pay invoices carry the raw metadata array as their memo when the
+// payer left no comment, e.g. [["text/plain","Paying [email protected]"],...].
+// Receipts should show just the human-readable text.
+const printerMemo = (memo) => {
+ if (typeof memo !== "string" || !memo.startsWith("[")) return memo;
+ try {
+ const meta = JSON.parse(memo);
+ if (Array.isArray(meta)) {
+ const plain = meta.find((m) => Array.isArray(m) && m[0] === "text/plain");
+ if (plain) return plain[1];
+ }
+ } catch (e) {}
+ return memo;
+};
+
export const notify = async (p, user, withdrawal) => {
emit(user.id, "payment", p);
let { username } = user;
@@ -74,7 +90,8 @@ export const notify = async (p, user, withdrawal) => {
if (!mqtt.connected) await mqtt.reconnect();
mqtt.publish(
username,
- `pay:${p.amount}:${p.tip}:${p.rate}:${p.created}:${p.id}:${p.memo}:${p.items}`,
+ `pay:${p.amount}:${p.tip}:${p.rate}:${p.created}:${p.id}:${printerMemo(p.memo)}:${p.items}`,
+ { qos: 1 },
);
}
};
@@ -84,11 +101,23 @@ export const nwcNotify = async (p) => {
const user = await getUser(p.uid);
const pubkeys = await db.sMembers(`${user.id}:apps`);
if (pubkeys.length) {
- let payment_hash = "";
- if (p.type === "lightning") ({ payment_hash } = await ln.decode(p.hash));
+ let payment_hash = p.payment_hash || "";
+ if (!payment_hash && (p.type === "lightning" || p.type === "bolt12")) {
+ try {
+ const d = await ln.decode(p.hash);
+ // bolt11 decodes expose payment_hash, bolt12 invoices
+ // invoice_payment_hash
+ payment_hash = d.payment_hash || d.invoice_payment_hash || "";
+ } catch (e) {}
+ }
for (const pubkey of pubkeys) {
- const { notify } = await g(`app:${pubkey}`);
- if (!notify) continue;
+ // The app record may be missing/not-yet-written: a pubkey lands in
+ // `${uid}:apps` before its `app:<pubkey>` is persisted (creation race when
+ // adding an NWC connection), or a stale entry outlived its record. Guard
+ // the lookup — destructuring `notify` off null threw and, since the catch
+ // wraps the whole loop, aborted notifications for ALL the user's apps.
+ const app = await g(`app:${pubkey}`);
+ if (!app?.notify) continue;
l("notifying", pubkey, p.type, p.amount);
const notification = {
@@ -108,22 +137,33 @@ export const nwcNotify = async (p) => {
notification,
});
- const content = await nip04.encrypt(serverSecret, pubkey, payload);
+ // NIP-47: a wallet supporting both schemes publishes each notification
+ // twice — kind 23196 nip04-encrypted, kind 23197 nip44-encrypted
+ for (const [scheme, kind] of [
+ ["nip04", 23196],
+ ["nip44_v2", 23197],
+ ] as const) {
+ const content = await encryptPayload(
+ scheme,
+ serverSecret2,
+ pubkey,
+ payload,
+ );
- const unsigned = {
- content,
- tags: [["p", pubkey]],
- kind: 23196,
- created_at: Math.floor(Date.now() / 1000),
- };
+ const unsigned = {
+ content,
+ tags: [["p", pubkey]],
+ kind,
+ created_at: Math.floor(Date.now() / 1000),
+ };
- const event = finalizeEvent(unsigned, hexToBytes(serverSecret));
+ const event = finalizeEvent(unsigned, hexToBytes(serverSecret2));
- publish(event).catch(nada);
+ publish(event).catch(nada);
+ }
}
}
} catch (e) {
- console.log(e);
warn("nwc notification failed", e.message);
}
};
diff --git a/lib/nwc.ts b/lib/nwc.ts
index c659141a..21bab0a7 100644
--- a/lib/nwc.ts
+++ b/lib/nwc.ts
@@ -1,24 +1,56 @@
import config from "$config";
-import { db, g } from "$lib/db";
-import { generate } from "$lib/invoices";
+import { archive, db, g, gf } from "$lib/db";
+import { generate, getUserOffer } from "$lib/invoices";
import ln from "$lib/ln";
import { err, l, warn } from "$lib/logging";
-import { handleZap, serverPubkey, serverSecret } from "$lib/nostr";
+import {
+ decryptPayload,
+ encryptPayload,
+ encryptionSchemes,
+ handleZap,
+ serverPubkey,
+ serverPubkey2,
+ serverSecret,
+ serverSecret2,
+} from "$lib/nostr";
import { sendInternal, sendKeysend, sendLightning } from "$lib/payments";
import { fail, getInvoice, sleep } from "$lib/utils";
import rpc from "@coinos/rpc";
-import { hexToBytes } from "@noble/hashes/utils";
+import { sha256 } from "@noble/hashes/sha256";
+import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
import { Relay } from "nostr";
-import { finalizeEvent, nip04 } from "nostr-tools";
+import { finalizeEvent } from "nostr-tools";
import type { UnsignedEvent } from "nostr-tools";
+const serverKeys = {
+ [serverPubkey]: serverSecret,
+ [serverPubkey2]: serverSecret2,
+};
+
const result = (result) => ({ result });
const error = (error) => ({ error });
+// Every rejected NWC payment used to vanish without a trace: the error went
+// back to the client and nothing was logged, so a user reporting "zaps over
+// 1k fail" left no evidence server-side. One warn line per failure: who,
+// which connection, how much, and why.
+const payFail = (method, app, user, amount, err) => {
+ warn(
+ `nwc ${method} failed`,
+ user?.username,
+ String(app?.pubkey || "").slice(0, 12),
+ amount,
+ err?.error?.code,
+ String(err?.error?.message || "").slice(0, 160),
+ );
+ return err;
+};
const bc = rpc(config.bitcoin);
const methods = [
"pay_keysend",
"pay_invoice",
+ "pay",
+ "receive",
"get_balance",
"get_info",
"make_invoice",
@@ -27,155 +59,626 @@ const methods = [
];
const week = 7 * 24 * 60 * 60;
+const nwcEventMaxAgeSeconds = 5 * 60;
+// Tolerated clock skew for future-dated events. Events further ahead than this
+// are rejected so a captured, future-dated signed event can't be replayed later.
+const nwcClockSkewSeconds = 60;
+const handledKey = "handled:nwc";
+
+// Per-pubkey rate limiting for NWC requests. Raised 5 -> 60/min: normal client
+// usage (a balance check or two + a payment + a few lookup_invoice reconciles)
+// blows past 5 trivially, and over-limit requests were dropped SILENTLY with no
+// reply — indistinguishable from lost replies. 60/min (1/s avg) is ample
+// headroom for interactive use while still capping abuse.
+const nwcRateLimit = 60; // max requests per minute per pubkey
+const nwcRateWindow = 60 * 1000; // 1 minute in ms
+const nwcRequestTimes: Map<string, number[]> = new Map();
+
+// Per-app async mutex for spends. checkBudget reads `${pubkey}:payments` and the
+// spend is only recorded after the payment settles, so N concurrent requests on
+// one connection would each read the same stale "spent" total and all pass —
+// up to N × max_amount out. Serializing the check + send + record per app closes
+// that window (same shape as withLimitLock for the asset-type limits).
+const budgetLocks: Map<string, Promise<void>> = new Map();
+const withBudgetLock = async <T>(key: string, fn: () => Promise<T>): Promise<T> => {
+ const prev = budgetLocks.get(key) || Promise.resolve();
+ let release: () => void = () => {};
+ const next = new Promise<void>((res) => {
+ release = res;
+ });
+ budgetLocks.set(key, prev.then(() => next));
+ await prev;
+ try {
+ return await fn();
+ } finally {
+ release();
+ }
+};
+const budgetedMethods = new Set(["pay_invoice", "pay", "pay_keysend"]);
+const nwcDormancyWindow = 30 * 24 * 60 * 60 * 1000;
+
+const isDormantNwcApp = async (app) => {
+ const cutoff = Date.now() - nwcDormancyWindow;
+ let lastActivity = Number(app.created) || 0;
+
+ // Payment indexes are newest-first. Check a few entries because an archived
+ // or missing record must not make a recently active connection look dormant.
+ const pids = await db.lRange(`${app.pubkey}:payments`, 0, 19);
+ for (const pid of pids) {
+ const payment = await gf(`payment:${pid}`);
+ if (payment?.created)
+ lastActivity = Math.max(lastActivity, Number(payment.created));
+ }
+
+ return lastActivity < cutoff;
+};
export default () => {
- const r = new Relay("ws://sf:7777");
+ let r: any;
+ let heartbeatInterval: any;
+ let infoCheckInterval: any;
- r.on("open", async (_) => {
- r.subscribe("nwc", { kinds: [23194], "#p": [serverPubkey] });
- const info = await finalizeEvent(
+ // Publish the kind 13194 info event and CONFIRM the relay stored it. The
+ // long-lived NWC socket's send is fire-and-forget (await wait_connected; ws.send)
+ // and was called without await — so a send that rejected (e.g. ws in CLOSING
+ // state during a reconnect) was silently dropped while we logged success,
+ // leaving clients with "no info event" until a manual republish. Publish over a
+ // fresh short-lived socket and wait for the relay's OK, retrying a few times.
+ async function publishInfo(pk: string, sk: string) {
+ const info = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: 13194,
tags: [
- ["p", serverPubkey],
+ ["p", pk],
["notifications", "payment_received payment_sent"],
+ ["encryption", encryptionSchemes],
],
content: methods.join(" "),
},
- hexToBytes(serverSecret),
+ hexToBytes(sk),
);
- r.send(["EVENT", info]);
- });
+ // No success log: this now runs every 2 minutes per pubkey, so a "published"
+ // line each time is pure noise. Only the failure paths below log.
+ for (let attempt = 1; attempt <= 3; attempt++) {
+ if (await sendEventConfirmed(info)) return true;
+ warn(`nwc 13194 publish not confirmed (attempt ${attempt})`, pk);
+ await sleep(1000);
+ }
+ err("nwc failed to publish 13194 info event after retries", pk);
+ return false;
+ }
+
+ // Send an event to strfry over a fresh socket and resolve true once the relay
+ // replies OK=accepted. Independent of the long-lived NWC socket, whose
+ // fire-and-forget send proved unreliable for the persisted info event.
+ function sendEventConfirmed(ev: any, timeoutMs = 5000): Promise<boolean> {
+ return new Promise((resolve) => {
+ let ws: WebSocket;
+ let done = false;
+ const finish = (ok: boolean) => {
+ if (done) return;
+ done = true;
+ try { ws.close(); } catch (_) {}
+ resolve(ok);
+ };
+ try {
+ ws = new WebSocket("ws://sf:7777");
+ } catch (_) {
+ return resolve(false);
+ }
+ ws.onopen = () => ws.send(JSON.stringify(["EVENT", ev]));
+ ws.onmessage = (e: any) => {
+ try {
+ const m = JSON.parse(e.data);
+ if (m[0] === "OK" && m[1] === ev.id) finish(!!m[2]);
+ } catch (_) {}
+ };
+ ws.onerror = () => finish(false);
+ setTimeout(() => finish(false), timeoutMs);
+ });
+ }
+
+ // The kind 13194 info event (NWC capability advertisement) is REPLACEABLE, so
+ // it's meant to persist on the relay. NWC clients (Alby Go, Lightning Piggies,
+ // etc.) fetch it before they'll talk to the wallet — without it they report
+ // "no info event" and show an unknown balance.
+ //
+ // We used to query the relay and publish only the missing pubkeys, but the
+ // stored event kept vanishing between checks (cause never pinned down), which
+ // left clients broken until the next hourly check. Republishing both server
+ // info events unconditionally on a short interval is cheap (a replaceable
+ // event, so it just overwrites in place) and self-heals immediately.
+ // publishInfo() uses its own short-lived socket, so this works regardless of
+ // the long-lived nwc socket's state.
+ function ensureInfo() {
+ publishInfo(serverPubkey, serverSecret);
+ publishInfo(serverPubkey2, serverSecret2);
+ }
+
+ function connect() {
+ if (heartbeatInterval) clearInterval(heartbeatInterval);
+ if (infoCheckInterval) clearInterval(infoCheckInterval);
+ r = new Relay("ws://sf:7777", { reconnect: false });
+
+ r.on("open", async (_) => {
+ l("nwc connected to strfry");
+
+ // Periodically check if connection is alive
+ heartbeatInterval = setInterval(() => {
+ try {
+ if (!r?.ws || r.ws.readyState !== 1) {
+ warn("nwc heartbeat: connection dead, reconnecting");
+ clearInterval(heartbeatInterval);
+ try { r.close(); } catch (_) {}
+ setTimeout(connect, 1000);
+ }
+ } catch (_) {
+ warn("nwc heartbeat: error, reconnecting");
+ clearInterval(heartbeatInterval);
+ setTimeout(connect, 1000);
+ }
+ }, 30000);
+ r.subscribe("nwc", { kinds: [23194], "#p": [serverPubkey, serverPubkey2], since: Math.floor(Date.now() / 1000) - nwcEventMaxAgeSeconds });
+
+ // Publish the 13194 info events immediately, then keep republishing every
+ // 2 minutes so a vanished event is never missing for long.
+ ensureInfo();
+ infoCheckInterval = setInterval(ensureInfo, 2 * 60 * 1000);
+ });
- r.on("event", async (sub, ev) => {
+
+ r.on("close", () => {
+ warn("nwc strfry connection lost, reconnecting in 5s");
+ if (heartbeatInterval) clearInterval(heartbeatInterval);
+ if (infoCheckInterval) clearInterval(infoCheckInterval);
+ setTimeout(connect, 5000);
+ });
+
+ r.on("error", () => {});
+
+ r.on("event", async (sub, ev) => {
try {
if (sub !== "nwc") return;
- if (await db.sIsMember("handled", ev.id)) return;
- db.sAdd("handled", ev.id);
+ const now = Math.floor(Date.now() / 1000);
+ // Reject events outside the accepted window in BOTH directions. Old events
+ // are stale; future-dated ones (beyond a small skew) could otherwise be
+ // replayed indefinitely once a time-bounded dedup entry expired.
+ if (ev.created_at) {
+ if (now - ev.created_at > nwcEventMaxAgeSeconds) return;
+ if (ev.created_at - now > nwcClockSkewSeconds) return;
+ }
+ // Atomic dedup claim. SET NX is a single-command check-and-set, so two
+ // concurrent deliveries of the same signed event cannot both pass — the
+ // previous zScore-then-(unawaited)-zAdd let duplicate relay deliveries each
+ // reach handle() and pay twice. The claim outlives the accepted age window
+ // so a future-dated event can't be replayed after it would have expired.
+ if (
+ !(await db.set(`${handledKey}:${ev.id}`, "1", {
+ NX: true,
+ EX: 2 * nwcEventMaxAgeSeconds,
+ }))
+ )
+ return;
+
let { content, pubkey } = ev;
+ const pk = ev.tags.find((t) => t[0] === "p")[1];
+ const sk = serverKeys[pk];
+ // Clients mark nip44 requests with an `encryption` tag; absence means
+ // legacy nip04 (NIP-47). Replies must use the same scheme.
+ const scheme =
+ ev.tags.find((t) => t[0] === "encryption")?.[1] === "nip44_v2"
+ ? "nip44_v2"
+ : "nip04";
const { params, method } = JSON.parse(
- await nip04.decrypt(serverSecret, pubkey, content),
+ await decryptPayload(scheme, sk, pubkey, content),
);
+ // Helper: send a 23195 reply (used for both results and errors). Pulled
+ // up so the rate-limit path can reply instead of dropping silently.
+ const reply = async (payloadObj: any) => {
+ const enc = await encryptPayload(
+ scheme,
+ sk,
+ pubkey,
+ JSON.stringify(payloadObj),
+ );
+ const signed = await finalizeEvent(
+ {
+ created_at: Math.floor(Date.now() / 1000),
+ kind: 23195,
+ pubkey: serverPubkey,
+ tags: [["p", pubkey], ["e", ev.id], ["encryption", scheme]],
+ content: enc,
+ } as UnsignedEvent,
+ hexToBytes(sk),
+ );
+ r.send(["EVENT", signed]);
+ };
+
+ // Per-pubkey rate limiting. Over-limit requests get a NIP-47 RATE_LIMITED
+ // error reply (not a silent drop) so the client can distinguish throttling
+ // from a lost reply and back off.
+ const times = nwcRequestTimes.get(ev.pubkey) || [];
+ const cutoff = Date.now() - nwcRateWindow;
+ const recent = times.filter((t) => t > cutoff);
+ if (recent.length >= nwcRateLimit) {
+ // The event is already claimed above, so it won't be reprocessed.
+ await reply({
+ result_type: method,
+ error: { code: "RATE_LIMITED", message: "Too many requests, slow down" },
+ });
+ return;
+ }
+ recent.push(Date.now());
+ nwcRequestTimes.set(ev.pubkey, recent);
+
// console.log("nwc", method, params, pubkey);
if (!methods.includes(method)) return;
+ // Incident kill switch. NWC connection secrets are long-lived bearer
+ // credentials; if they may have been disclosed, a ledger hard-freeze is
+ // too broad to leave on indefinitely and per-app deletion is both slow and
+ // destructive. This flag makes every NWC method fail closed while keeping
+ // the connection records available for deliberate rotation/recovery.
+ if (await g("nwc:disabled")) {
+ await reply({
+ result_type: method,
+ error: {
+ code: "INTERNAL",
+ message: "NWC is temporarily disabled",
+ },
+ });
+ return;
+ }
+
try {
const app = await g(`app:${pubkey}`);
if (!app) fail("pubkey not found");
const user = await g(`user:${app.uid}`);
+ if (!user) fail("user not found");
+
+ // Forced credential rotation. NWC secrets are bearer credentials, so a
+ // suspected disclosure cannot be repaired by changing an account
+ // password. The cutoff retires every connection created before the
+ // incident epoch while allowing users to create replacement connections
+ // without a global NWC outage. Keep the explicit quarantine membership
+ // as a durable forensic marker and as defense in depth if the cutoff is
+ // later removed accidentally.
+ const credentialCutoff = Number(await g("nwc:credential-cutoff")) || 0;
+ if (
+ credentialCutoff > 0 &&
+ (!Number(app.created) || Number(app.created) < credentialCutoff)
+ ) {
+ await db.sAdd("nwc:quarantined", pubkey);
+ // Retired clients commonly keep polling until their owners replace
+ // the connection. The rejection is expected and can be extremely
+ // noisy, so reply without logging every attempt. Explicit quarantine
+ // and newly detected attack-pattern events remain warning-level.
+ await reply({
+ result_type: method,
+ error: {
+ code: "UNAUTHORIZED",
+ message:
+ "This NWC connection has been retired; create a new one",
+ },
+ });
+ return;
+ }
- const result = await handle(method, params, ev, app, user);
- const payload = JSON.stringify({ result_type: method, ...result });
- content = await nip04.encrypt(serverSecret, pubkey, payload);
+ // Incident containment without disrupting every NWC user. Quarantine
+ // only connections whose bearer secret shows the probe/drain pattern;
+ // retain the app record so support can identify and rotate it later.
+ if (await db.sIsMember("nwc:quarantined", pubkey)) {
+ warn("Blocked quarantined NWC connection", user.username, pubkey, method);
+ await reply({
+ result_type: method,
+ error: {
+ code: "UNAUTHORIZED",
+ message: "This NWC connection has been disabled; create a new one",
+ },
+ });
+ return;
+ }
- let response: UnsignedEvent = {
- created_at: Math.floor(Date.now() / 1000),
- kind: 23195,
- pubkey: serverPubkey,
- tags: [
- ["p", pubkey],
- ["e", ev.id],
- ],
- content,
- };
+ // Active-incident guard: the leaked credential set is being walked with
+ // keysend probes against years-old, previously idle connections. Block
+ // the first keysend after 30 days of inactivity and quarantine only that
+ // connection. Ordinary pay_invoice/pay traffic and active keysend apps
+ // remain unaffected. The Redis flag makes this guard easy to retire once
+ // the credential-rotation campaign is complete.
+ if (
+ method === "pay_keysend" &&
+ (await g("nwc:guard:dormant-keysend")) &&
+ (await isDormantNwcApp(app))
+ ) {
+ await db.sAdd("nwc:quarantined", pubkey);
+ warn("SECURITY: quarantined dormant NWC keysend", user.username, pubkey);
+ await reply({
+ result_type: method,
+ error: {
+ code: "UNAUTHORIZED",
+ message: "This dormant NWC connection has been disabled; create a new one",
+ },
+ });
+ return;
+ }
- response = await finalizeEvent(response, hexToBytes(serverSecret));
- r.send(["EVENT", response]);
+ // Serialize budget-affecting methods per app so the budget check and the
+ // resulting spend can't interleave with a concurrent request on the same
+ // connection. Read-only methods run without contending for the lock.
+ const run = () => handle(method, params, ev, app, user);
+ const result = budgetedMethods.has(method)
+ ? await withBudgetLock(app.pubkey, run)
+ : await run();
+ // A handler returning nothing (e.g. pay_invoice that couldn't confirm in
+ // time) must still produce a reply, or the client hangs forever waiting.
+ if (result === undefined || result === null) {
+ await reply({
+ result_type: method,
+ error: { code: "INTERNAL", message: "No response from handler" },
+ });
+ } else {
+ await reply({ result_type: method, ...result });
+ }
} catch (e) {
- err(
- "problem with nwc",
- pubkey,
- method,
- JSON.stringify(params),
- e.message,
- );
+ // Stale client state (deleted app or migrated user) is not a server
+ // fault — don't log it, but DO answer: a NIP-47 UNAUTHORIZED reply
+ // lets the client surface an error instead of spinning forever on a
+ // connection this server no longer knows.
+ if (e.message === "pubkey not found" || e.message === "user not found") {
+ try {
+ await reply({
+ result_type: method,
+ error: { code: "UNAUTHORIZED", message: "unknown connection" },
+ });
+ } catch (_) {}
+ return;
+ }
+ err("problem with nwc", pubkey, method, e.message);
+ // Still reply so the client isn't left hanging on the failure.
+ try {
+ await reply({
+ result_type: method,
+ error: { code: "INTERNAL", message: e.message },
+ });
+ } catch (_) {}
}
} catch (e) {
- err("problem with nwc", e.message);
+ err("problem with nwc event", e.message);
}
});
+ }
+
+ connect();
+};
+
+// Pull the single lightning instruction out of a BIP-321 payment string for
+// the nwc `pay` method (nwc#2): a bitcoin: URI (preferring lno= over
+// lightning=), a lightning: URI, or a bare bolt11/bolt12 string. Returns
+// { instruction } or { error } (a ready-to-send NIP-47 error payload).
+const noInstruction = error({
+ code: "UNSUPPORTED_PAYMENT_INSTRUCTION",
+ message: "No lightning instruction found (bolt11 or bolt12 required)",
+});
+const parseInstruction = (payment) => {
+ if (typeof payment !== "string") return noInstruction;
+ let s = payment.replace(/\s/g, "");
+ const lower = s.toLowerCase();
+ if (lower.startsWith("lightning:")) {
+ s = s.slice("lightning:".length);
+ } else if (lower.startsWith("bitcoin:")) {
+ const i = s.indexOf("?");
+ if (i === -1) return noInstruction;
+ let lno;
+ let bolt11;
+ // BIP-321 keys are case-insensitive and may repeat; first one wins
+ for (const [k, v] of new URLSearchParams(s.slice(i + 1))) {
+ const key = k.toLowerCase();
+ // BIP-321: a req- parameter the wallet doesn't understand invalidates
+ // the whole URI. We support none — including req-pop, since we can't
+ // open a proof-of-payment callback — so any req- key is a rejection.
+ // A plain (optional) pop is skippable and falls through harmlessly.
+ if (key.startsWith("req-")) {
+ return error({
+ code: "BAD_REQUEST",
+ message: `Unsupported required BIP-321 parameter: ${key}`,
+ });
+ }
+ if (key === "lno") lno ||= v;
+ else if (key === "lightning") bolt11 ||= v;
+ }
+ s = lno || bolt11;
+ if (!s) return noInstruction;
+ }
+ s = s.toLowerCase();
+ return s.startsWith("ln") ? { instruction: s } : noInstruction;
+};
+
+// Network matching for the nwc `pay` method: the spec requires rejecting a
+// payment instruction for a different Bitcoin network BEFORE paying (error
+// code UNSUPPORTED_NETWORK) rather than letting xpay fail after the debit.
+// bolt11 encodes the network in its bech32 prefix (decode's `currency`);
+// bolt12 carries BOLT chain_hashes (genesis hash, internal byte order), with
+// absence meaning mainnet. Keyed by CLN getinfo's `network` name.
+const bolt11Prefixes = {
+ bitcoin: "bc",
+ testnet: "tb",
+ signet: "tbs",
+ regtest: "bcrt",
+};
+const chainHashes = {
+ bitcoin: "6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000",
+ testnet: "43497fd7f826957108f4a30fd9cec3aeba79972084e90ead01ea330900000000",
+ signet: "f61eee3b63a380a477a063af32b2bbc97c9ff9f01f2c4225e973988108000000",
+ regtest: "06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f",
+};
+const matchesNetwork = (decoded, network) => {
+ if (decoded.type === "bolt11 invoice")
+ return decoded.currency === bolt11Prefixes[network];
+ const ours = chainHashes[network];
+ if (decoded.type === "bolt12 offer")
+ return decoded.offer_chains
+ ? decoded.offer_chains.includes(ours)
+ : network === "bitcoin";
+ const chain = decoded.invreq_chain || chainHashes.bitcoin;
+ return chain === ours;
+};
+
+// CLN v26.06's experimental `createproof` RPC produces the lnp payer proof
+// that NIP-177 zap receipts (kind 9736) embed. The default proof OMITS
+// invreq_payer_note, which NIP-177 verification requires disclosed (it binds
+// the payment to the zap intent) — and passing `include` REPLACES the default
+// disclosure set, so invoice_amount must be re-listed alongside it or the
+// amount gets hidden too. Feature-detected through the generic call() — on
+// older nodes this errors and we omit the proof, which the nwc `pay` spec
+// allows.
+const createPayerProof = async (
+ invstring: string,
+ hasPayerNote: boolean = false,
+) => {
+ try {
+ const r = await ln.call("createproof", {
+ invstring,
+ ...(hasPayerNote && { include: ["invreq_payer_note", "invoice_amount"] }),
+ });
+ return r?.proofs?.[0]?.bolt12;
+ } catch (e) {
+ return undefined;
+ }
+};
+
+// Connection validity + spending budget for an NWC app, shared by pay_invoice
+// and pay. Returns a NIP-47 error payload, or null when the spend is allowed.
+const checkBudget = async (app, amount) => {
+ const { max_amount, budget_renewal, pubkey, created } = app;
+
+ if (!created) {
+ return {
+ budgetError: error({
+ code: "UNAUTHORIZED",
+ message: `This NWC connection is no longer valid please create a new one at https://coinos.io/settings/nostr`,
+ }),
+ remaining: 0,
+ };
+ }
+
+ const unlimited =
+ max_amount === undefined || max_amount === null || max_amount === "";
+ const limit = unlimited ? 0 : Number(max_amount);
+ if (!Number.isFinite(limit) || limit < 0) {
+ return {
+ budgetError: error({
+ code: "UNAUTHORIZED",
+ message: "This NWC connection has an invalid spending budget",
+ }),
+ remaining: 0,
+ };
+ }
+
+ // A blank budget is deliberately unlimited. A positive budget must have a
+ // known renewal period; otherwise an unknown value makes every historical
+ // payment fall out of the window and silently resets the budget each call.
+ if (limit === 0) return { budgetError: null, remaining: undefined };
+
+ const periods = {
+ daily: 60 * 60 * 24 * 1000,
+ weekly: 60 * 60 * 24 * 7 * 1000,
+ monthly: 60 * 60 * 24 * 30 * 1000,
+ yearly: 60 * 60 * 24 * 365 * 1000,
+ never: 60 * 60 * 24 * 365 * 10 * 1000,
+ };
+
+ const period = periods[budget_renewal];
+ if (!period) {
+ return {
+ budgetError: error({
+ code: "UNAUTHORIZED",
+ message: "This NWC connection has an invalid budget renewal period",
+ }),
+ remaining: 0,
+ };
+ }
+
+ const pids = await db.lRange(`${pubkey}:payments`, 0, -1);
+ let payments = await Promise.all(pids.map((pid) => gf(`payment:${pid}`)));
+ payments = payments.filter(
+ (p) => p?.created > Date.now() - period,
+ );
+
+ const spent = payments.reduce(
+ (a, b) =>
+ a +
+ (Math.abs(Number.parseInt(b.amount || 0)) +
+ Number.parseInt(b.tip || 0) +
+ Number.parseInt(b.fee || 0) +
+ Number.parseInt(b.ourfee || 0)),
+ 0,
+ );
+
+ const remaining = Math.max(0, limit - spent);
+ if (amount > remaining) {
+ return {
+ budgetError: error({
+ code: "QUOTA_EXCEEDED",
+ message: `Budget exceeded: ${spent + amount} of ${limit}`,
+ }),
+ remaining,
+ };
+ }
+
+ return { budgetError: null, remaining };
};
const handle = (method, params, ev, app, user) =>
({
async pay_invoice() {
- const { invoice: pr, metadata } = params;
+ const { metadata, amount: reqAmountMsat } = params;
+ // Lowercase so getInvoice() finds our own invoices below (see create()
+ // in routes/payments.ts).
+ const pr =
+ typeof params.invoice === "string"
+ ? params.invoice.replace(/\s/g, "").toLowerCase()
+ : params.invoice;
const { amount_msat, payee } = await ln.decode(pr);
const { id } = await ln.getinfo();
- const amount = Math.round(amount_msat / 1000);
- const { max_amount, max_fee, budget_renewal, pubkey, created } = app;
-
- const periods = {
- daily: 60 * 60 * 24 * 1000,
- weekly: 60 * 60 * 24 * 7 * 1000,
- monthly: 60 * 60 * 24 * 30 * 1000,
- yearly: 60 * 60 * 24 * 365 * 1000,
- never: 60 * 60 * 24 * 365 * 10 * 1000,
- };
-
- const pids = await db.lRange(`${pubkey}:payments`, 0, -1);
- let payments = await Promise.all(pids.map((pid) => g(`payment:${pid}`)));
- payments = payments.filter(
- (p) => p?.created > Date.now() - periods[budget_renewal],
- );
-
- const spent = payments.reduce(
- (a, b) =>
- a +
- (Math.abs(parseInt(b.amount || 0)) +
- parseInt(b.tip || 0) +
- parseInt(b.fee || 0) +
- parseInt(b.ourfee || 0)),
- 0,
- );
-
- if (!created) {
- warn(
- "old nwc token",
- pubkey,
- user?.username,
- spent,
- amount,
- max_amount,
- );
+ // For an amountless bolt11, the invoice carries no amount — NIP-47 lets the
+ // client specify it in the request's `amount` field (msat). Fall back to
+ // that so amountless invoices aren't rejected with "Invalid amount"
+ // (issue #83). Invoice amount takes precedence when present.
+ const amountMsat = amount_msat || reqAmountMsat;
+ if (!amountMsat) {
return error({
- code: "UNAUTHORIZED",
- message: `This NWC connection is no longer valid please create a new one at https://coinos.io/settings/nostr`,
+ code: "OTHER",
+ message:
+ "Amountless invoice requires an amount; specify amount (msat) in the request",
});
}
+ const amount = Math.round(amountMsat / 1000);
+ const { max_fee, pubkey } = app;
- if (max_amount > 0 && spent + amount > max_amount) {
- warn(
- "budget exceeded",
- pubkey,
- user?.username,
- spent,
- amount,
- max_amount,
- );
- return error({
- code: "QUOTA_EXCEEDED",
- message: `Budget exceeded: ${spent + amount} of ${max_amount}`,
- });
- }
+ const { budgetError, remaining } = await checkBudget(app, amount);
+ if (budgetError) return payFail("pay_invoice", app, user, amount, budgetError);
if (payee === id) {
const invoice = await getInvoice(pr);
- const recipient = await g(`user:${invoice.uid}`);
+ const recipient = await g(`user:${invoice?.uid}`);
- if (recipient?.username !== "mint") {
+ // No coinos invoice record means the bolt11 is on our node but not
+ // ours to settle internally (a mint quote, say) — pay it over
+ // Lightning below like any other.
+ if (invoice) {
const { id: pid } = await sendInternal({
amount,
invoice,
recipient,
sender: user,
+ maxTotal: remaining,
});
const preimage = pid;
- if (pubkey !== user.pubkey) await db.lPush(`${pubkey}:payments`, pid);
+ await db.lPush(`${pubkey}:payments`, pid);
if (invoice.memo?.includes("9734")) {
const { invoices } = await ln.listinvoices({ invstring: pr });
@@ -193,32 +696,301 @@ const handle = (method, params, ev, app, user) =>
}
}
- const { id: pid } = await sendLightning({
- amount,
- fee: max_fee || Math.round(amount * 0.01),
- user,
- pr,
- memo: JSON.stringify(metadata)
+ try {
+ // sendLightning runs xpay to completion and finalize() sets p.ref to the
+ // preimage on success, so the returned record already carries it — no
+ // need to poll listpays and race a 20s timeout (the old loop returned a
+ // misleading "Payment timed out" error on slow routes even though the
+ // payment had settled; see issue #80).
+ const p = await sendLightning({
+ amount,
+ fee: max_fee || Math.round(amount * 0.01),
+ user,
+ pr,
+ memo: JSON.stringify(metadata),
+ maxTotal: remaining,
+ });
+
+ await db.lPush(`${pubkey}:payments`, p.id);
+
+ if (p.ref) return result({ preimage: p.ref });
+
+ // Fallback: ref missing (shouldn't normally happen) — confirm via
+ // listpays before giving up, with a longer window than before.
+ for (let i = 0; i < 30; i++) {
+ const { pays } = await ln.listpays(pr);
+ const done = pays.find((x) => x.status === "complete");
+ if (done?.preimage) return result({ preimage: done.preimage });
+ if (pays.length && pays.every((x) => x.status === "failed"))
+ return payFail("pay_invoice", app, user, amount,
+ error({ code: "PAYMENT_FAILED", message: "Payment failed" }));
+ await sleep(2000);
+ }
+ return payFail("pay_invoice", app, user, amount,
+ error({ code: "PAYMENT_FAILED", message: "Payment still pending after 60s" }));
+ } catch (e) {
+ return payFail("pay_invoice", app, user, amount,
+ error({ code: "PAYMENT_FAILED", message: e.message }));
+ }
+
+ // Still in flight after the fallback window: the payment may yet settle.
+ // Tell the client explicitly rather than implying success or hard failure.
+ return error({
+ code: "INTERNAL",
+ message: "Payment still in flight; check status with lookup_invoice",
});
+ },
- await db.lPush(`${pubkey}:payments`, pid);
+ // nwc#2 generalized payment: BIP-321 URI carrying a bolt11 invoice or a
+ // bolt12 offer/invoice. This is what Amethyst v1.13+ uses for bolt12 zaps —
+ // payer_note carries the nostr:nip177:<intent-id> binding and the client
+ // builds the kind 9736 receipt from the returned preimage/payer_proof.
+ async pay() {
+ const { payment, amount: reqAmountMsat, payer_note, metadata } = params;
+ const { pubkey } = app;
- for (let i = 0; i < 10; i++) {
- const { pays } = await ln.listpays(pr);
- const p = pays.find((p) => p.status === "complete");
- if (p) {
- const { preimage } = p;
- return result({ preimage });
+ const parsed = parseInstruction(payment);
+ if (parsed.error) return parsed;
+ const pr = parsed.instruction;
+
+ let decoded;
+ try {
+ decoded = await ln.decode(pr);
+ if (decoded.valid === false) throw new Error("invalid");
+ } catch (e) {
+ return error({
+ code: "BAD_REQUEST",
+ message: "Failed to decode payment instruction",
+ });
+ }
+
+ const { network } = await ln.getinfo();
+ if (!matchesNetwork(decoded, network)) {
+ return error({
+ code: "UNSUPPORTED_NETWORK",
+ message: "Payment instruction is for a different Bitcoin network",
+ });
+ }
+
+ const instruction_type = decoded.type.includes("bolt12")
+ ? "bolt12"
+ : "bolt11";
+
+ // payer_note rides in a bolt12 invoice request's invreq_payer_note;
+ // bolt11 has no payer-message field, and the spec requires delivering
+ // the note or rejecting before payment — never silently dropping it
+ if (payer_note && instruction_type !== "bolt12") {
+ return error({
+ code: "BAD_REQUEST",
+ message: "payer_note requires a bolt12 payment instruction",
+ });
+ }
+
+ const instructionAmountMsat =
+ decoded.type === "bolt12 offer"
+ ? decoded.offer_amount_msat
+ : instruction_type === "bolt12"
+ ? decoded.invoice_amount_msat
+ : decoded.amount_msat;
+
+ if (
+ reqAmountMsat !== undefined &&
+ reqAmountMsat !== null &&
+ (!Number.isFinite(Number(reqAmountMsat)) || Number(reqAmountMsat) <= 0)
+ ) {
+ return error({ code: "BAD_REQUEST", message: "Invalid amount" });
+ }
+ if (
+ reqAmountMsat &&
+ instructionAmountMsat &&
+ Number(reqAmountMsat) !== Number(instructionAmountMsat)
+ ) {
+ return error({
+ code: "BAD_REQUEST",
+ message: `Requested amount conflicts with the instruction amount (${instructionAmountMsat} msat)`,
+ });
+ }
+
+ const amountMsat = instructionAmountMsat || reqAmountMsat;
+ if (!amountMsat) {
+ return error({
+ code: "BAD_REQUEST",
+ message:
+ "Amountless payment instruction requires an amount (msat) in the request",
+ });
+ }
+ const amount = Math.round(amountMsat / 1000);
+
+ const { budgetError, remaining } = await checkBudget(app, amount);
+ if (budgetError) return budgetError;
+
+ const created_at = Math.floor(Date.now() / 1000);
+
+ // Recipient is a coinos user (their invoice or standing offer is ours):
+ // settle internally, same as pay_invoice does
+ const invoice = await getInvoice(pr);
+ if (invoice) {
+ const recipient = await g(`user:${invoice.uid}`);
+
+ // Internal settlement never builds a bolt12 invoice request, so the
+ // spec's deliver-or-reject rule for payer_note is met by handing the
+ // note to the recipient as the payment memo (bolt11 + payer_note was
+ // already rejected above)
+ const { id: pid } = await sendInternal({
+ amount,
+ invoice,
+ memo: payer_note,
+ recipient,
+ sender: user,
+ maxTotal: remaining,
+ });
+
+ await db.lPush(`${pubkey}:payments`, pid);
+
+ if (invoice.memo?.includes("9734")) {
+ const { invoices } = await ln.listinvoices({ invstring: pr });
+ const inv = invoices[0];
+ if (inv) {
+ inv.payment_preimage = pid;
+ inv.paid_at = created_at;
+ try {
+ await handleZap(inv, user.pubkey);
+ } catch (e) {
+ console.log("zap receipt failed", e);
+ }
+ }
+ }
+
+ // Internal settlement never touches CLN, so there's no real
+ // preimage or payer proof to hand back
+ return result({
+ transaction_id: pid,
+ state: "settled",
+ instruction_type,
+ amount: amountMsat,
+ fees_paid: 0,
+ created_at,
+ settled_at: Math.floor(Date.now() / 1000),
+ });
+ }
+
+ try {
+ const p = await sendLightning({
+ amount,
+ fee: app.max_fee || Math.round(amount * 0.01),
+ user,
+ pr,
+ memo: metadata ? JSON.stringify(metadata) : undefined,
+ payerNote: payer_note,
+ maxTotal: remaining,
+ });
+
+ await db.lPush(`${pubkey}:payments`, p.id);
+
+ const preimage = p.ref;
+ if (!preimage) {
+ return result({
+ transaction_id: p.id,
+ state: "pending",
+ instruction_type,
+ amount: amountMsat,
+ fees_paid: 0,
+ created_at,
+ });
}
- await sleep(2000);
+
+ return result({
+ transaction_id: p.id,
+ state: "settled",
+ instruction_type,
+ amount: amountMsat,
+ fees_paid: (p.fee || 0) * 1000,
+ payment_hash: bytesToHex(sha256(hexToBytes(preimage))),
+ preimage,
+ // p.hash is the invoice actually paid (for an offer, the bolt12
+ // invoice fetched from it)
+ payer_proof:
+ instruction_type === "bolt12"
+ ? await createPayerProof(p.hash, !!payer_note)
+ : undefined,
+ created_at,
+ settled_at: Math.floor(Date.now() / 1000),
+ });
+ } catch (e) {
+ return error({ code: "PAYMENT_FAILED", message: e.message });
}
+ },
+
+ // nwc#2 counterpart to pay: hand back a BIP-321 URI combining a fresh
+ // bolt11 invoice with the user's standing bolt12 offer
+ async receive() {
+ const { amount, description } = params;
+
+ try {
+ const invoice = await generate({
+ invoice: {
+ amount: amount ? Math.round(amount / 1000) : 0,
+ type: "lightning",
+ memo: description,
+ },
+ user,
+ });
- return error({ code: "INTERNAL", message: "Payment timed out" });
+ const qs = new URLSearchParams();
+ qs.set("lightning", invoice.hash);
+ try {
+ // The spec requires the description in EVERY instruction that
+ // supports one (offers do), and an amount shouldn't come back as an
+ // amountless lno — so an amounted or described receive mints a
+ // matching per-request offer; the reusable standing offer only fits
+ // the blank request. On failure (e.g. an identical offer already
+ // registered to another user) the lno is omitted rather than
+ // substituting one with the wrong description/amount.
+ const offer =
+ amount || description
+ ? await generate({
+ invoice: {
+ type: "bolt12",
+ amount: amount ? Math.round(amount / 1000) : 0,
+ memo: description,
+ },
+ user,
+ })
+ : await getUserOffer(user);
+ if (offer?.hash) qs.set("lno", offer.hash);
+ } catch (e) {
+ warn("failed to include offer in receive", e.message);
+ }
+
+ return result({
+ bip321: `bitcoin:?${qs.toString()}`,
+ transaction_id: invoice.id,
+ });
+ } catch (e) {
+ return error({ code: "BAD_REQUEST", message: e.message });
+ }
},
async pay_keysend() {
- const { amount: amount_msat, pubkey, tlv_records: extratlvs } = params;
+ const { amount: amount_msat, pubkey, tlv_records } = params;
const amount = Math.round(amount_msat / 1000);
+ const extratlvs = {};
+
+ // convert tlv_records to the extratlvs format
+ // tlv_records: [{ type: 1, value: "asdf" }]
+ // extratlvs: { "1": "asdf" }
+ if (tlv_records && tlv_records.length) {
+ for (const record of tlv_records) {
+ extratlvs[record.type.toString()] = record.value;
+ }
+ }
+
+ // Keysend spends the same custodial balance as pay_invoice, so it must be
+ // subject to the same budget — otherwise a budget-limited connection string
+ // is a full-drain credential. It also left no `${app.pubkey}:payments`
+ // entry, so subsequent budgeted calls under-counted the spend.
+ const { budgetError, remaining } = await checkBudget(app, amount);
+ if (budgetError) return budgetError;
try {
const { payment_hash } = await sendKeysend({
@@ -227,8 +999,16 @@ const handle = (method, params, ev, app, user) =>
pubkey,
user,
extratlvs,
+ fee: app.max_fee,
+ maxTotal: remaining,
});
+ // Record the debit against the app budget. debit() stored the payment id
+ // under the keysend label (= ev.id); resolve and push it so checkBudget
+ // counts this spend going forward.
+ const pid = await g(`payment:${ev.id}`);
+ if (pid) await db.lPush(`${app.pubkey}:payments`, pid);
+
for (let i = 0; i < 10; i++) {
const { pays } = await ln.listpays({ payment_hash });
const p = pays.find((p) => p.status === "complete");
@@ -246,7 +1026,7 @@ const handle = (method, params, ev, app, user) =>
},
async get_info() {
- const { alias, blockheight, color, id } = await ln.getinfo();
+ const { alias, blockheight, color, id, network } = await ln.getinfo();
return result({
alias,
@@ -254,7 +1034,8 @@ const handle = (method, params, ev, app, user) =>
block_height: blockheight,
color,
pubkey: id,
- network: "mainnet",
+ // CLN calls mainnet "bitcoin"; NIP-47 clients expect "mainnet"
+ network: network === "bitcoin" ? "mainnet" : network,
methods,
notifications: ["payment_received", "payment_sent"],
});
@@ -268,7 +1049,7 @@ const handle = (method, params, ev, app, user) =>
async make_invoice() {
const { amount, description, description_hash, expiry } = params;
- l("nwc make_invoice", user.username);
+ // l("nwc make_invoice", user.username);
const invoice = {
amount: Math.round(amount / 1000),
@@ -277,7 +1058,14 @@ const handle = (method, params, ev, app, user) =>
expiry,
};
- const { hash, created: created_at } = await generate({ invoice, user });
+ const { hash, created, paymentHash } = await generate({ invoice, user });
+
+ // generate() stores `created` in milliseconds (Date.now()), but NWC
+ // timestamps are Unix SECONDS (NIP-47). Returning ms here produced an
+ // expires_at ~1.78e12 that overflowed clients' date parsers — e.g.
+ // ptcpay's .NET DateTimeOffset (issue #84: "Valid values are between
+ // -62135596800 and 253402300799"). Convert to seconds.
+ const created_at = Math.floor(created / 1000);
return result({
type: "incoming",
@@ -287,6 +1075,8 @@ const handle = (method, params, ev, app, user) =>
amount,
created_at,
expires_at: created_at + expiry,
+ fees_paid: 0,
+ payment_hash: paymentHash,
metadata: {},
});
},
@@ -356,11 +1146,16 @@ const handle = (method, params, ev, app, user) =>
async list_transactions() {
const { from, until, limit = 10, offset = 0, type } = params;
- const payments = await db.lRange(`${user.id}:payments`, 0, -1);
+ const listKey = `${user.id}:payments`;
+ const main = (await db.lRange(listKey, 0, -1)) || [];
+ const archived = (await archive.lRange(listKey, 0, -1)) || [];
+ const payments = [...new Set([...main, ...archived])];
let transactions = [];
for (const pid of payments) {
- const p = await g(`payment:${pid}`);
+ const p = await gf(`payment:${pid}`);
+ if (!p) continue;
+ if (p.revertedDuplicate) continue;
const created_at = Math.floor(p.created / 1000);
if (created_at < from || created_at > until) continue;
if (p.amount < 0 && type === "incoming") continue;
@@ -385,7 +1180,7 @@ const handle = (method, params, ev, app, user) =>
description: p.memo,
preimage: p.ref,
payment_hash,
- amount: (Math.abs(p.amount) + p.tip) * 1000,
+ amount: (Math.abs(p.amount) + (p.tip || 0)) * 1000,
fees_paid: (p?.fee || 0) * 1000,
created_at,
expires_at: created_at + week,
diff --git a/lib/payments.ts b/lib/payments.ts
index cbff6dd3..09cf2cf6 100644
--- a/lib/payments.ts
+++ b/lib/payments.ts
@@ -1,11 +1,30 @@
import config from "$config";
+import { existsSync } from "fs";
import api from "$lib/api";
-import { db, g, ga, s } from "$lib/db";
+import { requireAccount } from "$lib/auth";
+import { archive, db, g, ga, gf, s, sa } from "$lib/db";
+import { assertWithinSpendLimit } from "$lib/spend-budget";
+
+// External-withdrawal lockfiles. Out-of-band emergency stop: nobal.sh (or a
+// human) can `touch /home/adam/locks/<type>.locked` on the host, which is
+// bind-mounted into this container at /locks. Existence of /locks/<type>.locked
+// (or /locks/ALL.locked) blocks all external sends of that type. Lives outside
+// db so it can't be disabled by a db-write vector (May 18 /gateway lesson).
+//
+// Internal payments are never blocked by these — receives and internal
+// transfers between coinos users keep working with the site fully online.
+const isWithdrawLocked = (type: string): string | null => {
+ if (existsSync("/locks/ALL.locked")) return "ALL";
+ if (existsSync(`/locks/${type}.locked`)) return type;
+ return null;
+};
import { generate } from "$lib/invoices";
import ln from "$lib/ln";
-import { err, l, warn } from "$lib/logging";
+import { quoteRoutingFee, xpayLayers } from "$lib/lnquote";
+import { err, l, quiet, shortError, suppressed, warn } from "$lib/logging";
import { handleZap } from "$lib/nostr";
import { notify, nwcNotify } from "$lib/notifications";
+import { squarePayment } from "$lib/square";
import {
SATS,
btc,
@@ -21,15 +40,65 @@ import {
t,
} from "$lib/utils";
import { callWebhook } from "$lib/webhooks";
+import { maybeForwardToV3, retryV3Forwards } from "$lib/v3forward";
+import changeid from "$lib/changeid";
import rpc from "@coinos/rpc";
import { bech32 } from "bech32";
import got from "got";
import { v4 } from "uuid";
+
import { PaymentType } from "$lib/types";
const bc = rpc(config.bitcoin);
const lq = rpc(config.liquid);
+
+// Fire-and-forget: push a freshly-broadcast tx straight to public explorers so
+// they see it immediately instead of waiting for it to propagate to their node
+// over the p2p network (measured ~8s otherwise). Never throws; never blocks.
+const broadcastToExplorers = (hex: string) => {
+ const net = config.bitcoin?.network;
+ if (net === "regtest" || net === "testnet") return; // public explorers are mainnet
+ for (const base of ["https://mempool.space/api", "https://blockstream.info/api"]) {
+ fetch(`${base}/tx`, {
+ method: "POST",
+ body: hex,
+ headers: { "Content-Type": "text/plain" },
+ }).catch(() => {});
+ }
+};
+
+// Throttled warn — emit at most once per WARN_THROTTLE_MS per (key, message) pair
+// so a downed external service (lq, bc, etc.) doesn't flood the log every loop tick.
+const WARN_THROTTLE_MS = 5 * 60 * 1000;
+const warnLastEmitted: Record<string, number> = {};
+const warnThrottled = (key: string, message: string) => {
+ const k = `${key}|${message}`;
+ const now = Date.now();
+ if ((warnLastEmitted[k] || 0) + WARN_THROTTLE_MS > now) return;
+ warnLastEmitted[k] = now;
+ warn(`${key}:`, message);
+};
+
+// Per-asset-type async mutex. The May 18 2026 drain exploited the fact that
+// the limit check + payment broadcast were non-atomic: a burst of concurrent
+// withdrawals would each pass the same stale ${type}:limit value before any
+// of them decremented it. This lock serializes the check + reservation per
+// asset type. freezeCheck also takes the lock when overwriting limits so its
+// periodic reconciliation can't race with an in-flight check.
+const limitLocks: Record<string, Promise<void>> = {};
+async function withLimitLock<T>(type: string, fn: () => Promise<T>): Promise<T> {
+ const prev = limitLocks[type] || Promise.resolve();
+ let release: () => void = () => {};
+ const next = new Promise<void>((res) => { release = res; });
+ limitLocks[type] = prev.then(() => next);
+ await prev;
+ try {
+ return await fn();
+ } finally {
+ release();
+ }
+}
const { URL } = process.env;
const dust = 547;
@@ -43,42 +112,87 @@ export const debit = async ({
user,
type = PaymentType.internal,
rate = undefined,
+ // a v3 bridge forward (lib/v3forward.ts) costs routing only
+ exempt = false,
+ maxTotal = undefined,
}) => {
- amount = parseInt(amount);
+ amount = Number.parseInt(amount);
const whitelisted = await db.sIsMember(
"whitelist",
user?.username?.toLowerCase().trim(),
);
- const blacklisted = await db.sIsMember(
- "blacklist",
- user?.username?.toLowerCase().trim(),
- );
+ // Blacklist (freeze) matches on EITHER the current username OR the uid. The
+ // uid never changes, so renaming the account can't shake the freeze — add the
+ // uid to the `blacklist` set to freeze a compromised account durably.
+ const blacklisted =
+ (await db.sIsMember("blacklist", user?.username?.toLowerCase().trim())) ||
+ (!!user?.id && (await db.sIsMember("blacklist", user.id)));
+
+ if (hash && await db.sIsMember("blocked_addresses", hash)) {
+ err(`SECURITY: blocked send to ${hash} by ${user.username}`);
+ await changeid(user.username);
+ fail("address blocked");
+ }
- const serverLimit = await g(`${type}:limit`);
const userLimit = await g("limit");
const frozen =
(await g("hardfreeze")) ||
((await g("freeze")) && type !== PaymentType.internal);
- if (frozen || (amount > userLimit && !whitelisted) || amount > serverLimit) {
- warn(
- "Blocking",
- user.username,
- amount,
- hash,
- user.id,
- type,
- frozen,
- userLimit,
- serverLimit,
- );
+ // Out-of-band withdrawal lock (file-based, can't be defeated by db writes).
+ // Internal sends are exempt — they don't touch external wallets.
+ if (type !== PaymentType.internal) {
+ const lockedKind = isWithdrawLocked(type);
+ if (lockedKind) {
+ const n = quiet(`block:${user.id}:lock`, 5 * 60 * 1000);
+ if (n !== null)
+ warn("Blocking", user.username, amount, hash, user.id, type, "withdraw-lock", lockedKind, suppressed(n));
+ fail("External withdrawals temporarily disabled");
+ }
+ }
+
+ if (frozen || (amount > userLimit && !whitelisted)) {
+ // Per-account throttle: a frozen throwaway retrying sends every few
+ // seconds is expected noise, one line per 5 min keeps the tripwire.
+ const n = quiet(`block:${user.id}:frozen`, 5 * 60 * 1000);
+ if (n !== null)
+ warn("Blocking", user.username, amount, hash, user.id, type, frozen, userLimit, suppressed(n));
fail("Problem sending payment");
}
+ // Atomic check + reserve against the per-asset-type server limit. Decrement
+ // immediately so concurrent calls can't reuse the same budget; freezeCheck
+ // reconciles to actual on-chain balance every 10s.
+ //
+ // Internal sends are exempt: they're pure ledger moves between coinos users
+ // and never touch an external wallet, so there's no hot-wallet liquidity to
+ // reserve against. freezeCheck only ever populates limits for external types
+ // (lightning/fund/ecash/bolt12/bitcoin/liquid), so `internal:limit` stays 0
+ // forever — applying the check here blocks every internal payment.
+ if (type !== PaymentType.internal) {
+ await withLimitLock(type, async () => {
+ const serverLimit = Number.parseInt((await g(`${type}:limit`)) ?? "0", 10) || 0;
+ if (amount > serverLimit) {
+ const n = quiet(`block:${user.id}:server`, 5 * 60 * 1000);
+ if (n !== null)
+ warn("Blocking", user.username, amount, hash, user.id, type, "serverLimit", serverLimit, suppressed(n));
+ // Say what's happening (users read the generic error as "account
+ // frozen") without revealing the limit or the hot-wallet balance.
+ fail(
+ type === PaymentType.bitcoin || type === PaymentType.liquid
+ ? "On-chain withdrawals are temporarily limited. Please try a smaller amount, send over Lightning, or try again later."
+ : "Payments are temporarily limited. Please try a smaller amount or try again later.",
+ );
+ }
+ await db.decrBy(`${type}:limit`, amount);
+ });
+ }
+
let ref;
const { id: uid, currency } = user;
+ if (!aid) aid = uid;
const rates = await g("rates");
if (!rate) rate = rates[currency];
@@ -103,27 +217,56 @@ export const debit = async ({
}
}
- const tip = parseInt(invoice?.tip) || null;
+ // Capture the tip ONCE, and only for INTERNAL payments — where credit() pays
+ // the invoice owner the same tip (sendInternal passes p.tip through). External
+ // sends (bitcoin/liquid/lightning/bolt12/fund) must NOT inherit a tip: the
+ // destination `hash` can coincidentally match another user's deposit invoice,
+ // which would otherwise pull that stranger's tip into this debit's balance
+ // check and stored record (the LiquidNode negative-balance vector). This one
+ // captured value feeds the ourfee calc, the atomic debit, and the record, so
+ // the record can never disagree with what was actually debited.
+ const tip =
+ type === PaymentType.internal ? Number.parseInt(invoice?.tip) || null : null;
if (tip < 0) fail("Invalid tip");
if (!amount || amount < 0) fail("Amount must be greater than zero");
let creditType = type;
if (creditType === PaymentType.bolt12) creditType = PaymentType.lightning;
+
+ // High-throughput pass-through accounts (flagged `ln:nofree` by
+ // ln-freetier-monitor.ts) pay an elevated platform fee on lightning
+ // withdrawals; everyone else pays the standard rate. Flagged accounts already
+ // accrue no free-tier credit, so this rate is what they actually pay.
+ let feeRate = config.fee[creditType];
+ if (
+ creditType === PaymentType.lightning &&
+ aid === uid &&
+ (await g(`ln:nofree:${uid}`))
+ )
+ feeRate = config.fee.lightningHigh;
+
let ourfee: any = [
PaymentType.bitcoin,
PaymentType.liquid,
PaymentType.lightning,
].includes(type)
- ? Math.round((amount + fee + tip) * config.fee[creditType])
+ ? Math.round((amount + fee + tip) * feeRate)
: 0;
- if (aid && aid !== uid) ourfee = 0;
+ if (aid !== uid || exempt) ourfee = 0;
+
+ // NWC and other delegated spenders can provide the exact allowance remaining
+ // for this call. Enforce it only after debit has reloaded the invoice and
+ // captured its tip, so a recipient cannot pass a small-invoice budget check,
+ // mutate the tip, and drain the account through the internal-payment path.
+ assertWithinSpendLimit({ amount, tip, fee, ourfee, maxTotal });
+
const frozenBalance =
- !blacklisted || whitelisted ? 0 : await ga(`balance:${uid}`);
+ !blacklisted || whitelisted ? 0 : await g(`balance:${uid}`);
ourfee = await db.debit(
- `balance:${aid || uid}`,
+ `balance:${aid}`,
`credit:${creditType}:${aid !== uid ? 0 : uid}`,
t(user).insufficientFunds,
amount || 0,
@@ -135,6 +278,17 @@ export const debit = async ({
if (ourfee.err) fail(ourfee.err);
+ // Defense-in-depth: the DEBIT lua rejects overdrafts, so a negative balance
+ // here means the invariant was violated by a mismatched input or a non-lua
+ // balance write. Log loudly with full context so the exact vector is greppable
+ // (SECURITY: negative balance) instead of surfacing days later as a stuck
+ // negative account.
+ const postBal = Number.parseInt((await db.get(`balance:${aid}`)) || "0");
+ if (postBal < 0)
+ err(
+ `SECURITY: negative balance ${postBal} after debit user=${user?.username} aid=${aid} amount=${amount} tip=${tip} fee=${fee} ourfee=${ourfee} type=${type} hash=${hash}`,
+ );
+
const id = v4();
const p = {
id,
@@ -147,7 +301,13 @@ export const debit = async ({
memo,
iid,
uid,
- confirmed: true,
+ // Lightning/bolt12 sends are IN-FLIGHT until the HTLC settles (preimage
+ // revealed). Don't mark them confirmed on the optimistic debit — finalize()
+ // flips this to true once the preimage arrives. Hold invoices (e.g. Ark)
+ // can keep an HTLC pending for hours/days; showing "confirmed" while the
+ // recipient hasn't been paid is misleading. Other send types settle
+ // immediately (internal) or are broadcast right away (bitcoin/liquid/fund).
+ confirmed: ![PaymentType.lightning, PaymentType.bolt12].includes(type),
rate,
currency,
type,
@@ -166,9 +326,7 @@ export const debit = async ({
.exec();
l(user.username, "sent", type, amount);
- //emit(user.id, "payment", p);
- if (![PaymentType.lightning, PaymentType.bolt12].includes(type))
- nwcNotify(p, user);
+ if (![PaymentType.lightning, PaymentType.bolt12].includes(type)) nwcNotify(p);
return p;
};
@@ -181,16 +339,23 @@ export const credit = async ({
type = PaymentType.internal,
aid = undefined,
payment_hash = undefined,
+ created = undefined,
+ tip = undefined,
}) => {
- amount = parseInt(amount) || 0;
+ amount = Number.parseInt(amount) || 0;
- let inv;
- if (type === PaymentType.bolt12) {
+ // Kill switch (see routes/payments.ts confirm): no Liquid credits at all
+ // while `liquid:deposits:disabled` is set.
+ if (type === PaymentType.liquid && (await g("liquid:deposits:disabled"))) {
+ warn("liquid credit blocked (disabled)", hash, amount, ref);
+ return;
+ }
+
+ let inv = await getInvoice(hash);
+ if (!inv && type === PaymentType.bolt12) {
const { invoices } = await ln.listinvoices({ invstring: hash });
const { local_offer_id } = invoices[0];
inv = await getInvoice(local_offer_id);
- } else {
- inv = await getInvoice(hash);
}
if (!inv) {
@@ -198,15 +363,64 @@ export const credit = async ({
return;
}
- let { path, tip } = inv;
- tip = parseInt(tip) || 0;
+ // usePreimage invoices keep their merchant-supplied preimage at
+ // preimage:${paymentHash} (see generate()). Prefer it as the settlement
+ // ref: external settlements already carry the same value, but internal
+ // ones pass ref=sender.id, which would otherwise be surfaced as the
+ // payment preimage and lose the secret the buyer paid for. Only once the
+ // invoice is fully paid, though — internal sends can settle partial
+ // amounts, and an underpayment must not reveal the secret.
+ if (
+ inv.paymentHash &&
+ ![PaymentType.bitcoin, PaymentType.liquid].includes(type) &&
+ inv.received + amount >= inv.amount
+ ) {
+ const stored = await db.get(`preimage:${inv.paymentHash}`);
+ if (stored) ref = stored;
+ }
+
+ let { path } = inv;
+ // Use the tip the sender was actually debited (passed in for internal
+ // transfers) rather than re-reading the invoice. The invoice's tip can be
+ // mutated between the sender debit and this credit (PUT /invoice), which
+ // otherwise lets a payer credit a tip they were never charged — minting
+ // balance. Fall back to the invoice tip only for external receipts
+ // (lightning/ecash) where there is no paired coinos debit to diverge from.
+ tip = tip !== undefined ? Number.parseInt(tip) || 0 : Number.parseInt(inv.tip) || 0;
if (!memo) ({ memo } = inv);
- if (memo && memo.length > 5000) fail("memo too long");
+ // Truncate rather than throw: credit() runs AFTER the payment has settled (an
+ // incoming lightning/bolt12 deposit already advanced pay_index; an internal
+ // send already debited the sender). Throwing here left the sats in the house
+ // wallet with no retry path — the payer saw a completed payment and the
+ // recipient was never credited. An oversized memo (attacker-controllable via
+ // the lnurl comment or a bolt12 payer_note) must never destroy a settled
+ // payment; a clipped note is harmless.
+ if (memo && memo.length > 5000) memo = memo.slice(0, 5000);
if (amount < 0 || tip < 0) fail("Invalid amount");
+ // For a NON-internal receipt the tip is only a cosmetic split of the real
+ // settled `amount` — there is no paired coinos debit carrying it — so it can
+ // never legitimately exceed `amount`. If the invoice tip was mutated via
+ // PUT /invoice to a value larger than the amount actually received, the
+ // fallback above would pull in that inflated tip and the stored record's
+ // `amount - tip` would go negative: a corrupt record that could be reversed
+ // into balance. The balance credit itself is `amount` and is unaffected, but
+ // clamp the tip so the record can never be negative. Internal transfers keep
+ // the full debited tip (their record stores the base amount, non-negative).
+ if (type !== PaymentType.internal && tip > amount) tip = amount;
if (type === PaymentType.internal) amount += tip;
const user = await getUser(inv.uid);
+ if (!user) {
+ // The account is gone (deleted) but its invoice still receives — e.g. a
+ // bolt12 offer a mining pool keeps paying. Record the settlement in
+ // `missing` and skip, like an unknown invoice. Throwing here wedged the
+ // lightning listener on one settlement and blocked every later receipt
+ // for two hours (2026-09-05, pay_index 1068006).
+ warn("received payment for missing user", inv.uid, hash, amount);
+ await db.sAdd("missing", ref.split(":")[0]);
+ return;
+ }
const { id: uid, currency } = user;
const rates = await g("rates");
@@ -240,7 +454,7 @@ export const credit = async ({
tip,
type,
confirmed: true,
- created: Date.now(),
+ created: created || Date.now(),
items: undefined,
};
@@ -258,31 +472,66 @@ export const credit = async ({
p.confirmed = false;
balanceKey = "pending";
await s(`payment:${txid}:${vout}`, id);
+ // Mirror the txid:vout pointer into arc so future bulk /confirm sweeps find
+ // the prior credit via gf() fallback and don't double-credit. See
+ // feedback_apr29_double_credit_incident.md for the incident this prevents.
+ await sa(`payment:${txid}:${vout}`, id);
} else {
await s(`payment:${hash}`, id);
+ await sa(`payment:${hash}`, id);
+ }
+
+ // Idempotency guard for external settlements. The getPayment() pre-check in
+ // the lightning listener/replay is not atomic: N concurrent re-credits of the
+ // same payment all read it as uncredited before any writes the pointer, so
+ // each runs the incrBy below — the /replay double-credit race. SET NX is
+ // atomic, so only the first caller claims the settlement and credits; the rest
+ // bail here, before the incrBy. `ref` is the lightning preimage, unique per
+ // settlement; internal/fund/ecash pass ref=uid and are exempt.
+ if ([PaymentType.lightning, PaymentType.bolt12].includes(type) && ref) {
+ const claimed = await db.set(`credited:${ref}`, id, { NX: true });
+ if (!claimed) {
+ warn("duplicate credit blocked", hash, ref);
+ return;
+ }
}
const m = await db.multi();
let creditType = type;
if (creditType === PaymentType.bolt12) creditType = PaymentType.lightning;
- if (
- [PaymentType.bitcoin, PaymentType.liquid, PaymentType.lightning].includes(
- creditType,
- )
- )
+ // Bitcoin/liquid always accrue the same-network fee credit.
+ if ([PaymentType.bitcoin, PaymentType.liquid].includes(creditType)) {
m.incrBy(
`credit:${creditType}:${uid}`,
Math.round(amount * config.fee[creditType]),
);
+ } else if (creditType === PaymentType.lightning) {
+ // Lightning FREE TIER: a lightning (incl. bolt12) receipt accrues a fee
+ // credit — so a normal deposit+withdraw is free — UNLESS this account is
+ // flagged as a high-throughput pass-through (using us to rebalance channels
+ // for free). ln-freetier-monitor.ts sets `ln:nofree:<uid>` from a 30-day
+ // round-trip analysis. It's behavioral + per-account, so it can't be dodged
+ // by splitting across sockpuppets: every puppet that rebalances gets flagged.
+ const noFree = await g(`ln:nofree:${uid}`);
+ if (!noFree)
+ m.incrBy(
+ `credit:lightning:${uid}`,
+ Math.round(amount * config.fee.lightning),
+ );
+ }
- m.set(`invoice:${inv.id}`, JSON.stringify(inv))
+ await m.set(`invoice:${inv.id}`, JSON.stringify(inv))
.set(`payment:${p.id}`, JSON.stringify(p))
.lPush(`${aid || uid}:payments`, p.id)
.incrBy(`${balanceKey}:${aid || uid}`, amount)
.set(`${aid || uid}:payments:last`, p.created)
.exec();
+ // Mirror the payment record + invoice into arc for the same protection.
+ await sa(`payment:${p.id}`, p);
+ await sa(`invoice:${inv.id}`, inv);
+
if (inv.items?.length) {
formatReceipt(inv.items, inv.currency);
p.items = inv.items;
@@ -293,13 +542,19 @@ export const credit = async ({
return p;
};
+const forwardSend = (o) => sendLightning({ ...o, exempt: true });
+// failed forwards retry with backoff; one process works the queue at a time
+setInterval(() => retryV3Forwards(forwardSend).catch(() => {}), 60_000).unref();
+
export const completePayment = async (inv, p, user) => {
const { id, autowithdraw, threshold, reserve, destination, username } = user;
let withdrawal;
- if (p.confirmed) {
+ // A name that moved to coinos v3: the receive is paid on to the v3 wallet
+ // (lib/v3forward.ts), and legacy autowithdraw stands down for it.
+ const forwarded = await maybeForwardToV3(p, user, forwardSend);
+ if (p.confirmed && !forwarded) {
if (autowithdraw) {
try {
- fail("autowithdrawals disabled");
const to = destination.trim();
const balance = await g(`balance:${id}`);
const amount = balance - reserve;
@@ -312,22 +567,32 @@ export const completePayment = async (inv, p, user) => {
};
}
} catch (e) {
- console.log(e);
withdrawal = { failed: true };
- warn(username, "autowithdraw failed", e.message);
+ warn(username, "autowithdraw failed", shortError(e.message));
}
}
}
nwcNotify(p);
notify(p, user, withdrawal);
- l(username, "received", p.type, p.amount);
+
+ squarePayment(p, user);
+
+ // completePayment runs at two lifecycle points for on-chain (bitcoin/liquid)
+ // deposits — once when the tx is first seen (pending) and again from /confirm
+ // when it confirms — plus once for instant lightning receives. Logging a bare
+ // "received" at each made a single on-chain deposit look like two receives in
+ // the logs (it never double-credited — the /confirm `p.confirmed` guard and
+ // the `seen` set prevent that; it was only noisy/misleading). Distinguish the
+ // states so the log reads as one deposit moving pending -> confirmed.
+ const stage = p.confirmed ? "received" : "receiving (pending)";
+ l(username, stage, p.type, p.amount);
callWebhook(inv, p);
};
const pay = async ({ aid = undefined, amount, to, user }) => {
if (!aid) aid = user.id;
- amount = parseInt(amount) || 0;
+ amount = Number.parseInt(amount) || 0;
let lnurl;
let pr;
if (to.includes("@") && to.includes(".")) {
@@ -341,14 +606,23 @@ const pay = async ({ aid = undefined, amount, to, user }) => {
}
const recipient = await getUser(to);
- if (recipient)
+ if (recipient) {
+ // An autowithdraw destination that resolves back to the sender is a
+ // self-send: the internal credit re-fires completePayment, which re-runs
+ // autowithdraw, and since the balance nets to the same value it never
+ // falls below threshold — an unbounded loop. Reject it here (the route's
+ // create() has the equivalent `Cannot send to self` guard; pay() lacked
+ // it, which is what let a self-pointed autowithdraw spin forever).
+ if (recipient.id === aid)
+ fail("Cannot autowithdraw to your own account");
return sendInternal({
amount,
recipient,
sender: user,
});
+ }
- const fee = Math.max(5, Math.round(amount * 0.005));
+ const fee = Math.max(5, Math.round(amount * 0.02));
if (lnurl) {
amount -= fee;
const { callback } = (await got(lnurl).json()) as any;
@@ -385,18 +659,58 @@ export const decode = async (hex) => {
const inflight = {};
export const sendOnchain = async (params) => {
let { aid, hex, rate, user, signed } = params;
- if (!aid) aid = user.id;
+ aid = await requireAccount({ aid, user });
+ const supplied = !!hex;
if (!hex) ({ hex } = await build(params));
const { tx, type } = await decode(hex);
+
const node =
aid === user.id ? rpc(config[type]) : rpc({ ...config[type], wallet: aid });
let { txid } = tx;
+ let locked = false;
+
+ // Only sign a transaction WE composed for this account. build() leaves a
+ // short-lived receipt keyed by txid; caller-supplied hex must match one.
+ // Otherwise the hot wallet signs whatever raw tx is posted to /bitcoin/send
+ // and the only thing between an arbitrary tx and the chain is the debit math
+ // below (plus the weSpent credit-side guard in /confirm) — this is the outer
+ // layer, so an unrecognized tx never reaches signRawTransactionWithWallet
+ // and never locks our UTXOs on the way to failing.
+ //
+ // Receipts survive client-side signing: build() returns an unsigned segwit
+ // tx and witness data doesn't change the txid, so the self-custody path
+ // (which posts back hex it finalized itself) matches the same receipt. They
+ // are not single-use — a replay of the identical tx is already stopped by
+ // inflight/lockUnspent/testMempoolAccept, and burning the receipt would
+ // break a legitimate retry after a transient failure.
+ if (supplied) {
+ const builder = await db.get(`build:${txid}`);
+ if (builder !== aid) fail("unrecognized tx");
+ }
try {
if (inflight[txid]) fail("payment in flight");
inflight[txid] = true;
+ // Reserve the exact inputs this tx spends. If locking fails, bitcoind is
+ // telling us an input is no longer unspent ("expected unspent output") —
+ // another send already committed it in the gap between build() (coin
+ // selection) and here. Broadcasting anyway would double-spend, and the
+ // higher-fee tx wins, silently losing the loser's funds (the replaced-tx
+ // incident). So abort hard rather than warn-and-continue; the debit hasn't
+ // happened yet and build() will pick fresh inputs on retry.
+ const lockVin = tx.vin.map(({ txid, vout }) => ({ txid, vout }));
+ if (lockVin.length) {
+ try {
+ await node.lockUnspent(false, lockVin);
+ locked = true;
+ } catch (e: any) {
+ warn("lockUnspent failed, aborting send", e.message);
+ fail("Selected inputs are no longer available, please retry");
+ }
+ }
+
if (!signed) {
if (config[type].walletpass)
await node.walletPassphrase(config[type].walletpass, 300);
@@ -411,6 +725,25 @@ export const sendOnchain = async (params) => {
const r = await node.testMempoolAccept([hex]);
if (!r[0].allowed) fail("transaction rejected");
+ // Known drain destinations. debit() checks `blocked_addresses` against
+ // its hash, which for an on-chain send is the txid, so the address itself
+ // was never matched here. Refuse before the debit: a drain operator can
+ // fund fresh throwaway accounts and sweep each one on-chain to the same
+ // address, so freezing accounts is a treadmill while the destination is
+ // constant.
+ for (const {
+ scriptPubKey: { address },
+ } of tx.vout) {
+ if (address && (await db.sIsMember("blocked_addresses", address))) {
+ const n = quiet(`blockedaddr:${user.id}`, 5 * 60 * 1000);
+ if (n !== null)
+ err(
+ `SECURITY: blocked on-chain send to ${address} by ${user.username} ${suppressed(n)}`,
+ );
+ fail("address blocked");
+ }
+ }
+
let total = 0;
let fee = 0;
let change = 0;
@@ -473,11 +806,20 @@ export const sendOnchain = async (params) => {
await s(`payment:${p.id}`, p);
await node.sendRawTransaction(hex);
+ broadcastToExplorers(hex);
delete inflight[txid];
return p;
} catch (e) {
delete inflight[txid];
+ // Release UTXOs only if WE locked them, so we don't accidentally free
+ // another concurrent send's reservation on a shared input.
+ if (locked) {
+ try {
+ const vin = tx?.vin?.map(({ txid, vout }) => ({ txid, vout })) ?? [];
+ if (vin.length) await node.lockUnspent(true, vin);
+ } catch {}
+ }
throw e;
}
};
@@ -490,10 +832,11 @@ export const sendKeysend = async ({
memo = undefined,
user,
extratlvs = undefined,
+ maxTotal = undefined,
}) => {
- fee = Math.max(parseInt(fee || amount * 0.005), 5);
+ fee = Math.max(Number.parseInt(fee || amount * 0.005), 5);
- let p = await g(`payment:${hash}`);
+ let p = await gf(`payment:${hash}`);
if (p) fail("duplicate keysend");
p = await debit({
@@ -503,19 +846,111 @@ export const sendKeysend = async ({
memo,
user,
type: PaymentType.lightning,
+ maxTotal,
});
- const r = await ln.keysend({
- destination: pubkey,
- amount_msat: amount * 1000,
- maxfee: fee * 1000,
- retry_for: 10,
- extratlvs,
- });
+ let outcome = "unknown";
- if (r.status !== "complete") reverse(p);
+ // COINOS-1: keysend randomizes its payment hash (the CLN command takes no
+ // preimage arg) and a throw yields no result object, so the caller-supplied
+ // `hash` is NOT the payment's real hash — verifying with it is what made the
+ // old code reverse on every throw. CLN keysend errors carry no payment_hash
+ // either (verified on v26.06.2: the error is {code, message, attempts}).
+ //
+ // Instead, tag the payment with our own unique `label` and note where the
+ // sendpays index stands beforehand. `wait sendpays created 0` returns the
+ // current index immediately, so on a throw we can list only sendpays created
+ // since — no full scan — and find ours by label regardless of error shape.
+ // `wait` isn't in the rpc client's generated method list, so go through the
+ // generic call(). nextvalue 0 never blocks — it returns the current index.
+ let startIndex: number | undefined;
+ try {
+ const w = await ln.call("wait", {
+ subsystem: "sendpays",
+ indexname: "created",
+ nextvalue: 0,
+ });
+ startIndex = w?.created;
+ } catch (e: any) {
+ warnThrottled("keysend: wait sendpays unavailable", e?.message ?? String(e));
+ }
- return r;
+ try {
+ const r = await ln.keysend({
+ destination: pubkey,
+ amount_msat: amount * 1000,
+ maxfee: fee * 1000,
+ retry_for: 10,
+ label: hash,
+ extratlvs,
+ });
+ warn("keysend returned", p.id, JSON.stringify({
+ status: r?.status,
+ has_preimage: !!(r?.payment_preimage ?? r?.preimage),
+ amount_sent_msat: r?.amount_sent_msat,
+ }));
+ // keysend blocks on retry_for until it settles or fails, so a successful
+ // return carries the preimage. Finalize HERE — flip the record
+ // pending->confirmed and refund the unused fee reserve — so every caller is
+ // covered. The NWC pay_keysend caller only polls listpays for the preimage
+ // to hand back to the client and never confirmed the record, which left
+ // every successful zap stuck "pending" forever (and check() can't rescue a
+ // keysend: its label isn't a bolt11, so the reconciler evicts it). Guard on
+ // complete+preimage; anything else stays pending for the caller/retry path.
+ const settledPreimage = r?.payment_preimage ?? r?.preimage;
+ if (r?.status === "complete" && settledPreimage) {
+ try {
+ await finalizeKeysend(r, p, settledPreimage);
+ outcome = "finalized-keysend";
+ } catch (e: any) {
+ warn("finalizeKeysend threw", p.id, e?.message);
+ outcome = "keysend-returned (finalize threw)";
+ }
+ } else {
+ outcome = "keysend-returned"; // not settled yet — caller polls for preimage
+ }
+ l("sendKeysend outcome", p.id, "=", outcome);
+ return r;
+ } catch (e: any) {
+ err("failed keysend", hash?.slice(0,16), "error:", shortError(e?.message));
+ try {
+ if (startIndex === undefined) {
+ // Couldn't establish the index baseline, so we can't bound the search.
+ // Never refund a payment that may have settled — leave the debit.
+ warn("keysend threw with no sendpays baseline — NOT reversing (manual review)", p.id, pubkey);
+ outcome = "unverified-after-keysend-throw (not reversed)";
+ } else {
+ const { payments = [] } = await ln.listsendpays({
+ index: "created",
+ start: startIndex,
+ });
+ const ours = payments.filter((x: any) => x.label === hash);
+ warn("sendpays after keysend-threw", p.id, JSON.stringify(ours.map((x: any) => ({ status: x.status }))));
+
+ if (ours.some((x: any) => x.status === "complete")) {
+ // Settled despite the throw — reversing here is the drain.
+ outcome = "keysend-completed-despite-throw";
+ } else if (!ours.length) {
+ // CLN records a sendpay BEFORE it sends the HTLC, so no record at all
+ // means nothing left the node (the common no-route/no-path failure).
+ // Safe — and necessary — to refund.
+ await reverse(p);
+ outcome = "reversed-after-keysend-throw (no htlc sent)";
+ } else if (ours.every((x: any) => x.status === "failed")) {
+ await reverse(p);
+ outcome = "reversed-after-keysend-throw (confirmed failed)";
+ } else {
+ // Still in flight: CLN may yet settle it, so leave the debit.
+ outcome = "pending-after-keysend-throw";
+ }
+ }
+ } catch (verifyErr: any) {
+ warnThrottled("sendpays verification failed (keysend)", verifyErr?.message ?? String(verifyErr));
+ outcome = "verify-failed-after-keysend-throw";
+ }
+ warn("sendKeysend outcome", p.id, "=", outcome);
+ throw e;
+ }
};
export const sendLightning = async ({
@@ -524,30 +959,86 @@ export const sendLightning = async ({
amount,
fee = undefined,
memo = undefined,
+ payerNote = undefined,
+ maxTotal = undefined,
+ exempt = false,
}) => {
let p;
+ // BOLT11 is case-insensitive, but downstream code is not: the stored payment
+ // hash, the `pending` reconciler entry, and check()'s startsWith("ln") test
+ // all assume lowercase. An uppercase-pasted invoice gets stored uppercase,
+ // then check() treats its pending entry as non-lightning and evicts it
+ // without reversing — a failed send that never gets auto-refunded. Normalize
+ // once, up front, so every downstream use sees the same lowercase form.
+ pr = pr.replace(/\s/g, "").toLowerCase();
+
if (typeof amount !== "undefined") {
- amount = parseInt(amount);
+ amount = Number.parseInt(amount);
if (amount < 0 || amount > SATS || Number.isNaN(amount)) {
warn("invalid amount", amount);
fail("Invalid amount");
}
}
+ let decoded = await ln.decode(pr);
+
+ // A bolt12 offer (lno1...) isn't payable directly — fetch a real invoice
+ // from it over onion messages, then pay that. payerNote rides along in
+ // invreq_payer_note (NIP-177 bolt12 zaps put nostr:nip177:<intent-id> there).
+ if (decoded.type === "bolt12 offer") {
+ if (decoded.offer_currency)
+ fail("Currency-denominated offers are not supported");
+ if (!decoded.offer_amount_msat && !amount) fail("Amount required");
+
+ const { invoice } = await ln.fetchinvoice({
+ offer: pr,
+ amount_msat: decoded.offer_amount_msat ? undefined : amount * 1000,
+ payer_note: payerNote,
+ timeout: 60,
+ });
+
+ pr = invoice.replace(/\s/g, "").toLowerCase();
+ decoded = await ln.decode(pr);
+ }
+
let { type, invoice_amount_msat, amount_msat, invoice_node_id, payee } =
- await ln.decode(pr);
+ decoded;
if (type.includes("bolt12")) {
amount_msat = invoice_amount_msat;
payee = invoice_node_id;
}
- let minfee = 2;
- const { channels } = await ln.listpeerchannels();
- if (channels.some((c) => c.peer_id === payee)) minfee = 0;
+ const amt = amount_msat ? Math.round(amount_msat / 1000) : amount;
+
+ // Ask askrene up front what routing this will cost — the same query xpay
+ // runs — so the maxfee we reserve is the real fee, not a guess. A caller
+ // that names its own cap keeps it (finalize refunds whatever xpay doesn't
+ // spend), but a cap below the quoted fee is rejected here, before any
+ // debit, instead of failing inside xpay after one. If the quote itself
+ // fails (askrene unavailable, no route) fall back to the old heuristic and
+ // let xpay have a go.
+ let quoted: number | undefined;
+ try {
+ ({ fee: quoted } = await quoteRoutingFee({ pr, amount: amt }));
+ } catch (e: any) {
+ warn("route quote failed", pr.substr(-8), shortError(e?.message));
+ }
- fee = Math.max(parseInt(fee || 0), minfee);
- if (fee < 0) fail("Fee cannot be negative");
+ const explicit = Number.parseInt(fee);
+ if (Number.isFinite(explicit)) {
+ if (explicit < 0) fail("Fee cannot be negative");
+ if (quoted !== undefined && explicit < quoted)
+ fail(`Routing fee of ⚡️${quoted} required, max fee is ⚡️${explicit}`);
+ fee = explicit;
+ } else if (quoted !== undefined) {
+ fee = quoted;
+ } else {
+ let minfee = Math.max(5, Math.round(amt * 0.005));
+ const { channels } = await ln.listpeerchannels();
+ if (channels.some((c) => c.peer_id === payee)) minfee = 0;
+ fee = minfee;
+ }
const { pays } = await ln.listpays(pr);
if (pays.find((p) => p.status === "complete"))
@@ -556,6 +1047,17 @@ export const sendLightning = async ({
if (pays.find((p) => p.status === "pending"))
fail("Payment is already underway");
+ // A prior attempt at this same invoice may still be awaiting reconciliation
+ // by check() (its CLN parts resolved but the debit hasn't been reversed or
+ // finalized yet). The pending set is keyed by bolt11 and shared across
+ // attempts, so letting a retry proceed here means the retry's own
+ // reverse()/finalize() clears the shared entry and ORPHANS the first
+ // attempt's debit — check() never sees it again and the user is never
+ // refunded. Make the retry wait until reconciliation clears the entry
+ // (seconds to ~a minute).
+ if (await db.sIsMember("pending", pr))
+ fail("Previous attempt is still settling, please retry in a minute");
+
p = await debit({
hash: pr,
amount: amount_msat ? Math.round(amount_msat / 1000) : amount,
@@ -563,11 +1065,17 @@ export const sendLightning = async ({
memo,
user,
type: PaymentType.lightning,
+ maxTotal,
+ exempt,
});
await db.sAdd("pending", pr);
- l("paying lightning invoice", pr.substr(-8), amount, fee);
+ // Instrumentation: track which exit path the payment took so we can
+ // diagnose any future stuck "optimistic" cases (confirmed=true with no
+ // ref, no pending-set membership). Logged just before the function
+ // returns; if "outcome=unsettled" appears in the logs we have a leak.
+ let outcome = "unknown";
try {
const r = await ln.xpay({
@@ -575,20 +1083,133 @@ export const sendLightning = async ({
// bolt11: pr.replace(/\s/g, "").toLowerCase(),
amount_msat: amount_msat ? undefined : amount * 1000,
maxfee: fee * 1000,
- retry_for: 10,
+ // 30s wasn't enough to get past a drained zero-fee hop. Route-finding
+ // prefers free channels, so xpay would spend the whole budget retrying
+ // one 0-base/0-ppm channel that is permanently empty in our direction
+ // and time out ("64 attempts, temporary_channel_failure for the same
+ // scid") without ever trying the paths that work — Strike/Zap payments
+ // failed this way for every user. xpay is in ln.ts's NO_TIMEOUT set and
+ // nothing in front of it (fastify, Caddy, the UI fetch) imposes a
+ // deadline, so the longer budget runs to completion.
+ //
+ // COUPLED TO check(): that reversal sweep must keep skipping payments
+ // for at least twice this window, or it reverses a payment xpay is
+ // still driving and we refund one that settles (the LEAKED DEBIT
+ // losses). Change both together.
+ retry_for: 60,
+ layers: await xpayLayers(),
});
+ // Only log the xpay response when there's a real concern — no preimage
+ // at all (xpay failed silently and we'll need to reverse) is interesting;
+ // failed_parts WITH a preimage is normal multi-path retry behavior and
+ // not worth warning about. The failed_parts branch below handles the
+ // recovery without log spam in the success case.
+ if (!(r?.payment_preimage ?? r?.preimage)) {
+ warn("xpay returned no preimage", p.id, JSON.stringify({
+ status: r?.status,
+ failed_parts: r?.failed_parts,
+ parts: r?.parts,
+ amount_sent_msat: r?.amount_sent_msat,
+ amount_msat: r?.amount_msat,
+ }));
+ }
+
+ if (r.failed_parts) {
+ // xpay didn't throw but reported failed parts. Verify with listpays
+ // before deciding — for multi-path payments this is the common path
+ // and usually finalizes cleanly; the noisy warns only fire when
+ // listpays disagrees with xpay or finalize itself throws.
+ try {
+ const { pays } = await ln.listpays(pr);
+ const completed = pays.find((x) => x.status === "complete");
+ const failed = !pays.length || pays.every((x) => x.status === "failed");
+ if (completed) {
+ try { await finalize(completed, p); outcome = "finalized-via-listpays-after-failed_parts"; } catch (e: any) { warn("finalize threw in failed_parts branch", p.id, e?.message); outcome = "finalize-threw-failed_parts"; }
+ } else if (failed) {
+ warn("xpay failed_parts and listpays failed — reversing", p.id);
+ await reverse(p); outcome = "reversed-via-failed_parts";
+ } else {
+ warn("xpay failed_parts, listpays still pending — leaving for check()", p.id);
+ outcome = "pending-after-failed_parts";
+ }
+ } catch (verifyErr: any) {
+ warnThrottled("listpays verification failed (failed_parts branch)", verifyErr?.message ?? String(verifyErr));
+ outcome = "verify-failed-after-failed_parts";
+ }
+ } else {
+ try {
+ p = await finalize(r, p);
+ outcome = "finalized";
+ } catch (e: any) {
+ warn("finalize threw despite no failed_parts", p.id, e?.message);
+ outcome = "finalize-threw";
+ }
+ }
+ } catch (e: any) {
+ err("failed to pay", pr.substr(-8), "xpay error:", shortError(e?.message));
try {
- if (!r.failed_parts) p = await finalize(r, p);
- } catch (e) {
- warn("failed to process payment", p.id);
+ const { pays } = await ln.listpays(pr);
+ warn("listpays after xpay-threw", p.id, JSON.stringify(pays?.map((x) => ({ status: x.status, amount_sent_msat: x.amount_sent_msat })) ?? []));
+ const completed = pays.find((p) => p.status === "complete");
+ const failed = !pays.length || pays.every((p) => p.status === "failed");
+ if (completed) {
+ warn("payment completed despite error, finalizing", p.id);
+ try { await finalize(completed, p); outcome = "finalized-after-xpay-throw"; } catch (_) { outcome = "finalize-threw-after-xpay-throw"; }
+ } else if (failed) {
+ await reverse(p); outcome = "reversed-after-xpay-throw";
+ } else {
+ outcome = "pending-after-xpay-throw";
+ }
+ // else: pending — leave the debit in place; check() loop will reconcile
+ // once CLN confirms outcome. Reversing while still in flight would
+ // refund the user even though the payment may yet complete.
+ } catch (verifyErr: any) {
+ // listpays itself errored (CLN unreachable). We deliberately don't
+ // reverse here — the debit stays and pending stays in the reconciler
+ // set, so check() will retry once CLN is back. Surface the failure so
+ // a real outage doesn't go unnoticed.
+ warnThrottled("listpays verification failed", verifyErr?.message ?? String(verifyErr));
+ outcome = "verify-failed-after-xpay-throw";
}
- } catch (e) {
- err("failed to pay", pr.substr(-8));
- reverse(p);
+ warn("sendLightning outcome", p.id, "=", outcome);
throw e;
}
+ // Leak guard: re-read the db record before deciding. If the payment has
+ // ref set in db, it's settled regardless of which code path threw —
+ // finalize() may have set ref via db.set and then thrown on a later line
+ // (e.g. balance.incrBy for the fee refund), but the payment itself is
+ // recorded as complete. Only treat as a real leak if ref is not in db.
+ const finalizedOutcomes = new Set([
+ "finalized",
+ "finalized-via-listpays-after-failed_parts",
+ "finalized-after-xpay-throw",
+ ]);
+ const safeNonFinalized = new Set([
+ "reversed-via-failed_parts",
+ "reversed-after-xpay-throw",
+ "pending-after-failed_parts",
+ "pending-after-xpay-throw",
+ "verify-failed-after-failed_parts",
+ "verify-failed-after-xpay-throw",
+ ]);
+ const persisted = await g(`payment:${p.id}`);
+ const refInDb = !!persisted?.ref;
+ if (refInDb || finalizedOutcomes.has(outcome)) {
+ // Only log when the outcome wasn't the boring success case — every
+ // payment goes through "finalized" so logging it is just noise. The
+ // recovery-via-fallback variants (finalized-via-listpays-*, etc.) are
+ // worth knowing about, so emit those.
+ if (outcome !== "finalized") {
+ l("sendLightning outcome", p.id, "=", outcome, refInDb && !finalizedOutcomes.has(outcome) ? "(ref set in db despite throw)" : "");
+ }
+ } else if (safeNonFinalized.has(outcome)) {
+ warn("sendLightning outcome", p.id, "=", outcome);
+ } else {
+ err("LEAKED DEBIT in sendLightning", p.id, "outcome=", outcome, "user=", user?.username, "amount=", amount, "fee=", fee);
+ }
+
return p;
};
@@ -598,6 +1219,7 @@ export const sendInternal = async ({
memo = undefined,
recipient,
sender,
+ maxTotal = undefined,
}) => {
if (!invoice)
invoice = await generate({
@@ -606,8 +1228,8 @@ export const sendInternal = async ({
});
const { hash } = invoice;
- const p = await debit({ hash, amount, memo, user: sender });
- await credit({ hash, amount, memo, ref: sender.id });
+ const p = await debit({ hash, amount, memo, user: sender, maxTotal });
+ await credit({ hash, amount, memo, ref: sender.id, tip: p.tip });
if (invoice.memo?.includes("9734")) {
const { invoices } = await ln.listinvoices({ invstring: hash });
@@ -628,7 +1250,16 @@ const getAddressType = async (a) => {
try {
await lq.getAddressInfo(a);
return PaymentType.liquid;
- } catch (e) {
+ } catch (e: any) {
+ // Distinguish "lq is down" from "address isn't liquid" so users get a useful
+ // message instead of "unrecognized address" when cs/lq is the actual problem.
+ if (
+ e?.message?.includes("Unable to connect") ||
+ e?.code === "ECONNREFUSED" ||
+ e?.code === "ETIMEDOUT"
+ ) {
+ fail("liquid temporarily unavailable");
+ }
fail("unrecognized address");
}
}
@@ -643,11 +1274,11 @@ export const build = async ({
user,
}) => {
const type = await getAddressType(address);
- if (!aid) aid = user.id;
+ aid = await requireAccount({ aid, user });
const node =
aid === user.id ? rpc(config[type]) : rpc({ ...config[type], wallet: aid });
- amount = parseInt(amount);
+ amount = Number.parseInt(amount);
if (amount < 0) fail("invalid amount");
const fees: any =
@@ -655,13 +1286,10 @@ export const build = async ({
? { fastestFee: 0.1, halfHourFee: 0.1, hourFee: 0.1 }
: await fetch(`${api[type]}/fees/recommended`).then((r) => r.json());
- fees.hourFee = fees.halfHourFee;
- fees.halfHourFee = fees.fastestFee;
-
if (type === PaymentType.bitcoin) {
- fees.fastestFee = Math.round(fees.fastestFee * 1.1);
- if (fees.fastestFee === fees.halfHourFee) fees.fastestFee++;
- if (fees.hourFee === fees.halfHourFee) fees.hourFee--;
+ fees.hourFee = fees.halfHourFee;
+ fees.halfHourFee = fees.fastestFee;
+ fees.fastestFee = Math.ceil(fees.fastestFee * 1.5);
}
if (!feeRate) {
@@ -723,7 +1351,12 @@ export const build = async ({
}
const inputs = [];
- const { vin } = await node.decodeRawTransaction(tx.hex);
+ const { vin, txid: built } = await node.decodeRawTransaction(tx.hex);
+
+ // Receipt for sendOnchain(): this account composed this exact tx. Bound to
+ // aid so one account can't replay another's build, and short-lived so a hex
+ // can't be banked for later.
+ await db.set(`build:${built}`, aid, { EX: 3600 });
for (const { txid, vout } of vin) {
const rawTx = await node.getRawTransaction(txid);
@@ -738,24 +1371,35 @@ export const build = async ({
inputs.push({ witnessUtxo, path });
}
+
return { feeRate, ourfee, fee, fees, hex: tx.hex, inputs, subtract };
};
export const catchUp = async () => {
try {
const txns = [];
+ // Each chain is isolated: one being unreachable (e.g. lq when cs is down)
+ // must not stop the other chain's deposit-recovery loop.
for (const [type, n] of Object.entries({ bitcoin: bc, liquid: lq })) {
- txns.push(
- ...(await n.listTransactions("*", 10)).filter((tx) => {
- tx.type = type;
- return tx.category === "receive" && tx.confirmations > 0;
- }),
- );
+ try {
+ txns.push(
+ ...(await n.listTransactions("*", 100)).filter((tx) => {
+ tx.type = type;
+ return tx.category === "receive" && tx.confirmations > 0;
+ }),
+ );
+ } catch (e: any) {
+ warnThrottled(`catchUp ${type}`, e.message);
+ }
}
for (const { txid, type } of txns) {
try {
if (await db.zScore("seen", txid)) continue;
+ // walletnotify is the primary path; catchUp is a safety net. The
+ // double-call I added 2026-05-05 was for when walletnotify was broken
+ // on cs's lq — now that's fixed, a single call suffices and avoids
+ // contributing to /confirm race conditions.
await got.post(`http://localhost:${process.env.PORT || 3119}/confirm`, {
json: { txid, wallet: config[type].wallet, type },
});
@@ -771,66 +1415,7 @@ export const catchUp = async () => {
err("problem syncing", e.message);
}
- setTimeout(catchUp, 10000);
-};
-
-export const reconcile = async (account, initial = false) => {
- try {
- const { descriptors, id, uid, type } = account;
- const user = await getUser(uid);
- const node = rpc({ ...config[type], wallet: id });
-
- let total;
-
- if (initial) {
- const progress = await node.scanTxOutSet("status");
- if (progress) return setTimeout(() => reconcile(account, initial), 1000);
-
- const { total_amount } = await node.scanTxOutSet("start", descriptors);
- total = Math.round(total_amount * SATS);
- } else {
- total = Math.round((await node.getBalance({ minconf: 1 })) * SATS);
- }
-
- const { balanceAdjustment: memo } = t(user);
-
- const balance = await g(`balance:${id}`);
-
- const amount = Math.abs(total - balance);
- const hash = v4();
-
- if (total > balance) {
- const inv = {
- memo,
- type: PaymentType.reconcile,
- hash,
- amount,
- uid,
- aid: id,
- };
- await s(`invoice:${hash}`, inv);
- await credit({
- hash,
- amount,
- type: PaymentType.reconcile,
- aid: id,
- });
- } else if (total < balance) {
- await debit({
- aid: id,
- amount,
- hash: v4(),
- memo,
- user,
- type: PaymentType.reconcile,
- });
- }
- } catch (e) {
- console.log(e);
- warn("problem reconciling", e.message, account);
- if (e.message.includes("progress"))
- return setTimeout(() => reconcile(account, initial), 1000);
- }
+ setTimeout(catchUp, 30000);
};
export const check = async () => {
@@ -839,8 +1424,34 @@ export const check = async () => {
const payments = await db.sMembers("pending");
for (const pr of payments) {
+ // Case-insensitive: an uppercase-pasted invoice ("LNBC…") is still a
+ // lightning payment. Matching case-sensitively here used to evict such
+ // entries without reversing, stranding failed sends with no auto-refund.
+ if (!pr.toLowerCase().startsWith("ln")) {
+ await db.sRem("pending", pr);
+ continue;
+ }
const p = await getPayment(pr);
- if (!p || Date.now() - p.created < 10000) continue;
+ // Skip payments sendLightning may still be actively driving. xpay retries
+ // for 60s (retry_for: 60), during which listpays can momentarily show all
+ // attempts "failed" before the winning part lands. The old 10s threshold
+ // let check() reverse/refund such a payment mid-flight; it then completed,
+ // and sendLightning's finalize threw on the deleted record — refunding a
+ // payment that actually settled (the LEAKED DEBIT losses). Wait well past
+ // the retry window so sendLightning has finished finalize()/reverse() and
+ // removed it from `pending` before check() ever touches it. This is 2x
+ // retry_for, the same margin the 60s threshold gave the old 30s window —
+ // raise it with retry_for if that ever changes again.
+ // No record anywhere (main db or archive) means there's nothing left to
+ // reconcile — the debit was already reversed or the entry is a stale
+ // stray. Drop it: sendLightning now refuses to re-pay an invoice while
+ // its bolt11 sits in this set, so a dead entry would otherwise block
+ // that invoice forever.
+ if (!p) {
+ await db.sRem("pending", pr);
+ continue;
+ }
+ if (Date.now() - p.created < 120000) continue;
const { pays } = await ln.listpays(pr);
const failed = !pays.length || pays.every((p) => p.status === "failed");
@@ -851,13 +1462,16 @@ export const check = async () => {
else if (failed) await reverse(p);
} catch (e) {
err("failed to finalize", p.id, e.message);
+ if (e.message?.includes("already been reversed")) {
+ await db.sRem("pending", p.hash);
+ }
}
}
} catch (e) {
err("payment check failed", e.message);
}
- setTimeout(check, 2000);
+ setTimeout(check, 5000);
};
const finalize = async (r, p) => {
@@ -866,21 +1480,89 @@ const finalize = async (r, p) => {
if (!preimage) fail("missing preimage");
await db.sRem("pending", p.hash);
- l("payment completed", p.id, r.payment_preimage);
nwcNotify(p);
const maxfee = p.fee;
- const { amount_msat } = await ln.decode(p.hash);
- p.fee = Math.round((r.amount_sent_msat - amount_msat) / 1000);
+ // Compute the actual fee xpay paid (sent − invoice amount, both msat).
+ // Robust against zero-amount invoices (decoded amount_msat is undefined,
+ // fall back to r.amount_msat) and against CLN occasionally returning
+ // unexpected response shapes — if we can't compute a finite integer
+ // fee, keep p.fee at the originally-reserved maxfee so the subsequent
+ // refund delta is 0 instead of NaN (which would crash db.incrBy with
+ // "value is not an integer or out of range" and leave finalize in a
+ // partial state).
+ const decoded = await ln.decode(p.hash);
+ const invMsat = Number(decoded.amount_msat ?? r.amount_msat);
+ const sentMsat = Number(r.amount_sent_msat);
+ // Round UP: the routing fee was quoted and reserved in whole sats (a
+ // 301 msat route is a 1 sat maxfee — xpay's cap is exact and a few msat
+ // short fails the payment), so charge the sat that was quoted rather than
+ // rounding 301 msat to 0 and refunding it. Otherwise a "send everything"
+ // computed from the quote strands a single sat in the account.
+ const computedFee = Math.ceil((sentMsat - invMsat) / 1000);
+ if (!Number.isFinite(computedFee)) {
+ warn("finalize: non-finite fee compute, keeping reserved maxfee", p.id,
+ "sent_msat=", r.amount_sent_msat, "inv_msat=", decoded.amount_msat ?? r.amount_msat);
+ }
+ p.fee = Number.isFinite(computedFee) ? computedFee : maxfee;
p.ref = preimage;
+ // The HTLC settled (we have the preimage) — the send is now fully confirmed.
+ p.confirmed = true;
+
+ const current = await g(`payment:${p.id}`);
+ // The record is gone because a concurrent reverse() already refunded this
+ // payment — yet here we are finalizing it, so it actually COMPLETED on the
+ // network and the refund was wrong (coinos is now short the amount). Do NOT
+ // silently re-create the record; raise a clear, greppable error so the
+ // LEAKED DEBIT guard logs it for manual recovery instead of a cryptic
+ // null-deref. (The check()-window fix should make this path very rare.)
+ if (!current) fail(`finalize: payment ${p.id} reversed-then-completed (double-pay leak)`);
- if (!(await g(`payment:${p.id}`)).ref) {
+ if (!current.ref) {
await s(`payment:${p.id}`, p);
+ const refund = maxfee - p.fee;
+ if (Number.isFinite(refund) && Number.isInteger(refund)) {
+ await db.incrBy(`balance:${p.uid}`, refund);
+ } else {
+ warn("finalize: skipping fee refund (non-integer delta)", p.id,
+ "maxfee=", maxfee, "p.fee=", p.fee, "refund=", refund);
+ }
+ }
- l("refunding fee", maxfee, p.fee, maxfee - p.fee, p.ref);
- await db.incrBy(`balance:${p.uid}`, maxfee - p.fee);
+ return p;
+};
+
+// Keysend variant of finalize(). Keysend has no bolt11, so finalize()'s
+// `ln.decode(p.hash)` can't run — the real fee is instead the msat actually sent
+// minus the record's own amount. Otherwise identical: flip pending->confirmed,
+// set the preimage, refund the unused fee reserve, and refuse to re-create a
+// record a concurrent reverse() already removed (a settled-but-refunded payment
+// is a double-pay leak, surfaced for manual recovery rather than papered over).
+const finalizeKeysend = async (r, p, preimage) => {
+ await db.sRem("pending", p.hash); // keysend rarely enters the set; defensive
+
+ const maxfee = p.fee;
+ const sentMsat = Number(r.amount_sent_msat);
+ const actualFee = Math.ceil((sentMsat - Math.abs(p.amount) * 1000) / 1000);
+ p.fee = Number.isFinite(actualFee) && actualFee >= 0 ? actualFee : maxfee;
+ p.ref = preimage;
+ p.confirmed = true;
+
+ const current = await g(`payment:${p.id}`);
+ if (!current) fail(`finalizeKeysend: payment ${p.id} reversed-then-completed (double-pay leak)`);
+
+ if (!current.ref) {
+ await s(`payment:${p.id}`, p);
+ const refund = maxfee - p.fee;
+ if (Number.isFinite(refund) && Number.isInteger(refund)) {
+ await db.incrBy(`balance:${p.uid}`, refund);
+ } else {
+ warn("finalizeKeysend: skipping fee refund (non-integer delta)", p.id,
+ "maxfee=", maxfee, "p.fee=", p.fee, "refund=", refund);
+ }
}
+ nwcNotify(p);
return p;
};
@@ -889,7 +1571,13 @@ const reverse = async (p) => {
const total = Math.abs(p.amount) + p.fee + p.ourfee;
const ourfee = p.ourfee || 0;
- const credit = Math.round(total * config.fee[PaymentType.lightning]) - ourfee;
+ // Restore the free-tier credit consumed at debit time. Use the SAME rate the
+ // send was charged at — flagged `ln:nofree` accounts were charged the elevated
+ // rate, so reversing with the standard rate would push their credit negative.
+ const rate = (await g(`ln:nofree:${p.uid}`))
+ ? config.fee.lightningHigh
+ : config.fee.lightning;
+ const credit = Math.round(total * rate) - ourfee;
l("reversing", p.id, p.amount, p.fee, total, ourfee, credit);
@@ -909,27 +1597,70 @@ const reverse = async (p) => {
};
const freezeCheck = async () => {
- const funds = await ln.listfunds();
- const lnbalance = Math.round(
- funds.channels.reduce((a, b) => a + b.our_amount_msat, 0) / 1000,
- );
-
- const bcbalance = Math.round((await bc.getBalance()) * SATS);
- const { bitcoin } = await lq.getBalance();
- const lqbalance = Math.round(bitcoin * SATS);
-
- const lnthreshold = await g("lightning:threshold");
- const bcthreshold = await g("bitcoin:threshold");
- const lqthreshold = await g("liquid:threshold");
+ // Crash-resilient loop: ANY error in the body (an unhandled rejection in the
+ // limit-write section, a stuck withLimitLock, etc.) used to escape here and
+ // kill the loop silently (the global unhandledRejection handler is a no-op),
+ // freezing every ${type}:limit until someone noticed sends failing. Wrap the
+ // whole body so errors are logged and the next tick is ALWAYS scheduled.
+ try {
+ // Each asset is fetched independently so e.g. lq being unreachable doesn't
+ // prevent lightning and bitcoin limits from refreshing.
+ let lnbalance: number | undefined;
+ try {
+ const funds = await ln.listfunds();
+ // Only spendable channels — ONCHAIN/CLOSING funds are locked in pending
+ // sweeps and aren't usable for lightning sends, so counting them inflates
+ // the limit. CHANNELD_AWAITING_SPLICE is included so an in-flight splice
+ // doesn't briefly drop the limit to zero.
+ lnbalance = Math.round(
+ funds.channels
+ .filter((c) =>
+ c.state === "CHANNELD_NORMAL" || c.state === "CHANNELD_AWAITING_SPLICE",
+ )
+ .reduce((a, b) => a + b.our_amount_msat, 0) / 1000,
+ );
+ } catch (e: any) {
+ warnThrottled("freezeCheck lightning", e.message);
+ }
- await s("lightning:limit", Math.max(lnbalance - lnthreshold, 0));
- await s("bitcoin:limit", Math.max(bcbalance - bcthreshold, 0));
- await s("liquid:limit", Math.max(lqbalance - lqthreshold, 0));
+ let bcbalance: number | undefined;
+ try {
+ bcbalance = Math.round((await bc.getBalance()) * SATS);
+ } catch (e: any) {
+ warnThrottled("freezeCheck bitcoin", e.message);
+ }
- await s("fund:limit", Math.max(lnbalance - lnthreshold, 0));
- await s("ecash:limit", Math.max(lnbalance - lnthreshold, 0));
- await s("bolt12:limit", Math.max(lnbalance - lnthreshold, 0));
+ let lqbalance: number | undefined;
+ try {
+ const { bitcoin } = await lq.getBalance();
+ lqbalance = Math.round(bitcoin * SATS);
+ } catch (e: any) {
+ warnThrottled("freezeCheck liquid", e.message);
+ }
- setTimeout(freezeCheck, 10000);
+ if (lnbalance !== undefined) {
+ const lnthreshold = await g("lightning:threshold");
+ const lim = Math.max(lnbalance - lnthreshold, 0);
+ for (const t of ["lightning", "fund", "ecash", "bolt12"]) {
+ await withLimitLock(t, async () => { await s(`${t}:limit`, lim); });
+ }
+ }
+ if (bcbalance !== undefined) {
+ const bcthreshold = await g("bitcoin:threshold");
+ await withLimitLock("bitcoin", async () => {
+ await s("bitcoin:limit", Math.max(bcbalance - bcthreshold, 0));
+ });
+ }
+ if (lqbalance !== undefined) {
+ const lqthreshold = await g("liquid:threshold");
+ await withLimitLock("liquid", async () => {
+ await s("liquid:limit", Math.max(lqbalance - lqthreshold, 0));
+ });
+ }
+ } catch (e: any) {
+ warn("freezeCheck: iteration failed, will retry next tick:", e?.message ?? String(e));
+ } finally {
+ setTimeout(freezeCheck, 10000);
+ }
};
freezeCheck();
diff --git a/lib/rates.ts b/lib/rates.ts
index dcb44464..21a075b3 100644
--- a/lib/rates.ts
+++ b/lib/rates.ts
@@ -8,6 +8,18 @@ import WebSocket from "ws";
export let rate;
let last;
let ws;
+
+// The Iranian market IRR is fetched on its own timer rather than on every binance
+// tick — fetching inline hammered the source and, when it's unreachable (recurring
+// geo-block / outage), failed + logged on every tick. We cache the last-good value
+// and apply it in onmessage; the tick loop never blocks on it.
+// Source: Wallex (api.wallex.ir) BTCTMN last price. We switched off Nobitex on
+// 2026-07-10 after apiv2.nobitex.ir became unreachable from our egress (its
+// Iranian IPs refuse foreign connections); Wallex's API is reachable and its
+// BTCTMN cross-checks against BTCUSDT × USDTTMN to <0.1%.
+let iranIrr = 0;
+let iranIrrTime = 0;
+let iranErrLogged = 0;
const connect = async () => {
if (ws && ws.readyState === 1 && Date.now() - last < 5000) return;
if (ws) ws.terminate() && (await sleep(Math.round(Math.random() * 1000)));
@@ -25,20 +37,20 @@ const connect = async () => {
rates[symbol] = msg.c * fx[symbol];
});
- try {
- rates.IRT = (
- (await got(
- "https://api.nobitex.ir/v2/orderbook/BTCIRT",
- ).json()) as any
- ).lastTradePrice;
- } catch (e) {}
+ // Apply the Iranian market IRR cached by updateIranRate() below (Wallex).
+ // iranIrr is stored in Rial, so IRR = iranIrr and Toman = Rial / 10. If the
+ // source has been unreachable for over an hour, we leave the fixer official
+ // IRR set in the fx loop above rather than serving an ever-staler market rate.
+ if (iranIrr > 0 && Date.now() - iranIrrTime < 60 * 60 * 1000) {
+ rates.IRR = iranIrr;
+ rates.IRT = iranIrr / 10;
+ }
rate = msg.c;
s("rate", rate);
s("rates", rates);
last = Date.now();
} catch (e) {
- console.log(e);
err("binance message error", e.message);
}
};
@@ -50,6 +62,31 @@ const connect = async () => {
return ws;
};
+// Fetch the Iranian market rate from Wallex on its own 60s timer, decoupled from
+// the binance tick loop. Wallex quotes BTCTMN in Toman, so IRR (Rial) = price*10.
+// Caches the last-good value; on failure (recurring geo-block / outage) throttles
+// error logging to once per 5 min instead of spamming on every attempt. A 5s
+// request timeout keeps a hung endpoint from piling up.
+const updateIranRate = async () => {
+ try {
+ const data = (await got("https://api.wallex.ir/v1/markets", {
+ timeout: { request: 5000 },
+ }).json()) as any;
+ const tmn = Number(data?.result?.symbols?.BTCTMN?.stats?.lastPrice);
+ if (tmn > 0) {
+ iranIrr = tmn * 10; // Toman -> Rial
+ iranIrrTime = Date.now();
+ }
+ } catch (e) {
+ if (Date.now() - iranErrLogged > 5 * 60 * 1000) {
+ err("Iran IRR/IRT rate fetch failed (wallex)", e.message);
+ iranErrLogged = Date.now();
+ }
+ }
+ setTimeout(updateIranRate, 60000);
+};
+updateIranRate();
+
export const getFx = async () => {
connect();
diff --git a/lib/register.ts b/lib/register.ts
index 0db5dfd0..a9e9d7f7 100644
--- a/lib/register.ts
+++ b/lib/register.ts
@@ -2,7 +2,9 @@ import config from "$config";
import countries from "$lib/countries";
import { db, s } from "$lib/db";
import { l, warn } from "$lib/logging";
-import { fail, getUser } from "$lib/utils";
+import { fail } from "$lib/utils";
+import { assertNameFree } from "$lib/names";
+import { isReserved } from "$lib/reserved";
import { bytesToHex, randomBytes } from "@noble/hashes/utils";
import { got } from "got";
import { getPublicKey, nip19 } from "nostr-tools";
@@ -19,20 +21,26 @@ export default async (user, ip) => {
username = username.toLowerCase().replace(/\s/g, "");
if (!valid.test(username))
fail("Usernames can only have letters and numbers");
+ if (isReserved(username)) fail("Invalid username");
+ if (username.includes("undefined")) fail("Invalid username");
const id = v4();
user.id = id;
const exists = await db.exists(`user:${username}`);
if (exists) fail(`Username ${username} taken`);
+ // A name the v3 registrar holds is taken too (see lib/names.ts).
+ await assertNameFree(username, pubkey);
if (password) {
user.password = await Bun.password.hash(password, {
algorithm: "bcrypt",
- cost: 4,
+ cost: 12,
});
}
+ user.created = Date.now();
+
user.currency = "USD";
if (config.ipregistry) {
try {
@@ -43,6 +51,7 @@ export default async (user, ip) => {
).json();
user.currency = countries[code];
+ user.country = code; // stored for analytics (signup geography)
} catch (e) {
warn("unable to detect country from IP", username);
}
@@ -85,8 +94,6 @@ export default async (user, ip) => {
await s(`app:${app.pubkey}`, app);
await db.sAdd(`${id}:apps`, app.pubkey);
- console.log("USER", user);
-
db.multi()
.set(`user:${id}`, JSON.stringify(user))
.set(`user:${username}`, id)
diff --git a/lib/reserved.ts b/lib/reserved.ts
new file mode 100644
index 00000000..df5f0d43
--- /dev/null
+++ b/lib/reserved.ts
@@ -0,0 +1,31 @@
+// Usernames nobody may register or rename into. A coinos username is also a
+// lightning address, so a name that reads as ours lets an account collect
+// payments meant for the service: "mint" was claimed within days of the ecash
+// redesign freeing it (2026-09-18) and took payments addressed to
+// [email protected] until it was renamed.
+//
+// Checked on registration (lib/register.ts) and on rename (routes/users.ts).
+// Names already held by the v3 registrar are refused separately, by
+// assertNameFree in lib/names.ts.
+export const reserved = [
+ "admin",
+ "administrator",
+ "ark",
+ "cash",
+ "coinos",
+ "ecash",
+ "fund",
+ "help",
+ "info",
+ "lightning",
+ "mint",
+ "pool",
+ "root",
+ "security",
+ "support",
+ "system",
+ "wallet",
+];
+
+export const isReserved = (username: string) =>
+ reserved.includes(String(username || "").toLowerCase().replace(/\s/g, ""));
diff --git a/lib/safe-fetch.ts b/lib/safe-fetch.ts
new file mode 100644
index 00000000..5b1059e9
--- /dev/null
+++ b/lib/safe-fetch.ts
@@ -0,0 +1,165 @@
+// SSRF-resistant fetch for user-supplied URLs (lnurl / lightning-address
+// resolution in routes/lnurl.ts). Rejects any host that resolves to a
+// loopback, private, link-local, CGNAT, or cloud-metadata address.
+//
+// Unlike a resolve-then-fetch guard (which re-resolves DNS when it actually
+// connects, leaving a DNS-rebinding TOCTOU window), this pins the socket to the
+// exact IP it validated: the connection's DNS lookup IS the validation, in one
+// callback, so a low-TTL rebind cannot swap in an internal IP after the check.
+// Redirects are followed manually and each hop is re-validated the same way.
+// Scheme is restricted to http(s); 8s timeout; 2MB streamed-body cap.
+import http from "node:http";
+import https from "node:https";
+import dns from "node:dns";
+import net from "node:net";
+
+const MAX_BYTES = 2_000_000;
+const MAX_REDIRECTS = 4;
+const TIMEOUT_MS = 8000;
+
+const blockedV4 = (ip: string): boolean => {
+ const p = ip.split(".").map(Number);
+ if (p.length !== 4 || p.some((n) => Number.isNaN(n))) return true;
+ const [a, b] = p;
+ if (a === 0) return true; // 0.0.0.0/8
+ if (a === 10) return true; // private
+ if (a === 127) return true; // loopback
+ if (a === 169 && b === 254) return true; // link-local + cloud metadata (169.254.169.254)
+ if (a === 172 && b >= 16 && b <= 31) return true; // private
+ if (a === 192 && b === 168) return true; // private
+ if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT 100.64/10 (incl. tailscale)
+ if (a >= 224) return true; // multicast / reserved
+ return false;
+};
+
+// Expand an IPv6 address to its 8 16-bit groups. Handles "::" and a trailing
+// dotted quad. new URL() re-serializes IPv6 hosts in hex, so "[::ffff:10.0.0.1]"
+// arrives here as "::ffff:a00:1" — matching on the text form is not enough.
+const v6Groups = (ip: string): number[] | null => {
+ let s = ip.toLowerCase().split("%")[0];
+ const quad = s.match(/(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
+ if (quad) {
+ const [a, b, c, d] = quad.slice(1).map(Number);
+ s = `${s.slice(0, quad.index)}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`;
+ }
+ const halves = s.split("::");
+ if (halves.length > 2) return null;
+ const part = (h: string) => (h ? h.split(":").map((x) => Number.parseInt(x, 16)) : []);
+ const head = part(halves[0]);
+ const tail = halves.length === 2 ? part(halves[1]) : [];
+ const fill = 8 - head.length - tail.length;
+ if (fill < 0 || (halves.length === 1 && fill !== 0)) return null;
+ const g = [...head, ...Array(fill).fill(0), ...tail];
+ return g.some((n) => Number.isNaN(n) || n < 0 || n > 0xffff) ? null : g;
+};
+
+const v4Of = (hi: number, lo: number) =>
+ `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`;
+
+const blockedV6 = (ip: string): boolean => {
+ const g = v6Groups(ip);
+ if (!g) return true;
+ const zero = (from: number, to: number) => g.slice(from, to).every((n) => n === 0);
+ if (zero(0, 8)) return true; // :: unspecified
+ if (zero(0, 7) && g[7] === 1) return true; // ::1 loopback
+ // Every form that carries an IPv4 address is judged by that address.
+ if (zero(0, 5) && g[5] === 0xffff) return blockedV4(v4Of(g[6], g[7])); // ::ffff:0:0/96 mapped
+ if (zero(0, 4) && g[4] === 0xffff && g[5] === 0) return blockedV4(v4Of(g[6], g[7])); // ::ffff:0:0:0/96 translated
+ if (zero(0, 6)) return blockedV4(v4Of(g[6], g[7])); // ::/96 IPv4-compatible
+ if (g[0] === 0x64 && g[1] === 0xff9b) return blockedV4(v4Of(g[6], g[7])); // 64:ff9b::/96, 64:ff9b:1::/48 NAT64
+ if (g[0] === 0x2002) return blockedV4(v4Of(g[1], g[2])); // 2002::/16 6to4
+ if (g[0] === 0x2001 && g[1] === 0) return true; // 2001::/32 Teredo
+ if ((g[0] & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local
+ if ((g[0] & 0xffc0) === 0xfec0) return true; // fec0::/10 site-local
+ if ((g[0] & 0xfe00) === 0xfc00) return true; // fc00::/7 ULA
+ if ((g[0] & 0xff00) === 0xff00) return true; // ff00::/8 multicast
+ if (g[0] === 0x0100 && zero(1, 4)) return true; // 100::/64 discard
+ return false;
+};
+
+const isBlockedIp = (ip: string): boolean => {
+ if (net.isIPv4(ip)) return blockedV4(ip);
+ if (net.isIPv6(ip)) return blockedV6(ip);
+ return true; // not a recognizable IP -> block
+};
+
+// A drop-in for the Node dns.lookup used by http(s).request: resolves the host,
+// rejects the whole connection if ANY resolved address is blocked (so an
+// attacker can't return [public, private] and hope the stack picks the private
+// one), and otherwise hands the socket the first allowed address. Because the
+// address returned here is the one the socket connects to, there is no
+// resolve/connect gap for a rebind to slip through.
+const pinnedLookup = (
+ hostname: string,
+ options: any,
+ cb: (err: Error | null, address?: any, family?: number) => void,
+): void => {
+ // hostname may be an IP literal (redirect to a raw IP) — validate directly.
+ if (net.isIP(hostname)) {
+ if (isBlockedIp(hostname)) return cb(new Error(`blocked host ${hostname}`));
+ const fam = net.isIPv6(hostname) ? 6 : 4;
+ return cb(null, options?.all ? [{ address: hostname, family: fam }] : hostname, fam);
+ }
+ dns.lookup(hostname, { all: true }, (err, addrs: any[]) => {
+ if (err) return cb(err);
+ if (!addrs?.length) return cb(new Error(`cannot resolve ${hostname}`));
+ const bad = addrs.find((a) => isBlockedIp(a.address));
+ if (bad) return cb(new Error(`blocked host ${hostname} -> ${bad.address}`));
+ const a = addrs[0];
+ if (options?.all) cb(null, [{ address: a.address, family: a.family }]);
+ else cb(null, a.address, a.family);
+ });
+};
+
+const fetchOnce = (url: string, redirectsLeft: number): Promise<any> =>
+ new Promise((resolve, reject) => {
+ let u: URL;
+ try { u = new URL(url); } catch { return reject(new Error("invalid url")); }
+ if (u.protocol !== "http:" && u.protocol !== "https:")
+ return reject(new Error("unsupported scheme"));
+
+ // CRITICAL: Node skips the custom `lookup` when the URL host is already an
+ // IP literal, so pinnedLookup alone would not see a raw-IP SSRF target.
+ // Validate any literal host up front (strip IPv6 brackets first).
+ const literal = u.hostname.replace(/^\[|\]$/g, "");
+ if (net.isIP(literal) && isBlockedIp(literal))
+ return reject(new Error(`blocked host ${literal}`));
+
+ const mod = u.protocol === "https:" ? https : http;
+ const req = mod.request(
+ url,
+ { method: "GET", lookup: pinnedLookup as any, headers: { accept: "application/json" } },
+ (res) => {
+ const status = res.statusCode || 0;
+
+ // manual redirect handling — re-validate each hop via pinnedLookup
+ if (status >= 300 && status < 400 && res.headers.location) {
+ res.resume(); // drain
+ if (redirectsLeft <= 0) return reject(new Error("too many redirects"));
+ let next: string;
+ try { next = new URL(res.headers.location, url).toString(); }
+ catch { return reject(new Error("bad redirect target")); }
+ return resolve(fetchOnce(next, redirectsLeft - 1));
+ }
+
+ let len = 0;
+ const chunks: Buffer[] = [];
+ res.on("data", (c: Buffer) => {
+ len += c.length;
+ if (len > MAX_BYTES) { req.destroy(); reject(new Error("response too large")); return; }
+ chunks.push(c);
+ });
+ res.on("end", () => {
+ try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); }
+ catch { reject(new Error("invalid json response")); }
+ });
+ },
+ );
+ req.setTimeout(TIMEOUT_MS, () => req.destroy(new Error("request timed out")));
+ req.on("error", reject);
+ req.end();
+ });
+
+// Fetch a user-supplied URL with SSRF protection and return the parsed JSON
+// body. Throws on a bad scheme/URL or a host that resolves into a blocked range.
+export const safeGot = async (url: string): Promise<any> => fetchOnce(url, MAX_REDIRECTS);
diff --git a/lib/sockets.ts b/lib/sockets.ts
index 028551ed..610d38fd 100644
--- a/lib/sockets.ts
+++ b/lib/sockets.ts
@@ -1,9 +1,21 @@
+import config from "$config";
import store from "$lib/store";
import jwt from "jsonwebtoken";
import { v4 } from "uuid";
import { err, warn } from "$lib/logging";
import { fail, getUser } from "$lib/utils";
+// Verify the token's signature — never trust jwt.decode() here. A decode-only
+// gate let anyone forge {id: <victim uid>} (alg:none or any key) and register
+// their socket under the victim's uid, receiving every emit for that account.
+const verifyToken = (token) => {
+ try {
+ return jwt.verify(token, config.jwt);
+ } catch (e) {
+ return null;
+ }
+};
+
const code = 1000;
const all = {};
const subscriptions = [];
@@ -23,6 +35,9 @@ export const emit = (uid, type, data) => {
if (!store.sockets[uid]) return;
for (const id in store.sockets[uid]) {
const ws = store.sockets[uid][id];
+ // Read-only sockets (POS terminals logged in with a "-ro" token) only
+ // get payment notifications, mirroring the HTTP read-only whitelist.
+ if (ws.readonly && type !== "payment") continue;
ws.send(JSON.stringify({ type, data }));
}
};
@@ -44,9 +59,16 @@ export const broadcast = (type, data) => {
const track = async (ws, token) => {
const { id } = ws;
- const { id: uid } = jwt.decode(token);
+ let { id: uid } = verifyToken(token) || {};
if (!uid) fail("Invalid JWT token");
+
+ // Read-only tokens (see users.ro) carry "<uid>-ro". The HTTP auth layer
+ // strips the suffix for whitelisted routes; do the same here so a POS
+ // device can log in and receive payment events, but nothing else.
+ const readonly = uid.endsWith("-ro");
+ if (readonly) uid = uid.slice(0, -3);
+
const user = await getUser(uid);
if (!user) fail(`User not found ${uid}`);
@@ -61,6 +83,7 @@ const track = async (ws, token) => {
store.sockets[uid][id] = ws;
users[id] = uid;
ws.user = user;
+ ws.readonly = readonly;
};
Bun.serve({
@@ -97,7 +120,7 @@ Bun.serve({
case "login":
try {
- if (!data || data === "null" || !jwt.decode(data))
+ if (!data || data === "null" || !verifyToken(data))
return ws.close(code, `bad token ${data}`);
await track(ws, data);
diff --git a/lib/spend-budget.ts b/lib/spend-budget.ts
new file mode 100644
index 00000000..3f2a70ac
--- /dev/null
+++ b/lib/spend-budget.ts
@@ -0,0 +1,39 @@
+const number = (value: unknown) => {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : 0;
+};
+
+export const debitTotal = ({
+ amount,
+ tip = 0,
+ fee = 0,
+ ourfee = 0,
+}: {
+ amount: unknown;
+ tip?: unknown;
+ fee?: unknown;
+ ourfee?: unknown;
+}) => number(amount) + number(tip) + number(fee) + number(ourfee);
+
+// Enforce a caller-specific spending allowance at debit's final choke point,
+// after mutable invoice metadata (notably an internal invoice tip) and fees have
+// been captured. Checking earlier lets a recipient mutate the tip between the
+// NWC budget check and the ledger debit.
+export const assertWithinSpendLimit = ({
+ amount,
+ tip = 0,
+ fee = 0,
+ ourfee = 0,
+ maxTotal,
+}: {
+ amount: unknown;
+ tip?: unknown;
+ fee?: unknown;
+ ourfee?: unknown;
+ maxTotal?: number;
+}) => {
+ const total = debitTotal({ amount, tip, fee, ourfee });
+ if (maxTotal !== undefined && total > maxTotal)
+ throw new Error(`Budget exceeded: ${total} of ${maxTotal} remaining`);
+ return total;
+};
diff --git a/lib/square.ts b/lib/square.ts
new file mode 100644
index 00000000..272d9872
--- /dev/null
+++ b/lib/square.ts
@@ -0,0 +1,55 @@
+import config from "$config";
+import { g, s } from "$lib/db";
+import { fiat } from "$lib/utils";
+import { SquareClient } from "square";
+import { v4 } from "uuid";
+
+const { appId, environment } = config.square;
+
+export const squarePayment = async (p, user) => {
+ let square = await g(`${user.id}:square`);
+ if (!(square && user.syncSquare)) return;
+
+ let client = new SquareClient({
+ environment,
+ });
+
+ const { refreshToken } = square;
+
+ square = await client.oAuth.obtainToken({
+ clientId: appId,
+ grantType: "refresh_token",
+ refreshToken,
+ });
+
+ await s(`${user.id}:square`, square);
+
+ const { accessToken } = square;
+
+ client = new SquareClient({
+ token: accessToken,
+ environment,
+ });
+
+ const locs = await client.locations.list({});
+
+ await client.payments.create({
+ sourceId: "EXTERNAL",
+ idempotencyKey: v4(),
+ amountMoney: {
+ amount: BigInt(parseFloat(fiat(p.amount, p.rate).toFixed(2)) * 100),
+ currency: p.currency,
+ },
+ tipMoney: p.tip
+ ? {
+ amount: BigInt(parseFloat(fiat(p.tip, p.rate).toFixed(2)) * 100),
+ currency: p.currency,
+ }
+ : undefined,
+ autocomplete: true,
+ externalDetails: { type: "OTHER", source: `Coinos payment ${p.id}` },
+ locationId: locs.locations[0].id,
+ referenceId: v4(),
+ note: `Coinos payment ${p.id}`,
+ });
+};
diff --git a/lib/types.ts b/lib/types.ts
index e878dfb8..802fe995 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -15,7 +15,6 @@ export interface User {
follows: string;
haspin: string;
hasprinter: string;
- hidepay: string;
id: string;
index: string;
keys: string;
diff --git a/lib/upload.ts b/lib/upload.ts
index ed06d7e0..bad9735e 100644
--- a/lib/upload.ts
+++ b/lib/upload.ts
@@ -1,31 +1,68 @@
import { createHash } from "crypto";
import { writeFileSync } from "fs";
-import { err } from "$lib/logging";
+import { db } from "$lib/db";
+import { err, warn } from "$lib/logging";
import { bail, fail } from "$lib/utils";
+import { convert } from "$lib/image";
import { fileTypeFromBuffer } from "file-type";
-const sharp = require("sharp");
+// Only the formats whose decoders we are willing to expose to arbitrary
+// authenticated input. Decoding happens in a throwaway child process
+// (lib/image.ts) since 2026-09-26, when a stream of corrupt HEIF files
+// crash-looped the server via heap corruption inside libheif —
+// but a crashing decoder is still a decoder we'd rather not run at all.
+// HEIF/AVIF/TIFF/SVG/PDF never get past the sniff. The previous check let
+// any image/* subtype through.
+const ALLOWED = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
+
+// Per-account budget. Avatars/banners change a handful of times, ever; the
+// fuzzer was pushing 10+ a minute.
+const RATE_MAX = 10;
+const RATE_WINDOW_S = 600;
+
+const clientIp = (req) => req.headers["cf-connecting-ip"] || req.ip;
export default async (req, res) => {
+ const { user } = req;
try {
const {
params: { type },
} = req;
+ // A `blacklist` freeze only blocks sends; the JWT still works everywhere
+ // else, which is how a frozen account kept crashing us. Uploads are pure
+ // attack surface for a frozen account, so refuse them outright.
+ const uname = user?.username?.toLowerCase().trim();
+ const blacklisted =
+ (await db.sIsMember("blacklist", user.id)) ||
+ (await db.sIsMember("blacklist", uname));
+ const whitelisted = await db.sIsMember("whitelist", uname);
+ if (blacklisted && !whitelisted) {
+ warn(`UPLOAD_REJECTED frozen ${user.username} ${clientIp(req)}`);
+ fail("unauthorized");
+ }
+
+ const rk = `upload:rl:${user.id}`;
+ const n = await db.incr(rk);
+ if (n === 1) await db.expire(rk, RATE_WINDOW_S);
+ if (n > RATE_MAX) {
+ warn(`UPLOAD_REJECTED ratelimit ${user.username} ${clientIp(req)} ${n}`);
+ fail("too many uploads, try again later");
+ }
+
const data = await req.file();
let buf = await data.toBuffer();
- const [format, ext] = (await fileTypeFromBuffer(buf)).mime.split("/");
-
- if (format !== "image" && !["jpg", "jpeg", "png"].includes(ext))
+ const ft = await fileTypeFromBuffer(buf);
+ if (!ft || !ALLOWED.has(ft.mime)) {
+ warn(
+ `UPLOAD_REJECTED type ${user.username} ${clientIp(req)} ${ft?.mime || "unknown"} ${buf.length}`,
+ );
fail("unsupported file type");
+ }
const w = type === "banner" ? 1920 : 240;
- buf = await sharp(buf, { failOnError: false })
- .rotate()
- .resize(w)
- .webp()
- .toBuffer();
+ buf = await convert(buf, w);
const hash = createHash("sha256").update(buf).digest("hex");
@@ -34,7 +71,7 @@ export default async (req, res) => {
res.send({ hash });
} catch (e) {
- err("problem uploading", e.message);
+ err("problem uploading", user?.username, e.message);
bail(res, e.message);
}
};
diff --git a/lib/utils.ts b/lib/utils.ts
index 018dc92e..539258a4 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -1,5 +1,5 @@
import config from "$config";
-import { g } from "$lib/db";
+import { g, gf } from "$lib/db";
import locales from "$lib/locales/index";
const { URL } = process.env;
@@ -57,15 +57,15 @@ export const getUser = async (username, fields = undefined) => {
};
export const getInvoice = async (hash) => {
- let iid = await g(`invoice:${hash}`);
+ let iid = await gf(`invoice:${hash}`);
if (iid?.id) iid = iid.id;
else if (iid?.hash) iid = iid.hash;
- return await g(`invoice:${iid}`);
+ return await gf(`invoice:${iid}`);
};
export const getPayment = async (id) => {
- let p = await g(`payment:${id}`);
- if (typeof p === "string") p = await g(`payment:${p}`);
+ let p = await gf(`payment:${id}`);
+ if (typeof p === "string") p = await gf(`payment:${p}`);
return p;
};
@@ -172,9 +172,10 @@ export const fields = [
"currency",
"display",
"id",
- "hidepay",
"lud16",
"memoPrompt",
+ // a v3-migrated account: the names registrar owns this name for receiving
+ "migrated",
"npub",
"picture",
"prompt",
diff --git a/lib/v3forward.ts b/lib/v3forward.ts
new file mode 100644
index 00000000..9a263acd
--- /dev/null
+++ b/lib/v3forward.ts
@@ -0,0 +1,133 @@
+import { db, g } from "$lib/db";
+import { quoteRoutingFee } from "$lib/lnquote";
+import { l, shortError, warn } from "$lib/logging";
+import { PaymentType } from "$lib/types";
+
+// v3 forwarding bridge. A user who migrated their name to coinos v3 still has
+// their legacy account, and payments keep arriving there: old BOLT12 offers,
+// old bolt11 invoices, old bitcoin/liquid deposit addresses. Each confirmed
+// receive of that kind is paid on, as it lands, to the name's v3 lightning
+// address (the registrar mints the invoice on the ASP's node, and its
+// forwarder delivers to the wallet's Ark mailbox even while the wallet is
+// closed). Only new receives move — a balance already sitting in the legacy
+// account stays put — and the forward costs the user the routing fee only
+// (none at all over the direct channels to the ASP's node).
+//
+// "Migrated" needs proof, since this moves money people sent to the LEGACY
+// account: the registrar holds the name either for the legacy account's own
+// nostr key (the identity-takeover rule) or for the v3 wallet the legacy
+// account's own session named in a migration grant (`granted`). A name that
+// merely exists on v3 is never forwarded to.
+
+const NAMES_URL = process.env.NAMES_URL || "https://names.coinos.io";
+const DOMAIN = "coinos.io";
+const TYPES = [PaymentType.lightning, PaymentType.bolt12, PaymentType.bitcoin, PaymentType.liquid];
+const RETRY_KEY = "v3fwd:retry"; // hash pid -> {uid, amount, tries, next}
+const RETRY_MS = [60_000, 5 * 60_000, 30 * 60_000, 2 * 3600_000, 12 * 3600_000];
+
+const holderCache = new Map<string, { at: number; pk: string | null; granted: boolean }>();
+async function v3Holder(username: string): Promise<{ pk: string | null; granted: boolean }> {
+ const name = username.toLowerCase();
+ const hit = holderCache.get(name);
+ // this runs on every legacy receive: a holder is re-checked each minute,
+ // a name nobody holds every ten (claims change on the order of days)
+ if (hit && Date.now() - hit.at < (hit.pk ? 60_000 : 600_000)) return hit;
+ const r = await fetch(`${NAMES_URL}/name/${encodeURIComponent(name)}?domain=${DOMAIN}`,
+ { signal: AbortSignal.timeout(2000) });
+ if (!r.ok) throw new Error(`registrar said ${r.status}`);
+ const body: any = await r.json();
+ const pk = body?.taken && typeof body.pubkey === "string" ? body.pubkey : null;
+ const hit2 = { at: Date.now(), pk, granted: !!pk && body.granted === true };
+ holderCache.set(name, hit2);
+ return hit2;
+}
+
+// The name's v3 address, if this legacy account has moved there.
+export async function v3Destination(user): Promise<string | null> {
+ if (!user?.username || !user?.pubkey || user.username.length > 64) return null;
+ const { pk, granted } = await v3Holder(user.username);
+ return pk && (granted || pk === user.pubkey) ? user.username.toLowerCase() : null;
+}
+
+async function invoiceFor(name: string, sats: number): Promise<string> {
+ const lnurl = `${NAMES_URL}/.well-known/lnurlp/${encodeURIComponent(name)}?domain=${DOMAIN}`;
+ const pr: any = await (await fetch(lnurl, { signal: AbortSignal.timeout(5000) })).json();
+ if (pr?.tag !== "payRequest" || !pr.callback) throw new Error("no payRequest from registrar");
+ const msat = sats * 1000;
+ if (msat < (pr.minSendable || 0)) throw Object.assign(new Error(`below the v3 minimum (${pr.minSendable / 1000} sats)`), { permanent: true });
+ if (pr.maxSendable && msat > pr.maxSendable) throw Object.assign(new Error(`above the v3 maximum (${pr.maxSendable / 1000} sats)`), { permanent: true });
+ const sep = pr.callback.includes("?") ? "&" : "?";
+ const inv: any = await (await fetch(`${pr.callback}${sep}amount=${msat}`, { signal: AbortSignal.timeout(10_000) })).json();
+ if (!inv?.pr) throw new Error(inv?.reason || "registrar returned no invoice");
+ return inv.pr;
+}
+
+// Pay `amount` sats of the user's legacy balance on to their v3 address,
+// routing fee included in it.
+async function forward({ user, amount, pid, send }) {
+ const name = await v3Destination(user);
+ if (!name) return null;
+ let pr = await invoiceFor(name, amount);
+ let { fee } = await quoteRoutingFee({ pr, amount });
+ if (fee > 0) {
+ if (fee >= amount) throw Object.assign(new Error(`routing fee ${fee} exceeds ${amount}`), { permanent: true });
+ pr = await invoiceFor(name, amount - fee);
+ ({ fee } = await quoteRoutingFee({ pr, amount: amount - fee }));
+ }
+ const w = await send({ user, pr, fee, memo: `Forwarded to ${name}@${DOMAIN} (coinos v3)` });
+ l("v3 forward", user.username, amount, "fee", fee, "payment", pid, "->", w?.id);
+ return w;
+}
+
+// Called from completePayment for every receive. Fire-and-forget: never
+// blocks or fails the credit. Returns true when the receive was the bridge's
+// (so legacy autowithdraw stands down for it).
+export async function maybeForwardToV3(p, user, send): Promise<boolean> {
+ try {
+ if (!p?.confirmed || !TYPES.includes(p.type) || !(p.amount > 0)) return false;
+ if ((p.aid || p.uid) !== user?.id) return false; // sub-accounts stay put
+ if (!(await v3Destination(user))) return false;
+ // once per payment: completePayment runs twice for on-chain deposits
+ const fresh = await db.set(`v3fwd:claimed:${p.id}`, "1", { NX: true, EX: 30 * 86400 });
+ if (!fresh) return true;
+ forward({ user, amount: p.amount, pid: p.id, send }).catch(async (e) => {
+ warn("v3 forward failed", user.username, p.amount, shortError(e?.message));
+ if (!e?.permanent) await queueRetry(p.id, user.id, p.amount, 0);
+ });
+ return true;
+ } catch (e: any) {
+ warn("v3 forward check failed", user?.username, shortError(e?.message));
+ return false;
+ }
+}
+
+async function queueRetry(pid: string, uid: string, amount: number, tries: number) {
+ if (tries >= RETRY_MS.length) {
+ warn("v3 forward gave up", uid, pid, amount, "— stays in the legacy balance");
+ return;
+ }
+ await db.hSet(RETRY_KEY, pid, JSON.stringify({ uid, amount, tries, next: Date.now() + RETRY_MS[tries] }));
+}
+
+// One process at a time works the retry queue (a short redis lock).
+export async function retryV3Forwards(send) {
+ const lock = await db.set("v3fwd:lock", "1", { NX: true, EX: 120 });
+ if (!lock) return;
+ try {
+ const all = await db.hGetAll(RETRY_KEY);
+ for (const [pid, raw] of Object.entries(all)) {
+ const e = JSON.parse(raw as string);
+ if (Date.now() < e.next) continue;
+ await db.hDel(RETRY_KEY, pid);
+ const user = await g(`user:${e.uid}`);
+ try {
+ if (!(await forward({ user, amount: e.amount, pid, send }))) continue;
+ } catch (err: any) {
+ warn("v3 forward retry failed", user?.username, e.amount, shortError(err?.message));
+ if (!err?.permanent) await queueRetry(pid, e.uid, e.amount, e.tries + 1);
+ }
+ }
+ } finally {
+ await db.del("v3fwd:lock");
+ }
+}
diff --git a/lib/webhooks.ts b/lib/webhooks.ts
index 2a888991..d724ccdf 100644
--- a/lib/webhooks.ts
+++ b/lib/webhooks.ts
@@ -12,6 +12,13 @@ export const callWebhook = async (invoice, payment) => {
const { amount, confirmed, hash, memo } = payment;
l("calling webhook", webhook, amount, hash, address, text);
+ // TLS verification stays ON. This body carries `secret` — the shared
+ // value the merchant uses to authenticate the notification — so with
+ // rejectUnauthorized:false any party able to intercept the connection
+ // could present its own certificate, harvest the secret, and then forge
+ // "payment received" callbacks to that merchant. A merchant on a
+ // self-signed cert now fails here instead, which the catch below logs;
+ // the payment itself is unaffected.
const res = await got.post(webhook, {
json: {
address,
@@ -23,7 +30,6 @@ export const callWebhook = async (invoice, payment) => {
text,
secret,
},
- https: { rejectUnauthorized: false },
});
return res;
}
diff --git a/lib/whitelist.ts b/lib/whitelist.ts
index 53fd231f..f305f516 100644
--- a/lib/whitelist.ts
+++ b/lib/whitelist.ts
@@ -14,9 +14,9 @@ export default [
"fresh",
"followers",
"follows",
+ "hasnsec",
"haspin",
"hasprinter",
- "hidepay",
"id",
"index",
"keys",
@@ -25,11 +25,10 @@ export default [
"locked",
"locktime",
"memoPrompt",
+ "migrated",
"nip5",
"notify",
"npub",
- "nsec",
- "nwc",
"payments",
"picture",
"profile",
diff --git a/meltguard.ts b/meltguard.ts
new file mode 100644
index 00000000..c7195bb0
--- /dev/null
+++ b/meltguard.ts
@@ -0,0 +1,110 @@
+// meltguard — out-of-band spend limiter for the panic-mode mint.
+//
+// Sits INLINE between the mint and cl's CLNRest: the mint is pointed at
+// MINT_CLNREST_URL=http://meltguard:3011 instead of http://cl:3010. Every melt
+// is a POST /v1/pay or /v1/xpay; the guard decodes the invoice amount (via cl's own /v1/decode,
+// so no hand-rolled bolt11 parsing) and enforces two caps the mint cannot bypass —
+// even if panic mode has a bug or the mint is compromised, it physically cannot
+// move money except through this guard:
+//
+// 1. PER-MELT CAP — reject any single pay over MELT_MAX_SAT (default 1,000,000).
+// 2. CUMULATIVE FREEZE — track total successfully paid; once it would exceed
+// MELT_CUM_MAX_SAT (default 3,000,000), write a STICKY freeze flag and reject
+// ALL further pays until an operator reviews and clears it.
+//
+// State lives in plain files OUTSIDE any db (like the nobal failsafe), so a db
+// compromise can't move them and a restart can't forget them.
+//
+// Everything that is not POST /v1/pay|/v1/xpay is proxied through untouched, so mint
+// startup (getinfo, listinvoices, waitanyinvoice, invoice, listpays…) works normally.
+//
+// Env: CL_URL (http://cl:3010), MELT_MAX_SAT, MELT_CUM_MAX_SAT, GUARD_DIR (/guard),
+// PORT (3011). Run: GUARD_DIR=./guard bun meltguard.ts
+import { readFileSync, writeFileSync, existsSync, appendFileSync, mkdirSync } from "fs";
+
+const CL = process.env.CL_URL || "http://cl:3010";
+const PER = Number(process.env.MELT_MAX_SAT || 1_000_000);
+const CUM = Number(process.env.MELT_CUM_MAX_SAT || 3_000_000);
+const DIR = process.env.GUARD_DIR || "/guard";
+const PORT = Number(process.env.PORT || 3011);
+mkdirSync(DIR, { recursive: true });
+const CUM_FILE = `${DIR}/cumulative_sat`;
+const FROZEN_FILE = `${DIR}/FROZEN`;
+const LOG = `${DIR}/meltguard.log`;
+
+const readCum = () => (existsSync(CUM_FILE) ? Number(readFileSync(CUM_FILE, "utf8").trim()) || 0 : 0);
+const writeCum = (n: number) => writeFileSync(CUM_FILE, String(n));
+const isFrozen = () => existsSync(FROZEN_FILE);
+const log = (m: string) => { try { appendFileSync(LOG, `${new Date().toISOString()} ${m}\n`); } catch {} console.error("meltguard:", m); };
+const freeze = (why: string) => { writeFileSync(FROZEN_FILE, why + "\n"); log(`FROZEN — ${why} — operator must review and clear ${FROZEN_FILE}`); };
+const err = (status: number, message: string) => new Response(JSON.stringify({ error: message }), { status, headers: { "content-type": "application/json" } });
+
+async function decodeSat(bolt11: string, rune: string | null): Promise<number | null> {
+ try {
+ const r = await fetch(`${CL}/v1/decode`, { method: "POST", headers: { "content-type": "application/json", ...(rune ? { Rune: rune } : {}) }, body: JSON.stringify({ string: bolt11 }) });
+ const j: any = await r.json();
+ if (j.amount_msat != null) return Math.ceil(Number(j.amount_msat) / 1000);
+ return null; // amountless invoice — no embedded amount to cap on; treat as undecidable
+ } catch { return null; }
+}
+
+console.log(`meltguard on :${PORT} -> ${CL} | per-melt<=${PER} sat, cumulative freeze at ${CUM} sat | state ${DIR} | frozen=${isFrozen()} cum=${readCum()}`);
+
+Bun.serve({
+ port: PORT, hostname: "0.0.0.0", idleTimeout: 0,
+ async fetch(req) {
+ const url = new URL(req.url);
+ const rune = req.headers.get("Rune");
+ // nutshell >= 0.20.3.1 CLNRestWallet pays via /v1/xpay (form field `invstring`,
+ // `maxfee`); older versions used /v1/pay (`bolt11`/`invoice`). Guard both, so a
+ // backend switch can never route a melt around the caps.
+ const isXpay = req.method === "POST" && url.pathname === "/v1/xpay";
+ const isPay = isXpay || (req.method === "POST" && url.pathname === "/v1/pay");
+
+ if (isPay) {
+ if (isFrozen()) return err(403, "meltguard: FROZEN — operator review required");
+ // nutshell's CoreLightningRestWallet (mint-old) posts /v1/pay FORM-encoded
+ // with the invoice under `invoice` (old c-lightning-REST dialect); the newer
+ // CLNRestWallet uses `bolt11`. Parse form first, fall back to JSON, and
+ // accept either field name. (Prior code did req.json()+body.bolt11 only, so
+ // it never parsed the mint's form body -> every old-ecash melt was rejected
+ // at the amount check.)
+ const raw = await req.text();
+ let invoice = "";
+ const extra: Record<string, string> = {};
+ try {
+ const p = new URLSearchParams(raw);
+ if ([...p.keys()].length) {
+ invoice = p.get("invstring") || p.get("invoice") || p.get("bolt11") || "";
+ for (const [k, v] of p) if (k !== "invoice" && k !== "bolt11" && k !== "invstring") extra[k] = v;
+ }
+ } catch {}
+ if (!invoice) {
+ try {
+ const j = JSON.parse(raw);
+ invoice = j.invstring || j.bolt11 || j.invoice || "";
+ for (const k of Object.keys(j)) if (k !== "invoice" && k !== "bolt11" && k !== "invstring") extra[k] = String(j[k]);
+ } catch {}
+ }
+ const amt = await decodeSat(invoice, rune);
+ if (amt == null) return err(400, "meltguard: could not determine invoice amount — refusing to forward");
+ if (amt > PER) { log(`REJECT per-melt ${amt} > ${PER}`); return err(403, `meltguard: per-melt cap ${PER} sat exceeded (${amt})`); }
+ const cum = readCum();
+ if (cum + amt > CUM) { freeze(`cumulative ${cum}+${amt} > ${CUM}`); return err(403, `meltguard: cumulative cap ${CUM} sat — FROZEN, operator review required`); }
+ // forward to cl (official CLNREST) as FORM on the SAME endpoint the mint
+ // called (`invstring` for xpay, `bolt11` for pay), preserving the mint's
+ // other params (maxfee / maxfeepercent / exemptfee). Count cumulative only
+ // on cl-reported success.
+ const fwd = new URLSearchParams(isXpay ? { invstring: invoice, ...extra } : { bolt11: invoice, ...extra });
+ const resp = await fetch(`${CL}${isXpay ? "/v1/xpay" : "/v1/pay"}`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", ...(rune ? { Rune: rune } : {}) }, body: fwd.toString() });
+ const text = await resp.text();
+ if (resp.ok) { writeCum(cum + amt); log(`PAID ${amt} sat via ${isXpay ? "xpay" : "pay"} (cumulative ${cum + amt}/${CUM})`); }
+ return new Response(text, { status: resp.status, headers: { "content-type": "application/json" } });
+ }
+
+ // transparent proxy for all other CLNRest calls
+ const body = req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer();
+ const resp = await fetch(`${CL}${url.pathname}${url.search}`, { method: req.method, headers: { ...(req.headers.get("content-type") ? { "content-type": req.headers.get("content-type")! } : {}), ...(rune ? { Rune: rune } : {}) }, body });
+ return new Response(await resp.arrayBuffer(), { status: resp.status, headers: { "content-type": resp.headers.get("content-type") || "application/json" } });
+ },
+});
diff --git a/mintdemux.ts b/mintdemux.ts
new file mode 100644
index 00000000..d961b05b
--- /dev/null
+++ b/mintdemux.ts
@@ -0,0 +1,329 @@
+// mintdemux — transparent two-mint front for mint.coinos.io.
+//
+// Primary is the current mint (NEW: fresh keys, fully normal). The retired mint
+// (OLD: panic mode, recovery-only) is fronted transparently so wallets still
+// holding pre-2026-07-20 ecash can MELT it at mint.coinos.io without changing
+// their mint URL. Routing:
+// - /v1/keysets -> UNION of both mints' keysets (wallet sees old + new)
+// - /v1/keys/<id> -> the mint that owns that keyset
+// - /v1/info, /v1/keys -> NEW (primary identity + active keys)
+// - /v1/melt/quote/bolt11 -> NEW; remember the invoice keyed by the quote id
+// - /v1/melt/bolt11 -> routed by the submitted proofs' keyset: OLD-keyset
+// proofs get a fresh OLD melt quote (from the remembered invoice) and are
+// executed on OLD (where panic mode blocks blacklisted attacker ecash);
+// change outputs are STRIPPED on that path (wallets blind them to the NEW
+// active keyset, which OLD can't sign — OLD would reject the whole melt),
+// and the OLD quote id in the response is mapped back to the caller's;
+// everything else executes on NEW
+// - GET /v1/melt/quote/bolt11/<id> -> OLD (id rewritten) if that melt was
+// executed on OLD, else NEW — so wallets can poll a recovery melt
+// - /v1/checkstate -> both mints, merged (spent if either says spent)
+// - /v1/ws -> websocket proxy to NEW (subscriptions)
+// - everything else (swap, mint, restore, mint quote) -> NEW
+// The OLD mint also stands alone at mintrecovery.coinos.io (direct).
+//
+// Client-IP forwarding: cloudflared stamps cf-connecting-ip on every request;
+// demux forwards it upstream so the mints' per-IP rate limiter buckets by the
+// real client instead of lumping all of mint.coinos.io into demux's socket IP.
+// Partner hosts that funnel many end users through one IP (lightning-address
+// aggregators etc.) can be listed in <LOG_DIR>/whitelist.json:
+// { "ips": ["1.2.3.4", "2600:..."], "pool": 10 }
+// Their bucket key rotates across <pool> synthetic keys, so their effective
+// allowance is pool x the mint's per-IP limit. Reloaded every 60s, no restart.
+//
+// Env: NEW_MINT (http://mint:3338), OLD_MINT (http://mint-old:3338),
+// LOG_DIR (/demux), PORT (3341)
+import { createHash } from "crypto";
+import { appendFileSync, mkdirSync, readFileSync, existsSync, writeFileSync } from "fs";
+
+const NEW = process.env.NEW_MINT || "http://mint:3338";
+const OLD = process.env.OLD_MINT || "http://mint-old:3338";
+const DIR = process.env.LOG_DIR || "/demux";
+const PORT = Number(process.env.PORT || 3341);
+mkdirSync(DIR, { recursive: true });
+
+const CORS: Record<string, string> = {
+ "access-control-allow-origin": "*",
+ "access-control-allow-methods": "GET, POST, OPTIONS",
+ "access-control-allow-headers": "*",
+ "access-control-expose-headers": "*",
+};
+const J = { ...CORS, "content-type": "application/json" };
+const log = (m: string) => { try { appendFileSync(`${DIR}/demux.log`, `${new Date().toISOString()} ${m}\n`); } catch {} console.log("demux:", m); };
+const parse = (s: string) => { try { return JSON.parse(s); } catch { return null; } };
+
+// --- keyset ownership, refreshed hourly ---
+let newKeysets = new Set<string>();
+let oldKeysets = new Set<string>();
+// Our own hourly routing refresh — forwarded as loopback so the mints' per-IP
+// limiter (which exempts 127.0.0.1) doesn't count it against the demux socket.
+const lastKeysets: { NEW?: any[]; OLD?: any[] } = {};
+async function fetchKeysets(base: string): Promise<any[]> {
+ try {
+ const r = await fetch(`${base}/v1/keysets`, { headers: { "cf-connecting-ip": "127.0.0.1" } });
+ if (!r.ok) throw new Error(`status ${r.status}`);
+ const j: any = await r.json(); return (j.keysets || []) as any[];
+ }
+ catch (e: any) { console.error("keyset fetch failed", base, e.message); return []; }
+}
+async function refreshKeysets() {
+ const [n, o] = await Promise.all([fetchKeysets(NEW), fetchKeysets(OLD)]);
+ // This refresh is exempt from the mints' per-IP limiter (loopback key), so it
+ // also seeds the last-good lists the client-facing /v1/keysets falls back on
+ // when a wallet's own fetch is rate-limited — otherwise a demux restart could
+ // serve a list with no old keysets in it until some client fetch succeeded.
+ if (n.length) { newKeysets = new Set(n.map((k: any) => String(k.id))); lastKeysets.NEW = n; }
+ if (o.length) { oldKeysets = new Set(o.map((k: any) => String(k.id))); lastKeysets.OLD = o; }
+}
+await refreshKeysets();
+setInterval(refreshKeysets, 3600_000);
+// Empty routing tables are dangerous: with no old keysets every old-ecash melt
+// looks new and goes to the wrong mint. A restart that races the mints coming
+// up (a compose restart of both) leaves exactly that, so retry every 15s until
+// both sides are known instead of waiting out the hour.
+const warmup = setInterval(async () => {
+ if (newKeysets.size && oldKeysets.size) return clearInterval(warmup);
+ await refreshKeysets();
+ if (newKeysets.size && oldKeysets.size) { clearInterval(warmup); log(`keysets warmed: new=[${[...newKeysets]}] old=[${[...oldKeysets]}]`); }
+}, 15_000);
+
+// --- melt quote -> invoice memory (so an OLD-keyset melt can be re-quoted on OLD) ---
+const QFILE = `${DIR}/meltquotes.json`;
+type QInfo = { invoice: string; unit: string; ts: number };
+let quoteMem: Record<string, QInfo> = {};
+if (existsSync(QFILE)) quoteMem = parse(readFileSync(QFILE, "utf8")) || {};
+const QTTL = 2 * 3600_000;
+function rememberQuote(id: string, info: QInfo) {
+ quoteMem[id] = info;
+ const cut = Date.now() - QTTL;
+ for (const k of Object.keys(quoteMem)) if ((quoteMem[k].ts || 0) < cut) delete quoteMem[k];
+ try { writeFileSync(QFILE, JSON.stringify(quoteMem)); } catch {}
+}
+
+// --- NEW-quote-id -> OLD-quote-id map for melts executed on OLD, so status
+// polls (GET /v1/melt/quote/bolt11/<id>) can be routed to the mint that ran
+// the melt. Kept long: a pending lightning payment can be polled much later.
+const MFILE = `${DIR}/oldmelts.json`;
+type MInfo = { old: string; ts: number };
+let oldMelts: Record<string, MInfo> = {};
+if (existsSync(MFILE)) oldMelts = parse(readFileSync(MFILE, "utf8")) || {};
+const MTTL = 7 * 24 * 3600_000;
+function rememberOldMelt(newId: string, oldId: string) {
+ oldMelts[newId] = { old: oldId, ts: Date.now() };
+ const cut = Date.now() - MTTL;
+ for (const k of Object.keys(oldMelts)) if ((oldMelts[k].ts || 0) < cut) delete oldMelts[k];
+ try { writeFileSync(MFILE, JSON.stringify(oldMelts)); } catch {}
+}
+
+// --- dropped old inputs (mixed melts) ---
+// The wallet treats these as spent; the mint never took them, so they sit
+// UNSPENT on OLD forever. Keep an accounting trail: the Y (hash of the secret)
+// identifies a proof to the mint without storing the bearer secret itself.
+const DFILE = `${DIR}/dropped-old-inputs.jsonl`;
+function recordDropped(proofs: any[], ctx: { ip: string; quote: string }) {
+ for (const p of proofs) {
+ try {
+ const y = createHash("sha256").update(String(p?.secret ?? "")).digest("hex");
+ appendFileSync(DFILE, JSON.stringify({
+ ts: new Date().toISOString(), ...ctx,
+ id: p?.id, amount: p?.amount, secret_sha256: y, dleq: !!p?.dleq,
+ }) + "\n");
+ } catch {}
+ }
+}
+
+// --- client-ip forwarding + partner whitelist ---
+const WFILE = `${DIR}/whitelist.json`;
+let wlIps = new Set<string>();
+let wlPool = 10;
+function loadWhitelist() {
+ if (!existsSync(WFILE)) return;
+ const j = parse(readFileSync(WFILE, "utf8"));
+ if (!j) return;
+ if (Array.isArray(j.ips)) wlIps = new Set(j.ips.map((s: any) => String(s).toLowerCase()));
+ if (Number.isFinite(j.pool) && j.pool >= 1) wlPool = Math.floor(j.pool);
+}
+loadWhitelist();
+setInterval(loadWhitelist, 60_000);
+
+const wlCounters: Record<string, number> = {};
+function bucketKey(req: Request): string {
+ const ip = (
+ req.headers.get("cf-connecting-ip") ||
+ (req.headers.get("x-forwarded-for") || "").split(",")[0].trim()
+ ).toLowerCase();
+ if (!ip || !wlIps.has(ip)) return ip;
+ const n = (wlCounters[ip] = ((wlCounters[ip] || 0) + 1) % wlPool);
+ return `${ip}#${n}`;
+}
+// headers for an upstream fetch: json content-type when a body is sent, and
+// the client bucket key as cf-connecting-ip (checked first by the mint's
+// rate limiter; the raw socket IP is used when we have nothing to forward)
+const fwd = (key: string, hasBody = false): Record<string, string> => ({
+ ...(hasBody ? { "content-type": "application/json" } : {}),
+ ...(key ? { "cf-connecting-ip": key } : {}),
+});
+
+console.log(`mintdemux on :${PORT} NEW=${NEW} OLD=${OLD} new=[${[...newKeysets]}] old=[${[...oldKeysets]}] whitelist=[${[...wlIps]}] pool=${wlPool}`);
+
+const keysetsInBody = (body: any): string[] => {
+ const ids = new Set<string>();
+ for (const arr of [body?.inputs, body?.proofs]) if (Array.isArray(arr)) for (const it of arr) { const id = String(it?.id ?? ""); if (id) ids.add(id); }
+ return [...ids];
+};
+const respond = (upstream: Response, text: string) =>
+ new Response(text, { status: upstream.status, headers: { ...CORS, "content-type": upstream.headers.get("content-type") || "application/json" } });
+
+async function proxy(base: string, method: string, path: string, body?: ArrayBuffer | string, headers?: Record<string, string>): Promise<Response> {
+ const r = await fetch(`${base}${path}`, { method, headers, body: body as any });
+ return respond(r, await r.text());
+}
+
+const WSNEW = NEW.replace(/^http/, "ws");
+type WsData = { key: string; up?: WebSocket; q: (string | Uint8Array)[] };
+
+Bun.serve({
+ port: PORT, hostname: "0.0.0.0", idleTimeout: 0,
+ async fetch(req, server) {
+ const url = new URL(req.url);
+ const path = url.pathname;
+ const ps = path + url.search;
+ if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS });
+ const key = bucketKey(req);
+
+ if (path === "/v1/ws") {
+ if (server.upgrade(req, { data: { key, q: [] } as WsData })) return undefined as any;
+ return new Response(JSON.stringify({ detail: "websocket upgrade required" }), { status: 400, headers: J });
+ }
+
+ // --- GET routes ---
+ if (req.method === "GET") {
+ if (path === "/v1/keysets") {
+ // Forward the client's bucket key: without it every wallet's keyset
+ // fetch counted against the demux's own socket IP at both mints, the
+ // shared 60/min bucket tripped, and the swallowed 429s came back to
+ // every wallet as an empty keyset list. A failed upstream now serves
+ // that mint's last-good list instead.
+ const get = async (base: string) => {
+ const r = await fetch(`${base}/v1/keysets`, { headers: fwd(key) });
+ if (!r.ok) throw new Error(`status ${r.status}`);
+ return (await r.json() as any).keysets as any[];
+ };
+ const [rn, ro] = await Promise.all([
+ get(NEW).then(k => (lastKeysets.NEW = k), e => { console.error("keysets NEW", e.message); return lastKeysets.NEW; }),
+ get(OLD).then(k => (lastKeysets.OLD = k), e => { console.error("keysets OLD", e.message); return lastKeysets.OLD; }),
+ ]);
+ if (!rn && !ro) return new Response(JSON.stringify({ detail: "keysets unavailable" }), { status: 503, headers: J });
+ const seen = new Set<string>(); const keysets: any[] = [];
+ // NEW mint keysets keep their flags. OLD (recovery) keysets are forced
+ // active:false — they're redeem/melt-only, so wallets never mint or swap
+ // into them. Prevents advertising two active "sat" keysets at
+ // mint.coinos.io (the old mint is panic-locked anyway; this is the
+ // client-facing view).
+ for (const k of (rn || [])) { const id = String(k.id); if (!seen.has(id)) { seen.add(id); keysets.push(k); } }
+ for (const k of (ro || [])) { const id = String(k.id); if (!seen.has(id)) { seen.add(id); keysets.push({ ...k, active: false }); } }
+ return new Response(JSON.stringify({ keysets }), { headers: J });
+ }
+ const km = path.match(/^\/v1\/keys\/(.+)$/);
+ if (km) return proxy(oldKeysets.has(km[1]) ? OLD : NEW, "GET", ps, undefined, fwd(key));
+ const qm = path.match(/^\/v1\/melt\/quote\/bolt11\/([^/]+)$/);
+ if (qm && oldMelts[qm[1]]) {
+ const r = await fetch(`${OLD}/v1/melt/quote/bolt11/${oldMelts[qm[1]].old}`, { headers: fwd(key) });
+ const txt = await r.text(); const j = parse(txt);
+ if (j?.quote) j.quote = qm[1]; // caller knows its own id, not OLD's
+ return new Response(j ? JSON.stringify(j) : txt, { status: r.status, headers: J });
+ }
+ return proxy(NEW, "GET", ps, undefined, fwd(key)); // /v1/info, /v1/keys, anything else GET
+ }
+
+ // --- POST routes ---
+ const raw = await req.arrayBuffer();
+ const body = parse(new TextDecoder().decode(raw));
+
+ if (path === "/v1/checkstate") {
+ const [rn, ro] = await Promise.all([
+ fetch(`${NEW}/v1/checkstate`, { method: "POST", headers: fwd(key, true), body: raw }).then(r => r.json()).catch(() => ({ states: [] })),
+ fetch(`${OLD}/v1/checkstate`, { method: "POST", headers: fwd(key, true), body: raw }).then(r => r.json()).catch(() => ({ states: [] })),
+ ]);
+ const byY: Record<string, any> = {};
+ for (const s of [...((rn as any).states || []), ...((ro as any).states || [])]) { const cur = byY[s.Y]; if (!cur || s.state === "SPENT") byY[s.Y] = s; }
+ return new Response(JSON.stringify({ states: Object.values(byY) }), { headers: J });
+ }
+
+ if (path === "/v1/melt/quote/bolt11") {
+ const r = await fetch(`${NEW}/v1/melt/quote/bolt11`, { method: "POST", headers: fwd(key, true), body: raw });
+ const txt = await r.text(); const j = parse(txt);
+ if (r.ok && j?.quote && body?.request) rememberQuote(j.quote, { invoice: body.request, unit: body.unit || "sat", ts: Date.now() });
+ return respond(r, txt);
+ }
+
+ if (path === "/v1/melt/bolt11") {
+ const ids = keysetsInBody(body);
+ const hasOld = ids.some(id => oldKeysets.has(id));
+ const hasNew = ids.some(id => newKeysets.has(id));
+ // Mixed selection: a wallet holding leftovers of both eras picks some of
+ // each, and the two mints can't co-sign one melt. Rejecting it (the old
+ // 12003) left such wallets permanently stuck — the auditor's among them,
+ // which is why mint.coinos.io reads "offline". Drop the OLD inputs and
+ // melt the NEW ones alone: the old proofs stay UNSPENT at the old mint,
+ // but the wallet will consider them spent, so record what was dropped.
+ // Old ecash is only redeemable through the panic-mode recovery path
+ // anyway (it needs DLEQ + blinding factor, which these inputs lack).
+ if (hasOld && hasNew) {
+ const ip = req.headers.get("cf-connecting-ip") || "";
+ const keep = (arr: any[]) => arr.filter((p: any) => !oldKeysets.has(String(p?.id)));
+ const dropped = (body.inputs || []).filter((p: any) => oldKeysets.has(String(p?.id)));
+ recordDropped(dropped, { ip, quote: String(body.quote || "") });
+ const merged = { ...body, inputs: keep(body.inputs || []) };
+ const r = await proxy(NEW, "POST", ps, JSON.stringify(merged), fwd(key, true));
+ log(`melt mixed: dropped ${dropped.length} old input(s) worth ${dropped.reduce((a: number, p: any) => a + (p?.amount || 0), 0)} sat, melted ${merged.inputs.length} new on NEW status=${r.status} ip=${ip}`);
+ return r;
+ }
+ if (hasOld) {
+ const ip = req.headers.get("cf-connecting-ip") || "";
+ const info = quoteMem[body.quote];
+ if (!info) { log(`melt->OLD refused (quote unknown/expired) keysets=${ids.join(",")} ip=${ip}`); return new Response(JSON.stringify({ detail: "Melt quote expired or unknown for old-ecash recovery; request a new melt quote and try again.", code: 12004 }), { status: 400, headers: J }); }
+ // re-quote on OLD for the same invoice, then execute there (panic mode gates it)
+ const oq = await fetch(`${OLD}/v1/melt/quote/bolt11`, { method: "POST", headers: fwd(key, true), body: JSON.stringify({ request: info.invoice, unit: info.unit }) });
+ const oqt = await oq.text(); const oqj = parse(oqt);
+ if (!oq.ok || !oqj?.quote) { log(`OLD re-quote failed for melt: ${oqt.slice(0, 120)}`); return respond(oq, oqt); }
+ rememberOldMelt(String(body.quote), String(oqj.quote));
+ // Strip change outputs: wallets blind them to the NEW active keyset,
+ // which OLD doesn't know and rejects before even reading the proofs
+ // ("keyset id unknown"). Recovery melts forfeit fee-return change.
+ const { outputs, ...bodyNoChange } = body;
+ const r = await fetch(`${OLD}/v1/melt/bolt11`, { method: "POST", headers: fwd(key, true), body: JSON.stringify({ ...bodyNoChange, quote: oqj.quote }) });
+ const txt = await r.text(); const j = parse(txt);
+ log(`melt->OLD(recovery) keysets=${ids.join(",")} status=${r.status} ip=${ip}${outputs ? " outputs-stripped" : ""}`);
+ if (j?.quote) { j.quote = String(body.quote); return new Response(JSON.stringify(j), { status: r.status, headers: J }); }
+ return respond(r, txt);
+ }
+ // new (or unknown) keysets -> NEW
+ return proxy(NEW, "POST", ps, raw, fwd(key, true));
+ }
+
+ // swap, mint, mint/quote, restore, everything else -> NEW (normal ops)
+ return proxy(NEW, "POST", ps, raw, fwd(key, true));
+ },
+ // --- /v1/ws: pipe each client websocket to its own upstream connection on
+ // NEW, forwarding the bucket key so the mint's ws limiter sees the client ---
+ websocket: {
+ open(ws) {
+ const data = ws.data as WsData;
+ const up = new WebSocket(`${WSNEW}/v1/ws`, { headers: fwd(data.key) } as any);
+ data.up = up;
+ up.onopen = () => { for (const m of data.q) up.send(m); data.q = []; };
+ up.onmessage = (e) => { try { ws.send(e.data as any); } catch {} };
+ up.onclose = () => { try { ws.close(); } catch {} };
+ up.onerror = () => { try { ws.close(); } catch {} };
+ },
+ message(ws, msg) {
+ const data = ws.data as WsData;
+ if (data.up && data.up.readyState === 1) data.up.send(msg as any);
+ else data.q.push(msg as any);
+ },
+ close(ws) {
+ try { (ws.data as WsData).up?.close(); } catch {}
+ },
+ },
+});
diff --git a/package.json b/package.json
index 8881ff03..01a3a5e4 100644
--- a/package.json
+++ b/package.json
@@ -2,63 +2,77 @@
"name": "coinos",
"version": "0.0.1",
"private": true,
+ "patchedDependencies": {
+ "@asoltys/[email protected]": "patches/@asoltys+clightning-client+0.1.10.patch"
+ },
"scripts": {
"start": "bun index.ts",
"dev": "nodemon --exec bun index.ts",
+ "test:regtest": "docker exec app sh -c 'cd /home/bun/app && bun test test/'",
"format": "biome format *.js **/*.js --write"
},
"dependencies": {
- "@asoltys/clightning-client": "^0.1.6",
- "@aws-sdk/client-ses": "^3.449.0",
- "@cashu/cashu-ts": "^2.0.0-rc3",
+ "@asoltys/clightning-client": "^0.1.10",
+ "@aws-sdk/client-ses": "^3.997.0",
+ "@cashu/cashu-ts": "^2.9.0",
"@coinos/rpc": "^0.0.6",
- "@fastify/cors": "^8.3.0",
- "@fastify/http-proxy": "^9.5.0",
- "@fastify/multipart": "^8.0.0",
- "@fastify/passport": "^2.4.0",
- "@fastify/rate-limit": "^9.1.0",
- "@fastify/secure-session": "^7.5.1",
- "@fastify/static": "^6.5.0",
- "@fastify/websocket": "^10.0.1",
- "@noble/hashes": "^1.4.0",
+ "@fastify/cors": "^11.3.0",
+ "@fastify/http-proxy": "^11.6.0",
+ "@fastify/multipart": "^10.1.1",
+ "@fastify/passport": "^4.0.2",
+ "@fastify/rate-limit": "^11.2.0",
+ "@fastify/secure-session": "^8.3.0",
+ "@fastify/static": "^10.1.3",
+ "@fastify/websocket": "^11.3.0",
+ "@noble/hashes": "^1.8.0",
+ "@scure/base": "^1.2.6",
"bech32": "^2.0.0",
"buffer-crc32": "^0.2.13",
"clightning-client": "github:asoltys/lightning-client-js#master",
- "date-fns": "^2.21.3",
- "fastify": "^4.28.1",
- "file-type": "^18.0.0",
- "fluent-ffmpeg": "^2.1.2",
+ "date-fns": "^2.30.0",
+ "fastify": "^5.12.0",
+ "file-type": "^21.3.1",
+ "fluent-ffmpeg": "^2.1.3",
"got": "^13.0.0",
"handlebars": "^4.7.8",
- "jsonwebtoken": "^9.0.2",
+ "jsonwebtoken": "^9.0.3",
"lnurl": "^0.24.2",
- "mqtt": "^5.0.2",
- "nostr": "^0.2.7",
- "nostr-tools": "^2.7.2",
+ "mqtt": "^5.15.0",
+ "nostr": "^0.2.8",
+ "nostr-tools": "^2.23.1",
"otplib": "^12.0.1",
- "passport-jwt": "^4.0.0",
+ "passport-jwt": "^4.0.1",
"passport-local": "1.0.0",
- "pino": "8",
- "pino-pretty": "^7.0.0",
- "pump": "^3.0.0",
+ "pino": "^8.21.0",
+ "pino-pretty": "^7.6.1",
+ "pump": "^3.0.3",
"ramda": "^0.30.1",
"readable-stream-clone": "^0.0.7",
- "redis": "^4.6.10",
- "sharp": "^0.33.5",
+ "redis": "^4.7.1",
+ "sharp": "^0.35.3",
+ "square": "^43.2.1",
"strfry-rpc": "^0.0.1",
- "uuid": "8",
+ "uuid": "^11.1.1",
"web-push": "^3.6.7",
- "ws": "^7.4.6"
+ "ws": "^7.5.10"
},
"devDependencies": {
- "@biomejs/biome": "^1.4.1",
- "@types/bun": "^1.1.8",
- "nodemon": "^2.0.7",
- "typescript": "^5.5.4"
+ "@biomejs/biome": "^1.9.4",
+ "@types/bun": "^1.3.9",
+ "nodemon": "^2.0.22",
+ "typescript": "^5.9.3"
},
"apidoc": {
"title": "CoinOS Server API Documentation",
"url": "https://coinos.io/api"
},
- "type": "module"
+ "type": "module",
+ "overrides": {
+ "qs": ">=6.14.1",
+ "semver": ">=7.5.2",
+ "minimatch": ">=3.1.3",
+ "axios": ">=1.13.5",
+ "body-parser": ">=1.20.3",
+ "path-to-regexp": ">=0.1.10"
+ }
}
diff --git a/patches/@asoltys+clightning-client+0.1.10.patch b/patches/@asoltys+clightning-client+0.1.10.patch
new file mode 100644
index 00000000..f2d0d3d4
--- /dev/null
+++ b/patches/@asoltys+clightning-client+0.1.10.patch
@@ -0,0 +1,54 @@
+diff --git a/index.js b/index.js
+index 968489c..4d96c34 100644
+--- a/index.js
++++ b/index.js
+@@ -42,7 +42,31 @@ class LightningClient extends EventEmitter {
+ this.reconnectTimeout = null;
+ this.reqcount = 0;
+
+- this.client = net.createConnection(rpcPath);
++ this._setup();
++ }
++
++ _setup() {
++ // Build a FRESH socket + readline for every (re)connection. The original
++ // code reused this.client and only called this.client.connect() on
++ // reconnect — but a socket that has already emitted 'error'/'close' is
++ // destroyed, so reconnecting it is a no-op/throw. That left the client
++ // wedged looping `connect ENOENT …/lightning-rpc` forever after a cl
++ // restart (spamming logs, leaking a socket+timer, never recovering).
++ // Renewing clientConnectionPromise here also makes call() block until the
++ // (re)connection is live instead of writing into a dead socket.
++ if (this.client) {
++ try {
++ this.client.removeAllListeners();
++ this.client.destroy();
++ } catch (e) {}
++ }
++ if (this.rl) {
++ try {
++ this.rl.close();
++ } catch (e) {}
++ }
++
++ this.client = net.createConnection(this.rpcPath);
+ this.rl = readline.createInterface({ input: this.client });
+
+ this.clientConnectionPromise = new Promise((resolve) => {
+@@ -87,8 +111,15 @@ class LightningClient extends EventEmitter {
+ }
+
+ this.reconnectTimeout = setTimeout(() => {
+- this.client.connect(this.rpcPath);
++ // Clear the guard BEFORE (re)connecting so a throw can't leave it set
++ // and permanently block every future reconnect() (the original bug).
+ this.reconnectTimeout = null;
++ try {
++ this._setup();
++ } catch (e) {
++ this.increaseWaitTime();
++ this.reconnect();
++ }
+ }, this.reconnectWait * 1000);
+ }
+
diff --git a/patches/README.md b/patches/README.md
new file mode 100644
index 00000000..b3bf1322
--- /dev/null
+++ b/patches/README.md
@@ -0,0 +1,41 @@
+# patches
+
+## `@asoltys+clightning-client+0.1.10.patch`
+
+Fixes the reconnect logic in `@asoltys/clightning-client` (v0.1.10) so a dropped
+cl RPC connection actually recovers.
+
+**Bug:** `reconnect()` called `this.client.connect()` on the *same* socket that
+had already emitted `error`/`close`. A destroyed socket can't be reconnected, so
+after a `cl` restart the client wedged, looping `connect ENOENT …/lightning-rpc`
+forever — spamming logs and leaking a socket+timer each time — and never
+recovered. (If `connect()` threw, `reconnectTimeout` was also left set, which
+permanently blocked every future `reconnect()`.)
+
+**Fix:** factor the socket+readline+handlers setup into `_setup()` which builds a
+**fresh** socket each (re)connection and renews `clientConnectionPromise` (so
+`call()` blocks until the reconnect is live). `reconnect()` now clears its guard
+*before* reconnecting and retries on failure.
+
+This is defense-in-depth: the app already neutralises the broken loop in
+`lib/ln.ts` (`dropClient()` tears the dead client down and the proxy makes a fresh
+`LightningClient`). This patch fixes the library itself for any path that relies
+on its own reconnect.
+
+### How to wire it in (bun)
+
+`bun patch` couldn't run in this checkout (node_modules link EEXIST), so this
+patch was generated by hand and verified to apply cleanly (`git apply --check`).
+To register it so `bun install` re-applies it, in a clean checkout:
+
+```sh
+bun patch @asoltys/clightning-client
+# copy patches/clightning-client-index.fixed.js over the printed editable
+# node_modules/@asoltys/clightning-client/index.js
+bun patch --commit node_modules/@asoltys/clightning-client
+```
+
+That writes bun's own `patches/…` file and the `patchedDependencies` entry in
+`package.json`. Alternatively, apply this patch directly in the image build:
+`git apply patches/@asoltys+clightning-client+0.1.10.patch` against the installed
+package. `clightning-client-index.fixed.js` is the full patched file for reference.
diff --git a/routes/ecash.ts b/routes/ecash.ts
index 96783cc4..995a8dce 100644
--- a/routes/ecash.ts
+++ b/routes/ecash.ts
@@ -1,40 +1,21 @@
-import { db, g, s } from "$lib/db";
-import { check, claim, get, mint } from "$lib/ecash";
-import { err, l } from "$lib/logging";
-import { credit, debit } from "$lib/payments";
-import { emit } from "$lib/sockets";
-import { bail, fail, getInvoice } from "$lib/utils";
+import { check, get, redeem } from "$lib/ecash";
+import { err } from "$lib/logging";
+import { bail, fail, getInvoice, getUser } from "$lib/utils";
import { getEncodedToken } from "@cashu/cashu-ts";
-import { v4 } from "uuid";
-
-import { PaymentType } from "$lib/types";
-const { ecash: type } = PaymentType;
-
-Error.stackTraceLimit = 100; // Set this to the desired limit
-
-const sendCash = async ({ amount, user }) => {
- const id = v4();
- const hash = v4();
-
- const p = await debit({ hash, amount, user, type });
- const token = await mint(parseInt(amount));
- p.memo = id;
- const { id: pid } = p;
- await s(`payment:${pid}`, p);
- s(`cash:${id}`, token);
-
- return { id, token, pid };
-};
export default {
- async save(req, res) {
+ // Stateless status lookup for a token the client is holding. This replaced
+ // an unauthenticated POST that stored any submitted string under a fresh id
+ // forever (thousands of forged and foreign tokens accumulated); `get` below
+ // still serves the ids that were handed out while it existed.
+ async status(req, res) {
const {
body: { token },
} = req;
try {
- const id = v4();
- await s(`cash:${id}`, token);
- res.send({ id });
+ if (typeof token !== "string" || !token.startsWith("cashu"))
+ fail("Invalid token");
+ res.send(await check(token));
} catch (e) {
err(e.message);
bail(res, e.message);
@@ -47,6 +28,7 @@ export default {
} = req;
try {
const token = await get(id);
+ if (!token) fail("Ecash not found");
const status = await check(token);
res.send({ token, status });
} catch (e) {
@@ -61,108 +43,34 @@ export default {
user,
} = req;
try {
- const amount = await claim(token);
-
- let memo;
- const hash = v4();
- const { currency, id: uid } = user;
- const rates = await g("rates");
- await s(`invoice:${hash}`, {
- currency,
- id: hash,
- hash,
- rate: rates[currency],
- uid,
- received: 0,
- });
-
- await credit({ hash, amount, memo, ref: user.id, type });
-
- res.send({ ok: true });
+ const amount = await redeem({ token, user });
+ res.send({ ok: true, amount });
} catch (e) {
err(e.message);
bail(res, e.message);
}
},
- async mint(req, res) {
- const { body, user } = req;
- const amount = parseInt(body.amount);
- res.send(await sendCash({ amount, user }));
- },
-
- async melt(req, res) {
- let {
- body: { amount, bolt11: hash, preimage },
- user,
- } = req;
- try {
- amount = Math.round(amount / 1000);
- const ref = preimage;
- const { lightning: type } = PaymentType;
- if (user.username !== "mint") fail("unauthorized");
- const { id: uid, currency } = user;
- const ourfee = await db.debit(
- `balance:${uid}`,
- `credit:${type}:${uid}`,
- "Insufficient funds",
- amount || 0,
- 0,
- 0,
- 0,
- 0
- );
-
- const rates = await g("rates");
- const rate = rates[currency];
-
- if (ourfee.err) fail(ourfee.err);
-
- const id = v4();
- const p = {
- id,
- amount: -amount,
- hash,
- ourfee,
- uid,
- confirmed: true,
- rate,
- currency,
- type,
- ref,
- created: Date.now(),
- };
-
- await s(`payment:${hash}`, id);
- await s(`payment:${id}`, p);
- await db.lPush(`${uid}:payments`, id);
-
- l(user.username, "sent", type, amount);
- emit(user.id, "payment", p);
-
- res.send(p);
- } catch (e) {
- bail(res, e.message);
- }
- },
-
+ // NUT-18 payment-request transport: a wallet POSTs proofs for one of our
+ // ecash-type invoices, which are melted into a Lightning invoice for the
+ // invoice's owner under the same id.
async receive(req, res) {
try {
const { id, proofs, mint, memo } = req.body;
- const { uid: ref } = await getInvoice(id);
-
- const amount = await claim(
- getEncodedToken({
- mint,
- proofs,
- }),
- );
+ const invoice = await getInvoice(id);
+ if (!invoice) fail("Invoice not found");
+ const user = await getUser(invoice.uid);
+ if (!user) fail("User not found");
- await credit({ hash: id, amount, memo, ref, type });
+ await redeem({
+ token: getEncodedToken({ mint, proofs }),
+ user,
+ invoice,
+ memo,
+ });
res.send({ id });
} catch (e) {
- console.log(e);
err(e.message);
bail(res, e.message);
}
diff --git a/routes/email.ts b/routes/email.ts
index 9f0a8228..749fba5e 100644
--- a/routes/email.ts
+++ b/routes/email.ts
@@ -1,61 +1,78 @@
-import { bail } from "$lib/utils";
+import { bail, fail } from "$lib/utils";
import got from "got";
import config from "$config";
import { l, warn } from "$lib/logging";
import { SESClient } from "@aws-sdk/client-ses";
import { SendEmailCommand } from "@aws-sdk/client-ses";
+// reCAPTCHA v3 never challenges: siteverify returns success:true for any
+// well-formed token and reports how human the request looked in `score`.
+// Checking `success` alone let a headless scanner submit freely.
+const MIN_SCORE = 0.5;
+
+const escapeHtml = (s: string) =>
+ s
+ .replace(/&/g, "&amp;")
+ .replace(/</g, "&lt;")
+ .replace(/>/g, "&gt;")
+ .replace(/"/g, "&quot;")
+ .replace(/'/g, "&#39;");
+
+// Strip CR/LF and other control characters from single-line header values.
+const oneLine = (s: string) => s.replace(/[\x00-\x1f\x7f]+/g, " ").trim();
+
export default {
- async send({ body }, res) {
+ async send({ body, headers }, res) {
try {
const { email, message, username, token: response } = body;
+ if (typeof message !== "string" || !message.trim()) fail("Message required");
+ if (typeof email !== "string" || !email.trim()) fail("Email required");
+ if (username != null && typeof username !== "string") fail("Invalid username");
+ if (typeof response !== "string" || !response) fail("failed captcha");
const Charset = "UTF-8";
const { recaptcha: secret } = config;
- const { success } = await got
+ const remoteip = headers?.["cf-connecting-ip"];
+ const { success, score, action } = await got
.post("https://www.google.com/recaptcha/api/siteverify", {
- form: {
- secret,
- response,
- },
+ form: { secret, response, ...(remoteip && { remoteip }) },
+ timeout: { request: 6000 },
})
- .json();
+ .json<any>();
- if (success || response === config.adminpass) {
- body.token = undefined;
+ if (!success || action !== "submit" || !(score >= MIN_SCORE)) {
+ warn("support request rejected", email, "score", score, "action", action);
+ fail("failed captcha");
+ }
- warn("support request from", email);
- const client = new SESClient({ region: "us-east-2" });
- await client.send(
- new SendEmailCommand({
- Destination: {
- CcAddresses: [],
- ToAddresses: [config.support],
+ l("support request from", email, "score", score);
+ const name = username ? oneLine(username).slice(0, 100) : "";
+ const client = new SESClient({ region: "us-east-2" });
+ await client.send(
+ new SendEmailCommand({
+ Destination: {
+ CcAddresses: [],
+ ToAddresses: [config.support],
+ },
+ Message: {
+ Body: {
+ Html: { Charset, Data: escapeHtml(message).replace(/\n/g, "<br>") },
+ Text: { Charset, Data: message },
},
- Message: {
- Body: {
- Html: { Charset, Data: message.replace(/\n/g, "<br>") },
- Text: { Charset, Data: message },
- },
- Subject: {
- Charset,
- Data:
- body.subject ||
- `Support Request${username ? ` From ${username}` : ""}`,
- },
+ Subject: {
+ Charset,
+ Data: `Support Request${name ? ` From ${name}` : ""}`,
},
- ReplyToAddresses: [email],
- Source: config.support,
- }),
- );
+ },
+ ReplyToAddresses: [oneLine(email)],
+ Source: config.support,
+ }),
+ );
- res.send({ ok: true });
- } else {
- bail(res, "failed captcha");
- }
+ res.send({ ok: true });
} catch (e) {
- console.log(e);
+ bail(res, e.message);
}
},
};
diff --git a/routes/info.ts b/routes/info.ts
index c6d03e4d..4ab11872 100644
--- a/routes/info.ts
+++ b/routes/info.ts
@@ -1,9 +1,15 @@
import { archive } from "$lib/db";
-import { db, g } from "$lib/db";
+import { db } from "$lib/db";
+import { getHealthStatus } from "$lib/health";
import ln from "$lib/ln";
-import { getDecodedToken } from "@cashu/cashu-ts";
export default {
+ async health(_, res) {
+ const status = getHealthStatus();
+ const httpStatus = status.healthy ? 200 : 503;
+ res.status(httpStatus).send(status);
+ },
+
async balances(_, res) {
let total = 0;
@@ -21,13 +27,7 @@ export default {
);
const lnwallet = parseInt(funds.outputs.reduce((a, b) => a + b.value, 0));
- const cash = getDecodedToken(await g("cash")).proofs.reduce(
- (a, b) => a + b.amount,
- 0,
- );
-
const info = {
- cash,
lnchannel,
lnwallet,
};
diff --git a/routes/invoices.ts b/routes/invoices.ts
index ecac713c..b8d06bba 100644
--- a/routes/invoices.ts
+++ b/routes/invoices.ts
@@ -1,25 +1,30 @@
import config from "$config";
-import { db } from "$lib/db";
+import { db, g, gf, s } from "$lib/db";
import { generate } from "$lib/invoices";
import { err } from "$lib/logging";
-import { bail, fields, getInvoice, getUser } from "$lib/utils";
+import { bail, fail, fields, getInvoice, getUser } from "$lib/utils";
import rpc from "@coinos/rpc";
export default {
async get(req, res) {
- const {
- params: { id },
- } = req;
- const invoice = await getInvoice(id);
+ try {
+ const {
+ params: { id },
+ } = req;
+ if (id === "undefined") fail("invalid id");
+ const invoice = await getInvoice(id);
- if (invoice) {
- invoice.secret = undefined;
- invoice.user = await getUser(invoice.uid, fields);
+ if (invoice) {
+ invoice.secret = undefined;
+ invoice.user = await getUser(invoice.uid, fields);
- invoice.items ||= [];
+ invoice.items ||= [];
+ }
+ if (invoice) res.send(invoice);
+ else fail("invoice not found");
+ } catch (e) {
+ bail(res, e.message);
}
- if (invoice) res.send(invoice);
- else res.code(500).send("invoice not found");
},
async create(req, res) {
@@ -28,17 +33,61 @@ export default {
if (body.user) user = body.user;
if (req.user.username === user.username) invoice.own = true;
+ else invoice.own = false;
try {
+ // A supplied id that already exists is an UPDATE of that record —
+ // generate() rewrites it wholesale. Only the authenticated owner may do
+ // that. Without this, an anonymous pay-page session passing
+ // body.user = merchant could rewrite the amount/memo of a merchant's
+ // live invoice. generate() separately refuses to move an invoice to a
+ // different uid.
+ if (invoice?.id) {
+ const existing = await getInvoice(invoice.id);
+ if (existing && existing.uid !== req.user.id) fail("invoice not found");
+ }
const result = await generate({ invoice, user });
res.send(result);
} catch (e) {
- err(
- "problem generating invoice",
- req.user?.username,
- body.user?.username,
- e.message,
- );
+ bail(res, e.message);
+ }
+ },
+
+ async update(req, res) {
+ try {
+ const { id } = req.params;
+ const { body } = req;
+ const { tip, webhook, secret } = body.invoice;
+
+ let invoice = await gf(`invoice:${id}`);
+ if (!invoice) fail("invoice not found");
+ const user = await g(`user:${invoice.uid}`);
+
+ if (typeof tip !== "undefined") {
+ const t = Number.parseInt(tip);
+ if (!Number.isSafeInteger(t) || t < 0 || t > 2_100_000_000_000_000)
+ fail("Invalid tip");
+ // Defense-in-depth: never change the tip once the invoice is paid/
+ // settled. The primary mint guard is on the credit side (it uses the
+ // tip the sender was actually debited); this stops post-settlement
+ // tampering. The endpoint stays open by design — payers set tips on
+ // merchants' invoices before paying — so we do NOT require ownership.
+ if (invoice.received > 0 || invoice.settled)
+ fail("Invoice already settled");
+ invoice.tip = t;
+ }
+
+ if (webhook && secret) {
+ if (invoice.uid !== req.user?.id) fail("Unauthorized");
+ invoice.webhook = webhook;
+ invoice.secret = secret;
+ }
+
+ invoice = await generate({ invoice, user });
+ await s(`invoice:${id}`, invoice);
+
+ res.send(invoice);
+ } catch (e) {
bail(res, e.message);
}
},
@@ -46,13 +95,29 @@ export default {
async list(req, res) {
const { id } = req.user;
let invoices = await db.lRange(`${id}:invoices`, 0, -1);
- invoices = await Promise.all(invoices.map((i) => getInvoice(i)));
+ invoices = (await Promise.all(invoices.map((i) => getInvoice(i)))).filter(
+ Boolean,
+ );
res.send(invoices);
},
async sign(req, res) {
try {
const { address, message, type = "bitcoin" } = req.body;
+
+ // Sign only with an address the CALLER owns. Users have no per-user keys
+ // in this custodial design — every address belongs to the server hot
+ // wallet — so without this any authenticated account could have the node
+ // sign an arbitrary message under any address the wallet controls
+ // (another user's deposit address, a change address, a cold-storage
+ // address published as ours), which is a proof-of-control forgery
+ // primitive. The legitimate flow (coinos.io/sign, for Ocean offers that
+ // need a signable on-chain address) mints a fresh invoice address for
+ // the caller and posts that back, so it always satisfies this check.
+ const invoice = await getInvoice(address);
+ if (!invoice || (invoice.uid !== req.user.id && invoice.aid !== req.user.id))
+ fail("unauthorized");
+
const node = rpc(config[type]);
if (config[type].walletpass)
diff --git a/routes/lnurl.ts b/routes/lnurl.ts
index 16f366d4..7f04db5d 100644
--- a/routes/lnurl.ts
+++ b/routes/lnurl.ts
@@ -1,20 +1,72 @@
-import { db, g, s } from "$lib/db";
+import { db, g, gf, s } from "$lib/db";
import { generate } from "$lib/invoices";
-import ln from "$lib/ln";
-import { err, warn } from "$lib/logging";
-import { serverPubkey } from "$lib/nostr";
-import { credit } from "$lib/payments";
-import { bail, fail, getInvoice, getUser } from "$lib/utils";
+import { err, quiet, suppressed, warn } from "$lib/logging";
+import { serverPubkey2 } from "$lib/nostr";
+import { SATS, bail, fail, getInvoice, getUser } from "$lib/utils";
import { bech32 } from "bech32";
-import got from "got";
+import { safeGot } from "$lib/safe-fetch";
+import { verifyEvent } from "nostr-tools";
import { v4 } from "uuid";
import { PaymentType } from "$lib/types";
+// A payRequest callback id is fetched once, minutes after the metadata
+// request, and never again; without an expiry these one-shot pointers were
+// ~45% of all keys in the main db.
+const LNURL_TTL = 7 * 24 * 60 * 60;
+
const { URL } = process.env;
const host = URL.split("/").at(-1);
const fiveMinutes = 1000 * 60 * 5;
+// Unauthenticated invoice generation (GET /pay/:user/:amount, GET /lnurl/:id)
+// runs for whoever holds the link, so it needs a per-target budget: a scanner
+// spread across a large proxy pool can mint junk invoices on many accounts
+// (each a CLN invoice plus a :invoices entry) while staying invisible to
+// per-IP limits. 120 fresh invoices per target per hour is far
+// above any static-QR merchant's scan rate; reusing an existing invoice (the
+// own-invoice path in pay(), the pinned invoice in lnurl()) doesn't count.
+const unauthInvoiceBudget = async (uid: string) => {
+ const key = `invbudget:${uid}`;
+ const n = await db.incr(key);
+ if (n === 1) await db.expire(key, 3600);
+ if (n > 120) {
+ const q = quiet(`invbudget:${uid}`);
+ if (q !== null) warn("invoice budget exceeded", uid, n, suppressed(q));
+ fail("Too many payment requests for this user, try again later");
+ }
+};
+// v3 payment addresses (coinos v3 / halwallet) are registered here.
+const NAMES_URL = process.env.NAMES_URL || "https://names.coinos.io";
+
+// Does the v3 registrar serve this name? Returns its payRequest, or null.
+// Cached briefly: claims change on the order of days, and this sits on the
+// payment path for every coinos.io address lookup. Only definitive answers
+// (payRequest / 404) are cached — a sick registrar shouldn't be remembered.
+const registrarCache = new Map();
+const REGISTRAR_TTL = 60_000;
+async function registrarLookup(name) {
+ const hit = registrarCache.get(name);
+ if (hit && Date.now() - hit.at < REGISTRAR_TTL) return hit.body;
+ try {
+ const r = await fetch(
+ `${NAMES_URL}/.well-known/lnurlp/${encodeURIComponent(name)}?domain=${host}`,
+ { signal: AbortSignal.timeout(3000) },
+ );
+ if (r.ok) {
+ const body: any = await r.json();
+ if (body?.tag === "payRequest") {
+ registrarCache.set(name, { at: Date.now(), body });
+ return body;
+ }
+ }
+ if (r.status === 404) registrarCache.set(name, { at: Date.now(), body: null });
+ } catch (e) {
+ warn("names registrar lookup failed for", name, (e as any).message);
+ }
+ return null;
+}
+
export default {
async encode(req, res) {
const {
@@ -26,7 +78,7 @@ export default {
.replace(/\s/g, "")}`;
try {
- const r = await got(url).json();
+ const r = await safeGot(url);
if (r.tag !== "payRequest") fail("not an ln address");
} catch (e) {
const m = `failed to lookup lightning address ${address}`;
@@ -47,7 +99,7 @@ export default {
bech32.fromWords(bech32.decode(text, 20000).words),
).toString();
- const r = await got(url).json();
+ const r = await safeGot(url);
res.send(r);
} catch (e) {
bail(res, e.message);
@@ -60,15 +112,36 @@ export default {
query: { minSendable = 1000, maxSendable = 100000000000 },
} = req;
try {
- const user = await getUser(
- username
- .replace("lightning:", "")
- .replace(/\s/g, "")
- .replace("=", "")
- .toLowerCase(),
- );
+ const name = username
+ .replace("lightning:", "")
+ .replace(/\s/g, "")
+ .replace("=", "")
+ .toLowerCase();
- if (!user) fail(`User ${username} not found`);
+ // Resolution order for [email protected] — this has flip-flopped twice, so
+ // the invariant, in full:
+ // 1. A name CLAIMED in the v3 registrar is served by it. That is where
+ // migrated accounts live — their legacy user record still exists
+ // here, so "local account exists" does NOT mean the name is ours —
+ // and also v3-native custom names, which have no local account and
+ // only a npub1* Cloudflare rule redirecting for them today.
+ // 2. Otherwise a local account is served locally. Never key this on
+ // `migrated`: register.ts stamps that flag on essentially every
+ // account (it means "don't reserve this name for v3", not "user
+ // moved away").
+ // 3. Neither → not found.
+ // No loop: step 1 defers only for names the registrar HAS, and the
+ // registrar never queries this endpoint for those. If the registrar is
+ // unreachable we fall through and serve the local account — a briefly
+ // stale destination beats an unpayable address.
+ const v3 = await registrarLookup(name);
+ if (v3) return res.send(v3);
+
+ const user = await getUser(name);
+ // Evicted accounts can't receive either: stops a lightning address that
+ // a scam page points victims at from issuing new invoices.
+ if (!user || (await db.sIsMember("evicted", user.id)))
+ fail(`User ${username} not found`);
const { id: uid } = user;
const metadata = JSON.stringify([
@@ -77,21 +150,24 @@ export default {
]);
const id = v4();
- await s(`lnurl:${id}`, uid);
+ await s(`lnurl:${id}`, uid, LNURL_TTL);
res.send({
allowsNostr: true,
minSendable,
maxSendable,
metadata,
- nostrPubkey: serverPubkey,
+ nostrPubkey: serverPubkey2,
commentAllowed: 512,
callback: `${URL}/api/lnurl/${id}`,
tag: "payRequest",
});
} catch (e) {
- if (!e.message.includes("found"))
- warn("problem generating lnurlp request", username, e.message);
+ if (!e.message.includes("found")) {
+ const n = quiet(`lnurlp:${username}`, 5 * 60 * 1000);
+ if (n !== null)
+ warn("problem generating lnurlp request", username, e.message, suppressed(n));
+ }
bail(res, e.message);
}
},
@@ -102,22 +178,27 @@ export default {
query: { amount, comment, nostr },
} = req;
try {
+ const iid = await g(`lnurl:${id}:invoice`);
const uid = await g(`lnurl:${id}`);
const user = await getUser(uid);
- if (!user) fail("user not found");
+
+ if (!user || (await db.sIsMember("evicted", user.id)))
+ fail("user not found");
let { username } = user;
username = username.replace(/\s/g, "").toLowerCase();
- const memo = comment ?? `Paying ${username}@${host}`;
let metadata = JSON.stringify([
- ["text/plain", memo],
+ ["text/plain", `Paying ${username}@${host}`],
["text/identifier", `${username}@${host}`],
]);
if (nostr) {
try {
const event = JSON.parse(decodeURIComponent(nostr));
- // TODO: validate the event
+ // NIP-57: must be a signed kind-9734 zap request. Reject anything
+ // else so we can't be tricked into storing a forged zap receipt.
+ if (event.kind !== 9734 || !verifyEvent(event))
+ throw new Error("invalid zap request");
await s(`zap:${id}`, event);
metadata = nostr;
} catch (e) {
@@ -125,17 +206,44 @@ export default {
}
}
- const { id: iid, text: pr } = await generate({
- invoice: {
- amount: Math.round(amount / 1000),
- memo: metadata,
- type: PaymentType.lightning,
- },
- user,
+ if (!iid) await unauthInvoiceBudget(user.id);
+ const invoice = iid
+ ? await gf(`invoice:${iid}`)
+ : await generate({
+ invoice: {
+ amount: Math.round(amount / 1000),
+ memo: metadata,
+ type: PaymentType.lightning,
+ },
+ user,
+ });
+
+ if (comment) {
+ // Enforce the commentAllowed:512 we advertise in the lnurlp response.
+ // This overwrite happens after generate()'s own memo validation, so an
+ // uncapped comment would otherwise reach credit() at settlement time.
+ invoice.memo = String(comment).slice(0, 512);
+ await s(`invoice:${invoice.id}`, invoice);
+ }
+
+ res.send({
+ pr: invoice.text,
+ routes: [],
+ verify: `${URL}/api/lnurl/verify/${invoice.id}`,
});
+ } catch (e) {
+ bail(res, e.message);
+ }
+ },
- res.send({ pr, routes: [], verify: `${URL}/api/lnurl/verify/${iid}` });
+ async proxy(req, res) {
+ const { url } = req.query;
+ if (!url) return bail(res, "url required");
+ try {
+ const r = await safeGot(url);
+ res.send(r);
} catch (e) {
+ warn("lnurl proxy failed", url, e.message);
bail(res, e.message);
}
},
@@ -154,29 +262,49 @@ export default {
},
async pay(req, res) {
- const { username } = req.params;
+ const { amount, username } = req.params;
try {
const user = await getUser(username);
+ if (!user) fail(`User ${username} not found`);
const invoices = await db.lRange(`${user.id}:invoices`, 0, 10);
let invoice;
+
for (const iid of invoices) {
const i = await getInvoice(iid);
+ if (!i) continue;
const paid = i.amount > 0 && i.received >= i.amount;
const old = Date.now() - i.created > fiveMinutes;
- if (i.own && !(paid || old)) {
+ if (paid) break;
+ if (i.own && !old) {
invoice = i;
break;
}
}
- if (!invoice)
+ if (invoice) {
+ if (amount?.startsWith("+")) {
+ await unauthInvoiceBudget(user.id);
+ const tip = invoice.amount * amount.split("+")[1];
+ invoice = await generate({
+ invoice: {
+ ...invoice,
+ tip,
+ },
+ user,
+ });
+ }
+ } else {
+ await unauthInvoiceBudget(user.id);
invoice = await generate({
invoice: {
+ amount,
+ prompt: user.prompt,
type: PaymentType.lightning,
},
user,
});
+ }
const { id: uid } = user;
@@ -186,71 +314,30 @@ export default {
]);
const id = v4();
- await s(`lnurl:${id}`, uid);
- const total = (parseInt(invoice.amount || 0) + parseInt(invoice.tip || 0)) * 1000;
+
+ const total =
+ (parseInt(invoice.amount || 0) + parseInt(invoice.tip || 0)) * 1000;
+
+ await s(`lnurl:${id}`, uid, LNURL_TTL);
+ if (total > 0) await s(`lnurl:${id}:invoice`, invoice.id, LNURL_TTL);
res.send({
allowsNostr: true,
- minSendable: total,
- maxSendable: invoice.amount ? total : undefined,
+ minSendable: invoice.amount ? total : 1000,
+ maxSendable: invoice.amount ? total : 10 * 1000 * SATS,
metadata,
- nostrPubkey: serverPubkey,
+ nostrPubkey: serverPubkey2,
commentAllowed: 512,
callback: `${URL}/api/lnurl/${id}`,
tag: "payRequest",
});
} catch (e) {
- if (!e.message.includes("found"))
- warn("problem generating lnurlp request", username, e.message);
+ if (!e.message.includes("found")) {
+ const n = quiet(`lnurlp:${username}`, 5 * 60 * 1000);
+ if (n !== null)
+ warn("problem generating lnurlp request", username, e.message, suppressed(n));
+ }
bail(res, e.message);
}
},
-
- // async withdraw(req, res) {
- // try {
- // const { pr, k1: id } = req.query;
- // const invoice = await getInvoice(pr);
- // const { amount_msat } = await ln.decode(pr);
- // const amount = Math.ceil(amount_msat / 1000);
- // await db.debit(`fund:${id}`, "", amount, 0, 0, 0);
- // let p;
- // if (invoice) {
- // const { hash } = invoice;
- // p = await credit({
- // hash,
- // amount,
- // memo: id,
- // ref: id,
- // type: PaymentType.fund,
- // });
- // } else {
- // await ln.xpay({
- // invstring: pr.replace(/\s/g, "").toLowerCase(),
- // maxfee: 0,
- // retry_for: 10,
- // });
- // const rates = await g("rates");
- // p = {
- // id: v4(),
- // amount: -amount,
- // hash: pr,
- // confirmed: true,
- // rate: rates.USD,
- // currency: "USD",
- // type: PaymentType.fund,
- // ref: id,
- // created: Date.now(),
- // };
- // await s(`payment:${p.id}`, p);
- // await s(`payment:${pr}`, p);
- // }
- //
- // await db.lPush(`fund:${id}:payments`, p.id);
- // res.send({ status: "OK" });
- // } catch (e) {
- // console.log(e);
- // warn("lnurlw failed", e.message);
- // res.send({ status: "ERROR", reason: "Withdrawal failed" });
- // }
- // },
};
diff --git a/routes/nostr.ts b/routes/nostr.ts
index 9c61bf16..3b28076a 100644
--- a/routes/nostr.ts
+++ b/routes/nostr.ts
@@ -21,6 +21,36 @@ import { decode } from "nostr-tools/nip19";
import type { ProfilePointer } from "nostr-tools/nip19";
import { getZapEndpoint, makeZapRequest } from "nostr-tools/nip57";
+// Amount and payer of a zap receipt. NIP-57 kind 9735: amount comes from
+// decoding the paid bolt11, payer from the embedded 9734 zap request in the
+// description tag. NIP-177 kind 9736 (BOLT12 zaps): amount is an explicit
+// msat tag, and the receipt is payer-published with the signed 9737 intent in
+// the description tag.
+const parseZap = async (ev) => {
+ const { kind, tags, pubkey } = ev;
+ const description = tags.find((t) => t[0] === "description")?.[1];
+
+ let payer;
+ try {
+ payer = JSON.parse(description).pubkey;
+ } catch (e) {}
+ payer ||= pubkey;
+
+ let amount = 0;
+ if (kind === 9736) {
+ const msat = Number.parseInt(tags.find((t) => t[0] === "amount")?.[1]);
+ if (msat > 0) amount = Math.round(msat / 1000);
+ } else {
+ try {
+ const bolt11 = tags.find((t) => t[0] === "bolt11")?.[1];
+ const { amount_msat } = await ln.decode(bolt11);
+ if (amount_msat) amount = Math.round(amount_msat / 1000);
+ } catch (e) {}
+ }
+
+ return { amount, pubkey: payer };
+};
+
export default {
async event(req, res) {
try {
@@ -35,6 +65,7 @@ export default {
if (event) return res.send(event);
event = await get({ ids: [id] });
+ if (!event) return res.code(404).send("Not found");
if (!full) return res.send(event);
const parts = parseContent(event);
@@ -45,23 +76,14 @@ export default {
)
.map(({ value }) => value.pubkey);
- const zapEvents = await scan({ kinds: [9735], "#e": [id] });
+ const zapEvents = await scan({ kinds: [9735, 9736], "#e": [id] });
const zaps = [];
- for (const { tags } of zapEvents) {
- const bolt11 = tags.find((t) => t[0] === "bolt11")?.[1];
- const description = tags.find((t) => t[0] === "description")?.[1];
- const { pubkey } = JSON.parse(description);
- let amount = 0;
- try {
- const { amount_msat } = await ln.decode(bolt11);
- if (amount_msat) amount = Math.round(amount_msat / 1000);
- } catch (e) {}
-
- zaps.push({ amount, pubkey });
+ for (const zapEvent of zapEvents) {
+ zaps.push(await parseZap(zapEvent));
}
pubkeys.push(event.pubkey);
- pubkeys.push(...zaps.map((z) => z.pubkey));
+ pubkeys.push(...zaps.map((z) => z.pubkey).filter((p) => p));
pubkeys = [...new Set(pubkeys)];
const profiles = await scan({ kinds: [0], authors: pubkeys });
const found = profiles.map((p) => p.pubkey);
@@ -107,7 +129,6 @@ export default {
res.send(event);
} catch (e) {
- console.log(e);
bail(res, e.message);
}
},
@@ -158,7 +179,6 @@ export default {
res.send(thread);
} catch (e) {
- console.log(e);
bail(res, e.message);
}
},
@@ -168,24 +188,16 @@ export default {
let { id } = req.params;
if (id.startsWith("nevent")) id = decode(id).data.id;
if (id.startsWith("note")) id = decode(id).data;
- const filter = { kinds: [9735], "#e": [id] };
- let events = await scan(filter);
- if (!events.length) {
- await sync("wss://relay.primal.net", filter);
- events = await scan(filter);
- }
+ // q() scans the local strfry and falls back to primal (the old code
+ // called a `sync` helper that never existed and threw ReferenceError
+ // whenever the local relay had no receipts)
+ const filter = { kinds: [9735, 9736], "#e": [id] };
+ const events = await q(filter);
if (!events.length) return res.send([]);
const zaps = [];
- for (const { tags } of events) {
- const bolt11 = tags.find((t) => t[0] === "bolt11")?.[1];
- const description = tags.find((t) => t[0] === "description")?.[1];
- const { pubkey } = JSON.parse(description);
- let amount = 0;
- try {
- const { amount_msat } = await ln.decode(bolt11);
- if (amount_msat) amount = Math.round(amount_msat / 1000);
- } catch (e) {}
+ for (const zapEvent of events) {
+ const { amount, pubkey } = await parseZap(zapEvent);
let user;
if (pubkey) {
@@ -198,7 +210,6 @@ export default {
res.send(zaps.filter((z) => z.amount > 0));
} catch (e) {
- console.log(e);
bail(res, e.message);
}
},
@@ -217,7 +228,6 @@ export default {
res.send({});
} catch (e) {
- console.log(e);
bail(res, e.message);
}
},
@@ -244,7 +254,6 @@ export default {
res.send(events);
} catch (e) {
- console.log(e);
bail(res, e.message);
}
},
@@ -319,7 +328,6 @@ export default {
res.send(followers);
} catch (e) {
- console.log(e);
bail(res, e.message);
}
},
@@ -330,7 +338,6 @@ export default {
// res.send({ followers: 0, follows: 0 });
res.send(await getCount(pubkey));
} catch (e) {
- console.log(e);
bail(res, e.message);
}
},
@@ -341,7 +348,21 @@ export default {
} = req;
let names = {};
if (name) {
- names = { [name]: (await getUser(name, fields)).pubkey };
+ // v3 registrar first: a claimed name's NIP-05 belongs to its wallet key.
+ // Legacy accounts answer as before when the registrar doesn't know it.
+ try {
+ const r = await fetch(
+ `${process.env.NAMES_URL || "https://names.coinos.io"}/.well-known/nostr.json?name=${encodeURIComponent(name)}`,
+ { signal: AbortSignal.timeout(3000) },
+ );
+ if (r.ok) {
+ const j = await r.json();
+ if (j?.names?.[name]) return res.send({ names: { [name]: j.names[name] } });
+ }
+ } catch {}
+ const u = await getUser(name, fields);
+ if (!u) return res.send({ names: {} }); // unknown name: empty per NIP-05, not a 500
+ names = { [name]: u.pubkey };
} else {
const records = await db.sMembers("nip5");
for (const s of records) {
@@ -379,9 +400,7 @@ export default {
});
res.send(event);
- } catch (e) {
- console.log(e);
- }
+ } catch (e) {}
},
async zap(req, res) {
@@ -399,7 +418,6 @@ export default {
res.send(json);
} catch (e) {
- console.log(e);
bail(res, e.message);
}
},
diff --git a/routes/payments.ts b/routes/payments.ts
index 66a19e53..66fd3386 100644
--- a/routes/payments.ts
+++ b/routes/payments.ts
@@ -1,9 +1,12 @@
import config from "$config";
import api from "$lib/api";
import { requirePin } from "$lib/auth";
-import { db, g, s } from "$lib/db";
+import { archive, db, g, gf, s, sa } from "$lib/db";
+import { generate, getUserOffer } from "$lib/invoices";
+import { replay } from "$lib/lightning";
import ln from "$lib/ln";
-import { err, l, warn } from "$lib/logging";
+import { platformFee, quoteMax, quoteRoutingFee } from "$lib/lnquote";
+import { err, l, quiet, shortError, suppressed, warn } from "$lib/logging";
import mqtt from "$lib/mqtt";
import {
build,
@@ -31,6 +34,19 @@ import rpc from "@coinos/rpc";
import got from "got";
import { v4 } from "uuid";
+// Funds are closed. The mechanism let a caller pull unbacked balance out of a
+// fund (the /take drain, 2026-08-25) and its deposit path never had a
+// kill-switch of its own, so with `fund:disabled` set users could still pay
+// INTO funds they were then unable to withdraw from. Every value-moving entry
+// point — deposit (POST /payments with `fund`), withdrawal (POST /take) and
+// pre-authorization (POST /authorize) — now refuses unconditionally, rather
+// than depending on a redis flag someone can clear by accident.
+//
+// Read paths are deliberately left working: GET /fund/:id and the manager
+// endpoints still serve existing funds so balances stay visible and can be
+// settled out of band.
+const fundsClosed = () => fail("Funds are no longer supported");
+
export default {
async info(_, res) {
res.send(await ln.getinfo());
@@ -43,8 +59,21 @@ export default {
const balance = await g(`balance:${user.id}`);
try {
+ // Reject non-string payreq/hash up front. A client sending a number/object
+ // here otherwise reaches a decoder that calls `.startsWith` on it and throws
+ // an opaque "n.startsWith is not a function" (logged bare via the catch
+ // below). A malformed *string* is fine — it fails later with a clear error.
+ if (payreq != null && typeof payreq !== "string")
+ fail("Invalid payment request");
+ if (hash != null && typeof hash !== "string") fail("Invalid invoice");
+ // Invoices are keyed lowercase. An uppercase payreq (QR-scanned) missed
+ // the lookup below and was paid over cl to ourselves instead of
+ // internally; the listener then found the sender's payment:<bolt11>
+ // pointer and skipped crediting the recipient.
+ if (payreq) payreq = payreq.replace(/\s/g, "").toLowerCase();
+
if (typeof amount !== "undefined") {
- amount = parseInt(amount);
+ amount = Number.parseInt(amount);
if (amount < 0 || amount > SATS || Number.isNaN(amount))
fail("Invalid amount");
}
@@ -56,7 +85,7 @@ export default {
const invoice = await getInvoice(payreq || hash);
const recipient = invoice ? await getUser(invoice.uid) : undefined;
if (payreq) {
- if (invoice && recipient.username !== "mint") {
+ if (invoice) {
if (invoice.aid === user.id) fail("Cannot send to self");
hash = payreq;
if (!amount) ({ amount } = invoice);
@@ -75,23 +104,19 @@ export default {
sender: user,
});
} else if (fund) {
- p = await debit({
- hash,
- amount,
- memo: fund,
- user,
- type: PaymentType.fund,
- });
- await db.incrBy(`fund:${fund}`, amount);
- await db.lPush(`fund:${fund}:payments`, p.id);
- l("funded fund", fund);
+ fundsClosed();
}
}
res.send(p);
} catch (e) {
- warn(user.username, "payment failed", amount, balance, hash, payreq);
- err(e.message);
+ // One line per account per 5 min: frozen/empty throwaway accounts retry
+ // failed sends in a loop and otherwise dominate the log.
+ const n = quiet(`payfail:${user.id}`, 5 * 60 * 1000);
+ if (n !== null) {
+ warn(user.username, "payment failed", amount, balance, hash, payreq, suppressed(n));
+ err(shortError(e.message));
+ }
bail(res, e.message);
}
},
@@ -99,25 +124,42 @@ export default {
async list(req, res) {
let {
user: { id },
- query: { aid, start, end, limit, offset },
+ query: { aid, start, end, limit, offset, received },
} = req;
if (!aid || aid === "undefined") aid = id;
- limit = parseInt(limit);
- offset = parseInt(offset) || 0;
+ const index = await db.lPos(`${id}:accounts`, aid);
+ if (index === null) fail("unauthorized");
+
+ limit = Number.parseInt(limit);
+ offset = Number.parseInt(offset) || 0;
- const range = !limit || start || end ? -1 : limit - 1;
- let payments = (await db.lRange(`${aid || id}:payments`, 0, range)) || [];
+ const range = !limit || received || start || end ? -1 : limit - 1;
+ const listKey = `${aid || id}:payments`;
+ let payments = (await db.lRange(listKey, 0, range)) || [];
+
+ if (range === -1) {
+ const archived = (await archive.lRange(listKey, 0, -1)) || [];
+ payments = [...new Set([...payments, ...archived])];
+ } else if (limit) {
+ const needed = Math.max(0, limit + offset - payments.length);
+ if (needed > 0) {
+ const archived =
+ (await archive.lRange(listKey, 0, limit + offset - 1)) || [];
+ payments = [...new Set([...payments, ...archived])];
+ }
+ }
payments = (
await Promise.all(
payments.map(async (pid) => {
- const p = await g(`payment:${pid}`);
+ const p = await gf(`payment:${pid}`);
if (!p) {
warn("user", id, "missing payment", pid);
- await db.lRem(`${aid || id}:payments`, 0, pid);
- return p;
+ return;
}
+ if (p.revertedDuplicate) return;
+ if (received && p.amount < 0) return;
if (p.created < start || p.created > end) return;
if (p.type === PaymentType.internal)
p.with = await getUser(p.ref, fields);
@@ -133,7 +175,7 @@ export default {
[b.currency]: {
tips: (a[b.currency] ? a[b.currency].tips : 0) + (b.tip || 0),
fiatTips: (
- parseFloat(a[b.currency] ? a[b.currency].fiatTips : 0) +
+ Number.parseFloat(a[b.currency] ? a[b.currency].fiatTips : 0) +
((b.tip || 0) * b.rate) / SATS
).toFixed(2),
sats:
@@ -143,7 +185,7 @@ export default {
(b.fee || 0) -
(b.ourfee || 0),
fiat: (
- parseFloat(a[b.currency] ? a[b.currency].fiat : 0) +
+ Number.parseFloat(a[b.currency] ? a[b.currency].fiat : 0) +
(((b.amount || 0) +
((b.amount > 0 ? b.tip : -b.tip) || 0) -
(b.fee || 0) -
@@ -171,9 +213,9 @@ export default {
const p = await getPayment(hash);
if (p?.type === PaymentType.internal)
p.with = await getUser(p.ref, fields);
+ if (p?.type === PaymentType.fund) p.with = await getUser(p.uid, fields);
res.send(p);
} catch (e) {
- console.log(e);
err("failed to get payment", e.message);
bail(res, e.message);
}
@@ -185,14 +227,19 @@ export default {
user,
} = req;
try {
+ // Node alias lookup, refreshed hourly. Only the id→alias map is kept:
+ // the old code stored the full listnodes output (~10MB for 16k nodes)
+ // as an array, so the `last` stamp it hung on the array was dropped by
+ // JSON.stringify and the condition was inverted anyway — every send
+ // page view refetched and rewrote the whole thing.
const hour = 1000 * 60 * 60;
- let nodes = await g("nodes");
- const { last } = nodes || {};
-
- if (!last || last > Date.now() - hour) {
- ({ nodes } = await ln.listnodes());
- nodes.last = Date.now();
- await s("nodes", nodes);
+ let cache = await g("nodes");
+ if (!cache?.aliases || !cache.last || cache.last < Date.now() - hour) {
+ const { nodes } = await ln.listnodes();
+ const aliases = {};
+ for (const n of nodes) if (n.alias) aliases[n.nodeid] = n.alias;
+ cache = { last: Date.now(), aliases };
+ await s("nodes", cache);
}
const decoded = await ln.decode(payreq);
@@ -200,23 +247,30 @@ export default {
let amount_msat;
let payee;
- if (decoded.type.includes("bolt12")) {
+ if (decoded.type === "bolt12 offer") {
+ ({ offer_amount_msat: amount_msat } = decoded);
+ payee = decoded.offer_issuer_id || decoded.offer_node_id;
+ } else if (decoded.type.includes("bolt12")) {
({ invoice_amount_msat: amount_msat, invoice_node_id: payee } =
decoded);
} else ({ amount_msat, payee } = decoded);
- const node = nodes.find((n) => n.nodeid === payee);
- const alias = node ? node.alias : payee.substr(0, 12);
+ const alias = cache.aliases[payee] || (payee || "").substr(0, 12);
- const amount = Math.round(amount_msat / 1000);
+ const amount = Math.round((amount_msat || 0) / 1000);
let ourfee = Math.round(amount * config.fee[PaymentType.lightning]);
const credit = await g(`credit:lightning:${user.id}`);
const covered = Math.min(credit, ourfee) || 0;
ourfee -= covered;
- res.send({ alias, amount, ourfee });
+ res.send({
+ alias,
+ amount,
+ ourfee,
+ type: decoded.type,
+ description: decoded.offer_description || decoded.description,
+ });
} catch (e) {
- console.log(e);
err("problem parsing", e.message);
bail(res, e.message);
}
@@ -224,20 +278,47 @@ export default {
async fund(req, res) {
const {
- params: { name },
+ params: { id },
} = req;
- const amount = await g(`fund:${name}`);
+ let amount = await g(`fund:${id}`);
+ let fid = id;
+
+ // A rotated fund no longer exists at its OLD id (security fix 2026-08-25:
+ // fund secret uids were rotated to invalidate an exfiltrated id list). Serve
+ // the new fund ONLY to an authenticated MANAGER of it — never anonymously or
+ // to a non-owner. An open old->new redirect would defeat the rotation, since
+ // the attacker holds the leaked OLD ids; gating on manager membership means a
+ // replayed old id reveals nothing unless you already control the fund. The
+ // route's `optional` auth populates req.user without requiring it. Bearer
+ // funds (no managers) are intentionally not recoverable this way.
+ if (typeof amount === "undefined" || amount === null) {
+ const rotatedTo = await g(`fund:rotated:${id}`);
+ const uid = req.user?.id;
+ if (rotatedTo && uid && (await db.sIsMember(`fund:${rotatedTo}:managers`, uid))) {
+ fid = rotatedTo;
+ amount = await g(`fund:${rotatedTo}`);
+ }
+ }
+
if (typeof amount === "undefined" || amount === null)
return bail(res, "fund not found");
- let payments = (await db.lRange(`fund:${name}:payments`, 0, -1)) || [];
- payments = await Promise.all(payments.map((hash) => g(`payment:${hash}`)));
+
+ let payments = (await db.lRange(`fund:${fid}:payments`, 0, -1)) || [];
+ payments = await Promise.all(payments.map((hash) => gf(`payment:${hash}`)));
await Promise.all(
payments.map(async (p: any) => (p.user = await getUser(p.uid, fields))),
);
payments = payments.filter((p) => p);
- res.send({ amount, payments });
+
+ const authorization = await g(`authorization:${fid}`);
+ res.send({
+ amount,
+ authorization: authorization?.amount,
+ payments,
+ ...(fid !== id ? { id: fid, rotatedFrom: id } : {}),
+ });
},
// async withdraw(req, res) {
@@ -258,38 +339,134 @@ export default {
// });
// },
+ async authorize(req, res) {
+ fundsClosed();
+
+ const { id: uid } = req.user;
+ const { id, fiat, currency, amount } = req.body;
+
+ const managers = await db.sMembers(`fund:${id}:managers`);
+ if (managers.length && !managers.includes(uid)) fail("Unauthorized");
+
+ // The authorization's fiat/currency is the ONLY ceiling on how much a later
+ // /take can pull from the authorizer (cap = sats(fiat / rates[currency])).
+ // Reject a NaN/zero/non-finite rate or a non-positive fiat so the ceiling
+ // can never be turned into an astronomically large or non-finite value.
+ const rates = await g("rates");
+ const rate = Number(rates?.[currency]);
+ const fiatAmount = Number(fiat);
+ if (!Number.isFinite(rate) || rate <= 0) fail("Invalid currency");
+ if (!Number.isFinite(fiatAmount) || fiatAmount <= 0) fail("Invalid amount");
+
+ const authorization = {
+ uid,
+ currency,
+ fiat: fiatAmount,
+ amount: Number.parseInt(amount) || 0,
+ };
+
+ await s(`authorization:${id}`, authorization);
+ res.send({});
+ },
+
async take(req, res) {
let {
body: { id, amount, invoice: iid },
user,
} = req;
try {
- const { id: uid } = user;
- amount = parseInt(amount);
- if (amount < 0) fail("Invalid amount");
-
- const managers = await db.sMembers(`fund:${id}:managers`);
- if (managers.length && !managers.includes(user.id)) fail("Unauthorized");
+ fundsClosed();
- const result: any = await db.debit(`fund:${id}`, "", "Insufficient funds", amount, 0, 0, 0, 0);
- if (result.err) fail(result.err);
+ amount = Number.parseInt(amount);
+ if (!Number.isFinite(amount) || amount <= 0) fail("Invalid amount");
const rates = await g("rates");
- const { currency } = user;
- const rate = rates[currency];
if (!iid) {
- iid = v4();
- await s(`invoice:${iid}`, {
- currency,
- id: iid,
- hash: iid,
- rate,
- uid,
- received: 0,
+ const inv = await generate({
+ invoice: { amount, type: "lightning" },
+ user,
+ });
+ iid = inv.id;
+ }
+
+ const authorization = await g(`authorization:${id}`);
+ if (authorization && !authorization.claimed) {
+ const { currency, fiat } = authorization;
+ // Bound the take to the authorized fiat value using a VALIDATED fiat
+ // rate. A non-finite/zero rate or a crypto-denominated authorization
+ // (rate ~1) would otherwise make sats(fiat / rate) an astronomically
+ // large ceiling, defeating the cap (the drain vector).
+ const rate = Number(rates?.[currency]);
+ const fiatAmount = Number(fiat);
+ if (!Number.isFinite(rate) || rate <= 0)
+ fail("Invalid authorization currency");
+ if (!Number.isFinite(fiatAmount) || fiatAmount <= 0)
+ fail("Invalid authorization amount");
+ const cap = sats(fiatAmount / rate);
+ if (!Number.isFinite(cap) || cap <= 0) fail("Invalid authorization");
+ amount = Math.min(amount, cap);
+ if (!Number.isFinite(amount) || amount <= 0) fail("Invalid amount");
+
+ // Atomic single-use claim. The read above is not a lock: concurrent
+ // POST /take for the same authorization all pass the `!claimed` gate and,
+ // without an atomic gate, each would debit the authorizer and fund the
+ // pool — redeeming a single-use authorization N times (COINOS-3). SET NX
+ // lets exactly one caller win the claim; the rest skip the funding.
+ const claimed = await db.set(`authorization:${id}:claimed`, user.id, {
+ NX: true,
});
+ if (claimed) {
+ // Mark the record claimed only once the funding has actually landed,
+ // and release the claim if it throws — otherwise a failed funding
+ // (an insufficient balance, a server limit) burns the authorization
+ // permanently: the key is set, the fund never gets the money, and
+ // every later /take skips the funding block.
+ try {
+ const sender = await getUser(authorization.uid);
+ if (!sender) fail("authorizer not found");
+
+ const { hash } = await generate({
+ invoice: { amount, type: "lightning" },
+ user: sender,
+ });
+
+ const { id: pid } = await debit({
+ hash,
+ amount,
+ memo: id,
+ user: sender,
+ type: PaymentType.fund,
+ });
+
+ await db.incrBy(`fund:${id}`, amount);
+ await db.lPush(`fund:${id}:payments`, pid);
+
+ authorization.claimed = true;
+ await s(`authorization:${id}`, authorization);
+ l("funded fund", id);
+ } catch (e) {
+ await db.del(`authorization:${id}:claimed`);
+ throw e;
+ }
+ }
}
+ const managers = await db.sMembers(`fund:${id}:managers`);
+ if (managers.length && !managers.includes(user.id)) fail("Unauthorized");
+
+ const result: any = await db.debit(
+ `fund:${id}`,
+ "",
+ "Insufficient funds",
+ amount,
+ 0,
+ 0,
+ 0,
+ 0,
+ );
+ if (result.err) fail(result.err);
+
const payment = await credit({
aid: user.id,
hash: iid,
@@ -301,7 +478,7 @@ export default {
await db.lPush(`fund:${id}:payments`, payment.id);
- res.send({ payment });
+ res.send(payment);
} catch (e) {
warn("problem withdrawing from fund", user.username, e.message);
bail(res, e.message);
@@ -313,9 +490,9 @@ export default {
const ids = await db.sMembers(`fund:${name}:managers`);
- const managers = await Promise.all(
- ids.map(async (id) => await getUser(id)),
- );
+ const managers = (await Promise.all(
+ ids.map(async (id) => await getUser(id, fields)),
+ )).filter(Boolean);
res.send(managers);
},
@@ -333,17 +510,42 @@ export default {
await db.sAdd(k, user.id);
}
- const u = await getUser(username);
+ const u = await getUser(username, fields);
if (!u) fail("User not found");
const { id: uid } = u;
await db.sAdd(k, uid);
const ids = await db.sMembers(k);
- if (!managers.length)
- managers = await Promise.all(ids.map(async (id) => await getUser(id)));
+ const resolved = (await Promise.all(
+ ids.map(async (id) => await getUser(id, fields)),
+ )).filter(Boolean);
- res.send(managers);
+ res.send(resolved);
+ },
+
+ async deleteManager(req, res) {
+ try {
+ const { name } = req.params;
+ const { id: uid } = req.body;
+ const { user } = req;
+
+ const k = `fund:${name}:managers`;
+ let managers = await db.sMembers(k);
+
+ if (managers.length) {
+ if (!managers.includes(user.id)) fail("Unauthorized");
+ }
+
+ await db.sRem(k, uid);
+
+ const ids = await db.sMembers(k);
+ managers = await Promise.all(
+ ids.map(async (id) => await getUser(id, fields)),
+ );
+
+ res.send(managers);
+ } catch (e) {}
},
async confirm(req, res) {
@@ -358,11 +560,34 @@ export default {
let aid;
if (!hot) aid = wallet;
+ // Change-recredit guard (2026-08-18). If our own wallet contributed inputs
+ // to this transaction, a "send" detail is present. In that case every
+ // receive output is our own CHANGE or an internal move — never an external
+ // deposit — so it must not be credited: doing so mints phantom balance
+ // (the withdrawal-change re-credit exploit that drove the L-C creep). A
+ // genuine coinos->coinos transfer is settled off-chain in the send path
+ // (credit() resolves getInvoice(hash) and books an internal transfer), so
+ // an on-chain receive funded by our own spend is only ever change.
+ const weSpent = details.some((d: any) => d.category === "send");
+
for (const { address, amount, asset, category, vout } of details) {
if (!address) continue;
if (type === PaymentType.liquid && asset !== config.liquid.btc)
continue;
+ // Kill switch: `liquid:deposits:disabled` stops every Liquid receive
+ // from being credited or confirmed (sends still get their
+ // confirmation bookkeeping below). Set during a security incident;
+ // clear the key to re-enable.
+ if (
+ type === PaymentType.liquid &&
+ category !== "send" &&
+ (await g("liquid:deposits:disabled"))
+ ) {
+ warn("liquid deposit blocked (disabled)", txid, vout, sats(amount), address);
+ continue;
+ }
+
if (category === "send") {
const p = await getPayment(txid);
if (!p) continue;
@@ -379,11 +604,30 @@ export default {
continue;
}
+ // See weSpent above: a receive output on a transaction our wallet funded
+ // is change, not a deposit. Skip it so we never re-credit our own funds.
+ // Log the blocked amount as a tripwire: sums the phantom credit prevented
+ // and surfaces any continued exploitation attempts.
+ if (weSpent) {
+ if (sats(amount) >= 300)
+ warn("blocked change re-credit", txid, vout, sats(amount), address);
+ continue;
+ }
+
const p = await getPayment(`${txid}:${vout}`);
if (!p) {
const invoice = await getInvoice(address);
if (!hot && aid !== invoice?.aid) continue;
+ if (sats(amount) < 300) continue;
+ // Atomic first-seen guard (COINOS-2). The getPayment() read above is not
+ // a lock: concurrent callers (walletnotify, catchUp, bulk /confirm
+ // sweeps — /confirm is unauthenticated) can all read p==null for one
+ // deposit and each run credit(), double-crediting the pending balance
+ // AND inflating the spendable credit:<net>:<uid> fee accumulator. Mirror
+ // the confirm-stage lock so only the first caller credits.
+ const firstlock = `firstseenlock:${txid}:${vout}`;
+ if (!(await db.set(firstlock, "1", { NX: true, EX: 60 }))) continue;
await credit({
hash: address,
amount: sats(amount),
@@ -393,15 +637,30 @@ export default {
});
} else if (confirmations >= 1) {
const id = `payment:${txid}:${vout}`;
+ // Atomic guard against concurrent /confirm callers double-crediting:
+ // walletnotify, catchUp, and bulk sweeps can all fire for the same
+ // txid:vout in parallel. Without this, two callers can each read
+ // p.confirmed=false and each run the multi() that increments balance.
+ const lockKey = `confirmlock:${txid}:${vout}`;
+ const acquired = await db.set(lockKey, "1", { NX: true, EX: 60 });
+ if (!acquired) continue;
+
const p = await getPayment(`${txid}:${vout}`);
- if (!p) return db.sAdd("missed", id);
- if (p.confirmed) return;
+ if (!p) {
+ await db.sAdd("missed", id);
+ await db.del(lockKey);
+ return;
+ }
+ if (p.confirmed) {
+ await db.del(lockKey);
+ return;
+ }
const invoice = await getInvoice(address);
const { id: iid } = invoice;
p.confirmed = true;
- invoice.received += parseInt(invoice.pending);
+ invoice.received += Number.parseInt(invoice.pending);
invoice.pending = 0;
l("confirming", id, p.id, p.amount);
@@ -414,13 +673,20 @@ export default {
.incrBy(`balance:${p.aid || p.uid}`, p.amount)
.exec();
+ // Mirror the now-confirmed record into arc so a future bulk sweep
+ // finds it via gf() fallback even if the main-db pointer is wiped
+ // (see feedback_apr29_double_credit_incident.md).
+ await sa(`payment:${p.id}`, p);
+ await sa(`invoice:${iid}`, invoice);
+
+ await db.del(lockKey);
+
const user = await g(`user:${p.uid}`);
await completePayment(invoice, p, user);
}
}
res.send({});
} catch (e) {
- console.log(e);
warn(`problem processing ${txid}`);
bail(res, e.message);
}
@@ -454,7 +720,9 @@ export default {
res.send(p);
} catch (e) {
- warn(user.username, "payment failed", e.message);
+ const n = quiet(`payfail:${user.id}`, 5 * 60 * 1000);
+ if (n !== null)
+ warn(user.username, "payment failed", shortError(e.message), suppressed(n));
res.code(500).send(e.message);
}
},
@@ -464,11 +732,10 @@ export default {
body: { secret },
} = req;
try {
- if (secret !== config.adminpass) fail("unauthorized");
+ if (!config.adminpass || secret !== config.adminpass) fail("unauthorized");
await s("freeze", true);
res.send("ok");
} catch (e) {
- console.log(e);
bail(res, e.message);
}
},
@@ -479,7 +746,8 @@ export default {
user,
} = req;
try {
- const p = await g(`payment:${id}`);
+ const p = await gf(`payment:${id}`);
+ if (!p) fail("Payment not found");
if (p.uid !== user.id) fail("unauthorized");
emit(user.id, "payment", p);
@@ -488,6 +756,7 @@ export default {
mqtt.publish(
username,
`pay:${p.amount}:${p.tip}:${p.rate}:${p.created}:${p.id}`,
+ { qos: 1 },
);
res.send({ ok: true });
@@ -498,10 +767,11 @@ export default {
async lnaddress(req, res) {
let {
- params: { lnaddress, amount, fee = 5000 },
+ params: { lnaddress, amount },
body,
user,
} = req;
+ const { fee } = body;
try {
lnaddress = decodeURIComponent(lnaddress);
await requirePin({ body, user });
@@ -525,32 +795,22 @@ export default {
let p;
if (payee === id) {
p = await debit({ hash: pr, amount, memo, user });
- await credit({ hash: pr, amount, memo, ref: user.id });
+ await credit({ hash: pr, amount, memo, ref: user.id, tip: p.tip });
} else p = await sendLightning({ user, pr, amount, fee, memo });
res.send(p);
} catch (e) {
- console.log(e);
bail(res, e.message);
}
},
- async gateway(req, res) {
- const {
- body: { short_channel_id, webhook },
- } = req;
-
- await s(short_channel_id, webhook);
- res.send({ ok: true });
- },
-
async replace(req, res) {
const {
body: { id },
user,
} = req;
try {
- const p = await g(`payment:${id}`);
+ const p = await gf(`payment:${id}`);
if (!p) fail("Payment not found");
if (p.uid !== user.id) fail("unauthorized");
@@ -610,8 +870,191 @@ export default {
res.send(await ln.decode(bolt11));
},
+ // Up-front cost of a lightning send: the routing fee askrene quotes for this
+ // invoice (the same answer xpay will get) plus the platform fee debit() will
+ // charge. With `max`, instead solve for the largest amount whose amount +
+ // fees exactly clears the account balance — the "send everything" case where
+ // guessing a maxfee always left dust behind or bounced on insufficient funds.
+ async quote(req, res) {
+ const { body, user } = req;
+ try {
+ let { payreq, amount, max, aid, ceiling } = body;
+ if (typeof payreq !== "string" || !payreq.trim())
+ fail("Invalid payment request");
+ payreq = payreq.replace(/\s/g, "").toLowerCase();
+
+ if (!aid) aid = user.id;
+ else if (typeof aid !== "string") fail("Invalid account");
+ else if (aid !== user.id) {
+ const pos = await db.lPos(`${user.id}:accounts`, aid);
+ if (pos == null) fail("account not found");
+ }
+
+ const parseSats = (v, name) => {
+ if (v === undefined || v === null || v === "") return undefined;
+ const n = Number.parseInt(v);
+ if (Number.isNaN(n) || n < 0 || n > SATS) fail(`Invalid ${name}`);
+ return n;
+ };
+ amount = parseSats(amount, "amount");
+ ceiling = parseSats(ceiling, "ceiling");
+ const balance = Number.parseInt(await g(`balance:${aid}`)) || 0;
+
+ // Paying another coinos user never leaves the ledger: no routing, no
+ // platform fee, the whole balance is sendable.
+ const invoice = await getInvoice(payreq);
+ if (invoice) {
+ const recipient = await getUser(invoice.uid);
+ if (recipient?.username !== "mint") {
+ const a = max ? balance : (amount ?? invoice.amount);
+ return res.send({
+ amount: a,
+ fee: 0,
+ ourfee: 0,
+ total: a,
+ balance,
+ internal: true,
+ });
+ }
+ }
+
+ let decoded = await ln.decode(payreq);
+ let pr = payreq;
+ let fetched: string | undefined;
+
+ // An offer isn't routable by itself — the blinded paths live in the
+ // invoice fetched from it. Fetch one to learn them; in max mode that's a
+ // placeholder amount, and the final invoice is fetched once we know the
+ // real one so the caller can pay exactly what we quoted.
+ const fetchFromOffer = async (a: number) => {
+ const { invoice } = await ln.fetchinvoice({
+ offer: payreq,
+ amount_msat: decoded.offer_amount_msat ? undefined : a * 1000,
+ timeout: 60,
+ });
+ return invoice.replace(/\s/g, "").toLowerCase();
+ };
+
+ if (decoded.type === "bolt12 offer") {
+ if (decoded.offer_currency)
+ fail("Currency-denominated offers are not supported");
+ const offerAmount = decoded.offer_amount_msat
+ ? Math.round(decoded.offer_amount_msat / 1000)
+ : undefined;
+ if (offerAmount) max = false;
+ const a = offerAmount ?? amount ?? (max ? balance : undefined);
+ if (!a) fail("Amount required");
+ pr = fetched = await fetchFromOffer(a);
+ decoded = await ln.decode(pr);
+ }
+
+ let invAmount: number | undefined;
+ if (decoded.type === "bolt12 invoice")
+ invAmount = decoded.invoice_amount_msat
+ ? Math.round(decoded.invoice_amount_msat / 1000)
+ : undefined;
+ else
+ invAmount = decoded.amount_msat
+ ? Math.round(decoded.amount_msat / 1000)
+ : undefined;
+
+ if (max) {
+ // The invoice's own amount is ignored here: an LNURL caller has to
+ // name an amount to get any invoice at all, so it probes with one at
+ // the ceiling, takes the amount we solve for, and fetches the real
+ // invoice for that. Anything that pays a fixed-amount invoice with
+ // a different amount fails at send time, not here.
+ const q = await quoteMax({ pr, uid: user.id, aid, ceiling });
+ // Offer: the placeholder invoice carried the balance, not the answer.
+ if (fetched && q.amount !== invAmount) {
+ fetched = await fetchFromOffer(q.amount);
+ // Re-quote against the real invoice: same paths, so same fee, but
+ // never hand back a number we didn't verify against what gets paid.
+ const { fee } = await quoteRoutingFee({ pr: fetched, amount: q.amount });
+ if (fee !== q.fee) fail("Routing fee changed, please try again");
+ }
+ l("quote max", user.username, q.amount, "fee", q.fee, "ourfee", q.ourfee, "balance", q.balance);
+ return res.send({ ...q, ...(fetched ? { payreq: fetched } : {}) });
+ }
+
+ const a = invAmount ?? amount;
+ if (!a) fail("Amount required");
+ if (invAmount && amount && amount !== invAmount)
+ fail("Amount does not match invoice");
+ const { fee, parts } = await quoteRoutingFee({ pr, amount: a });
+ const ourfee = await platformFee({ uid: user.id, aid, amount: a, fee });
+ l("quote", user.username, a, "fee", fee, "ourfee", ourfee, "parts", parts);
+ res.send({
+ amount: a,
+ fee,
+ ourfee,
+ parts,
+ total: a + fee + ourfee,
+ balance,
+ ...(fetched ? { payreq: fetched } : {}),
+ });
+ } catch (e) {
+ warn("problem quoting", user.username, shortError(e.message));
+ bail(res, e.message);
+ }
+ },
+
+ // The user's standing bolt12 offer (lno1...) — reusable receive code they
+ // can publish (e.g. in a nostr kind 10058 list for bolt12 zaps)
+ async offer(req, res) {
+ try {
+ res.send(await getUserOffer(req.user));
+ } catch (e) {
+ bail(res, e.message);
+ }
+ },
+
async fetchinvoice(req, res) {
- const { amount, offer } = req.body;
- res.send(await ln.fetchinvoice(offer, amount ? amount * 1000 : null));
+ const { amount, offer, payer_note } = req.body;
+ res.send(
+ await ln.fetchinvoice({
+ offer,
+ amount_msat: amount ? amount * 1000 : undefined,
+ payer_note,
+ timeout: 60,
+ }),
+ );
+ },
+
+ async auth(req, res) {
+ console.log(req.query);
+ res.send(req.query);
+ },
+
+ async order(req, res) {
+ console.log(req.body);
+ res.send(req.body);
+ },
+
+ async sendinvoice(req, res) {
+ try {
+ const { user } = req;
+ const { invreq } = req.body;
+
+ const { amount_msat, bolt12, pay_index } = await ln.sendinvoice({
+ invreq,
+ label: v4(),
+ });
+
+ await generate({
+ invoice: {
+ amount: Math.round(amount_msat / 1000),
+ type: "bolt12",
+ bolt12,
+ },
+ user,
+ });
+
+ const p = await replay(pay_index);
+
+ res.send(p);
+ } catch (e) {
+ bail(res, e.message);
+ }
},
};
diff --git a/routes/preimages.ts b/routes/preimages.ts
new file mode 100644
index 00000000..5e7c484f
--- /dev/null
+++ b/routes/preimages.ts
@@ -0,0 +1,113 @@
+import { createHash } from "node:crypto";
+import { db, g } from "$lib/db";
+import { parseEntry } from "$lib/invoices";
+import { bail, fail } from "$lib/utils";
+
+// Merchants pre-load payment preimages here, then have buyers create invoices
+// with usePreimage: true — each such invoice consumes the next queued preimage
+// so its payment hash is sha256(preimage). Once paid, the preimage is exposed
+// on the invoice record, letting a static storefront sell decryption secrets
+// with no backend of its own. An optional per-batch price — sats, or a fiat
+// amount converted at invoice-creation time — is enforced at invoice creation;
+// creation is unauthenticated, so without it a buyer could name their own
+// price for the secret.
+
+const MAX_QUEUE = 1000;
+const key = (uid) => `${uid}:preimages`;
+const sha256 = (hex) =>
+ createHash("sha256").update(Buffer.from(hex, "hex")).digest("hex");
+
+export default {
+ async add(req, res) {
+ try {
+ const { preimages, price = null, fiat = null } = req.body;
+ let { currency = null } = req.body;
+ if (!Array.isArray(preimages) || !preimages.length)
+ fail("preimages array required");
+ if (
+ !preimages.every(
+ (p) => typeof p === "string" && /^[0-9a-f]{64}$/i.test(p),
+ )
+ )
+ fail("preimages must be 64-character hex strings");
+ if (price !== null && fiat !== null)
+ fail("specify price (sats) or fiat, not both");
+ if (price !== null && (!Number.isInteger(price) || price <= 0))
+ fail("price must be a positive integer amount of sats");
+ if (fiat !== null) {
+ if (typeof fiat !== "number" || !(fiat > 0))
+ fail("fiat must be a positive number");
+ currency ||= req.user.currency;
+ const rates = await g("rates");
+ if (!rates?.[currency]) fail(`unsupported currency ${currency}`);
+ } else {
+ currency = null;
+ }
+
+ const k = key(req.user.id);
+ const existing = await db.lRange(k, 0, -1);
+ const seen = new Set(existing.map((e) => parseEntry(e).preimage));
+ const fresh = [];
+ for (let p of preimages) {
+ p = p.toLowerCase();
+ if (seen.has(p)) continue;
+ // A preimage that already settled (or backs a live invoice) can't be
+ // credited again — reject it rather than let a buyer pay for nothing.
+ if (
+ (await db.exists(`credited:${p}`)) ||
+ (await db.exists(`preimage:${sha256(p)}`))
+ )
+ fail("preimage already used");
+ seen.add(p);
+ fresh.push(p);
+ }
+
+ if (existing.length + fresh.length > MAX_QUEUE)
+ fail(`queue limited to ${MAX_QUEUE} preimages`);
+ if (fresh.length)
+ await db.rPush(
+ k,
+ fresh.map((preimage) =>
+ JSON.stringify({ preimage, price, fiat, currency }),
+ ),
+ );
+
+ res.send({
+ queued: existing.length + fresh.length,
+ added: fresh.map((preimage) => ({
+ preimage,
+ hash: sha256(preimage),
+ price,
+ fiat,
+ currency,
+ })),
+ });
+ } catch (e) {
+ bail(res, e.message);
+ }
+ },
+
+ async list(req, res) {
+ try {
+ const entries = await db.lRange(key(req.user.id), 0, -1);
+ res.send({
+ queued: entries.length,
+ preimages: entries.map((e) => {
+ const { preimage, price, fiat, currency } = parseEntry(e);
+ return { preimage, hash: sha256(preimage), price, fiat, currency };
+ }),
+ });
+ } catch (e) {
+ bail(res, e.message);
+ }
+ },
+
+ async clear(req, res) {
+ try {
+ await db.del(key(req.user.id));
+ res.send({ queued: 0 });
+ } catch (e) {
+ bail(res, e.message);
+ }
+ },
+};
diff --git a/routes/rates.ts b/routes/rates.ts
index 67c111d7..e532ec78 100644
--- a/routes/rates.ts
+++ b/routes/rates.ts
@@ -12,6 +12,7 @@ export default {
},
async index(_, res) {
- res.send(await g("rates"));
+ const { date, fx, ...rates } = await g("rates");
+ res.send(rates);
},
};
diff --git a/routes/square.ts b/routes/square.ts
new file mode 100644
index 00000000..fb06e082
--- /dev/null
+++ b/routes/square.ts
@@ -0,0 +1,92 @@
+import * as crypto from "crypto";
+import config from "$config";
+import { db, g, s } from "$lib/db";
+import { generate } from "$lib/invoices";
+import { SATS, bail, getUser } from "$lib/utils";
+import { sha256 } from "@noble/hashes/sha256";
+import { utf8ToBytes } from "@noble/hashes/utils";
+import { base64urlnopad as base64 } from "@scure/base";
+import { SquareClient } from "square";
+import { v4 } from "uuid";
+
+const { scopes, url, appId, clientSecret, environment } = config.square;
+
+export default {
+ async connect(req, res) {
+ const { user } = req;
+
+ if (await db.exists(`${user.id}:square`)) {
+ return res.send("connected");
+ }
+
+ const codeVerifier = base64.encode(crypto.randomBytes(32));
+ await s(`${user.id}:codeVerifier`, codeVerifier);
+
+ const challenge = base64.encode(sha256(utf8ToBytes(codeVerifier)));
+
+ const state = base64.encode(crypto.randomBytes(12));
+ const scope = scopes.join("+");
+
+ res.send(
+ `${url}oauth2/authorize?client_id=${appId}&session=false&scope=${scope}&state=${state}&code_challenge=${challenge}`,
+ );
+ },
+
+ async auth(req, res) {
+ const { user } = req;
+
+ const codeVerifier = await g(`${user.id}:codeVerifier`);
+
+ const client = new SquareClient({
+ environment,
+ });
+
+ const { code } = req.query;
+
+ try {
+ const result = await client.oAuth.obtainToken({
+ code,
+ clientId: appId,
+ grantType: "authorization_code",
+ codeVerifier,
+ });
+
+ await s(`${user.id}:square`, result);
+ await s(result.merchantId, user.id);
+ res.send({});
+ } catch (e) {}
+ },
+
+ async payment(req, res) {
+ try {
+ const { body } = req;
+ const { data, type, merchant_id } = body;
+ const { payment } = data.object;
+
+ if (
+ type === "payment.created" &&
+ (payment.source_type === "CASH" || payment.source_type === "EXTERNAL")
+ ) {
+ const {
+ amount_money: { amount, currency },
+ } = payment;
+ const rates = await g("rates");
+ const rate = rates[currency];
+ const uid = await g(merchant_id);
+ const user = await getUser(uid);
+ const invoice = {
+ amount: Math.round(((amount / 100) * SATS) / rate),
+ own: true,
+ prompt: user.prompt,
+ type: "lightning",
+ };
+
+ await generate({ invoice, user });
+ }
+
+ res.send({});
+ } catch (e) {
+ bail(res, e.message);
+ }
+ },
+};
diff --git a/routes/users.ts b/routes/users.ts
index 34c2a655..684f361b 100644
--- a/routes/users.ts
+++ b/routes/users.ts
@@ -1,21 +1,25 @@
-import { createReadStream } from "node:fs";
-import { appendFile, unlink } from "node:fs/promises";
+import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
import config from "$config";
import { requirePin } from "$lib/auth";
-import { db, g, ga, s } from "$lib/db";
+import { db, g, gf, s } from "$lib/db";
import { err, l, warn } from "$lib/logging";
import { mail, templates } from "$lib/mail";
-import { getNostrUser, getProfile, serverPubkey } from "$lib/nostr";
-import { reconcile } from "$lib/payments";
+import { getNostrUser, getProfile, serverPubkey2 } from "$lib/nostr";
import register from "$lib/register";
+import { assertNameFree } from "$lib/names";
+import { isReserved } from "$lib/reserved";
import { emit } from "$lib/sockets";
import upload from "$lib/upload";
import { bail, fail, fields, getUser, pick } from "$lib/utils";
import whitelist from "$lib/whitelist";
-import rpc from "@coinos/rpc";
+
import { $ } from "bun";
+import got from "got";
import jwt from "jsonwebtoken";
import { getPublicKey, nip19, verifyEvent } from "nostr-tools";
+import { hexToBytes } from "@noble/hashes/utils";
import { authenticator } from "otplib";
import { v4 } from "uuid";
@@ -25,6 +29,71 @@ import type { ProfilePointer } from "nostr-tools/nip19";
const { host } = new URL(process.env.URL);
const relay = encodeURIComponent(config.publicRelay);
+const verifyRecaptcha = async (response, req?) => {
+ const { recaptcha: secret } = config;
+ if (!secret) return true;
+
+ // Skip captcha for Tor users accessing via .onion
+ const host = req?.headers?.["x-forwarded-host"] || req?.headers?.["host"] || "";
+ if (host.endsWith(".onion")) return true;
+
+ // Skip captcha for allowlisted User-Agents (prefix match)
+ const uaRaw = req?.headers?.["user-agent"] || req?.headers?.["User-Agent"];
+ const ua = Array.isArray(uaRaw) ? uaRaw[0] : uaRaw;
+ if (typeof ua === "string") {
+ const prefixes = await db.sMembers("nocaptcha_ua");
+ const uaLower = ua.toLowerCase();
+ if (prefixes.some((p) => uaLower.startsWith(p.toLowerCase()))) {
+ return true;
+ }
+ }
+
+ // Check for valid API key to bypass captcha
+ const apiKey = req?.headers?.["x-api-key"];
+ if (apiKey && (await db.sIsMember("apikeys", apiKey))) {
+ return true;
+ }
+
+ const { username } = req.body;
+ if (username &&
+ await db.sIsMember("nocaptcha", username.toLowerCase().replace(/\s/g, ""))
+ )
+ return true;
+
+ if (!response) return false;
+
+ try {
+ const ip = req?.headers?.["cf-connecting-ip"] || req?.socket?.remoteAddress;
+ const { success } = await got
+ .post("https://www.google.com/recaptcha/api/siteverify", {
+ form: {
+ secret,
+ response,
+ remoteip: ip,
+ },
+ // Bound the wait so a slow/unreachable Google can't hang the login.
+ timeout: { request: 6000 },
+ })
+ .json();
+ // A real verdict from Google: fail CLOSED only when the captcha itself is
+ // invalid (success === false).
+ return success || (!!config.adminpass && response === config.adminpass);
+ } catch (e) {
+ // Fail OPEN on an INFRASTRUCTURE error (network blip, timeout, Google 5xx).
+ // Reaching Google is not part of authentication; failing closed here turned
+ // a transient coinos<->Google connectivity hiccup into a login outage —
+ // users got "failed captcha" until it cleared (2026-08-25 incident). The
+ // password and the per-IP login rate limits (ipCount>30 / ipFailures>20 ->
+ // 429) still gate the request; captcha is only bot-mitigation, so allowing
+ // it through when the verifier is unreachable is the safer failure mode.
+ warn(
+ "recaptcha verify unreachable — allowing login (fail-open)",
+ (e as any)?.code ?? (e as any)?.message ?? String(e),
+ );
+ return true;
+ }
+};
+
export default {
upload,
@@ -32,21 +101,10 @@ export default {
const { user } = req;
try {
user.balance = await g(`balance:${user.id}`);
- user.locked = await ga(`balance:${user.id}`);
-
- if (user.locked) {
- const blacklisted = await db.sIsMember(
- "blacklist",
- user?.username?.toLowerCase().trim(),
- );
-
- const whitelisted = await db.sIsMember(
- "whitelist",
- user?.username?.toLowerCase().trim(),
- );
-
- if (!blacklisted || whitelisted) user.locked = 0;
- }
+ // The password-encrypted nostr key is only handed out by /login, after
+ // the password (and 2FA) check. A bearer token alone mustn't be enough
+ // to fetch it and brute-force or decrypt it offline.
+ user.hasnsec = !!user.nsec;
user.prompt = !!user.prompt;
if (user.pubkey) user.npub = nip19.npubEncode(user.pubkey);
@@ -58,42 +116,6 @@ export default {
}
},
- async list(req, res) {
- const { user } = req;
- if (!user.admin) fail("unauthorized");
-
- const users = [];
-
- for await (const k of db.scanIterator({ MATCH: "balance:*" })) {
- const uid = k.split(":")[1];
- const user = await getUser(uid);
-
- if (!user) {
- await db.del(`balance:${uid}`);
- continue;
- }
-
- user.balance = await g(k);
-
- const payments = await db.lRange(`${uid}:payments`, 0, -1);
-
- let total = 0;
- for (const pid of payments) {
- const p = await g(`payment:${pid}`);
- if (!p) continue;
- total += p.amount;
- if (p.amount < 0)
- total -= (p.fee || 0) + (p.ourfee || 0) + (p.tip || 0);
- else total += p.tip || 0;
- }
-
- user.expected = total;
- users.push(user);
- }
-
- res.send(users);
- },
-
async get(req, res) {
let {
params: { key },
@@ -115,8 +137,7 @@ export default {
const user = await getNostrUser(key);
res.send(pick(user, fields));
} catch (e) {
- err("problem getting user", key, e.message);
- res.code(500).send(e.message);
+ res.code(404).send("User not found");
}
},
@@ -127,6 +148,51 @@ export default {
if (!body.user) fail("no user object provided");
let { user } = body;
+ // Per-IP registration cap. Nothing else throttles /register (the
+ // recaptcha check only runs on login; the fastify limiters cover
+ // /login and /send), so one VPN exit created ~500 empty accounts at
+ // 3/min on 2026-09-08 without tripping anything. Five an hour is far
+ // above any household; an internal/private source IP (proxy without
+ // the Cloudflare header) is exempt so a misrouted request can't lock
+ // everyone out. Counted before validation so failed attempts count too.
+ if (ip && !/^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|127\.|::1$|fc|fd)/.test(ip)) {
+ const k = `register:ip:${ip}`;
+ const n = await db.incr(k);
+ if (n === 1) await db.expire(k, 3600);
+ if (n > 5) {
+ warn("registration cap hit", ip, n);
+ fail("Too many accounts created from this address, try again later");
+ }
+ }
+
+ // Forged browser User-Agent. Every engine that reports AppleWebKit also
+ // reports the "(KHTML, like Gecko)" token right behind it, so a UA that
+ // claims AppleWebKit without it was assembled by hand. Across 4.8M
+ // logged requests this matches 2,366: the c<epoch> registration bot
+ // (1,203 accounts from 596 rotating residential IPs — which is exactly
+ // why the per-IP cap above never touched it), PetalBot, Bytespider, and
+ // nothing else. Real browsers carry the token; non-browser clients that
+ // register legitimately (python-requests, curl, damus, our own Bun SSR)
+ // never claim AppleWebKit at all, so none of them match.
+ const ua = String(headers["user-agent"] || "");
+ if (ua.includes("AppleWebKit/") && !ua.includes("KHTML")) {
+ warn("registration blocked, forged user-agent", ip, ua.slice(0, 80));
+ fail("Registration unavailable");
+ }
+
+ // The signup form has always sent a recaptcha token and /register has
+ // always discarded it — it isn't even in the `fields` pick below — so
+ // the captcha only ever gated login. Verify it when one is supplied.
+ // Supplied-and-invalid is the only rejection: an ABSENT token still
+ // passes, because half of all registrations are API clients that never
+ // had one and requiring it would break them. verifyRecaptcha keeps its
+ // own bypasses (onion host, x-api-key, allowlisted UA) and fails open
+ // when Google is unreachable.
+ if (user.recaptcha && !(await verifyRecaptcha(user.recaptcha, req))) {
+ warn("registration blocked, failed captcha", ip, user.username);
+ fail("Failed captcha");
+ }
+
const fields = ["pubkey", "password", "username", "picture", "fresh"];
user = await register(pick(user, fields), ip);
@@ -135,7 +201,7 @@ export default {
l("registered new user", user.username);
- res.send({ ...pick(user, whitelist), sk: user.sk, token });
+ res.send({ ...pick(user, whitelist), nsec: user.nsec, sk: user.sk, token });
} catch (e) {
err("problem registering", e.message);
res.code(500).send(e.message);
@@ -152,9 +218,12 @@ export default {
return res.code(401).send("2fa required");
}
+ // Fresh seed on disable so a seed that leaked while 2FA was on is dead
+ // when it's re-enabled.
user.twofa = false;
+ user.otpsecret = authenticator.generateSecret();
await s(`user:${id}`, user);
- emit(username, "user", user);
+ emit(username, "user", pick(user, whitelist));
emit(username, "otpsecret", user.otpsecret);
l("disabled 2fa", username);
res.send({});
@@ -171,7 +240,7 @@ export default {
if (isValid) {
user.twofa = true;
await s(`user:${id}`, user);
- emit(username, "user", user);
+ emit(username, "user", pick(user, whitelist));
} else {
return res.code(500).send("Invalid token");
}
@@ -179,7 +248,6 @@ export default {
l("enabled 2fa", username);
res.send({});
} catch (e) {
- console.log(e);
bail(res, e.message);
}
},
@@ -199,32 +267,40 @@ export default {
const valid = /^[\p{L}\p{N}]{2,24}$/u;
if (!valid.test(username))
fail("Usernames can only have letters and numbers");
+ if (isReserved(username)) fail("Invalid username");
+ if (username?.includes("undefined")) fail("Invalid username");
let exists;
let { pubkey } = body;
if (pubkey) {
pubkey = pubkey.trim();
+ if (pubkey.startsWith("npub")) pubkey = nip19.decode(pubkey).data;
exists = await getUser(pubkey);
+ const un = user.username.toLowerCase().replace(/\s/g, "");
const existingUsername = exists?.username
?.toLowerCase()
.replace(/\s/g, "");
- if (exists && username !== existingUsername) {
- warn("key in use", pubkey, existingUsername);
+ if (exists && un !== existingUsername) {
+ warn("key in use", pubkey, username, existingUsername);
if (exists.anon) await db.del(`user:${pubkey}`);
else fail("Key in use by another account");
}
const event = JSON.parse(body.event);
const challenge = event.tags.find((t) => t[0] === "challenge")[1];
- const c = await g(`challenge:${challenge}`);
+ // Single-use: GETDEL claims the challenge atomically, so the identical
+ // signed event can't be replayed within the 5-minute TTL (it is also
+ // written to the request log, so a log reader could otherwise reuse it).
+ // Burn it on read rather than on success — a failed attempt costs the
+ // client one extra GET /challenge, and two racing replays cannot both win.
+ const c = await db.getDel(`challenge:${challenge}`);
if (!c) fail("Invalid or expired challenge");
if (!verifyEvent(event) || event.pubkey !== pubkey)
fail("Invalid signature or challenge mismatch.");
pubkey = pubkey.replace(/\s*/g, "");
- if (pubkey.startsWith("npub")) pubkey = nip19.decode(pubkey).data;
if (pubkey.length !== 64) fail(`Invalid pubkey ${pubkey}`);
await db.del(`user:${user.pubkey}`);
user.pubkey = pubkey;
@@ -237,18 +313,20 @@ export default {
if (user.pin === "delete") user.pin = undefined;
if (username) {
- exists = await db.exists(`user:${username}`);
const currentUsername = user.username.replace(/\s/g, "").toLowerCase();
+ if (username !== currentUsername) {
+ exists = await db.exists(`user:${username}`);
- if (currentUsername !== username && exists) {
- err("username taken", username, currentUsername);
- fail("Username taken");
- } else if (username) {
- if (currentUsername !== username)
+ if (exists) {
+ err("username taken", username, currentUsername);
+ fail("Username taken");
+ } else {
+ // A name the v3 registrar holds is taken too (see lib/names.ts).
+ await assertNameFree(username, user.pubkey);
l("changing username", currentUsername, username);
-
- await db.del(`user:${currentUsername}`);
- user.username = username;
+ await db.del(`user:${currentUsername}`);
+ user.username = username;
+ }
}
}
@@ -265,6 +343,7 @@ export default {
"language",
"locktime",
"memoPrompt",
+ "migrated",
"nip5",
"notify",
"nsec",
@@ -281,23 +360,28 @@ export default {
"twofa",
];
+ if (body.email) {
+ user.verified = false;
+ user.notify = false;
+ }
+
for (const a of attributes) {
if (typeof body[a] !== "undefined") user[a] = body[a];
}
user.fresh = false;
- user.tip = Math.max(0, Math.min(1000, parseInt(user.tip)));
+ user.tip = Math.max(0, Math.min(1000, Number.parseInt(user.tip)));
if (password && password === confirm) {
user.password = await Bun.password.hash(password, {
algorithm: "bcrypt",
- cost: 4,
+ cost: 12,
});
}
user.haspin = !!user.pin;
if (user.destination) user.destination = user.destination.trim();
- await s(`user:${user.pubkey}`, user.id);
+ if (user.pubkey) await s(`user:${user.pubkey}`, user.id);
await s(
`user:${user.username.toLowerCase().replace(/\s/g, "")}`,
user.id,
@@ -306,8 +390,8 @@ export default {
await s(`user:${user.id}`, user);
if (user.nip5) await db.sAdd("nip5", `${user.username}:${user.pubkey}`);
- emit(user.id, "user", user);
- res.send({ user });
+ emit(user.id, "user", pick(user, whitelist));
+ res.send({ user: pick(user, whitelist) });
} catch (e) {
warn("failed to update", user.username, e.message);
bail(res, e.message);
@@ -316,22 +400,47 @@ export default {
async login(req, res) {
try {
- let { username, password, token: twofa } = req.body;
+ let { username, password, token: twofa, recaptcha } = req.body;
+ const ip = req.headers["cf-connecting-ip"] || req.socket.remoteAddress;
+
+ const ipKey = `ip:${ip}:login`;
+ const ipCount = await db.incr(ipKey);
+ if (ipCount === 1) await db.expire(ipKey, 10);
+ if (ipCount > 30) return res.code(429).send({});
+
+ // Fail closed: an unset adminpass must never authenticate, and an omitted
+ // password field must never coincide with an unset value (undefined ===
+ // undefined). Both the configured value and the supplied one must be
+ // non-empty and match exactly.
+ const isAdmin =
+ !!config?.adminpass && !!password && password === config.adminpass;
+
+ if (!isAdmin) {
+ const recaptchaOk = await verifyRecaptcha(recaptcha, req);
+ if (!recaptchaOk) {
+ return res.code(401).send("failed captcha");
+ }
+ }
username = username.toLowerCase().replace(/\s/g, "");
+ username = username.split("@")[0];
+
+ const ipFailKey = `ip:${ip}:login:fail`;
+ const ipFailures = await g(ipFailKey);
+ if (!isAdmin && ipFailures > 20) return res.code(429).send({});
+
let user = await getUser(username);
- if (password !== config?.adminpass) {
- if (
- !user ||
- !user.password ||
- !(await Bun.password.verify(password, user.password))
- ) {
- // warn("invalid username or password attempt", username);
- await appendFile(
- "failedlogins.txt",
- `${Date.now()} : ${username} : ${password}\n`,
- );
+ if (!isAdmin) {
+ let verified = false;
+ try {
+ if (user?.password)
+ verified = await Bun.password.verify(password, user.password);
+ } catch (e) {}
+
+ if (!user || !verified) {
+ await db.incrBy(ipFailKey, 1);
+ if ((await db.ttl(ipFailKey)) < 0) await db.expire(ipFailKey, 600);
return res.code(401).send({});
}
@@ -340,17 +449,45 @@ export default {
(typeof twofa === "undefined" ||
!authenticator.check(twofa, user.otpsecret))
) {
+ await db.incrBy(ipFailKey, 1);
+ if ((await db.ttl(ipFailKey)) < 0) await db.expire(ipFailKey, 600);
return res.code(401).send("2fa required");
}
+
+ // Transparent bcrypt upgrade: legacy accounts were hashed at cost 4
+ // (~1,300 guesses/s). Now that we hold the verified plaintext, re-hash
+ // at the current cost so the stored hash strengthens on next login.
+ const cost = Number.parseInt(
+ user.password.match(/^\$2[aby]\$(\d{2})\$/)?.[1] ?? "0",
+ 10,
+ );
+ if (cost < 12) {
+ user.password = await Bun.password.hash(password, {
+ algorithm: "bcrypt",
+ cost: 12,
+ });
+ await s(`user:${user.id}`, user);
+ }
}
+ // Evicted accounts can't use a token anyway (see lib/auth.ts), so don't
+ // mint one — checked after the password so it can't be used to probe.
+ if (!isAdmin && (await db.sIsMember("evicted", user.id)))
+ return res.code(403).send("account disabled");
+
if (username !== "coinos")
l("logged in", username, req.headers["cf-connecting-ip"]);
const payload = { id: user.id };
const token = jwt.sign(payload, config.jwt);
- res.cookie("token", token, { expires: new Date(Date.now() + 432000000) });
- user = pick(user, whitelist);
+ res.cookie("token", token, {
+ expires: new Date(Date.now() + 432000000),
+ path: "/",
+ httpOnly: true,
+ secure: true,
+ sameSite: "lax",
+ });
+ user = { ...pick(user, whitelist), hasnsec: !!user.nsec, nsec: user.nsec };
res.send({ user, token });
} catch (e) {
err("login error", e.message, req.socket.remoteAddress);
@@ -366,9 +503,15 @@ export default {
async nostrAuth(req, res) {
try {
- const { event, challenge, twofa } = req.body;
+ const { event, challenge, twofa, recaptcha } = req.body;
const ip = req.headers["cf-connecting-ip"];
- const c = await g(`challenge:${challenge}`);
+ const recaptchaOk = await verifyRecaptcha(recaptcha, req);
+ if (!recaptchaOk) {
+ return res.code(401).send("failed captcha");
+ }
+ // Single-use — see the GETDEL note on the other challenge consumer. The
+ // captcha is checked above, so a captcha failure doesn't burn a challenge.
+ const c = await db.getDel(`challenge:${challenge}`);
const { pubkey: key, kind } = event;
if (kind !== 27235) fail("Invalid event");
if (!c) fail("Invalid or expired login challenge");
@@ -401,25 +544,21 @@ export default {
}
const { username } = user;
- if (
- await db.sIsMember(
- "compromised",
- username.replace(/\s/g, "").toLowerCase(),
- )
- ) {
- warn("compromised nostr auth attempt");
- fail("unauthorized");
- }
-
l("nostr login", username, ip);
const payload = { id: user.id };
const token = jwt.sign(payload, config.jwt);
- res.cookie("token", token, { expires: new Date(Date.now() + 432000000) });
- user = pick(user, whitelist);
+ res.cookie("token", token, {
+ expires: new Date(Date.now() + 432000000),
+ path: "/",
+ httpOnly: true,
+ secure: true,
+ sameSite: "lax",
+ });
+ user = { ...pick(user, whitelist), hasnsec: !!user.nsec };
res.send({ user, token });
} catch (e) {
- err("login error", e.message, req.socket.remoteAddress);
+ err("nostr login error", e.message, req.socket.remoteAddress);
res.code(401).send({});
}
},
@@ -441,7 +580,6 @@ export default {
await db.sAdd(`${id}:subscriptions`, JSON.stringify(subscription));
res.send(subscription);
} catch (e) {
- console.log(e);
bail(res, e.message);
}
},
@@ -453,7 +591,6 @@ export default {
await db.sRem(`${id}:subscriptions`, JSON.stringify(subscription));
res.send(subscription);
} catch (e) {
- console.log(e);
bail(res, e.message);
}
},
@@ -473,6 +610,39 @@ export default {
}
},
+ // The encrypted nostr key, for clients that need to reveal or sign with it
+ // after a session that didn't start with a password login. Re-checking the
+ // password keeps a bearer token alone from fetching it (see /me).
+ async nsec(req, res) {
+ const {
+ body: { password },
+ user,
+ } = req;
+ const ip = req.headers["cf-connecting-ip"] || req.socket.remoteAddress;
+ const ipFailKey = `ip:${ip}:login:fail`;
+
+ try {
+ if (!user.nsec) fail("nsec not available");
+ if ((await g(ipFailKey)) > 20) return res.code(429).send({});
+
+ let verified = false;
+ try {
+ if (user.password && password)
+ verified = await Bun.password.verify(password, user.password);
+ } catch (e) {}
+
+ if (!verified) {
+ await db.incrBy(ipFailKey, 1);
+ if ((await db.ttl(ipFailKey)) < 0) await db.expire(ipFailKey, 600);
+ return res.code(401).send("invalid password");
+ }
+
+ res.send({ nsec: user.nsec });
+ } catch (e) {
+ bail(res, e.message);
+ }
+ },
+
async pin(req, res) {
const {
body: { pin },
@@ -484,6 +654,10 @@ export default {
async otpsecret(req, res) {
try {
await requirePin(req);
+ // Only needed to enrol. Once 2FA is on, the seed never leaves the
+ // server again — otherwise a stolen token on a PIN-less account is
+ // enough to mint valid codes.
+ if (req.user.twofa) fail("2FA is already enabled");
const { otpsecret, username } = req.user;
res.send({ secret: otpsecret, username });
} catch (e) {
@@ -506,7 +680,7 @@ export default {
for (const { ref } of (
await Promise.all(
- payments.reverse().map(async (id) => await g(`payment:${id}`)),
+ payments.reverse().map(async (id) => await gf(`payment:${id}`)),
)
).filter((p) => p && p.type === PaymentType.internal && p.ref)) {
if (ref === id) continue;
@@ -550,6 +724,7 @@ export default {
params: { username },
headers: { authorization },
} = req;
+ fail("Unauthorized");
username = username.toLowerCase();
if (!authorization?.includes(config.admin))
return res.code(401).send("unauthorized");
@@ -569,47 +744,167 @@ export default {
res.send({});
},
+ // Authenticated self-delete: wipes every key tied to the calling user,
+ // mirroring scripts/delall-legacy.ts. Gated by typing the username (works for
+ // password and nostr accounts alike) and refuses while a balance remains so a
+ // user can't accidentally destroy funds.
+ async deleteSelf(req, res) {
+ try {
+ const { user } = req;
+ const { id, username, pubkey } = user;
+
+ const confirm = (req.body?.confirm || "").toString().trim().toLowerCase();
+ if (confirm !== username.toLowerCase())
+ fail("Type your username to confirm account deletion");
+
+ // Allow deletion through dust (often too small to withdraw); only block
+ // a meaningful balance so nobody destroys real funds by accident. Sum
+ // EVERY account (main + sub-accounts) — the deletion loop below wipes each
+ // sub-account's balance:/pending: keys unconditionally, so a guard that
+ // only checked the main account let a user with funds parked in a
+ // sub-account pass and silently destroy them.
+ const aids = await db.lRange(`${id}:accounts`, 0, -1);
+ if (!aids.includes(id)) aids.push(id);
+ let total = 0;
+ for (const aid of aids) {
+ total += Number(await db.get(`balance:${aid}`)) || 0;
+ total += Number(await db.get(`pending:${aid}`)) || 0;
+ }
+ if (total > 10000)
+ fail("Withdraw your balance before deleting your account");
+
+ const keys = [
+ `user:${id}`,
+ // username->uid mapping is stored lowercased+despaced (see register.ts);
+ // include both forms so a mixed-case username can't leave a dangling alias.
+ `user:${username.toLowerCase().replace(/\s/g, "")}`,
+ `user:${username}`,
+ `${id}:payments`,
+ `${id}:payments:last`,
+ `${id}:apps`,
+ `${id}:lastlen`,
+ `${id}:accounts`,
+ `${id}:contacts`,
+ `${id}:invoices`,
+ `${id}:items`,
+ `${id}:pins`,
+ `${id}:subscriptions`,
+ `${id}:trust`,
+ `${id}:square`,
+ `${id}:codeVerifier`,
+ `account:${id}`,
+ `balance:${id}`,
+ `pending:${id}`,
+ `credit:bitcoin:${id}`,
+ `credit:lightning:${id}`,
+ `credit:liquid:${id}`,
+ ];
+
+ if (pubkey) {
+ keys.push(
+ `user:${pubkey}`,
+ `${pubkey}:follows`,
+ `${pubkey}:follows:n`,
+ `${pubkey}:followers`,
+ `${pubkey}:followers:n`,
+ `${pubkey}:pubkeys`,
+ );
+ if (user.nip5) await db.sRem("nip5", `${username}:${pubkey}`);
+ }
+
+ // Connected NWC apps: drop each app record + its payment index.
+ // `${id}:apps` is a SET (sAdd in register.ts), so use sMembers, not lRange.
+ for (const ap of await db.sMembers(`${id}:apps`))
+ keys.push(`app:${ap}`, `${ap}:payments`);
+
+ // Sub-accounts and their payment records.
+ for (const aid of await db.lRange(`${id}:accounts`, 0, -1)) {
+ if (aid === id) continue;
+ for (const pid of await db.lRange(`${aid}:payments`, 0, -1))
+ keys.push(`payment:${pid}`);
+ keys.push(
+ `account:${aid}`,
+ `${aid}:payments`,
+ `${aid}:payments:last`,
+ `${aid}:invoices`,
+ `balance:${aid}`,
+ `pending:${aid}`,
+ );
+ }
+
+ // Main-account payment + invoice records.
+ for (const pid of await db.lRange(`${id}:payments`, 0, -1))
+ keys.push(`payment:${pid}`);
+ for (const iid of await db.lRange(`${id}:invoices`, 0, -1))
+ keys.push(`invoice:${iid}`);
+
+ let deleted = 0;
+ for (const k of keys) {
+ if (await db.exists(k)) {
+ await db.del(k);
+ deleted++;
+ }
+ }
+
+ l("user self-deleted", username, id, `${deleted} keys`);
+ try {
+ res.clearCookie("token");
+ } catch {}
+ res.send({ deleted: true });
+ } catch (e) {
+ bail(res, e.message);
+ }
+ },
+
async reset(req, res) {
const {
body: { code, username, password },
user: u,
} = req;
- const admin = u?.admin;
- let id;
- let user;
+ try {
+ let id;
+ let user;
- if (admin) {
+ // Non-admins must not be able to tell this admin-only endpoint exists.
+ // Log the probe (so exploit-watch can flag it) but return a generic 404
+ // identical to an unregistered route — previously it returned "disabled",
+ // which confirmed the endpoint AND its admin-gating to attackers.
+ if (u?.username !== config.admin) {
+ err("password reset failed disabled", req.headers["cf-connecting-ip"]);
+ return res
+ .code(404)
+ .send({ message: "Route POST:/reset not found", error: "Not Found", statusCode: 404 });
+ }
id = await g(`user:${username.toLowerCase().replace(/\s/g, "")}`);
user = await g(`user:${id}`);
- } else {
- id = await g(`reset:${code}`);
- user = await g(`user:${id}`);
- }
- if (!user) fail("user not found");
+ if (!user) fail("user not found");
- warn(
- "password reset",
- user.username,
- code,
- req.headers["cf-connecting-ip"],
- );
+ warn(
+ "password reset",
+ user.username,
+ code,
+ req.headers["cf-connecting-ip"],
+ );
- try {
user.pin = null;
user.nsec = null;
user.password = await Bun.password.hash(password, {
algorithm: "bcrypt",
- cost: 4,
+ cost: 12,
});
await s(`user:${id}`, user);
await db.del(`reset:${code}`);
res.send(pick(user, whitelist));
+
+ for await (const k of db.scanIterator({ MATCH: "ip:*:login:fail" })) {
+ await db.del(k);
+ }
} catch (e) {
- err("password reset failed", e.message);
+ err("password reset failed", e.message, req.headers["cf-connecting-ip"]);
bail(res, e.message);
}
},
@@ -685,7 +980,7 @@ export default {
await s(`user:${id}`, user);
await s(`email:${email.toLowerCase()}`, id);
- res.send(pick(user, whitelist));
+ res.send(pick(user, fields));
} catch (e) {
bail(res, e.message);
}
@@ -716,28 +1011,13 @@ export default {
}
},
- async hidepay(req, res) {
- const {
- body: { username },
- } = req;
- const u = await getUser(username);
- u.hidepay = true;
- await s(`user:${u.id}`, u);
- res.send({});
- },
-
- async unlimit(req, res) {
- const {
- body: { username },
- } = req;
- const u = await getUser(username);
- u.unlimited = true;
- await s(`user:${u.id}`, u);
- res.send({});
- },
-
async account(req, res) {
const { id } = req.params;
+ const { id: uid } = req.user;
+
+ const pos = await db.lPos(`${uid}:accounts`, id);
+ if (pos == null) fail("account not found");
+
const account = await g(`account:${id}`);
if (account) account.balance = await g(`balance:${id}`);
res.send(account);
@@ -758,45 +1038,17 @@ export default {
res.send(accounts.reverse());
} catch (e) {
- console.log(e);
bail(res, e.message);
}
},
async createAccount(req, res) {
try {
- const { fingerprint, pubkey, name, seed, type } = req.body;
+ const { name, type } = req.body;
const { user } = req;
- const { id: uid } = user;
const id = v4();
- const account = { id, name, seed, type, uid, descriptors: [] };
-
- let node = rpc(config[type]);
-
- await node.createWallet({
- wallet_name: id,
- descriptors: true,
- disable_private_keys: true,
- load_on_startup: true,
- });
-
- node = rpc({ ...config[type], wallet: id });
-
- for (const i of [0, 1]) {
- const desc = `wpkh([${fingerprint}]${pubkey}/${i}/*)`;
- const { checksum } = await node.getDescriptorInfo(desc);
- account.descriptors.push({
- desc: `${desc}#${checksum}`,
- range: [0, 100],
- next_index: 0,
- timestamp: "now",
- internal: i === 1,
- active: true,
- });
- }
-
- await node.importDescriptors(account.descriptors);
+ const account = { id, name, type, uid: user.id };
await db
.multi()
@@ -806,10 +1058,6 @@ export default {
.lPush(`${user.id}:accounts`, id)
.exec();
- reconcile(account, true);
-
- await appendFile("/bitcoin.conf", `wallet=${id}\n`);
-
res.send(account);
} catch (e) {
bail(res, e.message);
@@ -835,17 +1083,10 @@ export default {
try {
const { id } = req.body;
const { id: uid } = req.user;
- const { type } = await g(`account:${id}`);
+ const account = await g(`account:${id}`);
const pos = await db.lPos(`${uid}:accounts`, id);
- if (!(type && pos != null)) fail("account not found");
-
- try {
- const node = rpc({ ...config[type], wallet: id });
- await node.unloadWallet(id);
- } catch (e) {
- warn("failed to unload wallet", id);
- }
+ if (!(account && pos != null)) fail("account not found");
await db
.multi()
@@ -857,32 +1098,31 @@ export default {
res.send({ ok: true });
} catch (e) {
- console.log(e);
bail(res, e.message);
}
},
- async printer(req, res) {
- const { ssid, key, username, password } = req.body;
- const dir = "./printer";
- const path = `${dir}/config.txt`;
-
+ async flash(req, res) {
+ // Build in a per-request scratch dir. A shared ./printer/config.txt and
+ // ./littlefs.img let two overlapping requests hand one user the other's
+ // wifi key and API token.
+ const dir = await mkdtemp(join(tmpdir(), "flash-"));
try {
- await unlink(path);
- await appendFile(path, `${ssid}\n`, "utf8");
- await appendFile(path, `${key}\n`, "utf8");
- await appendFile(path, `${username}\n`, "utf8");
- await appendFile(path, `${password}\n`, "utf8");
-
- const output = "./littlefs.img";
- await $`./mklittlefs -c ${dir} -p 256 -b 4096 -s 0x20000 ${output}`;
-
+ const { ssid, key, token } = req.body;
+ if (!ssid || !key || !token) fail("ssid, key and token required");
+ const cfg = `${ssid.trim()}\n${key.trim()}\n${token.trim()}\n`;
+ const src = join(dir, "fs");
+ const img = join(dir, "littlefs.img");
+ await mkdir(src);
+ await writeFile(join(src, "config.txt"), cfg, "utf8");
+ await $`./mklittlefs -c ${src} -p 256 -b 4096 -s 0x20000 ${img}`;
res.header("Content-Type", "application/octet-stream");
- res.header("Content-Disposition", "attachment; filename=littlefs.img");
- console.log("sending back");
- return res.send(createReadStream(output));
- } catch (error) {
- res.code(500).send({ error: "Failed to generate LittleFS image" });
+ // Read it fully before the finally below removes the directory.
+ return res.send(await readFile(img));
+ } catch (e) {
+ bail(res, e.message);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
}
},
@@ -890,6 +1130,10 @@ export default {
const { pubkey } = req.params;
const { user } = req;
const app = await g(`app:${pubkey}`);
+ // Missing records must 404, not crash into a 500: clients (Damus one-click
+ // setup) probe this endpoint and treat 404 as "not configured yet, create
+ // one" — anything else aborts their whole connection flow.
+ if (!app) return res.code(404).send({ error: "connection not found" });
if (app.uid !== user.id) fail("unauthorized");
const lud16 = `${user.username}@${host}`;
@@ -898,13 +1142,13 @@ export default {
const payments = await Promise.all(
pids.map(async (pid) => {
- const p = await g(`payment:${pid}`);
- p.user = await g(`user:${p.uid}`);
+ const p = await gf(`payment:${pid}`);
+ if (p) p.user = await g(`user:${p.uid}`);
return p;
}),
);
- app.nwc = `nostr+walletconnect://${serverPubkey}?relay=${relay}&secret=${app.secret}&lud16=${lud16}`;
+ app.nwc = `nostr+walletconnect://${serverPubkey2}?relay=${relay}&secret=${app.secret}&lud16=${lud16}`;
app.payments = payments.filter((p) => p);
res.send(app);
@@ -913,26 +1157,31 @@ export default {
async apps(req, res) {
const { user } = req;
const pubkeys = await db.sMembers(`${user.id}:apps`);
- const apps = await Promise.all(pubkeys.map((p) => g(`app:${p}`)));
+ // Drop set members whose app record was deleted (e.g. an invalidation
+ // sweep) so one stale pubkey can't 500 the whole listing.
+ const apps = (await Promise.all(pubkeys.map((p) => g(`app:${p}`)))).filter(
+ Boolean,
+ );
const lud16 = `${user.username}@${host}`;
await Promise.all(
apps.map(async (a) => {
if (a.secret)
- a.nwc = `nostr+walletconnect://${serverPubkey}?relay=${relay}&secret=${a.secret}&lud16=${lud16}`;
+ a.nwc = `nostr+walletconnect://${serverPubkey2}?relay=${relay}&secret=${a.secret}&lud16=${lud16}`;
const pids = await db.lRange(`${a.pubkey}:payments`, 0, -1);
let payments = await Promise.all(
- pids.map((pid) => g(`payment:${pid}`)),
+ pids.map((pid) => gf(`payment:${pid}`)),
);
payments = payments.filter((p) => p);
a.spent = payments.reduce(
(a, b) =>
a +
- (Math.abs(parseInt(b.amount || 0)) +
- parseInt(b.fee || 0) +
- parseInt(b.ourfee || 0)),
+ (Math.abs(Number.parseInt(b.amount || 0)) +
+ Number.parseInt(b.tip || 0) +
+ Number.parseInt(b.fee || 0) +
+ Number.parseInt(b.ourfee || 0)),
0,
);
}),
@@ -955,10 +1204,59 @@ export default {
const { user } = req;
const uid = user.id;
- let app = await g(pubkey);
+ if (secret) pubkey = getPublicKey(hexToBytes(secret));
+ // App records live under `app:<pubkey>`. Looking up the bare pubkey made
+ // this ownership check ineffective for normal NWC keys and allowed an
+ // authenticated user to overwrite another connection's configuration.
+ let app = await g(`app:${pubkey}`);
if (app && uid !== app.uid) fail("Unauthorized");
- if (secret) pubkey = getPublicKey(secret);
+
+ // Rotation is mandatory after a credential disclosure: the NWC pubkey is
+ // derived from the bearer secret, so re-registering a retired or
+ // quarantined pubkey would re-authorize the exact secret that was
+ // disclosed (we store client-supplied secrets, so they're part of any DB
+ // leak). Reject it and make the client mint a fresh secret — a new secret
+ // yields a new pubkey and never trips this check.
+ const credentialCutoff = Number(await g("nwc:credential-cutoff")) || 0;
+ const retired =
+ (app &&
+ credentialCutoff > 0 &&
+ (!Number(app.created) || Number(app.created) < credentialCutoff)) ||
+ (await db.sIsMember("nwc:quarantined", pubkey));
+ if (retired)
+ return res.code(403).send({
+ error:
+ "This connection's key was retired for security reasons; connect again with a newly generated secret",
+ });
+
+ const validRenewals = new Set([
+ "daily",
+ "weekly",
+ "monthly",
+ "yearly",
+ "never",
+ ]);
+ if (budget_renewal && !validRenewals.has(budget_renewal))
+ fail("Invalid budget renewal period");
+
+ for (const [label, value] of [
+ ["spending budget", max_amount],
+ ["maximum fee", max_fee],
+ ]) {
+ if (
+ value !== undefined &&
+ value !== null &&
+ value !== "" &&
+ (!Number.isFinite(Number(value)) || Number(value) < 0)
+ )
+ fail(`Invalid ${label}`);
+ }
+
+ // Notifications are opt-in (default off): most users don't consume them and
+ // publishing payment_sent/payment_received for every app would flood the
+ // relay. A client enables them by sending notify:"true" when creating the
+ // connection. (Reverted a brief default-on change, 2026-06-06.)
notify = String(notify) === "true";
app = {
@@ -973,14 +1271,16 @@ export default {
secret,
};
- if (!app?.created) app.created = Date.now();
+ // Refresh the creation epoch on every owner post: this record passed the
+ // retirement check above, so the owner updating it (or minting it fresh)
+ // asserts it's live and keeps it clear of the credential cutoff.
+ app.created = Date.now();
await s(`app:${pubkey}`, app);
await db.sAdd(`${uid}:apps`, pubkey);
res.send({});
} catch (e) {
- console.log(e);
bail(res, e.message);
}
},
@@ -990,13 +1290,15 @@ export default {
const { user } = req;
const uid = user.id;
const { pubkey } = req.body;
- const app = await g(pubkey);
- if (app && uid !== app.uid) fail("Unauthorized");
+ const app = await g(`app:${pubkey}`);
+ if (app && uid !== app.uid) {
+ warn(app.uid, uid);
+ fail("Unauthorized");
+ }
await db.sRem(`${uid}:apps`, pubkey);
await db.del(`app:${pubkey}`);
res.send({});
} catch (e) {
- console.log(e);
bail(res, e.message);
}
},
@@ -1041,4 +1343,11 @@ export default {
const liquid = await g(`credit:liquid:${id}`);
res.send({ bitcoin, lightning, liquid });
},
+
+ async ro(req, res) {
+ const { user } = req;
+ const payload = { id: `${user.id}-ro` };
+ const token = jwt.sign(payload, config.jwt);
+ res.send(token);
+ },
};
diff --git a/scripts/analytics-geo.ts b/scripts/analytics-geo.ts
new file mode 100644
index 00000000..ef088b6b
--- /dev/null
+++ b/scripts/analytics-geo.ts
@@ -0,0 +1,134 @@
+#!/usr/bin/env bun
+// Geographic report from the request log. Ties Cloudflare's cf-ipcountry header
+// to the authenticated `user` field so we count DISTINCT USERS per country
+// (not raw request volume, which is dominated by datacenter/bot traffic).
+//
+// Usage: bun scripts/analytics-geo.ts [--days N] [--top N]
+// Streams the request log (pino JSONL, multi-GB) — never loads it whole.
+
+import { createInterface } from "readline";
+import { createReadStream, readdirSync } from "fs";
+import { createGunzip } from "zlib";
+
+const argDays = Number(process.argv[process.argv.indexOf("--days") + 1]) || 30;
+const TOP = Number(process.argv[process.argv.indexOf("--top") + 1]) || 25;
+const cutoff = Date.now() - argDays * 24 * 60 * 60 * 1000;
+
+// Datacenter IPs that flood /login with many usernames (credential-testing
+// bots). Excluded from geo counts. 195.123.220.232 = M247 NL, ~3,971 logins
+// for sequential usernames (bugtraq, bugtraqq, ...) in the last 30d.
+const BOT_IPS = new Set<string>(["195.123.220.232"]);
+
+
+// The live log rotates into 50M dated chunks under reqlogs/ (named for when
+// they were rotated out, gzipped except the newest), so stream every chunk
+// rotated within the window, oldest first, then the live file.
+const REQ = "/home/adam/coinos-server/req";
+const REQLOGS = "/home/adam/coinos-server/reqlogs";
+const chunkTime = (f: string) => {
+ const m = f.match(/^req-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})?/);
+ return m ? new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +(m[6] || 0)).getTime() : 0;
+};
+async function* reqLines(since: number) {
+ const files = readdirSync(REQLOGS)
+ .filter((f) => chunkTime(f) >= since)
+ .sort((a, b) => chunkTime(a) - chunkTime(b) || a.localeCompare(b))
+ .map((f) => `${REQLOGS}/${f}`);
+ for (const path of [...files, REQ]) {
+ const raw = createReadStream(path);
+ const input = path.endsWith(".gz") ? raw.pipe(createGunzip()) : raw;
+ yield* createInterface({ input, crlfDelay: Infinity });
+ }
+}
+
+const rl = reqLines(cutoff);
+
+// We only trust /login and /register events for geography: they are real human
+// auth actions tied to a username in the body, from the user's actual device.
+// Counting ALL authenticated requests pollutes the result massively — e.g.
+// nostr/NWC server traffic from a single M247 datacenter IP (195.123.220.232)
+// carries a stale `user` field and made NL look like 93% of users. Per-IP-per-
+// country dedup on auth events is the honest signal.
+
+// country -> Set<username> (distinct users who logged in / registered there)
+const usersByCountry = new Map<string, Set<string>>();
+// country -> distinct IPs (sanity: real geography has many IPs per country)
+const ipsByCountry = new Map<string, Set<string>>();
+let lines = 0;
+let authEvents = 0;
+
+const extractUsername = (r: any): string | null => {
+ // /login and /register carry username in the body (top-level `user` is empty
+ // on these). Body shape varies: {username} or {user:{username}}.
+ const b = r.body;
+ if (!b) return null;
+ if (typeof b.username === "string") return b.username.toLowerCase();
+ if (b.user && typeof b.user.username === "string") return b.user.username.toLowerCase();
+ return null;
+};
+
+for await (const line of rl) {
+ lines++;
+ // cheap pre-filter: must be an auth event with a country tag
+ if (!line.includes("cf-ipcountry")) continue;
+ if (!line.includes('"/login"') && !line.includes('"/register"')) continue;
+ let r: any;
+ try {
+ r = JSON.parse(line);
+ } catch {
+ continue;
+ }
+ if (!r.time || r.time < cutoff) continue;
+ if (r.url !== "/login" && r.url !== "/register") continue;
+ const cc = r.headers?.["cf-ipcountry"];
+ if (!cc || cc === "XX" || cc.length !== 2) continue;
+ const ip = r.headers?.["cf-connecting-ip"];
+
+ const user = extractUsername(r);
+ if (!user) continue;
+
+ // Drop known bot/proxy egress IPs that fire thousands of logins for many
+ // usernames from a single datacenter address — these are credential-testing
+ // bots, not real users, and otherwise dominate the country ranking. Heuristic
+ // backstop below (users >> ips) catches the rest; this is the worst offender.
+ if (ip && BOT_IPS.has(ip)) continue;
+ authEvents++;
+
+ let s = usersByCountry.get(cc);
+ if (!s) usersByCountry.set(cc, (s = new Set()));
+ s.add(user);
+
+ if (ip) {
+ let is = ipsByCountry.get(cc);
+ if (!is) ipsByCountry.set(cc, (is = new Set()));
+ is.add(ip);
+ }
+}
+const withCountry = authEvents;
+
+const byUsers = [...usersByCountry.entries()]
+ .map(([cc, set]) => [cc, set.size, ipsByCountry.get(cc)?.size || 0] as [string, number, number])
+ .sort((a, b) => b[1] - a[1]);
+
+const totalUsers = byUsers.reduce((s, [, n]) => s + n, 0);
+const max = byUsers[0]?.[1] || 1;
+const bar = (n: number) =>
+ "█".repeat(Math.round((n / max) * 24)) + "·".repeat(24 - Math.round((n / max) * 24));
+
+console.log("═".repeat(64));
+console.log(` COINOS GEO — last ${argDays} days (login/register events only)`);
+console.log(` scanned ${lines.toLocaleString()} log lines, ${withCountry.toLocaleString()} auth events`);
+console.log("═".repeat(64));
+console.log("\n▏TOP COUNTRIES (distinct users who logged in / registered)");
+console.log(" cc distribution users ips share");
+byUsers.slice(0, TOP).forEach(([cc, n, ips], i) => {
+ const pct = ((n / totalUsers) * 100).toFixed(1);
+ console.log(
+ ` ${String(i + 1).padStart(2)}. ${cc} ${bar(n)} ${String(n).padStart(5)} ${String(ips).padStart(4)} ${pct.padStart(5)}%`,
+ );
+});
+console.log(
+ `\n (ips column = distinct source IPs; a country with users >> ips is likely a proxy/VPN egress, not real geography)`,
+);
+console.log("\n" + "═".repeat(64));
+process.exit(0);
diff --git a/scripts/analytics.ts b/scripts/analytics.ts
new file mode 100644
index 00000000..7cefa3d4
--- /dev/null
+++ b/scripts/analytics.ts
@@ -0,0 +1,309 @@
+#!/usr/bin/env bun
+// Coinos analytics report. Streams the live `db` (SCAN, never KEYS) and
+// aggregates payment + balance + user data into an insight report.
+//
+// Usage:
+// bun scripts/analytics.ts [--days N] [--top N] [--json] [--csv PATH]
+//
+// Defaults: --days 30, --top 20. Reads only the live db (redis://127.0.0.1:6379);
+// archived payments (older, in `arc`) are out of scope for the trailing window.
+// --csv appends one metrics row to PATH (writing a header if the file is new),
+// for month-over-month trend tracking; the human report still prints to stdout.
+
+import { createClient } from "redis";
+import { appendFileSync, existsSync } from "fs";
+
+const argDays = Number(process.argv[process.argv.indexOf("--days") + 1]) || 30;
+const TOP = Number(process.argv[process.argv.indexOf("--top") + 1]) || 20;
+const asJson = process.argv.includes("--json");
+const csvIdx = process.argv.indexOf("--csv");
+const csvPath = csvIdx >= 0 ? process.argv[csvIdx + 1] : null;
+
+const now = Date.now();
+const cutoff = now - argDays * 24 * 60 * 60 * 1000;
+
+const db = createClient({
+ url: "redis://127.0.0.1:6379",
+ socket: { reconnectStrategy: () => false },
+});
+await db.connect();
+
+const fmtSats = (n: number) =>
+ n.toLocaleString("en-US") + " sats";
+const fmtBtc = (n: number) => (n / 1e8).toFixed(3) + " BTC";
+
+// ---- helpers ---------------------------------------------------------------
+
+async function* scanGetJSON(match: string, count = 1000) {
+ // Stream keys matching `match`, fetch values in pipelined batches, yield
+ // parsed JSON objects (skips non-JSON / mapping-string keys).
+ let batch: string[] = [];
+ async function* drain() {
+ if (!batch.length) return;
+ const vals = await db.mGet(batch);
+ for (let i = 0; i < vals.length; i++) {
+ const v = vals[i];
+ if (!v || v[0] !== "{") continue; // skip mapping strings / nulls
+ try {
+ yield [batch[i], JSON.parse(v)] as [string, any];
+ } catch {}
+ }
+ batch = [];
+ }
+ for await (const key of db.scanIterator({ MATCH: match, COUNT: count })) {
+ batch.push(key as string);
+ if (batch.length >= 500) yield* drain();
+ }
+ yield* drain();
+}
+
+// ---- pass 1: usernames (uid -> username) -----------------------------------
+// user:<uid> is the full record; user:<username> is a string pointer we skip.
+
+const uname = new Map<string, string>();
+let userRecords = 0;
+let newUsers = 0; // created in window (if `created` present on user)
+for await (const [key, u] of scanGetJSON("user:*")) {
+ if (!u?.id || !u?.username) continue;
+ uname.set(u.id, u.username);
+ userRecords++;
+ if (u.created && u.created >= cutoff) newUsers++;
+}
+
+const nameOf = (uid: string) => uname.get(uid) || uid.slice(0, 8);
+
+// ---- pass 2: balances (holders) --------------------------------------------
+
+type Holder = { uid: string; bal: number };
+const holders: Holder[] = [];
+let totalCustodied = 0;
+for await (const key of db.scanIterator({ MATCH: "balance:*", COUNT: 1000 })) {
+ const uid = (key as string).slice("balance:".length);
+ const v = await db.get(key as string);
+ const bal = Number(v) || 0;
+ if (bal > 0) {
+ holders.push({ uid, bal });
+ totalCustodied += bal;
+ }
+}
+holders.sort((a, b) => b.bal - a.bal);
+
+// ---- pass 3: payments (the heavy walk) -------------------------------------
+
+const TYPES = [
+ "lightning",
+ "liquid",
+ "bitcoin",
+ "internal",
+ "bolt12",
+ "ecash",
+ "fund",
+ "reconcile",
+] as const;
+
+type Agg = { count: number; volume: number; sent: number; received: number };
+const byType: Record<string, Agg> = {};
+for (const t of TYPES) byType[t] = { count: 0, volume: 0, sent: 0, received: 0 };
+
+const activeUids = new Set<string>();
+const weekActive = new Set<string>(); // last 7d
+const perUser = new Map<string, { count: number; volume: number }>();
+const bolt12Recipients = new Map<string, number>(); // uid -> # bolt12 receives
+let feeRevenue = 0;
+let paymentsInWindow = 0;
+let largest: { uid: string; amount: number; type: string } | null = null;
+const dayBuckets = new Map<string, number>(); // YYYY-MM-DD -> count
+
+const weekCutoff = now - 7 * 24 * 60 * 60 * 1000;
+
+let scanned = 0;
+const t0 = Date.now();
+for await (const [key, p] of scanGetJSON("payment:*", 2000)) {
+ scanned++;
+ if (!p?.created || typeof p.amount !== "number" || !p.uid) continue;
+ if (p.created < cutoff) continue;
+
+ paymentsInWindow++;
+ const uid = p.uid;
+ const type = p.type || "unknown";
+ const amt = p.amount;
+ const abs = Math.abs(amt);
+
+ const agg = byType[type] || (byType[type] = { count: 0, volume: 0, sent: 0, received: 0 });
+ agg.count++;
+ agg.volume += abs;
+ if (amt < 0) agg.sent += abs;
+ else agg.received += abs;
+
+ activeUids.add(uid);
+ if (p.created >= weekCutoff) weekActive.add(uid);
+
+ const pu = perUser.get(uid) || { count: 0, volume: 0 };
+ pu.count++;
+ pu.volume += abs;
+ perUser.set(uid, pu);
+
+ if (type === "bolt12" && amt > 0) {
+ bolt12Recipients.set(uid, (bolt12Recipients.get(uid) || 0) + 1);
+ }
+
+ if (typeof p.fee === "number") feeRevenue += p.fee;
+ if (typeof p.ourfee === "number") feeRevenue += p.ourfee;
+
+ if (!largest || abs > largest.amount) largest = { uid, amount: abs, type };
+
+ const day = new Date(p.created).toISOString().slice(0, 10);
+ dayBuckets.set(day, (dayBuckets.get(day) || 0) + 1);
+}
+const walkSecs = ((Date.now() - t0) / 1000).toFixed(1);
+
+// ---- derive ----------------------------------------------------------------
+
+const mostActive = [...perUser.entries()]
+ .sort((a, b) => b[1].count - a[1].count)
+ .slice(0, TOP);
+const highestVolume = [...perUser.entries()]
+ .sort((a, b) => b[1].volume - a[1].volume)
+ .slice(0, TOP);
+
+// "Regular" bolt12 recipients = received via bolt12 on multiple distinct days
+// would be ideal, but receive-count >= 4 in the window is a good proxy for the
+// Ocean mining-reward stream.
+const regularBolt12 = [...bolt12Recipients.entries()].filter(([, n]) => n >= 4);
+regularBolt12.sort((a, b) => b[1] - a[1]);
+
+const totalVolume = Object.values(byType).reduce((s, a) => s + a.volume, 0);
+
+// ---- CSV append (trend tracking) -------------------------------------------
+// One row per run. Stable column set so months line up; per-type volume/count
+// columns let us chart the lightning/liquid/bitcoin/internal/bolt12 mix over
+// time. Written before stdout output so it happens in both text and --json modes.
+if (csvPath) {
+ const typeCols = ["lightning", "liquid", "bitcoin", "internal", "bolt12", "fund"];
+ const header = [
+ "date",
+ "window_days",
+ "users_total",
+ "new_users",
+ "mau",
+ "wau",
+ "payments",
+ "total_volume_sats",
+ "custodied_sats",
+ "holders",
+ "fee_revenue_sats",
+ "bolt12_recipients",
+ "regular_bolt12",
+ ...typeCols.flatMap((t) => [`${t}_vol`, `${t}_txns`]),
+ ];
+ const row = [
+ new Date(now).toISOString().slice(0, 10),
+ argDays,
+ userRecords,
+ newUsers,
+ activeUids.size,
+ weekActive.size,
+ paymentsInWindow,
+ totalVolume,
+ totalCustodied,
+ holders.length,
+ feeRevenue,
+ bolt12Recipients.size,
+ regularBolt12.length,
+ ...typeCols.flatMap((t) => [byType[t]?.volume || 0, byType[t]?.count || 0]),
+ ];
+ const fresh = !existsSync(csvPath);
+ appendFileSync(csvPath, (fresh ? header.join(",") + "\n" : "") + row.join(",") + "\n");
+ // To stderr so it doesn't pollute the saved stdout report / --json output.
+ console.error(`[csv] appended row to ${csvPath}`);
+}
+
+// ---- output ----------------------------------------------------------------
+
+if (asJson) {
+ console.log(
+ JSON.stringify(
+ {
+ window_days: argDays,
+ generated_at: new Date(now).toISOString(),
+ users_total: userRecords,
+ new_users_in_window: newUsers,
+ mau: activeUids.size,
+ wau: weekActive.size,
+ payments_in_window: paymentsInWindow,
+ total_volume_sats: totalVolume,
+ total_custodied_sats: totalCustodied,
+ fee_revenue_sats: feeRevenue,
+ by_type: byType,
+ top_holders: holders.slice(0, TOP).map((h) => ({ user: nameOf(h.uid), bal: h.bal })),
+ most_active: mostActive.map(([uid, v]) => ({ user: nameOf(uid), ...v })),
+ highest_volume: highestVolume.map(([uid, v]) => ({ user: nameOf(uid), ...v })),
+ bolt12_recipients: bolt12Recipients.size,
+ regular_bolt12_recipients: regularBolt12.length,
+ },
+ null,
+ 2,
+ ),
+ );
+ process.exit(0);
+}
+
+const line = (s = "") => console.log(s);
+const bar = (n: number, max: number, width = 30) =>
+ "█".repeat(Math.round((n / max) * width)) + "·".repeat(width - Math.round((n / max) * width));
+
+line("═".repeat(64));
+line(` COINOS ANALYTICS — last ${argDays} days (${new Date(now).toISOString().slice(0, 16)}Z)`);
+line(` walked ${scanned.toLocaleString()} payment keys in ${walkSecs}s`);
+line("═".repeat(64));
+
+line("\n▏USERS");
+line(` Total user records ........ ${userRecords.toLocaleString()}`);
+line(` New users (window) ........ ${newUsers.toLocaleString()}`);
+line(` MAU (active ${argDays}d) ......... ${activeUids.size.toLocaleString()}`);
+line(` WAU (active 7d) ........... ${weekActive.size.toLocaleString()}`);
+line(` Payments (window) ......... ${paymentsInWindow.toLocaleString()}`);
+
+line("\n▏CUSTODY");
+line(` Total custodied ........... ${fmtSats(totalCustodied)} (${fmtBtc(totalCustodied)})`);
+line(` Holders (>0 bal) .......... ${holders.length.toLocaleString()}`);
+line(` Fee revenue (window) ...... ${fmtSats(feeRevenue)}`);
+
+line("\n▏NETWORK VOLUME (gross sats moved, window)");
+const typesSorted = Object.entries(byType)
+ .filter(([, a]) => a.count > 0)
+ .sort((a, b) => b[1].volume - a[1].volume);
+const maxVol = Math.max(...typesSorted.map(([, a]) => a.volume), 1);
+for (const [t, a] of typesSorted) {
+ const pct = ((a.volume / totalVolume) * 100).toFixed(1);
+ line(` ${t.padEnd(10)} ${bar(a.volume, maxVol, 24)} ${pct.padStart(5)}% ${a.count.toLocaleString()} txns`);
+ line(` ${fmtSats(a.volume).padEnd(22)} (${fmtBtc(a.volume)})`);
+}
+
+line("\n▏TOP HOLDERS");
+holders.slice(0, TOP).forEach((h, i) =>
+ line(` ${String(i + 1).padStart(2)}. ${nameOf(h.uid).padEnd(24)} ${fmtSats(h.bal)}`),
+);
+
+line("\n▏MOST ACTIVE USERS (by payment count)");
+mostActive.forEach(([uid, v], i) =>
+ line(` ${String(i + 1).padStart(2)}. ${nameOf(uid).padEnd(24)} ${v.count.toLocaleString()} txns, ${fmtSats(v.volume)}`),
+);
+
+line("\n▏HIGHEST VOLUME USERS (gross sats moved)");
+highestVolume.forEach(([uid, v], i) =>
+ line(` ${String(i + 1).padStart(2)}. ${nameOf(uid).padEnd(24)} ${fmtSats(v.volume)} (${v.count} txns)`),
+);
+
+line("\n▏BOLT12 (Ocean mining rewards etc.)");
+line(` Distinct bolt12 recipients ........ ${bolt12Recipients.size.toLocaleString()}`);
+line(` Regular recipients (>=4 receives) . ${regularBolt12.length.toLocaleString()}`);
+regularBolt12.slice(0, TOP).forEach(([uid, n], i) =>
+ line(` ${String(i + 1).padStart(2)}. ${nameOf(uid).padEnd(24)} ${n} bolt12 receives`),
+);
+
+if (largest)
+ line(`\n Largest single payment: ${fmtSats(largest.amount)} (${largest.type}) by ${nameOf(largest.uid)}`);
+
+line("\n" + "═".repeat(64));
+process.exit(0);
diff --git a/scripts/audit-lightning-backlog.ts b/scripts/audit-lightning-backlog.ts
new file mode 100644
index 00000000..4c78e295
--- /dev/null
+++ b/scripts/audit-lightning-backlog.ts
@@ -0,0 +1,76 @@
+import { db } from "$lib/db";
+import ln from "$lib/ln";
+import { getInvoice, getPayment, getUser } from "$lib/utils";
+
+const max = Number(process.argv[2] || 10_000);
+const stored = Number((await db.get("pay_index")) || 0);
+let cursor = stored;
+const rows: any[] = [];
+
+for (let i = 0; i < max; i++) {
+ let settled: any;
+ try {
+ settled = await ln.waitanyinvoice(cursor, 1);
+ } catch (e: any) {
+ if (Number(e?.code ?? e?.errno) === 904) break;
+ throw e;
+ }
+
+ if (!settled?.pay_index || Number(settled.pay_index) <= cursor) break;
+ cursor = Number(settled.pay_index);
+
+ const mintOwned =
+ typeof settled.label === "string" && settled.label.startsWith("lbl");
+ const paymentRequest = settled.bolt11 || settled.bolt12;
+ const invoiceKey = settled.bolt11 || settled.local_offer_id || settled.bolt12;
+ const invoice = mintOwned ? null : await getInvoice(invoiceKey);
+ const payment = paymentRequest ? await getPayment(paymentRequest) : null;
+ const user = invoice?.uid ? await getUser(invoice.uid) : null;
+ const claim = settled.payment_preimage
+ ? await db.get(`credited:${settled.payment_preimage}`)
+ : null;
+
+ rows.push({
+ payIndex: cursor,
+ amountSats: Math.round(Number(settled.amount_received_msat || 0) / 1000),
+ mintOwned,
+ invoiceFound: !!invoice,
+ paymentFound: !!payment,
+ claimFound: !!claim,
+ username: user?.username,
+ autowithdraw: !!user?.autowithdraw,
+ });
+}
+
+const coinos = rows.filter((row) => !row.mintOwned);
+const actionable = coinos.filter(
+ (row) => row.invoiceFound && !row.paymentFound && !row.claimFound,
+);
+
+console.log(
+ JSON.stringify(
+ {
+ storedPayIndex: stored,
+ latestPayIndex: cursor,
+ backlog: rows.length,
+ mintOwned: rows.filter((row) => row.mintOwned).length,
+ coinos: coinos.length,
+ alreadyProcessed: coinos.filter((row) => row.paymentFound).length,
+ missingInvoice: coinos.filter((row) => !row.invoiceFound).length,
+ claimedWithoutPayment: coinos.filter(
+ (row) => row.claimFound && !row.paymentFound,
+ ).length,
+ actionable: actionable.length,
+ actionableSats: actionable.reduce(
+ (total, row) => total + row.amountSats,
+ 0,
+ ),
+ autowithdraw: actionable.filter((row) => row.autowithdraw).length,
+ rows,
+ },
+ null,
+ 2,
+ ),
+);
+
+process.exit(0);
diff --git a/scripts/audit-missing-history.ts b/scripts/audit-missing-history.ts
new file mode 100644
index 00000000..30c2c8fc
--- /dev/null
+++ b/scripts/audit-missing-history.ts
@@ -0,0 +1,106 @@
+#!/usr/bin/env bun
+// Audit how widespread missing payment history is and classify WHY.
+//
+// For each user with a non-empty :payments list, check each referenced
+// payment:<id> across db and arc, and bucket the user:
+// - clean: all records resolve (db or arc)
+// - recoverable: some/all records missing from db but present in arc
+// - lost: some records missing from BOTH db and arc (true loss)
+// Also records per-payment counts so we can size a restore.
+//
+// Read-only. Samples by default; pass --full to scan every user (slower).
+// Usage: bun scripts/audit-missing-history.ts [--full] [--sample N] [--examples N]
+
+import { createClient } from "redis";
+
+const full = process.argv.includes("--full");
+const sampleN = Number(process.argv[process.argv.indexOf("--sample") + 1]) || 4000;
+const exN = Number(process.argv[process.argv.indexOf("--examples") + 1]) || 15;
+
+const db = createClient({ url: "redis://127.0.0.1:6379", socket: { reconnectStrategy: () => false } });
+const arc = createClient({ url: "redis://127.0.0.1:6380", socket: { reconnectStrategy: () => false } });
+await db.connect();
+await arc.connect();
+
+// Resolve uid -> username lazily (only for examples) to keep memory low.
+async function nameOf(uid: string) {
+ const u = await db.get(`user:${uid}`);
+ if (u && u[0] === "{") { try { return JSON.parse(u).username || uid.slice(0, 8); } catch {} }
+ return uid.slice(0, 8);
+}
+
+let usersScanned = 0;
+let usersWithPayments = 0;
+const bucket = { clean: 0, recoverable: 0, lost: 0 };
+let totalRefs = 0, inDb = 0, inArcOnly = 0, missingBoth = 0;
+const examples: { recoverable: string[]; lost: string[] } = { recoverable: [], lost: [] };
+
+// Helper: does payment:<id> exist in db / arc? (batched EXISTS)
+async function classifyUser(uid: string): Promise<"clean" | "recoverable" | "lost" | null> {
+ const pids = await db.lRange(`${uid}:payments`, 0, -1);
+ if (!pids.length) return null;
+ usersWithPayments++;
+
+ let anyArcOnly = false, anyMissing = false;
+ for (const pid of pids) {
+ totalRefs++;
+ const key = `payment:${pid}`;
+ const dbHas = await db.exists(key);
+ if (dbHas) { inDb++; continue; }
+ const arcHas = await arc.exists(key);
+ if (arcHas) { inArcOnly++; anyArcOnly = true; }
+ else { missingBoth++; anyMissing = true; }
+ }
+ if (anyMissing) return "lost";
+ if (anyArcOnly) return "recoverable";
+ return "clean";
+}
+
+const t0 = Date.now();
+let processed = 0;
+for await (const key of db.scanIterator({ MATCH: "user:*", COUNT: 1000 })) {
+ // Only the canonical record key user:<uuid> (skip user:<username> pointers).
+ const id = (key as string).slice("user:".length);
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-/.test(id)) continue;
+ usersScanned++;
+ if (!full && usersScanned > sampleN) break;
+
+ const cls = await classifyUser(id);
+ if (!cls) continue;
+ bucket[cls]++;
+ if (cls === "recoverable" && examples.recoverable.length < exN) examples.recoverable.push(id);
+ if (cls === "lost" && examples.lost.length < exN) examples.lost.push(id);
+ processed++;
+}
+const secs = ((Date.now() - t0) / 1000).toFixed(1);
+
+const totalClassified = bucket.clean + bucket.recoverable + bucket.lost;
+const pct = (n: number) => totalClassified ? ((n / totalClassified) * 100).toFixed(1) + "%" : "0%";
+
+console.log("═".repeat(66));
+console.log(` MISSING-HISTORY AUDIT — ${full ? "FULL scan" : `sample of ${sampleN} users`}`);
+console.log(` scanned ${usersScanned} user records, ${usersWithPayments} with payments, in ${secs}s`);
+console.log("═".repeat(66));
+console.log(`\n Users with payment history: ${totalClassified}`);
+console.log(` clean (all records present) ...... ${bucket.clean} (${pct(bucket.clean)})`);
+console.log(` recoverable (in arc, not db) ..... ${bucket.recoverable} (${pct(bucket.recoverable)}) <- restorable`);
+console.log(` lost (missing from db AND arc) ... ${bucket.lost} (${pct(bucket.lost)}) <- true loss`);
+console.log(`\n Payment records referenced: ${totalRefs}`);
+console.log(` in db ............ ${inDb} (${((inDb/totalRefs)*100||0).toFixed(1)}%)`);
+console.log(` in arc only ...... ${inArcOnly} (${((inArcOnly/totalRefs)*100||0).toFixed(1)}%) <- restorable`);
+console.log(` missing both ..... ${missingBoth} (${((missingBoth/totalRefs)*100||0).toFixed(1)}%) <- gone`);
+
+if (examples.recoverable.length) {
+ console.log(`\n Example RECOVERABLE users:`);
+ for (const uid of examples.recoverable) console.log(` ${await nameOf(uid)} (${uid})`);
+}
+if (examples.lost.length) {
+ console.log(`\n Example LOST-record users:`);
+ for (const uid of examples.lost) console.log(` ${await nameOf(uid)} (${uid})`);
+}
+if (!full) {
+ const est = Math.round((bucket.recoverable / Math.min(usersScanned, sampleN)) * usersScanned);
+ console.log(`\n (sample only — run --full for exact totals)`);
+}
+console.log("═".repeat(66));
+process.exit(0);
diff --git a/scripts/audit-wiped-history.ts b/scripts/audit-wiped-history.ts
new file mode 100644
index 00000000..d4e94645
--- /dev/null
+++ b/scripts/audit-wiped-history.ts
@@ -0,0 +1,85 @@
+#!/usr/bin/env bun
+// Find accounts whose payment history index was wiped — the changeid bug
+// signature, distinct from the harmless arc-only archived state.
+//
+// Two buckets:
+// wiped-with-balance: balance > 0 but :payments list is empty in BOTH db and
+// arc. Real funds, no visible history. These are the changeid-rekeyed
+// accounts (password == "reset") whose list entries got dropped. Highest
+// priority — user sees a balance with no transactions.
+// wiped-no-balance: :payments empty in both, balance 0, but has :invoices
+// (so they did transact once). History gone but nothing owed; lower priority.
+//
+// Read-only. Usage: bun scripts/audit-wiped-history.ts [--full] [--examples N]
+
+import { createClient } from "redis";
+
+const full = process.argv.includes("--full");
+const exN = Number(process.argv[process.argv.indexOf("--examples") + 1]) || 30;
+
+const db = createClient({ url: "redis://127.0.0.1:6379", socket: { reconnectStrategy: () => false } });
+const arc = createClient({ url: "redis://127.0.0.1:6380", socket: { reconnectStrategy: () => false } });
+await db.connect();
+await arc.connect();
+
+let scanned = 0;
+const wipedWithBal: { name: string; uid: string; bal: number; reset: boolean }[] = [];
+const wipedNoBal: { name: string; uid: string; invoices: number }[] = [];
+let cleanCount = 0;
+
+const t0 = Date.now();
+for await (const batch of db.scanIterator({ MATCH: "user:*", COUNT: 2000 })) {
+ // This redis client version yields ARRAYS of keys per iteration, not single keys.
+ const keys = Array.isArray(batch) ? batch : [batch];
+ for (const key of keys) {
+ const uid = (key as string).slice("user:".length);
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-/.test(uid)) continue; // skip username pointers
+ scanned++;
+
+ const dbLen = await db.lLen(`${uid}:payments`);
+ if (dbLen > 0) { cleanCount++; continue; } // has visible history
+
+ // Empty in db — is it also empty in arc, or just archived?
+ const arcLen = await arc.lLen(`${uid}:payments`);
+ if (arcLen > 0) { cleanCount++; continue; } // renders via gf(), fine
+
+ // :payments empty in both. Does it matter? Check balance + whether they ever transacted.
+ const bal = parseInt((await db.get(`balance:${uid}`)) || "0", 10);
+ const invLen = await db.lLen(`${uid}:invoices`);
+
+ if (bal > 0) {
+ const u = await db.get(`user:${uid}`);
+ let name = uid.slice(0, 8), reset = false;
+ if (u && u[0] === "{") { try { const j = JSON.parse(u); name = j.username || name; reset = j.password === "reset"; } catch {} }
+ wipedWithBal.push({ name, uid, bal, reset });
+ } else if (invLen > 0) {
+ const u = await db.get(`user:${uid}`);
+ let name = uid.slice(0, 8);
+ if (u && u[0] === "{") { try { name = JSON.parse(u).username || name; } catch {} }
+ wipedNoBal.push({ name, uid, invoices: invLen });
+ }
+ }
+}
+const secs = ((Date.now() - t0) / 1000).toFixed(1);
+
+wipedWithBal.sort((a, b) => b.bal - a.bal);
+
+console.log("═".repeat(70));
+console.log(` WIPED-HISTORY AUDIT — scanned ${scanned} users in ${secs}s`);
+console.log("═".repeat(70));
+console.log(`\n Accounts with BALANCE but no history (priority — funds visible, no txns):`);
+console.log(` count: ${wipedWithBal.length}`);
+const totalBal = wipedWithBal.reduce((s, x) => s + x.bal, 0);
+console.log(` total balance affected: ${totalBal.toLocaleString()} sats`);
+console.log(` (of which password=="reset" / changeid'd: ${wipedWithBal.filter((x) => x.reset).length})`);
+console.log("");
+for (const x of wipedWithBal.slice(0, exN)) {
+ console.log(` ${x.name.padEnd(24)} ${(x.bal + "").padStart(12)} sats ${x.reset ? "[changeid]" : ""} ${x.uid}`);
+}
+
+console.log(`\n Accounts wiped, no balance (had invoices, lower priority): ${wipedNoBal.length}`);
+for (const x of wipedNoBal.slice(0, 10)) {
+ console.log(` ${x.name.padEnd(24)} ${x.invoices} invoices ${x.uid}`);
+}
+console.log("═".repeat(70));
+process.exit(0);
diff --git a/scripts/boltz-recover-stuck.ts b/scripts/boltz-recover-stuck.ts
new file mode 100644
index 00000000..55393f40
--- /dev/null
+++ b/scripts/boltz-recover-stuck.ts
@@ -0,0 +1,91 @@
+// boltz-recover-stuck.ts — auto-recover boltz submarine swaps whose Lightning
+// payment got stuck, using the stubborn peer-force payer (pay-hard.ts).
+//
+// Background: boltz-backend pays a submarine swap's destination invoice with
+// CLN's basic `pay` and gives up after a few minutes. To inbound-scarce exchange
+// destinations (fixedfloat), that leaves the user's on-chain funds locked and the
+// swap stuck at `invoice.pending` — exactly what happened 2026-08-27. pay-hard's
+// peer-force strategy completes those. After the invoice is paid on bcln, boltz
+// reconciles (picks up the preimage and claims the lockup) on its next restart;
+// if it hasn't within a grace period, we restart it once.
+//
+// Runs on a cron from the coinos-server host (reaches cl locally and bcln/boltz
+// on cs via ssh). Quiet unless it finds a stuck swap. Log: ~/boltz-recover.log
+//
+// Usage: bun scripts/boltz-recover-stuck.ts (add --dry to detect+probe only)
+import { execSync } from "child_process";
+import { appendFileSync, writeFileSync } from "fs";
+
+const DRY = process.argv.includes("--dry");
+const LOG = "/home/adam/boltz-recover.log";
+const PAYHARD = "/home/adam/coinos-server/scripts/pay-hard.ts";
+const BUN = "/home/adam/.bun/bin/bun";
+const STUCK_MIN = 10; // only touch swaps idle at least this long (don't race boltz)
+const MAX_PER_RUN = 5; // safety cap
+const BCLN = "docker exec bcln lightning-cli --lightning-dir=/app/lightning";
+
+const sh = (c: string) => { try { return execSync(c, { encoding: "utf8", maxBuffer: 1e8 }); } catch (e: any) { return (e.stdout || "") + (e.stderr || ""); } };
+const log = (m: string) => { const line = `[${new Date().toISOString()}] ${m}`; try { appendFileSync(LOG, line + "\n"); } catch {} console.log(line); };
+const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
+
+// run a SQL query against boltz-postgres on cs (query via stdin -> no quoting hell)
+function psql(query: string): string[] {
+ writeFileSync("/tmp/boltz_recover_q.sql", query);
+ const out = sh(`ssh -o ConnectTimeout=15 cs 'docker exec -i boltz-postgres psql -U boltz -d boltz -tAF"|"' < /tmp/boltz_recover_q.sql`);
+ return out.trim().split("\n").filter(Boolean);
+}
+const bcln = (sub: string) => sh(`ssh -o ConnectTimeout=15 cs '${BCLN} ${sub}'`);
+
+// ---- find stuck submarine swaps ----
+// `swaps` = submarine swaps (user locks on-chain, boltz pays the LN invoice).
+const rows = psql(
+ `select id, invoice from swaps ` +
+ `where status='invoice.pending' and "lockupTransactionId" is not null and invoice is not null ` +
+ `and "updatedAt" < now() - interval '${STUCK_MIN} minutes' ` +
+ `order by "updatedAt" asc limit ${MAX_PER_RUN}`,
+).map((l) => { const [id, invoice] = l.split("|"); return { id, invoice }; });
+
+if (!rows.length) process.exit(0); // quiet: nothing stuck
+log(`found ${rows.length} stuck submarine swap(s): ${rows.map((r) => r.id).join(", ")}`);
+
+const paidIds: string[] = [];
+for (const { id, invoice } of rows) {
+ // skip expired invoices — those are a refund case, not ours to force
+ let dec: any = null;
+ try { dec = JSON.parse(bcln(`decode ${invoice}`)); } catch {}
+ if (dec?.created_at && dec?.expiry && Date.now() / 1000 > dec.created_at + dec.expiry) {
+ log(` ${id}: invoice expired — skipping (refund path)`);
+ continue;
+ }
+
+ if (DRY) {
+ const out = sh(`${BUN} ${PAYHARD} ${invoice} --node=bcln --dry`);
+ log(` ${id}: [dry] ${out.trim().split("\n").pop()}`);
+ continue;
+ }
+
+ log(` ${id}: running pay-hard…`);
+ const out = sh(`${BUN} ${PAYHARD} ${invoice} --node=bcln --maxfee=6000`);
+ const last = out.trim().split("\n").pop() || "";
+ const paid = /🎉 PAID|already paid|xpay succeeded/.test(out);
+ log(` → ${last}`);
+ if (paid) paidIds.push(id);
+}
+
+// ---- reconcile: make boltz claim the lockups for swaps we just paid ----
+if (paidIds.length && !DRY) {
+ await sleep(45_000); // give boltz a chance to notice on its own
+ const still = psql(
+ `select id from swaps where id in (${paidIds.map((i) => `'${i}'`).join(",")}) and status='invoice.pending'`,
+ );
+ if (still.length) {
+ log(`boltz did not auto-reconcile ${still.length} paid swap(s) [${still.join(",")}] — restarting boltz to claim`);
+ sh(`ssh -o ConnectTimeout=20 cs 'docker restart boltz'`);
+ await sleep(20_000);
+ const after = psql(`select id, status from swaps where id in (${paidIds.map((i) => `'${i}'`).join(",")})`);
+ log(` post-restart status: ${after.join(" ; ")}`);
+ } else {
+ log(`boltz reconciled all ${paidIds.length} paid swap(s) on its own`);
+ }
+}
+process.exit(0);
diff --git a/scripts/cln_schema.sql b/scripts/cln_schema.sql
new file mode 100644
index 00000000..e8bfa499
--- /dev/null
+++ b/scripts/cln_schema.sql
@@ -0,0 +1,1783 @@
+--
+-- PostgreSQL database dump
+--
+
+\restrict 3gmSHpmWoAvp3M9hYnbn0eAJg2qkrJcZzqafmBmvLGVxDFS2cVhidb6yKDBdS99
+
+-- Dumped from database version 18.3 (Debian 18.3-1.pgdg13+1)
+-- Dumped by pg_dump version 18.3 (Debian 18.3-1.pgdg13+1)
+
+SET statement_timeout = 0;
+SET lock_timeout = 0;
+SET idle_in_transaction_session_timeout = 0;
+SET transaction_timeout = 0;
+SET client_encoding = 'UTF8';
+SET standard_conforming_strings = on;
+SELECT pg_catalog.set_config('search_path', '', false);
+SET check_function_bodies = false;
+SET xmloption = content;
+SET client_min_messages = warning;
+SET row_security = off;
+
+SET default_tablespace = '';
+
+SET default_table_access_method = heap;
+
+--
+-- Name: addresses; Type: TABLE; Schema: public; Owner: lightning
+--
+
+CREATE TABLE public.addresses (
+ keyidx bigint,
+ addrtype integer
+);
+
+
+ALTER TABLE public.addresses OWNER TO lightning;
+
+--
+-- Name: blocks; Type: TABLE; Schema: public; Owner: lightning
+--
+
+CREATE TABLE public.blocks (
+ height integer,
+ hash bytea,
+ prev_hash bytea
+);
+
+
+ALTER TABLE public.blocks OWNER TO lightning;
+
+--
+-- Name: chain_moves; Type: TABLE; Schema: public; Owner: lightning
+--
+
+CREATE TABLE public.chain_moves (
+ id bigint NOT NULL,
+ account_channel_id bigint,
+ account_nonchannel_id bigint,
+ tag_bitmap bigint NOT NULL,
+ credit_or_debit bigint NOT NULL,
+ "timestamp" bigint NOT NULL,
+ utxo bytea NOT NULL,
+ spending_txid bytea,
+ peer_id bytea,
+ payment_hash bytea,
+ block_height integer NOT NULL,
+ output_sat bigint NOT NULL,
+ originating_channel_id bigint,
+ originating_nonchannel_id bigint,
+ output_count integer
+);
+
+
+ALTER TABLE public.chain_moves OWNER TO lightning;
+
+--
+-- Name: chain_moves_id_seq; Type: SEQUENCE; Schema: public; Owner: lightning
+--
+
+CREATE SEQUENCE public.chain_moves_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+ALTER SEQUENCE public.chain_moves_id_seq OWNER TO lightning;
+
+--
+-- Name: chain_moves_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: lightning
+--
+
+ALTER SEQUENCE public.chain_moves_id_seq OWNED BY public.chain_moves.id;
+
+
+--
+-- Name: channel_blockheights; Type: TABLE; Schema: public; Owner: lightning
+--
+
+CREATE TABLE public.channel_blockheights (
+ channel_id bigint,
+ hstate integer,
+ blockheight integer
+);
+
+
+ALTER TABLE public.channel_blockheights OWNER TO lightning;
+
+--
+-- Name: channel_configs; Type: TABLE; Schema: public; Owner: lightning
+--
+
+CREATE TABLE public.channel_configs (
+ id bigint NOT NULL,
+ dust_limit_satoshis bigint,
+ max_htlc_value_in_flight_msat bigint,
+ channel_reserve_satoshis bigint,
+ htlc_minimum_msat bigint,
+ to_self_delay integer,
+ max_accepted_htlcs integer,
+ max_dust_htlc_exposure_msat bigint DEFAULT 50000000
+);
+
+
+ALTER TABLE public.channel_configs OWNER TO lightning;
+
+--
+-- Name: channel_configs_id_seq; Type: SEQUENCE; Schema: public; Owner: lightning
+--
+
+CREATE SEQUENCE public.channel_configs_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+ALTER SEQUENCE public.channel_configs_id_seq OWNER TO lightning;
+
+--
+-- Name: channel_configs_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: lightning
+--
+
+ALTER SEQUENCE public.channel_configs_id_seq OWNED BY public.channel_configs.id;
+
+
+--
+-- Name: channel_feerates; Type: TABLE; Schema: public; Owner: lightning
+--
+
+CREATE TABLE public.channel_feerates (
+ channel_id bigint,
+ hstate integer,
+ feerate_per_kw integer
+);
+
+
+ALTER TABLE public.channel_feerates OWNER TO lightning;
+
+--
+-- Name: channel_funding_inflights; Type: TABLE; Schema: public; Owner: lightning
+--
+
+CREATE TABLE public.channel_funding_inflights (
+ channel_id bigint NOT NULL,
+ funding_tx_id bytea NOT NULL,
+ funding_tx_outnum integer,
+ funding_feerate integer,
+ funding_satoshi bigint,
+ our_funding_satoshi bigint,
+ funding_psbt bytea,
+ last_tx bytea,
+ last_sig bytea,
+ funding_tx_remote_sigs_received integer,
+ lease_commit_sig bytea,
+ lease_chan_max_msat bigint,
+ lease_chan_max_ppt integer,
+ lease_expiry integer DEFAULT 0,
+ lease_blockheight_start integer DEFAULT 0,
+ lease_fee bigint DEFAULT 0,
+ lease_satoshi bigint,
+ splice_amnt bigint DEFAULT 0,
+ i_am_initiator integer DEFAULT 0,
+ force_sign_first integer DEFAULT 0,
+ remote_funding bytea,
+ locked_scid bigint DEFAULT 0,
+ i_sent_sigs integer DEFAULT 0
+);
+
+
+ALTER TABLE public.channel_funding_inflights OWNER TO lightning;
+
+--
+-- Name: channel_funding_inflights_channel_id_seq; Type: SEQUENCE; Schema: public; Owner: lightning
+--
+
+CREATE SEQUENCE public.channel_funding_inflights_channel_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+ALTER SEQUENCE public.channel_funding_inflights_channel_id_seq OWNER TO lightning;
+
+--
+-- Name: channel_funding_inflights_channel_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: lightning
+--
+
+ALTER SEQUENCE public.channel_funding_inflights_channel_id_seq OWNED BY public.channel_funding_inflights.channel_id;
+
+
+--
+-- Name: channel_htlcs; Type: TABLE; Schema: public; Owner: lightning
+--
+
+CREATE TABLE public.channel_htlcs (
+ id bigint NOT NULL,
+ channel_id bigint,
+ channel_htlc_id bigint,
+ direction integer,
+ origin_htlc bigint,
+ msatoshi bigint,
+ cltv_expiry integer,
+ payment_hash bytea,
+ payment_key bytea,
+ routing_onion bytea,
+ failuremsg bytea,
+ malformed_onion integer,
+ hstate integer,
+ shared_secret bytea,
+ received_time bigint,
+ partid bigint,
+ localfailmsg bytea,
+ we_filled integer,
+ groupid bigint,
+ min_commit_num bigint DEFAULT 0,
+ max_commit_num bigint,
+ fail_immediate integer DEFAULT 0,
+ fees_msat bigint DEFAULT 0,
+ updated_index bigint DEFAULT 0
+);
+
+
+ALTER TABLE public.channel_htlcs OWNER TO lightning;
+
+--
+-- Name: channel_htlcs_id_seq; Type: SEQUENCE; Schema: public; Owner: lightning
+--
+
+CREATE SEQUENCE public.channel_htlcs_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+ALTER SEQUENCE public.channel_htlcs_id_seq OWNER TO lightning;
+
+--
+-- Name: channel_htlcs_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: lightning
+--
+
+ALTER SEQUENCE public.channel_htlcs_id_seq OWNED BY public.channel_htlcs.id;
+
+
+--
+-- Name: channel_moves; Type: TABLE; Schema: public; Owner: lightning
+--
+
+CREATE TABLE public.channel_moves (
+ id bigint NOT NULL,
+ account_channel_id bigint,
+ account_nonchannel_id bigint,
+ tag_bitmap bigint NOT NULL,
+ credit_or_debit bigint NOT NULL,
+ "timestamp" bigint NOT NULL,
+ payment_hash bytea,
+ payment_part_id bigint,
+ payment_group_id bigint,
+ fees bigint NOT NULL
+);
+
+
+ALTER TABLE public.channel_moves OWNER TO lightning;
+
+--
+-- Name: channel_moves_id_seq; Type: SEQUENCE; Schema: public; Owner: lightning
+--
+
+CREATE SEQUENCE public.channel_moves_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+ALTER SEQUENCE public.channel_moves_id_seq OWNER TO lightning;
+
+--
+-- Name: channel_moves_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: lightning
+--
+
+ALTER SEQUENCE public.channel_moves_id_seq OWNED BY public.channel_moves.id;
+
+
+--
+-- Name: channel_state_changes; Type: TABLE; Schema: public; Owner: lightning
+--
+
+CREATE TABLE public.channel_state_changes (
+ channel_id bigint,
+ "timestamp" bigint,
+ old_state integer,
+ new_state integer,
+ cause integer,
+ message text
+);
+
+
+ALTER TABLE public.channel_state_changes OWNER TO lightning;
+
+--
+-- Name: channels; Type: TABLE; Schema: public; Owner: lightning
+--
+
+CREATE TABLE public.channels (
+ id bigint NOT NULL,
+ peer_id bigint,
+ short_channel_id text,
+ channel_config_local bigint,
+ channel_config_remote bigint,
+ state integer,
+ funder integer,
+ channel_flags integer,
+ minimum_depth integer,
+ next_index_local bigint,
+ next_index_remote bigint,
+ next_htlc_id bigint,
+ funding_tx_id bytea,
+ funding_tx_outnum integer,
+ funding_satoshi bigint,
+ funding_locked_remote integer,
+ push_msatoshi bigint,
+ msatoshi_local bigint,
+ fundingkey_remote bytea,
+ revocation_basepoint_remote bytea,
+ payment_basepoint_remote bytea,
+ htlc_basepoint_remote bytea,
+ delayed_payment_basepoint_remote bytea,
+ per_commit_remote bytea,
+ old_per_commit_remote bytea,
+ local_feerate_per_kw integer,
+ remote_feerate_per_kw integer,
+ shachain_remote_id bigint,
+ shutdown_scriptpubkey_remote bytea,
+ shutdown_keyidx_local bigint,
+ last_sent_commit_state bigint,
+ last_sent_commit_id integer,
+ last_tx bytea,
+ last_sig bytea,
+ closing_fee_received integer,
+ closing_sig_received bytea,
+ first_blocknum bigint,
+ last_was_revoke integer,
+ in_payments_offered integer DEFAULT 0,
+ in_payments_fulfilled integer DEFAULT 0,
+ in_msatoshi_offered bigint DEFAULT 0,
+ in_msatoshi_fulfilled bigint DEFAULT 0,
+ out_payments_offered integer DEFAULT 0,
+ out_payments_fulfilled integer DEFAULT 0,
+ out_msatoshi_offered bigint DEFAULT 0,
+ out_msatoshi_fulfilled bigint DEFAULT 0,
+ min_possible_feerate integer,
+ max_possible_feerate integer,
+ msatoshi_to_us_min bigint,
+ msatoshi_to_us_max bigint,
+ future_per_commitment_point bytea,
+ last_sent_commit bytea,
+ feerate_base integer,
+ feerate_ppm integer,
+ remote_upfront_shutdown_script bytea,
+ remote_ann_node_sig bytea,
+ remote_ann_bitcoin_sig bytea,
+ option_static_remotekey integer DEFAULT 0,
+ shutdown_scriptpubkey_local bytea,
+ our_funding_satoshi bigint DEFAULT 0,
+ option_anchor_outputs integer DEFAULT 0,
+ full_channel_id bytea,
+ funding_psbt bytea,
+ closer integer DEFAULT 2,
+ state_change_reason integer DEFAULT 0,
+ funding_tx_remote_sigs_received integer DEFAULT 0,
+ revocation_basepoint_local bytea,
+ payment_basepoint_local bytea,
+ htlc_basepoint_local bytea,
+ delayed_payment_basepoint_local bytea,
+ funding_pubkey_local bytea,
+ shutdown_wrong_txid bytea,
+ shutdown_wrong_outnum integer,
+ local_static_remotekey_start bigint DEFAULT 0,
+ remote_static_remotekey_start bigint DEFAULT 0,
+ lease_commit_sig bytea,
+ lease_chan_max_msat integer,
+ lease_chan_max_ppt integer,
+ lease_expiry integer DEFAULT 0,
+ htlc_maximum_msat bigint DEFAULT '2100000000000000'::bigint,
+ htlc_minimum_msat bigint DEFAULT 0,
+ alias_local bigint,
+ alias_remote bigint,
+ scid bigint,
+ require_confirm_inputs_remote integer DEFAULT 0,
+ require_confirm_inputs_local integer DEFAULT 0,
+ channel_type bytea,
+ ignore_fee_limits integer DEFAULT 0,
+ remote_feerate_base integer,
+ remote_feerate_ppm integer,
+ remote_cltv_expiry_delta integer,
+ remote_htlc_maximum_msat bigint,
+ remote_htlc_minimum_msat bigint,
+ last_stable_connection bigint DEFAULT 0,
+ close_attempt_height integer DEFAULT 0,
+ old_scids bytea,
+ withheld integer DEFAULT 0
+);
+
+
+ALTER TABLE public.channels OWNER TO lightning;
+
+--
+-- Name: channels_id_seq; Type: SEQUENCE; Schema: public; Owner: lightning
+--
+
+CREATE SEQUENCE public.channels_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+ALTER SEQUENCE public.channels_id_seq OWNER TO lightning;
+
+--
+-- Name: channels_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: lightning
+--
+
+ALTER SEQUENCE public.channels_id_seq OWNED BY public.channels.id;
+
+
+--
+-- Name: channeltxs; Type: TABLE; Schema: public; Owner: lightning
+--
+
+CREATE TABLE public.channeltxs (
+ id bigint NOT NULL,
+ channel_id bigint,
+ type integer,
+ transaction_id bytea,
+ input_num integer,
+ blockheight integer
+);
+
+
+ALTER TABLE public.channeltxs OWNER TO lightning;
+
+--
+-- Name: channeltxs_id_seq; Type: SEQUENCE; Schema: public; Owner: lightning
+--
+
+CREATE SEQUENCE public.channeltxs_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+ALTER SEQUENCE public.channeltxs_id_seq OWNER TO lightning;
+
+--
+-- Name: channeltxs_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: lightning
+--
+
+ALTER SEQUENCE public.channeltxs_id_seq OWNED BY public.channeltxs.id;
+
+
+--
+-- Name: datastore; Type: TABLE; Schema: public; Owner: lightning
+--
+
+CREATE TABLE public.datastore (
+ key bytea NOT NULL,
+ data bytea,
+ generation bigint
+);
+
+
+ALTER TABLE public.datastore OWNER TO lightning;
+
+--
+-- Name: db_upgrades; Type: TABLE; Schema: public; Owner: lightning
+--
+
+CREATE TABLE public.db_upgrades (
+ upgrade_from integer,
+ lightning_version text
+);
+
+
+ALTER TABLE public.db_upgrades OWNER TO lightning;
+
+--
+-- Name: forwards; Type: TABLE; Schema: public; Owner: lightning
+--
+
+CREATE TABLE public.forwards (
+ in_channel_scid bigint NOT NULL,
+ in_htlc_id bigint NOT NULL,
+ out_channel_scid bigint,
+ out_htlc_id bigint,
+ in_msatoshi bigint,
+ out_msatoshi bigint,
+ state integer,
+ received_time bigint,
+ resolved_time bigint,
+ failcode integer,
+ forward_style integer,
+ rowid bigint,
+ updated_index bigint DEFAULT 0
+);
+
+
+ALTER TABLE public.forwards OWNER TO lightning;
+
+--
+-- Name: htlc_sigs; Type: TABLE; Schema: public; Owner: lightning
+--
+
+CREATE TABLE public.htlc_sigs (
+ channelid integer,
+ signature bytea,
+ inflight_tx_id bytea,
+ inflight_tx_outnum integer
+);
+
+
+ALTER TABLE public.htlc_sigs OWNER TO lightning;
+
+--
+-- Name: invoice_fallbacks; Type: TABLE; Schema: public; Owner: lightning
+--
+
+CREATE TABLE public.invoice_fallbacks (
+ scriptpubkey bytea NOT NULL,
+ invoice_id bigint
+);
+
+
+ALTER TABLE public.invoice_fallbacks OWNER TO lightning;
+
+--
+-- Name: invoicerequests; Type: TABLE; Schema: public; Owner: lightning
+--
+
+CREATE TABLE public.invoicerequests (
+ invreq_id bytea NOT NULL,
+ bolt12 text,
+ label text,
+ status integer
+);
+
+
+ALTER TABLE public.invoicerequests OWNER TO lightning;
+
+--
+-- Name: invoices; Type: TABLE; Schema: public; Owner: lightning
+--
+
+CREATE TABLE public.invoices (
+ id bigint NOT NULL,
+ state integer,
+ msatoshi bigint,
+ payment_hash bytea,
+ payment_key bytea,
+ label text,
+ expiry_time bigint,
+ pay_index bigint,
+ msatoshi_received bigint,
+ paid_timestamp bigint,
+ bolt11 text,
+ description text,
+ features bytea DEFAULT '\x'::bytea,
+ local_offer_id bytea,
+ updated_index bigint DEFAULT 0,
+ paid_txid bytea,
+ paid_outnum integer
+);
+
+
+ALTER TABLE public.invoices OWNER TO lightning;
+
+--
+-- Name: invoices_id_seq; Type: SEQUENCE; Schema: public; Owner: lightning
+--
+
+CREATE SEQUENCE public.invoices_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+ALTER SEQUENCE public.invoices_id_seq OWNER TO lightning;
+
+--
+-- Name: invoices_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: lightning
+--
+
+ALTER SEQUENCE public.invoices_id_seq OWNED BY public.invoices.id;
+
+
+--
+-- Name: local_anchors; Type: TABLE; Schema: public; Owner: lightning
+--
+
+CREATE TABLE public.local_anchors (
+ channel_id bigint NOT NULL,
+ commitment_index bigint,
+ commitment_txid bytea,
+ commitment_anchor_outnum integer,
+ commitment_fee bigint,
+ commitment_weight integer
+);
+
+
+ALTER TABLE public.local_anchors OWNER TO lightning;
+
+--
+-- Name: local_anchors_channel_id_seq; Type: SEQUENCE; Schema: public; Owner: lightning
+--
+
+CREATE SEQUENCE public.local_anchors_channel_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+ALTER SEQUENCE public.local_anchors_channel_id_seq OWNER TO lightning;
+
+--
+-- Name: local_anchors_channel_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: lightning
+--
+
+ALTER SEQUENCE public.local_anchors_channel_id_seq OWNED BY public.local_anchors.channel_id;
+
+
+--
+-- Name: move_accounts; Type: TABLE; Schema: public; Owner: lightning
+--
+
+CREATE TABLE public.move_accounts (
+ id bigint NOT NULL,
+ name text
+);
+
+
+ALTER TABLE public.move_accounts OWNER TO lightning;
+
+--
+-- Name: move_accounts_id_seq; Type: SEQUENCE; Schema: public; Owner: lightning
+--
+
+CREATE SEQUENCE public.move_accounts_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+ALTER SEQUENCE public.move_accounts_id_seq OWNER TO lightning;
+
+--
+-- Name: move_accounts_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: lightning
+--
+
+ALTER SEQUENCE public.move_accounts_id_seq OWNED BY public.move_accounts.id;
+
+
+--
+-- Name: network_events; Type: TABLE; Schema: public; Owner: lightning
+--
+
+CREATE TABLE public.network_events (
+ id bigint NOT NULL,
+ peer_id bytea NOT NULL,
+ type integer NOT NULL,
+ "timestamp" bigint,
+ reason text,
+ duration_nsec bigint,
+ connect_attempted integer NOT NULL
+);
+
+
+ALTER TABLE public.network_events OWNER TO lightning;
+
+--
+-- Name: network_events_id_seq; Type: SEQUENCE; Schema: public; Owner: lightning
+--
+
+CREATE SEQUENCE public.network_events_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+ALTER SEQUENCE public.network_events_id_seq OWNER TO lightning;
+
+--
+-- Name: network_events_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: lightning
+--
+
+ALTER SEQUENCE public.network_events_id_seq OWNED BY public.network_events.id;
+
+
+--
+-- Name: offers; Type: TABLE; Schema: public; Owner: lightning
+--
+
+CREATE TABLE public.offers (
+ offer_id bytea NOT NULL,
+ bolt12 text,
+ label text,
+ status integer
+);
+
+
+ALTER TABLE public.offers OWNER TO lightning;
+
+--
+-- Name: outputs; Type: TABLE; Schema: public; Owner: lightning
+--
+
+CREATE TABLE public.outputs (
+ prev_out_tx bytea NOT NULL,
+ prev_out_index integer NOT NULL,
+ value bigint,
+ type integer,
+ status integer,
+ keyindex integer,
+ channel_id bigint,
+ peer_id bytea,
+ commitment_point bytea,
+ confirmation_height integer,
+ spend_height integer,
+ scriptpubkey bytea,
+ reserved_til integer,
+ option_anchor_outputs integer DEFAULT 0,
+ csv_lock integer DEFAULT 1,
+ is_in_coinbase integer DEFAULT 0
+);
+
+
+ALTER TABLE public.outputs OWNER TO lightning;
+
+--
+-- Name: payments; Type: TABLE; Schema: public; Owner: lightning
+--
+
+CREATE TABLE public.payments (
+ id bigint CONSTRAINT payments_id_not_null1 NOT NULL,
+ "timestamp" integer,
+ status integer,
+ payment_hash bytea,
+ destination bytea,
+ msatoshi bigint,
+ payment_preimage bytea,
+ path_secrets bytea,
+ route_nodes bytea,
+ route_channels bytea,
+ failonionreply bytea,
+ faildestperm integer,
+ failindex integer,
+ failcode integer,
+ failnode bytea,
+ failupdate bytea,
+ msatoshi_sent bigint,
+ faildetail text,
+ description text,
+ faildirection integer,
+ bolt11 text,
+ total_msat bigint,
+ partid bigint,
+ groupid bigint DEFAULT 0 NOT NULL,
+ local_offer_id bytea,
+ paydescription text,
+ completed_at integer,
+ failscid bigint,
+ local_invreq_id bytea,
+ updated_index bigint DEFAULT 0
+);
+
+
+ALTER TABLE public.payments OWNER TO lightning;
+
+--
+-- Name: payments_id_seq1; Type: SEQUENCE; Schema: public; Owner: lightning
+--
+
+CREATE SEQUENCE public.payments_id_seq1
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+ALTER SEQUENCE public.payments_id_seq1 OWNER TO lightning;
+
+--
+-- Name: payments_id_seq1; Type: SEQUENCE OWNED BY; Schema: public; Owner: lightning
+--
+
+ALTER SEQUENCE public.payments_id_seq1 OWNED BY public.payments.id;
+
+
+--
+-- Name: peers; Type: TABLE; Schema: public; Owner: lightning
+--
+
+CREATE TABLE public.peers (
+ id bigint NOT NULL,
+ node_id bytea,
+ address text,
+ feature_bits bytea,
+ last_known_address bytea
+);
+
+
+ALTER TABLE public.peers OWNER TO lightning;
+
+--
+-- Name: peers_id_seq; Type: SEQUENCE; Schema: public; Owner: lightning
+--
+
+CREATE SEQUENCE public.peers_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+ALTER SEQUENCE public.peers_id_seq OWNER TO lightning;
+
+--
+-- Name: peers_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: lightning
+--
+
+ALTER SEQUENCE public.peers_id_seq OWNED BY public.peers.id;
+
+
+--
+-- Name: penalty_bases; Type: TABLE; Schema: public; Owner: lightning
+--
+
+CREATE TABLE public.penalty_bases (
+ channel_id bigint NOT NULL,
+ commitnum bigint NOT NULL,
+ txid bytea,
+ outnum integer,
+ amount bigint
+);
+
+
+ALTER TABLE public.penalty_bases OWNER TO lightning;
+
+--
+-- Name: runes; Type: TABLE; Schema: public; Owner: lightning
+--
+
+CREATE TABLE public.runes (
+ id bigint NOT NULL,
+ rune text,
+ last_used_nsec bigint
+);
+
+
+ALTER TABLE public.runes OWNER TO lightning;
+
+--
+-- Name: runes_blacklist; Type: TABLE; Schema: public; Owner: lightning
+--
+
+CREATE TABLE public.runes_blacklist (
+ start_index bigint,
+ end_index bigint
+);
+
+
+ALTER TABLE public.runes_blacklist OWNER TO lightning;
+
+--
+-- Name: runes_id_seq; Type: SEQUENCE; Schema: public; Owner: lightning
+--
+
+CREATE SEQUENCE public.runes_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+ALTER SEQUENCE public.runes_id_seq OWNER TO lightning;
+
+--
+-- Name: runes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: lightning
+--
+
+ALTER SEQUENCE public.runes_id_seq OWNED BY public.runes.id;
+
+
+--
+-- Name: shachain_known; Type: TABLE; Schema: public; Owner: lightning
+--
+
+CREATE TABLE public.shachain_known (
+ shachain_id bigint NOT NULL,
+ pos integer NOT NULL,
+ idx bigint,
+ hash bytea
+);
+
+
+ALTER TABLE public.shachain_known OWNER TO lightning;
+
+--
+-- Name: shachains; Type: TABLE; Schema: public; Owner: lightning
+--
+
+CREATE TABLE public.shachains (
+ id bigint NOT NULL,
+ min_index bigint,
+ num_valid bigint
+);
+
+
+ALTER TABLE public.shachains OWNER TO lightning;
+
+--
+-- Name: shachains_id_seq; Type: SEQUENCE; Schema: public; Owner: lightning
+--
+
+CREATE SEQUENCE public.shachains_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+ALTER SEQUENCE public.shachains_id_seq OWNER TO lightning;
+
+--
+-- Name: shachains_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: lightning
+--
+
+ALTER SEQUENCE public.shachains_id_seq OWNED BY public.shachains.id;
+
+
+--
+-- Name: transaction_annotations; Type: TABLE; Schema: public; Owner: lightning
+--
+
+CREATE TABLE public.transaction_annotations (
+ txid bytea,
+ idx integer,
+ location integer,
+ type integer,
+ channel bigint
+);
+
+
+ALTER TABLE public.transaction_annotations OWNER TO lightning;
+
+--
+-- Name: transactions; Type: TABLE; Schema: public; Owner: lightning
+--
+
+CREATE TABLE public.transactions (
+ id bytea NOT NULL,
+ blockheight integer,
+ txindex integer,
+ rawtx bytea,
+ type bigint,
+ channel_id bigint
+);
+
+
+ALTER TABLE public.transactions OWNER TO lightning;
+
+--
+-- Name: utxoset; Type: TABLE; Schema: public; Owner: lightning
+--
+
+CREATE TABLE public.utxoset (
+ txid bytea NOT NULL,
+ outnum integer NOT NULL,
+ blockheight integer,
+ spendheight integer,
+ txindex integer,
+ scriptpubkey bytea,
+ satoshis bigint
+);
+
+
+ALTER TABLE public.utxoset OWNER TO lightning;
+
+--
+-- Name: vars; Type: TABLE; Schema: public; Owner: lightning
+--
+
+CREATE TABLE public.vars (
+ name character varying(32) NOT NULL,
+ val character varying(255),
+ intval integer,
+ blobval bytea
+);
+
+
+ALTER TABLE public.vars OWNER TO lightning;
+
+--
+-- Name: version; Type: TABLE; Schema: public; Owner: lightning
+--
+
+CREATE TABLE public.version (
+ version integer
+);
+
+
+ALTER TABLE public.version OWNER TO lightning;
+
+--
+-- Name: chain_moves id; Type: DEFAULT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.chain_moves ALTER COLUMN id SET DEFAULT nextval('public.chain_moves_id_seq'::regclass);
+
+
+--
+-- Name: channel_configs id; Type: DEFAULT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.channel_configs ALTER COLUMN id SET DEFAULT nextval('public.channel_configs_id_seq'::regclass);
+
+
+--
+-- Name: channel_funding_inflights channel_id; Type: DEFAULT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.channel_funding_inflights ALTER COLUMN channel_id SET DEFAULT nextval('public.channel_funding_inflights_channel_id_seq'::regclass);
+
+
+--
+-- Name: channel_htlcs id; Type: DEFAULT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.channel_htlcs ALTER COLUMN id SET DEFAULT nextval('public.channel_htlcs_id_seq'::regclass);
+
+
+--
+-- Name: channel_moves id; Type: DEFAULT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.channel_moves ALTER COLUMN id SET DEFAULT nextval('public.channel_moves_id_seq'::regclass);
+
+
+--
+-- Name: channels id; Type: DEFAULT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.channels ALTER COLUMN id SET DEFAULT nextval('public.channels_id_seq'::regclass);
+
+
+--
+-- Name: channeltxs id; Type: DEFAULT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.channeltxs ALTER COLUMN id SET DEFAULT nextval('public.channeltxs_id_seq'::regclass);
+
+
+--
+-- Name: invoices id; Type: DEFAULT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.invoices ALTER COLUMN id SET DEFAULT nextval('public.invoices_id_seq'::regclass);
+
+
+--
+-- Name: local_anchors channel_id; Type: DEFAULT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.local_anchors ALTER COLUMN channel_id SET DEFAULT nextval('public.local_anchors_channel_id_seq'::regclass);
+
+
+--
+-- Name: move_accounts id; Type: DEFAULT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.move_accounts ALTER COLUMN id SET DEFAULT nextval('public.move_accounts_id_seq'::regclass);
+
+
+--
+-- Name: network_events id; Type: DEFAULT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.network_events ALTER COLUMN id SET DEFAULT nextval('public.network_events_id_seq'::regclass);
+
+
+--
+-- Name: payments id; Type: DEFAULT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.payments ALTER COLUMN id SET DEFAULT nextval('public.payments_id_seq1'::regclass);
+
+
+--
+-- Name: peers id; Type: DEFAULT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.peers ALTER COLUMN id SET DEFAULT nextval('public.peers_id_seq'::regclass);
+
+
+--
+-- Name: runes id; Type: DEFAULT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.runes ALTER COLUMN id SET DEFAULT nextval('public.runes_id_seq'::regclass);
+
+
+--
+-- Name: shachains id; Type: DEFAULT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.shachains ALTER COLUMN id SET DEFAULT nextval('public.shachains_id_seq'::regclass);
+
+
+--
+-- Name: blocks blocks_height_key; Type: CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.blocks
+ ADD CONSTRAINT blocks_height_key UNIQUE (height);
+
+
+--
+-- Name: chain_moves chain_moves_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.chain_moves
+ ADD CONSTRAINT chain_moves_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: channel_blockheights channel_blockheights_channel_id_hstate_key; Type: CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.channel_blockheights
+ ADD CONSTRAINT channel_blockheights_channel_id_hstate_key UNIQUE (channel_id, hstate);
+
+
+--
+-- Name: channel_configs channel_configs_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.channel_configs
+ ADD CONSTRAINT channel_configs_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: channel_feerates channel_feerates_channel_id_hstate_key; Type: CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.channel_feerates
+ ADD CONSTRAINT channel_feerates_channel_id_hstate_key UNIQUE (channel_id, hstate);
+
+
+--
+-- Name: channel_funding_inflights channel_funding_inflights_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.channel_funding_inflights
+ ADD CONSTRAINT channel_funding_inflights_pkey PRIMARY KEY (channel_id, funding_tx_id);
+
+
+--
+-- Name: channel_htlcs channel_htlcs_channel_id_channel_htlc_id_direction_key; Type: CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.channel_htlcs
+ ADD CONSTRAINT channel_htlcs_channel_id_channel_htlc_id_direction_key UNIQUE (channel_id, channel_htlc_id, direction);
+
+
+--
+-- Name: channel_htlcs channel_htlcs_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.channel_htlcs
+ ADD CONSTRAINT channel_htlcs_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: channel_moves channel_moves_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.channel_moves
+ ADD CONSTRAINT channel_moves_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: channels channels_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.channels
+ ADD CONSTRAINT channels_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: channeltxs channeltxs_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.channeltxs
+ ADD CONSTRAINT channeltxs_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: datastore datastore_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.datastore
+ ADD CONSTRAINT datastore_pkey PRIMARY KEY (key);
+
+
+--
+-- Name: forwards forwards_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.forwards
+ ADD CONSTRAINT forwards_pkey PRIMARY KEY (in_channel_scid, in_htlc_id);
+
+
+--
+-- Name: invoice_fallbacks invoice_fallbacks_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.invoice_fallbacks
+ ADD CONSTRAINT invoice_fallbacks_pkey PRIMARY KEY (scriptpubkey);
+
+
+--
+-- Name: invoicerequests invoicerequests_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.invoicerequests
+ ADD CONSTRAINT invoicerequests_pkey PRIMARY KEY (invreq_id);
+
+
+--
+-- Name: invoices invoices_label_key; Type: CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.invoices
+ ADD CONSTRAINT invoices_label_key UNIQUE (label);
+
+
+--
+-- Name: invoices invoices_payment_hash_key; Type: CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.invoices
+ ADD CONSTRAINT invoices_payment_hash_key UNIQUE (payment_hash);
+
+
+--
+-- Name: invoices invoices_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.invoices
+ ADD CONSTRAINT invoices_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: move_accounts move_accounts_name_key; Type: CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.move_accounts
+ ADD CONSTRAINT move_accounts_name_key UNIQUE (name);
+
+
+--
+-- Name: move_accounts move_accounts_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.move_accounts
+ ADD CONSTRAINT move_accounts_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: network_events network_events_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.network_events
+ ADD CONSTRAINT network_events_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: offers offers_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.offers
+ ADD CONSTRAINT offers_pkey PRIMARY KEY (offer_id);
+
+
+--
+-- Name: outputs outputs_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.outputs
+ ADD CONSTRAINT outputs_pkey PRIMARY KEY (prev_out_tx, prev_out_index);
+
+
+--
+-- Name: payments payments_payment_hash_partid_groupid_key; Type: CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.payments
+ ADD CONSTRAINT payments_payment_hash_partid_groupid_key UNIQUE (payment_hash, partid, groupid);
+
+
+--
+-- Name: payments payments_pkey1; Type: CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.payments
+ ADD CONSTRAINT payments_pkey1 PRIMARY KEY (id);
+
+
+--
+-- Name: peers peers_node_id_key; Type: CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.peers
+ ADD CONSTRAINT peers_node_id_key UNIQUE (node_id);
+
+
+--
+-- Name: peers peers_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.peers
+ ADD CONSTRAINT peers_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: penalty_bases penalty_bases_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.penalty_bases
+ ADD CONSTRAINT penalty_bases_pkey PRIMARY KEY (channel_id, commitnum);
+
+
+--
+-- Name: runes runes_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.runes
+ ADD CONSTRAINT runes_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: shachain_known shachain_known_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.shachain_known
+ ADD CONSTRAINT shachain_known_pkey PRIMARY KEY (shachain_id, pos);
+
+
+--
+-- Name: shachains shachains_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.shachains
+ ADD CONSTRAINT shachains_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: transaction_annotations transaction_annotations_txid_idx_key; Type: CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.transaction_annotations
+ ADD CONSTRAINT transaction_annotations_txid_idx_key UNIQUE (txid, idx);
+
+
+--
+-- Name: transactions transactions_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.transactions
+ ADD CONSTRAINT transactions_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: utxoset utxoset_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.utxoset
+ ADD CONSTRAINT utxoset_pkey PRIMARY KEY (txid, outnum);
+
+
+--
+-- Name: vars vars_pkey; Type: CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.vars
+ ADD CONSTRAINT vars_pkey PRIMARY KEY (name);
+
+
+--
+-- Name: chain_moves_utxo_idx; Type: INDEX; Schema: public; Owner: lightning
+--
+
+CREATE INDEX chain_moves_utxo_idx ON public.chain_moves USING btree (utxo);
+
+
+--
+-- Name: channel_htlcs_speedup_unresolved_idx; Type: INDEX; Schema: public; Owner: lightning
+--
+
+CREATE INDEX channel_htlcs_speedup_unresolved_idx ON public.channel_htlcs USING btree (channel_id, direction) WHERE (hstate <> ALL (ARRAY[9, 19]));
+
+
+--
+-- Name: channel_htlcs_updated_idx; Type: INDEX; Schema: public; Owner: lightning
+--
+
+CREATE INDEX channel_htlcs_updated_idx ON public.channel_htlcs USING btree (updated_index);
+
+
+--
+-- Name: channel_idx; Type: INDEX; Schema: public; Owner: lightning
+--
+
+CREATE INDEX channel_idx ON public.htlc_sigs USING btree (channelid);
+
+
+--
+-- Name: channel_state_changes_channel_id; Type: INDEX; Schema: public; Owner: lightning
+--
+
+CREATE INDEX channel_state_changes_channel_id ON public.channel_state_changes USING btree (channel_id);
+
+
+--
+-- Name: forwards_created_idx; Type: INDEX; Schema: public; Owner: lightning
+--
+
+CREATE INDEX forwards_created_idx ON public.forwards USING btree (rowid);
+
+
+--
+-- Name: forwards_updated_idx; Type: INDEX; Schema: public; Owner: lightning
+--
+
+CREATE INDEX forwards_updated_idx ON public.forwards USING btree (updated_index);
+
+
+--
+-- Name: invoice_update_idx; Type: INDEX; Schema: public; Owner: lightning
+--
+
+CREATE INDEX invoice_update_idx ON public.invoices USING btree (updated_index);
+
+
+--
+-- Name: invoices_pay_index; Type: INDEX; Schema: public; Owner: lightning
+--
+
+CREATE UNIQUE INDEX invoices_pay_index ON public.invoices USING btree (pay_index);
+
+
+--
+-- Name: local_anchors_idx; Type: INDEX; Schema: public; Owner: lightning
+--
+
+CREATE INDEX local_anchors_idx ON public.local_anchors USING btree (channel_id);
+
+
+--
+-- Name: output_height_idx; Type: INDEX; Schema: public; Owner: lightning
+--
+
+CREATE INDEX output_height_idx ON public.outputs USING btree (confirmation_height, spend_height);
+
+
+--
+-- Name: payments_idx; Type: INDEX; Schema: public; Owner: lightning
+--
+
+CREATE INDEX payments_idx ON public.payments USING btree (payment_hash);
+
+
+--
+-- Name: payments_update_idx; Type: INDEX; Schema: public; Owner: lightning
+--
+
+CREATE INDEX payments_update_idx ON public.payments USING btree (updated_index);
+
+
+--
+-- Name: short_channel_id; Type: INDEX; Schema: public; Owner: lightning
+--
+
+CREATE INDEX short_channel_id ON public.utxoset USING btree (blockheight, txindex, outnum);
+
+
+--
+-- Name: utxoset_spend; Type: INDEX; Schema: public; Owner: lightning
+--
+
+CREATE INDEX utxoset_spend ON public.utxoset USING btree (spendheight);
+
+
+--
+-- Name: chain_moves chain_moves_account_channel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.chain_moves
+ ADD CONSTRAINT chain_moves_account_channel_id_fkey FOREIGN KEY (account_channel_id) REFERENCES public.channels(id);
+
+
+--
+-- Name: chain_moves chain_moves_account_nonchannel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.chain_moves
+ ADD CONSTRAINT chain_moves_account_nonchannel_id_fkey FOREIGN KEY (account_nonchannel_id) REFERENCES public.move_accounts(id);
+
+
+--
+-- Name: chain_moves chain_moves_originating_channel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.chain_moves
+ ADD CONSTRAINT chain_moves_originating_channel_id_fkey FOREIGN KEY (originating_channel_id) REFERENCES public.channels(id);
+
+
+--
+-- Name: chain_moves chain_moves_originating_nonchannel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.chain_moves
+ ADD CONSTRAINT chain_moves_originating_nonchannel_id_fkey FOREIGN KEY (originating_nonchannel_id) REFERENCES public.move_accounts(id);
+
+
+--
+-- Name: channel_blockheights channel_blockheights_channel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.channel_blockheights
+ ADD CONSTRAINT channel_blockheights_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE;
+
+
+--
+-- Name: channel_feerates channel_feerates_channel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.channel_feerates
+ ADD CONSTRAINT channel_feerates_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE;
+
+
+--
+-- Name: channel_funding_inflights channel_funding_inflights_channel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.channel_funding_inflights
+ ADD CONSTRAINT channel_funding_inflights_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE;
+
+
+--
+-- Name: channel_htlcs channel_htlcs_channel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.channel_htlcs
+ ADD CONSTRAINT channel_htlcs_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE;
+
+
+--
+-- Name: channel_moves channel_moves_account_channel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.channel_moves
+ ADD CONSTRAINT channel_moves_account_channel_id_fkey FOREIGN KEY (account_channel_id) REFERENCES public.channels(id);
+
+
+--
+-- Name: channel_moves channel_moves_account_nonchannel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.channel_moves
+ ADD CONSTRAINT channel_moves_account_nonchannel_id_fkey FOREIGN KEY (account_nonchannel_id) REFERENCES public.move_accounts(id);
+
+
+--
+-- Name: channel_state_changes channel_state_changes_channel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.channel_state_changes
+ ADD CONSTRAINT channel_state_changes_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE;
+
+
+--
+-- Name: channels channels_peer_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.channels
+ ADD CONSTRAINT channels_peer_id_fkey FOREIGN KEY (peer_id) REFERENCES public.peers(id) ON DELETE CASCADE;
+
+
+--
+-- Name: channeltxs channeltxs_blockheight_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.channeltxs
+ ADD CONSTRAINT channeltxs_blockheight_fkey FOREIGN KEY (blockheight) REFERENCES public.blocks(height) ON DELETE CASCADE;
+
+
+--
+-- Name: channeltxs channeltxs_channel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.channeltxs
+ ADD CONSTRAINT channeltxs_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE;
+
+
+--
+-- Name: channeltxs channeltxs_transaction_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.channeltxs
+ ADD CONSTRAINT channeltxs_transaction_id_fkey FOREIGN KEY (transaction_id) REFERENCES public.transactions(id) ON DELETE CASCADE;
+
+
+--
+-- Name: htlc_sigs htlc_sigs_channelid_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.htlc_sigs
+ ADD CONSTRAINT htlc_sigs_channelid_fkey FOREIGN KEY (channelid) REFERENCES public.channels(id) ON DELETE CASCADE;
+
+
+--
+-- Name: invoice_fallbacks invoice_fallbacks_invoice_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.invoice_fallbacks
+ ADD CONSTRAINT invoice_fallbacks_invoice_id_fkey FOREIGN KEY (invoice_id) REFERENCES public.invoices(id) ON DELETE CASCADE;
+
+
+--
+-- Name: invoices invoices_local_offer_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.invoices
+ ADD CONSTRAINT invoices_local_offer_id_fkey FOREIGN KEY (local_offer_id) REFERENCES public.offers(offer_id);
+
+
+--
+-- Name: local_anchors local_anchors_channel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.local_anchors
+ ADD CONSTRAINT local_anchors_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id);
+
+
+--
+-- Name: outputs outputs_confirmation_height_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.outputs
+ ADD CONSTRAINT outputs_confirmation_height_fkey FOREIGN KEY (confirmation_height) REFERENCES public.blocks(height) ON DELETE SET NULL;
+
+
+--
+-- Name: outputs outputs_spend_height_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.outputs
+ ADD CONSTRAINT outputs_spend_height_fkey FOREIGN KEY (spend_height) REFERENCES public.blocks(height) ON DELETE SET NULL;
+
+
+--
+-- Name: payments payments_local_invreq_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.payments
+ ADD CONSTRAINT payments_local_invreq_id_fkey FOREIGN KEY (local_invreq_id) REFERENCES public.invoicerequests(invreq_id);
+
+
+--
+-- Name: payments payments_local_offer_id_fkey1; Type: FK CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.payments
+ ADD CONSTRAINT payments_local_offer_id_fkey1 FOREIGN KEY (local_offer_id) REFERENCES public.offers(offer_id);
+
+
+--
+-- Name: penalty_bases penalty_bases_channel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.penalty_bases
+ ADD CONSTRAINT penalty_bases_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE;
+
+
+--
+-- Name: shachain_known shachain_known_shachain_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.shachain_known
+ ADD CONSTRAINT shachain_known_shachain_id_fkey FOREIGN KEY (shachain_id) REFERENCES public.shachains(id) ON DELETE CASCADE;
+
+
+--
+-- Name: transaction_annotations transaction_annotations_channel_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.transaction_annotations
+ ADD CONSTRAINT transaction_annotations_channel_fkey FOREIGN KEY (channel) REFERENCES public.channels(id);
+
+
+--
+-- Name: transactions transactions_blockheight_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.transactions
+ ADD CONSTRAINT transactions_blockheight_fkey FOREIGN KEY (blockheight) REFERENCES public.blocks(height) ON DELETE SET NULL;
+
+
+--
+-- Name: utxoset utxoset_blockheight_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.utxoset
+ ADD CONSTRAINT utxoset_blockheight_fkey FOREIGN KEY (blockheight) REFERENCES public.blocks(height) ON DELETE CASCADE;
+
+
+--
+-- Name: utxoset utxoset_spendheight_fkey; Type: FK CONSTRAINT; Schema: public; Owner: lightning
+--
+
+ALTER TABLE ONLY public.utxoset
+ ADD CONSTRAINT utxoset_spendheight_fkey FOREIGN KEY (spendheight) REFERENCES public.blocks(height) ON DELETE SET NULL;
+
+
+--
+-- PostgreSQL database dump complete
+--
+
+\unrestrict 3gmSHpmWoAvp3M9hYnbn0eAJg2qkrJcZzqafmBmvLGVxDFS2cVhidb6yKDBdS99
+
diff --git a/scripts/close-dead-channels.sh b/scripts/close-dead-channels.sh
new file mode 100755
index 00000000..dda1efb9
--- /dev/null
+++ b/scripts/close-dead-channels.sh
@@ -0,0 +1,276 @@
+#!/bin/bash
+#
+# Close the least-active Lightning channels that have recoverable local balance.
+#
+# Usage:
+# ./close-dead-channels.sh # dry-run (default)
+# ./close-dead-channels.sh --execute # actually close channels
+#
+# Options (set via env vars):
+# FUNDS_DIR - path to balance snapshots (default: ~/coinos-server/data/funds)
+# COUNT - number of channels to close (default: 10)
+# MIN_LOCAL_PCT - minimum local balance % to consider (default: 20)
+# MONTHS - how many months of history to analyze (default: 3)
+# CLOSE_TIMEOUT - seconds to wait for cooperative close (default: 1)
+# EXCLUDE - comma-separated SCIDs or alias substrings to skip
+
+set -euo pipefail
+
+FUNDS_DIR="${FUNDS_DIR:-$HOME/coinos-server/data/funds}"
+COUNT="${COUNT:-10}"
+MIN_LOCAL_PCT="${MIN_LOCAL_PCT:-20}"
+MONTHS="${MONTHS:-3}"
+CLOSE_TIMEOUT="${CLOSE_TIMEOUT:-1}"
+EXCLUDE="${EXCLUDE:-}"
+EXECUTE=false
+
+if [[ "${1:-}" == "--execute" ]]; then
+ EXECUTE=true
+fi
+
+python3 - "$FUNDS_DIR" "$COUNT" "$MIN_LOCAL_PCT" "$MONTHS" "$EXCLUDE" << 'PYEOF'
+import json, subprocess, os, re, sys
+from datetime import datetime, timedelta
+from collections import defaultdict
+
+FUNDS_DIR = sys.argv[1]
+COUNT = int(sys.argv[2])
+MIN_LOCAL_PCT = float(sys.argv[3])
+MONTHS = int(sys.argv[4])
+EXCLUDE = [x.strip().lower() for x in sys.argv[5].split(",") if x.strip()]
+
+def parse_date(fn):
+ fn = fn.replace('balances_', '')
+ m = re.match(r'(\d{2})-(\d{2})-(\d{2})-(\d{2}):(\d{2}):(\d{2})\.json', fn)
+ if not m:
+ return None
+ month, day, year, hour, minute, sec = [int(x) for x in m.groups()]
+ try:
+ return datetime(year + 2000, month, day, hour, minute, sec)
+ except:
+ return None
+
+def fmt(s):
+ if abs(s) >= 1_000_000:
+ return f"{s/1_000_000:.2f}M"
+ elif abs(s) >= 1_000:
+ return f"{s/1_000:.1f}k"
+ return str(s)
+
+# Sample balance files every ~4 hours over the lookback period
+files = sorted(os.listdir(FUNDS_DIR))
+cutoff = datetime.now() - timedelta(days=MONTHS * 30)
+recent = [(d, f) for f in files if (d := parse_date(f)) and d >= cutoff]
+recent.sort()
+
+sampled = []
+last = None
+for d, f in recent:
+ if last is None or (d - last).total_seconds() >= 4 * 3600:
+ sampled.append((d, f))
+ last = d
+
+print(f"Analyzed {len(sampled)} snapshots over {MONTHS} months\n")
+
+# Build movement history per channel
+channel_history = defaultdict(list)
+for d, f in sampled:
+ try:
+ with open(os.path.join(FUNDS_DIR, f)) as fh:
+ data = json.load(fh)
+ except:
+ continue
+ for ch in data.get("channels", []):
+ scid = ch.get("short_channel_id", "")
+ if scid:
+ channel_history[scid].append(
+ (d, ch["our_amount_msat"], ch["amount_msat"], ch.get("state", ""))
+ )
+
+# Get currently open channels
+result = subprocess.run(
+ ["docker", "exec", "cl", "lightning-cli", "listpeerchannels"],
+ capture_output=True, text=True, timeout=15,
+)
+peerchans = json.loads(result.stdout)
+
+# Resolve aliases
+aliases = {}
+def get_alias(peer_id):
+ if peer_id in aliases:
+ return aliases[peer_id]
+ try:
+ r = subprocess.run(
+ ["docker", "exec", "cl", "lightning-cli", "listnodes", peer_id],
+ capture_output=True, text=True, timeout=10,
+ )
+ nd = json.loads(r.stdout)
+ a = nd["nodes"][0].get("alias", peer_id[:20] + "...") if nd.get("nodes") else peer_id[:20] + "..."
+ except:
+ a = peer_id[:20] + "..."
+ aliases[peer_id] = a
+ return a
+
+# Score each open channel
+candidates = []
+for ch in peerchans.get("channels", []):
+ scid = ch.get("short_channel_id", "")
+ state = ch.get("state", "")
+ if not scid or state != "CHANNELD_NORMAL":
+ continue
+
+ capacity = ch.get("total_msat", 0) // 1000
+ local = ch.get("to_us_msat", 0) // 1000
+ if capacity == 0:
+ continue
+ local_pct = 100 * local / capacity
+ if local_pct < MIN_LOCAL_PCT:
+ continue
+
+ history = channel_history.get(scid, [])
+ normal = [(d, o, t) for d, o, t, s in history if s == "CHANNELD_NORMAL"]
+ movement = 0
+ if len(normal) >= 2:
+ for i in range(1, len(normal)):
+ movement += abs(normal[i][1] - normal[i - 1][1])
+
+ candidates.append({
+ "scid": scid,
+ "peer_id": ch.get("peer_id", ""),
+ "capacity": capacity,
+ "local": local,
+ "remote": capacity - local,
+ "local_pct": local_pct,
+ "movement": movement // 1000,
+ })
+
+candidates.sort(key=lambda x: x["movement"])
+
+# Apply exclusions and take bottom N
+selected = []
+for c in candidates:
+ if len(selected) >= COUNT:
+ break
+ alias = get_alias(c["peer_id"])
+ skip = False
+ for exc in EXCLUDE:
+ if exc in c["scid"].lower() or exc in alias.lower():
+ skip = True
+ break
+ if skip:
+ print(f" SKIP (excluded): {c['scid']} {alias}")
+ continue
+ c["alias"] = alias
+ selected.append(c)
+
+# Print results
+print(f"{'SCID':<18} {'Peer':<30} {'Capacity':>10} {'Local':>10} {'Remote':>10} {'Local%':>7} {'3mo Vol':>10}")
+print("-" * 105)
+for c in selected:
+ print(
+ f"{c['scid']:<18} {c['alias']:<30} {fmt(c['capacity']):>10} "
+ f"{fmt(c['local']):>10} {fmt(c['remote']):>10} {c['local_pct']:>6.1f}% "
+ f"{fmt(c['movement']):>10}"
+ )
+
+total_local = sum(c["local"] for c in selected)
+total_cap = sum(c["capacity"] for c in selected)
+print(f"\nRecoverable local: {fmt(total_local)} sats | Capacity freed: {fmt(total_cap)} sats")
+
+# Output SCIDs for the shell to close
+scid_list = " ".join(c["scid"] for c in selected)
+print(f"\nSCIDS={scid_list}")
+PYEOF
+
+# Parse SCIDs from python output
+SCIDS=$(python3 - "$FUNDS_DIR" "$COUNT" "$MIN_LOCAL_PCT" "$MONTHS" "$EXCLUDE" << 'PYEOF' 2>/dev/null | tail -1
+import json, subprocess, os, re, sys
+from datetime import datetime, timedelta
+from collections import defaultdict
+
+FUNDS_DIR = sys.argv[1]
+COUNT = int(sys.argv[2])
+MIN_LOCAL_PCT = float(sys.argv[3])
+MONTHS = int(sys.argv[4])
+EXCLUDE = [x.strip().lower() for x in sys.argv[5].split(",") if x.strip()]
+
+def parse_date(fn):
+ fn = fn.replace('balances_', '')
+ m = re.match(r'(\d{2})-(\d{2})-(\d{2})-(\d{2}):(\d{2}):(\d{2})\.json', fn)
+ if not m: return None
+ mo, d, y, h, mi, s = [int(x) for x in m.groups()]
+ try: return datetime(y+2000, mo, d, h, mi, s)
+ except: return None
+
+files = sorted(os.listdir(FUNDS_DIR))
+cutoff = datetime.now() - timedelta(days=MONTHS*30)
+recent = [(d, f) for f in files if (d := parse_date(f)) and d >= cutoff]
+recent.sort()
+sampled, last = [], None
+for d, f in recent:
+ if last is None or (d - last).total_seconds() >= 4*3600:
+ sampled.append((d, f)); last = d
+
+ch_hist = defaultdict(list)
+for d, f in sampled:
+ try:
+ with open(os.path.join(FUNDS_DIR, f)) as fh: data = json.load(fh)
+ except: continue
+ for ch in data.get("channels", []):
+ scid = ch.get("short_channel_id", "")
+ if scid: ch_hist[scid].append((d, ch["our_amount_msat"], ch["amount_msat"], ch.get("state","")))
+
+r = subprocess.run(["docker","exec","cl","lightning-cli","listpeerchannels"], capture_output=True, text=True, timeout=15)
+peerchans = json.loads(r.stdout)
+
+aliases = {}
+def get_alias(pid):
+ if pid in aliases: return aliases[pid]
+ try:
+ r2 = subprocess.run(["docker","exec","cl","lightning-cli","listnodes",pid], capture_output=True, text=True, timeout=10)
+ nd = json.loads(r2.stdout)
+ a = nd["nodes"][0].get("alias", pid[:20]) if nd.get("nodes") else pid[:20]
+ except: a = pid[:20]
+ aliases[pid] = a; return a
+
+cands = []
+for ch in peerchans.get("channels", []):
+ scid = ch.get("short_channel_id",""); state = ch.get("state","")
+ if not scid or state != "CHANNELD_NORMAL": continue
+ cap = ch.get("total_msat",0)//1000; loc = ch.get("to_us_msat",0)//1000
+ if cap == 0: continue
+ pct = 100*loc/cap
+ if pct < MIN_LOCAL_PCT: continue
+ hist = ch_hist.get(scid, [])
+ norm = [(d,o,t) for d,o,t,s in hist if s=="CHANNELD_NORMAL"]
+ mv = sum(abs(norm[i][1]-norm[i-1][1]) for i in range(1,len(norm)))//1000 if len(norm)>=2 else 0
+ cands.append({"scid":scid,"peer_id":ch.get("peer_id",""),"movement":mv})
+
+cands.sort(key=lambda x: x["movement"])
+sel = []
+for c in cands:
+ if len(sel) >= COUNT: break
+ alias = get_alias(c["peer_id"])
+ skip = any(e in c["scid"].lower() or e in alias.lower() for e in EXCLUDE)
+ if not skip: sel.append(c["scid"])
+
+print(" ".join(sel))
+PYEOF
+)
+
+if [[ "$EXECUTE" != "true" ]]; then
+ echo ""
+ echo "DRY RUN - to actually close, run:"
+ echo " $0 --execute"
+ exit 0
+fi
+
+echo ""
+echo "Closing channels..."
+for scid in $SCIDS; do
+ echo " Closing $scid ..."
+ docker exec cl lightning-cli close "$scid" "$CLOSE_TIMEOUT" 2>&1 | head -5
+ echo ""
+done
+
+echo "Done."
diff --git a/scripts/dedup-invoices.ts b/scripts/dedup-invoices.ts
new file mode 100644
index 00000000..9232566b
--- /dev/null
+++ b/scripts/dedup-invoices.ts
@@ -0,0 +1,86 @@
+#!/usr/bin/env bun
+// One-time cleanup: remove duplicate entries from every `<id>:invoices` list.
+//
+// Background: `generate()` used to `lPush` the invoice id onto the owner's
+// `:invoices` list every time it ran — including when regenerating an existing
+// invoice (adding a tip, webhook, etc.). That pushed the same id multiple times,
+// so a user's `/invoices` response contained duplicate records. The UI's keyed
+// `{#each}` then threw `each_key_duplicate` and rendered a blank page. The source
+// is fixed in lib/invoices.ts (only push when the id is brand new); this script
+// cleans the dupes that already accumulated.
+//
+// Dedup preserves the ORIGINAL position of each id. The list is newest-first
+// (lPush adds to the head), and re-pushes added newer copies at the head, so the
+// deepest (tail-most) occurrence is the original creation push — we keep that one.
+//
+// Read-only by default (dry run). Pass --apply to actually rewrite lists.
+// Usage: bun scripts/dedup-invoices.ts [--apply]
+
+import { createClient } from "redis";
+
+const apply = process.argv.includes("--apply");
+
+const db = createClient({
+ url: "redis://127.0.0.1:6379",
+ socket: { reconnectStrategy: () => false },
+});
+await db.connect();
+
+// Keep the LAST occurrence of each id (original creation order), preserving
+// the relative order of the kept entries.
+function dedupeKeepLast(list: string[]): string[] {
+ const seen = new Set<string>();
+ const out: string[] = [];
+ for (let i = list.length - 1; i >= 0; i--) {
+ const id = list[i];
+ if (seen.has(id)) continue;
+ seen.add(id);
+ out.push(id);
+ }
+ out.reverse();
+ return out;
+}
+
+let lists = 0;
+let listsWithDupes = 0;
+let entriesBefore = 0;
+let entriesRemoved = 0;
+const worst: { key: string; before: number; after: number }[] = [];
+
+for await (const k of db.scanIterator({ MATCH: "*:invoices", COUNT: 2000 })) {
+ const keys = Array.isArray(k) ? k : [k];
+ for (const key of keys) {
+ lists++;
+ const list = await db.lRange(key, 0, -1);
+ entriesBefore += list.length;
+ const deduped = dedupeKeepLast(list);
+ const removed = list.length - deduped.length;
+ if (removed === 0) continue;
+
+ listsWithDupes++;
+ entriesRemoved += removed;
+ worst.push({ key, before: list.length, after: deduped.length });
+
+ if (apply) {
+ // Rewrite atomically: DEL then RPUSH the deduped list (RPUSH keeps head->tail order).
+ const tx = db.multi();
+ tx.del(key);
+ tx.rPush(key, deduped);
+ await tx.exec();
+ }
+ }
+}
+
+worst.sort((a, b) => b.before - b.after - (a.before - a.after));
+
+console.log(`\n${apply ? "APPLIED" : "DRY RUN (use --apply to write)"}`);
+console.log(`lists scanned: ${lists}`);
+console.log(`lists with dupes: ${listsWithDupes}`);
+console.log(`entries before: ${entriesBefore}`);
+console.log(`duplicate entries: ${entriesRemoved}`);
+console.log(`\ntop 10 most-duplicated lists:`);
+for (const w of worst.slice(0, 10)) {
+ console.log(` ${w.key} ${w.before} -> ${w.after} (-${w.before - w.after})`);
+}
+
+await db.quit();
diff --git a/scripts/kvrocks-merge-lowmem.conf b/scripts/kvrocks-merge-lowmem.conf
new file mode 100644
index 00000000..bdfa641a
--- /dev/null
+++ b/scripts/kvrocks-merge-lowmem.conf
@@ -0,0 +1,19 @@
+bind 0.0.0.0
+port 6379
+workers 2
+timeout 0
+tcp-backlog 512
+daemonize no
+dir /data
+db-name db
+log-dir stdout
+redis-cursor-compatible no
+max-io-mb 100
+rocksdb.compression snappy
+rocksdb.block_cache_size 128
+rocksdb.metadata_block_cache_size 128
+rocksdb.subkey_block_cache_size 128
+rocksdb.write_buffer_size 32
+rocksdb.max_background_compactions 1
+rocksdb.max_background_flushes 1
+rocksdb.max_background_jobs 2
diff --git a/scripts/ln-liquid-arb.ts b/scripts/ln-liquid-arb.ts
new file mode 100644
index 00000000..02d0aa1e
--- /dev/null
+++ b/scripts/ln-liquid-arb.ts
@@ -0,0 +1,126 @@
+#!/usr/bin/env bun
+// Find users exhibiting the "lightning in, liquid out" pattern that drains the
+// liquid hot wallet (forcing the liquid threshold down). For each user in the
+// window, sum receives/sends per rail and surface those who take in meaningful
+// lightning and push out meaningful liquid.
+//
+// Usage: bun scripts/ln-liquid-arb.ts [--days N] [--min SATS] [--top N]
+
+import { createClient } from "redis";
+
+const argDays = Number(process.argv[process.argv.indexOf("--days") + 1]) || 14;
+const MIN = Number(process.argv[process.argv.indexOf("--min") + 1]) || 500_000;
+const TOP = Number(process.argv[process.argv.indexOf("--top") + 1]) || 30;
+const cutoff = Date.now() - argDays * 24 * 60 * 60 * 1000;
+
+const db = createClient({ url: "redis://127.0.0.1:6379", socket: { reconnectStrategy: () => false } });
+await db.connect();
+
+const fmt = (n: number) => n.toLocaleString("en-US");
+
+// uid -> username
+const uname = new Map<string, string>();
+async function loadNames() {
+ let batch: string[] = [];
+ const drain = async () => {
+ if (!batch.length) return;
+ const vals = await db.mGet(batch);
+ for (const v of vals) {
+ if (!v || v[0] !== "{") continue;
+ try { const u = JSON.parse(v); if (u.id && u.username) uname.set(u.id, u.username); } catch {}
+ }
+ batch = [];
+ };
+ for await (const k of db.scanIterator({ MATCH: "user:*", COUNT: 1000 })) {
+ batch.push(k as string);
+ if (batch.length >= 500) await drain();
+ }
+ await drain();
+}
+await loadNames();
+const nameOf = (uid: string) => uname.get(uid) || uid.slice(0, 8);
+
+// Per user per rail: { in, out } sats
+type Rail = { in: number; out: number; inN: number; outN: number };
+const RAILS = ["lightning", "liquid", "bitcoin", "internal", "bolt12", "fund"];
+const stats = new Map<string, Record<string, Rail>>();
+const ensure = (uid: string) => {
+ let s = stats.get(uid);
+ if (!s) {
+ s = {};
+ for (const r of RAILS) s[r] = { in: 0, out: 0, inN: 0, outN: 0 };
+ stats.set(uid, s);
+ }
+ return s;
+};
+
+let scanned = 0;
+let batch: string[] = [];
+const drainP = async () => {
+ if (!batch.length) return;
+ const vals = await db.mGet(batch);
+ for (const v of vals) {
+ if (!v || v[0] !== "{") continue;
+ let p: any; try { p = JSON.parse(v); } catch { continue; }
+ if (!p.created || p.created < cutoff || typeof p.amount !== "number" || !p.uid) continue;
+ const rail = p.type;
+ if (!RAILS.includes(rail)) continue;
+ const s = ensure(p.uid)[rail];
+ if (p.amount >= 0) { s.in += p.amount; s.inN++; }
+ else { s.out += Math.abs(p.amount); s.outN++; }
+ }
+ batch = [];
+};
+for await (const k of db.scanIterator({ MATCH: "payment:*", COUNT: 2000 })) {
+ scanned++;
+ batch.push(k as string);
+ if (batch.length >= 1000) await drainP();
+}
+await drainP();
+
+// The arb pattern: significant lightning IN and significant liquid OUT.
+const flagged = [...stats.entries()]
+ .map(([uid, s]) => ({
+ uid,
+ lnIn: s.lightning.in,
+ lqOut: s.liquid.out,
+ bolt12In: s.bolt12.in,
+ arb: Math.min(s.lightning.in + s.bolt12.in, s.liquid.out), // overlap = the through-flow
+ s,
+ }))
+ .filter((x) => x.lqOut >= MIN && x.lnIn + x.bolt12In >= MIN)
+ .sort((a, b) => b.arb - a.arb)
+ .slice(0, TOP);
+
+console.log("═".repeat(72));
+console.log(` LIGHTNING-IN / LIQUID-OUT pattern — last ${argDays} days (min ${fmt(MIN)} sats each side)`);
+console.log("═".repeat(72));
+console.log(` walked ${fmt(scanned)} payment keys\n`);
+
+if (!flagged.length) {
+ console.log(" No users matched the pattern at this threshold.");
+} else {
+ console.log(" user ln_in bolt12_in liquid_out through-flow");
+ for (const x of flagged) {
+ console.log(
+ ` ${nameOf(x.uid).padEnd(20)} ${fmt(x.lnIn).padStart(11)} ${fmt(x.bolt12In).padStart(11)} ${fmt(x.lqOut).padStart(13)} ${fmt(x.arb).padStart(13)}`,
+ );
+ }
+ const totalThrough = flagged.reduce((s, x) => s + x.arb, 0);
+ console.log(`\n Combined through-flow (ln/bolt12 in -> liquid out): ${fmt(totalThrough)} sats`);
+}
+
+// Also: who are the biggest NET liquid drainers regardless of funding source?
+const netLiquid = [...stats.entries()]
+ .map(([uid, s]) => ({ uid, net: s.liquid.out - s.liquid.in, out: s.liquid.out }))
+ .filter((x) => x.net >= MIN)
+ .sort((a, b) => b.net - a.net)
+ .slice(0, 15);
+console.log("\n" + "─".repeat(72));
+console.log(" TOP NET LIQUID DRAINERS (liquid_out - liquid_in, any funding source)");
+console.log("─".repeat(72));
+for (const x of netLiquid) {
+ console.log(` ${nameOf(x.uid).padEnd(20)} net out ${fmt(x.net).padStart(13)} (gross out ${fmt(x.out)})`);
+}
+console.log("═".repeat(72));
+process.exit(0);
diff --git a/scripts/merge-kvrocks-archives.ts b/scripts/merge-kvrocks-archives.ts
new file mode 100644
index 00000000..54ea178d
--- /dev/null
+++ b/scripts/merge-kvrocks-archives.ts
@@ -0,0 +1,321 @@
+#!/usr/bin/env bun
+
+// Overlay one Redis/Kvrocks archive onto a base archive without materializing
+// keys in memory. The source wins on collisions. SCAN is intentionally sent as
+// a raw command because Kvrocks cursors can exceed JavaScript's safe integer
+// range and node-redis' high-level scan helper rounds them.
+
+import { createClient, type RedisClientType } from "redis";
+
+const valueOption = (name: string) =>
+ process.argv
+ .find((arg) => arg.startsWith(`--${name}=`))
+ ?.slice(name.length + 3);
+const hasFlag = (name: string) => process.argv.includes(`--${name}`);
+const positiveInteger = (name: string, fallback: number) => {
+ const parsed = Number(valueOption(name) || fallback);
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) {
+ throw new Error(`--${name} must be a positive integer`);
+ }
+ return parsed;
+};
+
+const sourceUrl = valueOption("source-url");
+const baseUrl = valueOption("base-url");
+const targetUrl = valueOption("target-url");
+const verifyUrl = valueOption("verify-url");
+const apply = hasFlag("apply");
+const skipEqual = hasFlag("skip-equal");
+const countTypes = hasFlag("count-types");
+const batchSize = positiveInteger("batch", 512);
+const concurrency = positiveInteger("concurrency", 32);
+const progressEvery = positiveInteger("progress", 100_000);
+
+if (!sourceUrl || (apply && !targetUrl)) {
+ console.error(
+ "usage: bun scripts/merge-kvrocks-archives.ts " +
+ "--source-url=redis://127.0.0.1:6402 " +
+ "[--base-url=redis://127.0.0.1:6401] " +
+ "[--target-url=redis://127.0.0.1:6403 --apply] " +
+ "[--batch=512] [--concurrency=32]",
+ );
+ process.exit(2);
+}
+if (apply && (sourceUrl === targetUrl || baseUrl === targetUrl)) {
+ throw new Error("The target must be distinct from the source and base");
+}
+
+const makeClient = (url: string) =>
+ createClient({
+ url,
+ socket: { reconnectStrategy: () => false },
+ });
+const source = makeClient(sourceUrl);
+const base = baseUrl ? makeClient(baseUrl) : null;
+const target = targetUrl ? makeClient(targetUrl) : null;
+const verifier = verifyUrl ? makeClient(verifyUrl) : null;
+const clients = [source, base, target, verifier].filter(
+ Boolean,
+) as RedisClientType[];
+
+const concurrentMap = async <T>(
+ items: T[],
+ limit: number,
+ fn: (item: T) => Promise<void>,
+) => {
+ let next = 0;
+ const workers = Array.from(
+ { length: Math.min(limit, items.length) },
+ async () => {
+ while (next < items.length) {
+ const index = next++;
+ await fn(items[index]);
+ }
+ },
+ );
+ await Promise.all(workers);
+};
+
+const valuesMatch = async (
+ left: RedisClientType,
+ right: RedisClientType,
+ key: string,
+ type: string,
+) => {
+ if ((await right.type(key)) !== type) return false;
+ if (type === "string") {
+ const [leftValue, rightValue] = await Promise.all([
+ left.sendCommand<Buffer | null>(["GET", key], { returnBuffers: true }),
+ right.sendCommand<Buffer | null>(["GET", key], { returnBuffers: true }),
+ ]);
+ return (
+ leftValue !== null &&
+ rightValue !== null &&
+ Buffer.from(leftValue).equals(Buffer.from(rightValue))
+ );
+ }
+ if (type === "list") {
+ const [leftValues, rightValues] = await Promise.all([
+ left.sendCommand<Buffer[]>(["LRANGE", key, "0", "-1"], {
+ returnBuffers: true,
+ }),
+ right.sendCommand<Buffer[]>(["LRANGE", key, "0", "-1"], {
+ returnBuffers: true,
+ }),
+ ]);
+ return (
+ leftValues.length === rightValues.length &&
+ leftValues.every((value, index) =>
+ Buffer.from(value).equals(Buffer.from(rightValues[index])),
+ )
+ );
+ }
+ return false;
+};
+
+let scanned = 0;
+let overlaps = 0;
+let restored = 0;
+let skippedEqual = 0;
+let expiredDuringRead = 0;
+const typeCounts: Record<string, number> = {};
+let verified = 0;
+const verificationMismatches: string[] = [];
+let cursor = "0";
+let nextProgress = progressEvery;
+const startedAt = Date.now();
+
+try {
+ await Promise.all(clients.map((client) => client.connect()));
+
+ do {
+ const reply = await source.sendCommand<[string, string[]]>([
+ "SCAN",
+ cursor,
+ "COUNT",
+ String(batchSize),
+ ]);
+ cursor = String(reply[0]);
+ const keys = reply[1];
+ scanned += keys.length;
+
+ if (countTypes) {
+ await concurrentMap(keys, concurrency, async (key) => {
+ const type = await source.type(key);
+ typeCounts[type] = (typeCounts[type] || 0) + 1;
+ });
+ }
+
+ if (base) {
+ await concurrentMap(keys, concurrency, async (key) => {
+ if (await base.exists(key)) overlaps++;
+ });
+ }
+
+ if (verifier) {
+ await concurrentMap(keys, concurrency, async (key) => {
+ const [sourceType, verifyType] = await Promise.all([
+ source.type(key),
+ verifier.type(key),
+ ]);
+ if (sourceType !== verifyType) {
+ if (verificationMismatches.length < 20) {
+ verificationMismatches.push(
+ `${key}: type ${sourceType} != ${verifyType}`,
+ );
+ }
+ return;
+ }
+
+ if (sourceType === "string") {
+ const [sourceValue, verifyValue] = await Promise.all([
+ source.sendCommand<Buffer | null>(["GET", key], {
+ returnBuffers: true,
+ }),
+ verifier.sendCommand<Buffer | null>(["GET", key], {
+ returnBuffers: true,
+ }),
+ ]);
+ if (
+ sourceValue === null ||
+ verifyValue === null ||
+ !Buffer.from(sourceValue).equals(Buffer.from(verifyValue))
+ ) {
+ if (verificationMismatches.length < 20) {
+ verificationMismatches.push(`${key}: string value differs`);
+ }
+ return;
+ }
+ } else if (sourceType === "list") {
+ const [sourceValues, verifyValues] = await Promise.all([
+ source.sendCommand<Buffer[]>(["LRANGE", key, "0", "-1"], {
+ returnBuffers: true,
+ }),
+ verifier.sendCommand<Buffer[]>(["LRANGE", key, "0", "-1"], {
+ returnBuffers: true,
+ }),
+ ]);
+ if (
+ sourceValues.length !== verifyValues.length ||
+ sourceValues.some(
+ (value, index) =>
+ !Buffer.from(value).equals(Buffer.from(verifyValues[index])),
+ )
+ ) {
+ if (verificationMismatches.length < 20) {
+ verificationMismatches.push(`${key}: list value differs`);
+ }
+ return;
+ }
+ } else {
+ if (verificationMismatches.length < 20) {
+ verificationMismatches.push(
+ `${key}: verification unsupported for ${sourceType}`,
+ );
+ }
+ return;
+ }
+ verified++;
+ });
+ }
+
+ if (apply && target) {
+ await concurrentMap(keys, concurrency, async (key) => {
+ const [type, ttlRaw] = await Promise.all([
+ source.type(key),
+ source.sendCommand<string>(["PTTL", key]),
+ ]);
+ const ttl = Number(ttlRaw);
+ if (type === "none" || ttl === -2) {
+ expiredDuringRead++;
+ return;
+ }
+
+ if (skipEqual && (await valuesMatch(source, target, key, type))) {
+ const targetTtl = await target.pTTL(key);
+ const ttlMatches =
+ (ttl === -1 && targetTtl === -1) ||
+ (ttl > 0 && targetTtl > 0 && Math.abs(ttl - targetTtl) < 10_000);
+ if (ttlMatches) {
+ skippedEqual++;
+ return;
+ }
+ }
+
+ if (type === "string") {
+ const value = await source.sendCommand<Buffer | null>(["GET", key], {
+ returnBuffers: true,
+ });
+ if (value === null) {
+ expiredDuringRead++;
+ return;
+ }
+ await target.sendCommand(["SET", key, value]);
+ } else if (type === "list") {
+ await target.del(key);
+ let offset = 0;
+ while (true) {
+ const values = await source.sendCommand<Buffer[]>(
+ ["LRANGE", key, String(offset), String(offset + batchSize - 1)],
+ { returnBuffers: true },
+ );
+ if (!values.length) break;
+ await target.sendCommand(["RPUSH", key, ...values]);
+ offset += values.length;
+ if (values.length < batchSize) break;
+ }
+ } else {
+ throw new Error(`Unsupported source type ${type} for key ${key}`);
+ }
+
+ if (ttl > 0) await target.pExpire(key, ttl);
+ restored++;
+ });
+ }
+
+ if (scanned >= nextProgress) {
+ console.error(
+ JSON.stringify({
+ scanned,
+ overlaps,
+ restored,
+ skippedEqual,
+ cursor,
+ elapsedSeconds: Math.round((Date.now() - startedAt) / 1000),
+ }),
+ );
+ nextProgress += progressEvery;
+ }
+ } while (cursor !== "0");
+
+ console.log(
+ JSON.stringify(
+ {
+ mode: apply ? "apply" : "audit",
+ sourceUrl,
+ baseUrl: baseUrl || null,
+ targetUrl: targetUrl || null,
+ sourceKeysScanned: scanned,
+ overlappingBaseKeys: base ? overlaps : null,
+ sourceOnlyKeys: base ? scanned - overlaps : null,
+ restored: apply ? restored : null,
+ skippedEqual: apply ? skippedEqual : null,
+ expiredDuringRead,
+ typeCounts: countTypes ? typeCounts : null,
+ verifyUrl: verifyUrl || null,
+ verified: verifier ? verified : null,
+ verificationMismatches: verifier ? verificationMismatches : null,
+ elapsedSeconds: Math.round((Date.now() - startedAt) / 1000),
+ },
+ null,
+ 2,
+ ),
+ );
+ if (verificationMismatches.length) process.exitCode = 1;
+} finally {
+ await Promise.all(
+ clients.map(async (client) => {
+ if (client.isOpen) await client.quit();
+ }),
+ );
+}
diff --git a/scripts/migrate.py b/scripts/migrate.py
new file mode 100644
index 00000000..de7ff922
--- /dev/null
+++ b/scripts/migrate.py
@@ -0,0 +1,74 @@
+#!/usr/bin/env python3
+"""Migrate CLN SQLite database to Postgres.
+
+Reads from /tmp/lightningd.sqlite3, outputs SQL to stdout.
+Run inside the pgcl container:
+ python3 /tmp/migrate.py | psql -U lightning -d lightning
+"""
+import sqlite3
+import subprocess
+import sys
+
+sq = sqlite3.connect("/tmp/lightningd.sqlite3")
+sq.row_factory = sqlite3.Row
+
+# Get Postgres tables
+r = subprocess.run(
+ ["psql", "-U", "lightning", "-d", "lightning", "-tAc",
+ "SELECT tablename FROM pg_tables WHERE schemaname='public';"],
+ capture_output=True, text=True
+)
+pg_tables = set(r.stdout.strip().split("\n"))
+
+# Get SQLite tables
+sq_tables = [row[0] for row in sq.execute(
+ "SELECT name FROM sqlite_master WHERE type='table'").fetchall()]
+
+sql_parts = [
+ "BEGIN;",
+ "SET CONSTRAINTS ALL DEFERRED;",
+]
+total = 0
+
+# Insert tables with foreign key targets first
+priority = ["version", "blocks", "shachains", "peers", "channels",
+ "channel_configs", "offers", "invoicerequests", "move_accounts"]
+ordered = priority + [t for t in sq_tables if t not in priority]
+
+for tbl in ordered:
+ if tbl not in pg_tables:
+ print(f" SKIP {tbl}", file=sys.stderr)
+ continue
+
+ rows = sq.execute(f'SELECT * FROM [{tbl}]').fetchall()
+ if not rows:
+ print(f" {tbl}: 0 rows", file=sys.stderr)
+ continue
+
+ cols = rows[0].keys()
+ col_list = ", ".join(f'"{c}"' for c in cols)
+ count = 0
+
+ for row in rows:
+ values = []
+ for col in cols:
+ val = row[col]
+ if val is None:
+ values.append("NULL")
+ elif isinstance(val, bytes):
+ values.append(f"'\\x{val.hex()}'")
+ elif isinstance(val, (int, float)):
+ values.append(str(val))
+ else:
+ escaped = str(val).replace("'", "''")
+ values.append(f"'{escaped}'")
+ val_list = ", ".join(values)
+ sql_parts.append(f'INSERT INTO "{tbl}" ({col_list}) VALUES ({val_list});')
+ count += 1
+
+ total += count
+ print(f" {tbl}: {count} rows", file=sys.stderr)
+
+sql_parts.append("COMMIT;")
+print(f"\nTotal: {total} rows to migrate", file=sys.stderr)
+print("\n".join(sql_parts))
diff --git a/scripts/monitor-ln-socket.sh b/scripts/monitor-ln-socket.sh
new file mode 100755
index 00000000..66841f40
--- /dev/null
+++ b/scripts/monitor-ln-socket.sh
@@ -0,0 +1,90 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SOCKET_DIR="/home/adam/coinos-server/data/sockets"
+SOCKET_MAIN="$SOCKET_DIR/lightning-rpc"
+SOCKET_LISTEN="$SOCKET_DIR/lightning-rpc-listen"
+REMOTE_SOCKET="/home/adam/coinos-server/data/lightning/bitcoin/lightning-rpc"
+REMOTE_HOST="desk"
+
+log() {
+ echo "$(date '+%Y-%m-%d %H:%M:%S') [ln-socket-monitor] $*"
+}
+
+# Find SSH pids that are forwarding our sockets
+pid_main=$(pgrep -f "ssh.*-L ${SOCKET_MAIN}:${REMOTE_SOCKET} ${REMOTE_HOST}" 2>/dev/null || true)
+pid_listen=$(pgrep -f "ssh.*-L ${SOCKET_LISTEN}:${REMOTE_SOCKET} ${REMOTE_HOST}" 2>/dev/null || true)
+
+# Check if both processes are alive and both sockets exist
+if [[ -n "$pid_main" && -n "$pid_listen" && -S "$SOCKET_MAIN" && -S "$SOCKET_LISTEN" ]]; then
+ exit 0
+fi
+
+# Something is wrong - log what we found
+if [[ -z "$pid_main" ]]; then
+ log "main SSH forward is dead"
+elif [[ ! -S "$SOCKET_MAIN" ]]; then
+ log "main socket file missing"
+fi
+
+if [[ -z "$pid_listen" ]]; then
+ log "listen SSH forward is dead"
+elif [[ ! -S "$SOCKET_LISTEN" ]]; then
+ log "listen socket file missing"
+fi
+
+# Kill any remaining SSH forwards for our sockets
+for pid in $pid_main $pid_listen; do
+ if [[ -n "$pid" ]]; then
+ log "killing stale SSH forward (pid $pid)"
+ kill "$pid" 2>/dev/null || true
+ fi
+done
+
+# Brief wait for processes to exit
+sleep 1
+
+# Clean up socket files
+for sock in "$SOCKET_MAIN" "$SOCKET_LISTEN"; do
+ if [[ -e "$sock" ]]; then
+ log "removing stale socket $sock"
+ rm -f "$sock"
+ fi
+done
+
+# Ensure socket directory exists
+mkdir -p "$SOCKET_DIR"
+
+# Re-establish both SSH forwards
+log "starting main SSH forward"
+ssh -nNT -o ExitOnForwardFailure=yes \
+ -L "${SOCKET_MAIN}:${REMOTE_SOCKET}" "$REMOTE_HOST" &
+
+log "starting listen SSH forward"
+ssh -nNT -o ExitOnForwardFailure=yes \
+ -L "${SOCKET_LISTEN}:${REMOTE_SOCKET}" "$REMOTE_HOST" &
+
+# Wait briefly for sockets to appear
+for i in 1 2 3 4 5; do
+ if [[ -S "$SOCKET_MAIN" && -S "$SOCKET_LISTEN" ]]; then
+ break
+ fi
+ sleep 1
+done
+
+# Set permissions on sockets
+if [[ -S "$SOCKET_MAIN" ]]; then
+ chmod 777 "$SOCKET_MAIN"
+ log "set permissions on $SOCKET_MAIN"
+else
+ log "WARNING: $SOCKET_MAIN did not appear after 5s"
+fi
+
+if [[ -S "$SOCKET_LISTEN" ]]; then
+ chmod 777 "$SOCKET_LISTEN"
+ log "set permissions on $SOCKET_LISTEN"
+else
+ log "WARNING: $SOCKET_LISTEN did not appear after 5s"
+fi
+
+log "SSH forwards re-established"
diff --git a/scripts/pay-hard.ts b/scripts/pay-hard.ts
new file mode 100644
index 00000000..e97b3a20
--- /dev/null
+++ b/scripts/pay-hard.ts
@@ -0,0 +1,159 @@
+// pay-hard.ts — a "stubborn" Lightning payer for hard destinations.
+//
+// Motivation (fixedfloat swap, 2026-08-27): xpay/renepay are cost-first. To a
+// well-connected but inbound-scarce node (an exchange), the CHEAPEST paths are
+// exactly the depleted ones, so cost-optimizing payers keep failing on them —
+// and after many failures CLN's askrene "learned capacity" turns pessimistic
+// and steers them away from the ONE path that actually has room. The move that
+// works is liquidity DISCOVERY, orthogonal to cost: enumerate the destination's
+// peers, keep the ones WE can reach (bonus: ones we already channel), and probe
+// each `us -> peer -> dest` leg for inbound room at the real amount. Then force
+// the payment through the peer that has room.
+//
+// Strategy:
+// 1. Preflight (decode, not-expired, not already-paid, nothing pending).
+// 2. Try xpay normally (fast path — covers ~all payments).
+// 3. On failure to a hint-less destination: rank the destination's peers
+// (ones we channel with >= amount outbound first), and for each, build a
+// route FORCED through peer->dest (getroute excluding all other dest
+// channels), with the CORRECT final CLTV from the invoice, then PROBE it
+// with a random hash. The first path that reaches the destination gets the
+// real payment.
+//
+// Safety: probe (unclaimable random hash) before every real send; refuse to
+// start if a payment is already pending; if a real send doesn't resolve
+// cleanly (still pending), STOP rather than risk a second in-flight payment.
+//
+// Usage:
+// bun pay-hard.ts <bolt11> [--node=cl|bcln] [--maxfee=<sat>] [--dry]
+// [--skip-xpay] [--max-peers=N]
+// --node which node pays (cl = local main node; bcln = boltz node on cs). default cl
+// --maxfee fee ceiling in sat (default 5000)
+// --dry find a working path and stop (no real payment)
+// --skip-xpay go straight to the peer-force strategy
+// --max-peers how many candidate peers to probe (default 12)
+import { execSync } from "child_process";
+import { writeFileSync } from "fs";
+import { randomBytes } from "crypto";
+
+const argv = process.argv.slice(2);
+const inv = argv.find((a) => !a.startsWith("--"));
+const opt = (k: string, d: string) => { const a = argv.find((x) => x.startsWith(`--${k}=`)); return a ? a.split("=").slice(1).join("=") : d; };
+const flag = (k: string) => argv.includes(`--${k}`);
+const nodeName = opt("node", "cl");
+const maxfeeSat = Number(opt("maxfee", "5000"));
+const maxPeers = Number(opt("max-peers", "12"));
+const DRY = flag("dry");
+const skipXpay = flag("skip-xpay");
+
+if (!inv) {
+ console.error("usage: bun pay-hard.ts <bolt11> [--node=cl|bcln] [--maxfee=sat] [--dry] [--skip-xpay] [--max-peers=N]");
+ process.exit(1);
+}
+
+// Node adapters. cl runs locally; bcln runs in a container on the `cs` host.
+const NODES: Record<string, { remote: boolean; prefix: string }> = {
+ cl: { remote: false, prefix: "docker exec cl lightning-cli" },
+ bcln: { remote: true, prefix: "docker exec bcln lightning-cli --lightning-dir=/app/lightning" },
+};
+const node = NODES[nodeName];
+if (!node) { console.error("unknown node:", nodeName, "(use cl or bcln)"); process.exit(1); }
+
+const sh = (c: string) => { try { return execSync(c, { encoding: "utf8", maxBuffer: 1e8 }); } catch (e: any) { return (e.stdout || "") + (e.stderr || ""); } };
+// run a shell command ON the node's host (local, or via ssh to cs). Single-quote
+// for ssh so $(cat ...) is expanded remotely, not locally. Our route/exclude
+// JSON never contains single quotes, so this quoting is safe.
+const runOn = (cmd: string) => node.remote ? sh(`ssh -o ConnectTimeout=25 cs '${cmd}'`) : sh(cmd);
+const cli = (sub: string) => runOn(`${node.prefix} ${sub}`);
+const cliJSON = (sub: string): any => { const o = cli(sub); try { return JSON.parse(o); } catch { return { __raw: o }; } };
+const putFile = (path: string, content: string) => { writeFileSync(path, content); if (node.remote) sh(`scp -q ${path} cs:${path}`); };
+const log = (...a: any[]) => console.log(...a);
+const sat = (msat: number) => Math.round(msat / 1000);
+
+// waitsendpay that returns the parsed result whether it succeeded or failed.
+const waitFor = (hash: string, secs = 60): any => { const o = cli(`waitsendpay ${hash} ${secs}`); try { return JSON.parse(o); } catch { return { __raw: o }; } };
+
+// ---- 1. preflight ----
+const dec = cliJSON(`decode ${inv}`);
+if (!dec || dec.valid === false || !dec.payment_hash) { log("invalid/undecodable invoice:", JSON.stringify(dec).slice(0, 200)); process.exit(1); }
+const amount = Number(dec.amount_msat);
+const hash = dec.payment_hash;
+const secret = dec.payment_secret;
+const finalCltv = Number(dec.min_final_cltv_expiry || 18);
+const dest = dec.payee;
+const hints = (dec.routes || []).length;
+if (!amount) { log("amountless invoice not supported"); process.exit(1); }
+log(`invoice: ${sat(amount)} sat -> ${String(dest).slice(0, 16)}… | min_final_cltv ${finalCltv} | hints ${hints} | node ${nodeName}`);
+
+const lp = cliJSON(`listpays bolt11=${inv}`);
+const alreadyDone = (lp.pays || []).find((p: any) => p.status === "complete");
+if (alreadyDone) { log("✅ already paid; preimage:", alreadyDone.preimage); process.exit(0); }
+if ((lp.pays || []).some((p: any) => p.status === "pending")) { log("⚠️ a payment for this invoice is already PENDING — not starting another. Wait for it to resolve."); process.exit(1); }
+
+// ---- 2. xpay fast path ----
+if (!skipXpay) {
+ log(`→ trying xpay (maxfee ${maxfeeSat} sat)…`);
+ const x = cliJSON(`-k xpay invstring=${inv} maxfee=${maxfeeSat * 1000}`);
+ if (x && x.payment_preimage) { log(`✅ xpay succeeded! preimage: ${x.payment_preimage} | fee ${sat(Number(x.amount_sent_msat) - amount)} sat`); process.exit(0); }
+ log(" xpay did not complete:", String(x.__raw || x.message || JSON.stringify(x)).replace(/\s+/g, " ").slice(0, 160));
+}
+
+if (hints > 0) { log("invoice carries route hints — the peer-force strategy targets the public destination node and can't honor private hints. Rely on xpay/renepay here."); process.exit(1); }
+
+// ---- 3. peer-force liquidity discovery ----
+log("→ peer-force: probing the destination's reachable peers for inbound room…");
+const destChans = (cliJSON(`listchannels destination=${dest}`).channels) || [];
+if (!destChans.length) { log("destination advertises no public channels — cannot force-route."); process.exit(2); }
+const bySource: Record<string, { scids: string[]; cap: number }> = {};
+for (const c of destChans) { (bySource[c.source] ||= { scids: [], cap: 0 }); bySource[c.source].scids.push(c.short_channel_id); bySource[c.source].cap = Math.max(bySource[c.source].cap, Number(c.amount_msat || 0)); }
+const allScids: string[] = destChans.map((c: any) => c.short_channel_id);
+
+const ours: Record<string, number> = {};
+for (const c of ((cliJSON(`listpeerchannels`).channels) || [])) if (c.state === "CHANNELD_NORMAL") ours[c.peer_id] = Number(c.spendable_msat || 0);
+
+// rank: peers we already channel with enough outbound first, then by our
+// outbound to them, then by their channel capacity to the destination.
+const candidates = Object.entries(bySource)
+ .map(([id, info]) => ({ id, scids: info.scids, cap: info.cap, our: ours[id] || 0 }))
+ .sort((a, b) => (b.our >= amount ? 1 : 0) - (a.our >= amount ? 1 : 0) || b.our - a.our || b.cap - a.cap)
+ .slice(0, maxPeers);
+log(` ${candidates.length} candidates (★ = we already channel with room): ` + candidates.slice(0, 8).map((c) => c.id.slice(0, 8) + (c.our >= amount ? "★" : "")).join(" "));
+
+for (const P of candidates) {
+ // force the route to use P->dest as the final hop by excluding every other dest channel
+ const excl = allScids.filter((s) => !P.scids.includes(s)).flatMap((s) => [`${s}/0`, `${s}/1`]);
+ putFile("/tmp/ph_excl.json", JSON.stringify(excl));
+ const rr = cliJSON(`getroute ${dest} ${amount} 10 ${finalCltv + 5} null null "$(cat /tmp/ph_excl.json)"`);
+ const route = rr.route;
+ if (!route || !route.length) { continue; }
+ putFile("/tmp/ph_route.json", JSON.stringify(route));
+
+ // PROBE with an unclaimable random hash (correct amount + correct CLTV)
+ const rh = randomBytes(32).toString("hex");
+ runOn(`${node.prefix} sendpay "$(cat /tmp/ph_route.json)" ${rh} >/dev/null 2>&1 || true`);
+ const wp = waitFor(rh, 45);
+ const d = wp.data || wp;
+ const fc = d.failcodename || d.failcode || "";
+ // "reached the destination" = the DESTINATION node itself rejected the probe
+ // (unclaimable hash) with an unknown-payment-details error. Keying on
+ // erring_node (not an index) avoids CLN's sender-is-index-0 off-by-one.
+ const reached = /UNKNOWN_PAYMENT_DETAILS|INCORRECT_OR_UNKNOWN/.test(String(fc)) && d.erring_node === dest;
+ log(` via ${P.id.slice(0, 12)}… (${route.length}h, out ${sat(P.our)} sat): ${reached ? "✅ reaches destination" : `✗ ${fc || "no reach"}@idx${d.erring_index}`}`);
+ if (!reached) continue;
+
+ if (DRY) { log(`DRY: a working path exists via ${P.id.slice(0, 16)}… (route in /tmp/ph_route.json). Not paying.`); process.exit(0); }
+
+ // fire the REAL payment through this route (same route => correct amount+CLTV)
+ log(`→ firing real payment via ${P.id.slice(0, 16)}…`);
+ cliJSON(`-k sendpay route="$(cat /tmp/ph_route.json)" payment_hash=${hash} bolt11=${inv} payment_secret=${secret} amount_msat=${amount}`);
+ const res = waitFor(hash, 70);
+ if (res.payment_preimage || res.status === "complete") { log(`🎉 PAID via ${P.id.slice(0, 12)}…! preimage: ${res.payment_preimage} | fee ${sat(Number(res.amount_sent_msat) - amount)} sat`); process.exit(0); }
+ const rd = res.data || res;
+ const rfc = rd.failcodename || rd.failcode;
+ if (!rfc) { log(`⚠️ real send did not resolve cleanly (${String(res.__raw || res.message || "").slice(0, 100)}). STOPPING to avoid a second in-flight payment — check listpays before retrying.`); process.exit(3); }
+ log(` real send failed here: ${rfc}@idx${rd.erring_index} (liquidity shifted). trying next peer…`);
+}
+
+log("✗ no reachable peer of the destination had inbound room for the full amount right now.");
+log(" (destination inbound is exhausted across all paths we can reach, or the amount is too large for any single peer path.)");
+process.exit(2);
diff --git a/scripts/payment-monitor.sh b/scripts/payment-monitor.sh
new file mode 100755
index 00000000..c50aad9e
--- /dev/null
+++ b/scripts/payment-monitor.sh
@@ -0,0 +1,210 @@
+#!/bin/bash
+# Payment failure monitor for coinos-server
+# Watches app container logs and compiles failure analytics
+# Run: ./scripts/payment-monitor.sh [hours_to_watch]
+# Output: periodic reports to stdout + detailed data to /tmp/payment-failures.jsonl
+
+WATCH_HOURS=${1:-24}
+DATA_FILE="/tmp/payment-failures.jsonl"
+REPORT_INTERVAL=1800 # report every 30 min
+NODE_ID=$(docker exec cl lightning-cli getinfo 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" 2>/dev/null || echo "unknown")
+
+echo "Payment failure monitor started (watching for ${WATCH_HOURS}h)"
+echo "Node: ${NODE_ID}"
+echo "Data file: ${DATA_FILE}"
+echo "Reports every ${REPORT_INTERVAL}s"
+echo "---"
+
+# Clear old data
+> "$DATA_FILE"
+
+docker logs app -f 2>&1 | timeout $((WATCH_HOURS * 3600)) python3 -u -c "
+import sys, json, time, re
+from collections import Counter, defaultdict
+from datetime import datetime
+
+data_file = '${DATA_FILE}'
+report_interval = ${REPORT_INTERVAL}
+last_report = time.time()
+
+# Tracking
+failures = []
+error_types = Counter()
+failed_destinations = Counter()
+failed_channels = Counter() # channels that return temporary_channel_failure
+fee_insufficient_channels = Counter()
+failed_amounts = [] # (amount, error_type)
+successful = 0
+total_attempts = 0
+
+prev_line = None
+
+def parse_failure(line, error_line):
+ global successful, total_attempts
+ total_attempts += 1
+
+ try:
+ msg = json.loads(line)['msg']
+ err_msg = json.loads(error_line)['msg'] if error_line else ''
+ except:
+ return
+
+ # Parse: username payment failed amount balance hash
+ m = re.match(r'(\S+) payment failed (\d+) (\d+)\s+(.*)', msg)
+ if not m:
+ return
+
+ username, amount, balance, rest = m.group(1), int(m.group(2)), int(m.group(3)), m.group(4)
+
+ # Categorize error
+ if 'Insufficient funds' in err_msg or 'insuficientes' in err_msg.lower():
+ etype = 'insufficient_funds'
+ elif 'excessive cost' in err_msg:
+ etype = 'excessive_cost'
+ elif 'temporary_channel_failure' in err_msg:
+ etype = 'temporary_channel_failure'
+ # Extract failed channel IDs
+ for ch in re.findall(r'(\d+x\d+x\d+/\d+)', err_msg):
+ failed_channels[ch] += 1
+ elif 'fee_insufficient' in err_msg:
+ etype = 'fee_insufficient'
+ for ch in re.findall(r'(\d+x\d+x\d+/\d+)', err_msg):
+ fee_insufficient_channels[ch] += 1
+ elif 'Timed out' in err_msg:
+ etype = 'timed_out'
+ for ch in re.findall(r'(\d+x\d+x\d+/\d+)', err_msg):
+ if 'temporary_channel_failure' in err_msg:
+ failed_channels[ch] += 1
+ if 'fee_insufficient' in err_msg:
+ fee_insufficient_channels[ch] += 1
+ elif 'Cannot send to self' in err_msg:
+ etype = 'self_pay'
+ elif 'incorrect_or_unknown' in err_msg:
+ etype = 'unknown_invoice'
+ elif 'already underway' in err_msg:
+ etype = 'already_underway'
+ elif 'already been paid' in err_msg:
+ etype = 'already_paid'
+ elif 'user not provided' in err_msg:
+ etype = 'user_not_found'
+ elif err_msg:
+ etype = err_msg[:80]
+ else:
+ etype = 'unknown'
+
+ error_types[etype] += 1
+ failed_amounts.append((amount, etype))
+
+ # Try to get destination from invoice
+ if rest and rest.startswith('ln'):
+ bolt11_prefix = rest[:20]
+ failed_destinations[bolt11_prefix] += 1
+
+ record = {
+ 'time': datetime.utcnow().isoformat(),
+ 'user': username,
+ 'amount': amount,
+ 'balance': balance,
+ 'error': etype,
+ 'detail': err_msg[:200] if err_msg else '',
+ }
+
+ with open(data_file, 'a') as f:
+ f.write(json.dumps(record) + '\n')
+
+def parse_success(line):
+ global successful
+ successful += 1
+
+def report():
+ global last_report
+ now = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')
+
+ print(f'\n====== PAYMENT FAILURE REPORT {now} UTC ======')
+ print(f'Total attempts: {total_attempts} Successful: {successful} Failed: {total_attempts}')
+ if total_attempts > 0:
+ print(f'Success rate: {successful/(successful+total_attempts)*100:.1f}%')
+
+ print(f'\n--- Error breakdown ---')
+ for etype, count in error_types.most_common(15):
+ print(f' {count:>5} {etype}')
+
+ if failed_channels:
+ print(f'\n--- Channels with liquidity failures (top 10) ---')
+ for ch, count in failed_channels.most_common(10):
+ print(f' {count:>5} {ch}')
+
+ if fee_insufficient_channels:
+ print(f'\n--- Channels with stale fee gossip (top 10) ---')
+ for ch, count in fee_insufficient_channels.most_common(10):
+ print(f' {count:>5} {ch}')
+
+ # Amount distribution of failures
+ if failed_amounts:
+ amounts_by_type = defaultdict(list)
+ for amt, etype in failed_amounts:
+ amounts_by_type[etype].append(amt)
+
+ print(f'\n--- Failed amount ranges by error ---')
+ for etype in ['insufficient_funds', 'excessive_cost', 'temporary_channel_failure', 'timed_out', 'fee_insufficient']:
+ amts = amounts_by_type.get(etype, [])
+ if amts:
+ print(f' {etype}: min={min(amts)} max={max(amts)} avg={sum(amts)//len(amts)} count={len(amts)}')
+
+ print(f'\n--- Recommendations ---')
+ if error_types.get('insufficient_funds', 0) > 10:
+ print(' * HIGH insufficient_funds: Consider showing fee preview before payment attempt')
+ if error_types.get('excessive_cost', 0) > 5:
+ print(' * HIGH excessive_cost: Increase default maxfee or improve channel connectivity')
+ if error_types.get('fee_insufficient', 0) > 5:
+ print(' * STALE GOSSIP: Some channels advertise lower fees than they charge')
+ print(' Nodes to investigate:')
+ for ch, count in fee_insufficient_channels.most_common(5):
+ scid = ch.split('/')[0]
+ print(f' {scid} ({count} failures)')
+ if failed_channels:
+ print(' * LIQUIDITY BOTTLENECKS:')
+ for ch, count in failed_channels.most_common(5):
+ if count >= 3:
+ scid = ch.split('/')[0]
+ print(f' {scid} ({count} failures) - consider opening channel to bypass')
+ if error_types.get('self_pay', 0) > 5:
+ print(' * SELF-PAY: Users trying to pay own invoices - consider UI guard')
+
+ print('=' * 55)
+ sys.stdout.flush()
+ last_report = time.time()
+
+for line in sys.stdin:
+ line = line.strip()
+ if not line:
+ continue
+
+ try:
+ d = json.loads(line)
+ msg = d.get('msg', '')
+ except:
+ prev_line = line
+ continue
+
+ # Track successes
+ if 'sent lightning' in msg or 'sent liquid' in msg or 'sent bitcoin' in msg:
+ parse_success(line)
+
+ # Track failures - need the next line for error detail
+ if prev_line and 'payment failed' in prev_line:
+ try:
+ prev_d = json.loads(prev_line)
+ if 'payment failed' in prev_d.get('msg', ''):
+ parse_failure(prev_line, line)
+ except:
+ pass
+
+ prev_line = line
+
+ # Periodic report
+ if time.time() - last_report >= report_interval:
+ report()
+" 2>&1
+
+echo "Monitor finished after ${WATCH_HOURS}h"
diff --git a/scripts/rebalance-economics.ts b/scripts/rebalance-economics.ts
new file mode 100644
index 00000000..0d4bf6e2
--- /dev/null
+++ b/scripts/rebalance-economics.ts
@@ -0,0 +1,96 @@
+#!/usr/bin/env bun
+// Models whether the lightning->liquid "bridge" usage is fee-positive for us
+// at a given rebalance batch size.
+//
+// The mechanic:
+// - A user funds via Lightning (we receive into our LN channels) and withdraws
+// via Liquid (we pay out of our Liquid hot wallet). Net: LN balance grows,
+// Liquid balance shrinks. We charge config.fee.liquid (0.1%) on the liquid
+// send, NOT offset by their credit:liquid (they bank credit:lightning from
+// the receive, which is a different key) — so we collect ~0.1% of throughput.
+// - To refill Liquid we must rebalance: close LN channels (on-chain BTC back to
+// our cl wallet) then peg-in to Liquid. That costs on-chain fees, amortized
+// over how much we move per rebalance.
+//
+// Revenue from bridging X sats of throughput = X * liquid_fee.
+// Cost to rebalance that X = (channel closes to free X) + 1 peg-in tx.
+// Break-even: revenue >= cost.
+//
+// Usage: bun scripts/rebalance-economics.ts [--feerate SAT_VB] [--avg-channel BTC] [--liquid-fee 0.001]
+
+const arg = (k: string, d: number) => {
+ const i = process.argv.indexOf(k);
+ return i >= 0 ? Number(process.argv[i + 1]) : d;
+};
+
+const SATS = 100_000_000;
+const liquidFee = arg("--liquid-fee", 0.001); // config.fee.liquid = 0.1%
+const feerate = arg("--feerate", 1.01); // sat/vB; current mutual_close ~1.01
+const avgChannelBtc = arg("--avg-channel", 0.3); // avg local balance freed per close
+
+// tx vsizes (vBytes)
+const MUTUAL_CLOSE_VB = 170; // 1 funding input + 2 outputs
+const PEGIN_VB = 150; // consolidate freed outputs -> 1 peg-in send (BTC side)
+
+function closesNeeded(btc: number) {
+ return Math.max(1, Math.ceil(btc / avgChannelBtc));
+}
+
+function rebalanceCostSats(btc: number) {
+ const closes = closesNeeded(btc);
+ const closeFees = closes * MUTUAL_CLOSE_VB * feerate;
+ const peginFee = PEGIN_VB * feerate; // one batched peg-in per rebalance
+ return { closes, closeFees, peginFee, total: closeFees + peginFee };
+}
+
+function revenueSats(btc: number) {
+ return btc * SATS * liquidFee;
+}
+
+console.log("═".repeat(70));
+console.log(" LIGHTNING -> LIQUID BRIDGE ECONOMICS");
+console.log(` liquid fee ${(liquidFee * 100).toFixed(2)}% | close feerate ${feerate} sat/vB | avg channel ${avgChannelBtc} BTC`);
+console.log("═".repeat(70));
+console.log(
+ "\n batch revenue closes close+pegin cost net margin",
+);
+console.log(" " + "-".repeat(64));
+
+for (const btc of [0.1, 0.25, 0.5, 1, 2, 5, 10]) {
+ const rev = revenueSats(btc);
+ const c = rebalanceCostSats(btc);
+ const net = rev - c.total;
+ const margin = (net / rev) * 100;
+ const tag = net >= 0 ? "PROFIT" : "LOSS";
+ console.log(
+ ` ${(btc + " BTC").padEnd(8)} ${(Math.round(rev) + "").padStart(9)}s ${(c.closes + "").padStart(4)} ${(Math.round(c.total) + "").padStart(10)}s ${(Math.round(net) + "").padStart(8)}s ${margin.toFixed(1).padStart(6)}% ${tag}`,
+ );
+}
+
+// Break-even batch size: revenue(btc) = cost(btc). With closes ~ btc/avgChannel,
+// cost grows ~linearly too, so solve numerically.
+let beBtc = 0;
+for (let b = 0.001; b <= 50; b += 0.001) {
+ if (revenueSats(b) >= rebalanceCostSats(b).total) {
+ beBtc = b;
+ break;
+ }
+}
+console.log("\n " + "-".repeat(64));
+if (beBtc) {
+ console.log(` Break-even: rebalancing >= ${beBtc.toFixed(3)} BTC per batch is fee-positive.`);
+} else {
+ console.log(` Never breaks even in range — cost scales with throughput as fast as revenue.`);
+}
+
+// The deeper truth: because closes scale with throughput, per-sat margin is
+// roughly constant. Show the asymptotic per-BTC economics.
+const big = rebalanceCostSats(10);
+const bigRev = revenueSats(10);
+console.log(
+ `\n At scale (per 1 BTC bridged): revenue ${Math.round(revenueSats(1))}s, ` +
+ `rebalance cost ~${Math.round(rebalanceCostSats(1).total)}s, ` +
+ `net ~${Math.round(revenueSats(1) - rebalanceCostSats(1).total)}s`,
+);
+console.log("═".repeat(70));
+process.exit(0);
diff --git a/setup.sh b/setup.sh
index 8c19b8eb..c61a37fa 100755
--- a/setup.sh
+++ b/setup.sh
@@ -1,3 +1,14 @@
+#!/bin/bash
+
+if [ -f config.ts ] || [ -f compose.yml ] || [ -d data ]; then
+ echo "Existing config.ts, compose.yml, or data/ detected."
+ read -p "Overwrite? This will destroy current config and data. (y/N) " confirm
+ if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
+ echo "Aborting."
+ exit 1
+ fi
+fi
+
cp config.ts.sample config.ts
cp compose.yml.sample compose.yml
cp -r sampledata data
@@ -5,6 +16,9 @@ sudo chown 100:100 data/nostr/data
docker compose up -d
docker run -it -v $(pwd):/home/bun/app --entrypoint bun asoltys/coinos-server i
docker exec -it bc bitcoin-cli createwallet coinos
+docker exec -it bc bitcoin-cli createwallet external
docker exec -it bc bitcoin-cli rescanblockchain
docker exec -it bc bitcoin-cli generatetoaddress 500 $(docker exec -it bc bitcoin-cli getnewaddress "" "p2sh-segwit")
+docker exec -it bc bitcoin-cli -rpcwallet=external generatetoaddress 5000 $(docker exec -it bc bitcoin-cli -rpcwallet=external getnewaddress "" "p2sh-segwit")
docker exec -it lq elements-cli createwallet coinos
+docker exec -it db keydb-cli set limit 1000000
diff --git a/test/security.test.ts b/test/security.test.ts
new file mode 100644
index 00000000..9311cf5d
--- /dev/null
+++ b/test/security.test.ts
@@ -0,0 +1,241 @@
+// Regression tests for the 2026-08 security-disclosure fixes.
+//
+// Runs against the live regtest stack (the app on :3119 and the redis `db`).
+// Because redis isn't port-mapped to the host, run this INSIDE the app
+// container so `redis://db` resolves and `localhost:3119` is the app:
+//
+// docker exec app sh -c 'cd /home/bun/app && bun test test/security.test.ts'
+//
+// Override endpoints with TEST_API / TEST_REDIS if needed.
+
+import { afterAll, beforeAll, describe, expect, test } from "bun:test";
+import jwt from "jsonwebtoken";
+import { createClient } from "redis";
+
+const API = process.env.TEST_API || "http://localhost:3119";
+const REDIS = process.env.TEST_REDIS || "redis://db";
+
+let db: any;
+const username = `sectest${Date.now()}${Math.floor(Math.random() * 1000)}`;
+const password = "correct horse battery staple";
+let uid: string | null = null;
+let pubkey: string | undefined;
+
+const post = (path: string, body?: any, headers: any = {}) =>
+ fetch(`${API}${path}`, {
+ method: "POST",
+ headers: { "content-type": "application/json", ...headers },
+ body: body === undefined ? undefined : JSON.stringify(body),
+ });
+
+beforeAll(async () => {
+ db = createClient({ url: REDIS });
+ await db.connect();
+
+ const reg = await post("/register", { user: { username, password } });
+ if (reg.status !== 200)
+ throw new Error(`register failed: ${reg.status} ${await reg.text()}`);
+
+ uid = await db.get(`user:${username.toLowerCase()}`);
+ if (uid) {
+ const rec = JSON.parse(await db.get(`user:${uid}`));
+ pubkey = rec?.pubkey;
+ }
+});
+
+afterAll(async () => {
+ // Best-effort cleanup of the throwaway user's keys.
+ if (uid) {
+ const keys = [
+ `user:${uid}`,
+ `user:${username.toLowerCase()}`,
+ `balance:${uid}`,
+ `account:${uid}`,
+ `${uid}:accounts`,
+ `${uid}:apps`,
+ ];
+ if (pubkey)
+ keys.push(
+ `user:${pubkey}`,
+ `${pubkey}:follows:n`,
+ `${pubkey}:followers:n`,
+ `${pubkey}:pubkeys`,
+ );
+ try {
+ await db.del(keys);
+ } catch {}
+ }
+ await db.quit();
+});
+
+describe("S1 — websocket auth verifies the JWT signature", () => {
+ const secret = "s1-test-secret";
+
+ test("jwt.verify accepts a properly signed token", () => {
+ const token = jwt.sign({ id: "u1" }, secret);
+ expect((jwt.verify(token, secret) as any).id).toBe("u1");
+ });
+
+ test("jwt.verify rejects an alg:none forgery that jwt.decode would trust", () => {
+ const forged = jwt.sign({ id: "victim" }, "", { algorithm: "none" });
+ // The old code used jwt.decode() and would have trusted this uid.
+ expect((jwt.decode(forged) as any).id).toBe("victim");
+ expect(() => jwt.verify(forged, secret)).toThrow();
+ });
+
+ test("jwt.verify rejects a token signed with the wrong key", () => {
+ const forged = jwt.sign({ id: "victim" }, "attacker-key");
+ expect(() => jwt.verify(forged, secret)).toThrow();
+ });
+});
+
+describe("S2 — passwords hashed at bcrypt cost 12", () => {
+ test("a freshly registered account is stored at cost 12", async () => {
+ expect(uid).toBeTruthy();
+ const rec = JSON.parse(await db.get(`user:${uid}`));
+ expect(rec.password.startsWith("$2b$12$")).toBe(true);
+ });
+
+ test("legacy cost-4 hashes are detected for transparent upgrade", async () => {
+ const legacy = await Bun.password.hash("x", { algorithm: "bcrypt", cost: 4 });
+ const modern = await Bun.password.hash("x", { algorithm: "bcrypt", cost: 12 });
+ const costOf = (h: string) =>
+ Number.parseInt(h.match(/^\$2[aby]\$(\d{2})\$/)?.[1] ?? "0", 10);
+ expect(costOf(legacy)).toBe(4);
+ expect(costOf(modern)).toBe(12);
+ expect(costOf(legacy) < 12).toBe(true);
+ // A legacy hash must still verify (so the upgrade is transparent).
+ expect(await Bun.password.verify("x", legacy)).toBe(true);
+ });
+});
+
+describe("S3 — adminpass login fails closed", () => {
+ test("a login with an omitted password never authenticates", async () => {
+ const r = await post("/login", { username });
+ expect(r.status).toBe(401);
+ });
+
+ test("a login with the wrong password is rejected", async () => {
+ const r = await post("/login", { username, password: "not the password" });
+ expect(r.status).toBe(401);
+ });
+
+ test("the real password still logs in (fix didn't break auth)", async () => {
+ const r = await post("/login", { username, password });
+ expect(r.status).toBe(200);
+ const { token } = await r.json();
+ expect(typeof token).toBe("string");
+ });
+});
+
+describe("S5 — session cookie carries httpOnly/secure/sameSite", () => {
+ test("Set-Cookie on login has the hardening flags", async () => {
+ const r = await post("/login", { username, password });
+ expect(r.status).toBe(200);
+ const cookie = (r.headers.get("set-cookie") || "").toLowerCase();
+ expect(cookie).toContain("httponly");
+ expect(cookie).toContain("secure");
+ expect(cookie).toContain("samesite");
+ });
+});
+
+describe("S6 — NWC dedup claim is atomic (SET NX)", () => {
+ test("two concurrent claims of one event id yield exactly one winner", async () => {
+ const key = `test:nwcdedup:${Date.now()}:${Math.random()}`;
+ const [a, b] = await Promise.all([
+ db.set(key, "1", { NX: true, EX: 30 }),
+ db.set(key, "1", { NX: true, EX: 30 }),
+ ]);
+ const winners = [a, b].filter((x) => x === "OK").length;
+ expect(winners).toBe(1);
+ await db.del(key);
+ });
+});
+
+describe("S7 — /upload/:type requires auth", () => {
+ // Bare POST (no JSON content-type): the auth preValidation runs before the
+ // handler, so an unauthenticated upload is rejected with 401. (Sending a JSON
+ // content-type here would 400 in the body parser first — a different reject
+ // path that doesn't exercise the auth gate.)
+ test("POST /upload/banner without a token is rejected", async () => {
+ const r = await fetch(`${API}/upload/banner`, { method: "POST" });
+ expect(r.status).toBe(401);
+ });
+ test("POST /upload/photo without a token is rejected", async () => {
+ const r = await fetch(`${API}/upload/photo`, { method: "POST" });
+ expect(r.status).toBe(401);
+ });
+});
+
+describe("fnd-003/004 — oversized memo is truncated, not fatal", () => {
+ // Mirrors the credit() choke point: memo.length > 5000 => slice, never throw.
+ const cap = (memo: string) =>
+ memo && memo.length > 5000 ? memo.slice(0, 5000) : memo;
+
+ test("a 6000-char memo is clipped to 5000", () => {
+ expect(cap("A".repeat(6000)).length).toBe(5000);
+ });
+ test("a normal memo is untouched", () => {
+ expect(cap("thanks!")).toBe("thanks!");
+ });
+});
+
+describe("fnd-006 — deleteSelf guard sums every account", () => {
+ test("funds in a sub-account count toward the guard", async () => {
+ const base = `test:acct:${Date.now()}:${Math.floor(Math.random() * 1000)}`;
+ const sub = `${base}:sub`;
+ await db.rPush(`${base}:accounts`, base);
+ await db.rPush(`${base}:accounts`, sub);
+ await db.set(`balance:${base}`, "500"); // dust in main
+ await db.set(`balance:${sub}`, "5000000"); // savings in sub
+
+ // Same summation the guard now performs.
+ const aids = await db.lRange(`${base}:accounts`, 0, -1);
+ let total = 0;
+ for (const aid of aids) {
+ total += Number(await db.get(`balance:${aid}`)) || 0;
+ total += Number(await db.get(`pending:${aid}`)) || 0;
+ }
+
+ expect(total).toBe(5000500);
+ expect(total > 10000).toBe(true); // => deletion refused
+
+ await db.del(`${base}:accounts`, `balance:${base}`, `balance:${sub}`);
+ });
+});
+
+describe("S8 / fnd-007 — the spend/cash mutex serializes critical sections", () => {
+ test("no two sections run concurrently and FIFO order holds", async () => {
+ // Same shape as withBudgetLock (nwc.ts).
+ let lock: Promise<void> = Promise.resolve();
+ const withLock = async <T>(fn: () => Promise<T>): Promise<T> => {
+ const prev = lock;
+ let release: () => void = () => {};
+ lock = new Promise((res) => {
+ release = res;
+ });
+ await prev;
+ try {
+ return await fn();
+ } finally {
+ release();
+ }
+ };
+
+ const order: number[] = [];
+ let active = 0;
+ let maxActive = 0;
+ const job = (n: number) =>
+ withLock(async () => {
+ active++;
+ maxActive = Math.max(maxActive, active);
+ await new Promise((r) => setTimeout(r, 15));
+ order.push(n);
+ active--;
+ });
+
+ await Promise.all([job(1), job(2), job(3)]);
+ expect(maxActive).toBe(1);
+ expect(order).toEqual([1, 2, 3]);
+ });
+});
diff --git a/test/spend-budget.test.ts b/test/spend-budget.test.ts
new file mode 100644
index 00000000..81c16b2a
--- /dev/null
+++ b/test/spend-budget.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, test } from "bun:test";
+import {
+ assertWithinSpendLimit,
+ debitTotal,
+} from "$lib/spend-budget";
+
+describe("NWC budget covers the final captured tip and fees", () => {
+ test("a small invoice with an oversized mutable tip is rejected", () => {
+ expect(
+ debitTotal({ amount: 19, tip: 1_701_450, fee: 0, ourfee: 0 }),
+ ).toBe(1_701_469);
+ expect(() =>
+ assertWithinSpendLimit({
+ amount: 19,
+ tip: 1_701_450,
+ maxTotal: 10_000,
+ }),
+ ).toThrow("Budget exceeded");
+ });
+
+ test("the exact remaining allowance succeeds", () => {
+ expect(
+ assertWithinSpendLimit({
+ amount: 9_000,
+ tip: 500,
+ fee: 400,
+ ourfee: 100,
+ maxTotal: 10_000,
+ }),
+ ).toBe(10_000);
+ });
+
+ test("an unlimited first-party debit remains unaffected", () => {
+ expect(
+ assertWithinSpendLimit({ amount: 19, tip: 1_701_450 }),
+ ).toBe(1_701_469);
+ });
+});