Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,4 @@ RUN npm run build
# Expose port
EXPOSE 3001

CMD ["npm", "run", "start:migrate"]
CMD ["npm", "run", "start:migrate"]
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 |
|---|---|---|
Expand Down
18 changes: 17 additions & 1 deletion src/config/config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(['rpcUrl', 'databaseUrl', 'telegramBotToken', 'coingeckoApiKey']);
const SENSITIVE_KEYS = new Set<string>(['rpcUrl', 'databaseUrl', 'telegramBotToken', 'coingeckoApiKey', 'guardPrivateKey']);

function redactConfig<T>(config: T): T {
return walkRedact(config, '') as T;
Expand Down Expand Up @@ -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;
}
}
21 changes: 21 additions & 0 deletions src/config/monitoring.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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}`);

Expand Down
4 changes: 4 additions & 0 deletions src/monitoringV2/config/whitelist.mainnet.json
Original file line number Diff line number Diff line change
@@ -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": []
}
4 changes: 4 additions & 0 deletions src/monitoringV2/config/whitelist.testnet.json
Original file line number Diff line number Diff line change
@@ -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": []
}
131 changes: 131 additions & 0 deletions src/monitoringV2/minter-guard.service.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
private helperAddress?: string;
private alreadyDenied = new Set<string>();

constructor(
private readonly config: AppConfigService,
private readonly providerService: ProviderService,
private readonly minterRepo: MinterRepository,
private readonly telegramService: TelegramService
) {}

async initialize(): Promise<void> {
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<void> {
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.`
);
}
}
}
}
2 changes: 2 additions & 0 deletions src/monitoringV2/monitoring.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -47,6 +48,7 @@ import { ApiModule } from './api/api.module';
ChallengeService,
CollateralService,
MinterService,
MinterGuardService,
JusdService,
TelegramService,
MonitoringService,
Expand Down
5 changes: 5 additions & 0 deletions src/monitoringV2/monitoring.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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
) {}
Expand All @@ -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);
}
Expand Down Expand Up @@ -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.
Expand Down
45 changes: 0 additions & 45 deletions src/monitoringV2/price.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,8 @@ interface CoingeckoEndpoint {
headers: Record<string, string>;
}

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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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<void> {
try {
const { baseUrl, headers } = this.resolveCoingeckoEndpoint();
const response = await axios.get<CoingeckoKeyInfo>(`${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 {};

Expand Down
Loading