From b65d3909468d08a6c8876b01f605582f146b8f29 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 15 May 2026 20:39:08 +0200 Subject: [PATCH 1/3] Remove daily CoinGecko quota probe (now owned by pricing-proxy) (#36) The pricing-proxy ships with its own quota monitor (DFXswiss/pricing-proxy PR #7 + #9): it probes /api/v3/key every 30 min and alerts via the dedicated @dfx_pricing_proxy_bot at 80 % (warning) and 95 % (critical) of the monthly credit, with a recovery message on healthy. That makes this service's daily probe redundant. Drop the cron, its state field and its two constants; the unused CoingeckoKeyInfo interface goes with them. The BTC-staleness watchdog and the cache plumbing are untouched. Mirror of d-EURO/monitoring#68. Update the README to drop the 'daily Pro quota probe' clause from the CoinGecko section. --- README.md | 4 +-- src/monitoringV2/price.service.ts | 45 ------------------------------- 2 files changed, 2 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index ad7cdba..11738cb 100644 --- a/README.md +++ b/README.md @@ -91,8 +91,8 @@ Swagger documentation available at: `http://localhost:3001/swagger` ## CoinGecko The monitoring service needs a CoinGecko-compatible endpoint for BTC spot -prices (drives the WCBTC suspicious-liq-price watchdog) and the daily Pro -quota probe. Configuration is two env vars: +prices — they drive the WCBTC suspicious-liq-price watchdog. Configuration +is two env vars: | Var | Required | Purpose | |---|---|---| diff --git a/src/monitoringV2/price.service.ts b/src/monitoringV2/price.service.ts index 63fd721..b4963a0 100644 --- a/src/monitoringV2/price.service.ts +++ b/src/monitoringV2/price.service.ts @@ -29,17 +29,8 @@ interface CoingeckoEndpoint { headers: Record; } -interface CoingeckoKeyInfo { - plan?: string; - monthly_call_credit?: number; - current_total_monthly_calls?: number; - current_remaining_monthly_calls?: number; -} - const STALENESS_ALERT_THRESHOLD_MS = 60 * 60 * 1000; const STALENESS_ALERT_REPEAT_MS = 6 * 60 * 60 * 1000; -const QUOTA_REMAINING_ALERT_THRESHOLD = 25_000; -const QUOTA_ALERT_REPEAT_MS = 24 * 60 * 60 * 1000; @Injectable() export class PriceService { @@ -53,7 +44,6 @@ export class PriceService { // the alert indefinitely. private btcLastSuccessMs: number = Date.now(); private btcStalenessAlertedAt: number | null = null; - private quotaAlertedAt: number | null = null; constructor( private readonly providerService: ProviderService, @@ -186,41 +176,6 @@ export class PriceService { ); } - /** - * Daily probe of /api/v3/key through the pricing proxy. Emits a critical - * alert when the monthly remaining call credit drops below - * QUOTA_REMAINING_ALERT_THRESHOLD. - */ - @Cron(CronExpression.EVERY_DAY_AT_NOON) - async checkCoingeckoQuota(): Promise { - try { - const { baseUrl, headers } = this.resolveCoingeckoEndpoint(); - const response = await axios.get(`${baseUrl}/api/v3/key`, { - headers, - timeout: 10000, - }); - const { current_remaining_monthly_calls: remaining, monthly_call_credit: credit } = response.data; - if (typeof remaining !== 'number' || typeof credit !== 'number' || credit <= 0) return; - - const pct = Math.round((remaining / credit) * 100); - this.logger.log(`CoinGecko quota: ${remaining} of ${credit} calls remaining (${pct}%)`); - - if (remaining >= QUOTA_REMAINING_ALERT_THRESHOLD) { - this.quotaAlertedAt = null; - return; - } - if (this.quotaAlertedAt && Date.now() - this.quotaAlertedAt < QUOTA_ALERT_REPEAT_MS) return; - - this.quotaAlertedAt = Date.now(); - await this.telegramService.sendCriticalAlert( - `CoinGecko monthly quota almost exhausted: ${remaining.toLocaleString()} of ` + - `${credit.toLocaleString()} calls remaining (${pct}%).` - ); - } catch (error) { - this.logger.warn(`CoinGecko quota probe failed: ${error.message ?? error}`); - } - } - private async getGeckoTerminalPricesInUSD(addresses: string[]): Promise<{ [key: string]: string }> { if (addresses.length === 0) return {}; From d7aac01e8d39cabcadae737aa26427c503c752ee Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 16 May 2026 15:07:26 +0200 Subject: [PATCH 2/3] feat(monitoringV2): add minter-guard watcher to auto-deny unwhitelisted minters (#33) A new MinterGuardService runs after syncMinters() each cycle. For any PROPOSED minter that is not on a committed whitelist (initially empty), it submits denyMinter() within the application period using a signer fetched from Vaultwarden at container start. Bridge proposals are not exempted because the bridge type is inferred from a trivial usd() check. The signing key is loaded by entrypoint.sh via bw CLI using the vault-password file and a per-container copy of the bw appdata dir mounted from the host. The key only ever lives in container memory. Whitelisted exceptions are added via PR + redeploy. GUARD_ENABLED=false disables the watcher entirely without removing the service from the module graph. --- Dockerfile | 10 +- entrypoint.sh | 62 +++++++++ src/config/config.service.ts | 18 ++- src/config/monitoring.config.ts | 21 +++ .../config/whitelist.mainnet.json | 4 + .../config/whitelist.testnet.json | 4 + src/monitoringV2/minter-guard.service.ts | 131 ++++++++++++++++++ src/monitoringV2/monitoring.module.ts | 2 + src/monitoringV2/monitoring.service.ts | 5 + 9 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 entrypoint.sh create mode 100644 src/monitoringV2/config/whitelist.mainnet.json create mode 100644 src/monitoringV2/config/whitelist.testnet.json create mode 100644 src/monitoringV2/minter-guard.service.ts diff --git a/Dockerfile b/Dockerfile index 86ba26e..66ec1cd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,9 @@ FROM node:lts-alpine +# bw CLI is used by entrypoint.sh to fetch GUARD_PRIVATE_KEY from Vaultwarden at container start. +# Installed as root before switching user so the global npm prefix is writable. +RUN apk add --no-cache bash && npm install -g @bitwarden/cli@2024.9.0 + RUN mkdir /app && chown -R node:node /app WORKDIR /app USER node @@ -13,7 +17,11 @@ COPY --chown=node . . RUN npm run prisma:generate RUN npm run build +# Entrypoint optionally fetches GUARD_PRIVATE_KEY from Vaultwarden, then execs npm. +COPY --chown=node --chmod=0755 entrypoint.sh /app/entrypoint.sh + # Expose port EXPOSE 3001 -CMD ["npm", "run", "start:migrate"] \ No newline at end of file +ENTRYPOINT ["/app/entrypoint.sh"] +CMD ["npm", "run", "start:migrate"] diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..7ed051d --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,62 @@ +#!/bin/sh +# entrypoint.sh — Optionally fetches GUARD_PRIVATE_KEY from Vaultwarden at container +# start (not deploy time), so the key only ever lives in container memory. +# +# Activation is opt-in: set VAULT_FETCH_GUARD_KEY=true and provide: +# VAULT_ITEM_NAME Name of the vault item holding the key +# VAULT_PASSWORD_FILE Path to file with the master password +# VAULT_SERVER_URL Bitwarden server URL (default: https://dfxvault.com) +# VAULT_FIELD Item field to read (default: GUARD_PRIVATE_KEY) +# +# When GUARD_ENABLED=true and VAULT_FETCH_GUARD_KEY=true the entrypoint fails fast +# if the vault is unreachable or the item is missing — the service must not silently +# start without a signing key. + +set -e + +if [ "${VAULT_FETCH_GUARD_KEY:-false}" = "true" ]; then + : "${VAULT_ITEM_NAME:?VAULT_ITEM_NAME is required when VAULT_FETCH_GUARD_KEY=true}" + : "${VAULT_PASSWORD_FILE:?VAULT_PASSWORD_FILE is required when VAULT_FETCH_GUARD_KEY=true}" + : "${VAULT_BW_DATA_DIR:?VAULT_BW_DATA_DIR is required when VAULT_FETCH_GUARD_KEY=true (mounted from host)}" + + VAULT_SERVER_URL="${VAULT_SERVER_URL:-https://dfxvault.com}" + VAULT_FIELD="${VAULT_FIELD:-GUARD_PRIVATE_KEY}" + + if [ ! -r "$VAULT_PASSWORD_FILE" ]; then + echo "entrypoint: vault password file $VAULT_PASSWORD_FILE not readable" >&2 + exit 1 + fi + + # Copy bw data to a writable per-container location so the host's bw state stays untouched + # even if `bw sync` updates the local cache. Without this, two containers sharing the same + # mount would race on data.json writes. + BW_RUNTIME_DIR="$(mktemp -d /tmp/bw-runtime.XXXXXX)" + cp -r "$VAULT_BW_DATA_DIR"/* "$BW_RUNTIME_DIR"/ 2>/dev/null || true + export BITWARDENCLI_APPDATA_DIR="$BW_RUNTIME_DIR" + + echo "entrypoint: configuring bw server $VAULT_SERVER_URL" + bw config server "$VAULT_SERVER_URL" > /dev/null + + echo "entrypoint: unlocking vault" + BW_PASSWORD="$(cat "$VAULT_PASSWORD_FILE")" + export BW_PASSWORD + BW_SESSION="$(bw unlock --passwordenv BW_PASSWORD --raw)" + export BW_SESSION + unset BW_PASSWORD + + echo "entrypoint: syncing vault" + bw sync --session "$BW_SESSION" > /dev/null + + echo "entrypoint: fetching $VAULT_FIELD from item '$VAULT_ITEM_NAME'" + GUARD_PRIVATE_KEY="$(bw get item "$VAULT_ITEM_NAME" --session "$BW_SESSION" \ + | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const i=JSON.parse(s);const f=(i.fields||[]).find(x=>x.name===process.argv[1]);if(!f){process.stderr.write("field not found: "+process.argv[1]+"\n");process.exit(2);}process.stdout.write(f.value);}' "$VAULT_FIELD")" + export GUARD_PRIVATE_KEY + + bw lock --session "$BW_SESSION" > /dev/null || true + rm -rf "$BW_RUNTIME_DIR" + unset BW_SESSION BITWARDENCLI_APPDATA_DIR + + echo "entrypoint: GUARD_PRIVATE_KEY loaded into env" +fi + +exec "$@" diff --git a/src/config/config.service.ts b/src/config/config.service.ts index 80c8759..470ed5f 100644 --- a/src/config/config.service.ts +++ b/src/config/config.service.ts @@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { MonitoringConfig } from './monitoring.config'; -const SENSITIVE_KEYS = new Set(['rpcUrl', 'databaseUrl', 'telegramBotToken', 'coingeckoApiKey']); +const SENSITIVE_KEYS = new Set(['rpcUrl', 'databaseUrl', 'telegramBotToken', 'coingeckoApiKey', 'guardPrivateKey']); function redactConfig(config: T): T { return walkRedact(config, '') as T; @@ -103,4 +103,20 @@ export class AppConfigService { get chain(): string | undefined { return this.monitoringConfig.chain; } + + get guardEnabled(): boolean { + return this.monitoringConfig.guardEnabled || false; + } + + get guardPrivateKey(): string | undefined { + return this.monitoringConfig.guardPrivateKey; + } + + get guardHelperAddress(): string | undefined { + return this.monitoringConfig.guardHelperAddress; + } + + get guardWhitelistFile(): string | undefined { + return this.monitoringConfig.guardWhitelistFile; + } } diff --git a/src/config/monitoring.config.ts b/src/config/monitoring.config.ts index 5cc0a97..b90f22d 100644 --- a/src/config/monitoring.config.ts +++ b/src/config/monitoring.config.ts @@ -86,6 +86,22 @@ export class MonitoringConfig { @IsOptional() @IsString() chain?: string; + + @IsOptional() + @IsBoolean() + guardEnabled?: boolean; + + @IsOptional() + @IsString() + guardPrivateKey?: string; + + @IsOptional() + @IsString() + guardHelperAddress?: string; + + @IsOptional() + @IsString() + guardWhitelistFile?: string; } export default registerAs('monitoring', () => { @@ -113,6 +129,11 @@ export default registerAs('monitoring', () => { config.environment = process.env.ENVIRONMENT?.toLowerCase(); config.chain = process.env.CHAIN; + config.guardEnabled = (process.env.GUARD_ENABLED || 'false').toLowerCase() === 'true'; + config.guardPrivateKey = process.env.GUARD_PRIVATE_KEY || ''; + config.guardHelperAddress = process.env.GUARD_HELPER_ADDRESS || ''; + config.guardWhitelistFile = process.env.GUARD_WHITELIST_FILE || ''; + const errors = validateSync(plainToClass(MonitoringConfig, config)); if (errors.length > 0) throw new Error(`Config validation failed: ${errors}`); diff --git a/src/monitoringV2/config/whitelist.mainnet.json b/src/monitoringV2/config/whitelist.mainnet.json new file mode 100644 index 0000000..98bc0c4 --- /dev/null +++ b/src/monitoringV2/config/whitelist.mainnet.json @@ -0,0 +1,4 @@ +{ + "_comment": "Whitelist of approved generic minters (lowercase addresses). Empty = deny any new minter proposal. Bridges are never auto-denied (different type). Update via PR and redeploy.", + "minters": [] +} diff --git a/src/monitoringV2/config/whitelist.testnet.json b/src/monitoringV2/config/whitelist.testnet.json new file mode 100644 index 0000000..98bc0c4 --- /dev/null +++ b/src/monitoringV2/config/whitelist.testnet.json @@ -0,0 +1,4 @@ +{ + "_comment": "Whitelist of approved generic minters (lowercase addresses). Empty = deny any new minter proposal. Bridges are never auto-denied (different type). Update via PR and redeploy.", + "minters": [] +} diff --git a/src/monitoringV2/minter-guard.service.ts b/src/monitoringV2/minter-guard.service.ts new file mode 100644 index 0000000..8a3c2f5 --- /dev/null +++ b/src/monitoringV2/minter-guard.service.ts @@ -0,0 +1,131 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ethers } from 'ethers'; +import * as fs from 'fs'; +import { JuiceDollarABI, ADDRESS } from '@juicedollar/jusd'; +import { AppConfigService } from '../config/config.service'; +import { ProviderService } from './provider.service'; +import { MinterRepository } from './prisma/repositories/minter.repository'; +import { TelegramService } from './telegram.service'; +import { MinterStatus } from './types'; + +interface Whitelist { + minters: string[]; +} + +/** + * Watches for newly proposed minters and automatically denies any that are not + * in the configured whitelist. The deny window is the application period + * specified in the suggestMinter call (typically days), so an hourly cadence is + * sufficient. A bricked or wrong-network signer is a startup error and exits. + */ +@Injectable() +export class MinterGuardService { + private readonly logger = new Logger(MinterGuardService.name); + + private enabled = false; + private wallet?: ethers.Wallet; + private juiceDollar?: ethers.Contract; + private whitelist = new Set(); + private helperAddress?: string; + private alreadyDenied = new Set(); + + constructor( + private readonly config: AppConfigService, + private readonly providerService: ProviderService, + private readonly minterRepo: MinterRepository, + private readonly telegramService: TelegramService + ) {} + + async initialize(): Promise { + if (!this.config.guardEnabled) { + this.logger.log('MinterGuard is DISABLED (GUARD_ENABLED != true)'); + return; + } + + const pk = this.config.guardPrivateKey; + const helper = this.config.guardHelperAddress; + const whitelistFile = this.config.guardWhitelistFile; + + if (!pk) throw new Error('GUARD_ENABLED=true but GUARD_PRIVATE_KEY is missing'); + if (!helper) throw new Error('GUARD_ENABLED=true but GUARD_HELPER_ADDRESS is missing'); + if (!whitelistFile) throw new Error('GUARD_ENABLED=true but GUARD_WHITELIST_FILE is missing'); + + this.wallet = new ethers.Wallet(pk, this.providerService.provider); + this.helperAddress = ethers.getAddress(helper); + + const jusdAddress = ADDRESS[this.config.blockchainId]?.juiceDollar; + if (!jusdAddress) throw new Error(`No JUSD address configured for chain ${this.config.blockchainId}`); + this.juiceDollar = new ethers.Contract(jusdAddress, JuiceDollarABI, this.wallet); + + this.loadWhitelist(whitelistFile); + this.enabled = true; + + this.logger.log( + `MinterGuard ENABLED: signer=${this.wallet.address}, helper=${this.helperAddress}, ` + + `whitelist=${this.whitelist.size} entries, jusd=${jusdAddress}` + ); + } + + private loadWhitelist(path: string): void { + try { + const raw = fs.readFileSync(path, 'utf8'); + const parsed = JSON.parse(raw) as Whitelist; + if (!Array.isArray(parsed.minters)) throw new Error('whitelist.minters must be an array'); + this.whitelist = new Set(parsed.minters.map((a) => a.toLowerCase())); + this.logger.log(`Loaded whitelist with ${this.whitelist.size} entries from ${path}`); + } catch (error) { + throw new Error(`Failed to load whitelist from ${path}: ${error.message}`); + } + } + + /** + * Called by MonitoringService after syncMinters(). Iterates PROPOSED minters + * and denies any not on the whitelist that haven't been denied yet. + */ + async checkAndDeny(): Promise { + if (!this.enabled || !this.juiceDollar || !this.wallet || !this.helperAddress) return; + + const minters = await this.minterRepo.findAll(); + // Denies BRIDGE-typed proposals too: bridge type is inferred from a single + // `usd()` view call, which is trivial to mimic in a malicious contract. + // Legitimate new bridges must be added to the whitelist before proposal. + const candidates = minters.filter( + (m) => + m.status === MinterStatus.PROPOSED && + !this.whitelist.has(m.address.toLowerCase()) && + !this.alreadyDenied.has(m.address.toLowerCase()) + ); + + if (candidates.length === 0) return; + + this.logger.warn(`Found ${candidates.length} unwhitelisted PROPOSED minter(s) to deny`); + + for (const minter of candidates) { + const address = ethers.getAddress(minter.address); + try { + const message = `Auto-deny by minter-guard: not in whitelist (${this.config.environment ?? 'unknown'}/${this.config.chain ?? 'unknown'})`; + const tx = await this.juiceDollar.denyMinter(address, [this.helperAddress], message); + this.logger.warn(`Submitted denyMinter for ${address}: tx=${tx.hash}`); + const receipt = await tx.wait(); + this.alreadyDenied.add(address.toLowerCase()); + this.logger.warn(`denyMinter confirmed for ${address}: block=${receipt.blockNumber}`); + await this.telegramService.sendCriticalAlert( + `🛡️ *Minter auto-denied*\n\n` + + `Address: \`${address}\`\n` + + `Tx: \`${tx.hash}\`\n` + + `Block: ${receipt.blockNumber}\n` + + `Message: ${message}` + ); + } catch (error) { + const errorMsg = typeof error?.message === 'string' && error.message ? error.message : String(error); + this.logger.error(`Failed to deny minter ${address}: ${errorMsg}`, error?.stack || error); + await this.telegramService.sendCriticalAlert( + `⚠️ *Minter auto-deny FAILED*\n\n` + + `Address: \`${address}\`\n` + + `Error: ${errorMsg}\n\n` + + `Manual denyMinter() required before application period expires.` + ); + } + } + } +} diff --git a/src/monitoringV2/monitoring.module.ts b/src/monitoringV2/monitoring.module.ts index e98d392..8767497 100644 --- a/src/monitoringV2/monitoring.module.ts +++ b/src/monitoringV2/monitoring.module.ts @@ -18,6 +18,7 @@ import { PositionService } from './position.service'; import { ChallengeService } from './challenge.service'; import { CollateralService } from './collateral.service'; import { MinterService } from './minter.service'; +import { MinterGuardService } from './minter-guard.service'; import { JusdService } from './jusd.service'; import { MonitoringService } from './monitoring.service'; import { PriceService } from './price.service'; @@ -47,6 +48,7 @@ import { ApiModule } from './api/api.module'; ChallengeService, CollateralService, MinterService, + MinterGuardService, JusdService, TelegramService, MonitoringService, diff --git a/src/monitoringV2/monitoring.service.ts b/src/monitoringV2/monitoring.service.ts index f2bd2d7..943f55b 100644 --- a/src/monitoringV2/monitoring.service.ts +++ b/src/monitoringV2/monitoring.service.ts @@ -10,6 +10,7 @@ import { PositionService } from './position.service'; import { ChallengeService } from './challenge.service'; import { CollateralService } from './collateral.service'; import { MinterService } from './minter.service'; +import { MinterGuardService } from './minter-guard.service'; import { JusdService } from './jusd.service'; import { TelegramService } from './telegram.service'; @@ -31,6 +32,7 @@ export class MonitoringService implements OnModuleInit { private readonly challengeService: ChallengeService, private readonly collateralService: CollateralService, private readonly minterService: MinterService, + private readonly minterGuardService: MinterGuardService, private readonly jusdService: JusdService, private readonly telegramService: TelegramService ) {} @@ -42,6 +44,7 @@ export class MonitoringService implements OnModuleInit { await this.positionService.initialize(); await this.challengeService.initialize(); await this.minterService.initialize(); + await this.minterGuardService.initialize(); await this.jusdService.initialize(); setTimeout(() => this.runMonitoring(), 5000); } @@ -135,6 +138,8 @@ export class MonitoringService implements OnModuleInit { await this.collateralService.syncCollaterals(); // sync collateral states await this.minterService.syncMinters(); // sync minter states await this.jusdService.syncState(); // sync JUSD global state + // Auto-deny watcher: runs after minters have been synced so PROPOSED state is fresh. + await this.runWatcher('minterGuard', () => this.minterGuardService.checkAndDeny()); // Watchers wrapped individually so a bug in one cannot mask the alerts of the others. // A throwing watcher is logged and the cycle continues; the outer try/catch is reserved // for genuine sync-pipeline failures that need the consecutive-failures escalation. From fee799586f0d61a3feb309dabdcd36c9b273c37a Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 16 May 2026 21:16:28 +0200 Subject: [PATCH 3/3] refactor(entrypoint): use standard .env pattern instead of in-container bw (#39) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(entrypoint): use standard .env pattern instead of in-container bw Removes the bw CLI from the image and the entrypoint that fetched GUARD_PRIVATE_KEY at container start. The infrastructure repo now provides GUARD_PRIVATE_KEY directly via .env (generated by generate-env.sh from Vaultwarden at deploy time), consistent with how every other secret is wired into this stack. Trade-off: GUARD_PRIVATE_KEY now lives on the host's .env file (chmod 600) between deploys; redeploy is required to rotate it. The added complexity of bw-in-container was not worth the divergence from the established pattern. * docs(minter-guard): document env vars + fix stale whitelist comment - .env.example: document GUARD_ENABLED, GUARD_PRIVATE_KEY, GUARD_HELPER_ADDRESS, GUARD_WHITELIST_FILE in the same style as the other sections - README.md: add Minter Guard to the architecture list - whitelist.{testnet,mainnet}.json: rewrite stale "_comment". The service denies bridge proposals too — the previous comment promised the opposite. --- .env.example | 21 +++++++ Dockerfile | 8 --- README.md | 1 + entrypoint.sh | 62 ------------------- .../config/whitelist.mainnet.json | 2 +- .../config/whitelist.testnet.json | 2 +- 6 files changed, 24 insertions(+), 72 deletions(-) delete mode 100644 entrypoint.sh diff --git a/.env.example b/.env.example index b2e7809..4b4ea44 100644 --- a/.env.example +++ b/.env.example @@ -59,3 +59,24 @@ GECKOTERMINAL_BASE_URL=http://pricing-proxy:8080/geckoterminal # observes when the same chat receives alerts from several chains. Leave # unset on single-chain stacks. Free-form value, rendered upper-cased. # CHAIN=Mainnet + +# Minter-guard auto-deny watcher. +# +# When enabled, the watcher iterates PROPOSED minters at the end of every +# monitoring cycle and submits denyMinter() for any address not on the +# committed whitelist (src/monitoringV2/config/whitelist.{testnet,mainnet}.json). +# Bridge proposals are not exempted: the bridge type is inferred from a trivial +# usd() view call and is therefore unsafe to exclude. +# +# GUARD_ENABLED true/false. Disables the watcher entirely if false. +# GUARD_PRIVATE_KEY Hex private key (0x...) of the signer. Must hold or be +# delegated enough voting power to pass checkQualified() +# on the JUSD reserve. +# GUARD_HELPER_ADDRESS Address passed as the single helper to denyMinter(). +# Use the equity holder that delegated to the signer. +# GUARD_WHITELIST_FILE Absolute path to the whitelist JSON inside the +# container (e.g. /app/src/monitoringV2/config/whitelist.mainnet.json). +# GUARD_ENABLED=false +# GUARD_PRIVATE_KEY=0x0000000000000000000000000000000000000000000000000000000000000000 +# GUARD_HELPER_ADDRESS=0x0000000000000000000000000000000000000000 +# GUARD_WHITELIST_FILE=/app/src/monitoringV2/config/whitelist.mainnet.json diff --git a/Dockerfile b/Dockerfile index 66ec1cd..9fbda16 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,5 @@ FROM node:lts-alpine -# bw CLI is used by entrypoint.sh to fetch GUARD_PRIVATE_KEY from Vaultwarden at container start. -# Installed as root before switching user so the global npm prefix is writable. -RUN apk add --no-cache bash && npm install -g @bitwarden/cli@2024.9.0 - RUN mkdir /app && chown -R node:node /app WORKDIR /app USER node @@ -17,11 +13,7 @@ COPY --chown=node . . RUN npm run prisma:generate RUN npm run build -# Entrypoint optionally fetches GUARD_PRIVATE_KEY from Vaultwarden, then execs npm. -COPY --chown=node --chmod=0755 entrypoint.sh /app/entrypoint.sh - # Expose port EXPOSE 3001 -ENTRYPOINT ["/app/entrypoint.sh"] CMD ["npm", "run", "start:migrate"] diff --git a/README.md b/README.md index 11738cb..faebf60 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ The monitoring service continuously syncs blockchain data to provide real-time i - Collateral aggregation by token type 4. **Token Prices**: Fetches real-time prices from GeckoTerminal API with caching 5. **API Endpoints**: Serves data via REST API for frontend consumption +6. **Minter Guard**: Optional auto-deny watcher (opt-in via `GUARD_ENABLED=true`). At the end of every monitoring cycle it submits `denyMinter()` for any `PROPOSED` minter not on a committed whitelist (`src/monitoringV2/config/whitelist.{testnet,mainnet}.json`). Requires `GUARD_PRIVATE_KEY` and `GUARD_HELPER_ADDRESS`. See `.env.example`. ## Tech Stack diff --git a/entrypoint.sh b/entrypoint.sh deleted file mode 100644 index 7ed051d..0000000 --- a/entrypoint.sh +++ /dev/null @@ -1,62 +0,0 @@ -#!/bin/sh -# entrypoint.sh — Optionally fetches GUARD_PRIVATE_KEY from Vaultwarden at container -# start (not deploy time), so the key only ever lives in container memory. -# -# Activation is opt-in: set VAULT_FETCH_GUARD_KEY=true and provide: -# VAULT_ITEM_NAME Name of the vault item holding the key -# VAULT_PASSWORD_FILE Path to file with the master password -# VAULT_SERVER_URL Bitwarden server URL (default: https://dfxvault.com) -# VAULT_FIELD Item field to read (default: GUARD_PRIVATE_KEY) -# -# When GUARD_ENABLED=true and VAULT_FETCH_GUARD_KEY=true the entrypoint fails fast -# if the vault is unreachable or the item is missing — the service must not silently -# start without a signing key. - -set -e - -if [ "${VAULT_FETCH_GUARD_KEY:-false}" = "true" ]; then - : "${VAULT_ITEM_NAME:?VAULT_ITEM_NAME is required when VAULT_FETCH_GUARD_KEY=true}" - : "${VAULT_PASSWORD_FILE:?VAULT_PASSWORD_FILE is required when VAULT_FETCH_GUARD_KEY=true}" - : "${VAULT_BW_DATA_DIR:?VAULT_BW_DATA_DIR is required when VAULT_FETCH_GUARD_KEY=true (mounted from host)}" - - VAULT_SERVER_URL="${VAULT_SERVER_URL:-https://dfxvault.com}" - VAULT_FIELD="${VAULT_FIELD:-GUARD_PRIVATE_KEY}" - - if [ ! -r "$VAULT_PASSWORD_FILE" ]; then - echo "entrypoint: vault password file $VAULT_PASSWORD_FILE not readable" >&2 - exit 1 - fi - - # Copy bw data to a writable per-container location so the host's bw state stays untouched - # even if `bw sync` updates the local cache. Without this, two containers sharing the same - # mount would race on data.json writes. - BW_RUNTIME_DIR="$(mktemp -d /tmp/bw-runtime.XXXXXX)" - cp -r "$VAULT_BW_DATA_DIR"/* "$BW_RUNTIME_DIR"/ 2>/dev/null || true - export BITWARDENCLI_APPDATA_DIR="$BW_RUNTIME_DIR" - - echo "entrypoint: configuring bw server $VAULT_SERVER_URL" - bw config server "$VAULT_SERVER_URL" > /dev/null - - echo "entrypoint: unlocking vault" - BW_PASSWORD="$(cat "$VAULT_PASSWORD_FILE")" - export BW_PASSWORD - BW_SESSION="$(bw unlock --passwordenv BW_PASSWORD --raw)" - export BW_SESSION - unset BW_PASSWORD - - echo "entrypoint: syncing vault" - bw sync --session "$BW_SESSION" > /dev/null - - echo "entrypoint: fetching $VAULT_FIELD from item '$VAULT_ITEM_NAME'" - GUARD_PRIVATE_KEY="$(bw get item "$VAULT_ITEM_NAME" --session "$BW_SESSION" \ - | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const i=JSON.parse(s);const f=(i.fields||[]).find(x=>x.name===process.argv[1]);if(!f){process.stderr.write("field not found: "+process.argv[1]+"\n");process.exit(2);}process.stdout.write(f.value);}' "$VAULT_FIELD")" - export GUARD_PRIVATE_KEY - - bw lock --session "$BW_SESSION" > /dev/null || true - rm -rf "$BW_RUNTIME_DIR" - unset BW_SESSION BITWARDENCLI_APPDATA_DIR - - echo "entrypoint: GUARD_PRIVATE_KEY loaded into env" -fi - -exec "$@" diff --git a/src/monitoringV2/config/whitelist.mainnet.json b/src/monitoringV2/config/whitelist.mainnet.json index 98bc0c4..abdb6ec 100644 --- a/src/monitoringV2/config/whitelist.mainnet.json +++ b/src/monitoringV2/config/whitelist.mainnet.json @@ -1,4 +1,4 @@ { - "_comment": "Whitelist of approved generic minters (lowercase addresses). Empty = deny any new minter proposal. Bridges are never auto-denied (different type). Update via PR and redeploy.", + "_comment": "Whitelist of approved minter addresses (lowercase). Empty = deny any new minter proposal, including bridges (bridge type is inferred from a trivial usd() view call and is therefore unsafe to exempt). Add legitimate proposals here via PR + redeploy before they pass the application period.", "minters": [] } diff --git a/src/monitoringV2/config/whitelist.testnet.json b/src/monitoringV2/config/whitelist.testnet.json index 98bc0c4..abdb6ec 100644 --- a/src/monitoringV2/config/whitelist.testnet.json +++ b/src/monitoringV2/config/whitelist.testnet.json @@ -1,4 +1,4 @@ { - "_comment": "Whitelist of approved generic minters (lowercase addresses). Empty = deny any new minter proposal. Bridges are never auto-denied (different type). Update via PR and redeploy.", + "_comment": "Whitelist of approved minter addresses (lowercase). Empty = deny any new minter proposal, including bridges (bridge type is inferred from a trivial usd() view call and is therefore unsafe to exempt). Add legitimate proposals here via PR + redeploy before they pass the application period.", "minters": [] }