From 3cdf85fdcc021168edb012964d7b42707afb2dbf Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 7 May 2026 19:07:16 +0200 Subject: [PATCH 01/12] ci: lds-api docker pipeline + lnbitsapi switch to lightningdotspacecom (ARM64) (#177) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: add lds-api docker pipeline + switch lnbitsapi to lightningdotspacecom (ARM64) Adds: - Dockerfile + .dockerignore at repo root for the lds-api NestJS service - lds-api-{dev,prd}.yaml workflows that build linux/arm64 and push lightningdotspacecom/lds-api:{beta,latest} on push to {develop,main} Updates: - lnbitsapi-{dev,prd}.yaml: image renamed from dfxswiss/lnbitsapi:{latest,main} to lightningdotspacecom/lnbitsapi:{beta,latest}; build pinned to linux/arm64 via QEMU + buildx; Node bumped from 16.x (EOL) to 18.x to match Dockerfile The pre-existing api-{dev,prd}.yaml Azure App Service workflows are kept intact for the migration window — they will be removed once the dfxprd LDS stack is live. Docker Hub credentials secret (DOCKER_USERNAME / DOCKER_PASSWORD) must be set to a token with write access to the lightningdotspacecom org before the first build runs. * ci(lds): align workflows with DFX convention (ARM-native, deploy step) Aligns the lds-api and lnbitsapi build pipelines with the DFX house style (cf. juicedollarcom/api, deurocom/api): - runs-on: ubuntu-24.04-arm (native ARM, no QEMU) - platforms: linux/arm64 (single arch, matches DFX servers) - Deploy step after build: install cloudflared, SSH via Cloudflare Tunnel to dfxdev/dfxprd, invoke deploy.sh with the canonical service name (lds-api / lds-lnbitsapi). Matches the case-block added in DFXServer/server commit ba6fdf6. PR test workflows (api-pr.yaml, lnbitsapi-pr.yaml) bumped from Node 16.x (EOL) to Node 18.x to match the production Dockerfile. Required secrets per repo (set on top of DOCKER_USERNAME/PASSWORD): - DEPLOY_DEV_SSH_KEY, DEPLOY_DEV_SSH_KNOWN_HOSTS, DEPLOY_DEV_HOST, DEPLOY_DEV_USER - DEPLOY_PRD_SSH_KEY, DEPLOY_PRD_SSH_KNOWN_HOSTS, DEPLOY_PRD_HOST, DEPLOY_PRD_USER The DEV deploy will only succeed once dfxdev:~/lds/docker-compose.yaml is in place (skeleton currently committed without compose). Until then the build step pushes to Docker Hub and the deploy step fails — fine, image is published either way. --- .dockerignore | 12 +++ .github/workflows/api-pr.yaml | 2 +- .github/workflows/lds-api-dev.yaml | 56 +++++++++++++ .github/workflows/lds-api-prd.yaml | 56 +++++++++++++ .github/workflows/lnbitsapi-dev.yaml | 115 +++++++++++++-------------- .github/workflows/lnbitsapi-pr.yaml | 2 +- .github/workflows/lnbitsapi-prd.yaml | 115 +++++++++++++-------------- Dockerfile | 34 ++++++++ 8 files changed, 270 insertions(+), 122 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/lds-api-dev.yaml create mode 100644 .github/workflows/lds-api-prd.yaml create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..9f8d62471 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +dist +.git +.github +infrastructure/lnbitsapi/node_modules +infrastructure/lnbitsapi/dist +*.log +.env +.env.* +test +coverage +README.md diff --git a/.github/workflows/api-pr.yaml b/.github/workflows/api-pr.yaml index 7bee8a9c4..ac716dd82 100644 --- a/.github/workflows/api-pr.yaml +++ b/.github/workflows/api-pr.yaml @@ -10,7 +10,7 @@ on: workflow_dispatch: env: - NODE_VERSION: '16.x' + NODE_VERSION: '18.x' jobs: build: diff --git a/.github/workflows/lds-api-dev.yaml b/.github/workflows/lds-api-dev.yaml new file mode 100644 index 000000000..3181675d6 --- /dev/null +++ b/.github/workflows/lds-api-dev.yaml @@ -0,0 +1,56 @@ +name: LDS API DEV CI/CD + +on: + push: + branches: [develop] + paths-ignore: + - 'infrastructure/lnbitsapi/**' + workflow_dispatch: + +env: + DOCKER_TAGS: lightningdotspacecom/lds-api:beta + +permissions: + contents: read + +jobs: + build-and-deploy: + name: Build and deploy to DEV + runs-on: ubuntu-24.04-arm + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ env.DOCKER_TAGS }} + platforms: linux/arm64 + + - name: Install cloudflared + run: | + curl -fsSL https://github.com/cloudflare/cloudflared/releases/download/2025.4.0/cloudflared-linux-arm64 -o /usr/local/bin/cloudflared + chmod +x /usr/local/bin/cloudflared + + - name: Deploy + run: | + mkdir -p ~/.ssh + echo "${{ secrets.DEPLOY_DEV_SSH_KEY }}" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + echo "${{ secrets.DEPLOY_DEV_SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts + ssh -i ~/.ssh/deploy_key \ + -o ProxyCommand="cloudflared access ssh --hostname ${{ secrets.DEPLOY_DEV_HOST }}" \ + ${{ secrets.DEPLOY_DEV_USER }}@${{ secrets.DEPLOY_DEV_HOST }} \ + "lds-api" diff --git a/.github/workflows/lds-api-prd.yaml b/.github/workflows/lds-api-prd.yaml new file mode 100644 index 000000000..c67cc9d34 --- /dev/null +++ b/.github/workflows/lds-api-prd.yaml @@ -0,0 +1,56 @@ +name: LDS API PRD CI/CD + +on: + push: + branches: [main] + paths-ignore: + - 'infrastructure/lnbitsapi/**' + workflow_dispatch: + +env: + DOCKER_TAGS: lightningdotspacecom/lds-api:latest + +permissions: + contents: read + +jobs: + build-and-deploy: + name: Build and deploy to PRD + runs-on: ubuntu-24.04-arm + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ env.DOCKER_TAGS }} + platforms: linux/arm64 + + - name: Install cloudflared + run: | + curl -fsSL https://github.com/cloudflare/cloudflared/releases/download/2025.4.0/cloudflared-linux-arm64 -o /usr/local/bin/cloudflared + chmod +x /usr/local/bin/cloudflared + + - name: Deploy + run: | + mkdir -p ~/.ssh + echo "${{ secrets.DEPLOY_PRD_SSH_KEY }}" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + echo "${{ secrets.DEPLOY_PRD_SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts + ssh -i ~/.ssh/deploy_key \ + -o ProxyCommand="cloudflared access ssh --hostname ${{ secrets.DEPLOY_PRD_HOST }}" \ + ${{ secrets.DEPLOY_PRD_USER }}@${{ secrets.DEPLOY_PRD_HOST }} \ + "lds-api" diff --git a/.github/workflows/lnbitsapi-dev.yaml b/.github/workflows/lnbitsapi-dev.yaml index 2234920bd..ddba91b36 100644 --- a/.github/workflows/lnbitsapi-dev.yaml +++ b/.github/workflows/lnbitsapi-dev.yaml @@ -1,60 +1,55 @@ -name: LNBITSAPI DEV CI/CD - -on: - push: - branches: [develop] - paths: - - 'infrastructure/lnbitsapi/**' - workflow_dispatch: - -env: - DOCKER_TAGS: dfxswiss/lnbitsapi:latest - NODE_VERSION: '16.x' - -jobs: - build-and-deploy: - name: Build, test and deploy to DEV - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./infrastructure/lnbitsapi - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Use Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - - - name: Install packages - run: | - npm ci - - - name: Build code - run: | - npm run build - - - name: Run tests - run: | - npm run test - - - name: Run linter - run: | - npm run lint - - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build and push Docker image - uses: docker/build-push-action@v6 - with: - context: ./infrastructure/lnbitsapi - push: true - tags: ${{ env.DOCKER_TAGS }} +name: LDS LNBITSAPI DEV CI/CD + +on: + push: + branches: [develop] + paths: + - 'infrastructure/lnbitsapi/**' + workflow_dispatch: + +env: + DOCKER_TAGS: lightningdotspacecom/lnbitsapi:beta + +permissions: + contents: read + +jobs: + build-and-deploy: + name: Build and deploy to DEV + runs-on: ubuntu-24.04-arm + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: ./infrastructure/lnbitsapi + push: true + tags: ${{ env.DOCKER_TAGS }} + platforms: linux/arm64 + + - name: Install cloudflared + run: | + curl -fsSL https://github.com/cloudflare/cloudflared/releases/download/2025.4.0/cloudflared-linux-arm64 -o /usr/local/bin/cloudflared + chmod +x /usr/local/bin/cloudflared + + - name: Deploy + run: | + mkdir -p ~/.ssh + echo "${{ secrets.DEPLOY_DEV_SSH_KEY }}" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + echo "${{ secrets.DEPLOY_DEV_SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts + ssh -i ~/.ssh/deploy_key \ + -o ProxyCommand="cloudflared access ssh --hostname ${{ secrets.DEPLOY_DEV_HOST }}" \ + ${{ secrets.DEPLOY_DEV_USER }}@${{ secrets.DEPLOY_DEV_HOST }} \ + "lds-lnbitsapi" diff --git a/.github/workflows/lnbitsapi-pr.yaml b/.github/workflows/lnbitsapi-pr.yaml index 1adbfc7ba..a3e172dbd 100644 --- a/.github/workflows/lnbitsapi-pr.yaml +++ b/.github/workflows/lnbitsapi-pr.yaml @@ -10,7 +10,7 @@ on: workflow_dispatch: env: - NODE_VERSION: '16.x' + NODE_VERSION: '18.x' jobs: build: diff --git a/.github/workflows/lnbitsapi-prd.yaml b/.github/workflows/lnbitsapi-prd.yaml index b797b5590..51dfc2733 100644 --- a/.github/workflows/lnbitsapi-prd.yaml +++ b/.github/workflows/lnbitsapi-prd.yaml @@ -1,60 +1,55 @@ -name: LNBITSAPI PRD CI/CD - -on: - push: - branches: [main] - paths: - - 'infrastructure/lnbitsapi/**' - workflow_dispatch: - -env: - DOCKER_TAGS: dfxswiss/lnbitsapi:main - NODE_VERSION: '16.x' - -jobs: - build-and-deploy: - name: Build, test and deploy to PRD - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./infrastructure/lnbitsapi - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Use Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - - - name: Install packages - run: | - npm ci - - - name: Build code - run: | - npm run build - - - name: Run tests - run: | - npm run test - - - name: Run linter - run: | - npm run lint - - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build and push Docker image - uses: docker/build-push-action@v6 - with: - context: ./infrastructure/lnbitsapi - push: true - tags: ${{ env.DOCKER_TAGS }} +name: LDS LNBITSAPI PRD CI/CD + +on: + push: + branches: [main] + paths: + - 'infrastructure/lnbitsapi/**' + workflow_dispatch: + +env: + DOCKER_TAGS: lightningdotspacecom/lnbitsapi:latest + +permissions: + contents: read + +jobs: + build-and-deploy: + name: Build and deploy to PRD + runs-on: ubuntu-24.04-arm + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: ./infrastructure/lnbitsapi + push: true + tags: ${{ env.DOCKER_TAGS }} + platforms: linux/arm64 + + - name: Install cloudflared + run: | + curl -fsSL https://github.com/cloudflare/cloudflared/releases/download/2025.4.0/cloudflared-linux-arm64 -o /usr/local/bin/cloudflared + chmod +x /usr/local/bin/cloudflared + + - name: Deploy + run: | + mkdir -p ~/.ssh + echo "${{ secrets.DEPLOY_PRD_SSH_KEY }}" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + echo "${{ secrets.DEPLOY_PRD_SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts + ssh -i ~/.ssh/deploy_key \ + -o ProxyCommand="cloudflared access ssh --hostname ${{ secrets.DEPLOY_PRD_HOST }}" \ + ${{ secrets.DEPLOY_PRD_USER }}@${{ secrets.DEPLOY_PRD_HOST }} \ + "lds-lnbitsapi" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..167ef57ac --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +# Multi-stage build for the lds-api NestJS service. +# +# Built and pushed by .github/workflows/lds-api-{dev,prd}.yaml as +# lightningdotspacecom/lds-api:{beta,latest} (linux/arm64). +# +# Mirrors the layout of the existing lnbitsapi Dockerfile. + +FROM node:18.19.1-alpine3.19 AS builder + +USER node +WORKDIR /home/node + +ADD --chown=node:node package.json . +ADD --chown=node:node package-lock.json . +RUN npm ci + +ADD --chown=node:node . . +RUN npm run build + + +FROM node:18.19.1-alpine3.19 + +USER node +WORKDIR /home/node + +COPY --from=builder /home/node/package.json /home/node/package-lock.json ./ +COPY --from=builder /home/node/dist ./dist +COPY --from=builder /home/node/migration ./migration + +RUN npm ci --omit=dev + +EXPOSE 3000 + +CMD ["node", "dist/main.js"] From 2fd4e5e9a78eef48e722d3ada560ed7e5acb011e Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 7 May 2026 19:28:37 +0200 Subject: [PATCH 02/12] fix(docker): install python3 + build toolchain for node-gyp (#179) * fix(docker): install python3 + build toolchain for node-gyp The CI build of lds-api fails on 'npm ci' with: npm ERR! gyp ERR! stack Error: Could not find any Python installation to use Several deps (solana, eth-signing-related crates) have native modules that node-gyp builds at install time. node:18-alpine ships without Python or a C/C++ toolchain, so install python3 + make + g++ before the npm step. * fix(docker,lnbitsapi): same python3 + toolchain fix as lds-api The lnbitsapi image uses the same node:18-alpine base as the new lds-api image, and depends on sqlite3 which has a native binding compiled by node-gyp. Add the same python3 + make + g++ install step proactively so the next push under infrastructure/lnbitsapi/** doesn't hit the same build failure. --- Dockerfile | 4 ++++ infrastructure/lnbitsapi/Dockerfile | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/Dockerfile b/Dockerfile index 167ef57ac..38ecf7a35 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,10 @@ FROM node:18.19.1-alpine3.19 AS builder +# node-gyp needs Python + a C/C++ toolchain to build native modules +# (e.g. solana/eth signing crates). Alpine ships none of those by default. +RUN apk add --no-cache python3 make g++ + USER node WORKDIR /home/node diff --git a/infrastructure/lnbitsapi/Dockerfile b/infrastructure/lnbitsapi/Dockerfile index 2c9e8b843..efcf97231 100644 --- a/infrastructure/lnbitsapi/Dockerfile +++ b/infrastructure/lnbitsapi/Dockerfile @@ -1,6 +1,10 @@ # Stage 0 FROM node:18.19.1-alpine3.19 AS builder +# node-gyp needs Python + a C/C++ toolchain to build sqlite3's native binding. +# Alpine ships none of those by default. +RUN apk add --no-cache python3 make g++ + USER node WORKDIR /home/node From 39a472b0838180d6ffd14efdedec4ea9fef884cd Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 8 May 2026 09:30:34 +0200 Subject: [PATCH 03/12] fix(db): make postgres SSL opt-in via SQL_SSL=true (#181) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(docker): copy pruned node_modules from builder stage Stage 2 was running 'npm ci --omit=dev' from scratch, which triggers node-gyp on native deps (solana/eth signers) and fails the same way stage 1 did before — the runtime image base also lacks python/g++. Fix: do 'npm prune --omit=dev' in the builder (drop dev-only deps from the existing node_modules tree, keeping the already-compiled native binaries) and COPY node_modules across to the final stage. This avoids ever re-running node-gyp at runtime-image-build time and keeps the runtime base small (no python/toolchain there). * fix(db): make postgres SSL opt-in via SQL_SSL=true The hardcoded { rejectUnauthorized: false } SSL config forces a TLS handshake against the postgres host even when the server doesn't speak SSL — which breaks the dfxdev/dfxprd setup where lds-api talks to a local api-postgres container without SSL. Error: The server does not support SSL connections Make it opt-in: SSL only when SQL_SSL=true (the Azure-hosted PostgreSQL Flexible Server expects it; the new container-postgres does not). --- Dockerfile | 7 +++++-- src/config/config.ts | 9 ++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 38ecf7a35..a2e922e9a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,10 @@ RUN npm ci ADD --chown=node:node . . RUN npm run build +# Drop dev deps in-place after the build so the runtime stage can copy +# the already-compiled native modules instead of re-running node-gyp +# (which would need python3 + g++ in the runtime image again). +RUN npm prune --omit=dev FROM node:18.19.1-alpine3.19 @@ -28,11 +32,10 @@ USER node WORKDIR /home/node COPY --from=builder /home/node/package.json /home/node/package-lock.json ./ +COPY --from=builder /home/node/node_modules ./node_modules COPY --from=builder /home/node/dist ./dist COPY --from=builder /home/node/migration ./migration -RUN npm ci --omit=dev - EXPOSE 3000 CMD ["node", "dist/main.js"] diff --git a/src/config/config.ts b/src/config/config.ts index eb1741a76..d3c55d8a4 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -38,9 +38,12 @@ export class Configuration { username: process.env.SQL_USERNAME, password: process.env.SQL_PASSWORD, database: process.env.SQL_DB, - ssl: { - rejectUnauthorized: false, - }, + // Azure PostgreSQL Flexible Server enforces require_secure_transport=on + // and is the long-running default for this codebase, so SSL stays + // on by default. Set SQL_SSL=false on a host where the postgres peer + // does not speak TLS (e.g. the local api-postgres container in the + // DFX dfxdev/dfxprd LDS stack). + ssl: process.env.SQL_SSL === 'false' ? false : { rejectUnauthorized: false }, entities: ['dist/**/*.entity{.ts,.js}'], autoLoadEntities: true, synchronize: process.env.SQL_SYNCHRONIZE === 'true', From 28fcd002d29d00bddabdc846eb7ea3c8a7398495 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 8 May 2026 09:31:14 +0200 Subject: [PATCH 04/12] fix(docker): copy pruned node_modules from builder stage (#180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2 was running 'npm ci --omit=dev' from scratch, which triggers node-gyp on native deps (solana/eth signers) and fails the same way stage 1 did before — the runtime image base also lacks python/g++. Fix: do 'npm prune --omit=dev' in the builder (drop dev-only deps from the existing node_modules tree, keeping the already-compiled native binaries) and COPY node_modules across to the final stage. This avoids ever re-running node-gyp at runtime-image-build time and keeps the runtime base small (no python/toolchain there). From df49329630e099862d4054ce08d5e6cdac31e6c7 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 8 May 2026 13:10:38 +0200 Subject: [PATCH 05/12] feat(lightning): add ThunderHub health endpoint for monitoring (#182) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds GET /v1/thunderhub/health which proxies an HTTP check to the upstream ThunderHub container and returns 200 {"up": true} when reachable, 503 with the error otherwise. Why: Uptime Kuma already monitors all 14 LDS services on dev.lightning.space via lds-api-forwarded paths (/v1/lndhub/getinfo, /v1/swap/v2/...). ThunderHub was the one service without coverage because it only listens on the internal nginx port 6123 (127.0.0.1 on dfxdev) and is reachable to operators only via the SSH-tunneled `thunderhub-remote` script in the server repo. Several times now a tunnel reconnect has been the only signal that the underlying container has been down for hours. The Kuma-consistent fix is to expose a narrow health probe through the same lds-api forwarding pattern the other 13 monitors already use. Configuration: - New env var LIGHTNING_THUNDERHUB_URL (default: empty → 503). In the DFX server repo's compose, this should be set to http://thunderhub:3000 (intra-stack docker DNS, no SSL — same pattern as the existing LIGHTNING_LNBITS_*_URL after the recent HTTP-intra-stack switch in DFXServer/server PR #133). Endpoint behavior: - 200 { "up": true } when ThunderHub responds with any 2xx/3xx/4xx (validateStatus rules out 5xx), so login-redirect is treated as up. - 503 { "up": false, "error": "..." } on connect failure / 5xx / timeout. - 5s timeout — short enough that Kuma sees DOWN within one probe interval. No auth on the endpoint by design: it returns only a boolean, no internal state, no upstream response body. Safe to be public. --- src/config/config.ts | 3 ++ .../lightning-thunderhub-health.controller.ts | 39 +++++++++++++++++++ .../lightning/lightning-forward.module.ts | 5 ++- 3 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 src/subdomains/lightning/controllers/lightning-thunderhub-health.controller.ts diff --git a/src/config/config.ts b/src/config/config.ts index d3c55d8a4..0e7286aae 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -122,6 +122,9 @@ export class Configuration { apiUrl: process.env.LIGHTNING_LNBITSAPI_API_URL ?? '', certificate: process.env.LIGHTNING_LNBITSAPI_CERTIFICATE?.split('
').join('\n') ?? '', }, + thunderhub: { + apiUrl: process.env.LIGHTNING_THUNDERHUB_URL ?? '', + }, }, ethereum: { gatewayUrl: process.env.ETHEREUM_GATEWAY_URL ?? '', diff --git a/src/subdomains/lightning/controllers/lightning-thunderhub-health.controller.ts b/src/subdomains/lightning/controllers/lightning-thunderhub-health.controller.ts new file mode 100644 index 000000000..d79dcfabc --- /dev/null +++ b/src/subdomains/lightning/controllers/lightning-thunderhub-health.controller.ts @@ -0,0 +1,39 @@ +import { Controller, Get, HttpException, HttpStatus } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Config } from 'src/config/config'; +import { LightningLogger } from 'src/shared/services/lightning-logger'; +import { HttpService } from 'src/shared/services/http.service'; + +@ApiTags('ThunderHub') +@Controller('thunderhub') +export class LightningThunderhubHealthController { + private readonly logger = new LightningLogger(LightningThunderhubHealthController); + + constructor(private readonly http: HttpService) {} + + @Get('health') + async health(): Promise<{ up: true }> { + const url = Config.blockchain.lightning.thunderhub.apiUrl; + + if (!url) { + throw new HttpException( + { up: false, error: 'LIGHTNING_THUNDERHUB_URL not configured' }, + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + + try { + await this.http.request({ + url, + method: 'GET', + responseType: 'text', + timeout: 5000, + validateStatus: (status) => status >= 200 && status < 500, + }); + return { up: true }; + } catch (e) { + this.logger.warn(`ThunderHub health probe failed: ${e.message}`); + throw new HttpException({ up: false, error: e.message }, HttpStatus.SERVICE_UNAVAILABLE); + } + } +} diff --git a/src/subdomains/lightning/lightning-forward.module.ts b/src/subdomains/lightning/lightning-forward.module.ts index 9d569b037..16f693b74 100644 --- a/src/subdomains/lightning/lightning-forward.module.ts +++ b/src/subdomains/lightning/lightning-forward.module.ts @@ -1,21 +1,24 @@ import { Module, forwardRef } from '@nestjs/common'; import { LightningModule } from 'src/integration/blockchain/lightning/lightning.module'; import { UmaModule } from 'src/integration/blockchain/uma/uma.module'; +import { SharedModule } from 'src/shared/shared.module'; import { EvmModule } from '../evm/evm.module'; import { UserModule } from '../user/user.module'; import { LightingBoltcardsForwardController } from './controllers/lightning-boltcards-forward.controller'; import { LightingLndhubForwardController } from './controllers/lightning-lndhub-forward.controller'; import { LightingLnurlwForwardController } from './controllers/lightning-lnurlw-forward.controller'; import { LightingWellknownForwardController } from './controllers/lightning-wellknown-forward.controller'; +import { LightningThunderhubHealthController } from './controllers/lightning-thunderhub-health.controller'; import { LightningForwardService } from './services/lightning-forward.service'; @Module({ - imports: [LightningModule, forwardRef(() => UmaModule), UserModule, EvmModule], + imports: [SharedModule, LightningModule, forwardRef(() => UmaModule), UserModule, EvmModule], controllers: [ LightingLndhubForwardController, LightingBoltcardsForwardController, LightingWellknownForwardController, LightingLnurlwForwardController, + LightningThunderhubHealthController, ], providers: [LightningForwardService], exports: [LightningForwardService], From 0eaf2da2a6c474d16fc6934dfbd6114917e64b1e Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 8 May 2026 16:24:03 +0200 Subject: [PATCH 06/12] fix: add X-Forwarded-Proto/Host headers to LNBits calls (#183) LNBits generates LNURLs from the incoming request URL. In the Docker setup, internal calls arrive as http://lnbits:5000/... which fails LNURL validation (requires HTTPS or localhost). Use Config.baseUrl (dev.lightning.space / lightning.space / localhost) to set Host and X-Forwarded-Proto headers on all LNBits HTTP calls. --- .../blockchain/lightning/lightning-client.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/integration/blockchain/lightning/lightning-client.ts b/src/integration/blockchain/lightning/lightning-client.ts index 49b640100..3c30d810e 100644 --- a/src/integration/blockchain/lightning/lightning-client.ts +++ b/src/integration/blockchain/lightning/lightning-client.ts @@ -1,7 +1,7 @@ import { HttpException } from '@nestjs/common'; import { IncomingHttpHeaders } from 'http'; import { Agent } from 'https'; -import { Config } from 'src/config/config'; +import { Config, Environment } from 'src/config/config'; import { HttpRequestConfig, HttpService } from 'src/shared/services/http.service'; import { LightningLogger } from 'src/shared/services/lightning-logger'; import { Util } from 'src/shared/utils/util'; @@ -596,10 +596,18 @@ export class LightningClient { httpsAgent: new Agent({ ca: Config.blockchain.lightning.certificate, }), + headers: this.lnBitsForwardHeaders(), params: { 'api-key': adminKey, ...params }, }; } + private lnBitsForwardHeaders(): Record { + return { + Host: Config.baseUrl, + 'X-Forwarded-Proto': Config.environment === Environment.LOC ? 'http' : 'https', + }; + } + private httpLndConfig(params?: any): HttpRequestConfig { return { httpsAgent: new Agent({ From 0b82b74c1da6372f06e7fca3fcc5e25a00e6bd55 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 8 May 2026 16:41:04 +0200 Subject: [PATCH 07/12] fix: remove non-existent version column from chainSwaps query (#184) The swap stats query selects cs.version from chainSwaps, but this column does not exist in the Boltz database schema (verified against both LDS fork and upstream BoltzExchange/boltz-backend v3.13.0). This causes /v1/support/swaps to fail with "column cs.version does not exist". --- src/subdomains/support/dto/swap-stats.dto.ts | 3 --- src/subdomains/support/services/support.service.ts | 3 +-- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/subdomains/support/dto/swap-stats.dto.ts b/src/subdomains/support/dto/swap-stats.dto.ts index c4061b061..01f52e46b 100644 --- a/src/subdomains/support/dto/swap-stats.dto.ts +++ b/src/subdomains/support/dto/swap-stats.dto.ts @@ -76,9 +76,6 @@ export class SwapDto { @ApiPropertyOptional({ description: 'Preimage (revealed after claim)' }) preimage?: string; - @ApiPropertyOptional({ description: 'Swap version' }) - version?: number; - // Source chain details @ApiProperty() sourceSymbol: string; diff --git a/src/subdomains/support/services/support.service.ts b/src/subdomains/support/services/support.service.ts index 22f5406fb..0715bb2da 100644 --- a/src/subdomains/support/services/support.service.ts +++ b/src/subdomains/support/services/support.service.ts @@ -333,7 +333,7 @@ export class SupportService implements OnModuleDestroy { // Fetch chain swaps with their data (including preimageHash and preimage for claim TX lookup) const chainSwapsResult = await pool.query(` SELECT cs.id, cs.pair, cs."orderSide", cs.status, cs."failureReason", cs.fee, - cs.referral, cs."createdAt", cs."updatedAt", cs."preimageHash", cs.preimage, cs.version, + cs.referral, cs."createdAt", cs."updatedAt", cs."preimageHash", cs.preimage, sd_base.symbol as base_symbol, sd_base."lockupAddress" as base_lockup, sd_base."claimAddress" as base_claim, sd_base."expectedAmount" as base_expected, sd_base.amount as base_amount, sd_base."transactionId" as base_tx, @@ -442,7 +442,6 @@ export class SupportService implements OnModuleDestroy { // Crypto details preimageHash: preimageHash || undefined, preimage: (row.preimage as string) || undefined, - version: row.version as number | undefined, // Source chain sourceSymbol, sourceChainId, From c4db6a5b7306bb65050dc844fa69569ac9b4a506 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 8 May 2026 18:15:40 +0200 Subject: [PATCH 08/12] fix: hardcode EVM chain IDs instead of reading from env vars (#185) Chain IDs are fixed per chain (Ethereum=1, Polygon=137, etc.), not deployment-specific secrets. The env vars contained stale Goerli/Mumbai testnet IDs from the Azure DEV setup, causing the Alchemy SDK to query deprecated testnet endpoints. --- src/config/config.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/config/config.ts b/src/config/config.ts index 0e7286aae..7cd4da60a 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -129,36 +129,36 @@ export class Configuration { ethereum: { gatewayUrl: process.env.ETHEREUM_GATEWAY_URL ?? '', apiKey: process.env.ALCHEMY_API_KEY ?? '', - chainId: +(process.env.ETHEREUM_CHAIN_ID ?? -1), + chainId: 1, walletAddress: process.env.EVM_PAYMENT_ADDRESS ?? '', }, arbitrum: { gatewayUrl: process.env.ARBITRUM_GATEWAY_URL ?? '', apiKey: process.env.ALCHEMY_API_KEY ?? '', - chainId: +(process.env.ARBITRUM_CHAIN_ID ?? -1), + chainId: 42161, walletAddress: process.env.EVM_PAYMENT_ADDRESS ?? '', }, optimism: { gatewayUrl: process.env.OPTIMISM_GATEWAY_URL ?? '', apiKey: process.env.ALCHEMY_API_KEY ?? '', - chainId: +(process.env.OPTIMISM_CHAIN_ID ?? -1), + chainId: 10, walletAddress: process.env.EVM_PAYMENT_ADDRESS ?? '', }, polygon: { gatewayUrl: process.env.POLYGON_GATEWAY_URL ?? '', apiKey: process.env.ALCHEMY_API_KEY ?? '', - chainId: +(process.env.POLYGON_CHAIN_ID ?? -1), + chainId: 137, walletAddress: process.env.EVM_PAYMENT_ADDRESS ?? '', }, base: { gatewayUrl: process.env.BASE_GATEWAY_URL ?? '', apiKey: process.env.ALCHEMY_API_KEY ?? '', - chainId: +(process.env.BASE_CHAIN_ID ?? -1), + chainId: 8453, walletAddress: process.env.EVM_PAYMENT_ADDRESS ?? '', }, citrea: { gatewayUrl: process.env.CITREA_GATEWAY_URL ?? '', - chainId: +(process.env.CITREA_CHAIN_ID ?? -1), + chainId: 5115, }, }; From 984ebc6324244289222c3d6ceda8b39af81808c2 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 8 May 2026 18:27:29 +0200 Subject: [PATCH 09/12] fix: hardcode Alchemy gateway URLs instead of reading from env vars (#186) Alchemy gateway URLs follow a fixed pattern per chain (e.g. https://eth-mainnet.g.alchemy.com/v2). The env vars contained stale Goerli/Mumbai testnet URLs causing NETWORK_ERROR in the ethers.js provider used by MonitoringService for ERC20 contract calls. Only ALCHEMY_API_KEY remains as env var (it's a secret). Citrea keeps its env var (non-Alchemy, deployment-specific RPC). --- src/config/config.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/config/config.ts b/src/config/config.ts index 7cd4da60a..e8648b226 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -127,31 +127,31 @@ export class Configuration { }, }, ethereum: { - gatewayUrl: process.env.ETHEREUM_GATEWAY_URL ?? '', + gatewayUrl: 'https://eth-mainnet.g.alchemy.com/v2', apiKey: process.env.ALCHEMY_API_KEY ?? '', chainId: 1, walletAddress: process.env.EVM_PAYMENT_ADDRESS ?? '', }, arbitrum: { - gatewayUrl: process.env.ARBITRUM_GATEWAY_URL ?? '', + gatewayUrl: 'https://arb-mainnet.g.alchemy.com/v2', apiKey: process.env.ALCHEMY_API_KEY ?? '', chainId: 42161, walletAddress: process.env.EVM_PAYMENT_ADDRESS ?? '', }, optimism: { - gatewayUrl: process.env.OPTIMISM_GATEWAY_URL ?? '', + gatewayUrl: 'https://opt-mainnet.g.alchemy.com/v2', apiKey: process.env.ALCHEMY_API_KEY ?? '', chainId: 10, walletAddress: process.env.EVM_PAYMENT_ADDRESS ?? '', }, polygon: { - gatewayUrl: process.env.POLYGON_GATEWAY_URL ?? '', + gatewayUrl: 'https://polygon-mainnet.g.alchemy.com/v2', apiKey: process.env.ALCHEMY_API_KEY ?? '', chainId: 137, walletAddress: process.env.EVM_PAYMENT_ADDRESS ?? '', }, base: { - gatewayUrl: process.env.BASE_GATEWAY_URL ?? '', + gatewayUrl: 'https://base-mainnet.g.alchemy.com/v2', apiKey: process.env.ALCHEMY_API_KEY ?? '', chainId: 8453, walletAddress: process.env.EVM_PAYMENT_ADDRESS ?? '', From 4bd5cacf9e95eaf9d6602df1ba74b19545a4902b Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 12 May 2026 23:42:29 +0200 Subject: [PATCH 10/12] fix(pricing): await currencies load in CoinGeckoService.onModuleInit (#187) The onModuleInit method used void-promise pattern, leaving this.currencies undefined until the async call resolved. If MonitoringService cron fired before that (which it does on container start), getCurrency() crashed with "Cannot read properties of undefined (reading 'find')". Make onModuleInit properly async with try/catch to preserve original resilience (CoinGecko outage at startup should not crash app startup) while adding error logging. --- src/subdomains/pricing/services/coingecko.service.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/subdomains/pricing/services/coingecko.service.ts b/src/subdomains/pricing/services/coingecko.service.ts index 8fdcd09e2..89df03f06 100644 --- a/src/subdomains/pricing/services/coingecko.service.ts +++ b/src/subdomains/pricing/services/coingecko.service.ts @@ -9,14 +9,18 @@ export class CoinGeckoService implements OnModuleInit { private readonly logger = new LightningLogger(CoinGeckoService); private readonly client: CoinGeckoClient; - private currencies: string[]; + private currencies: string[] = []; constructor() { this.client = new CoinGeckoClient({ autoRetry: false }, GetConfig().coinGecko.apiKey); } - onModuleInit() { - void this.client.simpleSupportedCurrencies().then((cs) => (this.currencies = cs)); + async onModuleInit(): Promise { + try { + this.currencies = await this.client.simpleSupportedCurrencies(); + } catch (e) { + this.logger.error('Failed to load CoinGecko currencies on startup', e); + } } async getPrice(from: string, to: string): Promise { From 04ea76115a8782fa51096f52efe58430892b814a Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 15 May 2026 20:33:44 +0200 Subject: [PATCH 11/12] Route CoinGecko via in-cluster pricing-proxy (#188) The LDS API was the last service still hitting CoinGecko directly with its own pro key. That key shares a CoinGecko account with the DFX API, and the joint usage on that account ran ~17% over the monthly quota. Switch CoinGeckoService to talk to a configurable COINGECKO_BASE_URL (default: http://pricing-proxy:8080/coingecko in the cluster), drop the coingecko-api-v3 npm dependency in favour of a small axios client that calls /api/v3/simple/supported_vs_currencies and /api/v3/simple/price directly, and let the proxy own the upstream key. When COIN_GECKO_API_KEY is set we still send it as x-cg-pro-api-key so the same code path works against pro-api.coingecko.com directly in local dev. --- .env.example | 8 + package-lock.json | 16 -- package.json | 1 - src/config/config.ts | 5 + .../pricing/services/coingecko.service.ts | 146 ++++++++++-------- 5 files changed, 94 insertions(+), 82 deletions(-) diff --git a/.env.example b/.env.example index c27d4078e..2f7c4fda3 100644 --- a/.env.example +++ b/.env.example @@ -35,3 +35,11 @@ PONDER_PG_PORT=5432 PONDER_PG_DATABASE= PONDER_PG_USER= PONDER_PG_PASSWORD= + +# CoinGecko endpoint. The production deployment routes through the in-cluster +# pricing-proxy (https://github.com/DFXswiss/pricing-proxy) which holds the +# upstream key and shares a 60 s cache + monthly quota monitor with the rest +# of the stack. In local dev you can point this at CoinGecko directly and +# supply COIN_GECKO_API_KEY (Pro plans need it; the free tier does not). +COINGECKO_BASE_URL=http://pricing-proxy:8080/coingecko +COIN_GECKO_API_KEY= diff --git a/package-lock.json b/package-lock.json index 7cf54c1d1..ec4257900 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,6 @@ "bolt11": "^1.4.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", - "coingecko-api-v3": "^0.0.29", "ethers": "^5.8.0", "helmet": "^6.2.0", "http-proxy-middleware": "^3.0.5", @@ -6468,15 +6467,6 @@ "node": ">= 0.12.0" } }, - "node_modules/coingecko-api-v3": { - "version": "0.0.29", - "resolved": "https://registry.npmjs.org/coingecko-api-v3/-/coingecko-api-v3-0.0.29.tgz", - "integrity": "sha512-4aF0mU6Pwmo78W4NsPbMslU3ooeSa2Dq8a8PFR+5+wWcWvTBMiUZgfy9UvybynYVsnvYCoyxtNwxTiMZBeZh4w==", - "license": "MIT", - "dependencies": { - "https": "^1.0.0" - } - }, "node_modules/collect-v8-coverage": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", @@ -9066,12 +9056,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/https": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz", - "integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==", - "license": "ISC" - }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", diff --git a/package.json b/package.json index f3bf8ec00..a812f22bf 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,6 @@ "bolt11": "^1.4.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", - "coingecko-api-v3": "^0.0.29", "ethers": "^5.8.0", "helmet": "^6.2.0", "http-proxy-middleware": "^3.0.5", diff --git a/src/config/config.ts b/src/config/config.ts index e8648b226..39759bc9d 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -178,6 +178,11 @@ export class Configuration { }; coinGecko = { + // Origin the CoinGeckoService talks to. Defaults to the in-cluster + // pricing-proxy so the upstream key + 60 s cache + quota monitor live + // in one place; set to `https://pro-api.coingecko.com` (or + // `https://api.coingecko.com`) to bypass the proxy in local dev. + baseUrl: process.env.COINGECKO_BASE_URL, apiKey: process.env.COIN_GECKO_API_KEY, }; diff --git a/src/subdomains/pricing/services/coingecko.service.ts b/src/subdomains/pricing/services/coingecko.service.ts index 89df03f06..059b23045 100644 --- a/src/subdomains/pricing/services/coingecko.service.ts +++ b/src/subdomains/pricing/services/coingecko.service.ts @@ -1,65 +1,81 @@ -import { Injectable, OnModuleInit, ServiceUnavailableException } from '@nestjs/common'; -import { CoinGeckoClient } from 'coingecko-api-v3'; -import { GetConfig } from 'src/config/config'; -import { LightningLogger } from 'src/shared/services/lightning-logger'; -import { Price } from '../../support/dto/price.dto'; - -@Injectable() -export class CoinGeckoService implements OnModuleInit { - private readonly logger = new LightningLogger(CoinGeckoService); - - private readonly client: CoinGeckoClient; - private currencies: string[] = []; - - constructor() { - this.client = new CoinGeckoClient({ autoRetry: false }, GetConfig().coinGecko.apiKey); - } - - async onModuleInit(): Promise { - try { - this.currencies = await this.client.simpleSupportedCurrencies(); - } catch (e) { - this.logger.error('Failed to load CoinGecko currencies on startup', e); - } - } - - async getPrice(from: string, to: string): Promise { - const fromCurrency = this.getCurrency(from); - const toCurrency = this.getCurrency(to); - - if (fromCurrency && toCurrency) { - const [priceFrom, priceTo] = await Promise.all([ - this.fetchPrice('tether', fromCurrency), - this.fetchPrice('tether', toCurrency), - ]); - return Price.join(priceFrom.invert(), priceTo); - } else if (fromCurrency) { - const price = await this.fetchPrice(to, fromCurrency); - return price.invert(); - } else if (toCurrency) { - return this.fetchPrice(from, toCurrency); - } else { - const [priceFrom, priceTo] = await Promise.all([this.fetchPrice(from, 'usd'), this.fetchPrice(to, 'usd')]); - return Price.join(priceFrom, priceTo.invert()); - } - } - - // --- HELPER METHODS --- // - - private async fetchPrice(token: string, currency: string): Promise { - try { - const data = await this.client.simplePrice({ ids: token, vs_currencies: currency }); - const price = data[token]?.[currency]; - if (!price) throw new Error('Price not found'); - - return Price.create(token, currency, 1 / price); - } catch (e) { - this.logger.error(`Failed to get price for ${token} -> ${currency}:`, e); - throw new ServiceUnavailableException(`Failed to get price`); - } - } - - private getCurrency(token: string): string | undefined { - return this.currencies.find((c) => c === token.toLowerCase()); - } -} +import { Injectable, OnModuleInit, ServiceUnavailableException } from '@nestjs/common'; +import axios, { AxiosInstance } from 'axios'; +import { GetConfig } from 'src/config/config'; +import { LightningLogger } from 'src/shared/services/lightning-logger'; +import { Price } from '../../support/dto/price.dto'; + +// Talks to a CoinGecko-compatible endpoint. The default deployment routes +// through the in-cluster pricing-proxy (https://github.com/DFXswiss/pricing-proxy) +// so the upstream key lives in one place and the 60 s cache + quota monitor +// apply. Setting `COINGECKO_BASE_URL` to `https://pro-api.coingecko.com` (or +// `https://api.coingecko.com`) makes the service talk to CoinGecko directly, +// in which case `COINGECKO_API_KEY` is attached as `x-cg-pro-api-key`. +@Injectable() +export class CoinGeckoService implements OnModuleInit { + private readonly logger = new LightningLogger(CoinGeckoService); + + private readonly http: AxiosInstance; + private currencies: string[] = []; + + constructor() { + const baseUrl = GetConfig().coinGecko.baseUrl; + if (!baseUrl) throw new Error('COINGECKO_BASE_URL is not set'); + + const headers: Record = { Accept: 'application/json' }; + const apiKey = GetConfig().coinGecko.apiKey; + if (apiKey) headers['x-cg-pro-api-key'] = apiKey; + + this.http = axios.create({ baseURL: baseUrl, headers, timeout: 10_000 }); + } + + async onModuleInit(): Promise { + try { + const { data } = await this.http.get('/api/v3/simple/supported_vs_currencies'); + this.currencies = Array.isArray(data) ? data : []; + } catch (e) { + this.logger.error('Failed to load CoinGecko currencies on startup', e); + } + } + + async getPrice(from: string, to: string): Promise { + const fromCurrency = this.getCurrency(from); + const toCurrency = this.getCurrency(to); + + if (fromCurrency && toCurrency) { + const [priceFrom, priceTo] = await Promise.all([ + this.fetchPrice('tether', fromCurrency), + this.fetchPrice('tether', toCurrency), + ]); + return Price.join(priceFrom.invert(), priceTo); + } else if (fromCurrency) { + const price = await this.fetchPrice(to, fromCurrency); + return price.invert(); + } else if (toCurrency) { + return this.fetchPrice(from, toCurrency); + } else { + const [priceFrom, priceTo] = await Promise.all([this.fetchPrice(from, 'usd'), this.fetchPrice(to, 'usd')]); + return Price.join(priceFrom, priceTo.invert()); + } + } + + // --- HELPER METHODS --- // + + private async fetchPrice(token: string, currency: string): Promise { + try { + const { data } = await this.http.get>>('/api/v3/simple/price', { + params: { ids: token, vs_currencies: currency }, + }); + const price = data?.[token]?.[currency]; + if (!price) throw new Error('Price not found'); + + return Price.create(token, currency, 1 / price); + } catch (e) { + this.logger.error(`Failed to get price for ${token} -> ${currency}:`, e); + throw new ServiceUnavailableException(`Failed to get price`); + } + } + + private getCurrency(token: string): string | undefined { + return this.currencies.find((c) => c === token.toLowerCase()); + } +} From 56072e525b98243ec3675c51a4d7414962e5cf12 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 16 May 2026 15:15:34 +0200 Subject: [PATCH 12/12] Relax DEV balance thresholds to silence cron alerts (#190) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Relax DEV balance thresholds to silence cron alerts DEV wallets carry test funds (sub-dollar amounts), so the PRD thresholds (1000 USDT, 0.1 cBTC, 1 BTC Lightning capacity) fire LOW alerts on every 5-min tick. Keep the same asset list on DEV but with min=0 / max=Infinity so balance < min and balance > max are never satisfied — the cron, RPC balance fetch and Telegram pipeline stay exercised on DEV with no noise. PRD list is unchanged. * Only lower DEV minBalance, keep maxBalance unchanged Address review: requirement was to lower DEV min thresholds, not to also disable HIGH alerts. minBalance=0 silences LOW alerts (balance < 0 is never satisfied for non-negative wallet balances) while leaving the maxBalance bounds as-is so HIGH alerts still fire if a DEV wallet ever overshoots the PRD-shaped ceiling. * Add sync reminder between DEV and PRD threshold lists --- src/config/balance-thresholds.config.ts | 36 ++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/src/config/balance-thresholds.config.ts b/src/config/balance-thresholds.config.ts index 1dd9726c2..327527944 100644 --- a/src/config/balance-thresholds.config.ts +++ b/src/config/balance-thresholds.config.ts @@ -9,7 +9,7 @@ export interface BalanceThreshold { direction?: Direction; } -export const BALANCE_THRESHOLDS: BalanceThreshold[] = [ +const PRD_BALANCE_THRESHOLDS: BalanceThreshold[] = [ // Bitcoin { blockchain: Blockchain.BITCOIN, asset: 'BTC', minBalance: 0.1, maxBalance: 1 }, @@ -32,3 +32,37 @@ export const BALANCE_THRESHOLDS: BalanceThreshold[] = [ { blockchain: Blockchain.POLYGON, asset: 'POL', minBalance: 1, maxBalance: 100 }, { blockchain: Blockchain.POLYGON, asset: 'USDT', minBalance: 1000, maxBalance: 100000 }, ]; + +// DEV wallets carry test funds (single-digit USDT, milli-cBTC), so the PRD +// minimums fire every cron tick. Lower minBalance below the DEV funding +// levels so no LOW alert fires; maxBalance unchanged. +// +// Keep this list in sync with PRD_BALANCE_THRESHOLDS above — adding a new +// asset to PRD without adding it here means the new asset's alerting path +// is never exercised on DEV before it hits production. +const DEV_BALANCE_THRESHOLDS: BalanceThreshold[] = [ + // Bitcoin + { blockchain: Blockchain.BITCOIN, asset: 'BTC', minBalance: 0, maxBalance: 1 }, + + // Lightning (Onchain, Outgoing and Incoming Channels) + { blockchain: Blockchain.LIGHTNING, asset: 'BTC', minBalance: 0, maxBalance: 1 }, + { blockchain: Blockchain.LIGHTNING, asset: 'BTC', minBalance: 0, maxBalance: 5, direction: Direction.OUTGOING }, + { blockchain: Blockchain.LIGHTNING, asset: 'BTC', minBalance: 0, maxBalance: 5, direction: Direction.INCOMING }, + + // Citrea + { blockchain: Blockchain.CITREA, asset: 'cBTC', minBalance: 0, maxBalance: 1 }, + { blockchain: Blockchain.CITREA, asset: 'JUSD', minBalance: 0, maxBalance: 100000 }, + + // Ethereum + { blockchain: Blockchain.ETHEREUM, asset: 'ETH', minBalance: 0, maxBalance: 0.1 }, + { blockchain: Blockchain.ETHEREUM, asset: 'USDC', minBalance: 0, maxBalance: 100000 }, + { blockchain: Blockchain.ETHEREUM, asset: 'USDT', minBalance: 0, maxBalance: 100000 }, + { blockchain: Blockchain.ETHEREUM, asset: 'WBTC', minBalance: 0, maxBalance: 1 }, + + // Polygon + { blockchain: Blockchain.POLYGON, asset: 'POL', minBalance: 0, maxBalance: 100 }, + { blockchain: Blockchain.POLYGON, asset: 'USDT', minBalance: 0, maxBalance: 100000 }, +]; + +export const BALANCE_THRESHOLDS: BalanceThreshold[] = + process.env.ENVIRONMENT === 'prd' ? PRD_BALANCE_THRESHOLDS : DEV_BALANCE_THRESHOLDS;