From 7917a7413cbcee5d7a8d5395b074d665e3d8dd31 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 13 Nov 2025 11:53:53 +0100 Subject: [PATCH 01/22] README.md aktualisieren Do not merge this state into PRD! --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 48d4ead2..88fc4cec 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ # lightning.space API API for lightning.space custodial service + +Do not merge this state into PRD! From 538fb2b7adef35889282fe48b390400d631cb38e Mon Sep 17 00:00:00 2001 From: bernd2022 <104787072+bernd2022@users.noreply.github.com> Date: Fri, 14 Nov 2025 14:26:46 +0100 Subject: [PATCH 02/22] [DEV-4458] Update infrastructure (#87) * [DEV-4458] Update infrastructure * [DEV-4458] Changes after review * [DEV-4458] Change because of renaming compose file * [DEV-4458] Add second volume again --- infrastructure/README.md | 17 +- .../config/bitcoin/dev-bitcoin.conf | 12 +- .../config/bitcoin/prd-bitcoin.conf | 1 + .../config/boltz/backend/dev-boltz.conf | 150 ++++++++++++++++++ .../config/boltz/backend/prd-boltz.conf | 150 ++++++++++++++++++ .../docker/dev-docker-compose-boltz.yml | 93 +++++++++++ ...e.yml => dev-docker-compose-lightning.yml} | 89 +++++++---- .../docker/dev-docker-compose-nginx.yml | 24 +++ .../docker/prd-docker-compose-boltz.yml | 93 +++++++++++ ...e.yml => prd-docker-compose-lightning.yml} | 85 ++++++---- .../docker/prd-docker-compose-nginx.yml | 24 +++ infrastructure/config/lightning/dev-lnd.conf | 4 + infrastructure/config/lightning/prd-pwd.txt | 1 + infrastructure/config/lnbits/dev.env | 6 +- infrastructure/config/lnbits/prd.env | 2 +- infrastructure/config/nginx/dev-default.conf | 71 +++++++++ infrastructure/config/nginx/prd-default.conf | 71 +++++++++ infrastructure/config/taproot/dev-tapd.conf | 10 +- .../config/thunderhub/dev-accounts.yml | 2 +- infrastructure/config/thunderhub/dev.env | 2 +- infrastructure/scripts/dev-docker-compose.sh | 7 - .../scripts/dev-runBackup-exclude-file.txt | 3 - ...rd-docker-compose.sh => docker-compose.sh} | 2 +- ...de-file.txt => runBackup-exclude-file.txt} | 0 infrastructure/scripts/setupEnv.sh | 21 +-- 25 files changed, 843 insertions(+), 97 deletions(-) create mode 100644 infrastructure/config/boltz/backend/dev-boltz.conf create mode 100644 infrastructure/config/boltz/backend/prd-boltz.conf create mode 100644 infrastructure/config/docker/dev-docker-compose-boltz.yml rename infrastructure/config/docker/{dev-docker-compose.yml => dev-docker-compose-lightning.yml} (58%) create mode 100644 infrastructure/config/docker/dev-docker-compose-nginx.yml create mode 100644 infrastructure/config/docker/prd-docker-compose-boltz.yml rename infrastructure/config/docker/{prd-docker-compose.yml => prd-docker-compose-lightning.yml} (62%) create mode 100644 infrastructure/config/docker/prd-docker-compose-nginx.yml create mode 100644 infrastructure/config/lightning/prd-pwd.txt delete mode 100644 infrastructure/scripts/dev-docker-compose.sh delete mode 100644 infrastructure/scripts/dev-runBackup-exclude-file.txt rename infrastructure/scripts/{prd-docker-compose.sh => docker-compose.sh} (71%) rename infrastructure/scripts/{prd-runBackup-exclude-file.txt => runBackup-exclude-file.txt} (100%) diff --git a/infrastructure/README.md b/infrastructure/README.md index 2a570978..337ab128 100644 --- a/infrastructure/README.md +++ b/infrastructure/README.md @@ -14,8 +14,11 @@ 1. Execute script: `sudo ./setupDocker.sh` 1. Copy script `infrastructure/scripts/setupEnv.sh` to virtual machine `~/setupEnv.sh` 1. Execute script: `./setupEnv.sh` -1. Copy script `infrastructure/scripts/{env}-docker-compose.sh` to virtual machine `~/docker-compose.sh` -1. Copy file `infrastructure/config/docker/{env}-docker-compose.yml` to virtual machine `~/docker-compose.yml` +1. Create docker network `docker network create lightning-network` +1. Copy script `infrastructure/scripts/docker-compose.sh` to virtual machine `~/docker-compose.sh` +1. Copy file `infrastructure/config/docker/{env}-docker-compose-lightning.yml` to virtual machine `~/docker-compose-lightning.yml` +1. Copy file `infrastructure/config/docker/{env}-docker-compose-nginx.yml` to virtual machine `~/docker-compose-nginx.yml` +1. Copy file `infrastructure/config/docker/{env}-docker-compose-boltz.yml` to virtual machine `~/docker-compose-boltz.yml` 1. Execute Docker Compose (see [below](#docker-compose)) after all other setup steps are done: 1. [Bitcoin Node Setup](#bitcoin-node-setup-bitcoind) 1. [Lightning Node Setup](#lightning-node-setup-lnd) @@ -23,6 +26,7 @@ 1. [LNbits Setup](#lnbits-setup) 1. [ThunderHub Setup](#thunderhub-setup) 1. [NGINX Setup](#nginx-setup) + 1. [Boltz Setup](#boltz-setup) # Bitcoin Node Setup (bitcoind) @@ -59,6 +63,15 @@ 1. Copy content of config file `infrastructure/config/nginx/{env}-default.conf` to virtual machine `~/volumes/nginx/default.conf` +# Boltz Setup + +1. Copy content of config file `infrastructure/config/boltz/backend/{env}-boltz.conf` to virtual machine `~/volumes/boltz/backend/boltz.conf` +1. `boltz.conf`: Replace + 1. `[POSTGRES_DATABASE]` / `[POSTGRES_USERNAME]` / `[POSTGRES_PASSWORD]` + 1. `[RPC_USER]` / `[RPC_PASSWORD]` + 1. `[WALLET_NAME]` + 1. `[PROVIDER_ENDPOINT]` + # Docker Compose The complete Bitcoin Blockchain data is loaded after the very first startup of the bitcoin node. Therefore it is recommended to copy already available blockchain data to the `~/volumes/bitcoin/...` directory. diff --git a/infrastructure/config/bitcoin/dev-bitcoin.conf b/infrastructure/config/bitcoin/dev-bitcoin.conf index 3018e8cc..870156b3 100644 --- a/infrastructure/config/bitcoin/dev-bitcoin.conf +++ b/infrastructure/config/bitcoin/dev-bitcoin.conf @@ -1,16 +1,14 @@ -testnet=1 server=1 rest=1 +txindex=1 rpcallowip=0.0.0.0/0 +rpcbind=0.0.0.0 +rpcport=8332 rpcauth=[RPC-AUTH] -zmqpubrawblock=tcp://0.0.0.0:28332 -zmqpubrawtx=tcp://0.0.0.0:28333 - -[test] wallet=[WALLET] addresstype=p2sh-segwit -rpcbind=0.0.0.0 -rpcport=18332 +zmqpubrawblock=tcp://0.0.0.0:28332 +zmqpubrawtx=tcp://0.0.0.0:28333 diff --git a/infrastructure/config/bitcoin/prd-bitcoin.conf b/infrastructure/config/bitcoin/prd-bitcoin.conf index a93bdbcf..870156b3 100644 --- a/infrastructure/config/bitcoin/prd-bitcoin.conf +++ b/infrastructure/config/bitcoin/prd-bitcoin.conf @@ -1,5 +1,6 @@ server=1 rest=1 +txindex=1 rpcallowip=0.0.0.0/0 rpcbind=0.0.0.0 diff --git a/infrastructure/config/boltz/backend/dev-boltz.conf b/infrastructure/config/boltz/backend/dev-boltz.conf new file mode 100644 index 00000000..5c1d50ad --- /dev/null +++ b/infrastructure/config/boltz/backend/dev-boltz.conf @@ -0,0 +1,150 @@ +# Boltz Backend Configuration - Mainnet +# Integration with existing Lightning.space infrastructure + +# Network Configuration - MUST be at top level before any sections! +network = "mainnet" + +loglevel = "debug" + +[api] +host = "0.0.0.0" +port = 9001 +allowedCors = ["*"] + +[grpc] +host = "0.0.0.0" +port = 9000 + +# Sidecar Configuration (Rust component) +[sidecar] + [sidecar.grpc] + host = "0.0.0.0" + port = 9003 + certificates = "/root/.boltz/sidecar/certificates" + + [sidecar.ws] + host = "0.0.0.0" + port = 9004 + + [sidecar.api] + host = "0.0.0.0" + port = 9005 + +# PostgreSQL Database +[postgres] +host = "postgres" +port = 5432 +database = "[POSTGRES_DATABASE]" +username = "[POSTGRES_USERNAME]" +password = "[POSTGRES_PASSWORD]" + +# Redis Cache (optional but recommended) +[cache] +redisEndpoint = "redis://redis:6379" + +# Bitcoin/Lightning Configuration +[[currencies]] +symbol = "BTC" +network = "bitcoinMainnet" + +# Wallet balances - adjusted for testing +minWalletBalance = 100_000 # 0.001 BTC (100k sats) +minChannelBalance = 100_000 # 0.001 BTC (100k sats) + +# Swap limits +maxSwapAmount = 10_000_000 # 0.1 BTC maximum +minSwapAmount = 2_500 # 2,500 sats minimum (currency level - for all BTC swaps) +maxZeroConfAmount = 0 # Disable 0-conf for security + + # Bitcoin Core Configuration + [currencies.chain] + host = "bitcoind" + port = 8332 + + # RPC Authentication + user = "[RPC_USER]" + password = "[RPC_PASSWORD]" + + # ZMQ endpoints for blockchain notifications + zmqpubrawtx = "tcp://bitcoind:28333" + zmqpubrawblock = "tcp://bitcoind:28332" + + # Bitcoin wallet name + wallet = "[WALLET_NAME]" + + # LND Configuration + [currencies.lnd] + host = "lnd" + port = 10009 # gRPC port (not REST 8080!) + + # Credentials - mounted from Docker volumes + certpath = "/root/.lnd/tls.cert" + macaroonpath = "/root/.lnd/data/chain/bitcoin/mainnet/admin.macaroon" + +# Swap Pair Configuration: BTC/BTC (Lightning <-> OnChain) +[[pairs]] +base = "BTC" +quote = "BTC" +rate = 1 # 1:1 exchange rate + +# Fee configuration (in percent) +fee = 0.5 # 0.5% service fee +swapInFee = 0.25 # 0.25% for submarine swaps (chain -> lightning) + +# Swap amount limits (in satoshis) +maxSwapAmount = 10_000_000 # 0.1 BTC +minSwapAmount = 2_500 # 2,500 sats (pair level) + + # Submarine Swap specific settings (Chain -> Lightning) + [pairs.submarineSwap] + minSwapAmount = 10_000 # Minimum for submarine swaps (needs high limit due to on-chain fees) + + # Reverse Swap specific settings (Lightning -> Chain) + [pairs.reverseSwap] + minSwapAmount = 2_500 # Minimum for reverse swaps (lower fees, no input tx) + + # Timeout configuration (in minutes!) + [pairs.timeoutDelta] + chain = 1440 # Chain swap timeout (~24 hours = 144 blocks) + reverse = 1440 # ~24 hours for reverse swaps (lightning -> chain) + swapMinimal = 1440 # Minimum timeout for submarine swaps (~24 hours = 144 blocks) + swapMaximal = 2880 # Maximum timeout (~48 hours = 288 blocks) + swapTaproot = 10080 # 1 week for taproot swaps (10080 blocks) + +# Swap Pair Configuration: BTC/RBTC (Lightning BTC <-> RSK RBTC) +[[pairs]] +base = "BTC" +quote = "RBTC" +rate = 1 # 1:1 peg between BTC and RBTC + +# Fee configuration (in percent) +fee = 0.25 +swapInFee = 0.1 + +# Swap amount limits (in satoshis) +maxSwapAmount = 10_000_000 # 0.1 BTC/RBTC +minSwapAmount = 2_500 # 2,500 sats minimum (Rootstock has lower fees) + + [pairs.timeoutDelta] + chain = 1440 # Chain swap timeout (~24 hours) + reverse = 1440 # Reverse swap timeout (Lightning -> RBTC) + swapMinimal = 1440 # Minimum timeout + swapMaximal = 2880 # Maximum timeout + swapTaproot = 10080 + +# RSK (Rootstock) Configuration +[rsk] +networkName = "RSK Mainnet" +providerEndpoint = "[PROVIDER_ENDPOINT]" + + [[rsk.contracts]] + etherSwap = "0x3d9cc5780CA1db78760ad3D35458509178A85A4A" + erc20Swap = "0x7d5a2187CC8EF75f8822daB0E8C9a2DB147BA045" + + [[rsk.tokens]] + symbol = "RBTC" + + maxSwapAmount = 10_000_000 + minSwapAmount = 2_500 + + minWalletBalance = 10_000 diff --git a/infrastructure/config/boltz/backend/prd-boltz.conf b/infrastructure/config/boltz/backend/prd-boltz.conf new file mode 100644 index 00000000..5c1d50ad --- /dev/null +++ b/infrastructure/config/boltz/backend/prd-boltz.conf @@ -0,0 +1,150 @@ +# Boltz Backend Configuration - Mainnet +# Integration with existing Lightning.space infrastructure + +# Network Configuration - MUST be at top level before any sections! +network = "mainnet" + +loglevel = "debug" + +[api] +host = "0.0.0.0" +port = 9001 +allowedCors = ["*"] + +[grpc] +host = "0.0.0.0" +port = 9000 + +# Sidecar Configuration (Rust component) +[sidecar] + [sidecar.grpc] + host = "0.0.0.0" + port = 9003 + certificates = "/root/.boltz/sidecar/certificates" + + [sidecar.ws] + host = "0.0.0.0" + port = 9004 + + [sidecar.api] + host = "0.0.0.0" + port = 9005 + +# PostgreSQL Database +[postgres] +host = "postgres" +port = 5432 +database = "[POSTGRES_DATABASE]" +username = "[POSTGRES_USERNAME]" +password = "[POSTGRES_PASSWORD]" + +# Redis Cache (optional but recommended) +[cache] +redisEndpoint = "redis://redis:6379" + +# Bitcoin/Lightning Configuration +[[currencies]] +symbol = "BTC" +network = "bitcoinMainnet" + +# Wallet balances - adjusted for testing +minWalletBalance = 100_000 # 0.001 BTC (100k sats) +minChannelBalance = 100_000 # 0.001 BTC (100k sats) + +# Swap limits +maxSwapAmount = 10_000_000 # 0.1 BTC maximum +minSwapAmount = 2_500 # 2,500 sats minimum (currency level - for all BTC swaps) +maxZeroConfAmount = 0 # Disable 0-conf for security + + # Bitcoin Core Configuration + [currencies.chain] + host = "bitcoind" + port = 8332 + + # RPC Authentication + user = "[RPC_USER]" + password = "[RPC_PASSWORD]" + + # ZMQ endpoints for blockchain notifications + zmqpubrawtx = "tcp://bitcoind:28333" + zmqpubrawblock = "tcp://bitcoind:28332" + + # Bitcoin wallet name + wallet = "[WALLET_NAME]" + + # LND Configuration + [currencies.lnd] + host = "lnd" + port = 10009 # gRPC port (not REST 8080!) + + # Credentials - mounted from Docker volumes + certpath = "/root/.lnd/tls.cert" + macaroonpath = "/root/.lnd/data/chain/bitcoin/mainnet/admin.macaroon" + +# Swap Pair Configuration: BTC/BTC (Lightning <-> OnChain) +[[pairs]] +base = "BTC" +quote = "BTC" +rate = 1 # 1:1 exchange rate + +# Fee configuration (in percent) +fee = 0.5 # 0.5% service fee +swapInFee = 0.25 # 0.25% for submarine swaps (chain -> lightning) + +# Swap amount limits (in satoshis) +maxSwapAmount = 10_000_000 # 0.1 BTC +minSwapAmount = 2_500 # 2,500 sats (pair level) + + # Submarine Swap specific settings (Chain -> Lightning) + [pairs.submarineSwap] + minSwapAmount = 10_000 # Minimum for submarine swaps (needs high limit due to on-chain fees) + + # Reverse Swap specific settings (Lightning -> Chain) + [pairs.reverseSwap] + minSwapAmount = 2_500 # Minimum for reverse swaps (lower fees, no input tx) + + # Timeout configuration (in minutes!) + [pairs.timeoutDelta] + chain = 1440 # Chain swap timeout (~24 hours = 144 blocks) + reverse = 1440 # ~24 hours for reverse swaps (lightning -> chain) + swapMinimal = 1440 # Minimum timeout for submarine swaps (~24 hours = 144 blocks) + swapMaximal = 2880 # Maximum timeout (~48 hours = 288 blocks) + swapTaproot = 10080 # 1 week for taproot swaps (10080 blocks) + +# Swap Pair Configuration: BTC/RBTC (Lightning BTC <-> RSK RBTC) +[[pairs]] +base = "BTC" +quote = "RBTC" +rate = 1 # 1:1 peg between BTC and RBTC + +# Fee configuration (in percent) +fee = 0.25 +swapInFee = 0.1 + +# Swap amount limits (in satoshis) +maxSwapAmount = 10_000_000 # 0.1 BTC/RBTC +minSwapAmount = 2_500 # 2,500 sats minimum (Rootstock has lower fees) + + [pairs.timeoutDelta] + chain = 1440 # Chain swap timeout (~24 hours) + reverse = 1440 # Reverse swap timeout (Lightning -> RBTC) + swapMinimal = 1440 # Minimum timeout + swapMaximal = 2880 # Maximum timeout + swapTaproot = 10080 + +# RSK (Rootstock) Configuration +[rsk] +networkName = "RSK Mainnet" +providerEndpoint = "[PROVIDER_ENDPOINT]" + + [[rsk.contracts]] + etherSwap = "0x3d9cc5780CA1db78760ad3D35458509178A85A4A" + erc20Swap = "0x7d5a2187CC8EF75f8822daB0E8C9a2DB147BA045" + + [[rsk.tokens]] + symbol = "RBTC" + + maxSwapAmount = 10_000_000 + minSwapAmount = 2_500 + + minWalletBalance = 10_000 diff --git a/infrastructure/config/docker/dev-docker-compose-boltz.yml b/infrastructure/config/docker/dev-docker-compose-boltz.yml new file mode 100644 index 00000000..896d49be --- /dev/null +++ b/infrastructure/config/docker/dev-docker-compose-boltz.yml @@ -0,0 +1,93 @@ +name: 'boltz' + +services: + postgres: + image: postgres:17 + cpus: 0.5 + shm_size: '1gb' + restart: unless-stopped + networks: + - shared + volumes: + - ./volumes/boltz/postgres:/var/lib/postgresql/data + ports: + - '5432:5432' + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U [POSTGRES_USERNAME]'] + interval: 30s + timeout: 5s + retries: 5 + logging: + driver: 'json-file' + options: + max-size: '100m' + max-file: '3' + environment: + - POSTGRES_DB=[POSTGRES_DATABASE] + - POSTGRES_USER=[POSTGRES_USERNAME] + - POSTGRES_PASSWORD=[POSTGRES_PASSWORD] + command: postgres -c max_connections=100 + + redis: + image: redis:alpine + restart: unless-stopped + networks: + - shared + volumes: + - ./volumes/boltz/redis/data:/data + ports: + - '6379:6379' + logging: + driver: 'json-file' + options: + max-size: '100m' + max-file: '3' + command: redis-server + + backend: + image: dfxswiss/boltz-backend:dev + restart: unless-stopped + networks: + - shared + depends_on: + - postgres + volumes: + - ./volumes/lightning:/root/.lnd + - ./volumes/boltz/backend/data:/root/.boltz + - ./volumes/boltz/backend/boltz.conf:/root/.boltz/boltz.conf + ports: + - '9000:9000' + - '9001:9001' + - '9004:9004' + logging: + driver: 'json-file' + options: + max-size: '100m' + max-file: '3' + environment: + - NODE_EXTRA_CA_CERTS=/root/.lnd/tls.cert + webapp: + image: dfxswiss/boltz-webapp:dev + restart: unless-stopped + networks: + - shared + depends_on: + - backend + volumes: + - ./volumes/lightning:/root/.lnd + + ports: + - '444:444' + logging: + driver: 'json-file' + options: + max-size: '100m' + max-file: '3' + environment: + - VITE_API_URL=http://backend:9000 + - VITE_RSK_LOG_SCAN_ENDPOINT=[VITE_RSK_LOG_SCAN_ENDPOINT] + +networks: + shared: + external: true + name: lightning-network diff --git a/infrastructure/config/docker/dev-docker-compose.yml b/infrastructure/config/docker/dev-docker-compose-lightning.yml similarity index 58% rename from infrastructure/config/docker/dev-docker-compose.yml rename to infrastructure/config/docker/dev-docker-compose-lightning.yml index ccb23537..57a97ab6 100644 --- a/infrastructure/config/docker/dev-docker-compose.yml +++ b/infrastructure/config/docker/dev-docker-compose-lightning.yml @@ -1,31 +1,36 @@ -version: '3.7' -name: 'bitcoin-lightning' +name: 'lightning' services: bitcoind: image: lightninglabs/bitcoin-core - restart: always + restart: unless-stopped + networks: + - shared volumes: - ./volumes/bitcoin:/home/bitcoin/.bitcoin - - ./volumes/bitcoin/bitcoin.conf:/home/bitcoin/.bitcoin/bitcoin.conf - - ./volumes/bitcoin/wallets:/home/bitcoin/.bitcoin/testnet3/wallets ports: - - '18332:18332' + - '8332:8332' healthcheck: - test: curl --fail http://localhost:18332/rest/chaininfo.json || exit 1 + test: curl --fail http://localhost:8332/rest/chaininfo.json || exit 1 start_period: 120s interval: 30s timeout: 60s retries: 10 + logging: + driver: 'json-file' + options: + max-size: '100m' + max-file: '3' command: > bitcoind -conf=/home/bitcoin/.bitcoin/bitcoin.conf lnd: - image: lightninglabs/lnd:v0.17.3-beta - restart: always + image: lightninglabs/lnd:v0.19.3-beta + restart: unless-stopped + networks: + - shared volumes: - ./volumes/lightning:/root/.lnd - - ./volumes/lightning/lnd.conf:/root/.lnd/lnd.conf ports: - '8080:8080' healthcheck: @@ -34,15 +39,22 @@ services: interval: 30s timeout: 60s retries: 10 + logging: + driver: 'json-file' + options: + max-size: '100m' + max-file: '3' depends_on: bitcoind: condition: service_healthy command: > - lnd --configfile=/root/.lnd/lnd.conf --bitcoin.testnet + lnd --configfile=/root/.lnd/lnd.conf --bitcoin.mainnet tapd: - image: polarlightning/tapd:0.3.2-alpha - restart: always + image: polarlightning/tapd:0.3.3-alpha + restart: unless-stopped + networks: + - shared volumes: - ./volumes/lightning:/home/tap/.lnd - ./volumes/taproot:/home/tap/.tapd @@ -52,12 +64,19 @@ services: depends_on: lnd: condition: service_healthy + logging: + driver: 'json-file' + options: + max-size: '100m' + max-file: '3' command: > tapd --configfile=/home/tap/tapd.conf lnbits: - image: lnbitsdocker/lnbits-legend:0.11.3 - restart: always + image: lnbits/lnbits:0.12.8 + restart: unless-stopped + networks: + - shared volumes: - ./volumes/lightning:/app/.lnd - ./volumes/lnbits/data:/app/data @@ -70,6 +89,11 @@ services: interval: 60s timeout: 30s retries: 10 + logging: + driver: 'json-file' + options: + max-size: '100m' + max-file: '3' depends_on: lnd: condition: service_healthy @@ -78,20 +102,29 @@ services: lnbitsapi: image: dfxswiss/lnbitsapi:latest - restart: always + restart: unless-stopped + networks: + - shared volumes: - ./volumes/lnbitsapi/data:/home/node/data - ./volumes/lnbits/data:/home/node/data/sqlite3 - ./volumes/lnbitsapi/.env:/home/node/.env ports: - '5001:5001' + logging: + driver: 'json-file' + options: + max-size: '100m' + max-file: '3' depends_on: lnbits: condition: service_healthy thunderhub: - image: apotdevin/thunderhub:v0.13.30 - restart: always + image: apotdevin/thunderhub:v0.13.31 + restart: unless-stopped + networks: + - shared volumes: - ./volumes/lightning:/app/.lnd - ./volumes/thunderhub/.env:/app/.env @@ -101,15 +134,13 @@ services: depends_on: lnd: condition: service_healthy + logging: + driver: 'json-file' + options: + max-size: '100m' + max-file: '3' - nginx: - image: nginx:1.25.3-perl - restart: always - volumes: - - ./volumes/lightning:/app/.lnd - - ./volumes/nginx/default.conf:/etc/nginx/conf.d/default.conf - ports: - - '443:443' - depends_on: - - lnbits - - thunderhub +networks: + shared: + external: true + name: lightning-network diff --git a/infrastructure/config/docker/dev-docker-compose-nginx.yml b/infrastructure/config/docker/dev-docker-compose-nginx.yml new file mode 100644 index 00000000..eee1fc0f --- /dev/null +++ b/infrastructure/config/docker/dev-docker-compose-nginx.yml @@ -0,0 +1,24 @@ +name: 'network' + +services: + nginx: + image: nginx:1.25.5-perl + restart: unless-stopped + networks: + - shared + volumes: + - ./volumes/lightning:/app/.lnd + - ./volumes/nginx/default.conf:/etc/nginx/conf.d/default.conf + ports: + - '443:443' + - '9006:9006' + logging: + driver: 'json-file' + options: + max-size: '100m' + max-file: '3' + +networks: + shared: + external: true + name: lightning-network diff --git a/infrastructure/config/docker/prd-docker-compose-boltz.yml b/infrastructure/config/docker/prd-docker-compose-boltz.yml new file mode 100644 index 00000000..896d49be --- /dev/null +++ b/infrastructure/config/docker/prd-docker-compose-boltz.yml @@ -0,0 +1,93 @@ +name: 'boltz' + +services: + postgres: + image: postgres:17 + cpus: 0.5 + shm_size: '1gb' + restart: unless-stopped + networks: + - shared + volumes: + - ./volumes/boltz/postgres:/var/lib/postgresql/data + ports: + - '5432:5432' + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U [POSTGRES_USERNAME]'] + interval: 30s + timeout: 5s + retries: 5 + logging: + driver: 'json-file' + options: + max-size: '100m' + max-file: '3' + environment: + - POSTGRES_DB=[POSTGRES_DATABASE] + - POSTGRES_USER=[POSTGRES_USERNAME] + - POSTGRES_PASSWORD=[POSTGRES_PASSWORD] + command: postgres -c max_connections=100 + + redis: + image: redis:alpine + restart: unless-stopped + networks: + - shared + volumes: + - ./volumes/boltz/redis/data:/data + ports: + - '6379:6379' + logging: + driver: 'json-file' + options: + max-size: '100m' + max-file: '3' + command: redis-server + + backend: + image: dfxswiss/boltz-backend:dev + restart: unless-stopped + networks: + - shared + depends_on: + - postgres + volumes: + - ./volumes/lightning:/root/.lnd + - ./volumes/boltz/backend/data:/root/.boltz + - ./volumes/boltz/backend/boltz.conf:/root/.boltz/boltz.conf + ports: + - '9000:9000' + - '9001:9001' + - '9004:9004' + logging: + driver: 'json-file' + options: + max-size: '100m' + max-file: '3' + environment: + - NODE_EXTRA_CA_CERTS=/root/.lnd/tls.cert + webapp: + image: dfxswiss/boltz-webapp:dev + restart: unless-stopped + networks: + - shared + depends_on: + - backend + volumes: + - ./volumes/lightning:/root/.lnd + + ports: + - '444:444' + logging: + driver: 'json-file' + options: + max-size: '100m' + max-file: '3' + environment: + - VITE_API_URL=http://backend:9000 + - VITE_RSK_LOG_SCAN_ENDPOINT=[VITE_RSK_LOG_SCAN_ENDPOINT] + +networks: + shared: + external: true + name: lightning-network diff --git a/infrastructure/config/docker/prd-docker-compose.yml b/infrastructure/config/docker/prd-docker-compose-lightning.yml similarity index 62% rename from infrastructure/config/docker/prd-docker-compose.yml rename to infrastructure/config/docker/prd-docker-compose-lightning.yml index db0e62b3..27b6179d 100644 --- a/infrastructure/config/docker/prd-docker-compose.yml +++ b/infrastructure/config/docker/prd-docker-compose-lightning.yml @@ -1,14 +1,13 @@ -version: '3.7' -name: 'bitcoin-lightning' +name: 'lightning' services: bitcoind: image: lightninglabs/bitcoin-core - restart: always + restart: unless-stopped + networks: + - shared volumes: - ./volumes/bitcoin:/home/bitcoin/.bitcoin - - ./volumes/bitcoin/bitcoin.conf:/home/bitcoin/.bitcoin/bitcoin.conf - - ./volumes/bitcoin/wallets:/home/bitcoin/.bitcoin/wallets ports: - '8332:8332' healthcheck: @@ -17,15 +16,21 @@ services: interval: 30s timeout: 60s retries: 10 + logging: + driver: 'json-file' + options: + max-size: '100m' + max-file: '3' command: > bitcoind -conf=/home/bitcoin/.bitcoin/bitcoin.conf lnd: - image: lightninglabs/lnd:v0.17.3-beta - restart: always + image: lightninglabs/lnd:v0.19.3-beta + restart: unless-stopped + networks: + - shared volumes: - ./volumes/lightning:/root/.lnd - - ./volumes/lightning/lnd.conf:/root/.lnd/lnd.conf ports: - '8080:8080' - '9735:9735' @@ -35,6 +40,11 @@ services: interval: 30s timeout: 60s retries: 10 + logging: + driver: 'json-file' + options: + max-size: '100m' + max-file: '3' depends_on: bitcoind: condition: service_healthy @@ -42,8 +52,10 @@ services: lnd --configfile=/root/.lnd/lnd.conf --bitcoin.mainnet tapd: - image: polarlightning/tapd:0.3.2-alpha - restart: always + image: polarlightning/tapd:0.3.3-alpha + restart: unless-stopped + networks: + - shared volumes: - ./volumes/lightning:/home/tap/.lnd - ./volumes/taproot:/home/tap/.tapd @@ -54,12 +66,19 @@ services: depends_on: lnd: condition: service_healthy + logging: + driver: 'json-file' + options: + max-size: '100m' + max-file: '3' command: > - tapd --configfile=/home/tap/tapd.conf --profile=localhost:6060 + tapd --configfile=/home/tap/tapd.conf lnbits: - image: lnbitsdocker/lnbits-legend:0.11.3 - restart: always + image: lnbits/lnbits:0.12.8 + restart: unless-stopped + networks: + - shared volumes: - ./volumes/lightning:/app/.lnd - ./volumes/lnbits/data:/app/data @@ -72,6 +91,11 @@ services: interval: 60s timeout: 30s retries: 10 + logging: + driver: 'json-file' + options: + max-size: '100m' + max-file: '3' depends_on: lnd: condition: service_healthy @@ -80,20 +104,29 @@ services: lnbitsapi: image: dfxswiss/lnbitsapi:main - restart: always + restart: unless-stopped + networks: + - shared volumes: - ./volumes/lnbitsapi/data:/home/node/data - ./volumes/lnbits/data:/home/node/data/sqlite3 - ./volumes/lnbitsapi/.env:/home/node/.env ports: - '5001:5001' + logging: + driver: 'json-file' + options: + max-size: '100m' + max-file: '3' depends_on: lnbits: condition: service_healthy thunderhub: - image: apotdevin/thunderhub:v0.13.30 - restart: always + image: apotdevin/thunderhub:v0.13.31 + restart: unless-stopped + networks: + - shared volumes: - ./volumes/lightning:/app/.lnd - ./volumes/thunderhub/.env:/app/.env @@ -103,15 +136,13 @@ services: depends_on: lnd: condition: service_healthy + logging: + driver: 'json-file' + options: + max-size: '100m' + max-file: '3' - nginx: - image: nginx:1.25.3-perl - restart: always - volumes: - - ./volumes/lightning:/app/.lnd - - ./volumes/nginx/default.conf:/etc/nginx/conf.d/default.conf - ports: - - '443:443' - depends_on: - - lnbits - - thunderhub +networks: + shared: + external: true + name: lightning-network diff --git a/infrastructure/config/docker/prd-docker-compose-nginx.yml b/infrastructure/config/docker/prd-docker-compose-nginx.yml new file mode 100644 index 00000000..eee1fc0f --- /dev/null +++ b/infrastructure/config/docker/prd-docker-compose-nginx.yml @@ -0,0 +1,24 @@ +name: 'network' + +services: + nginx: + image: nginx:1.25.5-perl + restart: unless-stopped + networks: + - shared + volumes: + - ./volumes/lightning:/app/.lnd + - ./volumes/nginx/default.conf:/etc/nginx/conf.d/default.conf + ports: + - '443:443' + - '9006:9006' + logging: + driver: 'json-file' + options: + max-size: '100m' + max-file: '3' + +networks: + shared: + external: true + name: lightning-network diff --git a/infrastructure/config/lightning/dev-lnd.conf b/infrastructure/config/lightning/dev-lnd.conf index b16b6186..fb2cbece 100644 --- a/infrastructure/config/lightning/dev-lnd.conf +++ b/infrastructure/config/lightning/dev-lnd.conf @@ -2,6 +2,7 @@ debuglevel=debug rpclisten=0.0.0.0:10009 restlisten=0.0.0.0:8080 + nolisten=true maxpendingchannels=0 @@ -35,3 +36,6 @@ bitcoind.rpcpass=[RPC-PASSWORD] bitcoind.zmqpubrawblock=tcp://bitcoind:28332 bitcoind.zmqpubrawtx=tcp://bitcoind:28333 + +[protocol] +protocol.wumbo-channels=true diff --git a/infrastructure/config/lightning/prd-pwd.txt b/infrastructure/config/lightning/prd-pwd.txt new file mode 100644 index 00000000..979878ab --- /dev/null +++ b/infrastructure/config/lightning/prd-pwd.txt @@ -0,0 +1 @@ +[PASSWORD] \ No newline at end of file diff --git a/infrastructure/config/lnbits/dev.env b/infrastructure/config/lnbits/dev.env index 058131bf..4490e20b 100644 --- a/infrastructure/config/lnbits/dev.env +++ b/infrastructure/config/lnbits/dev.env @@ -29,15 +29,15 @@ LNBITS_SITE_DESCRIPTION="Lightning custodial service brought to you by lightning LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber" LNBITS_BACKEND_WALLET_CLASS=LndWallet -LIGHTNING_INVOICE_EXPIRY=600 +LIGHTNING_INVOICE_EXPIRY=86400 # LndWallet LND_GRPC_ENDPOINT=lnd LND_GRPC_PORT=10009 LND_GRPC_CERT="/app/.lnd/tls.cert" -LND_GRPC_MACAROON="/app/.lnd/data/chain/bitcoin/testnet/admin.macaroon" +LND_GRPC_MACAROON="/app/.lnd/data/chain/bitcoin/mainnet/admin.macaroon" # LndRestWallet LND_REST_ENDPOINT=lnd:8080/ LND_REST_CERT="/app/.lnd/tls.cert" -LND_REST_MACAROON="/app/.lnd/data/chain/bitcoin/testnet/admin.macaroon" +LND_REST_MACAROON="/app/.lnd/data/chain/bitcoin/mainnet/admin.macaroon" diff --git a/infrastructure/config/lnbits/prd.env b/infrastructure/config/lnbits/prd.env index eea19c7c..4490e20b 100644 --- a/infrastructure/config/lnbits/prd.env +++ b/infrastructure/config/lnbits/prd.env @@ -29,7 +29,7 @@ LNBITS_SITE_DESCRIPTION="Lightning custodial service brought to you by lightning LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber" LNBITS_BACKEND_WALLET_CLASS=LndWallet -LIGHTNING_INVOICE_EXPIRY=600 +LIGHTNING_INVOICE_EXPIRY=86400 # LndWallet LND_GRPC_ENDPOINT=lnd diff --git a/infrastructure/config/nginx/dev-default.conf b/infrastructure/config/nginx/dev-default.conf index 9513c5f7..4c649184 100644 --- a/infrastructure/config/nginx/dev-default.conf +++ b/infrastructure/config/nginx/dev-default.conf @@ -1,3 +1,8 @@ +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + server { listen 443 ssl; listen [::]:443 ssl; @@ -11,3 +16,69 @@ server { proxy_pass http://thunderhub:3000; } } + +server { + listen 9006 ssl; + listen [::]:9006 ssl; + + ssl_certificate /app/.lnd/tls.cert; + ssl_certificate_key /app/.lnd/tls.key; + + add_header 'Access-Control-Allow-Origin' '*' always; + add_header 'Access-Control-Allow-Headers' '*' always; + add_header 'Access-Control-Allow-Methods' 'GET, PATCH, POST, DELETE, OPTIONS' always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;" always; + + if ($request_method = OPTIONS) { + return 200; + } + + location /v2/ws { + proxy_pass http://backend:9004/; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $remote_addr; + } + + location /streamswapstatus { + proxy_pass http://backend:9005; + + proxy_http_version 1.1; + proxy_set_header Connection ''; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 24h; + proxy_set_header X-Forwarded-For $remote_addr; + keepalive_timeout 3600; + + chunked_transfer_encoding off; + } + + location ~ ^/v2/swap/[^/]+/stats/[^/]+/[^/]+$ { + proxy_pass http://backend:9005; + } + + location ~ ^/v2/swap/rescue { + proxy_pass http://backend:9005; + } + + location ~ ^/v2/swap/restore { + proxy_pass http://backend:9005; + } + + location /v2/lightning/ { + proxy_pass http://backend:9005; + } + location ~ ^/v2/quote { + proxy_pass http://backend:9005; + } + + location / { + proxy_pass http://backend:9001; + + proxy_hide_header Content-Security-Policy; + } +} diff --git a/infrastructure/config/nginx/prd-default.conf b/infrastructure/config/nginx/prd-default.conf index 9d42f734..3fddd710 100644 --- a/infrastructure/config/nginx/prd-default.conf +++ b/infrastructure/config/nginx/prd-default.conf @@ -1,3 +1,8 @@ +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + server { listen 443 ssl; listen [::]:443 ssl; @@ -11,3 +16,69 @@ server { proxy_pass http://thunderhub:4000; } } + +server { + listen 9006 ssl; + listen [::]:9006 ssl; + + ssl_certificate /app/.lnd/tls.cert; + ssl_certificate_key /app/.lnd/tls.key; + + add_header 'Access-Control-Allow-Origin' '*' always; + add_header 'Access-Control-Allow-Headers' '*' always; + add_header 'Access-Control-Allow-Methods' 'GET, PATCH, POST, DELETE, OPTIONS' always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;" always; + + if ($request_method = OPTIONS) { + return 200; + } + + location /v2/ws { + proxy_pass http://backend:9004/; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $remote_addr; + } + + location /streamswapstatus { + proxy_pass http://backend:9005; + + proxy_http_version 1.1; + proxy_set_header Connection ''; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 24h; + proxy_set_header X-Forwarded-For $remote_addr; + keepalive_timeout 3600; + + chunked_transfer_encoding off; + } + + location ~ ^/v2/swap/[^/]+/stats/[^/]+/[^/]+$ { + proxy_pass http://backend:9005; + } + + location ~ ^/v2/swap/rescue { + proxy_pass http://backend:9005; + } + + location ~ ^/v2/swap/restore { + proxy_pass http://backend:9005; + } + + location /v2/lightning/ { + proxy_pass http://backend:9005; + } + location ~ ^/v2/quote { + proxy_pass http://backend:9005; + } + + location / { + proxy_pass http://backend:9001; + + proxy_hide_header Content-Security-Policy; + } +} diff --git a/infrastructure/config/taproot/dev-tapd.conf b/infrastructure/config/taproot/dev-tapd.conf index ab3d5846..24e51638 100644 --- a/infrastructure/config/taproot/dev-tapd.conf +++ b/infrastructure/config/taproot/dev-tapd.conf @@ -1,9 +1,9 @@ -debuglevel=trace -network=testnet +debuglevel=info +network=mainnet lnd.host=lnd:10009 -lnd.macaroonpath=/home/tap/.lnd/data/chain/bitcoin/testnet/admin.macaroon +lnd.macaroonpath=/home/tap/.lnd/data/chain/bitcoin/mainnet/admin.macaroon lnd.tlspath=/home/tap/.lnd/tls.cert -rpclisten=127.0.0.1:10029 -restlisten=127.0.0.1:8089 +rpclisten=0.0.0.0:10029 +restlisten=0.0.0.0:8089 diff --git a/infrastructure/config/thunderhub/dev-accounts.yml b/infrastructure/config/thunderhub/dev-accounts.yml index a8f448f6..f666deca 100644 --- a/infrastructure/config/thunderhub/dev-accounts.yml +++ b/infrastructure/config/thunderhub/dev-accounts.yml @@ -1,6 +1,6 @@ accounts: - name: [NAME] serverUrl: lnd:10009 - macaroonPath: /app/.lnd/data/chain/bitcoin/testnet/admin.macaroon + macaroonPath: /app/.lnd/data/chain/bitcoin/mainnet/admin.macaroon certificatePath: /app/.lnd/tls.cert password: [PASSWORD] diff --git a/infrastructure/config/thunderhub/dev.env b/infrastructure/config/thunderhub/dev.env index d8c8c479..52d50e92 100644 --- a/infrastructure/config/thunderhub/dev.env +++ b/infrastructure/config/thunderhub/dev.env @@ -10,7 +10,7 @@ ACCOUNT_CONFIG_PATH='/app/accounts.yml' SSO_SERVER_URL='lnd:10009' SSO_CERT_PATH="/app/.lnd/tls.cert" -SSO_MACAROON_PATH="/app/.lnd/data/chain/bitcoin/testnet" +SSO_MACAROON_PATH="/app/.lnd/data/chain/bitcoin/mainnet" TLS_KEY_PATH="/app/.lnd/tls.key" TLS_CERT_PATH="/app/.lnd/tls.cert" diff --git a/infrastructure/scripts/dev-docker-compose.sh b/infrastructure/scripts/dev-docker-compose.sh deleted file mode 100644 index e599aadf..00000000 --- a/infrastructure/scripts/dev-docker-compose.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/bash - -cat << EOF > .env -MACAROON=$(xxd -ps -u -c 1000 volumes/lightning/data/chain/bitcoin/testnet/admin.macaroon) -EOF - -docker compose up -d diff --git a/infrastructure/scripts/dev-runBackup-exclude-file.txt b/infrastructure/scripts/dev-runBackup-exclude-file.txt deleted file mode 100644 index b071109e..00000000 --- a/infrastructure/scripts/dev-runBackup-exclude-file.txt +++ /dev/null @@ -1,3 +0,0 @@ -./volumes/bitcoin/testnet3/blocks -./volumes/bitcoin/testnet3/chainstate -./volumes/bitcoin/testnet3/indexes diff --git a/infrastructure/scripts/prd-docker-compose.sh b/infrastructure/scripts/docker-compose.sh similarity index 71% rename from infrastructure/scripts/prd-docker-compose.sh rename to infrastructure/scripts/docker-compose.sh index 9beb6d0f..40209a4e 100644 --- a/infrastructure/scripts/prd-docker-compose.sh +++ b/infrastructure/scripts/docker-compose.sh @@ -4,4 +4,4 @@ cat << EOF > .env MACAROON=$(xxd -ps -u -c 1000 volumes/lightning/data/chain/bitcoin/mainnet/admin.macaroon) EOF -docker compose up -d +docker compose -f docker-compose-lightning.yml up -d diff --git a/infrastructure/scripts/prd-runBackup-exclude-file.txt b/infrastructure/scripts/runBackup-exclude-file.txt similarity index 100% rename from infrastructure/scripts/prd-runBackup-exclude-file.txt rename to infrastructure/scripts/runBackup-exclude-file.txt diff --git a/infrastructure/scripts/setupEnv.sh b/infrastructure/scripts/setupEnv.sh index a73a0b82..ad9fe81c 100644 --- a/infrastructure/scripts/setupEnv.sh +++ b/infrastructure/scripts/setupEnv.sh @@ -2,18 +2,19 @@ cd ~ mkdir backup -mkdir volumes -cd ~/volumes -mkdir bitcoin -mkdir lightning -mkdir taproot -mkdir lnbits -mkdir lnbitsapi -mkdir thunderhub -mkdir nginx +mkdir -p volumes/bitcoin +mkdir -p volumes/lightning +mkdir -p volumes/taproot +mkdir -p volumes/lnbits +mkdir -p volumes/lnbitsapi +mkdir -p volumes/thunderhub +mkdir -p volumes/nginx -cd ~ +mkdir -p volumes/boltz/postgres +mkdir -p volumes/boltz/redis +mkdir -p volumes/boltz/backend +mkdir -p volumes/boltz/webapp sudo apt install wget sudo apt install iputils-ping From dfc9abe4961377d006c61bfd80a8fface480298d Mon Sep 17 00:00:00 2001 From: bernd2022 <104787072+bernd2022@users.noreply.github.com> Date: Tue, 18 Nov 2025 11:53:39 +0100 Subject: [PATCH 03/22] [DEV-4476] Citrea monitoring (#88) * [DEV-4476] Citrea monitoring * [DEV-4476] Changed env to EVM_WALLET_SEED --- .../1763453659323-addCitreaMonitoring.js | 14 +++++ src/config/config.ts | 9 +++- .../blockchain/blockchain.module.ts | 2 + .../blockchain/citrea/citrea-client.ts | 27 ++++++++++ .../blockchain/citrea/citrea.module.ts | 12 +++++ .../blockchain/citrea/citrea.service.ts | 26 ++++++++++ .../blockchain/rootstock/rootstock-client.ts | 2 +- .../blockchain/shared/evm/evm-client.ts | 2 +- src/shared/enums/blockchain.enum.ts | 1 + .../entities/monitoring-balance.entity.ts | 26 +++++++--- .../monitoring/services/monitoring.service.ts | 51 ++++++------------- 11 files changed, 126 insertions(+), 46 deletions(-) create mode 100644 migration/1763453659323-addCitreaMonitoring.js create mode 100644 src/integration/blockchain/citrea/citrea-client.ts create mode 100644 src/integration/blockchain/citrea/citrea.module.ts create mode 100644 src/integration/blockchain/citrea/citrea.service.ts diff --git a/migration/1763453659323-addCitreaMonitoring.js b/migration/1763453659323-addCitreaMonitoring.js new file mode 100644 index 00000000..eb9ff70d --- /dev/null +++ b/migration/1763453659323-addCitreaMonitoring.js @@ -0,0 +1,14 @@ +const { MigrationInterface, QueryRunner } = require("typeorm"); + +module.exports = class addCitreaMonitoring1763453659323 { + name = 'addCitreaMonitoring1763453659323' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "monitoring_balance" ADD "citreaBalance" float NOT NULL CONSTRAINT "DF_98f1eabfa79a178eacdc47fe777" DEFAULT 0`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "monitoring_balance" DROP CONSTRAINT "DF_98f1eabfa79a178eacdc47fe777"`); + await queryRunner.query(`ALTER TABLE "monitoring_balance" DROP COLUMN "citreaBalance"`); + } +} diff --git a/src/config/config.ts b/src/config/config.ts index d839ad44..a04b0b52 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -152,8 +152,15 @@ export class Configuration { gatewayUrl: process.env.ROOTSTOCK_GATEWAY_URL ?? '', apiKey: process.env.ALCHEMY_API_KEY ?? '', chainId: +(process.env.ROOTSTOCK_CHAIN_ID ?? -1), - walletSeed: process.env.ROOTSTOCK_WALLET_SEED ?? '', }, + citrea: { + gatewayUrl: process.env.CITREA_GATEWAY_URL ?? '', + chainId: +(process.env.CITREA_CHAIN_ID ?? -1), + }, + }; + + evm = { + walletSeed: process.env.EVM_WALLET_SEED ?? '', }; alchemy = { diff --git a/src/integration/blockchain/blockchain.module.ts b/src/integration/blockchain/blockchain.module.ts index 826d4cd6..5f57e745 100644 --- a/src/integration/blockchain/blockchain.module.ts +++ b/src/integration/blockchain/blockchain.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { ArbitrumModule } from './arbitrum/arbitrum.module'; import { BaseModule } from './base/base.module'; import { BitcoinModule } from './bitcoin/bitcoin.module'; +import { CitreaModule } from './citrea/citrea.module'; import { EthereumModule } from './ethereum/ethereum.module'; import { LightningModule } from './lightning/lightning.module'; import { OptimismModule } from './optimism/optimism.module'; @@ -21,6 +22,7 @@ import { UmaModule } from './uma/uma.module'; PolygonModule, BaseModule, RootstockModule, + CitreaModule, ], controllers: [], providers: [CryptoService], diff --git a/src/integration/blockchain/citrea/citrea-client.ts b/src/integration/blockchain/citrea/citrea-client.ts new file mode 100644 index 00000000..a4025473 --- /dev/null +++ b/src/integration/blockchain/citrea/citrea-client.ts @@ -0,0 +1,27 @@ +import { Config } from 'src/config/config'; +import { EvmUtil } from 'src/subdomains/evm/evm.util'; +import { AssetTransferEntity } from 'src/subdomains/master-data/asset/entities/asset-transfer.entity'; +import { LightningHelper } from '../lightning/lightning-helper'; +import { EvmTokenBalance } from '../shared/evm/dto/evm-token-balance.dto'; +import { EvmClient, EvmClientParams } from '../shared/evm/evm-client'; + +export class CitreaClient extends EvmClient { + constructor(private readonly params: EvmClientParams) { + super(params); + } + + async getNativeCoinBalance(): Promise { + const walletAddress = EvmUtil.createWallet({ seed: Config.evm.walletSeed, index: 0 }).address; + + const balance = await this.provider.getBalance(walletAddress); + return LightningHelper.btcToSat(EvmUtil.fromWeiAmount(balance.toString())); + } + + async getTokenBalance(_asset: AssetTransferEntity): Promise { + throw new Error('Method not implemented.'); + } + + async getTokenBalances(_assets: AssetTransferEntity[]): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/src/integration/blockchain/citrea/citrea.module.ts b/src/integration/blockchain/citrea/citrea.module.ts new file mode 100644 index 00000000..a5672f25 --- /dev/null +++ b/src/integration/blockchain/citrea/citrea.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { SharedModule } from 'src/shared/shared.module'; +import { AlchemyModule } from 'src/subdomains/alchemy/alchemy.module'; +import { EvmRegistryModule } from '../shared/evm/registry/evm-registry.module'; +import { CitreaService } from './citrea.service'; + +@Module({ + imports: [SharedModule, EvmRegistryModule, AlchemyModule], + providers: [CitreaService], + exports: [], +}) +export class CitreaModule {} diff --git a/src/integration/blockchain/citrea/citrea.service.ts b/src/integration/blockchain/citrea/citrea.service.ts new file mode 100644 index 00000000..debe0c16 --- /dev/null +++ b/src/integration/blockchain/citrea/citrea.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { GetConfig } from 'src/config/config'; +import { Blockchain } from 'src/shared/enums/blockchain.enum'; +import { HttpService } from 'src/shared/services/http.service'; +import { AlchemyService } from 'src/subdomains/alchemy/services/alchemy.service'; +import { EvmService } from '../shared/evm/evm.service'; +import { CitreaClient } from './citrea-client'; + +@Injectable() +export class CitreaService extends EvmService { + constructor(http: HttpService, alchemyService: AlchemyService) { + const { gatewayUrl, chainId } = GetConfig().blockchain.citrea; + + super(CitreaClient, { + http: http, + alchemyService, + gatewayUrl, + apiKey: '', + chainId, + }); + } + + get blockchain(): Blockchain { + return Blockchain.CITREA; + } +} diff --git a/src/integration/blockchain/rootstock/rootstock-client.ts b/src/integration/blockchain/rootstock/rootstock-client.ts index 88fbee3d..0a551e00 100644 --- a/src/integration/blockchain/rootstock/rootstock-client.ts +++ b/src/integration/blockchain/rootstock/rootstock-client.ts @@ -16,7 +16,7 @@ export class RootstockClient extends EvmClient { const url = `${this.params.gatewayUrl}/${this.params.apiKey ?? ''}`; - const walletAddress = EvmUtil.createWallet({ seed: Config.blockchain.rootstock.walletSeed, index: 0 }).address; + const walletAddress = EvmUtil.createWallet({ seed: Config.evm.walletSeed, index: 0 }).address; const balanceResult = await http .post<{ result: number }>(url, { diff --git a/src/integration/blockchain/shared/evm/evm-client.ts b/src/integration/blockchain/shared/evm/evm-client.ts index cd3c9033..222cf6e1 100644 --- a/src/integration/blockchain/shared/evm/evm-client.ts +++ b/src/integration/blockchain/shared/evm/evm-client.ts @@ -21,7 +21,7 @@ export abstract class EvmClient { private readonly alchemyService: AlchemyService; private readonly chainId: number; - private readonly provider: ethers.providers.JsonRpcProvider; + protected readonly provider: ethers.providers.JsonRpcProvider; private readonly tokens = new AsyncCache(); constructor(params: EvmClientParams) { diff --git a/src/shared/enums/blockchain.enum.ts b/src/shared/enums/blockchain.enum.ts index d227756d..32455de9 100644 --- a/src/shared/enums/blockchain.enum.ts +++ b/src/shared/enums/blockchain.enum.ts @@ -7,4 +7,5 @@ export enum Blockchain { POLYGON = 'polygon', BASE = 'base', ROOTSTOCK = 'rootstock', + CITREA = 'citrea', } diff --git a/src/subdomains/monitoring/entities/monitoring-balance.entity.ts b/src/subdomains/monitoring/entities/monitoring-balance.entity.ts index e39d38dc..b8b289cb 100644 --- a/src/subdomains/monitoring/entities/monitoring-balance.entity.ts +++ b/src/subdomains/monitoring/entities/monitoring-balance.entity.ts @@ -5,6 +5,14 @@ import { Price } from 'src/subdomains/support/dto/price.dto'; import { LightningWalletTotalBalanceDto } from 'src/subdomains/user/application/dto/lightning-wallet.dto'; import { Column, Entity, ManyToOne } from 'typeorm'; +export interface MonitoringBlockchainBalance { + onchainBalance: number; + lndOnchainBalance: number; + lightningBalance: number; + rootstockBalance: number; + citreaBalance: number; +} + @Entity('monitoring_balance') export class MonitoringBalanceEntity extends IEntity { @ManyToOne(() => AssetAccountEntity, { eager: true }) @@ -22,6 +30,9 @@ export class MonitoringBalanceEntity extends IEntity { @Column({ type: 'float', default: 0 }) rootstockBalance: number; + @Column({ type: 'float', default: 0 }) + citreaBalance: number; + @Column({ type: 'float', default: 0 }) customerBalance: number; @@ -37,10 +48,7 @@ export class MonitoringBalanceEntity extends IEntity { // --- FACTORY METHODS --- // static createAsBtcEntity( - onchainBalance: number, - lndOnchainBalance: number, - lightningBalance: number, - rootstockBalance: number, + blockchainBalance: MonitoringBlockchainBalance, internalBalance: LightningWalletTotalBalanceDto, customerBalance: LightningWalletTotalBalanceDto, chfPrice: Price, @@ -48,10 +56,11 @@ export class MonitoringBalanceEntity extends IEntity { const entity = new MonitoringBalanceEntity(); entity.asset = { id: customerBalance.assetId } as AssetAccountEntity; - entity.onchainBalance = onchainBalance; - entity.lndOnchainBalance = lndOnchainBalance; - entity.lightningBalance = lightningBalance; - entity.rootstockBalance = rootstockBalance; + entity.onchainBalance = blockchainBalance.onchainBalance; + entity.lndOnchainBalance = blockchainBalance.lndOnchainBalance; + entity.lightningBalance = blockchainBalance.lightningBalance; + entity.rootstockBalance = blockchainBalance.rootstockBalance; + entity.citreaBalance = blockchainBalance.citreaBalance; entity.customerBalance = customerBalance.totalBalance; entity.ldsBalance = @@ -59,6 +68,7 @@ export class MonitoringBalanceEntity extends IEntity { entity.lndOnchainBalance + entity.lightningBalance + entity.rootstockBalance + + entity.citreaBalance + internalBalance.totalBalance - entity.customerBalance; diff --git a/src/subdomains/monitoring/services/monitoring.service.ts b/src/subdomains/monitoring/services/monitoring.service.ts index 0cad920b..7776175b 100644 --- a/src/subdomains/monitoring/services/monitoring.service.ts +++ b/src/subdomains/monitoring/services/monitoring.service.ts @@ -1,6 +1,7 @@ import { Injectable, InternalServerErrorException, OnModuleInit } from '@nestjs/common'; import { BitcoinClient } from 'src/integration/blockchain/bitcoin/bitcoin-client'; import { BitcoinService } from 'src/integration/blockchain/bitcoin/bitcoin.service'; +import { CitreaClient } from 'src/integration/blockchain/citrea/citrea-client'; import { LndChannelDto } from 'src/integration/blockchain/lightning/dto/lnd.dto'; import { LightningClient } from 'src/integration/blockchain/lightning/lightning-client'; import { LightningService } from 'src/integration/blockchain/lightning/services/lightning.service'; @@ -12,7 +13,7 @@ import { QueueHandler } from 'src/shared/utils/queue-handler'; import { AssetService } from 'src/subdomains/master-data/asset/services/asset.service'; import { CoinGeckoService } from 'src/subdomains/pricing/services/coingecko.service'; import { LightningWalletTotalBalanceDto } from 'src/subdomains/user/application/dto/lightning-wallet.dto'; -import { MonitoringBalanceEntity } from '../entities/monitoring-balance.entity'; +import { MonitoringBalanceEntity, MonitoringBlockchainBalance } from '../entities/monitoring-balance.entity'; import { MonitoringBalanceRepository } from '../repositories/monitoring-balance.repository'; import { MonitoringRepository } from '../repositories/monitoring.repository'; @@ -23,6 +24,7 @@ export class MonitoringService implements OnModuleInit { private readonly bitcoinClient: BitcoinClient; private readonly lightningClient: LightningClient; private rootstockClient: RootstockClient; + private citreaClient: CitreaClient; private readonly processBalancesQueue: QueueHandler; @@ -43,6 +45,7 @@ export class MonitoringService implements OnModuleInit { onModuleInit() { this.rootstockClient = this.evmRegistryService.getClient(Blockchain.ROOTSTOCK) as RootstockClient; + this.citreaClient = this.evmRegistryService.getClient(Blockchain.CITREA) as CitreaClient; } // --- LIGHTNING --- // @@ -66,10 +69,7 @@ export class MonitoringService implements OnModuleInit { customerBalances: LightningWalletTotalBalanceDto[], ): Promise { try { - const onchainBalance = await this.getOnchainBalance(); - const lndOnchainBalance = await this.getLndOnchainBalance(); - const lightningBalance = await this.getLightningBalance(); - const rootstockBalance = await this.getRootstockBalance(); + const blockchainBalance = await this.getBlockchainBalances(); const btcAccountAsset = await this.assetService.getBtcAccountAssetOrThrow(); const btcAccountAssetId = btcAccountAsset.id; @@ -86,14 +86,7 @@ export class MonitoringService implements OnModuleInit { const customerFiatBalances = customerBalances.filter((b) => b.assetId !== btcAccountAssetId); - await this.processBtcBalance( - onchainBalance, - lndOnchainBalance, - lightningBalance, - rootstockBalance, - internalBtcBalance, - customerBtcBalance, - ); + await this.processBtcBalance(blockchainBalance, internalBtcBalance, customerBtcBalance); await this.processFiatBalances(customerFiatBalances); } catch (e) { this.logger.error('Error while processing balances', e); @@ -119,10 +112,7 @@ export class MonitoringService implements OnModuleInit { } private async processBtcBalance( - onchainBalance: number, - lndOnchainBalance: number, - lightningBalance: number, - rootstockBalance: number, + blockchainBalance: MonitoringBlockchainBalance, internalBtcBalance: LightningWalletTotalBalanceDto, customerBtcBalance: LightningWalletTotalBalanceDto, ) { @@ -130,10 +120,7 @@ export class MonitoringService implements OnModuleInit { if (!chfPrice.isValid) throw new InternalServerErrorException(`Invalid price from BTC to CHF`); const btcMonitoringEntity = MonitoringBalanceEntity.createAsBtcEntity( - onchainBalance, - lndOnchainBalance, - lightningBalance, - rootstockBalance, + blockchainBalance, internalBtcBalance, customerBtcBalance, chfPrice, @@ -169,20 +156,14 @@ export class MonitoringService implements OnModuleInit { return balance; } - private async getOnchainBalance(): Promise { - return this.bitcoinClient.getWalletBalance(); - } - - private async getLndOnchainBalance(): Promise { - return this.lightningClient.getLndConfirmedWalletBalance(); - } - - private async getLightningBalance(): Promise { - return this.lightningClient.getLndLightningBalance(); - } - - private async getRootstockBalance(): Promise { - return this.rootstockClient.getNativeCoinBalance(); + private async getBlockchainBalances(): Promise { + return { + onchainBalance: await this.bitcoinClient.getWalletBalance(), + lndOnchainBalance: await this.lightningClient.getLndConfirmedWalletBalance(), + lightningBalance: await this.lightningClient.getLndLightningBalance(), + rootstockBalance: await this.rootstockClient.getNativeCoinBalance(), + citreaBalance: await this.citreaClient.getNativeCoinBalance(), + }; } private async getChannels(): Promise { From 6d2e586b28aeb36a85c793a67589f0e177084f3b Mon Sep 17 00:00:00 2001 From: bernd2022 <104787072+bernd2022@users.noreply.github.com> Date: Mon, 24 Nov 2025 10:52:20 +0100 Subject: [PATCH 04/22] [DEV-4498] Boltz ERC-20 swaps (#89) --- .../config/boltz/backend/dev-boltz.conf | 149 +++++++++++++++--- .../config/boltz/backend/prd-boltz.conf | 149 +++++++++++++++--- 2 files changed, 246 insertions(+), 52 deletions(-) diff --git a/infrastructure/config/boltz/backend/dev-boltz.conf b/infrastructure/config/boltz/backend/dev-boltz.conf index 5c1d50ad..99ba8764 100644 --- a/infrastructure/config/boltz/backend/dev-boltz.conf +++ b/infrastructure/config/boltz/backend/dev-boltz.conf @@ -49,7 +49,7 @@ network = "bitcoinMainnet" # Wallet balances - adjusted for testing minWalletBalance = 100_000 # 0.001 BTC (100k sats) -minChannelBalance = 100_000 # 0.001 BTC (100k sats) +minChannelBalance = 100_000 # 0.001 BTC (100k sats) # Swap limits maxSwapAmount = 10_000_000 # 0.1 BTC maximum @@ -85,15 +85,13 @@ maxZeroConfAmount = 0 # Disable 0-conf for security [[pairs]] base = "BTC" quote = "BTC" -rate = 1 # 1:1 exchange rate - -# Fee configuration (in percent) -fee = 0.5 # 0.5% service fee -swapInFee = 0.25 # 0.25% for submarine swaps (chain -> lightning) +rate = 1 +fee = 0.5 # 0.5% service fee +swapInFee = 0.25 # 0.25% for submarine swaps (chain -> lightning) # Swap amount limits (in satoshis) -maxSwapAmount = 10_000_000 # 0.1 BTC -minSwapAmount = 2_500 # 2,500 sats (pair level) +maxSwapAmount = 10_000_000 # 0.1 BTC +minSwapAmount = 2_500 # 2,500 sats (pair level) # Submarine Swap specific settings (Chain -> Lightning) [pairs.submarineSwap] @@ -115,36 +113,135 @@ minSwapAmount = 2_500 # 2,500 sats (pair level) [[pairs]] base = "BTC" quote = "RBTC" -rate = 1 # 1:1 peg between BTC and RBTC - -# Fee configuration (in percent) +rate = 1 fee = 0.25 swapInFee = 0.1 # Swap amount limits (in satoshis) -maxSwapAmount = 10_000_000 # 0.1 BTC/RBTC -minSwapAmount = 2_500 # 2,500 sats minimum (Rootstock has lower fees) +maxSwapAmount = 10_000_000 # 0.1 BTC/RBTC +minSwapAmount = 2_500 # 2,500 sats minimum (Rootstock has lower fees) + + [pairs.timeoutDelta] + chain = 1440 # Chain swap timeout (~24 hours = 144 blocks) + reverse = 1440 # ~24 hours for reverse swaps (lightning -> chain) + swapMinimal = 1440 # Minimum timeout for submarine swaps (~24 hours = 144 blocks) + swapMaximal = 2880 # Maximum timeout (~48 hours = 288 blocks) + swapTaproot = 10080 # 1 week for taproot swaps (10080 blocks) + +[[pairs]] +base = "BTC" +quote = "cBTC" +rate = 1 +fee = 0.25 +swapInFee = 0.1 + +maxSwapAmount = 10_000_000 # 0.1 BTC/cBTC +minSwapAmount = 2_500 # 2,500 sats minimum (Citrea Testnet has low fees) + + [pairs.timeoutDelta] + chain = 60 # Chain swap timeout (~1 hour, due to fast 2s blocks) + reverse = 180 # Reverse swap timeout (Lightning -> cBTC) - increased for CLTV requirements + swapMinimal = 1440 # Minimum timeout (~13.3 hours) - minimum for Lightning CLTV (80 blocks × 10 min) + swapMaximal = 2880 # Maximum timeout (~16.7 hours) - allows 100 Bitcoin blocks CLTV + swapTaproot = 10080 # Taproot timeout (~16.7 hours) + +[[pairs]] +base = "USDT_ETH" +quote = "USDT_CITREA" +rate = 1 +fee = 0.25 +swapInFee = 0.1 + +maxSwapAmount = 1_000_000_000 # 1000 USDT_ETH/USDT_CITREA +minSwapAmount = 1_000_000 # 1 USDT_ETH/USDT_CITREA + + [pairs.timeoutDelta] + chain = 1440 # Chain swap timeout (~24 hours = 144 blocks) + reverse = 1440 # ~24 hours for reverse swaps (lightning -> chain) + swapMinimal = 1440 # Minimum timeout for submarine swaps (~24 hours = 144 blocks) + swapMaximal = 2880 # Maximum timeout (~48 hours = 288 blocks) + swapTaproot = 10080 # 1 week for taproot swaps (10080 blocks) + +[[pairs]] +base = "USDT_POLYGON" +quote = "USDT_CITREA" +rate = 1 +fee = 0.25 +swapInFee = 0.1 + +maxSwapAmount = 1_000_000_000 # 1000 USDT_POLYGON/USDT_CITREA +minSwapAmount = 1_000_000 # 1 USDT_POLYGON/USDT_CITREA [pairs.timeoutDelta] - chain = 1440 # Chain swap timeout (~24 hours) - reverse = 1440 # Reverse swap timeout (Lightning -> RBTC) - swapMinimal = 1440 # Minimum timeout - swapMaximal = 2880 # Maximum timeout - swapTaproot = 10080 + chain = 1440 # Chain swap timeout (~24 hours = 144 blocks) + reverse = 1440 # ~24 hours for reverse swaps (lightning -> chain) + swapMinimal = 1440 # Minimum timeout for submarine swaps (~24 hours = 144 blocks) + swapMaximal = 2880 # Maximum timeout (~48 hours = 288 blocks) + swapTaproot = 10080 # 1 week for taproot swaps (10080 blocks) # RSK (Rootstock) Configuration [rsk] networkName = "RSK Mainnet" providerEndpoint = "[PROVIDER_ENDPOINT]" - [[rsk.contracts]] - etherSwap = "0x3d9cc5780CA1db78760ad3D35458509178A85A4A" - erc20Swap = "0x7d5a2187CC8EF75f8822daB0E8C9a2DB147BA045" + [[rsk.contracts]] + etherSwap = "0x3d9cc5780CA1db78760ad3D35458509178A85A4A" + erc20Swap = "0x7d5a2187CC8EF75f8822daB0E8C9a2DB147BA045" + + [[rsk.tokens]] + symbol = "RBTC" + + minWalletBalance = 10_000 + +# ETH (Ethereum) Configuration +[ethereum] +networkName = "Ethereum Mainnet" +providerEndpoint = "[PROVIDER_ENDPOINT]" + + [[ethereum.contracts]] + etherSwap = "0x9ADfB0F1B783486289Fc23f3A3Ad2927cebb17e4" + erc20Swap = "0x2E21F58Da58c391F110467c7484EdfA849C1CB9B" + + [[ethereum.tokens]] + symbol = "USDT_ETH" + decimals = 6 + contractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7" + + minWalletBalance = 1_000_000 # 1 USDT_ETH + +# POL (Polygon) Configuration +[polygon] +networkName = "Polygon Mainnet" +providerEndpoint = "[PROVIDER_ENDPOINT]" + + [[polygon.contracts]] + etherSwap = "0x9ADfB0F1B783486289Fc23f3A3Ad2927cebb17e4" + erc20Swap = "0x2E21F58Da58c391F110467c7484EdfA849C1CB9B" + + [[polygon.tokens]] + symbol = "USDT_POLYGON" + decimals = 6 + contractAddress = "0xc2132D05D31c914a87C6611C10748AEb04B58e8F" + + minWalletBalance = 1_000_000 # 1 USDT_POLYGON + +# Citrea Testnet Configuration +[citrea] +networkName = "Citrea Testnet" +providerEndpoint = "https://dev.rpc.testnet.juiceswap.com" + + [[citrea.contracts]] + etherSwap = "0xd02731fD8c5FDD53B613A699234FAd5EE8851B65" + erc20Swap = "0xf2e019a371e5Fd32dB2fC564Ad9eAE9E433133cc" + + [[citrea.tokens]] + symbol = "cBTC" - [[rsk.tokens]] - symbol = "RBTC" + minWalletBalance = 100_000 - maxSwapAmount = 10_000_000 - minSwapAmount = 2_500 + [[citrea.tokens]] + symbol = "USDT_CITREA" + decimals = 6 + contractAddress = "0x1Dd3057888944ff1f914626aB4BD47Dc8b6285Fe" - minWalletBalance = 10_000 + minWalletBalance = 1_000_000 # 1 USDT_CITREA diff --git a/infrastructure/config/boltz/backend/prd-boltz.conf b/infrastructure/config/boltz/backend/prd-boltz.conf index 5c1d50ad..99ba8764 100644 --- a/infrastructure/config/boltz/backend/prd-boltz.conf +++ b/infrastructure/config/boltz/backend/prd-boltz.conf @@ -49,7 +49,7 @@ network = "bitcoinMainnet" # Wallet balances - adjusted for testing minWalletBalance = 100_000 # 0.001 BTC (100k sats) -minChannelBalance = 100_000 # 0.001 BTC (100k sats) +minChannelBalance = 100_000 # 0.001 BTC (100k sats) # Swap limits maxSwapAmount = 10_000_000 # 0.1 BTC maximum @@ -85,15 +85,13 @@ maxZeroConfAmount = 0 # Disable 0-conf for security [[pairs]] base = "BTC" quote = "BTC" -rate = 1 # 1:1 exchange rate - -# Fee configuration (in percent) -fee = 0.5 # 0.5% service fee -swapInFee = 0.25 # 0.25% for submarine swaps (chain -> lightning) +rate = 1 +fee = 0.5 # 0.5% service fee +swapInFee = 0.25 # 0.25% for submarine swaps (chain -> lightning) # Swap amount limits (in satoshis) -maxSwapAmount = 10_000_000 # 0.1 BTC -minSwapAmount = 2_500 # 2,500 sats (pair level) +maxSwapAmount = 10_000_000 # 0.1 BTC +minSwapAmount = 2_500 # 2,500 sats (pair level) # Submarine Swap specific settings (Chain -> Lightning) [pairs.submarineSwap] @@ -115,36 +113,135 @@ minSwapAmount = 2_500 # 2,500 sats (pair level) [[pairs]] base = "BTC" quote = "RBTC" -rate = 1 # 1:1 peg between BTC and RBTC - -# Fee configuration (in percent) +rate = 1 fee = 0.25 swapInFee = 0.1 # Swap amount limits (in satoshis) -maxSwapAmount = 10_000_000 # 0.1 BTC/RBTC -minSwapAmount = 2_500 # 2,500 sats minimum (Rootstock has lower fees) +maxSwapAmount = 10_000_000 # 0.1 BTC/RBTC +minSwapAmount = 2_500 # 2,500 sats minimum (Rootstock has lower fees) + + [pairs.timeoutDelta] + chain = 1440 # Chain swap timeout (~24 hours = 144 blocks) + reverse = 1440 # ~24 hours for reverse swaps (lightning -> chain) + swapMinimal = 1440 # Minimum timeout for submarine swaps (~24 hours = 144 blocks) + swapMaximal = 2880 # Maximum timeout (~48 hours = 288 blocks) + swapTaproot = 10080 # 1 week for taproot swaps (10080 blocks) + +[[pairs]] +base = "BTC" +quote = "cBTC" +rate = 1 +fee = 0.25 +swapInFee = 0.1 + +maxSwapAmount = 10_000_000 # 0.1 BTC/cBTC +minSwapAmount = 2_500 # 2,500 sats minimum (Citrea Testnet has low fees) + + [pairs.timeoutDelta] + chain = 60 # Chain swap timeout (~1 hour, due to fast 2s blocks) + reverse = 180 # Reverse swap timeout (Lightning -> cBTC) - increased for CLTV requirements + swapMinimal = 1440 # Minimum timeout (~13.3 hours) - minimum for Lightning CLTV (80 blocks × 10 min) + swapMaximal = 2880 # Maximum timeout (~16.7 hours) - allows 100 Bitcoin blocks CLTV + swapTaproot = 10080 # Taproot timeout (~16.7 hours) + +[[pairs]] +base = "USDT_ETH" +quote = "USDT_CITREA" +rate = 1 +fee = 0.25 +swapInFee = 0.1 + +maxSwapAmount = 1_000_000_000 # 1000 USDT_ETH/USDT_CITREA +minSwapAmount = 1_000_000 # 1 USDT_ETH/USDT_CITREA + + [pairs.timeoutDelta] + chain = 1440 # Chain swap timeout (~24 hours = 144 blocks) + reverse = 1440 # ~24 hours for reverse swaps (lightning -> chain) + swapMinimal = 1440 # Minimum timeout for submarine swaps (~24 hours = 144 blocks) + swapMaximal = 2880 # Maximum timeout (~48 hours = 288 blocks) + swapTaproot = 10080 # 1 week for taproot swaps (10080 blocks) + +[[pairs]] +base = "USDT_POLYGON" +quote = "USDT_CITREA" +rate = 1 +fee = 0.25 +swapInFee = 0.1 + +maxSwapAmount = 1_000_000_000 # 1000 USDT_POLYGON/USDT_CITREA +minSwapAmount = 1_000_000 # 1 USDT_POLYGON/USDT_CITREA [pairs.timeoutDelta] - chain = 1440 # Chain swap timeout (~24 hours) - reverse = 1440 # Reverse swap timeout (Lightning -> RBTC) - swapMinimal = 1440 # Minimum timeout - swapMaximal = 2880 # Maximum timeout - swapTaproot = 10080 + chain = 1440 # Chain swap timeout (~24 hours = 144 blocks) + reverse = 1440 # ~24 hours for reverse swaps (lightning -> chain) + swapMinimal = 1440 # Minimum timeout for submarine swaps (~24 hours = 144 blocks) + swapMaximal = 2880 # Maximum timeout (~48 hours = 288 blocks) + swapTaproot = 10080 # 1 week for taproot swaps (10080 blocks) # RSK (Rootstock) Configuration [rsk] networkName = "RSK Mainnet" providerEndpoint = "[PROVIDER_ENDPOINT]" - [[rsk.contracts]] - etherSwap = "0x3d9cc5780CA1db78760ad3D35458509178A85A4A" - erc20Swap = "0x7d5a2187CC8EF75f8822daB0E8C9a2DB147BA045" + [[rsk.contracts]] + etherSwap = "0x3d9cc5780CA1db78760ad3D35458509178A85A4A" + erc20Swap = "0x7d5a2187CC8EF75f8822daB0E8C9a2DB147BA045" + + [[rsk.tokens]] + symbol = "RBTC" + + minWalletBalance = 10_000 + +# ETH (Ethereum) Configuration +[ethereum] +networkName = "Ethereum Mainnet" +providerEndpoint = "[PROVIDER_ENDPOINT]" + + [[ethereum.contracts]] + etherSwap = "0x9ADfB0F1B783486289Fc23f3A3Ad2927cebb17e4" + erc20Swap = "0x2E21F58Da58c391F110467c7484EdfA849C1CB9B" + + [[ethereum.tokens]] + symbol = "USDT_ETH" + decimals = 6 + contractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7" + + minWalletBalance = 1_000_000 # 1 USDT_ETH + +# POL (Polygon) Configuration +[polygon] +networkName = "Polygon Mainnet" +providerEndpoint = "[PROVIDER_ENDPOINT]" + + [[polygon.contracts]] + etherSwap = "0x9ADfB0F1B783486289Fc23f3A3Ad2927cebb17e4" + erc20Swap = "0x2E21F58Da58c391F110467c7484EdfA849C1CB9B" + + [[polygon.tokens]] + symbol = "USDT_POLYGON" + decimals = 6 + contractAddress = "0xc2132D05D31c914a87C6611C10748AEb04B58e8F" + + minWalletBalance = 1_000_000 # 1 USDT_POLYGON + +# Citrea Testnet Configuration +[citrea] +networkName = "Citrea Testnet" +providerEndpoint = "https://dev.rpc.testnet.juiceswap.com" + + [[citrea.contracts]] + etherSwap = "0xd02731fD8c5FDD53B613A699234FAd5EE8851B65" + erc20Swap = "0xf2e019a371e5Fd32dB2fC564Ad9eAE9E433133cc" + + [[citrea.tokens]] + symbol = "cBTC" - [[rsk.tokens]] - symbol = "RBTC" + minWalletBalance = 100_000 - maxSwapAmount = 10_000_000 - minSwapAmount = 2_500 + [[citrea.tokens]] + symbol = "USDT_CITREA" + decimals = 6 + contractAddress = "0x1Dd3057888944ff1f914626aB4BD47Dc8b6285Fe" - minWalletBalance = 10_000 + minWalletBalance = 1_000_000 # 1 USDT_CITREA From 3d4dc253997078afc152b22fb7352950562d95b8 Mon Sep 17 00:00:00 2001 From: bernd2022 Date: Mon, 24 Nov 2025 15:31:10 +0100 Subject: [PATCH 05/22] [NO-TASK] added swap/deferredClaimSymbols --- infrastructure/config/boltz/backend/dev-boltz.conf | 3 +++ infrastructure/config/boltz/backend/prd-boltz.conf | 3 +++ 2 files changed, 6 insertions(+) diff --git a/infrastructure/config/boltz/backend/dev-boltz.conf b/infrastructure/config/boltz/backend/dev-boltz.conf index 99ba8764..9d45819c 100644 --- a/infrastructure/config/boltz/backend/dev-boltz.conf +++ b/infrastructure/config/boltz/backend/dev-boltz.conf @@ -42,6 +42,9 @@ password = "[POSTGRES_PASSWORD]" [cache] redisEndpoint = "redis://redis:6379" +[swap] +deferredClaimSymbols = ["cBTC"] + # Bitcoin/Lightning Configuration [[currencies]] symbol = "BTC" diff --git a/infrastructure/config/boltz/backend/prd-boltz.conf b/infrastructure/config/boltz/backend/prd-boltz.conf index 99ba8764..9d45819c 100644 --- a/infrastructure/config/boltz/backend/prd-boltz.conf +++ b/infrastructure/config/boltz/backend/prd-boltz.conf @@ -42,6 +42,9 @@ password = "[POSTGRES_PASSWORD]" [cache] redisEndpoint = "redis://redis:6379" +[swap] +deferredClaimSymbols = ["cBTC"] + # Bitcoin/Lightning Configuration [[currencies]] symbol = "BTC" From 3f6613f8a1c8b1f44214af634547e2f28a126aba Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 24 Nov 2025 16:23:21 +0100 Subject: [PATCH 06/22] Add L-BTC to deferred claim symbols in backend configs Added L-BTC to deferredClaimSymbols array in both dev and prod configurations to enable server-side automatic claiming for L-BTC swaps. --- infrastructure/config/boltz/backend/dev-boltz.conf | 2 +- infrastructure/config/boltz/backend/prd-boltz.conf | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/infrastructure/config/boltz/backend/dev-boltz.conf b/infrastructure/config/boltz/backend/dev-boltz.conf index 9d45819c..bcc66e64 100644 --- a/infrastructure/config/boltz/backend/dev-boltz.conf +++ b/infrastructure/config/boltz/backend/dev-boltz.conf @@ -43,7 +43,7 @@ password = "[POSTGRES_PASSWORD]" redisEndpoint = "redis://redis:6379" [swap] -deferredClaimSymbols = ["cBTC"] +deferredClaimSymbols = ["L-BTC", "cBTC"] # Bitcoin/Lightning Configuration [[currencies]] diff --git a/infrastructure/config/boltz/backend/prd-boltz.conf b/infrastructure/config/boltz/backend/prd-boltz.conf index 9d45819c..bcc66e64 100644 --- a/infrastructure/config/boltz/backend/prd-boltz.conf +++ b/infrastructure/config/boltz/backend/prd-boltz.conf @@ -43,7 +43,7 @@ password = "[POSTGRES_PASSWORD]" redisEndpoint = "redis://redis:6379" [swap] -deferredClaimSymbols = ["cBTC"] +deferredClaimSymbols = ["L-BTC", "cBTC"] # Bitcoin/Lightning Configuration [[currencies]] From ccd146cc613c4756b12e4303d7d2ce5519a08eca Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 24 Nov 2025 16:24:43 +0100 Subject: [PATCH 07/22] Revert: Remove L-BTC from deferredClaimSymbols Only cBTC should be in the deferred claim symbols list. --- infrastructure/config/boltz/backend/dev-boltz.conf | 2 +- infrastructure/config/boltz/backend/prd-boltz.conf | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/infrastructure/config/boltz/backend/dev-boltz.conf b/infrastructure/config/boltz/backend/dev-boltz.conf index bcc66e64..9d45819c 100644 --- a/infrastructure/config/boltz/backend/dev-boltz.conf +++ b/infrastructure/config/boltz/backend/dev-boltz.conf @@ -43,7 +43,7 @@ password = "[POSTGRES_PASSWORD]" redisEndpoint = "redis://redis:6379" [swap] -deferredClaimSymbols = ["L-BTC", "cBTC"] +deferredClaimSymbols = ["cBTC"] # Bitcoin/Lightning Configuration [[currencies]] diff --git a/infrastructure/config/boltz/backend/prd-boltz.conf b/infrastructure/config/boltz/backend/prd-boltz.conf index bcc66e64..9d45819c 100644 --- a/infrastructure/config/boltz/backend/prd-boltz.conf +++ b/infrastructure/config/boltz/backend/prd-boltz.conf @@ -43,7 +43,7 @@ password = "[POSTGRES_PASSWORD]" redisEndpoint = "redis://redis:6379" [swap] -deferredClaimSymbols = ["L-BTC", "cBTC"] +deferredClaimSymbols = ["cBTC"] # Bitcoin/Lightning Configuration [[currencies]] From 75491e9ef783dcb87902b69e8758619833c44dff Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Wed, 17 Dec 2025 11:23:23 -0300 Subject: [PATCH 08/22] [NO-TASK]: Rewrite request to Boltz claim server (#90) * [NO-TASK]: Rewrite request to Boltz claim server * Rename path --- src/config/config.ts | 4 ++++ src/main.ts | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/config/config.ts b/src/config/config.ts index a04b0b52..2aa15754 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -187,6 +187,10 @@ export class Configuration { apiUrl: process.env.SWAP_API_URL, }; + boltzClaim = { + apiUrl: process.env.BOLTZ_CLAIM_API_URL, + }; + // --- GETTERS --- // get baseUrl(): string { return this.environment === Environment.LOC diff --git a/src/main.ts b/src/main.ts index b22ac0f5..2ff72bdb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -60,6 +60,25 @@ async function bootstrap() { server.on('upgrade', forwardProxy.upgrade); } + // --- REWRITE BOLTZ CLAIM URL --- // + if (Config.boltzClaim.apiUrl) { + const rewriteUrl = `/${Config.version}/claim`; + const forwardProxy = createProxyMiddleware({ + target: Config.boltzClaim.apiUrl, + changeOrigin: true, + toProxy: true, + secure: false, + pathRewrite: { [rewriteUrl]: '' }, + on: { + proxyReq(proxyReq, req: Request) { + if (req.ip) proxyReq.setHeader('X-Forwarded-For', req.ip.split(':')[0]); + fixRequestBody(proxyReq, req); + }, + }, + }); + app.use(rewriteUrl, forwardProxy); + } + // --- SWAGGER --- // const swaggerOptions = new DocumentBuilder() .setTitle('lightning.space API') From 966e35be5642a51d1ed99d3d05438eaff654567e Mon Sep 17 00:00:00 2001 From: David May Date: Wed, 17 Dec 2025 15:31:31 +0100 Subject: [PATCH 09/22] [NO-TASK] Refactoring & docker compose update --- .../docker/dev-docker-compose-boltz.yml | 22 +++++++++++++++++++ src/config/config.ts | 5 +---- src/main.ts | 10 ++------- 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/infrastructure/config/docker/dev-docker-compose-boltz.yml b/infrastructure/config/docker/dev-docker-compose-boltz.yml index 896d49be..e2180adf 100644 --- a/infrastructure/config/docker/dev-docker-compose-boltz.yml +++ b/infrastructure/config/docker/dev-docker-compose-boltz.yml @@ -66,6 +66,7 @@ services: max-file: '3' environment: - NODE_EXTRA_CA_CERTS=/root/.lnd/tls.cert + webapp: image: dfxswiss/boltz-webapp:dev restart: unless-stopped @@ -87,6 +88,27 @@ services: - VITE_API_URL=http://backend:9000 - VITE_RSK_LOG_SCAN_ENDPOINT=[VITE_RSK_LOG_SCAN_ENDPOINT] + claim: + image: dfxswiss/boltz-claim:dev + restart: unless-stopped + networks: + - shared + depends_on: + - postgres + ports: + - '3001:3001' + logging: + driver: 'json-file' + options: + max-size: '100m' + max-file: '3' + environment: + - PORT=3001 + - PONDER_PROFILE=mainnet + - DATABASE_URL=postgresql://[POSTGRES_USERNAME]:[POSTGRES_PASSWORD]@postgres:5432/boltz_claim + - SIGNER_PRIVATE_KEY=[SIGNER_PRIVATE_KEY] + - RPC_PROVIDER_URL=https://rpc.testnet.juiceswap.com/ + networks: shared: external: true diff --git a/src/config/config.ts b/src/config/config.ts index 2aa15754..2fa61ba2 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -185,10 +185,7 @@ export class Configuration { swap = { apiUrl: process.env.SWAP_API_URL, - }; - - boltzClaim = { - apiUrl: process.env.BOLTZ_CLAIM_API_URL, + claimApiUrl: process.env.SWAP_CLAIM_API_URL, }; // --- GETTERS --- // diff --git a/src/main.ts b/src/main.ts index 2ff72bdb..f545b062 100644 --- a/src/main.ts +++ b/src/main.ts @@ -61,20 +61,14 @@ async function bootstrap() { } // --- REWRITE BOLTZ CLAIM URL --- // - if (Config.boltzClaim.apiUrl) { + if (Config.swap.claimApiUrl) { const rewriteUrl = `/${Config.version}/claim`; const forwardProxy = createProxyMiddleware({ - target: Config.boltzClaim.apiUrl, + target: Config.swap.claimApiUrl, changeOrigin: true, toProxy: true, secure: false, pathRewrite: { [rewriteUrl]: '' }, - on: { - proxyReq(proxyReq, req: Request) { - if (req.ip) proxyReq.setHeader('X-Forwarded-For', req.ip.split(':')[0]); - fixRequestBody(proxyReq, req); - }, - }, }); app.use(rewriteUrl, forwardProxy); } From 7f8a266c5e6d892388853b8ba6f85c40d6a3afd3 Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Wed, 17 Dec 2025 13:46:43 -0300 Subject: [PATCH 10/22] fix: add fixRequestBody to Boltz claim proxy for POST requests (#91) --- src/main.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main.ts b/src/main.ts index f545b062..307ecc5b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -69,6 +69,11 @@ async function bootstrap() { toProxy: true, secure: false, pathRewrite: { [rewriteUrl]: '' }, + on: { + proxyReq(proxyReq, req: Request) { + fixRequestBody(proxyReq, req); + }, + }, }); app.use(rewriteUrl, forwardProxy); } From a41f411fdbbe0809fcfa255768a8d82a58727ca5 Mon Sep 17 00:00:00 2001 From: David May Date: Thu, 18 Dec 2025 10:27:32 +0100 Subject: [PATCH 11/22] Fixed docker compose --- infrastructure/config/docker/dev-docker-compose-boltz.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/config/docker/dev-docker-compose-boltz.yml b/infrastructure/config/docker/dev-docker-compose-boltz.yml index e2180adf..c6c7683f 100644 --- a/infrastructure/config/docker/dev-docker-compose-boltz.yml +++ b/infrastructure/config/docker/dev-docker-compose-boltz.yml @@ -89,7 +89,7 @@ services: - VITE_RSK_LOG_SCAN_ENDPOINT=[VITE_RSK_LOG_SCAN_ENDPOINT] claim: - image: dfxswiss/boltz-claim:dev + image: dfxswiss/boltz-claim:beta restart: unless-stopped networks: - shared From 20d9a9aa43e6eec88c12142e1a29a9d3575ed9b9 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 14 Jan 2026 12:42:23 +0100 Subject: [PATCH 12/22] Remove RBTC, rename USDT_CITREA to JUSD_CITREA, fix contract (#92) * Remove RBTC/RSK support from Boltz configuration - Remove BTC/RBTC swap pair - Remove RSK network configuration and contracts - Focus on BTC, cBTC, and USDT stablecoin swaps only * Fix USDT_CITREA contract address Use correct JUSD token contract on Citrea Testnet: - Old: 0x1Dd3057888944ff1f914626aB4BD47Dc8b6285Fe - New: 0xFdB0a83d94CD65151148a131167Eb499Cb85d015 * Rename USDT_CITREA to JUSD_CITREA The token on Citrea Testnet is Juice Dollar (JUSD), not USDT. Rename symbol to reflect the actual token name. --- .../config/boltz/backend/dev-boltz.conf | 51 ++++--------------- 1 file changed, 9 insertions(+), 42 deletions(-) diff --git a/infrastructure/config/boltz/backend/dev-boltz.conf b/infrastructure/config/boltz/backend/dev-boltz.conf index 9d45819c..246b0c80 100644 --- a/infrastructure/config/boltz/backend/dev-boltz.conf +++ b/infrastructure/config/boltz/backend/dev-boltz.conf @@ -112,25 +112,6 @@ minSwapAmount = 2_500 # 2,500 sats (pair level) swapMaximal = 2880 # Maximum timeout (~48 hours = 288 blocks) swapTaproot = 10080 # 1 week for taproot swaps (10080 blocks) -# Swap Pair Configuration: BTC/RBTC (Lightning BTC <-> RSK RBTC) -[[pairs]] -base = "BTC" -quote = "RBTC" -rate = 1 -fee = 0.25 -swapInFee = 0.1 - -# Swap amount limits (in satoshis) -maxSwapAmount = 10_000_000 # 0.1 BTC/RBTC -minSwapAmount = 2_500 # 2,500 sats minimum (Rootstock has lower fees) - - [pairs.timeoutDelta] - chain = 1440 # Chain swap timeout (~24 hours = 144 blocks) - reverse = 1440 # ~24 hours for reverse swaps (lightning -> chain) - swapMinimal = 1440 # Minimum timeout for submarine swaps (~24 hours = 144 blocks) - swapMaximal = 2880 # Maximum timeout (~48 hours = 288 blocks) - swapTaproot = 10080 # 1 week for taproot swaps (10080 blocks) - [[pairs]] base = "BTC" quote = "cBTC" @@ -150,13 +131,13 @@ minSwapAmount = 2_500 # 2,500 sats minimum (Citrea Testnet has low fees) [[pairs]] base = "USDT_ETH" -quote = "USDT_CITREA" +quote = "JUSD_CITREA" rate = 1 fee = 0.25 swapInFee = 0.1 -maxSwapAmount = 1_000_000_000 # 1000 USDT_ETH/USDT_CITREA -minSwapAmount = 1_000_000 # 1 USDT_ETH/USDT_CITREA +maxSwapAmount = 1_000_000_000 # 1000 USDT_ETH/JUSD_CITREA +minSwapAmount = 1_000_000 # 1 USDT_ETH/JUSD_CITREA [pairs.timeoutDelta] chain = 1440 # Chain swap timeout (~24 hours = 144 blocks) @@ -167,13 +148,13 @@ minSwapAmount = 1_000_000 # 1 USDT_ETH/USDT_CITREA [[pairs]] base = "USDT_POLYGON" -quote = "USDT_CITREA" +quote = "JUSD_CITREA" rate = 1 fee = 0.25 swapInFee = 0.1 -maxSwapAmount = 1_000_000_000 # 1000 USDT_POLYGON/USDT_CITREA -minSwapAmount = 1_000_000 # 1 USDT_POLYGON/USDT_CITREA +maxSwapAmount = 1_000_000_000 # 1000 USDT_POLYGON/JUSD_CITREA +minSwapAmount = 1_000_000 # 1 USDT_POLYGON/JUSD_CITREA [pairs.timeoutDelta] chain = 1440 # Chain swap timeout (~24 hours = 144 blocks) @@ -182,20 +163,6 @@ minSwapAmount = 1_000_000 # 1 USDT_POLYGON/USDT_CITREA swapMaximal = 2880 # Maximum timeout (~48 hours = 288 blocks) swapTaproot = 10080 # 1 week for taproot swaps (10080 blocks) -# RSK (Rootstock) Configuration -[rsk] -networkName = "RSK Mainnet" -providerEndpoint = "[PROVIDER_ENDPOINT]" - - [[rsk.contracts]] - etherSwap = "0x3d9cc5780CA1db78760ad3D35458509178A85A4A" - erc20Swap = "0x7d5a2187CC8EF75f8822daB0E8C9a2DB147BA045" - - [[rsk.tokens]] - symbol = "RBTC" - - minWalletBalance = 10_000 - # ETH (Ethereum) Configuration [ethereum] networkName = "Ethereum Mainnet" @@ -243,8 +210,8 @@ providerEndpoint = "https://dev.rpc.testnet.juiceswap.com" minWalletBalance = 100_000 [[citrea.tokens]] - symbol = "USDT_CITREA" + symbol = "JUSD_CITREA" decimals = 6 - contractAddress = "0x1Dd3057888944ff1f914626aB4BD47Dc8b6285Fe" + contractAddress = "0xFdB0a83d94CD65151148a131167Eb499Cb85d015" - minWalletBalance = 1_000_000 # 1 USDT_CITREA + minWalletBalance = 1_000_000 # 1 JUSD_CITREA From 20926c2dd99875684f160a2dcc1d6667899cf390 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 23 Jan 2026 22:15:23 +0100 Subject: [PATCH 13/22] Add debug endpoint for raw SQL queries (#93) --- package-lock.json | 29 + package.json | 1 + scripts/db-debug.sh | 108 ++++ scripts/log-debug.sh | 183 ++++++ src/config/config.ts | 7 + src/shared/auth/role.guard.ts | 1 + src/shared/auth/wallet-role.enum.ts | 1 + .../services/app-insights-query.service.ts | 39 ++ src/shared/shared.module.ts | 5 +- .../support/controllers/support.controller.ts | 23 + src/subdomains/support/dto/debug-query.dto.ts | 8 + src/subdomains/support/dto/debug.config.ts | 81 +++ src/subdomains/support/dto/log-query.dto.ts | 49 ++ .../support/services/support.service.ts | 561 +++++++++++++++++- 14 files changed, 1093 insertions(+), 3 deletions(-) create mode 100755 scripts/db-debug.sh create mode 100755 scripts/log-debug.sh create mode 100644 src/shared/services/app-insights-query.service.ts create mode 100644 src/subdomains/support/dto/debug-query.dto.ts create mode 100644 src/subdomains/support/dto/debug.config.ts create mode 100644 src/subdomains/support/dto/log-query.dto.ts diff --git a/package-lock.json b/package-lock.json index 8f7a0618..7ecae815 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "morgan": "^1.10.1", "mssql": "^9.3.2", "nestjs-real-ip": "^2.2.0", + "node-sql-parser": "^5.3.6", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.1.14", "rimraf": "^4.4.1", @@ -4301,6 +4302,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/pegjs": { + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/@types/pegjs/-/pegjs-0.10.6.tgz", + "integrity": "sha512-eLYXDbZWXh2uxf+w8sXS8d6KSoXTswfps6fvCUuVAGN8eRpfe7h9eSRydxiSJvo9Bf+GzifsDOr9TMQlmJdmkw==", + "license": "MIT" + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -5520,6 +5527,15 @@ "integrity": "sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ==", "license": "MIT" }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, "node_modules/big.js": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", @@ -11816,6 +11832,19 @@ "dev": true, "license": "MIT" }, + "node_modules/node-sql-parser": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/node-sql-parser/-/node-sql-parser-5.4.0.tgz", + "integrity": "sha512-jVe6Z61gPcPjCElPZ6j8llB3wnqGcuQzefim1ERsqIakxnEy5JlzV7XKdO1KmacRG5TKwPc4vJTgSRQ0LfkbFw==", + "license": "Apache-2.0", + "dependencies": { + "@types/pegjs": "^0.10.0", + "big-integer": "^1.6.48" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/package.json b/package.json index a34fe3b6..7b8d6d37 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "lnurl": "^0.24.2", "morgan": "^1.10.1", "mssql": "^9.3.2", + "node-sql-parser": "^5.3.6", "nestjs-real-ip": "^2.2.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.1.14", diff --git a/scripts/db-debug.sh b/scripts/db-debug.sh new file mode 100755 index 00000000..895ef961 --- /dev/null +++ b/scripts/db-debug.sh @@ -0,0 +1,108 @@ +#!/bin/bash + +# LDS API Debug Database Access Script +# +# Usage: +# ./scripts/db-debug.sh # Default query (wallets) +# ./scripts/db-debug.sh "SELECT TOP 10 id FROM wallet" # Custom SQL query +# +# Environment: +# Uses the central .env file. Required variables: +# - DEBUG_ADDRESS: Wallet address with DEBUG role +# - DEBUG_SIGNATURE: Signature from signing the LDS login message +# - DEBUG_API_URL (optional): API URL, defaults to https://api.lightning.space/v1 +# +# Requirements: +# - curl +# - jq (optional, for pretty output) + +set -e + +# --- Help --- +if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then + echo "LDS API Debug Database Access Script" + echo "" + echo "Usage:" + echo " ./scripts/db-debug.sh [SQL_QUERY]" + echo "" + echo "Examples:" + echo " ./scripts/db-debug.sh \"SELECT TOP 10 * FROM wallet\"" + echo " ./scripts/db-debug.sh \"SELECT TOP 10 * FROM user_transaction ORDER BY id DESC\"" + echo " ./scripts/db-debug.sh \"SELECT TOP 10 * FROM lightning_wallet\"" + exit 0 +fi + +# --- Parse arguments --- +SQL="${1:-SELECT TOP 5 id, address, role FROM wallet ORDER BY id DESC}" + +# --- Load environment --- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="$SCRIPT_DIR/../.env" + +if [ ! -f "$ENV_FILE" ]; then + echo "Error: Environment file not found: $ENV_FILE" + echo "Create .env in the api root directory with DEBUG_ADDRESS and DEBUG_SIGNATURE" + exit 1 +fi + +# Read specific variables (avoid sourcing to prevent bash keyword conflicts) +DEBUG_ADDRESS=$(grep -E "^DEBUG_ADDRESS=" "$ENV_FILE" | cut -d'=' -f2-) +DEBUG_SIGNATURE=$(grep -E "^DEBUG_SIGNATURE=" "$ENV_FILE" | cut -d'=' -f2-) +DEBUG_API_URL=$(grep -E "^DEBUG_API_URL=" "$ENV_FILE" | cut -d'=' -f2-) + +if [ -z "$DEBUG_ADDRESS" ] || [ -z "$DEBUG_SIGNATURE" ]; then + echo "Error: DEBUG_ADDRESS and DEBUG_SIGNATURE must be set in .env" + echo "" + echo "To set up debug access:" + echo "1. Get a wallet address with DEBUG role assigned" + echo "2. Sign the message from GET /v1/auth/sign-message?address=YOUR_ADDRESS" + echo "3. Add to .env:" + echo " DEBUG_ADDRESS=your_wallet_address" + echo " DEBUG_SIGNATURE=your_signature" + exit 1 +fi + +API_URL="${DEBUG_API_URL:-https://api.lightning.space/v1}" + +# --- Authenticate --- +echo "=== Authenticating to $API_URL ===" +TOKEN_RESPONSE=$(curl -s -X POST "$API_URL/auth" \ + -H "Content-Type: application/json" \ + -d "{\"address\":\"$DEBUG_ADDRESS\",\"signature\":\"$DEBUG_SIGNATURE\"}") + +TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.accessToken' 2>/dev/null) + +if [ "$TOKEN" == "null" ] || [ -z "$TOKEN" ]; then + echo "Authentication failed:" + echo "$TOKEN_RESPONSE" | jq . 2>/dev/null || echo "$TOKEN_RESPONSE" + exit 1 +fi + +ROLE=$(echo "$TOKEN" | cut -d'.' -f2 | base64 -d 2>/dev/null | jq -r '.role' 2>/dev/null || echo "unknown") +echo "Authenticated with role: $ROLE" +echo "" + +# --- Execute query --- +echo "=== Executing SQL Query ===" +echo "Query: $SQL" +echo "" + +RESULT=$(curl -s -X POST "$API_URL/support/debug" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"sql\":\"$SQL\"}") + +echo "=== Result ===" + +if command -v jq &> /dev/null; then + # Check if it's an error + ERROR=$(echo "$RESULT" | jq -r '.message // empty' 2>/dev/null) + if [ -n "$ERROR" ]; then + echo "Error: $ERROR" + exit 1 + fi + + echo "$RESULT" | jq . +else + echo "$RESULT" +fi diff --git a/scripts/log-debug.sh b/scripts/log-debug.sh new file mode 100755 index 00000000..a7485f43 --- /dev/null +++ b/scripts/log-debug.sh @@ -0,0 +1,183 @@ +#!/bin/bash + +# LDS API Debug Log Access Script (Azure Application Insights) +# +# Usage: +# ./scripts/log-debug.sh # Recent exceptions (default) +# ./scripts/log-debug.sh exceptions # Recent exceptions +# ./scripts/log-debug.sh failures # Failed requests +# ./scripts/log-debug.sh slow [durationMs] # Slow dependencies (default: 1000ms) +# ./scripts/log-debug.sh traces "search term" # Search in trace messages +# ./scripts/log-debug.sh operation # Traces by operation ID +# ./scripts/log-debug.sh events # Custom events +# +# Options: +# -h, --hours Time range in hours (default: 1, max: 168) +# +# Environment: +# Uses the central .env file. Required variables: +# - DEBUG_ADDRESS: Wallet address with DEBUG role +# - DEBUG_SIGNATURE: Signature from signing the LDS login message +# - DEBUG_API_URL (optional): API URL, defaults to https://api.lightning.space/v1 + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="$SCRIPT_DIR/../.env" + +# Load environment variables +if [ ! -f "$ENV_FILE" ]; then + echo "Error: Environment file not found: $ENV_FILE" + echo "Create .env in the api root directory" + exit 1 +fi + +# Read specific variables (avoid sourcing to prevent bash keyword conflicts) +DEBUG_ADDRESS=$(grep -E "^DEBUG_ADDRESS=" "$ENV_FILE" | cut -d'=' -f2-) +DEBUG_SIGNATURE=$(grep -E "^DEBUG_SIGNATURE=" "$ENV_FILE" | cut -d'=' -f2-) +DEBUG_API_URL=$(grep -E "^DEBUG_API_URL=" "$ENV_FILE" | cut -d'=' -f2-) + +# Validate required variables +if [ -z "$DEBUG_ADDRESS" ] || [ -z "$DEBUG_SIGNATURE" ]; then + echo "Error: DEBUG_ADDRESS and DEBUG_SIGNATURE must be set in .env" + exit 1 +fi + +API_URL="${DEBUG_API_URL:-https://api.lightning.space/v1}" + +# Parse arguments +HOURS=1 +COMMAND="${1:-exceptions}" +shift 2>/dev/null || true + +# Parse options +while [[ $# -gt 0 ]]; do + case $1 in + -h|--hours) + HOURS="$2" + shift 2 + ;; + *) + PARAM="$1" + shift + ;; + esac +done + +# Get JWT Token +echo "=== Authenticating to $API_URL ===" +TOKEN_RESPONSE=$(curl -s -X POST "$API_URL/auth" \ + -H "Content-Type: application/json" \ + -d "{\"address\":\"$DEBUG_ADDRESS\",\"signature\":\"$DEBUG_SIGNATURE\"}") + +TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.accessToken' 2>/dev/null) + +if [ "$TOKEN" == "null" ] || [ -z "$TOKEN" ]; then + echo "Authentication failed:" + echo "$TOKEN_RESPONSE" | jq . 2>/dev/null || echo "$TOKEN_RESPONSE" + exit 1 +fi + +ROLE=$(echo "$TOKEN" | cut -d'.' -f2 | base64 -d 2>/dev/null | jq -r '.role' 2>/dev/null || echo "unknown") +echo "Authenticated with role: $ROLE" +echo "" + +# Build request based on command +case $COMMAND in + exceptions|exc) + TEMPLATE="exceptions-recent" + BODY="{\"template\":\"$TEMPLATE\",\"hours\":$HOURS}" + echo "=== Recent Exceptions (last ${HOURS}h) ===" + ;; + failures|fail) + TEMPLATE="request-failures" + BODY="{\"template\":\"$TEMPLATE\",\"hours\":$HOURS}" + echo "=== Failed Requests (last ${HOURS}h) ===" + ;; + slow) + TEMPLATE="dependencies-slow" + DURATION="${PARAM:-1000}" + BODY="{\"template\":\"$TEMPLATE\",\"hours\":$HOURS,\"durationMs\":$DURATION}" + echo "=== Slow Dependencies >${DURATION}ms (last ${HOURS}h) ===" + ;; + traces|trace) + if [ -z "$PARAM" ]; then + echo "Error: traces requires a search term" + echo "Usage: ./log-debug.sh traces \"search term\"" + exit 1 + fi + TEMPLATE="traces-by-message" + BODY="{\"template\":\"$TEMPLATE\",\"hours\":$HOURS,\"messageFilter\":\"$PARAM\"}" + echo "=== Traces containing '$PARAM' (last ${HOURS}h) ===" + ;; + operation|op) + if [ -z "$PARAM" ]; then + echo "Error: operation requires a GUID" + echo "Usage: ./log-debug.sh operation " + exit 1 + fi + TEMPLATE="traces-by-operation" + BODY="{\"template\":\"$TEMPLATE\",\"hours\":$HOURS,\"operationId\":\"$PARAM\"}" + echo "=== Traces for operation $PARAM (last ${HOURS}h) ===" + ;; + events|event) + if [ -z "$PARAM" ]; then + echo "Error: events requires an event name" + echo "Usage: ./log-debug.sh events " + exit 1 + fi + TEMPLATE="custom-events" + BODY="{\"template\":\"$TEMPLATE\",\"hours\":$HOURS,\"eventName\":\"$PARAM\"}" + echo "=== Custom Events '$PARAM' (last ${HOURS}h) ===" + ;; + *) + echo "Unknown command: $COMMAND" + echo "" + echo "Available commands:" + echo " exceptions Recent exceptions" + echo " failures Failed HTTP requests" + echo " slow [ms] Slow dependencies (default: 1000ms)" + echo " traces Search trace messages" + echo " operation Traces by operation GUID" + echo " events Custom events by name" + exit 1 + ;; +esac + +echo "" + +# Execute log query +RESULT=$(curl -s -X POST "$API_URL/support/debug/logs" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "$BODY") + +# Format output +if command -v jq &> /dev/null; then + # Check if it's an error + ERROR=$(echo "$RESULT" | jq -r '.message // empty' 2>/dev/null) + if [ -n "$ERROR" ]; then + echo "Error: $ERROR" + exit 1 + fi + + # Get columns and rows + COLUMNS=$(echo "$RESULT" | jq -r '.columns[].name' 2>/dev/null | tr '\n' '\t') + ROWS=$(echo "$RESULT" | jq -r '.rows[] | @tsv' 2>/dev/null) + + if [ -z "$ROWS" ]; then + echo "No results found." + else + echo -e "$COLUMNS" + echo "---" + echo -e "$ROWS" | head -50 + + ROW_COUNT=$(echo "$RESULT" | jq '.rows | length' 2>/dev/null) + if [ "$ROW_COUNT" -gt 50 ]; then + echo "" + echo "... and $((ROW_COUNT - 50)) more rows (showing first 50)" + fi + fi +else + echo "$RESULT" +fi diff --git a/src/config/config.ts b/src/config/config.ts index 2fa61ba2..982c5537 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -178,6 +178,13 @@ export class Configuration { apiKey: process.env.COIN_GECKO_API_KEY, }; + azure = { + appInsights: { + appId: process.env.AZURE_APP_INSIGHTS_APP_ID ?? '', + apiKey: process.env.AZURE_APP_INSIGHTS_API_KEY ?? '', + }, + }; + request = { knownIps: process.env.REQUEST_KNOWN_IPS?.split(',') ?? [], limitCheck: process.env.REQUEST_LIMIT_CHECK === 'true', diff --git a/src/shared/auth/role.guard.ts b/src/shared/auth/role.guard.ts index 770978d2..3bfdd14b 100644 --- a/src/shared/auth/role.guard.ts +++ b/src/shared/auth/role.guard.ts @@ -6,6 +6,7 @@ export class RoleGuard implements CanActivate { // additional allowed roles private readonly additionalRoles = { [WalletRole.USER]: [WalletRole.ADMIN], + [WalletRole.DEBUG]: [WalletRole.ADMIN], }; constructor(private readonly entryRole: WalletRole | WalletRole[]) {} diff --git a/src/shared/auth/wallet-role.enum.ts b/src/shared/auth/wallet-role.enum.ts index b3a580b0..dc10f715 100644 --- a/src/shared/auth/wallet-role.enum.ts +++ b/src/shared/auth/wallet-role.enum.ts @@ -1,4 +1,5 @@ export enum WalletRole { USER = 'User', ADMIN = 'Admin', + DEBUG = 'Debug', } diff --git a/src/shared/services/app-insights-query.service.ts b/src/shared/services/app-insights-query.service.ts new file mode 100644 index 00000000..42b9c7ed --- /dev/null +++ b/src/shared/services/app-insights-query.service.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@nestjs/common'; +import { GetConfig } from 'src/config/config'; +import { HttpService } from './http.service'; + +interface AppInsightsQueryResponse { + tables: { + name: string; + columns: { name: string; type: string }[]; + rows: unknown[][]; + }[]; +} + +@Injectable() +export class AppInsightsQueryService { + private readonly baseUrl = 'https://api.applicationinsights.io/v1'; + + constructor(private readonly http: HttpService) {} + + async query(kql: string, timespan?: string): Promise { + const { appId, apiKey } = GetConfig().azure.appInsights; + + if (!appId || !apiKey) { + throw new Error('App Insights config missing (AZURE_APP_INSIGHTS_APP_ID, AZURE_APP_INSIGHTS_API_KEY)'); + } + + const body: { query: string; timespan?: string } = { query: kql }; + if (timespan) body.timespan = timespan; + + return this.http.request({ + url: `${this.baseUrl}/apps/${appId}/query`, + method: 'POST', + data: body, + headers: { + 'x-api-key': apiKey, + 'Content-Type': 'application/json', + }, + }); + } +} diff --git a/src/shared/shared.module.ts b/src/shared/shared.module.ts index ce0a3124..578bd42a 100644 --- a/src/shared/shared.module.ts +++ b/src/shared/shared.module.ts @@ -7,6 +7,7 @@ import { GetConfig } from 'src/config/config'; import { ConfigModule } from 'src/config/config.module'; import { JwtStrategy } from './auth/jwt.strategy'; import { RepositoryFactory } from './db/repository.factory'; +import { AppInsightsQueryService } from './services/app-insights-query.service'; import { HttpService } from './services/http.service'; @Module({ @@ -18,7 +19,7 @@ import { HttpService } from './services/http.service'; ScheduleModule.forRoot(), ], controllers: [], - providers: [HttpService, JwtStrategy, RepositoryFactory], - exports: [PassportModule, JwtModule, ScheduleModule, HttpService, RepositoryFactory], + providers: [HttpService, JwtStrategy, RepositoryFactory, AppInsightsQueryService], + exports: [PassportModule, JwtModule, ScheduleModule, HttpService, RepositoryFactory, AppInsightsQueryService], }) export class SharedModule {} diff --git a/src/subdomains/support/controllers/support.controller.ts b/src/subdomains/support/controllers/support.controller.ts index 74dba59f..670694f7 100644 --- a/src/subdomains/support/controllers/support.controller.ts +++ b/src/subdomains/support/controllers/support.controller.ts @@ -2,9 +2,13 @@ import { Controller, UseGuards } from '@nestjs/common'; import { Body, Post } from '@nestjs/common/decorators'; import { AuthGuard } from '@nestjs/passport'; import { ApiBearerAuth, ApiExcludeEndpoint } from '@nestjs/swagger'; +import { GetJwt } from 'src/shared/auth/get-jwt.decorator'; +import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; import { RoleGuard } from 'src/shared/auth/role.guard'; import { WalletRole } from 'src/shared/auth/wallet-role.enum'; import { DbQueryDto } from '../dto/db-query.dto'; +import { DebugQueryDto } from '../dto/debug-query.dto'; +import { LogQueryDto, LogQueryResult } from '../dto/log-query.dto'; import { SupportService } from '../services/support.service'; @Controller('support') @@ -21,4 +25,23 @@ export class SupportController { ): Promise<{ keys: string[]; values: any }> { return this.supportService.getRawData(query); } + + @Post('debug') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), new RoleGuard(WalletRole.DEBUG)) + async executeDebugQuery( + @GetJwt() jwt: JwtPayload, + @Body() dto: DebugQueryDto, + ): Promise[]> { + return this.supportService.executeDebugQuery(dto.sql, jwt.address); + } + + @Post('debug/logs') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), new RoleGuard(WalletRole.DEBUG)) + async executeLogQuery(@GetJwt() jwt: JwtPayload, @Body() dto: LogQueryDto): Promise { + return this.supportService.executeLogQuery(dto, jwt.address); + } } diff --git a/src/subdomains/support/dto/debug-query.dto.ts b/src/subdomains/support/dto/debug-query.dto.ts new file mode 100644 index 00000000..9385b762 --- /dev/null +++ b/src/subdomains/support/dto/debug-query.dto.ts @@ -0,0 +1,8 @@ +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +export class DebugQueryDto { + @IsNotEmpty() + @IsString() + @MaxLength(10000) + sql: string; +} diff --git a/src/subdomains/support/dto/debug.config.ts b/src/subdomains/support/dto/debug.config.ts new file mode 100644 index 00000000..d3ad81c9 --- /dev/null +++ b/src/subdomains/support/dto/debug.config.ts @@ -0,0 +1,81 @@ +import { LogQueryDto, LogQueryTemplate } from './log-query.dto'; + +// Debug endpoint configuration + +// Maximum number of results returned by debug queries +export const DebugMaxResults = 10000; + +// Blocked database schemas (system tables) +export const DebugBlockedSchemas = ['sys', 'information_schema', 'master', 'msdb', 'tempdb']; + +// Dangerous SQL functions that could be used for data exfiltration or external connections +export const DebugDangerousFunctions = ['openrowset', 'openquery', 'opendatasource', 'openxml']; + +// Blocked columns per table (sensitive data that should not be exposed via debug endpoint) +export const DebugBlockedCols: Record = { + wallet: ['signature', 'addressOwnershipProof'], + lightning_wallet: ['adminKey', 'invoiceKey'], + user_boltcard: ['k0', 'k1', 'k2', 'prevK0', 'prevK1', 'prevK2', 'otp', 'uid'], + transaction_lightning: ['secret', 'paymentRequest'], + payment_request: ['paymentRequest'], +}; + +// Log query templates for Azure Application Insights +export const DebugLogQueryTemplates: Record< + LogQueryTemplate, + { kql: string; requiredParams: (keyof LogQueryDto)[]; defaultLimit: number } +> = { + [LogQueryTemplate.TRACES_BY_OPERATION]: { + kql: `traces +| where operation_Id == "{operationId}" +| where timestamp > ago({hours}h) +| project timestamp, severityLevel, message, customDimensions +| order by timestamp desc`, + requiredParams: ['operationId'], + defaultLimit: 500, + }, + [LogQueryTemplate.TRACES_BY_MESSAGE]: { + kql: `traces +| where timestamp > ago({hours}h) +| where message contains "{messageFilter}" +| project timestamp, severityLevel, message, operation_Id +| order by timestamp desc`, + requiredParams: ['messageFilter'], + defaultLimit: 200, + }, + [LogQueryTemplate.EXCEPTIONS_RECENT]: { + kql: `exceptions +| where timestamp > ago({hours}h) +| project timestamp, problemId, outerMessage, innermostMessage, operation_Id +| order by timestamp desc`, + requiredParams: [], + defaultLimit: 500, + }, + [LogQueryTemplate.REQUEST_FAILURES]: { + kql: `requests +| where timestamp > ago({hours}h) +| where success == false +| project timestamp, resultCode, duration, operation_Name, operation_Id +| order by timestamp desc`, + requiredParams: [], + defaultLimit: 500, + }, + [LogQueryTemplate.DEPENDENCIES_SLOW]: { + kql: `dependencies +| where timestamp > ago({hours}h) +| where duration > {durationMs} +| project timestamp, target, type, duration, success, operation_Id +| order by duration desc`, + requiredParams: ['durationMs'], + defaultLimit: 200, + }, + [LogQueryTemplate.CUSTOM_EVENTS]: { + kql: `customEvents +| where timestamp > ago({hours}h) +| where name == "{eventName}" +| project timestamp, name, customDimensions, operation_Id +| order by timestamp desc`, + requiredParams: ['eventName'], + defaultLimit: 500, + }, +}; diff --git a/src/subdomains/support/dto/log-query.dto.ts b/src/subdomains/support/dto/log-query.dto.ts new file mode 100644 index 00000000..db1e181e --- /dev/null +++ b/src/subdomains/support/dto/log-query.dto.ts @@ -0,0 +1,49 @@ +import { IsEnum, IsInt, IsOptional, IsString, Matches, Max, Min } from 'class-validator'; + +export enum LogQueryTemplate { + TRACES_BY_OPERATION = 'traces-by-operation', + TRACES_BY_MESSAGE = 'traces-by-message', + EXCEPTIONS_RECENT = 'exceptions-recent', + REQUEST_FAILURES = 'request-failures', + DEPENDENCIES_SLOW = 'dependencies-slow', + CUSTOM_EVENTS = 'custom-events', +} + +export class LogQueryDto { + @IsEnum(LogQueryTemplate) + template: LogQueryTemplate; + + @IsOptional() + @IsString() + @Matches(/^[a-f0-9-]{36}$/i, { message: 'operationId must be a valid GUID' }) + operationId?: string; + + @IsOptional() + @IsString() + @Matches(/^[a-zA-Z0-9_\-.: ()]{1,100}$/, { + message: 'messageFilter must be alphanumeric with basic punctuation (max 100 chars)', + }) + messageFilter?: string; + + @IsOptional() + @IsInt() + @Min(1) + @Max(168) // max 7 days + hours?: number; + + @IsOptional() + @IsInt() + @Min(100) + @Max(5000) + durationMs?: number; + + @IsOptional() + @IsString() + @Matches(/^\w{1,50}$/, { message: 'eventName must be alphanumeric' }) + eventName?: string; +} + +export class LogQueryResult { + columns: { name: string; type: string }[]; + rows: unknown[][]; +} diff --git a/src/subdomains/support/services/support.service.ts b/src/subdomains/support/services/support.service.ts index a756c7e9..f45294d0 100644 --- a/src/subdomains/support/services/support.service.ts +++ b/src/subdomains/support/services/support.service.ts @@ -1,10 +1,27 @@ import { BadRequestException, Injectable } from '@nestjs/common'; +import { Parser } from 'node-sql-parser'; +import { AppInsightsQueryService } from 'src/shared/services/app-insights-query.service'; +import { LightningLogger } from 'src/shared/services/lightning-logger'; import { DataSource } from 'typeorm'; import { DbQueryDto } from '../dto/db-query.dto'; +import { + DebugBlockedCols, + DebugBlockedSchemas, + DebugDangerousFunctions, + DebugLogQueryTemplates, + DebugMaxResults, +} from '../dto/debug.config'; +import { LogQueryDto, LogQueryResult } from '../dto/log-query.dto'; @Injectable() export class SupportService { - constructor(private readonly dataSource: DataSource) {} + private readonly logger = new LightningLogger(SupportService); + private readonly sqlParser = new Parser(); + + constructor( + private readonly dataSource: DataSource, + private readonly appInsightsQueryService: AppInsightsQueryService, + ) {} async getRawData(query: DbQueryDto): Promise { const request = this.dataSource @@ -33,8 +50,128 @@ export class SupportService { return this.transformResultArray(data, query.table); } + async executeDebugQuery(sql: string, userIdentifier: string): Promise[]> { + // 1. Parse SQL to AST for robust validation + let ast; + try { + ast = this.sqlParser.astify(sql, { database: 'TransactSQL' }); + } catch { + throw new BadRequestException('Invalid SQL syntax'); + } + + // 2. Only single SELECT statements allowed (array means multiple statements) + const statements = Array.isArray(ast) ? ast : [ast]; + if (statements.length !== 1) { + throw new BadRequestException('Only single statements allowed'); + } + + const stmt = statements[0]; + if (stmt.type !== 'select') { + throw new BadRequestException('Only SELECT queries allowed'); + } + + // 3. No UNION/INTERSECT/EXCEPT queries (these have _next property) + if (stmt._next) { + throw new BadRequestException('UNION/INTERSECT/EXCEPT queries not allowed'); + } + + // 4. No SELECT INTO (creates tables - write operation!) + if (stmt.into?.type === 'into' || stmt.into?.expr) { + throw new BadRequestException('SELECT INTO not allowed'); + } + + // 5. No system tables/schemas (prevent access to sys.*, INFORMATION_SCHEMA.*, etc.) + this.checkForBlockedSchemas(stmt); + + // 6. No dangerous functions anywhere in the query (external connections) + this.checkForDangerousFunctionsRecursive(stmt); + + // 7. No FOR XML/JSON (data exfiltration) - check recursively including subqueries + this.checkForXmlJsonRecursive(stmt); + + // 8. Check for blocked columns BEFORE execution (prevents alias bypass) + const tables = this.getTablesFromQuery(sql); + const blockedColumn = this.findBlockedColumnInQuery(sql, stmt, tables); + if (blockedColumn) { + throw new BadRequestException(`Access to column '${blockedColumn}' is not allowed`); + } + + // 9. Validate TOP value if present (use AST for accurate detection including TOP(n) syntax) + if (stmt.top?.value > DebugMaxResults) { + throw new BadRequestException(`TOP value exceeds maximum of ${DebugMaxResults}`); + } + + // 10. Log query for audit trail + this.logger.verbose(`Debug query by ${userIdentifier}: ${sql.substring(0, 500)}${sql.length > 500 ? '...' : ''}`); + + // 11. Execute query with result limit + try { + const limitedSql = this.ensureResultLimit(sql); + const result = await this.dataSource.query(limitedSql); + + // 12. Post-execution masking (defense in depth - also catches pre-execution failures) + this.maskDebugBlockedColumns(result, tables); + + return result; + } catch (e) { + this.logger.info(`Debug query by ${userIdentifier} failed: ${e.message}`); + throw new BadRequestException('Query execution failed'); + } + } + + async executeLogQuery(dto: LogQueryDto, userIdentifier: string): Promise { + const template = DebugLogQueryTemplates[dto.template]; + if (!template) { + throw new BadRequestException('Unknown template'); + } + + // Validate required params + for (const param of template.requiredParams) { + if (!dto[param]) { + throw new BadRequestException(`Parameter '${param}' is required for template '${dto.template}'`); + } + } + + // Build KQL with safe parameter substitution + let kql = template.kql; + kql = kql.replace('{operationId}', this.escapeKqlString(dto.operationId ?? '')); + kql = kql.replace('{messageFilter}', this.escapeKqlString(dto.messageFilter ?? '')); + kql = kql.replaceAll('{hours}', String(dto.hours ?? 1)); + kql = kql.replace('{durationMs}', String(dto.durationMs ?? 1000)); + kql = kql.replace('{eventName}', this.escapeKqlString(dto.eventName ?? '')); + + // Add limit + kql += `\n| take ${template.defaultLimit}`; + + // Log for audit + this.logger.verbose(`Log query by ${userIdentifier}: template=${dto.template}, params=${JSON.stringify(dto)}`); + + // Execute + const timespan = `PT${dto.hours ?? 1}H`; + + try { + const response = await this.appInsightsQueryService.query(kql, timespan); + + if (!response.tables?.length) { + return { columns: [], rows: [] }; + } + + return { + columns: response.tables[0].columns, + rows: response.tables[0].rows, + }; + } catch (e) { + this.logger.info(`Log query by ${userIdentifier} failed: ${e.message}`); + throw new BadRequestException('Query execution failed'); + } + } + //*** HELPER METHODS ***// + private escapeKqlString(value: string): string { + return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"'); + } + private transformResultArray( data: any[], table: string, @@ -60,4 +197,426 @@ export class SupportService { private toDotSeparation(str: string): string { return str.charAt(0).toLowerCase() + str.slice(1).split('_').join('.'); } + + // --- DEBUG QUERY HELPER METHODS --- // + + private getTablesFromQuery(sql: string): string[] { + const tableList = this.sqlParser.tableList(sql, { database: 'TransactSQL' }); + // Format: 'select::null::table_name' → extract table_name + return tableList.map((t) => t.split('::')[2]).filter(Boolean); + } + + private getAliasToTableMap(ast: any): Map { + const map = new Map(); + if (!ast.from) return map; + + for (const item of ast.from) { + if (item.table) { + map.set(item.as || item.table, item.table); + } + } + return map; + } + + private resolveTableFromAlias( + tableOrAlias: string, + tables: string[], + aliasMap: Map, + ): string | null { + if (tableOrAlias === 'null') { + return tables.length === 1 ? tables[0] : null; + } + return aliasMap.get(tableOrAlias) || tableOrAlias; + } + + private isColumnBlockedInTable(columnName: string, table: string | null, allTables: string[]): boolean { + const lower = columnName.toLowerCase(); + + if (table) { + const blockedCols = DebugBlockedCols[table.toLowerCase()]; + return blockedCols?.some((b) => b.toLowerCase() === lower) ?? false; + } else { + return allTables.some((t) => { + const blockedCols = DebugBlockedCols[t.toLowerCase()]; + return blockedCols?.some((b) => b.toLowerCase() === lower) ?? false; + }); + } + } + + private findBlockedColumnInQuery(sql: string, ast: any, tables: string[]): string | null { + try { + const columns = this.sqlParser.columnList(sql, { database: 'TransactSQL' }); + const aliasMap = this.getAliasToTableMap(ast); + + for (const col of columns) { + const parts = col.split('::'); + const tableOrAlias = parts[1]; + const columnName = parts[2]; + + if (columnName === '*' || columnName === '(.*)') continue; + + const resolvedTable = this.resolveTableFromAlias(tableOrAlias, tables, aliasMap); + + if (this.isColumnBlockedInTable(columnName, resolvedTable, tables)) { + return `${resolvedTable || 'unknown'}.${columnName}`; + } + } + + return null; + } catch { + return null; + } + } + + private checkForBlockedSchemas(stmt: any): void { + if (!stmt) return; + + if (stmt.from) { + for (const item of stmt.from) { + if (item.server) { + throw new BadRequestException('Linked server access is not allowed'); + } + + const schema = item.db?.toLowerCase() || item.schema?.toLowerCase(); + const table = item.table?.toLowerCase(); + + if (schema && DebugBlockedSchemas.includes(schema)) { + throw new BadRequestException(`Access to schema '${schema}' is not allowed`); + } + + if (table && DebugBlockedSchemas.some((s) => table.startsWith(s + '.'))) { + throw new BadRequestException(`Access to system tables is not allowed`); + } + + if (item.expr?.ast) { + this.checkForBlockedSchemas(item.expr.ast); + } + + this.checkSubqueriesForBlockedSchemas(item.on); + } + } + + if (stmt.columns) { + for (const col of stmt.columns) { + this.checkSubqueriesForBlockedSchemas(col.expr); + } + } + + this.checkSubqueriesForBlockedSchemas(stmt.where); + this.checkSubqueriesForBlockedSchemas(stmt.having); + + if (stmt.orderby) { + for (const item of stmt.orderby) { + this.checkSubqueriesForBlockedSchemas(item.expr); + } + } + + if (stmt.groupby?.columns) { + for (const item of stmt.groupby.columns) { + this.checkSubqueriesForBlockedSchemas(item); + } + } + + if (stmt.with) { + for (const cte of stmt.with) { + if (cte.stmt?.ast) { + this.checkForBlockedSchemas(cte.stmt.ast); + } + } + } + } + + private checkSubqueriesForBlockedSchemas(node: any): void { + if (!node) return; + + if (node.ast) { + this.checkForBlockedSchemas(node.ast); + } + + if (node.left) this.checkSubqueriesForBlockedSchemas(node.left); + if (node.right) this.checkSubqueriesForBlockedSchemas(node.right); + if (node.expr) this.checkSubqueriesForBlockedSchemas(node.expr); + + if (node.result) this.checkSubqueriesForBlockedSchemas(node.result); + if (node.condition) this.checkSubqueriesForBlockedSchemas(node.condition); + + if (node.args) { + const args = Array.isArray(node.args) ? node.args : node.args?.value || []; + for (const arg of Array.isArray(args) ? args : [args]) { + this.checkSubqueriesForBlockedSchemas(arg); + } + } + if (node.value && Array.isArray(node.value)) { + for (const val of node.value) { + this.checkSubqueriesForBlockedSchemas(val); + } + } + + if (node.over?.as_window_specification?.window_specification) { + const winSpec = node.over.as_window_specification.window_specification; + if (winSpec.orderby) { + for (const item of winSpec.orderby) { + this.checkSubqueriesForBlockedSchemas(item.expr); + } + } + if (winSpec.partitionby) { + for (const item of winSpec.partitionby) { + this.checkSubqueriesForBlockedSchemas(item); + } + } + } + } + + private checkForDangerousFunctionsRecursive(stmt: any): void { + if (!stmt) return; + + this.checkFromForDangerousFunctions(stmt.from); + this.checkExpressionsForDangerousFunctions(stmt.columns); + this.checkNodeForDangerousFunctions(stmt.where); + this.checkNodeForDangerousFunctions(stmt.having); + + if (stmt.orderby) { + for (const item of stmt.orderby) { + this.checkNodeForDangerousFunctions(item.expr); + } + } + + if (stmt.groupby?.columns) { + for (const item of stmt.groupby.columns) { + this.checkNodeForDangerousFunctions(item); + } + } + + if (stmt.with) { + for (const cte of stmt.with) { + if (cte.stmt?.ast) { + this.checkForDangerousFunctionsRecursive(cte.stmt.ast); + } + } + } + } + + private checkFromForDangerousFunctions(from: any[]): void { + if (!from) return; + + for (const item of from) { + if (item.type === 'expr' && item.expr?.type === 'function') { + const funcName = this.extractFunctionName(item.expr); + if (funcName && DebugDangerousFunctions.includes(funcName)) { + throw new BadRequestException(`Function '${funcName.toUpperCase()}' not allowed`); + } + } + + if (item.expr?.ast) { + this.checkForDangerousFunctionsRecursive(item.expr.ast); + } + + this.checkNodeForDangerousFunctions(item.on); + } + } + + private checkExpressionsForDangerousFunctions(columns: any[]): void { + if (!columns) return; + + for (const col of columns) { + this.checkNodeForDangerousFunctions(col.expr); + } + } + + private checkNodeForDangerousFunctions(node: any): void { + if (!node) return; + + if (node.type === 'function') { + const funcName = this.extractFunctionName(node); + if (funcName && DebugDangerousFunctions.includes(funcName)) { + throw new BadRequestException(`Function '${funcName.toUpperCase()}' not allowed`); + } + } + + if (node.ast) { + this.checkForDangerousFunctionsRecursive(node.ast); + } + + if (node.left) this.checkNodeForDangerousFunctions(node.left); + if (node.right) this.checkNodeForDangerousFunctions(node.right); + if (node.expr) this.checkNodeForDangerousFunctions(node.expr); + + if (node.result) this.checkNodeForDangerousFunctions(node.result); + if (node.condition) this.checkNodeForDangerousFunctions(node.condition); + + if (node.args) { + const args = Array.isArray(node.args) ? node.args : node.args?.value || []; + for (const arg of Array.isArray(args) ? args : [args]) { + this.checkNodeForDangerousFunctions(arg); + } + } + if (node.value && Array.isArray(node.value)) { + for (const val of node.value) { + this.checkNodeForDangerousFunctions(val); + } + } + + if (node.over?.as_window_specification?.window_specification) { + const winSpec = node.over.as_window_specification.window_specification; + if (winSpec.orderby) { + for (const item of winSpec.orderby) { + this.checkNodeForDangerousFunctions(item.expr); + } + } + if (winSpec.partitionby) { + for (const item of winSpec.partitionby) { + this.checkNodeForDangerousFunctions(item); + } + } + } + } + + private extractFunctionName(funcNode: any): string | null { + if (funcNode.name?.name?.[0]?.value) { + return funcNode.name.name[0].value.toLowerCase(); + } + if (typeof funcNode.name === 'string') { + return funcNode.name.toLowerCase(); + } + return null; + } + + private checkForXmlJsonRecursive(stmt: any): void { + if (!stmt) return; + + const forType = stmt.for?.type?.toLowerCase(); + if (forType?.includes('xml') || forType?.includes('json')) { + throw new BadRequestException('FOR XML/JSON not allowed'); + } + + if (stmt.columns) { + for (const col of stmt.columns) { + this.checkNodeForXmlJson(col.expr); + } + } + + if (stmt.from) { + for (const item of stmt.from) { + if (item.expr?.ast) { + this.checkForXmlJsonRecursive(item.expr.ast); + } + this.checkNodeForXmlJson(item.on); + } + } + + this.checkNodeForXmlJson(stmt.where); + this.checkNodeForXmlJson(stmt.having); + + if (stmt.orderby) { + for (const item of stmt.orderby) { + this.checkNodeForXmlJson(item.expr); + } + } + + if (stmt.groupby?.columns) { + for (const item of stmt.groupby.columns) { + this.checkNodeForXmlJson(item); + } + } + + if (stmt.with) { + for (const cte of stmt.with) { + if (cte.stmt?.ast) { + this.checkForXmlJsonRecursive(cte.stmt.ast); + } + } + } + } + + private checkNodeForXmlJson(node: any): void { + if (!node) return; + + if (node.ast) { + this.checkForXmlJsonRecursive(node.ast); + } + + if (node.left) this.checkNodeForXmlJson(node.left); + if (node.right) this.checkNodeForXmlJson(node.right); + if (node.expr) this.checkNodeForXmlJson(node.expr); + + if (node.result) this.checkNodeForXmlJson(node.result); + if (node.condition) this.checkNodeForXmlJson(node.condition); + + if (node.args) { + const args = Array.isArray(node.args) ? node.args : node.args?.value || []; + for (const arg of Array.isArray(args) ? args : [args]) { + this.checkNodeForXmlJson(arg); + } + } + if (node.value && Array.isArray(node.value)) { + for (const val of node.value) { + this.checkNodeForXmlJson(val); + } + } + + if (node.over?.as_window_specification?.window_specification) { + const winSpec = node.over.as_window_specification.window_specification; + if (winSpec.orderby) { + for (const item of winSpec.orderby) { + this.checkNodeForXmlJson(item.expr); + } + } + if (winSpec.partitionby) { + for (const item of winSpec.partitionby) { + this.checkNodeForXmlJson(item); + } + } + } + } + + private maskDebugBlockedColumns(data: Record[], tables: string[]): void { + if (!data?.length || !tables?.length) return; + + const blockedColumns = new Set(); + for (const table of tables) { + const tableCols = DebugBlockedCols[table.toLowerCase()]; + if (tableCols) { + for (const col of tableCols) { + blockedColumns.add(col.toLowerCase()); + } + } + } + + if (blockedColumns.size === 0) return; + + for (const entry of data) { + for (const key of Object.keys(entry)) { + if (this.shouldMaskDebugColumn(key, blockedColumns)) { + entry[key] = entry[key] == null ? '[RESTRICTED:NULL]' : '[RESTRICTED:SET]'; + } + } + } + } + + private shouldMaskDebugColumn(columnName: string, blockedColumns: Set): boolean { + const lower = columnName.toLowerCase(); + + for (const blocked of blockedColumns) { + if (lower === blocked || lower.endsWith('_' + blocked)) { + return true; + } + } + return false; + } + + private ensureResultLimit(sql: string): string { + const normalized = sql.trim().toLowerCase(); + + if (normalized.includes(' top ') || normalized.includes(' limit ')) { + return sql; + } + + const hasOrderBy = /order\s+by/i.test(normalized); + const orderByClause = hasOrderBy ? '' : ' ORDER BY (SELECT NULL)'; + + let trimmed = sql.trim(); + while (trimmed.endsWith(';')) trimmed = trimmed.slice(0, -1); + + return `${trimmed}${orderByClause} OFFSET 0 ROWS FETCH NEXT ${DebugMaxResults} ROWS ONLY`; + } } From b1aeece7f4c933a7da5f222e7c39b1e8ecfb1264 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 24 Jan 2026 07:32:03 +0100 Subject: [PATCH 14/22] Add DEBUG wallet and fix script API URLs (#95) --- README.md | 80 +++++++++++++++++++++++ migration/1769204578000-addDebugWallet.js | 16 +++++ scripts/db-debug.sh | 4 +- scripts/log-debug.sh | 4 +- 4 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 migration/1769204578000-addDebugWallet.js diff --git a/README.md b/README.md index 88fc4cec..08a388a5 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,83 @@ API for lightning.space custodial service Do not merge this state into PRD! + +## Debug Endpoint + +The API provides a debug endpoint for authorized users to execute read-only SQL queries and access Azure Application Insights logs. + +### Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/support/debug` | POST | Execute SQL SELECT queries | +| `/support/debug/logs` | POST | Query Azure Application Insights | + +### Authorization + +Access requires a wallet with the `DEBUG` role. The role hierarchy allows `ADMIN` users to also access debug endpoints. + +### Getting Access + +1. **Generate wallet credentials** from a mnemonic seed: + ```bash + node -e " + const { ethers } = require('ethers'); + const mnemonic = ''; + const wallet = ethers.Wallet.fromMnemonic(mnemonic); + const message = 'By_signing_this_message,_you_confirm_to_lightning.space_that_you_are_the_sole_owner_of_the_provided_Blockchain_address._Your_ID:_' + wallet.address; + console.log('Address:', wallet.address); + wallet.signMessage(message).then(sig => console.log('Signature:', sig)); + " + ``` + +2. **Register the wallet** on the target environment (dev/prd) via the normal registration flow. + +3. **Create a migration** to grant DEBUG role (replace `TIMESTAMP` with `date +%s000`): + ```javascript + // migration/TIMESTAMP-addDebugWallet.js + const { MigrationInterface, QueryRunner } = require("typeorm"); + + module.exports = class addDebugWalletTIMESTAMP { + name = 'addDebugWalletTIMESTAMP' + + async up(queryRunner) { + await queryRunner.query(`UPDATE wallet SET role = 'Debug', updated = GETDATE() WHERE address = 'WALLET_ADDRESS'`); + } + + async down(queryRunner) { + await queryRunner.query(`UPDATE wallet SET role = 'User', updated = GETDATE() WHERE address = 'WALLET_ADDRESS'`); + } + } + ``` + +4. **Configure environment** - create `.env` in the api root: + ``` + DEBUG_ADDRESS= + DEBUG_SIGNATURE= + DEBUG_API_URL=https://lightning.space/v1 # or https://dev.lightning.space/v1 + ``` + +### Usage + +**SQL Queries:** +```bash +./scripts/db-debug.sh "SELECT TOP 10 * FROM wallet" +./scripts/db-debug.sh "SELECT * FROM monitoring_balance" +``` + +**Log Queries:** +```bash +./scripts/log-debug.sh exceptions # Recent exceptions +./scripts/log-debug.sh failures # Failed requests +./scripts/log-debug.sh slow 2000 # Slow dependencies (>2000ms) +./scripts/log-debug.sh traces "error" # Search traces +./scripts/log-debug.sh operation # Traces by operation ID +``` + +### Security + +- Only `SELECT` queries allowed (no INSERT, UPDATE, DELETE) +- Sensitive columns are automatically masked (signatures, keys, secrets) +- System schemas blocked (sys, information_schema) +- All queries are logged for audit diff --git a/migration/1769204578000-addDebugWallet.js b/migration/1769204578000-addDebugWallet.js new file mode 100644 index 00000000..0ddce12d --- /dev/null +++ b/migration/1769204578000-addDebugWallet.js @@ -0,0 +1,16 @@ +const { MigrationInterface, QueryRunner } = require("typeorm"); + +module.exports = class addDebugWallet1769204578000 { + name = 'addDebugWallet1769204578000' + + async up(queryRunner) { + // Update existing wallet to DEBUG role + // Note: Wallet must already exist (created via normal registration flow) + await queryRunner.query(`UPDATE wallet SET role = 'Debug', updated = GETDATE() WHERE address = '0xfc2F5df4217f021C270bFD6b5C3bDB5064C97587'`); + } + + async down(queryRunner) { + // Revert to USER role + await queryRunner.query(`UPDATE wallet SET role = 'User', updated = GETDATE() WHERE address = '0xfc2F5df4217f021C270bFD6b5C3bDB5064C97587'`); + } +} diff --git a/scripts/db-debug.sh b/scripts/db-debug.sh index 895ef961..584e27ad 100755 --- a/scripts/db-debug.sh +++ b/scripts/db-debug.sh @@ -10,7 +10,7 @@ # Uses the central .env file. Required variables: # - DEBUG_ADDRESS: Wallet address with DEBUG role # - DEBUG_SIGNATURE: Signature from signing the LDS login message -# - DEBUG_API_URL (optional): API URL, defaults to https://api.lightning.space/v1 +# - DEBUG_API_URL (optional): API URL, defaults to https://lightning.space/v1 # # Requirements: # - curl @@ -62,7 +62,7 @@ if [ -z "$DEBUG_ADDRESS" ] || [ -z "$DEBUG_SIGNATURE" ]; then exit 1 fi -API_URL="${DEBUG_API_URL:-https://api.lightning.space/v1}" +API_URL="${DEBUG_API_URL:-https://lightning.space/v1}" # --- Authenticate --- echo "=== Authenticating to $API_URL ===" diff --git a/scripts/log-debug.sh b/scripts/log-debug.sh index a7485f43..6afb11a9 100755 --- a/scripts/log-debug.sh +++ b/scripts/log-debug.sh @@ -18,7 +18,7 @@ # Uses the central .env file. Required variables: # - DEBUG_ADDRESS: Wallet address with DEBUG role # - DEBUG_SIGNATURE: Signature from signing the LDS login message -# - DEBUG_API_URL (optional): API URL, defaults to https://api.lightning.space/v1 +# - DEBUG_API_URL (optional): API URL, defaults to https://lightning.space/v1 set -e @@ -43,7 +43,7 @@ if [ -z "$DEBUG_ADDRESS" ] || [ -z "$DEBUG_SIGNATURE" ]; then exit 1 fi -API_URL="${DEBUG_API_URL:-https://api.lightning.space/v1}" +API_URL="${DEBUG_API_URL:-https://lightning.space/v1}" # Parse arguments HOURS=1 From f08cdaa37050d2ebb274c35e36cdd542a4474f51 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 24 Jan 2026 09:20:26 +0100 Subject: [PATCH 15/22] Add cross-chain bridging documentation (#98) Document Layer0 and Lightning.space integration for JUSD peg arbitrage: - Layer0 bridge contracts (USDT, USDC, WBTC) between Ethereum and Citrea - Lightning.space atomic swap contracts and endpoints - StablecoinBridge architecture for USDT.e to JUSD conversion - Complete arbitrage loop explanation - All contract addresses and fee structures --- docs/CROSS_CHAIN_BRIDGING.md | 252 +++++++++++++++++++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 docs/CROSS_CHAIN_BRIDGING.md diff --git a/docs/CROSS_CHAIN_BRIDGING.md b/docs/CROSS_CHAIN_BRIDGING.md new file mode 100644 index 00000000..8ded2955 --- /dev/null +++ b/docs/CROSS_CHAIN_BRIDGING.md @@ -0,0 +1,252 @@ +# Cross-Chain Bridging & Arbitrage + +**How JUSD maintains its peg through cross-chain infrastructure connecting Ethereum, Citrea, and Lightning Network.** + +## Overview + +JuiceDollar (JUSD) on Citrea is connected to other networks through multiple bridging mechanisms. This enables: + +1. **Price stability** through arbitrage opportunities +2. **Liquidity access** to major stablecoin markets +3. **Fast settlements** via Lightning Network + +The combination of Layer0 cross-chain messaging and Lightning.space atomic swaps creates a complete arbitrage loop that helps maintain the JUSD peg. + +## Bridge Architecture + +``` + ETHEREUM CITREA +┌──────────────────┐ ┌───────────────────────┐ +│ │ │ │ +│ USDT │───Layer0────▶│ USDT.e │ +│ 0xdAC17F958... │ │ 0x9f3096Bac... │ +│ │ │ │ │ +└────────▲─────────┘ │ ▼ │ + │ │ StablecoinBridge │ + │ │ │ │ + │ │ ▼ │ + │ Lightning.space │ JUSD │ + └─────────────────────────│ │ + (Atomic Swap) └───────────────────────┘ +``` + +## Layer0 (LayerZero) Bridges + +LayerZero provides omnichain messaging to bridge assets between Ethereum and Citrea. + +### Ethereum → Citrea Bridges + +| Asset | Ethereum Contract | Citrea Contract | Type | +|-------|-------------------|-----------------|------| +| **USDT** | [`0x6925ccD29e3993c82a574CED4372d8737C6dbba6`](https://etherscan.io/address/0x6925ccD29e3993c82a574CED4372d8737C6dbba6) | [`0x9f3096Bac87e7F03DC09b0B416eB0DF837304dc4`](https://explorer.mainnet.citrea.xyz/address/0x9f3096Bac87e7F03DC09b0B416eB0DF837304dc4) | SourceOFTAdapter → USDT.e | +| **USDC** | [`0xdaa289CC487Cf95Ba99Db62f791c7E2d2a4b868E`](https://etherscan.io/address/0xdaa289CC487Cf95Ba99Db62f791c7E2d2a4b868E) | [`0xE045e6c36cF77FAA2CfB54466D71A3aEF7bBE839`](https://explorer.mainnet.citrea.xyz/address/0xE045e6c36cF77FAA2CfB54466D71A3aEF7bBE839) | SourceOFTAdapter → USDC.e | +| **WBTC** | [`0x2c01390E10e44C968B73A7BcFF7E4b4F50ba76Ed`](https://etherscan.io/address/0x2c01390E10e44C968B73A7BcFF7E4b4F50ba76Ed) | [`0xDF240DC08B0FdaD1d93b74d5048871232f6BEA3d`](https://explorer.mainnet.citrea.xyz/address/0xDF240DC08B0FdaD1d93b74d5048871232f6BEA3d) | WBTCOFTAdapter → WBTC.e | + +### Bridge Contracts on Citrea + +| Contract | Address | Purpose | +|----------|---------|---------| +| USDC.e Token | [`0xE045e6c36cF77FAA2CfB54466D71A3aEF7bBE839`](https://explorer.mainnet.citrea.xyz/address/0xE045e6c36cF77FAA2CfB54466D71A3aEF7bBE839) | Bridged USDC from Ethereum | +| USDC.e Bridge | [`0x41710804caB0974638E1504DB723D7bddec22e30`](https://explorer.mainnet.citrea.xyz/address/0x41710804caB0974638E1504DB723D7bddec22e30) | DestinationOUSDC | +| USDT.e Token | [`0x9f3096Bac87e7F03DC09b0B416eB0DF837304dc4`](https://explorer.mainnet.citrea.xyz/address/0x9f3096Bac87e7F03DC09b0B416eB0DF837304dc4) | Bridged USDT from Ethereum | +| USDT.e Bridge | [`0xF8b5983BFa11dc763184c96065D508AE1502C030`](https://explorer.mainnet.citrea.xyz/address/0xF8b5983BFa11dc763184c96065D508AE1502C030) | DestinationOUSDT | +| WBTC.e + Bridge | [`0xDF240DC08B0FdaD1d93b74d5048871232f6BEA3d`](https://explorer.mainnet.citrea.xyz/address/0xDF240DC08B0FdaD1d93b74d5048871232f6BEA3d) | WBTCOFT (combined) | + +### Source Token Verification + +All bridges use the **official Ethereum token contracts**: + +| Token | Ethereum Address | Decimals | +|-------|------------------|----------| +| USDT (Tether) | [`0xdAC17F958D2ee523a2206206994597C13D831ec7`](https://etherscan.io/address/0xdAC17F958D2ee523a2206206994597C13D831ec7) | 6 | +| USDC (Circle) | [`0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48`](https://etherscan.io/address/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48) | 6 | +| WBTC | [`0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599`](https://etherscan.io/address/0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599) | 8 | + +## Lightning.space Swaps + +Lightning.space provides atomic swaps between Ethereum stablecoins and JUSD on Citrea via the Lightning Network. + +### Swap Pairs + +| From | To | Direction | Min | Max | +|------|-----|-----------|-----|-----| +| ETH USDT | Citrea JUSD | Chain Swap | 1 USDT | 10,000 USDT | +| Citrea JUSD | ETH USDT | Reverse Swap | 1 USDT | 10,000 USDT | +| Polygon USDT | Citrea JUSD | Chain Swap | 1 USDT | 10,000 USDT | +| Citrea JUSD | Polygon USDT | Reverse Swap | 1 USDT | 10,000 USDT | + +### Contract Addresses + +#### Ethereum Mainnet + +| Contract | Address | +|----------|---------| +| EtherSwap | [`0x9ADfB0F1B783486289Fc23f3A3Ad2927cebb17e4`](https://etherscan.io/address/0x9ADfB0F1B783486289Fc23f3A3Ad2927cebb17e4) | +| ERC20Swap | [`0x2E21F58Da58c391F110467c7484EdfA849C1CB9B`](https://etherscan.io/address/0x2E21F58Da58c391F110467c7484EdfA849C1CB9B) | +| USDT Token | [`0xdAC17F958D2ee523a2206206994597C13D831ec7`](https://etherscan.io/address/0xdAC17F958D2ee523a2206206994597C13D831ec7) | + +#### Polygon Mainnet + +| Contract | Address | +|----------|---------| +| EtherSwap | [`0x9ADfB0F1B783486289Fc23f3A3Ad2927cebb17e4`](https://polygonscan.com/address/0x9ADfB0F1B783486289Fc23f3A3Ad2927cebb17e4) | +| ERC20Swap | [`0x2E21F58Da58c391F110467c7484EdfA849C1CB9B`](https://polygonscan.com/address/0x2E21F58Da58c391F110467c7484EdfA849C1CB9B) | +| USDT Token | [`0xc2132D05D31c914a87C6611C10748AEb04B58e8F`](https://polygonscan.com/address/0xc2132D05D31c914a87C6611C10748AEb04B58e8F) | + +#### Citrea Mainnet + +| Contract | Address | +|----------|---------| +| EtherSwap | [`0xd02731fD8c5FDD53B613A699234FAd5EE8851B65`](https://explorer.mainnet.citrea.xyz/address/0xd02731fD8c5FDD53B613A699234FAd5EE8851B65) | +| ERC20Swap | [`0xf2e019a371e5Fd32dB2fC564Ad9eAE9E433133cc`](https://explorer.mainnet.citrea.xyz/address/0xf2e019a371e5Fd32dB2fC564Ad9eAE9E433133cc) | +| USDT_CITREA | [`0x1Dd3057888944ff1f914626aB4BD47Dc8b6285Fe`](https://explorer.mainnet.citrea.xyz/address/0x1Dd3057888944ff1f914626aB4BD47Dc8b6285Fe) | + +### Token Compatibility + +Lightning.space uses the **same Ethereum USDT contract** as Layer0: + +``` +Ethereum USDT: 0xdAC17F958D2ee523a2206206994597C13D831ec7 +``` + +This ensures that tokens bridged via Layer0 can be used in Lightning.space swaps and vice versa. + +## StablecoinBridge: USDT.e → JUSD + +A StablecoinBridge contract can connect Layer0's USDT.e to JUSD on Citrea. + +### How It Works + +```solidity +// Deposit USDT.e, receive JUSD (1:1) +function mint(uint256 amount) external + +// Burn JUSD, receive USDT.e (1:1) +function burn(uint256 amount) external +``` + +The bridge automatically handles decimal conversion: +- USDT.e: 6 decimals +- JUSD: 18 decimals + +### Planned Deployment + +| Parameter | Value | +|-----------|-------| +| Source Token | USDT.e (`0x9f3096Bac87e7F03DC09b0B416eB0DF837304dc4`) | +| Target Token | JUSD | +| Exchange Rate | 1:1 | +| Network | Citrea Mainnet | + +## The Arbitrage Loop + +With all components in place, arbitrageurs can close the loop: + +### Loop 1: JUSD → ETH USDT → USDT.e → JUSD + +``` +Step 1: Burn JUSD via StablecoinBridge + → Receive USDT.e on Citrea + +Step 2: Bridge USDT.e to Ethereum via Layer0 + → Receive USDT on Ethereum + +Step 3: Swap USDT to JUSD via Lightning.space + → Receive JUSD on Citrea +``` + +### Loop 2: JUSD → ETH USDT via Lightning → USDT.e → JUSD + +``` +Step 1: Swap JUSD to USDT via Lightning.space + → Receive USDT on Ethereum + +Step 2: Bridge USDT to Citrea via Layer0 + → Receive USDT.e on Citrea + +Step 3: Mint JUSD via StablecoinBridge + → Receive JUSD on Citrea +``` + +### Economic Implications + +| Scenario | Arbitrage Action | Effect on JUSD | +|----------|------------------|----------------| +| JUSD > $1 | Mint JUSD from USDT.e, sell for profit | Increases supply, price decreases | +| JUSD < $1 | Buy cheap JUSD, burn for USDT.e | Decreases supply, price increases | + +This creates a self-correcting mechanism that maintains the JUSD peg. + +## Fee Structure + +### Layer0 Bridge Fees + +- Gas fees on source chain (Ethereum) +- LayerZero messaging fees +- Gas fees on destination chain (Citrea) + +### Lightning.space Swap Fees + +| Swap Type | Fee | +|-----------|-----| +| Chain Swap (USDT → JUSD) | 0.25% | +| Reverse Swap (JUSD → USDT) | 0.5% | + +### StablecoinBridge Fees + +- **No protocol fees** for mint/burn +- Only gas costs on Citrea (paid in cBTC) + +## Security Considerations + +### Bridge Risks + +| Risk | Mitigation | +|------|------------| +| Layer0 bridge exploit | Multiple security audits, decentralized validation | +| Lightning.space failure | Non-custodial atomic swaps, timeout refunds | +| StablecoinBridge exploit | Volume limits, time-based expiration, emergency stop | +| USDT depeg | Volume limits, governance veto power | + +### Best Practices + +1. **Verify contract addresses** before large transactions +2. **Check bridge liquidity** on both sides +3. **Monitor gas costs** - arbitrage must exceed fees +4. **Use official frontends** to avoid phishing + +## API Endpoints + +### Lightning.space + +| Environment | Base URL | +|-------------|----------| +| Production | `https://lightning.space/v1/swap/` | +| Development | `https://dev.lightning.space/v1/swap/` | + +### Citrea RPC + +| Environment | RPC URL | +|-------------|---------| +| Mainnet | `https://rpc.citrea.xyz` | +| Testnet | `https://rpc.testnet.citrea.xyz` | + +## Summary + +The cross-chain infrastructure enables: + +1. **Ethereum ↔ Citrea** via Layer0 (USDT, USDC, WBTC) +2. **Ethereum ↔ Citrea JUSD** via Lightning.space (atomic swaps) +3. **USDT.e ↔ JUSD** via StablecoinBridge (on-chain, 1:1) + +Together, these create a robust arbitrage mechanism that keeps JUSD pegged to $1. + +### Component Status + +| Component | Status | Contract Verified | +|-----------|--------|-------------------| +| Layer0 USDT Bridge | Live | Yes | +| Layer0 USDC Bridge | Live | Yes | +| Layer0 WBTC Bridge | Live | Yes | +| Lightning.space Swaps | Live | Yes | +| StablecoinBridge (USDT.e) | Planned | - | From 86b3b79f95f6423c994e723ce1426339188203b2 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 24 Jan 2026 09:23:09 +0100 Subject: [PATCH 16/22] Increase stablecoin chain swap max to 10,000 USDT (#97) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Increase stablecoin chain swap max from 1000 to 10000 USDT Affects dev.lightning.space pairs: - USDT_ETH ↔ JUSD_CITREA - USDT_POLYGON ↔ JUSD_CITREA * Also increase prd stablecoin swap limit to 10,000 USDT Sync stablecoin limits between dev and prd configs: - USDT_ETH ↔ USDT_CITREA: 1,000 → 10,000 USDT - USDT_POLYGON ↔ USDT_CITREA: 1,000 → 10,000 USDT --- infrastructure/config/boltz/backend/dev-boltz.conf | 8 ++++---- infrastructure/config/boltz/backend/prd-boltz.conf | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/infrastructure/config/boltz/backend/dev-boltz.conf b/infrastructure/config/boltz/backend/dev-boltz.conf index 246b0c80..9c45d6e0 100644 --- a/infrastructure/config/boltz/backend/dev-boltz.conf +++ b/infrastructure/config/boltz/backend/dev-boltz.conf @@ -136,8 +136,8 @@ rate = 1 fee = 0.25 swapInFee = 0.1 -maxSwapAmount = 1_000_000_000 # 1000 USDT_ETH/JUSD_CITREA -minSwapAmount = 1_000_000 # 1 USDT_ETH/JUSD_CITREA +maxSwapAmount = 10_000_000_000 # 10,000 USDT_ETH/JUSD_CITREA +minSwapAmount = 1_000_000 # 1 USDT_ETH/JUSD_CITREA [pairs.timeoutDelta] chain = 1440 # Chain swap timeout (~24 hours = 144 blocks) @@ -153,8 +153,8 @@ rate = 1 fee = 0.25 swapInFee = 0.1 -maxSwapAmount = 1_000_000_000 # 1000 USDT_POLYGON/JUSD_CITREA -minSwapAmount = 1_000_000 # 1 USDT_POLYGON/JUSD_CITREA +maxSwapAmount = 10_000_000_000 # 10,000 USDT_POLYGON/JUSD_CITREA +minSwapAmount = 1_000_000 # 1 USDT_POLYGON/JUSD_CITREA [pairs.timeoutDelta] chain = 1440 # Chain swap timeout (~24 hours = 144 blocks) diff --git a/infrastructure/config/boltz/backend/prd-boltz.conf b/infrastructure/config/boltz/backend/prd-boltz.conf index 9d45819c..3df690d9 100644 --- a/infrastructure/config/boltz/backend/prd-boltz.conf +++ b/infrastructure/config/boltz/backend/prd-boltz.conf @@ -155,8 +155,8 @@ rate = 1 fee = 0.25 swapInFee = 0.1 -maxSwapAmount = 1_000_000_000 # 1000 USDT_ETH/USDT_CITREA -minSwapAmount = 1_000_000 # 1 USDT_ETH/USDT_CITREA +maxSwapAmount = 10_000_000_000 # 10,000 USDT_ETH/USDT_CITREA +minSwapAmount = 1_000_000 # 1 USDT_ETH/USDT_CITREA [pairs.timeoutDelta] chain = 1440 # Chain swap timeout (~24 hours = 144 blocks) @@ -172,8 +172,8 @@ rate = 1 fee = 0.25 swapInFee = 0.1 -maxSwapAmount = 1_000_000_000 # 1000 USDT_POLYGON/USDT_CITREA -minSwapAmount = 1_000_000 # 1 USDT_POLYGON/USDT_CITREA +maxSwapAmount = 10_000_000_000 # 10,000 USDT_POLYGON/USDT_CITREA +minSwapAmount = 1_000_000 # 1 USDT_POLYGON/USDT_CITREA [pairs.timeoutDelta] chain = 1440 # Chain swap timeout (~24 hours = 144 blocks) From ed7af225d997704cd389c2305e172f864bce9eef Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 24 Jan 2026 11:19:13 +0100 Subject: [PATCH 17/22] Add USDC swaps, standardize stablecoin pairs to JUSD, prepare Mainnet config (#99) * Add USDC swap pairs for Ethereum only Add USDC support for Ethereum <-> Citrea swaps: Tokens added: - USDC_ETH (0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48) - USDC_CITREA (0xE045e6c36cF77FAA2CfB54466D71A3aEF7bBE839) Swap pairs added: - USDC_ETH <-> USDC_CITREA (prd) - USDC_ETH <-> JUSD_CITREA (dev) Limits: 1-10,000 USDC (same as USDT after PR #97) Polygon USDC NOT supported due to bridge incompatibility: - Polygon Bridge creates USDC.e (bridged) - Native USDC uses different contract address * Fix USDT swap limits in documentation (1,000 -> 10,000) * Set all swap fees to 0% * Remove RBTC/RSK (Rootstock) swap configuration * Change all stablecoin swaps to exclusively use JUSD * Prepare PRD config for Citrea Mainnet (placeholders for missing addresses) * Fix token decimals: JUSD=18, cBTC=18 (EVM native tokens) * Revert cBTC decimals change - cBTC uses satoshi units like BTC --- docs/CROSS_CHAIN_BRIDGING.md | 30 ++++-- .../config/boltz/backend/dev-boltz.conf | 47 ++++++-- .../config/boltz/backend/prd-boltz.conf | 100 ++++++++---------- 3 files changed, 104 insertions(+), 73 deletions(-) diff --git a/docs/CROSS_CHAIN_BRIDGING.md b/docs/CROSS_CHAIN_BRIDGING.md index 8ded2955..5d9d1068 100644 --- a/docs/CROSS_CHAIN_BRIDGING.md +++ b/docs/CROSS_CHAIN_BRIDGING.md @@ -68,12 +68,25 @@ Lightning.space provides atomic swaps between Ethereum stablecoins and JUSD on C ### Swap Pairs +All stablecoin swaps are exclusively against **JUSD** (JuiceDollar) on Citrea. + +#### USDT Pairs + | From | To | Direction | Min | Max | |------|-----|-----------|-----|-----| -| ETH USDT | Citrea JUSD | Chain Swap | 1 USDT | 10,000 USDT | -| Citrea JUSD | ETH USDT | Reverse Swap | 1 USDT | 10,000 USDT | -| Polygon USDT | Citrea JUSD | Chain Swap | 1 USDT | 10,000 USDT | -| Citrea JUSD | Polygon USDT | Reverse Swap | 1 USDT | 10,000 USDT | +| ETH USDT | JUSD | Chain Swap | 1 USDT | 10,000 USDT | +| JUSD | ETH USDT | Reverse Swap | 1 USDT | 10,000 USDT | +| Polygon USDT | JUSD | Chain Swap | 1 USDT | 10,000 USDT | +| JUSD | Polygon USDT | Reverse Swap | 1 USDT | 10,000 USDT | + +#### USDC Pairs (Ethereum only) + +| From | To | Direction | Min | Max | +|------|-----|-----------|-----|-----| +| ETH USDC | JUSD | Chain Swap | 1 USDC | 10,000 USDC | +| JUSD | ETH USDC | Reverse Swap | 1 USDC | 10,000 USDC | + +> **Note:** Polygon USDC is not supported due to bridge incompatibility (native USDC vs bridged USDC.e). ### Contract Addresses @@ -84,6 +97,7 @@ Lightning.space provides atomic swaps between Ethereum stablecoins and JUSD on C | EtherSwap | [`0x9ADfB0F1B783486289Fc23f3A3Ad2927cebb17e4`](https://etherscan.io/address/0x9ADfB0F1B783486289Fc23f3A3Ad2927cebb17e4) | | ERC20Swap | [`0x2E21F58Da58c391F110467c7484EdfA849C1CB9B`](https://etherscan.io/address/0x2E21F58Da58c391F110467c7484EdfA849C1CB9B) | | USDT Token | [`0xdAC17F958D2ee523a2206206994597C13D831ec7`](https://etherscan.io/address/0xdAC17F958D2ee523a2206206994597C13D831ec7) | +| USDC Token | [`0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48`](https://etherscan.io/address/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48) | #### Polygon Mainnet @@ -99,14 +113,15 @@ Lightning.space provides atomic swaps between Ethereum stablecoins and JUSD on C |----------|---------| | EtherSwap | [`0xd02731fD8c5FDD53B613A699234FAd5EE8851B65`](https://explorer.mainnet.citrea.xyz/address/0xd02731fD8c5FDD53B613A699234FAd5EE8851B65) | | ERC20Swap | [`0xf2e019a371e5Fd32dB2fC564Ad9eAE9E433133cc`](https://explorer.mainnet.citrea.xyz/address/0xf2e019a371e5Fd32dB2fC564Ad9eAE9E433133cc) | -| USDT_CITREA | [`0x1Dd3057888944ff1f914626aB4BD47Dc8b6285Fe`](https://explorer.mainnet.citrea.xyz/address/0x1Dd3057888944ff1f914626aB4BD47Dc8b6285Fe) | +| JUSD | [`0xFdB0a83d94CD65151148a131167Eb499Cb85d015`](https://explorer.mainnet.citrea.xyz/address/0xFdB0a83d94CD65151148a131167Eb499Cb85d015) | ### Token Compatibility -Lightning.space uses the **same Ethereum USDT contract** as Layer0: +Lightning.space uses the **same Ethereum token contracts** as Layer0: ``` Ethereum USDT: 0xdAC17F958D2ee523a2206206994597C13D831ec7 +Ethereum USDC: 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 ``` This ensures that tokens bridged via Layer0 can be used in Lightning.space swaps and vice versa. @@ -189,8 +204,7 @@ This creates a self-correcting mechanism that maintains the JUSD peg. | Swap Type | Fee | |-----------|-----| -| Chain Swap (USDT → JUSD) | 0.25% | -| Reverse Swap (JUSD → USDT) | 0.5% | +| All Swaps | 0% | ### StablecoinBridge Fees diff --git a/infrastructure/config/boltz/backend/dev-boltz.conf b/infrastructure/config/boltz/backend/dev-boltz.conf index 9c45d6e0..4c3d8d6b 100644 --- a/infrastructure/config/boltz/backend/dev-boltz.conf +++ b/infrastructure/config/boltz/backend/dev-boltz.conf @@ -89,8 +89,8 @@ maxZeroConfAmount = 0 # Disable 0-conf for security base = "BTC" quote = "BTC" rate = 1 -fee = 0.5 # 0.5% service fee -swapInFee = 0.25 # 0.25% for submarine swaps (chain -> lightning) +fee = 0 +swapInFee = 0 # Swap amount limits (in satoshis) maxSwapAmount = 10_000_000 # 0.1 BTC @@ -116,8 +116,8 @@ minSwapAmount = 2_500 # 2,500 sats (pair level) base = "BTC" quote = "cBTC" rate = 1 -fee = 0.25 -swapInFee = 0.1 +fee = 0 +swapInFee = 0 maxSwapAmount = 10_000_000 # 0.1 BTC/cBTC minSwapAmount = 2_500 # 2,500 sats minimum (Citrea Testnet has low fees) @@ -133,8 +133,8 @@ minSwapAmount = 2_500 # 2,500 sats minimum (Citrea Testnet has low fees) base = "USDT_ETH" quote = "JUSD_CITREA" rate = 1 -fee = 0.25 -swapInFee = 0.1 +fee = 0 +swapInFee = 0 maxSwapAmount = 10_000_000_000 # 10,000 USDT_ETH/JUSD_CITREA minSwapAmount = 1_000_000 # 1 USDT_ETH/JUSD_CITREA @@ -150,8 +150,8 @@ minSwapAmount = 1_000_000 # 1 USDT_ETH/JUSD_CITREA base = "USDT_POLYGON" quote = "JUSD_CITREA" rate = 1 -fee = 0.25 -swapInFee = 0.1 +fee = 0 +swapInFee = 0 maxSwapAmount = 10_000_000_000 # 10,000 USDT_POLYGON/JUSD_CITREA minSwapAmount = 1_000_000 # 1 USDT_POLYGON/JUSD_CITREA @@ -163,6 +163,24 @@ minSwapAmount = 1_000_000 # 1 USDT_POLYGON/JUSD_CITREA swapMaximal = 2880 # Maximum timeout (~48 hours = 288 blocks) swapTaproot = 10080 # 1 week for taproot swaps (10080 blocks) +# Swap Pair Configuration: USDC_ETH/JUSD_CITREA (Ethereum USDC <-> Citrea JUSD) +[[pairs]] +base = "USDC_ETH" +quote = "JUSD_CITREA" +rate = 1 +fee = 0 +swapInFee = 0 + +maxSwapAmount = 10_000_000_000 # 10,000 USDC_ETH/JUSD_CITREA +minSwapAmount = 1_000_000 # 1 USDC_ETH/JUSD_CITREA + + [pairs.timeoutDelta] + chain = 1440 # Chain swap timeout (~24 hours = 144 blocks) + reverse = 1440 # ~24 hours for reverse swaps (lightning -> chain) + swapMinimal = 1440 # Minimum timeout for submarine swaps (~24 hours = 144 blocks) + swapMaximal = 2880 # Maximum timeout (~48 hours = 288 blocks) + swapTaproot = 10080 # 1 week for taproot swaps (10080 blocks) + # ETH (Ethereum) Configuration [ethereum] networkName = "Ethereum Mainnet" @@ -179,6 +197,13 @@ providerEndpoint = "[PROVIDER_ENDPOINT]" minWalletBalance = 1_000_000 # 1 USDT_ETH + [[ethereum.tokens]] + symbol = "USDC_ETH" + decimals = 6 + contractAddress = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + + minWalletBalance = 1_000_000 # 1 USDC_ETH + # POL (Polygon) Configuration [polygon] networkName = "Polygon Mainnet" @@ -207,11 +232,11 @@ providerEndpoint = "https://dev.rpc.testnet.juiceswap.com" [[citrea.tokens]] symbol = "cBTC" - minWalletBalance = 100_000 + minWalletBalance = 100_000 # 0.001 cBTC (in satoshis) [[citrea.tokens]] symbol = "JUSD_CITREA" - decimals = 6 + decimals = 18 contractAddress = "0xFdB0a83d94CD65151148a131167Eb499Cb85d015" - minWalletBalance = 1_000_000 # 1 JUSD_CITREA + minWalletBalance = 1_000_000_000_000_000_000 # 1 JUSD_CITREA diff --git a/infrastructure/config/boltz/backend/prd-boltz.conf b/infrastructure/config/boltz/backend/prd-boltz.conf index 3df690d9..f087cbed 100644 --- a/infrastructure/config/boltz/backend/prd-boltz.conf +++ b/infrastructure/config/boltz/backend/prd-boltz.conf @@ -89,8 +89,8 @@ maxZeroConfAmount = 0 # Disable 0-conf for security base = "BTC" quote = "BTC" rate = 1 -fee = 0.5 # 0.5% service fee -swapInFee = 0.25 # 0.25% for submarine swaps (chain -> lightning) +fee = 0 +swapInFee = 0 # Swap amount limits (in satoshis) maxSwapAmount = 10_000_000 # 0.1 BTC @@ -112,31 +112,12 @@ minSwapAmount = 2_500 # 2,500 sats (pair level) swapMaximal = 2880 # Maximum timeout (~48 hours = 288 blocks) swapTaproot = 10080 # 1 week for taproot swaps (10080 blocks) -# Swap Pair Configuration: BTC/RBTC (Lightning BTC <-> RSK RBTC) -[[pairs]] -base = "BTC" -quote = "RBTC" -rate = 1 -fee = 0.25 -swapInFee = 0.1 - -# Swap amount limits (in satoshis) -maxSwapAmount = 10_000_000 # 0.1 BTC/RBTC -minSwapAmount = 2_500 # 2,500 sats minimum (Rootstock has lower fees) - - [pairs.timeoutDelta] - chain = 1440 # Chain swap timeout (~24 hours = 144 blocks) - reverse = 1440 # ~24 hours for reverse swaps (lightning -> chain) - swapMinimal = 1440 # Minimum timeout for submarine swaps (~24 hours = 144 blocks) - swapMaximal = 2880 # Maximum timeout (~48 hours = 288 blocks) - swapTaproot = 10080 # 1 week for taproot swaps (10080 blocks) - [[pairs]] base = "BTC" quote = "cBTC" rate = 1 -fee = 0.25 -swapInFee = 0.1 +fee = 0 +swapInFee = 0 maxSwapAmount = 10_000_000 # 0.1 BTC/cBTC minSwapAmount = 2_500 # 2,500 sats minimum (Citrea Testnet has low fees) @@ -150,13 +131,13 @@ minSwapAmount = 2_500 # 2,500 sats minimum (Citrea Testnet has low fees) [[pairs]] base = "USDT_ETH" -quote = "USDT_CITREA" +quote = "JUSD_CITREA" rate = 1 -fee = 0.25 -swapInFee = 0.1 +fee = 0 +swapInFee = 0 -maxSwapAmount = 10_000_000_000 # 10,000 USDT_ETH/USDT_CITREA -minSwapAmount = 1_000_000 # 1 USDT_ETH/USDT_CITREA +maxSwapAmount = 10_000_000_000 # 10,000 USDT_ETH/JUSD_CITREA +minSwapAmount = 1_000_000 # 1 USDT_ETH/JUSD_CITREA [pairs.timeoutDelta] chain = 1440 # Chain swap timeout (~24 hours = 144 blocks) @@ -167,13 +148,13 @@ minSwapAmount = 1_000_000 # 1 USDT_ETH/USDT_CITREA [[pairs]] base = "USDT_POLYGON" -quote = "USDT_CITREA" +quote = "JUSD_CITREA" rate = 1 -fee = 0.25 -swapInFee = 0.1 +fee = 0 +swapInFee = 0 -maxSwapAmount = 10_000_000_000 # 10,000 USDT_POLYGON/USDT_CITREA -minSwapAmount = 1_000_000 # 1 USDT_POLYGON/USDT_CITREA +maxSwapAmount = 10_000_000_000 # 10,000 USDT_POLYGON/JUSD_CITREA +minSwapAmount = 1_000_000 # 1 USDT_POLYGON/JUSD_CITREA [pairs.timeoutDelta] chain = 1440 # Chain swap timeout (~24 hours = 144 blocks) @@ -182,19 +163,23 @@ minSwapAmount = 1_000_000 # 1 USDT_POLYGON/USDT_CITREA swapMaximal = 2880 # Maximum timeout (~48 hours = 288 blocks) swapTaproot = 10080 # 1 week for taproot swaps (10080 blocks) -# RSK (Rootstock) Configuration -[rsk] -networkName = "RSK Mainnet" -providerEndpoint = "[PROVIDER_ENDPOINT]" - - [[rsk.contracts]] - etherSwap = "0x3d9cc5780CA1db78760ad3D35458509178A85A4A" - erc20Swap = "0x7d5a2187CC8EF75f8822daB0E8C9a2DB147BA045" +# Swap Pair Configuration: USDC_ETH/JUSD_CITREA (Ethereum USDC <-> Citrea JUSD) +[[pairs]] +base = "USDC_ETH" +quote = "JUSD_CITREA" +rate = 1 +fee = 0 +swapInFee = 0 - [[rsk.tokens]] - symbol = "RBTC" +maxSwapAmount = 10_000_000_000 # 10,000 USDC_ETH/JUSD_CITREA +minSwapAmount = 1_000_000 # 1 USDC_ETH/JUSD_CITREA - minWalletBalance = 10_000 + [pairs.timeoutDelta] + chain = 1440 # Chain swap timeout (~24 hours = 144 blocks) + reverse = 1440 # ~24 hours for reverse swaps (lightning -> chain) + swapMinimal = 1440 # Minimum timeout for submarine swaps (~24 hours = 144 blocks) + swapMaximal = 2880 # Maximum timeout (~48 hours = 288 blocks) + swapTaproot = 10080 # 1 week for taproot swaps (10080 blocks) # ETH (Ethereum) Configuration [ethereum] @@ -212,6 +197,13 @@ providerEndpoint = "[PROVIDER_ENDPOINT]" minWalletBalance = 1_000_000 # 1 USDT_ETH + [[ethereum.tokens]] + symbol = "USDC_ETH" + decimals = 6 + contractAddress = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + + minWalletBalance = 1_000_000 # 1 USDC_ETH + # POL (Polygon) Configuration [polygon] networkName = "Polygon Mainnet" @@ -228,23 +220,23 @@ providerEndpoint = "[PROVIDER_ENDPOINT]" minWalletBalance = 1_000_000 # 1 USDT_POLYGON -# Citrea Testnet Configuration +# Citrea Mainnet Configuration [citrea] -networkName = "Citrea Testnet" -providerEndpoint = "https://dev.rpc.testnet.juiceswap.com" +networkName = "Citrea Mainnet" +providerEndpoint = "[CITREA_MAINNET_RPC]" [[citrea.contracts]] - etherSwap = "0xd02731fD8c5FDD53B613A699234FAd5EE8851B65" - erc20Swap = "0xf2e019a371e5Fd32dB2fC564Ad9eAE9E433133cc" + etherSwap = "[CITREA_MAINNET_ETHER_SWAP]" + erc20Swap = "[CITREA_MAINNET_ERC20_SWAP]" [[citrea.tokens]] symbol = "cBTC" - minWalletBalance = 100_000 + minWalletBalance = 100_000 # 0.001 cBTC (in satoshis) [[citrea.tokens]] - symbol = "USDT_CITREA" - decimals = 6 - contractAddress = "0x1Dd3057888944ff1f914626aB4BD47Dc8b6285Fe" + symbol = "JUSD_CITREA" + decimals = 18 + contractAddress = "[CITREA_MAINNET_JUSD]" - minWalletBalance = 1_000_000 # 1 USDT_CITREA + minWalletBalance = 1_000_000_000_000_000_000 # 1 JUSD_CITREA From 1788f53024051ed695e0e64dbd3b87f46f9bd42e Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 24 Jan 2026 11:20:33 +0100 Subject: [PATCH 18/22] Add DEBUG env variables to .env.example (#94) --- .env.example | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 9c7fbaed..db9f2dbe 100644 --- a/.env.example +++ b/.env.example @@ -8,4 +8,9 @@ SQL_DB= SQL_SYNCHRONIZE= SQL_MIGRATE= JWT_SECRET= -DISABLED_PROCESSES= \ No newline at end of file +DISABLED_PROCESSES= + +# Debug endpoint (scripts/db-debug.sh, scripts/log-debug.sh) +DEBUG_ADDRESS= +DEBUG_SIGNATURE= +DEBUG_API_URL= \ No newline at end of file From aff45b650e9f227af4c96919988bac9d693655d3 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 24 Jan 2026 11:46:11 +0100 Subject: [PATCH 19/22] Add Boltz PostgreSQL debug endpoint (#96) * Add Boltz PostgreSQL debug endpoint - Add new endpoint POST /support/debug/boltz for querying Boltz PostgreSQL - Same security as MSSQL debug endpoint (SQL AST parsing, column blocking) - Blocked columns: referrals.apiKey/apiSecret, keyproviders.privateKey, *.preimage - Blocked schemas: pg_catalog, information_schema, pg_toast - Add boltz-debug.sh shell script for CLI access - Add pg dependency for PostgreSQL connection - Requires BOLTZ_PG_* environment variables for configuration * Fix Boltz blocked columns config - Remove non-existent keyproviders table (actual table is 'keys' with no sensitive data) - Add dblink functions to blocked list (external DB connections) * Fix PostgreSQL case-sensitivity in script examples PostgreSQL tables reverseSwaps and chainSwaps require double quotes due to camelCase naming. Updated script help text with correct quoting examples and clarified config comment. * Add missing blocked column and dangerous function - Block minerFeeInvoicePreimage in reverseSwaps table - Block pg_sleep function to prevent DoS attacks --- package-lock.json | 151 ++++++++ package.json | 4 +- scripts/boltz-debug.sh | 117 ++++++ src/config/config.ts | 8 + .../support/controllers/support.controller.ts | 12 + .../support/dto/boltz-debug.config.ts | 29 ++ src/subdomains/support/dto/boltz-query.dto.ts | 8 + .../support/services/support.service.ts | 354 +++++++++++++++++- 8 files changed, 680 insertions(+), 3 deletions(-) create mode 100755 scripts/boltz-debug.sh create mode 100644 src/subdomains/support/dto/boltz-debug.config.ts create mode 100644 src/subdomains/support/dto/boltz-query.dto.ts diff --git a/package-lock.json b/package-lock.json index 7ecae815..29130125 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "nestjs-real-ip": "^2.2.0", "node-sql-parser": "^5.3.6", "passport-jwt": "^4.0.1", + "pg": "^8.17.2", "reflect-metadata": "^0.1.14", "rimraf": "^4.4.1", "rxjs": "^7.8.2", @@ -58,6 +59,7 @@ "@types/jest": "^29.5.14", "@types/multer": "^1.4.13", "@types/node": "^18.19.130", + "@types/pg": "^8.16.0", "@types/supertest": "^2.0.16", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", @@ -4308,6 +4310,18 @@ "integrity": "sha512-eLYXDbZWXh2uxf+w8sXS8d6KSoXTswfps6fvCUuVAGN8eRpfe7h9eSRydxiSJvo9Bf+GzifsDOr9TMQlmJdmkw==", "license": "MIT" }, + "node_modules/@types/pg": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", + "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -12290,6 +12304,95 @@ "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==", "peer": true }, + "node_modules/pg": { + "version": "8.17.2", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.17.2.tgz", + "integrity": "sha512-vjbKdiBJRqzcYw1fNU5KuHyYvdJ1qpcQg1CeBrHFqV1pWgHeVR6j/+kX0E1AAXfyuLUGY1ICrN2ELKA/z2HWzw==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.10.1", + "pg-pool": "^3.11.0", + "pg-protocol": "^1.11.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.10.1.tgz", + "integrity": "sha512-iNzslsoeSH2/gmDDKiyMqF64DATUCWj3YJ0wP14kqcsf2TUklwimd+66yYojKwZCA7h2yRNLGug71hCBA2a4sw==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz", + "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", + "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -12407,6 +12510,45 @@ "node": ">= 0.4" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -13564,6 +13706,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", diff --git a/package.json b/package.json index 7b8d6d37..04d0cb64 100644 --- a/package.json +++ b/package.json @@ -49,9 +49,10 @@ "lnurl": "^0.24.2", "morgan": "^1.10.1", "mssql": "^9.3.2", - "node-sql-parser": "^5.3.6", "nestjs-real-ip": "^2.2.0", + "node-sql-parser": "^5.3.6", "passport-jwt": "^4.0.1", + "pg": "^8.17.2", "reflect-metadata": "^0.1.14", "rimraf": "^4.4.1", "rxjs": "^7.8.2", @@ -68,6 +69,7 @@ "@types/jest": "^29.5.14", "@types/multer": "^1.4.13", "@types/node": "^18.19.130", + "@types/pg": "^8.16.0", "@types/supertest": "^2.0.16", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", diff --git a/scripts/boltz-debug.sh b/scripts/boltz-debug.sh new file mode 100755 index 00000000..a86fbcbe --- /dev/null +++ b/scripts/boltz-debug.sh @@ -0,0 +1,117 @@ +#!/bin/bash + +# LDS API Boltz PostgreSQL Debug Access Script +# +# Usage: +# ./scripts/boltz-debug.sh # Default query (swaps) +# ./scripts/boltz-debug.sh "SELECT * FROM swaps LIMIT 10" # Custom SQL query +# +# Environment: +# Uses the central .env file. Required variables: +# - DEBUG_ADDRESS: Wallet address with DEBUG role +# - DEBUG_SIGNATURE: Signature from signing the LDS login message +# - DEBUG_API_URL (optional): API URL, defaults to https://lightning.space/v1 +# +# Requirements: +# - curl +# - jq (optional, for pretty output) + +set -e + +# --- Help --- +if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then + echo "LDS API Boltz PostgreSQL Debug Access Script" + echo "" + echo "Usage:" + echo " ./scripts/boltz-debug.sh [SQL_QUERY]" + echo "" + echo "IMPORTANT: PostgreSQL is case-sensitive for table names!" + echo " - Use double quotes for camelCase tables: \"reverseSwaps\", \"chainSwaps\"" + echo " - Lowercase tables work without quotes: swaps, pairs, referrals" + echo "" + echo "Examples:" + echo " ./scripts/boltz-debug.sh 'SELECT * FROM swaps LIMIT 10'" + echo " ./scripts/boltz-debug.sh 'SELECT * FROM \"reverseSwaps\" WHERE status = '\\''swap.created'\\'' LIMIT 10'" + echo " ./scripts/boltz-debug.sh 'SELECT * FROM \"chainSwaps\" LIMIT 10'" + echo " ./scripts/boltz-debug.sh 'SELECT * FROM pairs'" + echo " ./scripts/boltz-debug.sh 'SELECT id, pair, status, fee FROM \"chainSwaps\" ORDER BY id DESC LIMIT 20'" + exit 0 +fi + +# --- Parse arguments --- +SQL="${1:-SELECT id, pair, status, \"createdAt\" FROM swaps ORDER BY \"createdAt\" DESC LIMIT 5}" + +# --- Load environment --- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="$SCRIPT_DIR/../.env" + +if [ ! -f "$ENV_FILE" ]; then + echo "Error: Environment file not found: $ENV_FILE" + echo "Create .env in the api root directory with DEBUG_ADDRESS and DEBUG_SIGNATURE" + exit 1 +fi + +# Read specific variables (avoid sourcing to prevent bash keyword conflicts) +DEBUG_ADDRESS=$(grep -E "^DEBUG_ADDRESS=" "$ENV_FILE" | cut -d'=' -f2-) +DEBUG_SIGNATURE=$(grep -E "^DEBUG_SIGNATURE=" "$ENV_FILE" | cut -d'=' -f2-) +DEBUG_API_URL=$(grep -E "^DEBUG_API_URL=" "$ENV_FILE" | cut -d'=' -f2-) + +if [ -z "$DEBUG_ADDRESS" ] || [ -z "$DEBUG_SIGNATURE" ]; then + echo "Error: DEBUG_ADDRESS and DEBUG_SIGNATURE must be set in .env" + echo "" + echo "To set up debug access:" + echo "1. Get a wallet address with DEBUG role assigned" + echo "2. Sign the message from GET /v1/auth/sign-message?address=YOUR_ADDRESS" + echo "3. Add to .env:" + echo " DEBUG_ADDRESS=your_wallet_address" + echo " DEBUG_SIGNATURE=your_signature" + exit 1 +fi + +API_URL="${DEBUG_API_URL:-https://lightning.space/v1}" + +# --- Authenticate --- +echo "=== Authenticating to $API_URL ===" +TOKEN_RESPONSE=$(curl -s -X POST "$API_URL/auth" \ + -H "Content-Type: application/json" \ + -d "{\"address\":\"$DEBUG_ADDRESS\",\"signature\":\"$DEBUG_SIGNATURE\"}") + +TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.accessToken' 2>/dev/null) + +if [ "$TOKEN" == "null" ] || [ -z "$TOKEN" ]; then + echo "Authentication failed:" + echo "$TOKEN_RESPONSE" | jq . 2>/dev/null || echo "$TOKEN_RESPONSE" + exit 1 +fi + +ROLE=$(echo "$TOKEN" | cut -d'.' -f2 | base64 -d 2>/dev/null | jq -r '.role' 2>/dev/null || echo "unknown") +echo "Authenticated with role: $ROLE" +echo "" + +# --- Execute query --- +echo "=== Executing Boltz PostgreSQL Query ===" +echo "Query: $SQL" +echo "" + +# Escape JSON properly +SQL_ESCAPED=$(echo "$SQL" | sed 's/"/\\"/g') + +RESULT=$(curl -s -X POST "$API_URL/support/debug/boltz" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"sql\":\"$SQL_ESCAPED\"}") + +echo "=== Result ===" + +if command -v jq &> /dev/null; then + # Check if it's an error + ERROR=$(echo "$RESULT" | jq -r '.message // empty' 2>/dev/null) + if [ -n "$ERROR" ]; then + echo "Error: $ERROR" + exit 1 + fi + + echo "$RESULT" | jq . +else + echo "$RESULT" +fi diff --git a/src/config/config.ts b/src/config/config.ts index 982c5537..cbf05b1c 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -185,6 +185,14 @@ export class Configuration { }, }; + boltzPostgres = { + host: process.env.BOLTZ_PG_HOST ?? '', + port: parseInt(process.env.BOLTZ_PG_PORT ?? '5432'), + database: process.env.BOLTZ_PG_DATABASE ?? '', + user: process.env.BOLTZ_PG_USER ?? '', + password: process.env.BOLTZ_PG_PASSWORD ?? '', + }; + request = { knownIps: process.env.REQUEST_KNOWN_IPS?.split(',') ?? [], limitCheck: process.env.REQUEST_LIMIT_CHECK === 'true', diff --git a/src/subdomains/support/controllers/support.controller.ts b/src/subdomains/support/controllers/support.controller.ts index 670694f7..4a1e2c90 100644 --- a/src/subdomains/support/controllers/support.controller.ts +++ b/src/subdomains/support/controllers/support.controller.ts @@ -6,6 +6,7 @@ import { GetJwt } from 'src/shared/auth/get-jwt.decorator'; import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; import { RoleGuard } from 'src/shared/auth/role.guard'; import { WalletRole } from 'src/shared/auth/wallet-role.enum'; +import { BoltzQueryDto } from '../dto/boltz-query.dto'; import { DbQueryDto } from '../dto/db-query.dto'; import { DebugQueryDto } from '../dto/debug-query.dto'; import { LogQueryDto, LogQueryResult } from '../dto/log-query.dto'; @@ -44,4 +45,15 @@ export class SupportController { async executeLogQuery(@GetJwt() jwt: JwtPayload, @Body() dto: LogQueryDto): Promise { return this.supportService.executeLogQuery(dto, jwt.address); } + + @Post('debug/boltz') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), new RoleGuard(WalletRole.DEBUG)) + async executeBoltzQuery( + @GetJwt() jwt: JwtPayload, + @Body() dto: BoltzQueryDto, + ): Promise[]> { + return this.supportService.executeBoltzQuery(dto.sql, jwt.address); + } } diff --git a/src/subdomains/support/dto/boltz-debug.config.ts b/src/subdomains/support/dto/boltz-debug.config.ts new file mode 100644 index 00000000..5309c5ea --- /dev/null +++ b/src/subdomains/support/dto/boltz-debug.config.ts @@ -0,0 +1,29 @@ +// Boltz PostgreSQL debug endpoint configuration + +// Maximum number of results returned by Boltz debug queries +export const BoltzMaxResults = 10000; + +// Blocked database schemas (PostgreSQL system tables) +export const BoltzBlockedSchemas = ['pg_catalog', 'information_schema', 'pg_toast']; + +// Dangerous SQL functions that could be used for data exfiltration, external connections, or DoS +export const BoltzDangerousFunctions = [ + 'pg_read_file', + 'pg_read_binary_file', + 'pg_ls_dir', + 'lo_import', + 'lo_export', + 'dblink', + 'dblink_exec', + 'dblink_connect', + 'pg_sleep', +]; + +// Blocked columns per table (sensitive data that should not be exposed via debug endpoint) +// Table names MUST be lowercase (lookup uses case-insensitive matching via toLowerCase()) +export const BoltzBlockedCols: Record = { + referrals: ['apiKey', 'apiSecret'], + swaps: ['preimage'], + reverseswaps: ['preimage', 'minerFeeInvoicePreimage'], + chainswaps: ['preimage'], +}; diff --git a/src/subdomains/support/dto/boltz-query.dto.ts b/src/subdomains/support/dto/boltz-query.dto.ts new file mode 100644 index 00000000..e3f9b5e1 --- /dev/null +++ b/src/subdomains/support/dto/boltz-query.dto.ts @@ -0,0 +1,8 @@ +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +export class BoltzQueryDto { + @IsNotEmpty() + @IsString() + @MaxLength(10000) + sql: string; +} diff --git a/src/subdomains/support/services/support.service.ts b/src/subdomains/support/services/support.service.ts index f45294d0..3a1a46dd 100644 --- a/src/subdomains/support/services/support.service.ts +++ b/src/subdomains/support/services/support.service.ts @@ -1,8 +1,16 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable, OnModuleDestroy } from '@nestjs/common'; import { Parser } from 'node-sql-parser'; +import { Pool } from 'pg'; +import { Config } from 'src/config/config'; import { AppInsightsQueryService } from 'src/shared/services/app-insights-query.service'; import { LightningLogger } from 'src/shared/services/lightning-logger'; import { DataSource } from 'typeorm'; +import { + BoltzBlockedCols, + BoltzBlockedSchemas, + BoltzDangerousFunctions, + BoltzMaxResults, +} from '../dto/boltz-debug.config'; import { DbQueryDto } from '../dto/db-query.dto'; import { DebugBlockedCols, @@ -14,15 +22,42 @@ import { import { LogQueryDto, LogQueryResult } from '../dto/log-query.dto'; @Injectable() -export class SupportService { +export class SupportService implements OnModuleDestroy { private readonly logger = new LightningLogger(SupportService); private readonly sqlParser = new Parser(); + private boltzPool: Pool | null = null; constructor( private readonly dataSource: DataSource, private readonly appInsightsQueryService: AppInsightsQueryService, ) {} + async onModuleDestroy(): Promise { + if (this.boltzPool) { + await this.boltzPool.end(); + } + } + + private getBoltzPool(): Pool { + if (!this.boltzPool) { + const pgConfig = Config.boltzPostgres; + if (!pgConfig.host || !pgConfig.database) { + throw new BadRequestException('Boltz PostgreSQL not configured'); + } + this.boltzPool = new Pool({ + host: pgConfig.host, + port: pgConfig.port, + database: pgConfig.database, + user: pgConfig.user, + password: pgConfig.password, + max: 5, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 10000, + }); + } + return this.boltzPool; + } + async getRawData(query: DbQueryDto): Promise { const request = this.dataSource .createQueryBuilder() @@ -166,6 +201,73 @@ export class SupportService { } } + async executeBoltzQuery(sql: string, userIdentifier: string): Promise[]> { + // 1. Parse SQL to AST for robust validation (PostgreSQL dialect) + let ast; + try { + ast = this.sqlParser.astify(sql, { database: 'PostgreSQL' }); + } catch { + throw new BadRequestException('Invalid SQL syntax'); + } + + // 2. Only single SELECT statements allowed + const statements = Array.isArray(ast) ? ast : [ast]; + if (statements.length !== 1) { + throw new BadRequestException('Only single statements allowed'); + } + + const stmt = statements[0]; + if (stmt.type !== 'select') { + throw new BadRequestException('Only SELECT queries allowed'); + } + + // 3. No UNION/INTERSECT/EXCEPT queries + if (stmt._next) { + throw new BadRequestException('UNION/INTERSECT/EXCEPT queries not allowed'); + } + + // 4. No SELECT INTO + if (stmt.into?.type === 'into' || stmt.into?.expr) { + throw new BadRequestException('SELECT INTO not allowed'); + } + + // 5. No system tables/schemas + this.checkForBoltzBlockedSchemas(stmt); + + // 6. No dangerous functions + this.checkForBoltzDangerousFunctionsRecursive(stmt); + + // 7. Check for blocked columns BEFORE execution + const tables = this.getBoltzTablesFromQuery(sql); + const blockedColumn = this.findBoltzBlockedColumnInQuery(sql, stmt, tables); + if (blockedColumn) { + throw new BadRequestException(`Access to column '${blockedColumn}' is not allowed`); + } + + // 8. Validate LIMIT value if present + if (stmt.limit?.value?.[0]?.value > BoltzMaxResults) { + throw new BadRequestException(`LIMIT value exceeds maximum of ${BoltzMaxResults}`); + } + + // 9. Log query for audit trail + this.logger.verbose(`Boltz query by ${userIdentifier}: ${sql.substring(0, 500)}${sql.length > 500 ? '...' : ''}`); + + // 10. Execute query with result limit + try { + const limitedSql = this.ensureBoltzResultLimit(sql); + const pool = this.getBoltzPool(); + const result = await pool.query(limitedSql); + + // 11. Post-execution masking (defense in depth) + this.maskBoltzBlockedColumns(result.rows, tables); + + return result.rows; + } catch (e) { + this.logger.info(`Boltz query by ${userIdentifier} failed: ${e.message}`); + throw new BadRequestException('Query execution failed'); + } + } + //*** HELPER METHODS ***// private escapeKqlString(value: string): string { @@ -619,4 +721,252 @@ export class SupportService { return `${trimmed}${orderByClause} OFFSET 0 ROWS FETCH NEXT ${DebugMaxResults} ROWS ONLY`; } + + // --- BOLTZ QUERY HELPER METHODS --- // + + private getBoltzTablesFromQuery(sql: string): string[] { + const tableList = this.sqlParser.tableList(sql, { database: 'PostgreSQL' }); + return tableList.map((t) => t.split('::')[2]).filter(Boolean); + } + + private getBoltzAliasToTableMap(ast: any): Map { + const map = new Map(); + if (!ast.from) return map; + + for (const item of ast.from) { + if (item.table) { + map.set(item.as || item.table, item.table); + } + } + return map; + } + + private resolveBoltzTableFromAlias( + tableOrAlias: string, + tables: string[], + aliasMap: Map, + ): string | null { + if (tableOrAlias === 'null') { + return tables.length === 1 ? tables[0] : null; + } + return aliasMap.get(tableOrAlias) || tableOrAlias; + } + + private isBoltzColumnBlockedInTable(columnName: string, table: string | null, allTables: string[]): boolean { + const lower = columnName.toLowerCase(); + + if (table) { + const blockedCols = BoltzBlockedCols[table.toLowerCase()]; + return blockedCols?.some((b) => b.toLowerCase() === lower) ?? false; + } else { + return allTables.some((t) => { + const blockedCols = BoltzBlockedCols[t.toLowerCase()]; + return blockedCols?.some((b) => b.toLowerCase() === lower) ?? false; + }); + } + } + + private findBoltzBlockedColumnInQuery(sql: string, ast: any, tables: string[]): string | null { + try { + const columns = this.sqlParser.columnList(sql, { database: 'PostgreSQL' }); + const aliasMap = this.getBoltzAliasToTableMap(ast); + + for (const col of columns) { + const parts = col.split('::'); + const tableOrAlias = parts[1]; + const columnName = parts[2]; + + if (columnName === '*' || columnName === '(.*)') continue; + + const resolvedTable = this.resolveBoltzTableFromAlias(tableOrAlias, tables, aliasMap); + + if (this.isBoltzColumnBlockedInTable(columnName, resolvedTable, tables)) { + return `${resolvedTable || 'unknown'}.${columnName}`; + } + } + + return null; + } catch { + return null; + } + } + + private checkForBoltzBlockedSchemas(stmt: any): void { + if (!stmt) return; + + if (stmt.from) { + for (const item of stmt.from) { + const schema = item.db?.toLowerCase() || item.schema?.toLowerCase(); + const table = item.table?.toLowerCase(); + + if (schema && BoltzBlockedSchemas.includes(schema)) { + throw new BadRequestException(`Access to schema '${schema}' is not allowed`); + } + + if (table && BoltzBlockedSchemas.some((s) => table.startsWith(s + '.'))) { + throw new BadRequestException(`Access to system tables is not allowed`); + } + + if (item.expr?.ast) { + this.checkForBoltzBlockedSchemas(item.expr.ast); + } + + this.checkBoltzSubqueriesForBlockedSchemas(item.on); + } + } + + if (stmt.columns) { + for (const col of stmt.columns) { + this.checkBoltzSubqueriesForBlockedSchemas(col.expr); + } + } + + this.checkBoltzSubqueriesForBlockedSchemas(stmt.where); + this.checkBoltzSubqueriesForBlockedSchemas(stmt.having); + + if (stmt.with) { + for (const cte of stmt.with) { + if (cte.stmt?.ast) { + this.checkForBoltzBlockedSchemas(cte.stmt.ast); + } + } + } + } + + private checkBoltzSubqueriesForBlockedSchemas(node: any): void { + if (!node) return; + + if (node.ast) { + this.checkForBoltzBlockedSchemas(node.ast); + } + + if (node.left) this.checkBoltzSubqueriesForBlockedSchemas(node.left); + if (node.right) this.checkBoltzSubqueriesForBlockedSchemas(node.right); + if (node.expr) this.checkBoltzSubqueriesForBlockedSchemas(node.expr); + + if (node.args) { + const args = Array.isArray(node.args) ? node.args : node.args?.value || []; + for (const arg of Array.isArray(args) ? args : [args]) { + this.checkBoltzSubqueriesForBlockedSchemas(arg); + } + } + } + + private checkForBoltzDangerousFunctionsRecursive(stmt: any): void { + if (!stmt) return; + + this.checkBoltzFromForDangerousFunctions(stmt.from); + this.checkBoltzExpressionsForDangerousFunctions(stmt.columns); + this.checkBoltzNodeForDangerousFunctions(stmt.where); + this.checkBoltzNodeForDangerousFunctions(stmt.having); + + if (stmt.with) { + for (const cte of stmt.with) { + if (cte.stmt?.ast) { + this.checkForBoltzDangerousFunctionsRecursive(cte.stmt.ast); + } + } + } + } + + private checkBoltzFromForDangerousFunctions(from: any[]): void { + if (!from) return; + + for (const item of from) { + if (item.type === 'expr' && item.expr?.type === 'function') { + const funcName = this.extractFunctionName(item.expr); + if (funcName && BoltzDangerousFunctions.includes(funcName)) { + throw new BadRequestException(`Function '${funcName.toUpperCase()}' not allowed`); + } + } + + if (item.expr?.ast) { + this.checkForBoltzDangerousFunctionsRecursive(item.expr.ast); + } + + this.checkBoltzNodeForDangerousFunctions(item.on); + } + } + + private checkBoltzExpressionsForDangerousFunctions(columns: any[]): void { + if (!columns) return; + + for (const col of columns) { + this.checkBoltzNodeForDangerousFunctions(col.expr); + } + } + + private checkBoltzNodeForDangerousFunctions(node: any): void { + if (!node) return; + + if (node.type === 'function') { + const funcName = this.extractFunctionName(node); + if (funcName && BoltzDangerousFunctions.includes(funcName)) { + throw new BadRequestException(`Function '${funcName.toUpperCase()}' not allowed`); + } + } + + if (node.ast) { + this.checkForBoltzDangerousFunctionsRecursive(node.ast); + } + + if (node.left) this.checkBoltzNodeForDangerousFunctions(node.left); + if (node.right) this.checkBoltzNodeForDangerousFunctions(node.right); + if (node.expr) this.checkBoltzNodeForDangerousFunctions(node.expr); + + if (node.args) { + const args = Array.isArray(node.args) ? node.args : node.args?.value || []; + for (const arg of Array.isArray(args) ? args : [args]) { + this.checkBoltzNodeForDangerousFunctions(arg); + } + } + } + + private maskBoltzBlockedColumns(data: Record[], tables: string[]): void { + if (!data?.length || !tables?.length) return; + + const blockedColumns = new Set(); + for (const table of tables) { + const tableCols = BoltzBlockedCols[table.toLowerCase()]; + if (tableCols) { + for (const col of tableCols) { + blockedColumns.add(col.toLowerCase()); + } + } + } + + if (blockedColumns.size === 0) return; + + for (const entry of data) { + for (const key of Object.keys(entry)) { + if (this.shouldMaskBoltzColumn(key, blockedColumns)) { + entry[key] = entry[key] == null ? '[RESTRICTED:NULL]' : '[RESTRICTED:SET]'; + } + } + } + } + + private shouldMaskBoltzColumn(columnName: string, blockedColumns: Set): boolean { + const lower = columnName.toLowerCase(); + + for (const blocked of blockedColumns) { + if (lower === blocked || lower.endsWith('_' + blocked)) { + return true; + } + } + return false; + } + + private ensureBoltzResultLimit(sql: string): string { + const normalized = sql.trim().toLowerCase(); + + if (normalized.includes(' limit ')) { + return sql; + } + + let trimmed = sql.trim(); + while (trimmed.endsWith(';')) trimmed = trimmed.slice(0, -1); + + return `${trimmed} LIMIT ${BoltzMaxResults}`; + } } From 6381fc292be926353550cd67fb4c59906078aff1 Mon Sep 17 00:00:00 2001 From: David May <85513542+davidleomay@users.noreply.github.com> Date: Sat, 24 Jan 2026 11:53:27 +0100 Subject: [PATCH 20/22] chore: refactoring (#100) --- .../support/dto/boltz-debug.config.ts | 19 +- src/subdomains/support/dto/debug.config.ts | 20 +- .../support/services/sql-query-validator.ts | 543 ++++++++++++ .../support/services/support.service.ts | 807 +----------------- 4 files changed, 592 insertions(+), 797 deletions(-) create mode 100644 src/subdomains/support/services/sql-query-validator.ts diff --git a/src/subdomains/support/dto/boltz-debug.config.ts b/src/subdomains/support/dto/boltz-debug.config.ts index 5309c5ea..cf3b73c2 100644 --- a/src/subdomains/support/dto/boltz-debug.config.ts +++ b/src/subdomains/support/dto/boltz-debug.config.ts @@ -1,13 +1,15 @@ +import { SqlDialect, SqlQueryConfig } from '../services/sql-query-validator'; + // Boltz PostgreSQL debug endpoint configuration // Maximum number of results returned by Boltz debug queries -export const BoltzMaxResults = 10000; +const BoltzMaxResults = 10000; // Blocked database schemas (PostgreSQL system tables) -export const BoltzBlockedSchemas = ['pg_catalog', 'information_schema', 'pg_toast']; +const BoltzBlockedSchemas = ['pg_catalog', 'information_schema', 'pg_toast']; // Dangerous SQL functions that could be used for data exfiltration, external connections, or DoS -export const BoltzDangerousFunctions = [ +const BoltzDangerousFunctions = [ 'pg_read_file', 'pg_read_binary_file', 'pg_ls_dir', @@ -21,9 +23,18 @@ export const BoltzDangerousFunctions = [ // Blocked columns per table (sensitive data that should not be exposed via debug endpoint) // Table names MUST be lowercase (lookup uses case-insensitive matching via toLowerCase()) -export const BoltzBlockedCols: Record = { +const BoltzBlockedCols: Record = { referrals: ['apiKey', 'apiSecret'], swaps: ['preimage'], reverseswaps: ['preimage', 'minerFeeInvoicePreimage'], chainswaps: ['preimage'], }; + +// Boltz PostgreSQL debug query configuration +export const BoltzDebugConfig: SqlQueryConfig = { + database: SqlDialect.PostgreSQL, + blockedSchemas: BoltzBlockedSchemas, + blockedCols: BoltzBlockedCols, + dangerousFunctions: BoltzDangerousFunctions, + maxResults: BoltzMaxResults, +}; diff --git a/src/subdomains/support/dto/debug.config.ts b/src/subdomains/support/dto/debug.config.ts index d3ad81c9..b7f8942b 100644 --- a/src/subdomains/support/dto/debug.config.ts +++ b/src/subdomains/support/dto/debug.config.ts @@ -1,18 +1,19 @@ +import { SqlDialect, SqlQueryConfig } from '../services/sql-query-validator'; import { LogQueryDto, LogQueryTemplate } from './log-query.dto'; // Debug endpoint configuration // Maximum number of results returned by debug queries -export const DebugMaxResults = 10000; +const DebugMaxResults = 10000; // Blocked database schemas (system tables) -export const DebugBlockedSchemas = ['sys', 'information_schema', 'master', 'msdb', 'tempdb']; +const DebugBlockedSchemas = ['sys', 'information_schema', 'master', 'msdb', 'tempdb']; // Dangerous SQL functions that could be used for data exfiltration or external connections -export const DebugDangerousFunctions = ['openrowset', 'openquery', 'opendatasource', 'openxml']; +const DebugDangerousFunctions = ['openrowset', 'openquery', 'opendatasource', 'openxml']; // Blocked columns per table (sensitive data that should not be exposed via debug endpoint) -export const DebugBlockedCols: Record = { +const DebugBlockedCols: Record = { wallet: ['signature', 'addressOwnershipProof'], lightning_wallet: ['adminKey', 'invoiceKey'], user_boltcard: ['k0', 'k1', 'k2', 'prevK0', 'prevK1', 'prevK2', 'otp', 'uid'], @@ -20,6 +21,17 @@ export const DebugBlockedCols: Record = { payment_request: ['paymentRequest'], }; +// MSSQL debug query configuration +export const MssqlDebugConfig: SqlQueryConfig = { + database: SqlDialect.MSSQL, + blockedSchemas: DebugBlockedSchemas, + blockedCols: DebugBlockedCols, + dangerousFunctions: DebugDangerousFunctions, + maxResults: DebugMaxResults, + checkForXmlJson: true, + checkLinkedServers: true, +}; + // Log query templates for Azure Application Insights export const DebugLogQueryTemplates: Record< LogQueryTemplate, diff --git a/src/subdomains/support/services/sql-query-validator.ts b/src/subdomains/support/services/sql-query-validator.ts new file mode 100644 index 00000000..0b6cb082 --- /dev/null +++ b/src/subdomains/support/services/sql-query-validator.ts @@ -0,0 +1,543 @@ +import { BadRequestException } from '@nestjs/common'; +import { Parser } from 'node-sql-parser'; + +export enum SqlDialect { + MSSQL = 'TransactSQL', + PostgreSQL = 'PostgreSQL', +} + +// Configuration for SQL query validation +export interface SqlQueryConfig { + // SQL dialect for parsing + database: SqlDialect; + // Schemas/databases that are blocked from access + blockedSchemas: string[]; + // Columns blocked per table (table names should be lowercase for lookup) + blockedCols: Record; + // Dangerous functions that should be rejected + dangerousFunctions: string[]; + // Maximum number of results to return + maxResults: number; + // Whether to check for FOR XML/JSON (MSSQL-specific) + checkForXmlJson?: boolean; + // Whether to check for linked servers (MSSQL-specific) + checkLinkedServers?: boolean; +} + +export interface SqlValidationResult { + ast: any; + tables: string[]; +} + +export class SqlQueryValidator { + private readonly sqlParser = new Parser(); + + /** + * Validates a SQL query against the provided configuration. + * Throws BadRequestException if validation fails. + * Returns the parsed AST and extracted table names on success. + */ + validateQuery(sql: string, config: SqlQueryConfig): SqlValidationResult { + // 1. Parse SQL to AST for robust validation + let ast; + try { + ast = this.sqlParser.astify(sql, { database: config.database }); + } catch { + throw new BadRequestException('Invalid SQL syntax'); + } + + // 2. Only single SELECT statements allowed + const statements = Array.isArray(ast) ? ast : [ast]; + if (statements.length !== 1) { + throw new BadRequestException('Only single statements allowed'); + } + + const stmt = statements[0]; + if (stmt.type !== 'select') { + throw new BadRequestException('Only SELECT queries allowed'); + } + + // 3. No UNION/INTERSECT/EXCEPT queries + if (stmt._next) { + throw new BadRequestException('UNION/INTERSECT/EXCEPT queries not allowed'); + } + + // 4. No SELECT INTO + if (stmt.into?.type === 'into' || stmt.into?.expr) { + throw new BadRequestException('SELECT INTO not allowed'); + } + + // 5. No system tables/schemas + this.checkForBlockedSchemas(stmt, config); + + // 6. No dangerous functions + this.checkForDangerousFunctionsRecursive(stmt, config); + + // 7. No FOR XML/JSON (MSSQL-specific) + if (config.checkForXmlJson) { + this.checkForXmlJsonRecursive(stmt); + } + + // 8. Check for blocked columns BEFORE execution + const tables = this.getTablesFromQuery(sql, config.database); + const blockedColumn = this.findBlockedColumnInQuery(sql, stmt, tables, config); + if (blockedColumn) { + throw new BadRequestException(`Access to column '${blockedColumn}' is not allowed`); + } + + // 9. Validate result limit if present + const limitValue = config.database === SqlDialect.PostgreSQL ? stmt.limit?.value?.[0]?.value : stmt.top?.value; + if (limitValue > config.maxResults) { + throw new BadRequestException( + `${config.database === SqlDialect.PostgreSQL ? 'LIMIT' : 'TOP'} value exceeds maximum of ${config.maxResults}`, + ); + } + + return { ast: stmt, tables }; + } + + /** + * Ensures SQL has a result limit. Returns modified SQL. + */ + ensureResultLimit(sql: string, config: SqlQueryConfig): string { + const normalized = sql.trim().toLowerCase(); + + if (config.database === SqlDialect.PostgreSQL) { + if (normalized.includes(' limit ')) { + return sql; + } + let trimmed = sql.trim(); + while (trimmed.endsWith(';')) trimmed = trimmed.slice(0, -1); + return `${trimmed} LIMIT ${config.maxResults}`; + } else { + // TransactSQL (MSSQL) + if (normalized.includes(' top ') || normalized.includes(' limit ')) { + return sql; + } + const hasOrderBy = /order\s+by/i.test(normalized); + const orderByClause = hasOrderBy ? '' : ' ORDER BY (SELECT NULL)'; + let trimmed = sql.trim(); + while (trimmed.endsWith(';')) trimmed = trimmed.slice(0, -1); + return `${trimmed}${orderByClause} OFFSET 0 ROWS FETCH NEXT ${config.maxResults} ROWS ONLY`; + } + } + + /** + * Masks blocked columns in result data (defense in depth). + */ + maskBlockedColumns(data: Record[], tables: string[], config: SqlQueryConfig): void { + if (!data?.length || !tables?.length) return; + + const blockedColumns = new Set(); + for (const table of tables) { + const tableCols = config.blockedCols[table.toLowerCase()]; + if (tableCols) { + for (const col of tableCols) { + blockedColumns.add(col.toLowerCase()); + } + } + } + + if (blockedColumns.size === 0) return; + + for (const entry of data) { + for (const key of Object.keys(entry)) { + if (this.shouldMaskColumn(key, blockedColumns)) { + entry[key] = entry[key] == null ? '[RESTRICTED:NULL]' : '[RESTRICTED:SET]'; + } + } + } + } + + // --- Private Helper Methods --- // + + private getTablesFromQuery(sql: string, database: SqlDialect): string[] { + const tableList = this.sqlParser.tableList(sql, { database }); + return tableList.map((t) => t.split('::')[2]).filter(Boolean); + } + + private getAliasToTableMap(ast: any): Map { + const map = new Map(); + if (!ast.from) return map; + + for (const item of ast.from) { + if (item.table) { + map.set(item.as || item.table, item.table); + } + } + return map; + } + + private resolveTableFromAlias( + tableOrAlias: string, + tables: string[], + aliasMap: Map, + ): string | null { + if (tableOrAlias === 'null') { + return tables.length === 1 ? tables[0] : null; + } + return aliasMap.get(tableOrAlias) || tableOrAlias; + } + + private isColumnBlockedInTable( + columnName: string, + table: string | null, + allTables: string[], + config: SqlQueryConfig, + ): boolean { + const lower = columnName.toLowerCase(); + + if (table) { + const blockedCols = config.blockedCols[table.toLowerCase()]; + return blockedCols?.some((b) => b.toLowerCase() === lower) ?? false; + } else { + return allTables.some((t) => { + const blockedCols = config.blockedCols[t.toLowerCase()]; + return blockedCols?.some((b) => b.toLowerCase() === lower) ?? false; + }); + } + } + + private findBlockedColumnInQuery( + sql: string, + ast: any, + tables: string[], + config: SqlQueryConfig, + ): string | null { + try { + const columns = this.sqlParser.columnList(sql, { database: config.database }); + const aliasMap = this.getAliasToTableMap(ast); + + for (const col of columns) { + const parts = col.split('::'); + const tableOrAlias = parts[1]; + const columnName = parts[2]; + + if (columnName === '*' || columnName === '(.*)') continue; + + const resolvedTable = this.resolveTableFromAlias(tableOrAlias, tables, aliasMap); + + if (this.isColumnBlockedInTable(columnName, resolvedTable, tables, config)) { + return `${resolvedTable || 'unknown'}.${columnName}`; + } + } + + return null; + } catch { + return null; + } + } + + private checkForBlockedSchemas(stmt: any, config: SqlQueryConfig): void { + if (!stmt) return; + + if (stmt.from) { + for (const item of stmt.from) { + // Check for linked servers (MSSQL-specific) + if (config.checkLinkedServers && item.server) { + throw new BadRequestException('Linked server access is not allowed'); + } + + const schema = item.db?.toLowerCase() || item.schema?.toLowerCase(); + const table = item.table?.toLowerCase(); + + if (schema && config.blockedSchemas.includes(schema)) { + throw new BadRequestException(`Access to schema '${schema}' is not allowed`); + } + + if (table && config.blockedSchemas.some((s) => table.startsWith(s + '.'))) { + throw new BadRequestException(`Access to system tables is not allowed`); + } + + if (item.expr?.ast) { + this.checkForBlockedSchemas(item.expr.ast, config); + } + + this.checkSubqueriesForBlockedSchemas(item.on, config); + } + } + + if (stmt.columns) { + for (const col of stmt.columns) { + this.checkSubqueriesForBlockedSchemas(col.expr, config); + } + } + + this.checkSubqueriesForBlockedSchemas(stmt.where, config); + this.checkSubqueriesForBlockedSchemas(stmt.having, config); + + if (stmt.orderby) { + for (const item of stmt.orderby) { + this.checkSubqueriesForBlockedSchemas(item.expr, config); + } + } + + if (stmt.groupby?.columns) { + for (const item of stmt.groupby.columns) { + this.checkSubqueriesForBlockedSchemas(item, config); + } + } + + if (stmt.with) { + for (const cte of stmt.with) { + if (cte.stmt?.ast) { + this.checkForBlockedSchemas(cte.stmt.ast, config); + } + } + } + } + + private checkSubqueriesForBlockedSchemas(node: any, config: SqlQueryConfig): void { + if (!node) return; + + if (node.ast) { + this.checkForBlockedSchemas(node.ast, config); + } + + if (node.left) this.checkSubqueriesForBlockedSchemas(node.left, config); + if (node.right) this.checkSubqueriesForBlockedSchemas(node.right, config); + if (node.expr) this.checkSubqueriesForBlockedSchemas(node.expr, config); + + if (node.result) this.checkSubqueriesForBlockedSchemas(node.result, config); + if (node.condition) this.checkSubqueriesForBlockedSchemas(node.condition, config); + + if (node.args) { + const args = Array.isArray(node.args) ? node.args : node.args?.value || []; + for (const arg of Array.isArray(args) ? args : [args]) { + this.checkSubqueriesForBlockedSchemas(arg, config); + } + } + if (node.value && Array.isArray(node.value)) { + for (const val of node.value) { + this.checkSubqueriesForBlockedSchemas(val, config); + } + } + + if (node.over?.as_window_specification?.window_specification) { + const winSpec = node.over.as_window_specification.window_specification; + if (winSpec.orderby) { + for (const item of winSpec.orderby) { + this.checkSubqueriesForBlockedSchemas(item.expr, config); + } + } + if (winSpec.partitionby) { + for (const item of winSpec.partitionby) { + this.checkSubqueriesForBlockedSchemas(item, config); + } + } + } + } + + private checkForDangerousFunctionsRecursive(stmt: any, config: SqlQueryConfig): void { + if (!stmt) return; + + this.checkFromForDangerousFunctions(stmt.from, config); + this.checkExpressionsForDangerousFunctions(stmt.columns, config); + this.checkNodeForDangerousFunctions(stmt.where, config); + this.checkNodeForDangerousFunctions(stmt.having, config); + + if (stmt.orderby) { + for (const item of stmt.orderby) { + this.checkNodeForDangerousFunctions(item.expr, config); + } + } + + if (stmt.groupby?.columns) { + for (const item of stmt.groupby.columns) { + this.checkNodeForDangerousFunctions(item, config); + } + } + + if (stmt.with) { + for (const cte of stmt.with) { + if (cte.stmt?.ast) { + this.checkForDangerousFunctionsRecursive(cte.stmt.ast, config); + } + } + } + } + + private checkFromForDangerousFunctions(from: any[], config: SqlQueryConfig): void { + if (!from) return; + + for (const item of from) { + if (item.type === 'expr' && item.expr?.type === 'function') { + const funcName = this.extractFunctionName(item.expr); + if (funcName && config.dangerousFunctions.includes(funcName)) { + throw new BadRequestException(`Function '${funcName.toUpperCase()}' not allowed`); + } + } + + if (item.expr?.ast) { + this.checkForDangerousFunctionsRecursive(item.expr.ast, config); + } + + this.checkNodeForDangerousFunctions(item.on, config); + } + } + + private checkExpressionsForDangerousFunctions(columns: any[], config: SqlQueryConfig): void { + if (!columns) return; + + for (const col of columns) { + this.checkNodeForDangerousFunctions(col.expr, config); + } + } + + private checkNodeForDangerousFunctions(node: any, config: SqlQueryConfig): void { + if (!node) return; + + if (node.type === 'function') { + const funcName = this.extractFunctionName(node); + if (funcName && config.dangerousFunctions.includes(funcName)) { + throw new BadRequestException(`Function '${funcName.toUpperCase()}' not allowed`); + } + } + + if (node.ast) { + this.checkForDangerousFunctionsRecursive(node.ast, config); + } + + if (node.left) this.checkNodeForDangerousFunctions(node.left, config); + if (node.right) this.checkNodeForDangerousFunctions(node.right, config); + if (node.expr) this.checkNodeForDangerousFunctions(node.expr, config); + + if (node.result) this.checkNodeForDangerousFunctions(node.result, config); + if (node.condition) this.checkNodeForDangerousFunctions(node.condition, config); + + if (node.args) { + const args = Array.isArray(node.args) ? node.args : node.args?.value || []; + for (const arg of Array.isArray(args) ? args : [args]) { + this.checkNodeForDangerousFunctions(arg, config); + } + } + if (node.value && Array.isArray(node.value)) { + for (const val of node.value) { + this.checkNodeForDangerousFunctions(val, config); + } + } + + if (node.over?.as_window_specification?.window_specification) { + const winSpec = node.over.as_window_specification.window_specification; + if (winSpec.orderby) { + for (const item of winSpec.orderby) { + this.checkNodeForDangerousFunctions(item.expr, config); + } + } + if (winSpec.partitionby) { + for (const item of winSpec.partitionby) { + this.checkNodeForDangerousFunctions(item, config); + } + } + } + } + + private extractFunctionName(funcNode: any): string | null { + if (funcNode.name?.name?.[0]?.value) { + return funcNode.name.name[0].value.toLowerCase(); + } + if (typeof funcNode.name === 'string') { + return funcNode.name.toLowerCase(); + } + return null; + } + + private checkForXmlJsonRecursive(stmt: any): void { + if (!stmt) return; + + const forType = stmt.for?.type?.toLowerCase(); + if (forType?.includes('xml') || forType?.includes('json')) { + throw new BadRequestException('FOR XML/JSON not allowed'); + } + + if (stmt.columns) { + for (const col of stmt.columns) { + this.checkNodeForXmlJson(col.expr); + } + } + + if (stmt.from) { + for (const item of stmt.from) { + if (item.expr?.ast) { + this.checkForXmlJsonRecursive(item.expr.ast); + } + this.checkNodeForXmlJson(item.on); + } + } + + this.checkNodeForXmlJson(stmt.where); + this.checkNodeForXmlJson(stmt.having); + + if (stmt.orderby) { + for (const item of stmt.orderby) { + this.checkNodeForXmlJson(item.expr); + } + } + + if (stmt.groupby?.columns) { + for (const item of stmt.groupby.columns) { + this.checkNodeForXmlJson(item); + } + } + + if (stmt.with) { + for (const cte of stmt.with) { + if (cte.stmt?.ast) { + this.checkForXmlJsonRecursive(cte.stmt.ast); + } + } + } + } + + private checkNodeForXmlJson(node: any): void { + if (!node) return; + + if (node.ast) { + this.checkForXmlJsonRecursive(node.ast); + } + + if (node.left) this.checkNodeForXmlJson(node.left); + if (node.right) this.checkNodeForXmlJson(node.right); + if (node.expr) this.checkNodeForXmlJson(node.expr); + + if (node.result) this.checkNodeForXmlJson(node.result); + if (node.condition) this.checkNodeForXmlJson(node.condition); + + if (node.args) { + const args = Array.isArray(node.args) ? node.args : node.args?.value || []; + for (const arg of Array.isArray(args) ? args : [args]) { + this.checkNodeForXmlJson(arg); + } + } + if (node.value && Array.isArray(node.value)) { + for (const val of node.value) { + this.checkNodeForXmlJson(val); + } + } + + if (node.over?.as_window_specification?.window_specification) { + const winSpec = node.over.as_window_specification.window_specification; + if (winSpec.orderby) { + for (const item of winSpec.orderby) { + this.checkNodeForXmlJson(item.expr); + } + } + if (winSpec.partitionby) { + for (const item of winSpec.partitionby) { + this.checkNodeForXmlJson(item); + } + } + } + } + + private shouldMaskColumn(columnName: string, blockedColumns: Set): boolean { + const lower = columnName.toLowerCase(); + + for (const blocked of blockedColumns) { + if (lower === blocked || lower.endsWith('_' + blocked)) { + return true; + } + } + return false; + } +} diff --git a/src/subdomains/support/services/support.service.ts b/src/subdomains/support/services/support.service.ts index 3a1a46dd..e658330f 100644 --- a/src/subdomains/support/services/support.service.ts +++ b/src/subdomains/support/services/support.service.ts @@ -1,30 +1,19 @@ import { BadRequestException, Injectable, OnModuleDestroy } from '@nestjs/common'; -import { Parser } from 'node-sql-parser'; import { Pool } from 'pg'; import { Config } from 'src/config/config'; import { AppInsightsQueryService } from 'src/shared/services/app-insights-query.service'; import { LightningLogger } from 'src/shared/services/lightning-logger'; import { DataSource } from 'typeorm'; -import { - BoltzBlockedCols, - BoltzBlockedSchemas, - BoltzDangerousFunctions, - BoltzMaxResults, -} from '../dto/boltz-debug.config'; +import { BoltzDebugConfig } from '../dto/boltz-debug.config'; import { DbQueryDto } from '../dto/db-query.dto'; -import { - DebugBlockedCols, - DebugBlockedSchemas, - DebugDangerousFunctions, - DebugLogQueryTemplates, - DebugMaxResults, -} from '../dto/debug.config'; +import { DebugLogQueryTemplates, MssqlDebugConfig } from '../dto/debug.config'; import { LogQueryDto, LogQueryResult } from '../dto/log-query.dto'; +import { SqlQueryValidator } from './sql-query-validator'; @Injectable() export class SupportService implements OnModuleDestroy { private readonly logger = new LightningLogger(SupportService); - private readonly sqlParser = new Parser(); + private readonly sqlValidator = new SqlQueryValidator(); private boltzPool: Pool | null = null; constructor( @@ -86,66 +75,19 @@ export class SupportService implements OnModuleDestroy { } async executeDebugQuery(sql: string, userIdentifier: string): Promise[]> { - // 1. Parse SQL to AST for robust validation - let ast; - try { - ast = this.sqlParser.astify(sql, { database: 'TransactSQL' }); - } catch { - throw new BadRequestException('Invalid SQL syntax'); - } - - // 2. Only single SELECT statements allowed (array means multiple statements) - const statements = Array.isArray(ast) ? ast : [ast]; - if (statements.length !== 1) { - throw new BadRequestException('Only single statements allowed'); - } - - const stmt = statements[0]; - if (stmt.type !== 'select') { - throw new BadRequestException('Only SELECT queries allowed'); - } - - // 3. No UNION/INTERSECT/EXCEPT queries (these have _next property) - if (stmt._next) { - throw new BadRequestException('UNION/INTERSECT/EXCEPT queries not allowed'); - } - - // 4. No SELECT INTO (creates tables - write operation!) - if (stmt.into?.type === 'into' || stmt.into?.expr) { - throw new BadRequestException('SELECT INTO not allowed'); - } - - // 5. No system tables/schemas (prevent access to sys.*, INFORMATION_SCHEMA.*, etc.) - this.checkForBlockedSchemas(stmt); + // Validate query using shared validator + const { tables } = this.sqlValidator.validateQuery(sql, MssqlDebugConfig); - // 6. No dangerous functions anywhere in the query (external connections) - this.checkForDangerousFunctionsRecursive(stmt); - - // 7. No FOR XML/JSON (data exfiltration) - check recursively including subqueries - this.checkForXmlJsonRecursive(stmt); - - // 8. Check for blocked columns BEFORE execution (prevents alias bypass) - const tables = this.getTablesFromQuery(sql); - const blockedColumn = this.findBlockedColumnInQuery(sql, stmt, tables); - if (blockedColumn) { - throw new BadRequestException(`Access to column '${blockedColumn}' is not allowed`); - } - - // 9. Validate TOP value if present (use AST for accurate detection including TOP(n) syntax) - if (stmt.top?.value > DebugMaxResults) { - throw new BadRequestException(`TOP value exceeds maximum of ${DebugMaxResults}`); - } - - // 10. Log query for audit trail + // Log query for audit trail this.logger.verbose(`Debug query by ${userIdentifier}: ${sql.substring(0, 500)}${sql.length > 500 ? '...' : ''}`); - // 11. Execute query with result limit + // Execute query with result limit try { - const limitedSql = this.ensureResultLimit(sql); + const limitedSql = this.sqlValidator.ensureResultLimit(sql, MssqlDebugConfig); const result = await this.dataSource.query(limitedSql); - // 12. Post-execution masking (defense in depth - also catches pre-execution failures) - this.maskDebugBlockedColumns(result, tables); + // Post-execution masking (defense in depth) + this.sqlValidator.maskBlockedColumns(result, tables, MssqlDebugConfig); return result; } catch (e) { @@ -202,64 +144,20 @@ export class SupportService implements OnModuleDestroy { } async executeBoltzQuery(sql: string, userIdentifier: string): Promise[]> { - // 1. Parse SQL to AST for robust validation (PostgreSQL dialect) - let ast; - try { - ast = this.sqlParser.astify(sql, { database: 'PostgreSQL' }); - } catch { - throw new BadRequestException('Invalid SQL syntax'); - } - - // 2. Only single SELECT statements allowed - const statements = Array.isArray(ast) ? ast : [ast]; - if (statements.length !== 1) { - throw new BadRequestException('Only single statements allowed'); - } + // Validate query using shared validator + const { tables } = this.sqlValidator.validateQuery(sql, BoltzDebugConfig); - const stmt = statements[0]; - if (stmt.type !== 'select') { - throw new BadRequestException('Only SELECT queries allowed'); - } - - // 3. No UNION/INTERSECT/EXCEPT queries - if (stmt._next) { - throw new BadRequestException('UNION/INTERSECT/EXCEPT queries not allowed'); - } - - // 4. No SELECT INTO - if (stmt.into?.type === 'into' || stmt.into?.expr) { - throw new BadRequestException('SELECT INTO not allowed'); - } - - // 5. No system tables/schemas - this.checkForBoltzBlockedSchemas(stmt); - - // 6. No dangerous functions - this.checkForBoltzDangerousFunctionsRecursive(stmt); - - // 7. Check for blocked columns BEFORE execution - const tables = this.getBoltzTablesFromQuery(sql); - const blockedColumn = this.findBoltzBlockedColumnInQuery(sql, stmt, tables); - if (blockedColumn) { - throw new BadRequestException(`Access to column '${blockedColumn}' is not allowed`); - } - - // 8. Validate LIMIT value if present - if (stmt.limit?.value?.[0]?.value > BoltzMaxResults) { - throw new BadRequestException(`LIMIT value exceeds maximum of ${BoltzMaxResults}`); - } - - // 9. Log query for audit trail + // Log query for audit trail this.logger.verbose(`Boltz query by ${userIdentifier}: ${sql.substring(0, 500)}${sql.length > 500 ? '...' : ''}`); - // 10. Execute query with result limit + // Execute query with result limit try { - const limitedSql = this.ensureBoltzResultLimit(sql); + const limitedSql = this.sqlValidator.ensureResultLimit(sql, BoltzDebugConfig); const pool = this.getBoltzPool(); const result = await pool.query(limitedSql); - // 11. Post-execution masking (defense in depth) - this.maskBoltzBlockedColumns(result.rows, tables); + // Post-execution masking (defense in depth) + this.sqlValidator.maskBlockedColumns(result.rows, tables, BoltzDebugConfig); return result.rows; } catch (e) { @@ -300,673 +198,4 @@ export class SupportService implements OnModuleDestroy { return str.charAt(0).toLowerCase() + str.slice(1).split('_').join('.'); } - // --- DEBUG QUERY HELPER METHODS --- // - - private getTablesFromQuery(sql: string): string[] { - const tableList = this.sqlParser.tableList(sql, { database: 'TransactSQL' }); - // Format: 'select::null::table_name' → extract table_name - return tableList.map((t) => t.split('::')[2]).filter(Boolean); - } - - private getAliasToTableMap(ast: any): Map { - const map = new Map(); - if (!ast.from) return map; - - for (const item of ast.from) { - if (item.table) { - map.set(item.as || item.table, item.table); - } - } - return map; - } - - private resolveTableFromAlias( - tableOrAlias: string, - tables: string[], - aliasMap: Map, - ): string | null { - if (tableOrAlias === 'null') { - return tables.length === 1 ? tables[0] : null; - } - return aliasMap.get(tableOrAlias) || tableOrAlias; - } - - private isColumnBlockedInTable(columnName: string, table: string | null, allTables: string[]): boolean { - const lower = columnName.toLowerCase(); - - if (table) { - const blockedCols = DebugBlockedCols[table.toLowerCase()]; - return blockedCols?.some((b) => b.toLowerCase() === lower) ?? false; - } else { - return allTables.some((t) => { - const blockedCols = DebugBlockedCols[t.toLowerCase()]; - return blockedCols?.some((b) => b.toLowerCase() === lower) ?? false; - }); - } - } - - private findBlockedColumnInQuery(sql: string, ast: any, tables: string[]): string | null { - try { - const columns = this.sqlParser.columnList(sql, { database: 'TransactSQL' }); - const aliasMap = this.getAliasToTableMap(ast); - - for (const col of columns) { - const parts = col.split('::'); - const tableOrAlias = parts[1]; - const columnName = parts[2]; - - if (columnName === '*' || columnName === '(.*)') continue; - - const resolvedTable = this.resolveTableFromAlias(tableOrAlias, tables, aliasMap); - - if (this.isColumnBlockedInTable(columnName, resolvedTable, tables)) { - return `${resolvedTable || 'unknown'}.${columnName}`; - } - } - - return null; - } catch { - return null; - } - } - - private checkForBlockedSchemas(stmt: any): void { - if (!stmt) return; - - if (stmt.from) { - for (const item of stmt.from) { - if (item.server) { - throw new BadRequestException('Linked server access is not allowed'); - } - - const schema = item.db?.toLowerCase() || item.schema?.toLowerCase(); - const table = item.table?.toLowerCase(); - - if (schema && DebugBlockedSchemas.includes(schema)) { - throw new BadRequestException(`Access to schema '${schema}' is not allowed`); - } - - if (table && DebugBlockedSchemas.some((s) => table.startsWith(s + '.'))) { - throw new BadRequestException(`Access to system tables is not allowed`); - } - - if (item.expr?.ast) { - this.checkForBlockedSchemas(item.expr.ast); - } - - this.checkSubqueriesForBlockedSchemas(item.on); - } - } - - if (stmt.columns) { - for (const col of stmt.columns) { - this.checkSubqueriesForBlockedSchemas(col.expr); - } - } - - this.checkSubqueriesForBlockedSchemas(stmt.where); - this.checkSubqueriesForBlockedSchemas(stmt.having); - - if (stmt.orderby) { - for (const item of stmt.orderby) { - this.checkSubqueriesForBlockedSchemas(item.expr); - } - } - - if (stmt.groupby?.columns) { - for (const item of stmt.groupby.columns) { - this.checkSubqueriesForBlockedSchemas(item); - } - } - - if (stmt.with) { - for (const cte of stmt.with) { - if (cte.stmt?.ast) { - this.checkForBlockedSchemas(cte.stmt.ast); - } - } - } - } - - private checkSubqueriesForBlockedSchemas(node: any): void { - if (!node) return; - - if (node.ast) { - this.checkForBlockedSchemas(node.ast); - } - - if (node.left) this.checkSubqueriesForBlockedSchemas(node.left); - if (node.right) this.checkSubqueriesForBlockedSchemas(node.right); - if (node.expr) this.checkSubqueriesForBlockedSchemas(node.expr); - - if (node.result) this.checkSubqueriesForBlockedSchemas(node.result); - if (node.condition) this.checkSubqueriesForBlockedSchemas(node.condition); - - if (node.args) { - const args = Array.isArray(node.args) ? node.args : node.args?.value || []; - for (const arg of Array.isArray(args) ? args : [args]) { - this.checkSubqueriesForBlockedSchemas(arg); - } - } - if (node.value && Array.isArray(node.value)) { - for (const val of node.value) { - this.checkSubqueriesForBlockedSchemas(val); - } - } - - if (node.over?.as_window_specification?.window_specification) { - const winSpec = node.over.as_window_specification.window_specification; - if (winSpec.orderby) { - for (const item of winSpec.orderby) { - this.checkSubqueriesForBlockedSchemas(item.expr); - } - } - if (winSpec.partitionby) { - for (const item of winSpec.partitionby) { - this.checkSubqueriesForBlockedSchemas(item); - } - } - } - } - - private checkForDangerousFunctionsRecursive(stmt: any): void { - if (!stmt) return; - - this.checkFromForDangerousFunctions(stmt.from); - this.checkExpressionsForDangerousFunctions(stmt.columns); - this.checkNodeForDangerousFunctions(stmt.where); - this.checkNodeForDangerousFunctions(stmt.having); - - if (stmt.orderby) { - for (const item of stmt.orderby) { - this.checkNodeForDangerousFunctions(item.expr); - } - } - - if (stmt.groupby?.columns) { - for (const item of stmt.groupby.columns) { - this.checkNodeForDangerousFunctions(item); - } - } - - if (stmt.with) { - for (const cte of stmt.with) { - if (cte.stmt?.ast) { - this.checkForDangerousFunctionsRecursive(cte.stmt.ast); - } - } - } - } - - private checkFromForDangerousFunctions(from: any[]): void { - if (!from) return; - - for (const item of from) { - if (item.type === 'expr' && item.expr?.type === 'function') { - const funcName = this.extractFunctionName(item.expr); - if (funcName && DebugDangerousFunctions.includes(funcName)) { - throw new BadRequestException(`Function '${funcName.toUpperCase()}' not allowed`); - } - } - - if (item.expr?.ast) { - this.checkForDangerousFunctionsRecursive(item.expr.ast); - } - - this.checkNodeForDangerousFunctions(item.on); - } - } - - private checkExpressionsForDangerousFunctions(columns: any[]): void { - if (!columns) return; - - for (const col of columns) { - this.checkNodeForDangerousFunctions(col.expr); - } - } - - private checkNodeForDangerousFunctions(node: any): void { - if (!node) return; - - if (node.type === 'function') { - const funcName = this.extractFunctionName(node); - if (funcName && DebugDangerousFunctions.includes(funcName)) { - throw new BadRequestException(`Function '${funcName.toUpperCase()}' not allowed`); - } - } - - if (node.ast) { - this.checkForDangerousFunctionsRecursive(node.ast); - } - - if (node.left) this.checkNodeForDangerousFunctions(node.left); - if (node.right) this.checkNodeForDangerousFunctions(node.right); - if (node.expr) this.checkNodeForDangerousFunctions(node.expr); - - if (node.result) this.checkNodeForDangerousFunctions(node.result); - if (node.condition) this.checkNodeForDangerousFunctions(node.condition); - - if (node.args) { - const args = Array.isArray(node.args) ? node.args : node.args?.value || []; - for (const arg of Array.isArray(args) ? args : [args]) { - this.checkNodeForDangerousFunctions(arg); - } - } - if (node.value && Array.isArray(node.value)) { - for (const val of node.value) { - this.checkNodeForDangerousFunctions(val); - } - } - - if (node.over?.as_window_specification?.window_specification) { - const winSpec = node.over.as_window_specification.window_specification; - if (winSpec.orderby) { - for (const item of winSpec.orderby) { - this.checkNodeForDangerousFunctions(item.expr); - } - } - if (winSpec.partitionby) { - for (const item of winSpec.partitionby) { - this.checkNodeForDangerousFunctions(item); - } - } - } - } - - private extractFunctionName(funcNode: any): string | null { - if (funcNode.name?.name?.[0]?.value) { - return funcNode.name.name[0].value.toLowerCase(); - } - if (typeof funcNode.name === 'string') { - return funcNode.name.toLowerCase(); - } - return null; - } - - private checkForXmlJsonRecursive(stmt: any): void { - if (!stmt) return; - - const forType = stmt.for?.type?.toLowerCase(); - if (forType?.includes('xml') || forType?.includes('json')) { - throw new BadRequestException('FOR XML/JSON not allowed'); - } - - if (stmt.columns) { - for (const col of stmt.columns) { - this.checkNodeForXmlJson(col.expr); - } - } - - if (stmt.from) { - for (const item of stmt.from) { - if (item.expr?.ast) { - this.checkForXmlJsonRecursive(item.expr.ast); - } - this.checkNodeForXmlJson(item.on); - } - } - - this.checkNodeForXmlJson(stmt.where); - this.checkNodeForXmlJson(stmt.having); - - if (stmt.orderby) { - for (const item of stmt.orderby) { - this.checkNodeForXmlJson(item.expr); - } - } - - if (stmt.groupby?.columns) { - for (const item of stmt.groupby.columns) { - this.checkNodeForXmlJson(item); - } - } - - if (stmt.with) { - for (const cte of stmt.with) { - if (cte.stmt?.ast) { - this.checkForXmlJsonRecursive(cte.stmt.ast); - } - } - } - } - - private checkNodeForXmlJson(node: any): void { - if (!node) return; - - if (node.ast) { - this.checkForXmlJsonRecursive(node.ast); - } - - if (node.left) this.checkNodeForXmlJson(node.left); - if (node.right) this.checkNodeForXmlJson(node.right); - if (node.expr) this.checkNodeForXmlJson(node.expr); - - if (node.result) this.checkNodeForXmlJson(node.result); - if (node.condition) this.checkNodeForXmlJson(node.condition); - - if (node.args) { - const args = Array.isArray(node.args) ? node.args : node.args?.value || []; - for (const arg of Array.isArray(args) ? args : [args]) { - this.checkNodeForXmlJson(arg); - } - } - if (node.value && Array.isArray(node.value)) { - for (const val of node.value) { - this.checkNodeForXmlJson(val); - } - } - - if (node.over?.as_window_specification?.window_specification) { - const winSpec = node.over.as_window_specification.window_specification; - if (winSpec.orderby) { - for (const item of winSpec.orderby) { - this.checkNodeForXmlJson(item.expr); - } - } - if (winSpec.partitionby) { - for (const item of winSpec.partitionby) { - this.checkNodeForXmlJson(item); - } - } - } - } - - private maskDebugBlockedColumns(data: Record[], tables: string[]): void { - if (!data?.length || !tables?.length) return; - - const blockedColumns = new Set(); - for (const table of tables) { - const tableCols = DebugBlockedCols[table.toLowerCase()]; - if (tableCols) { - for (const col of tableCols) { - blockedColumns.add(col.toLowerCase()); - } - } - } - - if (blockedColumns.size === 0) return; - - for (const entry of data) { - for (const key of Object.keys(entry)) { - if (this.shouldMaskDebugColumn(key, blockedColumns)) { - entry[key] = entry[key] == null ? '[RESTRICTED:NULL]' : '[RESTRICTED:SET]'; - } - } - } - } - - private shouldMaskDebugColumn(columnName: string, blockedColumns: Set): boolean { - const lower = columnName.toLowerCase(); - - for (const blocked of blockedColumns) { - if (lower === blocked || lower.endsWith('_' + blocked)) { - return true; - } - } - return false; - } - - private ensureResultLimit(sql: string): string { - const normalized = sql.trim().toLowerCase(); - - if (normalized.includes(' top ') || normalized.includes(' limit ')) { - return sql; - } - - const hasOrderBy = /order\s+by/i.test(normalized); - const orderByClause = hasOrderBy ? '' : ' ORDER BY (SELECT NULL)'; - - let trimmed = sql.trim(); - while (trimmed.endsWith(';')) trimmed = trimmed.slice(0, -1); - - return `${trimmed}${orderByClause} OFFSET 0 ROWS FETCH NEXT ${DebugMaxResults} ROWS ONLY`; - } - - // --- BOLTZ QUERY HELPER METHODS --- // - - private getBoltzTablesFromQuery(sql: string): string[] { - const tableList = this.sqlParser.tableList(sql, { database: 'PostgreSQL' }); - return tableList.map((t) => t.split('::')[2]).filter(Boolean); - } - - private getBoltzAliasToTableMap(ast: any): Map { - const map = new Map(); - if (!ast.from) return map; - - for (const item of ast.from) { - if (item.table) { - map.set(item.as || item.table, item.table); - } - } - return map; - } - - private resolveBoltzTableFromAlias( - tableOrAlias: string, - tables: string[], - aliasMap: Map, - ): string | null { - if (tableOrAlias === 'null') { - return tables.length === 1 ? tables[0] : null; - } - return aliasMap.get(tableOrAlias) || tableOrAlias; - } - - private isBoltzColumnBlockedInTable(columnName: string, table: string | null, allTables: string[]): boolean { - const lower = columnName.toLowerCase(); - - if (table) { - const blockedCols = BoltzBlockedCols[table.toLowerCase()]; - return blockedCols?.some((b) => b.toLowerCase() === lower) ?? false; - } else { - return allTables.some((t) => { - const blockedCols = BoltzBlockedCols[t.toLowerCase()]; - return blockedCols?.some((b) => b.toLowerCase() === lower) ?? false; - }); - } - } - - private findBoltzBlockedColumnInQuery(sql: string, ast: any, tables: string[]): string | null { - try { - const columns = this.sqlParser.columnList(sql, { database: 'PostgreSQL' }); - const aliasMap = this.getBoltzAliasToTableMap(ast); - - for (const col of columns) { - const parts = col.split('::'); - const tableOrAlias = parts[1]; - const columnName = parts[2]; - - if (columnName === '*' || columnName === '(.*)') continue; - - const resolvedTable = this.resolveBoltzTableFromAlias(tableOrAlias, tables, aliasMap); - - if (this.isBoltzColumnBlockedInTable(columnName, resolvedTable, tables)) { - return `${resolvedTable || 'unknown'}.${columnName}`; - } - } - - return null; - } catch { - return null; - } - } - - private checkForBoltzBlockedSchemas(stmt: any): void { - if (!stmt) return; - - if (stmt.from) { - for (const item of stmt.from) { - const schema = item.db?.toLowerCase() || item.schema?.toLowerCase(); - const table = item.table?.toLowerCase(); - - if (schema && BoltzBlockedSchemas.includes(schema)) { - throw new BadRequestException(`Access to schema '${schema}' is not allowed`); - } - - if (table && BoltzBlockedSchemas.some((s) => table.startsWith(s + '.'))) { - throw new BadRequestException(`Access to system tables is not allowed`); - } - - if (item.expr?.ast) { - this.checkForBoltzBlockedSchemas(item.expr.ast); - } - - this.checkBoltzSubqueriesForBlockedSchemas(item.on); - } - } - - if (stmt.columns) { - for (const col of stmt.columns) { - this.checkBoltzSubqueriesForBlockedSchemas(col.expr); - } - } - - this.checkBoltzSubqueriesForBlockedSchemas(stmt.where); - this.checkBoltzSubqueriesForBlockedSchemas(stmt.having); - - if (stmt.with) { - for (const cte of stmt.with) { - if (cte.stmt?.ast) { - this.checkForBoltzBlockedSchemas(cte.stmt.ast); - } - } - } - } - - private checkBoltzSubqueriesForBlockedSchemas(node: any): void { - if (!node) return; - - if (node.ast) { - this.checkForBoltzBlockedSchemas(node.ast); - } - - if (node.left) this.checkBoltzSubqueriesForBlockedSchemas(node.left); - if (node.right) this.checkBoltzSubqueriesForBlockedSchemas(node.right); - if (node.expr) this.checkBoltzSubqueriesForBlockedSchemas(node.expr); - - if (node.args) { - const args = Array.isArray(node.args) ? node.args : node.args?.value || []; - for (const arg of Array.isArray(args) ? args : [args]) { - this.checkBoltzSubqueriesForBlockedSchemas(arg); - } - } - } - - private checkForBoltzDangerousFunctionsRecursive(stmt: any): void { - if (!stmt) return; - - this.checkBoltzFromForDangerousFunctions(stmt.from); - this.checkBoltzExpressionsForDangerousFunctions(stmt.columns); - this.checkBoltzNodeForDangerousFunctions(stmt.where); - this.checkBoltzNodeForDangerousFunctions(stmt.having); - - if (stmt.with) { - for (const cte of stmt.with) { - if (cte.stmt?.ast) { - this.checkForBoltzDangerousFunctionsRecursive(cte.stmt.ast); - } - } - } - } - - private checkBoltzFromForDangerousFunctions(from: any[]): void { - if (!from) return; - - for (const item of from) { - if (item.type === 'expr' && item.expr?.type === 'function') { - const funcName = this.extractFunctionName(item.expr); - if (funcName && BoltzDangerousFunctions.includes(funcName)) { - throw new BadRequestException(`Function '${funcName.toUpperCase()}' not allowed`); - } - } - - if (item.expr?.ast) { - this.checkForBoltzDangerousFunctionsRecursive(item.expr.ast); - } - - this.checkBoltzNodeForDangerousFunctions(item.on); - } - } - - private checkBoltzExpressionsForDangerousFunctions(columns: any[]): void { - if (!columns) return; - - for (const col of columns) { - this.checkBoltzNodeForDangerousFunctions(col.expr); - } - } - - private checkBoltzNodeForDangerousFunctions(node: any): void { - if (!node) return; - - if (node.type === 'function') { - const funcName = this.extractFunctionName(node); - if (funcName && BoltzDangerousFunctions.includes(funcName)) { - throw new BadRequestException(`Function '${funcName.toUpperCase()}' not allowed`); - } - } - - if (node.ast) { - this.checkForBoltzDangerousFunctionsRecursive(node.ast); - } - - if (node.left) this.checkBoltzNodeForDangerousFunctions(node.left); - if (node.right) this.checkBoltzNodeForDangerousFunctions(node.right); - if (node.expr) this.checkBoltzNodeForDangerousFunctions(node.expr); - - if (node.args) { - const args = Array.isArray(node.args) ? node.args : node.args?.value || []; - for (const arg of Array.isArray(args) ? args : [args]) { - this.checkBoltzNodeForDangerousFunctions(arg); - } - } - } - - private maskBoltzBlockedColumns(data: Record[], tables: string[]): void { - if (!data?.length || !tables?.length) return; - - const blockedColumns = new Set(); - for (const table of tables) { - const tableCols = BoltzBlockedCols[table.toLowerCase()]; - if (tableCols) { - for (const col of tableCols) { - blockedColumns.add(col.toLowerCase()); - } - } - } - - if (blockedColumns.size === 0) return; - - for (const entry of data) { - for (const key of Object.keys(entry)) { - if (this.shouldMaskBoltzColumn(key, blockedColumns)) { - entry[key] = entry[key] == null ? '[RESTRICTED:NULL]' : '[RESTRICTED:SET]'; - } - } - } - } - - private shouldMaskBoltzColumn(columnName: string, blockedColumns: Set): boolean { - const lower = columnName.toLowerCase(); - - for (const blocked of blockedColumns) { - if (lower === blocked || lower.endsWith('_' + blocked)) { - return true; - } - } - return false; - } - - private ensureBoltzResultLimit(sql: string): string { - const normalized = sql.trim().toLowerCase(); - - if (normalized.includes(' limit ')) { - return sql; - } - - let trimmed = sql.trim(); - while (trimmed.endsWith(';')) trimmed = trimmed.slice(0, -1); - - return `${trimmed} LIMIT ${BoltzMaxResults}`; - } } From 7ef65925426b1271d7bd349413d41da12854dcdb Mon Sep 17 00:00:00 2001 From: bernd2022 <104787072+bernd2022@users.noreply.github.com> Date: Sat, 24 Jan 2026 14:23:09 +0100 Subject: [PATCH 21/22] Add Azure App Insights env vars to .env.example (#102) Closes #101 --- .env.example | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index db9f2dbe..fb0d98a9 100644 --- a/.env.example +++ b/.env.example @@ -13,4 +13,8 @@ DISABLED_PROCESSES= # Debug endpoint (scripts/db-debug.sh, scripts/log-debug.sh) DEBUG_ADDRESS= DEBUG_SIGNATURE= -DEBUG_API_URL= \ No newline at end of file +DEBUG_API_URL= + +# Azure Application Insights (for log-debug.sh) +AZURE_APP_INSIGHTS_APP_ID= +AZURE_APP_INSIGHTS_API_KEY= From 01b8eb7acb24f7bac9c7be329cc57b259bb2ca47 Mon Sep 17 00:00:00 2001 From: bernd2022 <104787072+bernd2022@users.noreply.github.com> Date: Sun, 25 Jan 2026 13:28:20 +0100 Subject: [PATCH 22/22] Add WBTC_ETH/WBTCe_CITREA swap pair and update JUSD testnet contract (#103) * Add WBTC token and WBTC/cBTC swap pair - Add WBTC token to Ethereum configuration (both DEV and PRD) - Add WBTC/cBTC swap pair for Ethereum WBTC <-> Citrea cBTC swaps - WBTC contract: 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599 * Fix WBTC swap pair: use WBTCe_CITREA instead of cBTC - Change swap pair from WBTC/cBTC to WBTC/WBTCe_CITREA (PRD only) - Add WBTCe_CITREA token on Citrea with contract 0xDF240DC08B0FdaD1d93b74d5048871232f6BEA3d - Remove WBTC configuration from dev-boltz.conf (PRD only feature) * Rename WBTC to WBTC_ETH for naming consistency * Update JUSD_CITREA contract address for testnet --------- Co-authored-by: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> --- .../config/boltz/backend/dev-boltz.conf | 2 +- .../config/boltz/backend/prd-boltz.conf | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/infrastructure/config/boltz/backend/dev-boltz.conf b/infrastructure/config/boltz/backend/dev-boltz.conf index 4c3d8d6b..b4d451ca 100644 --- a/infrastructure/config/boltz/backend/dev-boltz.conf +++ b/infrastructure/config/boltz/backend/dev-boltz.conf @@ -237,6 +237,6 @@ providerEndpoint = "https://dev.rpc.testnet.juiceswap.com" [[citrea.tokens]] symbol = "JUSD_CITREA" decimals = 18 - contractAddress = "0xFdB0a83d94CD65151148a131167Eb499Cb85d015" + contractAddress = "0x6a850a548fdd050e8961223ec8FfCDfacEa57E39" minWalletBalance = 1_000_000_000_000_000_000 # 1 JUSD_CITREA diff --git a/infrastructure/config/boltz/backend/prd-boltz.conf b/infrastructure/config/boltz/backend/prd-boltz.conf index f087cbed..25e4fe73 100644 --- a/infrastructure/config/boltz/backend/prd-boltz.conf +++ b/infrastructure/config/boltz/backend/prd-boltz.conf @@ -129,6 +129,24 @@ minSwapAmount = 2_500 # 2,500 sats minimum (Citrea Testnet has low fees) swapMaximal = 2880 # Maximum timeout (~16.7 hours) - allows 100 Bitcoin blocks CLTV swapTaproot = 10080 # Taproot timeout (~16.7 hours) +# Swap Pair Configuration: WBTC_ETH/WBTCe_CITREA (Ethereum WBTC <-> Citrea WBTC.e) +[[pairs]] +base = "WBTC_ETH" +quote = "WBTCe_CITREA" +rate = 1 +fee = 0 +swapInFee = 0 + +maxSwapAmount = 10_000_000 # 0.1 WBTC_ETH/WBTC.e +minSwapAmount = 2_500 # 2,500 sats minimum + + [pairs.timeoutDelta] + chain = 1440 # Chain swap timeout (~24 hours) + reverse = 1440 # Reverse swap timeout + swapMinimal = 1440 # Minimum timeout + swapMaximal = 2880 # Maximum timeout + swapTaproot = 10080 # Taproot timeout + [[pairs]] base = "USDT_ETH" quote = "JUSD_CITREA" @@ -204,6 +222,13 @@ providerEndpoint = "[PROVIDER_ENDPOINT]" minWalletBalance = 1_000_000 # 1 USDC_ETH + [[ethereum.tokens]] + symbol = "WBTC_ETH" + decimals = 8 + contractAddress = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599" + + minWalletBalance = 100_000 # 0.001 WBTC_ETH + # POL (Polygon) Configuration [polygon] networkName = "Polygon Mainnet" @@ -240,3 +265,10 @@ providerEndpoint = "[CITREA_MAINNET_RPC]" contractAddress = "[CITREA_MAINNET_JUSD]" minWalletBalance = 1_000_000_000_000_000_000 # 1 JUSD_CITREA + + [[citrea.tokens]] + symbol = "WBTCe_CITREA" + decimals = 8 + contractAddress = "0xDF240DC08B0FdaD1d93b74d5048871232f6BEA3d" + + minWalletBalance = 100_000 # 0.001 WBTC.e