@aspan/sdk 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Aspan Protocol Monitoring Bot
3
+ * Main entry point
4
+ */
5
+
6
+ import "dotenv/config";
7
+ import { createAspanReadClient } from "../index";
8
+ import { loadConfig } from "./config";
9
+ import { SlackService } from "./services/slack";
10
+ import { createRobustClient } from "./services/rpc-client";
11
+ import { StatsMonitor } from "./monitors/stats-monitor";
12
+ import { CRMonitor } from "./monitors/cr-monitor";
13
+ import { MempoolMonitor } from "./monitors/mempool-monitor";
14
+
15
+ class AspanMonitoringBot {
16
+ private config = loadConfig();
17
+ private slack: SlackService;
18
+ private statsMonitor: StatsMonitor;
19
+ private crMonitor: CRMonitor;
20
+ private mempoolMonitor: MempoolMonitor;
21
+ private isRunning = false;
22
+
23
+ constructor() {
24
+ console.log("Initializing Aspan Monitoring Bot...");
25
+
26
+ // Initialize Slack service
27
+ this.slack = new SlackService(
28
+ this.config.slackWebhookUrl,
29
+ this.config.slackRateLimitMs
30
+ );
31
+
32
+ // Create SDK client
33
+ const aspanClient = createAspanReadClient(
34
+ this.config.diamondAddress,
35
+ this.config.rpcUrl
36
+ );
37
+
38
+ // Create robust public client for event watching
39
+ const publicClient = createRobustClient(this.config);
40
+
41
+ // Initialize monitors
42
+ this.statsMonitor = new StatsMonitor(aspanClient, this.slack, this.config);
43
+ this.crMonitor = new CRMonitor(aspanClient, this.slack, this.config);
44
+ this.mempoolMonitor = new MempoolMonitor(
45
+ aspanClient,
46
+ publicClient,
47
+ this.slack,
48
+ this.config
49
+ );
50
+
51
+ console.log(`Diamond Address: ${this.config.diamondAddress}`);
52
+ console.log(`RPC URL: ${this.config.rpcUrl}`);
53
+ }
54
+
55
+ async start(): Promise<void> {
56
+ if (this.isRunning) {
57
+ console.warn("Bot is already running");
58
+ return;
59
+ }
60
+
61
+ console.log("\nStarting Aspan Monitoring Bot...\n");
62
+
63
+ // Send startup notification
64
+ await this.slack.send({
65
+ text: ":robot_face: Aspan Monitoring Bot started",
66
+ });
67
+
68
+ // Start all monitors
69
+ this.statsMonitor.start();
70
+ this.crMonitor.start();
71
+ await this.mempoolMonitor.start();
72
+
73
+ this.isRunning = true;
74
+ console.log("\nAll monitors started successfully!\n");
75
+ }
76
+
77
+ async stop(): Promise<void> {
78
+ if (!this.isRunning) {
79
+ return;
80
+ }
81
+
82
+ console.log("\nStopping Aspan Monitoring Bot...");
83
+
84
+ // Stop all monitors
85
+ this.statsMonitor.stop();
86
+ this.crMonitor.stop();
87
+ this.mempoolMonitor.stop();
88
+
89
+ // Send shutdown notification
90
+ await this.slack.send({
91
+ text: ":wave: Aspan Monitoring Bot stopped",
92
+ });
93
+
94
+ this.isRunning = false;
95
+ console.log("Bot stopped\n");
96
+ }
97
+ }
98
+
99
+ // Main execution
100
+ async function main() {
101
+ const bot = new AspanMonitoringBot();
102
+
103
+ // Graceful shutdown handlers
104
+ const shutdown = async (signal: string) => {
105
+ console.log(`\nReceived ${signal}, shutting down gracefully...`);
106
+ await bot.stop();
107
+ process.exit(0);
108
+ };
109
+
110
+ process.on("SIGINT", () => shutdown("SIGINT"));
111
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
112
+
113
+ process.on("uncaughtException", (error) => {
114
+ console.error("Uncaught exception:", error);
115
+ shutdown("uncaughtException");
116
+ });
117
+
118
+ process.on("unhandledRejection", (reason) => {
119
+ console.error("Unhandled rejection:", reason);
120
+ shutdown("unhandledRejection");
121
+ });
122
+
123
+ // Start the bot
124
+ await bot.start();
125
+
126
+ // Keep process alive
127
+ console.log("Bot is running. Press Ctrl+C to stop.\n");
128
+ }
129
+
130
+ main().catch((error) => {
131
+ console.error("Fatal error:", error);
132
+ process.exit(1);
133
+ });
@@ -0,0 +1,182 @@
1
+ /**
2
+ * CR (Collateral Ratio) Monitor
3
+ * Monitors CR and alerts when thresholds are breached
4
+ */
5
+
6
+ import type { AspanReadClient } from "../../client";
7
+ import { formatCR } from "../../index";
8
+ import type { SlackService } from "../services/slack";
9
+ import { SlackColors } from "../services/slack";
10
+ import type { BotConfig } from "../config";
11
+ import { withRetry } from "../services/rpc-client";
12
+
13
+ interface AlertState {
14
+ mode1Triggered: boolean;
15
+ mode2Triggered: boolean;
16
+ lastAlertTime: number;
17
+ lastCR: bigint;
18
+ }
19
+
20
+ export class CRMonitor {
21
+ private client: AspanReadClient;
22
+ private slack: SlackService;
23
+ private config: BotConfig;
24
+ private intervalId?: ReturnType<typeof setInterval>;
25
+ private state: AlertState = {
26
+ mode1Triggered: false,
27
+ mode2Triggered: false,
28
+ lastAlertTime: 0,
29
+ lastCR: 0n,
30
+ };
31
+
32
+ // Cooldown between repeat alerts (5 minutes)
33
+ private readonly ALERT_COOLDOWN_MS = 5 * 60 * 1000;
34
+
35
+ constructor(
36
+ client: AspanReadClient,
37
+ slack: SlackService,
38
+ config: BotConfig
39
+ ) {
40
+ this.client = client;
41
+ this.slack = slack;
42
+ this.config = config;
43
+ }
44
+
45
+ start(): void {
46
+ console.log(
47
+ `[CRMonitor] Starting with ${this.config.crCheckInterval / 1000}s interval`
48
+ );
49
+ console.log(
50
+ `[CRMonitor] Thresholds - Mode1: ${formatCR(this.config.crThresholdMode1)}, Mode2: ${formatCR(this.config.crThresholdMode2)}`
51
+ );
52
+
53
+ // Initial check
54
+ this.checkCR();
55
+
56
+ // Schedule regular checks
57
+ this.intervalId = setInterval(
58
+ () => this.checkCR(),
59
+ this.config.crCheckInterval
60
+ );
61
+ }
62
+
63
+ stop(): void {
64
+ if (this.intervalId) {
65
+ clearInterval(this.intervalId);
66
+ this.intervalId = undefined;
67
+ console.log("[CRMonitor] Stopped");
68
+ }
69
+ }
70
+
71
+ private async checkCR(): Promise<void> {
72
+ try {
73
+ const stabilityMode = await withRetry(
74
+ () => this.client.getStabilityMode(),
75
+ this.config,
76
+ "getStabilityMode"
77
+ );
78
+
79
+ const currentCR = stabilityMode.currentCR;
80
+ const now = Date.now();
81
+
82
+ // Check Mode 2 threshold (< 130%)
83
+ if (currentCR < this.config.crThresholdMode2) {
84
+ if (!this.state.mode2Triggered || this.shouldRepeatAlert(now)) {
85
+ await this.sendCriticalAlert(currentCR);
86
+ this.state.mode2Triggered = true;
87
+ this.state.mode1Triggered = true; // Also in mode1 range
88
+ this.state.lastAlertTime = now;
89
+ }
90
+ }
91
+ // Check Mode 1 threshold (< 150% but >= 130%)
92
+ else if (currentCR < this.config.crThresholdMode1) {
93
+ // If recovering from Mode 2
94
+ if (this.state.mode2Triggered) {
95
+ await this.sendRecoveryAlert(currentCR, "Mode 2", "Mode 1");
96
+ this.state.mode2Triggered = false;
97
+ }
98
+
99
+ if (!this.state.mode1Triggered || this.shouldRepeatAlert(now)) {
100
+ await this.sendWarningAlert(currentCR);
101
+ this.state.mode1Triggered = true;
102
+ this.state.lastAlertTime = now;
103
+ }
104
+ }
105
+ // CR is healthy (>= 150%)
106
+ else {
107
+ if (this.state.mode1Triggered || this.state.mode2Triggered) {
108
+ const fromMode = this.state.mode2Triggered ? "Mode 2" : "Mode 1";
109
+ await this.sendRecoveryAlert(currentCR, fromMode, "Normal");
110
+ this.state.mode1Triggered = false;
111
+ this.state.mode2Triggered = false;
112
+ }
113
+ }
114
+
115
+ this.state.lastCR = currentCR;
116
+ } catch (error) {
117
+ console.error("[CRMonitor] CR check failed:", error);
118
+ }
119
+ }
120
+
121
+ private shouldRepeatAlert(now: number): boolean {
122
+ return now - this.state.lastAlertTime > this.ALERT_COOLDOWN_MS;
123
+ }
124
+
125
+ private async sendWarningAlert(cr: bigint): Promise<void> {
126
+ console.log(`[CRMonitor] Sending Mode 1 warning alert - CR: ${formatCR(cr)}`);
127
+
128
+ await this.slack.send({
129
+ attachments: [
130
+ {
131
+ color: SlackColors.warning,
132
+ title: ":warning: CR Warning - Mode 1 Threshold Breached",
133
+ text: "Collateral Ratio has dropped below 150%. Protocol entering stability mode.",
134
+ fields: [
135
+ { title: "Current CR", value: formatCR(cr), short: true },
136
+ { title: "Threshold", value: "150%", short: true },
137
+ ],
138
+ ts: Math.floor(Date.now() / 1000),
139
+ },
140
+ ],
141
+ });
142
+ }
143
+
144
+ private async sendCriticalAlert(cr: bigint): Promise<void> {
145
+ console.log(`[CRMonitor] Sending Mode 2 CRITICAL alert - CR: ${formatCR(cr)}`);
146
+
147
+ await this.slack.send({
148
+ attachments: [
149
+ {
150
+ color: SlackColors.danger,
151
+ title: ":rotating_light: CRITICAL - Mode 2 Threshold Breached",
152
+ text: "Collateral Ratio has dropped below 130%! Stability Mode 2 may trigger forced conversion.",
153
+ fields: [
154
+ { title: "Current CR", value: formatCR(cr), short: true },
155
+ { title: "Threshold", value: "130%", short: true },
156
+ ],
157
+ ts: Math.floor(Date.now() / 1000),
158
+ },
159
+ ],
160
+ });
161
+ }
162
+
163
+ private async sendRecoveryAlert(
164
+ cr: bigint,
165
+ from: string,
166
+ to: string
167
+ ): Promise<void> {
168
+ console.log(`[CRMonitor] CR recovered from ${from} to ${to} - CR: ${formatCR(cr)}`);
169
+
170
+ await this.slack.send({
171
+ attachments: [
172
+ {
173
+ color: SlackColors.success,
174
+ title: `:white_check_mark: CR Recovered - ${from} -> ${to}`,
175
+ text: "Collateral Ratio has recovered above threshold.",
176
+ fields: [{ title: "Current CR", value: formatCR(cr), short: true }],
177
+ ts: Math.floor(Date.now() / 1000),
178
+ },
179
+ ],
180
+ });
181
+ }
182
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Mempool/TVL Monitor
3
+ * Monitors contract events for large TVL changes
4
+ *
5
+ * Note: BSC doesn't expose public mempool, so we use event-based monitoring
6
+ * which is reactive (post-confirmation) rather than proactive
7
+ */
8
+
9
+ import type { PublicClient, Log } from "viem";
10
+ import type { AspanReadClient } from "../../client";
11
+ import { DiamondABI, formatAmount } from "../../index";
12
+ import type { SlackService } from "../services/slack";
13
+ import { SlackColors } from "../services/slack";
14
+ import type { BotConfig } from "../config";
15
+ import { withRetry } from "../services/rpc-client";
16
+
17
+ export class MempoolMonitor {
18
+ private client: AspanReadClient;
19
+ private publicClient: PublicClient;
20
+ private slack: SlackService;
21
+ private config: BotConfig;
22
+ private unwatch?: () => void;
23
+ private lastKnownTVL: bigint = 0n;
24
+
25
+ constructor(
26
+ client: AspanReadClient,
27
+ publicClient: PublicClient,
28
+ slack: SlackService,
29
+ config: BotConfig
30
+ ) {
31
+ this.client = client;
32
+ this.publicClient = publicClient;
33
+ this.slack = slack;
34
+ this.config = config;
35
+ }
36
+
37
+ async start(): Promise<void> {
38
+ console.log(
39
+ `[MempoolMonitor] Starting with ${this.config.tvlImpactThresholdPercent}% TVL impact threshold`
40
+ );
41
+
42
+ // Get initial TVL
43
+ this.lastKnownTVL = await withRetry(
44
+ () => this.client.getTVLInUSD(),
45
+ this.config,
46
+ "getTVLInUSD"
47
+ );
48
+ console.log(
49
+ `[MempoolMonitor] Initial TVL: $${formatAmount(this.lastKnownTVL, 2)}`
50
+ );
51
+
52
+ // Watch for contract events
53
+ this.unwatch = this.publicClient.watchContractEvent({
54
+ address: this.config.diamondAddress,
55
+ abi: DiamondABI,
56
+ onLogs: (logs) => this.handleLogs(logs),
57
+ onError: (error) => console.error("[MempoolMonitor] Event watch error:", error),
58
+ });
59
+
60
+ console.log("[MempoolMonitor] Event watcher started");
61
+ }
62
+
63
+ stop(): void {
64
+ if (this.unwatch) {
65
+ this.unwatch();
66
+ this.unwatch = undefined;
67
+ console.log("[MempoolMonitor] Stopped");
68
+ }
69
+ }
70
+
71
+ private async handleLogs(logs: Log[]): Promise<void> {
72
+ for (const log of logs) {
73
+ // Check if this is a TVL-impacting event by looking at event signature
74
+ await this.checkTVLImpact(log);
75
+ }
76
+ }
77
+
78
+ private async checkTVLImpact(log: Log): Promise<void> {
79
+ try {
80
+ // Get current TVL
81
+ const currentTVL = await withRetry(
82
+ () => this.client.getTVLInUSD(),
83
+ this.config,
84
+ "getTVLInUSD"
85
+ );
86
+
87
+ if (this.lastKnownTVL === 0n) {
88
+ this.lastKnownTVL = currentTVL;
89
+ return;
90
+ }
91
+
92
+ const tvlChange = currentTVL - this.lastKnownTVL;
93
+ const impactPercent =
94
+ this.lastKnownTVL > 0n
95
+ ? Number((tvlChange * 10000n) / this.lastKnownTVL) / 100
96
+ : 0;
97
+
98
+ const previousTVL = this.lastKnownTVL;
99
+ this.lastKnownTVL = currentTVL;
100
+
101
+ // Check if impact exceeds threshold
102
+ if (Math.abs(impactPercent) >= this.config.tvlImpactThresholdPercent) {
103
+ const isDecrease = tvlChange < 0n;
104
+ await this.sendImpactAlert(
105
+ log.transactionHash ?? "unknown",
106
+ previousTVL,
107
+ currentTVL,
108
+ impactPercent,
109
+ isDecrease
110
+ );
111
+ }
112
+ } catch (error) {
113
+ console.error("[MempoolMonitor] Error checking TVL impact:", error);
114
+ }
115
+ }
116
+
117
+ private async sendImpactAlert(
118
+ txHash: string,
119
+ previousTVL: bigint,
120
+ currentTVL: bigint,
121
+ impactPercent: number,
122
+ isDecrease: boolean
123
+ ): Promise<void> {
124
+ const direction = isDecrease ? "DECREASE" : "INCREASE";
125
+ const emoji = isDecrease
126
+ ? ":chart_with_downwards_trend:"
127
+ : ":chart_with_upwards_trend:";
128
+ const color = isDecrease ? SlackColors.danger : SlackColors.success;
129
+
130
+ console.log(
131
+ `[MempoolMonitor] Large TVL ${direction}: ${impactPercent.toFixed(2)}% - TX: ${txHash.slice(0, 18)}...`
132
+ );
133
+
134
+ await this.slack.send({
135
+ attachments: [
136
+ {
137
+ color,
138
+ title: `${emoji} Large TVL ${direction} Detected`,
139
+ text: `A transaction caused a ${Math.abs(impactPercent).toFixed(2)}% ${direction.toLowerCase()} in TVL`,
140
+ fields: [
141
+ {
142
+ title: "Impact",
143
+ value: `${impactPercent >= 0 ? "+" : ""}${impactPercent.toFixed(2)}%`,
144
+ short: true,
145
+ },
146
+ {
147
+ title: "Previous TVL",
148
+ value: `$${formatAmount(previousTVL, 2)}`,
149
+ short: true,
150
+ },
151
+ {
152
+ title: "Current TVL",
153
+ value: `$${formatAmount(currentTVL, 2)}`,
154
+ short: true,
155
+ },
156
+ {
157
+ title: "Transaction",
158
+ value: `\`${txHash.slice(0, 18)}...\``,
159
+ short: true,
160
+ },
161
+ ],
162
+ ts: Math.floor(Date.now() / 1000),
163
+ },
164
+ ],
165
+ });
166
+ }
167
+ }