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
29 changes: 28 additions & 1 deletion api.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,37 @@ export const CONFIG: ConfigType = {
},
};

const SENSITIVE_KEYS = new Set<string>([
'coingeckoApiKey',
'network.mainnet',
'network.polygon',
'telegram.botToken',
'twitter.clientSecret',
'twitter.appKey',
'twitter.appSecret',
]);

function redactConfig<T>(config: T): T {
return walkRedact(config, '') as T;
}

function walkRedact(value: unknown, path: string): unknown {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, val]) => {
const childPath = path ? `${path}.${key}` : key;
if (SENSITIVE_KEYS.has(childPath) && val) return [key, '***'];
return [key, walkRedact(val, childPath)];
})
);
}
return value;
}

export function logConfig() {
const logger = new Logger('ApiConfig');
logger.log(`Starting API with this config:`);
logger.log(JSON.stringify(CONFIG));
logger.log(JSON.stringify(redactConfig(CONFIG)));
}

// Refer to https://github.com/yagop/node-telegram-bot-api/blob/master/doc/usage.md#sending-files
Expand Down
30 changes: 29 additions & 1 deletion socialmedia/telegram/dtos/groups.dto.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { IsArray, IsNumber, IsString } from 'class-validator';
import { IsArray, IsNumber, IsOptional, IsString } from 'class-validator';

