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 86ba26e..9fbda16 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,4 +16,4 @@ RUN npm run build # Expose port EXPOSE 3001 -CMD ["npm", "run", "start:migrate"] \ No newline at end of file +CMD ["npm", "run", "start:migrate"] diff --git a/README.md b/README.md index ad7cdba..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 @@ -91,8 +92,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/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..abdb6ec --- /dev/null +++ b/src/monitoringV2/config/whitelist.mainnet.json @@ -0,0 +1,4 @@ +{ + "_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 new file mode 100644 index 0000000..abdb6ec --- /dev/null +++ b/src/monitoringV2/config/whitelist.testnet.json @@ -0,0 +1,4 @@ +{ + "_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/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. 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 {};