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 72582bd..1a8c869 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; @@ -99,4 +99,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 b64a1f8..a6a768a 100644 --- a/src/config/monitoring.config.ts +++ b/src/config/monitoring.config.ts @@ -82,6 +82,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', () => { @@ -108,6 +124,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.