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
10 changes: 9 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"]
ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["npm", "run", "start:migrate"]
62 changes: 62 additions & 0 deletions entrypoint.sh
Original file line number Diff line number Diff line change
@@ -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 "$@"
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 @@ -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;
}
}
21 changes: 21 additions & 0 deletions src/config/monitoring.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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}`);

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 generic minters (lowercase addresses). Empty = deny any new minter proposal. Bridges are never auto-denied (different type). Update via PR and redeploy.",
"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 generic minters (lowercase addresses). Empty = deny any new minter proposal. Bridges are never auto-denied (different type). Update via PR and redeploy.",
"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
Loading