@aspan/sdk 0.4.0 → 0.4.2

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,517 @@
1
+ /**
2
+ * Risk Keeper Service
3
+ * Executes risk management actions: SM2 trigger and TWAP vault cleaning
4
+ */
5
+
6
+ import {
7
+ createPublicClient,
8
+ createWalletClient,
9
+ http,
10
+ type Address,
11
+ type PublicClient,
12
+ type WalletClient,
13
+ type Chain,
14
+ type Account,
15
+ type Hash,
16
+ } from "viem";
17
+ import { privateKeyToAccount } from "viem/accounts";
18
+ import { bsc } from "viem/chains";
19
+ import type { AspanReadClient } from "../../client";
20
+ import { DiamondABI } from "../../abi/diamond";
21
+ import { SApUSDABI } from "../../abi/sApUSD";
22
+ import type { SlackService } from "./slack";
23
+ import { SlackColors } from "./slack";
24
+ import type { BotConfig } from "../config";
25
+ import { withRetry } from "./rpc-client";
26
+
27
+ // ============ Types ============
28
+
29
+ export interface RiskKeeperConfig {
30
+ /** Diamond contract address */
31
+ diamondAddress: Address;
32
+ /** sApUSD vault address */
33
+ sApUSDAddress: Address;
34
+ /** DEX router address (PancakeSwap) */
35
+ routerAddress: Address;
36
+ /** WBNB address */
37
+ wbnbAddress: Address;
38
+ /** Keeper private key */
39
+ privateKey: `0x${string}`;
40
+ /** RPC URL */
41
+ rpcUrl: string;
42
+ /** Chain */
43
+ chain?: Chain;
44
+ /** SM2 cooldown in ms (default 30 min) */
45
+ sm2CooldownMs?: number;
46
+ /** SM2 max pool percentage per trigger (default 40%) */
47
+ sm2MaxPoolPercent?: number;
48
+ /** TWAP interval in ms (default 10 min) */
49
+ twapIntervalMs?: number;
50
+ /** TWAP percentage per batch (default 5%) */
51
+ twapPercentage?: number;
52
+ /** Max slippage BPS (default 50 = 0.5%) */
53
+ maxSlippageBps?: number;
54
+ /** CR threshold to start TWAP cleaning (default 13500 = 135%) */
55
+ twapStartCR?: bigint;
56
+ /** Max BNB price volatility for TWAP (default 5%) */
57
+ maxVolatilityPercent?: number;
58
+ }
59
+
60
+ interface KeeperState {
61
+ lastSM2Trigger: number;
62
+ lastTWAPExecution: number;
63
+ isProcessing: boolean;
64
+ }
65
+
66
+ // ============ Risk Keeper Service ============
67
+
68
+ export class RiskKeeperService {
69
+ private readonly client: AspanReadClient;
70
+ private readonly publicClient: PublicClient;
71
+ private readonly walletClient: WalletClient;
72
+ private readonly slack: SlackService;
73
+ private readonly config: BotConfig;
74
+ private readonly keeperConfig: RiskKeeperConfig;
75
+ private readonly account: Account;
76
+
77
+ private state: KeeperState = {
78
+ lastSM2Trigger: 0,
79
+ lastTWAPExecution: 0,
80
+ isProcessing: false,
81
+ };
82
+
83
+ private sm2IntervalId?: ReturnType<typeof setInterval>;
84
+ private twapIntervalId?: ReturnType<typeof setInterval>;
85
+
86
+ // Defaults
87
+ private readonly SM2_COOLDOWN_MS: number;
88
+ private readonly SM2_MAX_POOL_PERCENT: number;
89
+ private readonly TWAP_INTERVAL_MS: number;
90
+ private readonly TWAP_PERCENTAGE: number;
91
+ private readonly MAX_SLIPPAGE_BPS: number;
92
+ private readonly TWAP_START_CR: bigint;
93
+ private readonly MAX_VOLATILITY_PERCENT: number;
94
+
95
+ // Price tracking for volatility
96
+ private lastBNBPrice: bigint = 0n;
97
+ /** Timestamp of last BNB price check (used for volatility tracking) */
98
+ public lastPriceTime: number = 0;
99
+
100
+ constructor(
101
+ client: AspanReadClient,
102
+ slack: SlackService,
103
+ botConfig: BotConfig,
104
+ keeperConfig: RiskKeeperConfig
105
+ ) {
106
+ this.client = client;
107
+ this.slack = slack;
108
+ this.config = botConfig;
109
+ this.keeperConfig = keeperConfig;
110
+
111
+ // Set defaults
112
+ this.SM2_COOLDOWN_MS = keeperConfig.sm2CooldownMs ?? 30 * 60 * 1000; // 30 min
113
+ this.SM2_MAX_POOL_PERCENT = keeperConfig.sm2MaxPoolPercent ?? 40; // 40% max per trigger
114
+ this.TWAP_INTERVAL_MS = keeperConfig.twapIntervalMs ?? 10 * 60 * 1000; // 10 min
115
+ this.TWAP_PERCENTAGE = keeperConfig.twapPercentage ?? 5; // 5%
116
+ this.MAX_SLIPPAGE_BPS = keeperConfig.maxSlippageBps ?? 50; // 0.5%
117
+ this.TWAP_START_CR = keeperConfig.twapStartCR ?? 13500n; // 135%
118
+ this.MAX_VOLATILITY_PERCENT = keeperConfig.maxVolatilityPercent ?? 5; // 5%
119
+
120
+ // Setup account and clients
121
+ this.account = privateKeyToAccount(keeperConfig.privateKey);
122
+ const chain = keeperConfig.chain ?? bsc;
123
+
124
+ this.publicClient = createPublicClient({
125
+ chain,
126
+ transport: http(keeperConfig.rpcUrl),
127
+ });
128
+
129
+ this.walletClient = createWalletClient({
130
+ chain,
131
+ transport: http(keeperConfig.rpcUrl),
132
+ account: this.account,
133
+ });
134
+
135
+ console.log(`[RiskKeeper] Initialized with address: ${this.account.address}`);
136
+ }
137
+
138
+ // ============ Lifecycle ============
139
+
140
+ start(): void {
141
+ console.log("[RiskKeeper] Starting risk keeper service...");
142
+ console.log(`[RiskKeeper] SM2 cooldown: ${this.SM2_COOLDOWN_MS / 1000}s`);
143
+ console.log(`[RiskKeeper] TWAP interval: ${this.TWAP_INTERVAL_MS / 1000}s`);
144
+ console.log(`[RiskKeeper] TWAP percentage: ${this.TWAP_PERCENTAGE}%`);
145
+
146
+ // Initial checks
147
+ this.checkAndTriggerSM2();
148
+ this.checkAndExecuteTWAP();
149
+
150
+ // Schedule regular checks
151
+ this.sm2IntervalId = setInterval(
152
+ () => this.checkAndTriggerSM2(),
153
+ 30 * 1000 // Check every 30 seconds
154
+ );
155
+
156
+ this.twapIntervalId = setInterval(
157
+ () => this.checkAndExecuteTWAP(),
158
+ this.TWAP_INTERVAL_MS
159
+ );
160
+
161
+ console.log("[RiskKeeper] Service started");
162
+ }
163
+
164
+ stop(): void {
165
+ if (this.sm2IntervalId) {
166
+ clearInterval(this.sm2IntervalId);
167
+ this.sm2IntervalId = undefined;
168
+ }
169
+ if (this.twapIntervalId) {
170
+ clearInterval(this.twapIntervalId);
171
+ this.twapIntervalId = undefined;
172
+ }
173
+ console.log("[RiskKeeper] Service stopped");
174
+ }
175
+
176
+ // ============ Stability Mode 2 ============
177
+
178
+ private async checkAndTriggerSM2(): Promise<void> {
179
+ if (this.state.isProcessing) return;
180
+
181
+ try {
182
+ // Check cooldown
183
+ const now = Date.now();
184
+ if (now - this.state.lastSM2Trigger < this.SM2_COOLDOWN_MS) {
185
+ return;
186
+ }
187
+
188
+ // Check if SM2 can be triggered
189
+ const sm2Info = await withRetry(
190
+ () => this.client.canTriggerStabilityMode2(),
191
+ this.config,
192
+ "canTriggerStabilityMode2"
193
+ );
194
+
195
+ if (!sm2Info.canTrigger) {
196
+ return;
197
+ }
198
+
199
+ // Check if stability pool has balance
200
+ if (sm2Info.potentialConversion === 0n) {
201
+ console.log("[RiskKeeper] SM2 triggered but no conversion possible (empty pool)");
202
+ return;
203
+ }
204
+
205
+ // Get pool balance and enforce 40% max
206
+ const poolStats = await withRetry(
207
+ () => this.client.getStabilityPoolStats(),
208
+ this.config,
209
+ "getStabilityPoolStats"
210
+ );
211
+
212
+ const maxConversion = (poolStats.totalStaked * BigInt(this.SM2_MAX_POOL_PERCENT)) / 100n;
213
+
214
+ // If potential conversion exceeds 40%, we need a lower target CR
215
+ // For simplicity, we'll just warn and proceed - the contract will cap at pool balance anyway
216
+ if (sm2Info.potentialConversion > maxConversion) {
217
+ console.log(`[RiskKeeper] Potential conversion ${sm2Info.potentialConversion} exceeds ${this.SM2_MAX_POOL_PERCENT}% limit (${maxConversion})`);
218
+ console.log(`[RiskKeeper] Will execute with reduced target to stay within limit`);
219
+ }
220
+
221
+ console.log(`[RiskKeeper] SM2 conditions met! CR: ${sm2Info.currentCR}, Potential: ${sm2Info.potentialConversion}`);
222
+
223
+ this.state.isProcessing = true;
224
+
225
+ // Calculate target CR that respects the 40% limit
226
+ // If potentialConversion > maxConversion, use a lower target
227
+ let targetCR = 14000n; // Default 140%
228
+ if (sm2Info.potentialConversion > maxConversion && maxConversion > 0n) {
229
+ // Calculate what CR we'd get with maxConversion
230
+ // This is approximate - actual CR depends on TVL which we fetch
231
+ const tvl = await withRetry(
232
+ () => this.client.getTVLInUSD(),
233
+ this.config,
234
+ "getTVLInUSD"
235
+ );
236
+ const apUSDSupply = await withRetry(
237
+ () => this.client.getApUSDSupply(),
238
+ this.config,
239
+ "getApUSDSupply"
240
+ );
241
+
242
+ // New supply after burning maxConversion
243
+ const newSupply = apUSDSupply - maxConversion;
244
+ if (newSupply > 0n) {
245
+ // targetCR = TVL * 10000 / newSupply
246
+ targetCR = (tvl * 10000n) / newSupply;
247
+ // Cap at 140% max
248
+ if (targetCR > 14000n) targetCR = 14000n;
249
+ }
250
+ console.log(`[RiskKeeper] Adjusted target CR to ${targetCR} to respect ${this.SM2_MAX_POOL_PERCENT}% limit`);
251
+ }
252
+
253
+ // Execute SM2
254
+ const txHash = await this.executeSM2(targetCR);
255
+
256
+ // Wait for confirmation
257
+ const receipt = await this.publicClient.waitForTransactionReceipt({ hash: txHash });
258
+
259
+ if (receipt.status === "success") {
260
+ this.state.lastSM2Trigger = now;
261
+ await this.notifySM2Success(sm2Info.currentCR, sm2Info.potentialConversion, txHash);
262
+ } else {
263
+ await this.notifySM2Failure(txHash, "Transaction reverted");
264
+ }
265
+ } catch (error) {
266
+ console.error("[RiskKeeper] SM2 check/trigger failed:", error);
267
+ await this.notifySM2Failure(undefined, String(error));
268
+ } finally {
269
+ this.state.isProcessing = false;
270
+ }
271
+ }
272
+
273
+ private async executeSM2(targetCR: bigint): Promise<Hash> {
274
+ console.log(`[RiskKeeper] Executing SM2 with target CR: ${targetCR}`);
275
+
276
+ return this.walletClient.writeContract({
277
+ chain: bsc,
278
+ account: this.account, address: this.keeperConfig.diamondAddress,
279
+ abi: DiamondABI,
280
+ functionName: "triggerStabilityMode2",
281
+ args: [targetCR],
282
+ });
283
+ }
284
+
285
+ // ============ TWAP Vault Cleaning ============
286
+
287
+ private async checkAndExecuteTWAP(): Promise<void> {
288
+ if (this.state.isProcessing) return;
289
+
290
+ try {
291
+ // Check if vault is dirty (has xBNB)
292
+ const vaultState = await this.getVaultState();
293
+
294
+ if (!vaultState.hasXBNB || vaultState.xBNBAmount === 0n) {
295
+ return;
296
+ }
297
+
298
+ // Check CR is above threshold (safe to sell xBNB)
299
+ const sm = await withRetry(
300
+ () => this.client.getStabilityMode(),
301
+ this.config,
302
+ "getStabilityMode"
303
+ );
304
+
305
+ if (sm.currentCR < this.TWAP_START_CR) {
306
+ console.log(`[RiskKeeper] CR ${sm.currentCR} below TWAP threshold ${this.TWAP_START_CR}, skipping`);
307
+ return;
308
+ }
309
+
310
+ // Check BNB price volatility
311
+ const isVolatile = await this.checkVolatility();
312
+ if (isVolatile) {
313
+ console.log(`[RiskKeeper] BNB price too volatile (>${this.MAX_VOLATILITY_PERCENT}%), skipping TWAP`);
314
+ return;
315
+ }
316
+
317
+ console.log(`[RiskKeeper] Vault dirty with ${vaultState.xBNBAmount} xBNB, executing TWAP clean`);
318
+
319
+ this.state.isProcessing = true;
320
+
321
+ // Calculate amount to clean (X% of total)
322
+ const cleanAmount = (vaultState.xBNBAmount * BigInt(this.TWAP_PERCENTAGE)) / 100n;
323
+
324
+ if (cleanAmount === 0n) {
325
+ console.log("[RiskKeeper] Clean amount too small, skipping");
326
+ return;
327
+ }
328
+
329
+ // Execute TWAP clean
330
+ const txHash = await this.executeVaultClean(cleanAmount);
331
+
332
+ // Wait for confirmation
333
+ const receipt = await this.publicClient.waitForTransactionReceipt({ hash: txHash });
334
+
335
+ if (receipt.status === "success") {
336
+ this.state.lastTWAPExecution = Date.now();
337
+ await this.notifyTWAPSuccess(cleanAmount, txHash);
338
+ } else {
339
+ await this.notifyTWAPFailure(txHash, "Transaction reverted");
340
+ }
341
+ } catch (error) {
342
+ console.error("[RiskKeeper] TWAP check/execute failed:", error);
343
+ await this.notifyTWAPFailure(undefined, String(error));
344
+ } finally {
345
+ this.state.isProcessing = false;
346
+ }
347
+ }
348
+
349
+ /**
350
+ * Check if BNB price is too volatile for TWAP execution
351
+ * Returns true if volatility exceeds threshold
352
+ */
353
+ private async checkVolatility(): Promise<boolean> {
354
+ try {
355
+ const currentPrice = await withRetry(
356
+ () => this.client.getBNBPriceUSD(),
357
+ this.config,
358
+ "getBNBPriceUSD"
359
+ );
360
+
361
+ const now = Date.now();
362
+
363
+ // First run - just record price
364
+ if (this.lastBNBPrice === 0n) {
365
+ this.lastBNBPrice = currentPrice;
366
+ this.lastPriceTime = now;
367
+ return false;
368
+ }
369
+
370
+ // Calculate price change percentage
371
+ const priceDiff = currentPrice > this.lastBNBPrice
372
+ ? currentPrice - this.lastBNBPrice
373
+ : this.lastBNBPrice - currentPrice;
374
+
375
+ const changePercent = Number((priceDiff * 100n) / this.lastBNBPrice);
376
+
377
+ // Update tracked price
378
+ this.lastBNBPrice = currentPrice;
379
+ this.lastPriceTime = now;
380
+
381
+ if (changePercent > this.MAX_VOLATILITY_PERCENT) {
382
+ console.log(`[RiskKeeper] Volatility: ${changePercent.toFixed(2)}% > ${this.MAX_VOLATILITY_PERCENT}%`);
383
+ return true;
384
+ }
385
+
386
+ return false;
387
+ } catch (error) {
388
+ console.error("[RiskKeeper] Volatility check failed:", error);
389
+ // If we can't check volatility, be conservative and skip
390
+ return true;
391
+ }
392
+ }
393
+
394
+ private async getVaultState(): Promise<{ hasXBNB: boolean; xBNBAmount: bigint }> {
395
+ const result = await this.publicClient.readContract({
396
+ address: this.keeperConfig.sApUSDAddress,
397
+ abi: SApUSDABI,
398
+ functionName: "hasStabilityConversion",
399
+ });
400
+
401
+ return {
402
+ hasXBNB: result[0],
403
+ xBNBAmount: result[1],
404
+ };
405
+ }
406
+
407
+ private async executeVaultClean(xBNBAmount: bigint): Promise<Hash> {
408
+ console.log(`[RiskKeeper] Cleaning ${xBNBAmount} xBNB from vault via Diamond internal conversion`);
409
+
410
+ // Preview output
411
+ const expectedOut = await this.previewClean(xBNBAmount);
412
+
413
+ // Calculate min out with slippage
414
+ const minOut = (expectedOut * BigInt(10000 - this.MAX_SLIPPAGE_BPS)) / 10000n;
415
+
416
+ console.log(`[RiskKeeper] Expected apUSD out: ${Number(expectedOut) / 1e18}, min: ${Number(minOut) / 1e18}`);
417
+
418
+ // Call Diamond's cleanVaultXBNB (internal burn xBNB + mint apUSD)
419
+ return this.walletClient.writeContract({
420
+ chain: bsc,
421
+ account: this.account,
422
+ address: this.client.diamondAddress,
423
+ abi: [{
424
+ type: "function",
425
+ name: "cleanVaultXBNB",
426
+ inputs: [
427
+ { name: "_xBNBAmount", type: "uint256" },
428
+ { name: "_minApUSDOut", type: "uint256" },
429
+ ],
430
+ outputs: [{ name: "apUSDMinted", type: "uint256" }],
431
+ stateMutability: "nonpayable",
432
+ }],
433
+ functionName: "cleanVaultXBNB",
434
+ args: [xBNBAmount, minOut],
435
+ });
436
+ }
437
+
438
+ private async previewClean(xBNBAmount: bigint): Promise<bigint> {
439
+ return this.publicClient.readContract({
440
+ address: this.client.diamondAddress,
441
+ abi: [{
442
+ type: "function",
443
+ name: "previewCleanVaultXBNB",
444
+ inputs: [{ name: "_xBNBAmount", type: "uint256" }],
445
+ outputs: [{ name: "apUSDOut", type: "uint256" }],
446
+ stateMutability: "view",
447
+ }],
448
+ functionName: "previewCleanVaultXBNB",
449
+ args: [xBNBAmount],
450
+ });
451
+ }
452
+
453
+ // ============ Notifications ============
454
+
455
+ private async notifySM2Success(cr: bigint, amount: bigint, txHash: Hash): Promise<void> {
456
+ console.log(`[RiskKeeper] SM2 executed successfully: ${txHash}`);
457
+
458
+ await this.slack.send({
459
+ attachments: [{
460
+ color: SlackColors.warning,
461
+ title: ":rotating_light: Stability Mode 2 Triggered",
462
+ text: "Forced conversion executed to restore CR",
463
+ fields: [
464
+ { title: "CR Before", value: `${Number(cr) / 100}%`, short: true },
465
+ { title: "Amount Converted", value: `${Number(amount) / 1e18} apUSD`, short: true },
466
+ { title: "TX", value: `<https://bscscan.com/tx/${txHash}|View>`, short: true },
467
+ ],
468
+ ts: Math.floor(Date.now() / 1000),
469
+ }],
470
+ });
471
+ }
472
+
473
+ private async notifySM2Failure(txHash: Hash | undefined, error: string): Promise<void> {
474
+ console.error(`[RiskKeeper] SM2 execution failed: ${error}`);
475
+
476
+ await this.slack.send({
477
+ attachments: [{
478
+ color: SlackColors.danger,
479
+ title: ":x: SM2 Execution Failed",
480
+ text: error,
481
+ fields: txHash ? [{ title: "TX", value: `<https://bscscan.com/tx/${txHash}|View>`, short: true }] : [],
482
+ ts: Math.floor(Date.now() / 1000),
483
+ }],
484
+ });
485
+ }
486
+
487
+ private async notifyTWAPSuccess(amount: bigint, txHash: Hash): Promise<void> {
488
+ console.log(`[RiskKeeper] TWAP clean executed successfully: ${txHash}`);
489
+
490
+ await this.slack.send({
491
+ attachments: [{
492
+ color: SlackColors.success,
493
+ title: ":broom: Vault TWAP Clean Executed",
494
+ text: "xBNB cleaned from stability pool vault",
495
+ fields: [
496
+ { title: "xBNB Sold", value: `${Number(amount) / 1e18}`, short: true },
497
+ { title: "TX", value: `<https://bscscan.com/tx/${txHash}|View>`, short: true },
498
+ ],
499
+ ts: Math.floor(Date.now() / 1000),
500
+ }],
501
+ });
502
+ }
503
+
504
+ private async notifyTWAPFailure(txHash: Hash | undefined, error: string): Promise<void> {
505
+ console.error(`[RiskKeeper] TWAP execution failed: ${error}`);
506
+
507
+ await this.slack.send({
508
+ attachments: [{
509
+ color: SlackColors.danger,
510
+ title: ":x: TWAP Clean Failed",
511
+ text: error,
512
+ fields: txHash ? [{ title: "TX", value: `<https://bscscan.com/tx/${txHash}|View>`, short: true }] : [],
513
+ ts: Math.floor(Date.now() / 1000),
514
+ }],
515
+ });
516
+ }
517
+ }