@aspan/sdk 0.4.0 → 0.4.1
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 +44 -4
- package/dist/index.d.mts +165 -5
- package/dist/index.d.ts +165 -5
- package/dist/index.js +170 -5
- package/dist/index.mjs +170 -5
- package/package.json +1 -1
- package/src/__tests__/fork.test.ts +2 -2
- package/src/abi/diamond.ts +74 -0
- package/src/abi/sApUSD.ts +100 -0
- package/src/bot/config.ts +51 -1
- package/src/bot/index.ts +69 -1
- package/src/bot/services/fee-manager.ts +303 -0
- package/src/bot/services/risk-keeper.ts +519 -0
- package/src/client.ts +109 -0
- package/src/index.ts +5 -5
|
@@ -0,0 +1,519 @@
|
|
|
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`);
|
|
409
|
+
|
|
410
|
+
// Build swap path: xBNB -> WBNB -> apUSD
|
|
411
|
+
// Note: In production, might need xBNB -> WBNB -> USDT -> apUSD
|
|
412
|
+
const path = [
|
|
413
|
+
await this.getXBNBAddress(),
|
|
414
|
+
this.keeperConfig.wbnbAddress,
|
|
415
|
+
await this.getApUSDAddress(),
|
|
416
|
+
];
|
|
417
|
+
|
|
418
|
+
// Preview output
|
|
419
|
+
const expectedOut = await this.previewClean(xBNBAmount, path);
|
|
420
|
+
|
|
421
|
+
// Calculate min out with slippage
|
|
422
|
+
const minOut = (expectedOut * BigInt(10000 - this.MAX_SLIPPAGE_BPS)) / 10000n;
|
|
423
|
+
|
|
424
|
+
// Deadline: 5 minutes from now
|
|
425
|
+
const deadline = BigInt(Math.floor(Date.now() / 1000) + 300);
|
|
426
|
+
|
|
427
|
+
return this.walletClient.writeContract({
|
|
428
|
+
chain: bsc,
|
|
429
|
+
account: this.account, address: this.keeperConfig.sApUSDAddress,
|
|
430
|
+
abi: SApUSDABI,
|
|
431
|
+
functionName: "cleanXBNB",
|
|
432
|
+
args: [xBNBAmount, minOut, this.keeperConfig.routerAddress, path, deadline],
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private async previewClean(xBNBAmount: bigint, path: Address[]): Promise<bigint> {
|
|
437
|
+
return this.publicClient.readContract({
|
|
438
|
+
address: this.keeperConfig.sApUSDAddress,
|
|
439
|
+
abi: SApUSDABI,
|
|
440
|
+
functionName: "previewCleanXBNB",
|
|
441
|
+
args: [xBNBAmount, this.keeperConfig.routerAddress, path],
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private async getXBNBAddress(): Promise<Address> {
|
|
446
|
+
const tokens = await this.client.getTokens();
|
|
447
|
+
return tokens.xBNB;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
private async getApUSDAddress(): Promise<Address> {
|
|
451
|
+
const tokens = await this.client.getTokens();
|
|
452
|
+
return tokens.apUSD;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ============ Notifications ============
|
|
456
|
+
|
|
457
|
+
private async notifySM2Success(cr: bigint, amount: bigint, txHash: Hash): Promise<void> {
|
|
458
|
+
console.log(`[RiskKeeper] SM2 executed successfully: ${txHash}`);
|
|
459
|
+
|
|
460
|
+
await this.slack.send({
|
|
461
|
+
attachments: [{
|
|
462
|
+
color: SlackColors.warning,
|
|
463
|
+
title: ":rotating_light: Stability Mode 2 Triggered",
|
|
464
|
+
text: "Forced conversion executed to restore CR",
|
|
465
|
+
fields: [
|
|
466
|
+
{ title: "CR Before", value: `${Number(cr) / 100}%`, short: true },
|
|
467
|
+
{ title: "Amount Converted", value: `${Number(amount) / 1e18} apUSD`, short: true },
|
|
468
|
+
{ title: "TX", value: `<https://bscscan.com/tx/${txHash}|View>`, short: true },
|
|
469
|
+
],
|
|
470
|
+
ts: Math.floor(Date.now() / 1000),
|
|
471
|
+
}],
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
private async notifySM2Failure(txHash: Hash | undefined, error: string): Promise<void> {
|
|
476
|
+
console.error(`[RiskKeeper] SM2 execution failed: ${error}`);
|
|
477
|
+
|
|
478
|
+
await this.slack.send({
|
|
479
|
+
attachments: [{
|
|
480
|
+
color: SlackColors.danger,
|
|
481
|
+
title: ":x: SM2 Execution Failed",
|
|
482
|
+
text: error,
|
|
483
|
+
fields: txHash ? [{ title: "TX", value: `<https://bscscan.com/tx/${txHash}|View>`, short: true }] : [],
|
|
484
|
+
ts: Math.floor(Date.now() / 1000),
|
|
485
|
+
}],
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
private async notifyTWAPSuccess(amount: bigint, txHash: Hash): Promise<void> {
|
|
490
|
+
console.log(`[RiskKeeper] TWAP clean executed successfully: ${txHash}`);
|
|
491
|
+
|
|
492
|
+
await this.slack.send({
|
|
493
|
+
attachments: [{
|
|
494
|
+
color: SlackColors.success,
|
|
495
|
+
title: ":broom: Vault TWAP Clean Executed",
|
|
496
|
+
text: "xBNB cleaned from stability pool vault",
|
|
497
|
+
fields: [
|
|
498
|
+
{ title: "xBNB Sold", value: `${Number(amount) / 1e18}`, short: true },
|
|
499
|
+
{ title: "TX", value: `<https://bscscan.com/tx/${txHash}|View>`, short: true },
|
|
500
|
+
],
|
|
501
|
+
ts: Math.floor(Date.now() / 1000),
|
|
502
|
+
}],
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
private async notifyTWAPFailure(txHash: Hash | undefined, error: string): Promise<void> {
|
|
507
|
+
console.error(`[RiskKeeper] TWAP execution failed: ${error}`);
|
|
508
|
+
|
|
509
|
+
await this.slack.send({
|
|
510
|
+
attachments: [{
|
|
511
|
+
color: SlackColors.danger,
|
|
512
|
+
title: ":x: TWAP Clean Failed",
|
|
513
|
+
text: error,
|
|
514
|
+
fields: txHash ? [{ title: "TX", value: `<https://bscscan.com/tx/${txHash}|View>`, short: true }] : [],
|
|
515
|
+
ts: Math.floor(Date.now() / 1000),
|
|
516
|
+
}],
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
}
|
package/src/client.ts
CHANGED
|
@@ -798,6 +798,32 @@ export class AspanReadClient {
|
|
|
798
798
|
});
|
|
799
799
|
}
|
|
800
800
|
|
|
801
|
+
// ============ Bootstrap View Functions ============
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Check if xBNB liquidity has been bootstrapped
|
|
805
|
+
* @returns true if bootstrap has been called
|
|
806
|
+
*/
|
|
807
|
+
async isBootstrapped(): Promise<boolean> {
|
|
808
|
+
return this.publicClient.readContract({
|
|
809
|
+
address: this.diamondAddress,
|
|
810
|
+
abi: DiamondABI,
|
|
811
|
+
functionName: "isBootstrapped",
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* Get the amount of xBNB locked in bootstrap (burned to dead address)
|
|
817
|
+
* @returns xBNB amount in wei
|
|
818
|
+
*/
|
|
819
|
+
async getBootstrapXBNBAmount(): Promise<bigint> {
|
|
820
|
+
return this.publicClient.readContract({
|
|
821
|
+
address: this.diamondAddress,
|
|
822
|
+
abi: DiamondABI,
|
|
823
|
+
functionName: "getBootstrapXBNBAmount",
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
|
|
801
827
|
// ============ Stability Mode View Functions ============
|
|
802
828
|
|
|
803
829
|
async getStabilityMode(): Promise<StabilityModeInfo> {
|
|
@@ -1031,6 +1057,89 @@ export class AspanClient extends AspanReadClient {
|
|
|
1031
1057
|
});
|
|
1032
1058
|
}
|
|
1033
1059
|
|
|
1060
|
+
// ============ Risk Management Functions ============
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* Trigger Stability Mode 2 forced conversion
|
|
1064
|
+
* @description Anyone can call when CR < 130%. Burns apUSD from stability pool
|
|
1065
|
+
* and mints xBNB to compensate stakers.
|
|
1066
|
+
* @param targetCR Target CR to restore to (in BPS, e.g., 14000 = 140%)
|
|
1067
|
+
* @returns Transaction hash
|
|
1068
|
+
*/
|
|
1069
|
+
async triggerStabilityMode2(targetCR: bigint = 14000n): Promise<Hash> {
|
|
1070
|
+
return this.walletClient.writeContract({
|
|
1071
|
+
chain: this.chain,
|
|
1072
|
+
account: this.walletClient.account!,
|
|
1073
|
+
address: this.diamondAddress,
|
|
1074
|
+
abi: DiamondABI,
|
|
1075
|
+
functionName: "triggerStabilityMode2",
|
|
1076
|
+
args: [targetCR],
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* Clean underwater xBNB (burn without getting LST back)
|
|
1082
|
+
* @description Only works when xBNB is underwater (price = 0)
|
|
1083
|
+
* @param xBNBAmount Amount of xBNB to clean (burn)
|
|
1084
|
+
* @returns Transaction hash
|
|
1085
|
+
*/
|
|
1086
|
+
async cleanXbnb(xBNBAmount: bigint): Promise<Hash> {
|
|
1087
|
+
return this.walletClient.writeContract({
|
|
1088
|
+
chain: this.chain,
|
|
1089
|
+
account: this.walletClient.account!,
|
|
1090
|
+
address: this.diamondAddress,
|
|
1091
|
+
abi: DiamondABI,
|
|
1092
|
+
functionName: "cleanXbnb",
|
|
1093
|
+
args: [xBNBAmount],
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// ============ Fee Management Functions ============
|
|
1098
|
+
|
|
1099
|
+
/**
|
|
1100
|
+
* Set fee tiers (requires feeManager or owner role)
|
|
1101
|
+
* @param tiers Array of fee tier configurations
|
|
1102
|
+
* @returns Transaction hash
|
|
1103
|
+
*/
|
|
1104
|
+
async setFeeTiers(tiers: Array<{
|
|
1105
|
+
minCR: bigint;
|
|
1106
|
+
apUSDMintFee: number;
|
|
1107
|
+
apUSDRedeemFee: number;
|
|
1108
|
+
xBNBMintFee: number;
|
|
1109
|
+
xBNBRedeemFee: number;
|
|
1110
|
+
apUSDMintDisabled: boolean;
|
|
1111
|
+
}>): Promise<Hash> {
|
|
1112
|
+
return this.walletClient.writeContract({
|
|
1113
|
+
chain: this.chain,
|
|
1114
|
+
account: this.walletClient.account!,
|
|
1115
|
+
address: this.diamondAddress,
|
|
1116
|
+
abi: DiamondABI,
|
|
1117
|
+
functionName: "setFeeTiers",
|
|
1118
|
+
args: [tiers],
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// ============ Admin Functions ============
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* Bootstrap xBNB liquidity (owner only)
|
|
1126
|
+
* @description Initializes xBNB supply by minting to dead address, preventing extreme initial prices.
|
|
1127
|
+
* Similar to Uniswap V2's MINIMUM_LIQUIDITY. Can only be called once.
|
|
1128
|
+
* @param lstToken LST token address to deposit
|
|
1129
|
+
* @param lstAmount Amount of LST to deposit for bootstrap
|
|
1130
|
+
* @returns Transaction hash
|
|
1131
|
+
*/
|
|
1132
|
+
async bootstrap(lstToken: Address, lstAmount: bigint): Promise<Hash> {
|
|
1133
|
+
return this.walletClient.writeContract({
|
|
1134
|
+
chain: this.chain,
|
|
1135
|
+
account: this.walletClient.account!,
|
|
1136
|
+
address: this.diamondAddress,
|
|
1137
|
+
abi: DiamondABI,
|
|
1138
|
+
functionName: "bootstrap",
|
|
1139
|
+
args: [lstToken, lstAmount],
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1034
1143
|
// ============ Transaction Helpers ============
|
|
1035
1144
|
|
|
1036
1145
|
/**
|
package/src/index.ts
CHANGED
|
@@ -102,15 +102,15 @@ export const PRICE_PRECISION = 10n ** 8n; // Chainlink price precision
|
|
|
102
102
|
|
|
103
103
|
// ============ Contract Addresses (BSC Mainnet) ============
|
|
104
104
|
export const BSC_ADDRESSES = {
|
|
105
|
-
diamond: "
|
|
105
|
+
diamond: "0x6a11B30d3a70727d5477D6d8090e144443fA1c78" as const,
|
|
106
106
|
router: "0x813d3D1A3154950E2f1d8718305426a335A974A9" as const,
|
|
107
|
-
apUSD: "
|
|
108
|
-
xBNB: "
|
|
109
|
-
sApUSD: "
|
|
107
|
+
apUSD: "0x4570047eeB5aDb4081c5d470494EB5134e34A287" as const,
|
|
108
|
+
xBNB: "0x0A0c9CD826e747D99F90D63e780B3727Da4D0d43" as const,
|
|
109
|
+
sApUSD: "0x73407A291c007a47CC926EcD5CaC256A1E2d00cF" as const,
|
|
110
110
|
// LSTs
|
|
111
111
|
slisBNB: "0xB0b84D294e0C75A6abe60171b70edEb2EFd14A1B" as const,
|
|
112
112
|
asBNB: "0x77734e70b6E88b4d82fE632a168EDf6e700912b6" as const,
|
|
113
|
-
wclisBNB: "
|
|
113
|
+
wclisBNB: "0x448f7c2fa4e5135a4a5B50879602cf3CD428e108" as const,
|
|
114
114
|
// Stablecoins
|
|
115
115
|
USDT: "0x55d398326f99059fF775485246999027B3197955" as const,
|
|
116
116
|
USDC: "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d" as const,
|