@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.
- package/README.md +451 -0
- package/dist/index.d.mts +1324 -0
- package/dist/index.d.ts +1324 -0
- package/dist/index.js +1451 -0
- package/dist/index.mjs +1413 -0
- package/package.json +63 -0
- package/src/abi/diamond.ts +531 -0
- package/src/bot/config.ts +84 -0
- package/src/bot/index.ts +133 -0
- package/src/bot/monitors/cr-monitor.ts +182 -0
- package/src/bot/monitors/mempool-monitor.ts +167 -0
- package/src/bot/monitors/stats-monitor.ts +287 -0
- package/src/bot/services/rpc-client.ts +61 -0
- package/src/bot/services/slack.ts +95 -0
- package/src/client.ts +1030 -0
- package/src/index.ts +144 -0
- package/src/types.ts +237 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stats Monitor
|
|
3
|
+
* Reports protocol statistics to Slack every 5 minutes
|
|
4
|
+
* Skips report if no significant changes detected
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { AspanReadClient } from "../../client";
|
|
8
|
+
import type { ProtocolStats, StabilityPoolStats } from "../../types";
|
|
9
|
+
import { formatAmount, formatCR } from "../../index";
|
|
10
|
+
import type { SlackService } from "../services/slack";
|
|
11
|
+
import { SlackColors } from "../services/slack";
|
|
12
|
+
import type { BotConfig } from "../config";
|
|
13
|
+
import { withRetry } from "../services/rpc-client";
|
|
14
|
+
|
|
15
|
+
interface LastReportState {
|
|
16
|
+
stats: ProtocolStats;
|
|
17
|
+
stabilityMode: number;
|
|
18
|
+
stabilityPoolStats: StabilityPoolStats;
|
|
19
|
+
apUSDMintDisabled: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class StatsMonitor {
|
|
23
|
+
private client: AspanReadClient;
|
|
24
|
+
private slack: SlackService;
|
|
25
|
+
private config: BotConfig;
|
|
26
|
+
private intervalId?: ReturnType<typeof setInterval>;
|
|
27
|
+
private lastReport?: LastReportState;
|
|
28
|
+
private isFirstReport = true;
|
|
29
|
+
|
|
30
|
+
// Threshold for considering a change significant (0.1% = 10 basis points)
|
|
31
|
+
private readonly CHANGE_THRESHOLD_BPS = 10n;
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
client: AspanReadClient,
|
|
35
|
+
slack: SlackService,
|
|
36
|
+
config: BotConfig
|
|
37
|
+
) {
|
|
38
|
+
this.client = client;
|
|
39
|
+
this.slack = slack;
|
|
40
|
+
this.config = config;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
start(): void {
|
|
44
|
+
console.log(
|
|
45
|
+
`[StatsMonitor] Starting with ${this.config.statsReportInterval / 1000}s interval`
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// Send initial report immediately
|
|
49
|
+
this.reportStats();
|
|
50
|
+
|
|
51
|
+
// Schedule regular reports
|
|
52
|
+
this.intervalId = setInterval(
|
|
53
|
+
() => this.reportStats(),
|
|
54
|
+
this.config.statsReportInterval
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
stop(): void {
|
|
59
|
+
if (this.intervalId) {
|
|
60
|
+
clearInterval(this.intervalId);
|
|
61
|
+
this.intervalId = undefined;
|
|
62
|
+
console.log("[StatsMonitor] Stopped");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private async reportStats(): Promise<void> {
|
|
67
|
+
try {
|
|
68
|
+
const [stats, stabilityMode, fees, stabilityPoolStats] =
|
|
69
|
+
await Promise.all([
|
|
70
|
+
withRetry(
|
|
71
|
+
() => this.client.getProtocolStats(),
|
|
72
|
+
this.config,
|
|
73
|
+
"getProtocolStats"
|
|
74
|
+
),
|
|
75
|
+
withRetry(
|
|
76
|
+
() => this.client.getStabilityMode(),
|
|
77
|
+
this.config,
|
|
78
|
+
"getStabilityMode"
|
|
79
|
+
),
|
|
80
|
+
withRetry(
|
|
81
|
+
() => this.client.getCurrentFees(),
|
|
82
|
+
this.config,
|
|
83
|
+
"getCurrentFees"
|
|
84
|
+
),
|
|
85
|
+
withRetry(
|
|
86
|
+
() => this.client.getStabilityPoolStats(),
|
|
87
|
+
this.config,
|
|
88
|
+
"getStabilityPoolStats"
|
|
89
|
+
),
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
const currentState: LastReportState = {
|
|
93
|
+
stats,
|
|
94
|
+
stabilityMode: stabilityMode.mode,
|
|
95
|
+
stabilityPoolStats,
|
|
96
|
+
apUSDMintDisabled: fees.apUSDMintDisabled,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Skip if no significant changes (except for first report)
|
|
100
|
+
if (!this.isFirstReport && !this.hasSignificantChange(currentState)) {
|
|
101
|
+
console.log(
|
|
102
|
+
`[StatsMonitor] No significant changes, skipping report - TVL: $${formatAmount(stats.tvlInUSD, 2)}, CR: ${formatCR(stats.collateralRatio)}`
|
|
103
|
+
);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
this.isFirstReport = false;
|
|
108
|
+
this.lastReport = currentState;
|
|
109
|
+
|
|
110
|
+
const modeLabels = ["Normal", "Mode 1 (Warning)", "Mode 2 (Critical)"];
|
|
111
|
+
const modeLabel = modeLabels[stabilityMode.mode] ?? "Unknown";
|
|
112
|
+
|
|
113
|
+
await this.slack.send({
|
|
114
|
+
attachments: [
|
|
115
|
+
{
|
|
116
|
+
color: this.getModeColor(stabilityMode.mode),
|
|
117
|
+
title: "Aspan Protocol Stats Report",
|
|
118
|
+
fields: [
|
|
119
|
+
{
|
|
120
|
+
title: "TVL (USD)",
|
|
121
|
+
value: `$${formatAmount(stats.tvlInUSD, 2)}`,
|
|
122
|
+
short: true,
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
title: "Collateral Ratio",
|
|
126
|
+
value: formatCR(stats.collateralRatio),
|
|
127
|
+
short: true,
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
title: "Stability Mode",
|
|
131
|
+
value: modeLabel,
|
|
132
|
+
short: true,
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
title: "Effective Leverage",
|
|
136
|
+
value: `${formatAmount(stats.effectiveLeverage, 2)}x`,
|
|
137
|
+
short: true,
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
title: "apUSD Supply",
|
|
141
|
+
value: formatAmount(stats.apUSDSupply, 2),
|
|
142
|
+
short: true,
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
title: "xBNB Supply",
|
|
146
|
+
value: formatAmount(stats.xBNBSupply, 4),
|
|
147
|
+
short: true,
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
title: "xBNB Price (BNB)",
|
|
151
|
+
value: formatAmount(stats.xBNBPriceBNB, 6),
|
|
152
|
+
short: true,
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
title: "xBNB Price (USD)",
|
|
156
|
+
value: `$${formatAmount(stats.xBNBPriceUSD, 2)}`,
|
|
157
|
+
short: true,
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
title: "Stability Pool Staked",
|
|
161
|
+
value: formatAmount(stabilityPoolStats.totalStaked, 2),
|
|
162
|
+
short: true,
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
title: "Exchange Rate",
|
|
166
|
+
value: formatAmount(stabilityPoolStats.exchangeRate, 6),
|
|
167
|
+
short: true,
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
title: "apUSD Mint Fee",
|
|
171
|
+
value: `${(fees.apUSDMintFee / 100).toFixed(2)}%${fees.apUSDMintDisabled ? " (Disabled)" : ""}`,
|
|
172
|
+
short: true,
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
title: "xBNB Redeem Fee",
|
|
176
|
+
value: `${(fees.xBNBRedeemFee / 100).toFixed(2)}%`,
|
|
177
|
+
short: true,
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
ts: Math.floor(Date.now() / 1000),
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
console.log(
|
|
186
|
+
`[StatsMonitor] Report sent - TVL: $${formatAmount(stats.tvlInUSD, 2)}, CR: ${formatCR(stats.collateralRatio)}`
|
|
187
|
+
);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
console.error("[StatsMonitor] Failed to report stats:", error);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Check if current state has significant changes compared to last report
|
|
195
|
+
*/
|
|
196
|
+
private hasSignificantChange(current: LastReportState): boolean {
|
|
197
|
+
if (!this.lastReport) {
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const last = this.lastReport;
|
|
202
|
+
|
|
203
|
+
// Always report if stability mode changed
|
|
204
|
+
if (current.stabilityMode !== last.stabilityMode) {
|
|
205
|
+
console.log("[StatsMonitor] Stability mode changed");
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Always report if mint disabled status changed
|
|
210
|
+
if (current.apUSDMintDisabled !== last.apUSDMintDisabled) {
|
|
211
|
+
console.log("[StatsMonitor] Mint disabled status changed");
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Check for significant changes in key metrics
|
|
216
|
+
const checks = [
|
|
217
|
+
{
|
|
218
|
+
name: "TVL",
|
|
219
|
+
current: current.stats.tvlInUSD,
|
|
220
|
+
last: last.stats.tvlInUSD,
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
name: "CR",
|
|
224
|
+
current: current.stats.collateralRatio,
|
|
225
|
+
last: last.stats.collateralRatio,
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
name: "apUSD Supply",
|
|
229
|
+
current: current.stats.apUSDSupply,
|
|
230
|
+
last: last.stats.apUSDSupply,
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
name: "xBNB Supply",
|
|
234
|
+
current: current.stats.xBNBSupply,
|
|
235
|
+
last: last.stats.xBNBSupply,
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
name: "xBNB Price",
|
|
239
|
+
current: current.stats.xBNBPriceUSD,
|
|
240
|
+
last: last.stats.xBNBPriceUSD,
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
name: "Staked",
|
|
244
|
+
current: current.stabilityPoolStats.totalStaked,
|
|
245
|
+
last: last.stabilityPoolStats.totalStaked,
|
|
246
|
+
},
|
|
247
|
+
];
|
|
248
|
+
|
|
249
|
+
for (const check of checks) {
|
|
250
|
+
if (this.hasSignificantValueChange(check.current, check.last)) {
|
|
251
|
+
console.log(`[StatsMonitor] Significant change detected in ${check.name}`);
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Check if a value has changed by more than CHANGE_THRESHOLD_BPS (0.1%)
|
|
261
|
+
*/
|
|
262
|
+
private hasSignificantValueChange(current: bigint, last: bigint): boolean {
|
|
263
|
+
if (last === 0n) {
|
|
264
|
+
return current !== 0n;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Calculate absolute difference
|
|
268
|
+
const diff = current > last ? current - last : last - current;
|
|
269
|
+
|
|
270
|
+
// Check if diff > (last * threshold / 10000)
|
|
271
|
+
// Rearranged to avoid precision loss: diff * 10000 > last * threshold
|
|
272
|
+
return diff * 10000n > last * this.CHANGE_THRESHOLD_BPS;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private getModeColor(mode: number): string {
|
|
276
|
+
switch (mode) {
|
|
277
|
+
case 0:
|
|
278
|
+
return SlackColors.success;
|
|
279
|
+
case 1:
|
|
280
|
+
return SlackColors.warning;
|
|
281
|
+
case 2:
|
|
282
|
+
return SlackColors.danger;
|
|
283
|
+
default:
|
|
284
|
+
return SlackColors.neutral;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RPC Client with Retry and Fallback
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { createPublicClient, http, fallback, type PublicClient } from "viem";
|
|
6
|
+
import { bsc } from "viem/chains";
|
|
7
|
+
import type { BotConfig } from "../config";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Creates a robust public client with fallback RPC endpoints
|
|
11
|
+
*/
|
|
12
|
+
export function createRobustClient(config: BotConfig): PublicClient {
|
|
13
|
+
const transports = [
|
|
14
|
+
http(config.rpcUrl),
|
|
15
|
+
...config.rpcFallbackUrls.map((url) => http(url)),
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
return createPublicClient({
|
|
19
|
+
chain: bsc,
|
|
20
|
+
transport: fallback(transports, {
|
|
21
|
+
retryCount: config.maxRetries,
|
|
22
|
+
retryDelay: config.retryDelayMs,
|
|
23
|
+
}),
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Retry utility for SDK/RPC calls with exponential backoff
|
|
29
|
+
*/
|
|
30
|
+
export async function withRetry<T>(
|
|
31
|
+
fn: () => Promise<T>,
|
|
32
|
+
config: BotConfig,
|
|
33
|
+
context: string = "operation"
|
|
34
|
+
): Promise<T> {
|
|
35
|
+
let lastError: Error | undefined;
|
|
36
|
+
let delay = config.retryDelayMs;
|
|
37
|
+
|
|
38
|
+
for (let attempt = 1; attempt <= config.maxRetries; attempt++) {
|
|
39
|
+
try {
|
|
40
|
+
return await fn();
|
|
41
|
+
} catch (error) {
|
|
42
|
+
lastError = error as Error;
|
|
43
|
+
console.warn(
|
|
44
|
+
`[${context}] Attempt ${attempt}/${config.maxRetries} failed: ${lastError.message}`
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
if (attempt < config.maxRetries) {
|
|
48
|
+
await sleep(delay);
|
|
49
|
+
delay = Math.floor(delay * config.retryBackoffMultiplier);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
throw new Error(
|
|
55
|
+
`[${context}] Failed after ${config.maxRetries} attempts: ${lastError?.message}`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function sleep(ms: number): Promise<void> {
|
|
60
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
61
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slack Webhook Service
|
|
3
|
+
* Sends messages to Slack with rate limiting
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface SlackField {
|
|
7
|
+
title: string;
|
|
8
|
+
value: string;
|
|
9
|
+
short?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SlackAttachment {
|
|
13
|
+
color: string;
|
|
14
|
+
title: string;
|
|
15
|
+
text?: string;
|
|
16
|
+
fields?: SlackField[];
|
|
17
|
+
ts?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SlackMessage {
|
|
21
|
+
text?: string;
|
|
22
|
+
attachments?: SlackAttachment[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class SlackService {
|
|
26
|
+
private webhookUrl: string;
|
|
27
|
+
private rateLimitMs: number;
|
|
28
|
+
private lastSentAt: number = 0;
|
|
29
|
+
private queue: SlackMessage[] = [];
|
|
30
|
+
private isProcessing: boolean = false;
|
|
31
|
+
|
|
32
|
+
constructor(webhookUrl: string, rateLimitMs: number = 1000) {
|
|
33
|
+
this.webhookUrl = webhookUrl;
|
|
34
|
+
this.rateLimitMs = rateLimitMs;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async send(message: SlackMessage): Promise<void> {
|
|
38
|
+
this.queue.push(message);
|
|
39
|
+
await this.processQueue();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private async processQueue(): Promise<void> {
|
|
43
|
+
if (this.isProcessing || this.queue.length === 0) return;
|
|
44
|
+
|
|
45
|
+
this.isProcessing = true;
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
while (this.queue.length > 0) {
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
const timeSinceLastSend = now - this.lastSentAt;
|
|
51
|
+
|
|
52
|
+
if (timeSinceLastSend < this.rateLimitMs) {
|
|
53
|
+
await this.sleep(this.rateLimitMs - timeSinceLastSend);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const message = this.queue.shift()!;
|
|
57
|
+
await this.sendImmediate(message);
|
|
58
|
+
this.lastSentAt = Date.now();
|
|
59
|
+
}
|
|
60
|
+
} finally {
|
|
61
|
+
this.isProcessing = false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private async sendImmediate(message: SlackMessage): Promise<void> {
|
|
66
|
+
try {
|
|
67
|
+
const response = await fetch(this.webhookUrl, {
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: { "Content-Type": "application/json" },
|
|
70
|
+
body: JSON.stringify(message),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
console.error(
|
|
75
|
+
`Slack webhook failed: ${response.status} ${response.statusText}`
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
} catch (error) {
|
|
79
|
+
console.error("Failed to send Slack message:", error);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private sleep(ms: number): Promise<void> {
|
|
84
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Color constants for different alert levels
|
|
89
|
+
export const SlackColors = {
|
|
90
|
+
success: "#36a64f", // Green
|
|
91
|
+
warning: "#ffcc00", // Yellow
|
|
92
|
+
danger: "#ff0000", // Red
|
|
93
|
+
info: "#2196f3", // Blue
|
|
94
|
+
neutral: "#808080", // Gray
|
|
95
|
+
} as const;
|