export class Groups {
// @dev: react/nextjs/ts causes type error. (lint in yarn install, deployment)
Expand All @@ -8,6 +8,10 @@ export class Groups {
this.createdAt = 0;
this.updatedAt = 0;
this.groups = [];
this.alertedMiniLifetime = [];
this.alertedExpiringSoon = [];
this.alertedExpired = [];
this.alertedPhase2 = [];
}

@IsString()
Expand All @@ -22,4 +26,28 @@ export class Groups {
@IsArray()
@IsString({ each: true })
groups: string[];

// Per-address alert dedup. Persisting these means a service restart does not silently
// drop alerts for positions that were actionable when the service went down, and a
// telegram outage does not lose alerts (positions stay un-listed until delivery
// confirms). @IsOptional so older backup files without these fields still validate.
@IsOptional()
@IsArray()
@IsString({ each: true })
alertedMiniLifetime: string[];

@IsOptional()
@IsArray()
@IsString({ each: true })
alertedExpiringSoon: string[];

@IsOptional()
@IsArray()
@IsString({ each: true })
alertedExpired: string[];

@IsOptional()
@IsArray()
@IsString({ each: true })
alertedPhase2: string[];
}
58 changes: 58 additions & 0 deletions socialmedia/telegram/messages/PositionExpired.message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { PositionQuery } from 'positions/positions.types';
import { formatCurrency, safeMarkdown } from 'utils/format';
import { AppUrl, ExplorerAddressUrl } from 'utils/func-helper';
import { formatUnits } from 'viem';

export function PositionExpiredMessage(position: PositionQuery): string {
const bal: number = parseInt(formatUnits(BigInt(position.collateralBalance), position.collateralDecimals - 2)) / 100;
const min: number = parseInt(formatUnits(BigInt(position.minimumCollateral), position.collateralDecimals - 2)) / 100;
const price: number = parseInt(formatUnits(BigInt(position.price), 36 - position.collateralDecimals - 2)) / 100;
const duration: number = position.challengePeriod * 1000;
const collateralName = safeMarkdown(position.collateralName);
const collateralSymbol = safeMarkdown(position.collateralSymbol);

const begin = new Date(position.expiration * 1000);
const mid = new Date(position.expiration * 1000 + 1 * duration);
const zero = new Date(position.expiration * 1000 + 2 * duration);

const header = `
*Position is expired*

Position: ${position.position} (v${position.version})
Owner: ${position.owner}

Principal: ${formatCurrency(formatUnits(BigInt(position.principal), 18), 2, 2)} dEURO
Retained Reserve: ${formatCurrency(position.reserveContribution / 10000, 1, 1)}%
Auction Duration: ${Math.floor(position.challengePeriod / 60 / 60)} hours

Collateral: ${collateralName} (${collateralSymbol})
At: ${position.collateral}
Balance: ${formatCurrency(bal, 2, 2)} ${collateralSymbol}
Bal. min.: ${formatCurrency(min, 2, 2)} ${collateralSymbol}
`;

const body = `
*ForceSell is available*

Declines (10x -> 1x Price): ${begin.toUTCString()}
Price (10x): ${formatCurrency(price * 10, 2, 2)} dEURO per 1 ${collateralSymbol}

Continues (1x -> 0x Price): ${mid.toUTCString()}
Price (1x): ${formatCurrency(price, 2, 2)} dEURO per 1 ${collateralSymbol}

Zero: ${zero.toUTCString()}
Price (0x): 0.00 dEURO per 1 ${collateralSymbol}
`;

const footer = `

[Overview Position](${AppUrl(`/monitoring/${position.position}`)})
[Buy Collateral](${AppUrl(`/monitoring/${position.position}/forceSell`)})

[Explorer Position](${ExplorerAddressUrl(position.position)})
[Explorer Owner](${ExplorerAddressUrl(position.owner)})
[Explorer Collateral](${ExplorerAddressUrl(position.collateral)})
`;

return header + body + footer;
}
38 changes: 38 additions & 0 deletions socialmedia/telegram/messages/PositionExpiringSoon.message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { PositionQuery } from 'positions/positions.types';
import { formatCurrency, safeMarkdown } from 'utils/format';
import { AppUrl, ExplorerAddressUrl } from 'utils/func-helper';
import { formatUnits } from 'viem';

export function PositionExpiringSoonMessage(position: PositionQuery): string {
const bal: number = parseInt(formatUnits(BigInt(position.collateralBalance), position.collateralDecimals - 2)) / 100;
const min: number = parseInt(formatUnits(BigInt(position.minimumCollateral), position.collateralDecimals - 2)) / 100;
const price: number = parseInt(formatUnits(BigInt(position.price), 36 - position.collateralDecimals - 2)) / 100;
const date = new Date(position.expiration * 1000);
const collateralName = safeMarkdown(position.collateralName);
const collateralSymbol = safeMarkdown(position.collateralSymbol);

return `
*Position will expire soon*

Position: ${position.position} (v${position.version})
Owner: ${position.owner}

Principal: ${formatCurrency(formatUnits(BigInt(position.principal), 18), 2, 2)} dEURO
Retained Reserve: ${formatCurrency(position.reserveContribution / 10000, 1, 1)}%
Auction Duration: ${Math.floor(position.challengePeriod / 60 / 60)} hours
Expiration: ${formatCurrency((position.expiration * 1000 - Date.now()) / 1000 / 60 / 60 / 24)} days
At: ${date.toUTCString()}

Collateral: ${collateralName} (${collateralSymbol})
At: ${position.collateral}
Balance: ${formatCurrency(bal, 2, 2)} ${collateralSymbol}
Bal. min.: ${formatCurrency(min, 2, 2)} ${collateralSymbol}
Price: ${formatCurrency(price, 2, 2)} dEURO

[Overview Position](${AppUrl(`/monitoring/${position.position}`)})

[Explorer Position](${ExplorerAddressUrl(position.position)})
[Explorer Owner](${ExplorerAddressUrl(position.owner)})
[Explorer Collateral](${ExplorerAddressUrl(position.collateral)})
`;
}
42 changes: 42 additions & 0 deletions socialmedia/telegram/messages/PositionMiniLifetime.message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { PositionQuery } from 'positions/positions.types';
import { formatCurrency, safeMarkdown } from 'utils/format';
import { AppUrl, ExplorerAddressUrl } from 'utils/func-helper';
import { formatUnits } from 'viem';

export function PositionMiniLifetimeMessage(position: PositionQuery): string {
const lifetimeSeconds = position.expiration - position.created;
const bal: number = parseInt(formatUnits(BigInt(position.collateralBalance), position.collateralDecimals - 2)) / 100;
const min: number = parseInt(formatUnits(BigInt(position.minimumCollateral), position.collateralDecimals - 2)) / 100;
const price: number = parseInt(formatUnits(BigInt(position.price), 36 - position.collateralDecimals - 2)) / 100;
const collateralName = safeMarkdown(position.collateralName);
const collateralSymbol = safeMarkdown(position.collateralSymbol);

return `
*Suspicious clone detected*

Position: ${position.position} (v${position.version})
Owner: ${position.owner}

Lifetime: ${lifetimeSeconds} seconds
Principal: ${formatCurrency(formatUnits(BigInt(position.principal), 18), 2, 2)} dEURO
Retained Reserve: ${formatCurrency(position.reserveContribution / 10000, 1, 1)}%
Auction Duration: ${Math.floor(position.challengePeriod / 60 / 60)} hours

Collateral: ${collateralName} (${collateralSymbol})
At: ${position.collateral}
Balance: ${formatCurrency(bal, 2, 2)} ${collateralSymbol}
Bal. min.: ${formatCurrency(min, 2, 2)} ${collateralSymbol}
Price: ${formatCurrency(price, 2, 2)} dEURO

This pattern matches the WFPS forced-sale attack vector — a clone with
sub-day lifetime, set up to be drained via expiredPurchasePrice decay.
Mitigation: open a challenge or call buyExpiredCollateral once the
position enters phase 2.

[Overview Position](${AppUrl(`/monitoring/${position.position}`)})

[Explorer Position](${ExplorerAddressUrl(position.position)})
[Explorer Owner](${ExplorerAddressUrl(position.owner)})
[Explorer Collateral](${ExplorerAddressUrl(position.collateral)})
`;
}
52 changes: 52 additions & 0 deletions socialmedia/telegram/messages/PositionPhase2.message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { PositionQuery } from 'positions/positions.types';
import { formatCurrency, safeMarkdown } from 'utils/format';
import { AppUrl, ExplorerAddressUrl } from 'utils/func-helper';
import { formatUnits } from 'viem';

/**
* Fired once when an expired position enters phase 2 of the forced-sale decay
* (price drops linearly from 1× to 0× liq-price over one challengePeriod).
* Phase 2 is the actionable arbitrage window before the equity reserve absorbs
* the loss via coverLoss.
*/
export function PositionPhase2Message(position: PositionQuery): string {
const bal: number = parseInt(formatUnits(BigInt(position.collateralBalance), position.collateralDecimals - 2)) / 100;
const min: number = parseInt(formatUnits(BigInt(position.minimumCollateral), position.collateralDecimals - 2)) / 100;
const price: number = parseInt(formatUnits(BigInt(position.price), 36 - position.collateralDecimals - 2)) / 100;
const collateralName = safeMarkdown(position.collateralName);
const collateralSymbol = safeMarkdown(position.collateralSymbol);
const phase2Start = new Date((position.expiration + position.challengePeriod) * 1000);
const phase2End = new Date((position.expiration + position.challengePeriod * 2) * 1000);

return `
*Forced-sale phase 2 entered*

Position: ${position.position} (v${position.version})
Owner: ${position.owner}

Principal: ${formatCurrency(formatUnits(BigInt(position.principal), 18), 2, 2)} dEURO
Retained Reserve: ${formatCurrency(position.reserveContribution / 10000, 1, 1)}%
Auction Duration: ${Math.floor(position.challengePeriod / 60 / 60)} hours
Liq. Price: ${formatCurrency(price, 2, 2)} dEURO per 1 ${collateralSymbol}

Phase 2 window:
Start: ${phase2Start.toUTCString()} (price = 1× liq)
End: ${phase2End.toUTCString()} (price = 0)

Collateral: ${collateralName} (${collateralSymbol})
At: ${position.collateral}
Balance: ${formatCurrency(bal, 2, 2)} ${collateralSymbol}
Bal. min.: ${formatCurrency(min, 2, 2)} ${collateralSymbol}

This is the arbitrage window — call MintingHub.buyExpiredCollateral
to repay the debt at decay price before the equity reserve covers
the gap.

[Overview Position](${AppUrl(`/monitoring/${position.position}`)})
[Buy Collateral](${AppUrl(`/monitoring/${position.position}/forceSell`)})

[Explorer Position](${ExplorerAddressUrl(position.position)})
[Explorer Owner](${ExplorerAddressUrl(position.owner)})
[Explorer Collateral](${ExplorerAddressUrl(position.collateral)})
`;
}
Loading
Loading