@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/src/bot/config.ts CHANGED
@@ -32,6 +32,29 @@ export interface BotConfig {
32
32
  maxRetries: number;
33
33
  retryDelayMs: number;
34
34
  retryBackoffMultiplier: number;
35
+
36
+ // Risk Keeper Configuration (optional)
37
+ riskKeeper?: {
38
+ enabled: boolean;
39
+ sApUSDAddress: Address;
40
+ routerAddress: Address;
41
+ wbnbAddress: Address;
42
+ privateKey: `0x${string}`;
43
+ sm2CooldownMs: number;
44
+ sm2MaxPoolPercent: number;
45
+ twapIntervalMs: number;
46
+ twapPercentage: number;
47
+ maxSlippageBps: number;
48
+ twapStartCR: bigint;
49
+ maxVolatilityPercent: number;
50
+ };
51
+
52
+ // Fee Manager Configuration (optional)
53
+ feeManager?: {
54
+ enabled: boolean;
55
+ privateKey: `0x${string}`;
56
+ checkIntervalMs: number;
57
+ };
35
58
  }
36
59
 
37
60
  export function loadConfig(): BotConfig {
@@ -50,7 +73,7 @@ export function loadConfig(): BotConfig {
50
73
 
51
74
  // Contract
52
75
  diamondAddress: (process.env.DIAMOND_ADDRESS ??
53
- "0x10d25Ae0690533e0BA9E64EC7ae77dbD4fE8A46f") as Address,
76
+ "0x6a11B30d3a70727d5477D6d8090e144443fA1c78") as Address,
54
77
 
55
78
  // Intervals
56
79
  statsReportInterval: parseInt(
@@ -80,5 +103,32 @@ export function loadConfig(): BotConfig {
80
103
  maxRetries: parseInt(process.env.MAX_RETRIES ?? "3", 10),
81
104
  retryDelayMs: parseInt(process.env.RETRY_DELAY_MS ?? "1000", 10),
82
105
  retryBackoffMultiplier: parseFloat(process.env.RETRY_BACKOFF ?? "2"),
106
+
107
+ // Risk Keeper (optional - only enabled if private key is provided)
108
+ riskKeeper: process.env.KEEPER_PRIVATE_KEY
109
+ ? {
110
+ enabled: process.env.RISK_KEEPER_ENABLED === "true",
111
+ sApUSDAddress: (process.env.SAPUSD_ADDRESS ?? "") as Address,
112
+ routerAddress: (process.env.ROUTER_ADDRESS ?? "0x10ED43C718714eb63d5aA57B78B54704E256024E") as Address, // PancakeSwap V2
113
+ wbnbAddress: (process.env.WBNB_ADDRESS ?? "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c") as Address,
114
+ privateKey: process.env.KEEPER_PRIVATE_KEY as `0x${string}`,
115
+ sm2CooldownMs: parseInt(process.env.SM2_COOLDOWN_MS ?? "1800000", 10), // 30 min
116
+ sm2MaxPoolPercent: parseInt(process.env.SM2_MAX_POOL_PERCENT ?? "40", 10), // 40%
117
+ twapIntervalMs: parseInt(process.env.TWAP_INTERVAL_MS ?? "600000", 10), // 10 min
118
+ twapPercentage: parseInt(process.env.TWAP_PERCENTAGE ?? "5", 10), // 5%
119
+ maxSlippageBps: parseInt(process.env.MAX_SLIPPAGE_BPS ?? "50", 10), // 0.5%
120
+ twapStartCR: BigInt(process.env.TWAP_START_CR ?? "13500"), // 135%
121
+ maxVolatilityPercent: parseInt(process.env.MAX_VOLATILITY_PERCENT ?? "5", 10), // 5%
122
+ }
123
+ : undefined,
124
+
125
+ // Fee Manager (optional - uses same or different private key)
126
+ feeManager: process.env.FEE_MANAGER_ENABLED === "true" && process.env.FEE_MANAGER_PRIVATE_KEY
127
+ ? {
128
+ enabled: true,
129
+ privateKey: process.env.FEE_MANAGER_PRIVATE_KEY as `0x${string}`,
130
+ checkIntervalMs: parseInt(process.env.FEE_CHECK_INTERVAL_MS ?? "60000", 10), // 1 min
131
+ }
132
+ : undefined,
83
133
  };
84
134
  }
package/src/bot/index.ts CHANGED
@@ -11,6 +11,8 @@ import { createRobustClient } from "./services/rpc-client";
11
11
  import { StatsMonitor } from "./monitors/stats-monitor";
12
12
  import { CRMonitor } from "./monitors/cr-monitor";
13
13
  import { MempoolMonitor } from "./monitors/mempool-monitor";
14
+ import { RiskKeeperService } from "./services/risk-keeper";
15
+ import { FeeManagerService } from "./services/fee-manager";
14
16
 
15
17
  class AspanMonitoringBot {
16
18
  private config = loadConfig();
@@ -18,6 +20,8 @@ class AspanMonitoringBot {
18
20
  private statsMonitor: StatsMonitor;
19
21
  private crMonitor: CRMonitor;
20
22
  private mempoolMonitor: MempoolMonitor;
23
+ private riskKeeper?: RiskKeeperService;
24
+ private feeManager?: FeeManagerService;
21
25
  private isRunning = false;
22
26
 
23
27
  constructor() {
@@ -48,6 +52,47 @@ class AspanMonitoringBot {
48
52
  this.config
49
53
  );
50
54
 
55
+ // Initialize Risk Keeper if enabled
56
+ if (this.config.riskKeeper?.enabled) {
57
+ console.log("Initializing Risk Keeper...");
58
+ this.riskKeeper = new RiskKeeperService(
59
+ aspanClient,
60
+ this.slack,
61
+ this.config,
62
+ {
63
+ diamondAddress: this.config.diamondAddress,
64
+ sApUSDAddress: this.config.riskKeeper.sApUSDAddress,
65
+ routerAddress: this.config.riskKeeper.routerAddress,
66
+ wbnbAddress: this.config.riskKeeper.wbnbAddress,
67
+ privateKey: this.config.riskKeeper.privateKey,
68
+ rpcUrl: this.config.rpcUrl,
69
+ sm2CooldownMs: this.config.riskKeeper.sm2CooldownMs,
70
+ sm2MaxPoolPercent: this.config.riskKeeper.sm2MaxPoolPercent,
71
+ twapIntervalMs: this.config.riskKeeper.twapIntervalMs,
72
+ twapPercentage: this.config.riskKeeper.twapPercentage,
73
+ maxSlippageBps: this.config.riskKeeper.maxSlippageBps,
74
+ twapStartCR: this.config.riskKeeper.twapStartCR,
75
+ maxVolatilityPercent: this.config.riskKeeper.maxVolatilityPercent,
76
+ }
77
+ );
78
+ }
79
+
80
+ // Initialize Fee Manager if enabled
81
+ if (this.config.feeManager?.enabled) {
82
+ console.log("Initializing Fee Manager...");
83
+ this.feeManager = new FeeManagerService(
84
+ aspanClient,
85
+ this.slack,
86
+ this.config,
87
+ {
88
+ diamondAddress: this.config.diamondAddress,
89
+ privateKey: this.config.feeManager.privateKey,
90
+ rpcUrl: this.config.rpcUrl,
91
+ checkIntervalMs: this.config.feeManager.checkIntervalMs,
92
+ }
93
+ );
94
+ }
95
+
51
96
  console.log(`Diamond Address: ${this.config.diamondAddress}`);
52
97
  console.log(`RPC URL: ${this.config.rpcUrl}`);
53
98
  }
@@ -61,8 +106,9 @@ class AspanMonitoringBot {
61
106
  console.log("\nStarting Aspan Monitoring Bot...\n");
62
107
 
63
108
  // Send startup notification
109
+ const riskKeeperStatus = this.riskKeeper ? " + Risk Keeper" : "";
64
110
  await this.slack.send({
65
- text: ":robot_face: Aspan Monitoring Bot started",
111
+ text: `:robot_face: Aspan Monitoring Bot started${riskKeeperStatus}`,
66
112
  });
67
113
 
68
114
  // Start all monitors
@@ -70,6 +116,18 @@ class AspanMonitoringBot {
70
116
  this.crMonitor.start();
71
117
  await this.mempoolMonitor.start();
72
118
 
119
+ // Start Risk Keeper if enabled
120
+ if (this.riskKeeper) {
121
+ this.riskKeeper.start();
122
+ console.log("Risk Keeper started");
123
+ }
124
+
125
+ // Start Fee Manager if enabled
126
+ if (this.feeManager) {
127
+ this.feeManager.start();
128
+ console.log("Fee Manager started");
129
+ }
130
+
73
131
  this.isRunning = true;
74
132
  console.log("\nAll monitors started successfully!\n");
75
133
  }
@@ -86,6 +144,16 @@ class AspanMonitoringBot {
86
144
  this.crMonitor.stop();
87
145
  this.mempoolMonitor.stop();
88
146
 
147
+ // Stop Risk Keeper if running
148
+ if (this.riskKeeper) {
149
+ this.riskKeeper.stop();
150
+ }
151
+
152
+ // Stop Fee Manager if running
153
+ if (this.feeManager) {
154
+ this.feeManager.stop();
155
+ }
156
+
89
157
  // Send shutdown notification
90
158
  await this.slack.send({
91
159
  text: ":wave: Aspan Monitoring Bot stopped",
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Fee Manager Service
3
+ * Automatically adjusts fee tiers based on CR zones
4
+ */
5
+
6
+ import {
7
+ createWalletClient,
8
+ http,
9
+ type Address,
10
+ type Chain,
11
+ type Account,
12
+ type Hash,
13
+ } from "viem";
14
+ import { privateKeyToAccount } from "viem/accounts";
15
+ import { bsc } from "viem/chains";
16
+ import type { AspanReadClient } from "../../client";
17
+ import { DiamondABI } from "../../abi/diamond";
18
+ import type { SlackService } from "./slack";
19
+ import { SlackColors } from "./slack";
20
+ import type { BotConfig } from "../config";
21
+ import { withRetry } from "./rpc-client";
22
+
23
+ // ============ Types ============
24
+
25
+ export interface FeeManagerConfig {
26
+ /** Diamond contract address */
27
+ diamondAddress: Address;
28
+ /** Fee manager private key */
29
+ privateKey: `0x${string}`;
30
+ /** RPC URL */
31
+ rpcUrl: string;
32
+ /** Chain */
33
+ chain?: Chain;
34
+ /** Check interval in ms (default 1 min) */
35
+ checkIntervalMs?: number;
36
+ }
37
+
38
+ /**
39
+ * Fee tier configuration per zone
40
+ * Based on Aspan Risk Management Framework
41
+ */
42
+ interface FeeTier {
43
+ minCR: bigint; // Minimum CR for this tier (BPS)
44
+ apUSDMintFee: number; // BPS
45
+ apUSDRedeemFee: number; // BPS
46
+ xBNBMintFee: number; // BPS
47
+ xBNBRedeemFee: number; // BPS
48
+ apUSDMintDisabled: boolean;
49
+ }
50
+
51
+ // Zone boundaries (in BPS)
52
+ const ZONE_A_MIN = 17000n; // > 170%
53
+ const ZONE_B_MIN = 13000n; // 130-170%
54
+ const ZONE_C_MIN = 11000n; // 110-130%
55
+ // Zone D: < 110%
56
+
57
+ /**
58
+ * Fee tiers per zone as defined in risk framework
59
+ *
60
+ * Zone A (>170%): Healthy - promote growth
61
+ * Zone B (130-170%): Normal - balanced fees
62
+ * Zone C (110-130%): Warning - encourage rebalancing
63
+ * Zone D (<110%): Critical - emergency measures
64
+ */
65
+ const FEE_TIERS: FeeTier[] = [
66
+ // Zone A: >170%
67
+ {
68
+ minCR: ZONE_A_MIN,
69
+ apUSDMintFee: 0, // 0% - promote minting
70
+ apUSDRedeemFee: 100, // 1%
71
+ xBNBMintFee: 200, // 2%
72
+ xBNBRedeemFee: 0, // 0% - promote redemption
73
+ apUSDMintDisabled: false,
74
+ },
75
+ // Zone B: 130-170%
76
+ {
77
+ minCR: ZONE_B_MIN,
78
+ apUSDMintFee: 10, // 0.1%
79
+ apUSDRedeemFee: 30, // 0.3%
80
+ xBNBMintFee: 100, // 1%
81
+ xBNBRedeemFee: 100, // 1%
82
+ apUSDMintDisabled: false,
83
+ },
84
+ // Zone C: 110-130%
85
+ {
86
+ minCR: ZONE_C_MIN,
87
+ apUSDMintFee: 50, // 0.5%
88
+ apUSDRedeemFee: 0, // 0% - encourage redemption
89
+ xBNBMintFee: 50, // 0.5% - promote minting
90
+ xBNBRedeemFee: 400, // 4% - discourage redemption
91
+ apUSDMintDisabled: false,
92
+ },
93
+ // Zone D: <110%
94
+ {
95
+ minCR: 0n,
96
+ apUSDMintFee: 0, // N/A - disabled
97
+ apUSDRedeemFee: 0, // 0% - encourage redemption
98
+ xBNBMintFee: 0, // 0% - encourage minting
99
+ xBNBRedeemFee: 800, // 8% - lock xBNB
100
+ apUSDMintDisabled: true, // Disable minting in emergency
101
+ },
102
+ ];
103
+
104
+ // ============ Fee Manager Service ============
105
+
106
+ export class FeeManagerService {
107
+ private readonly client: AspanReadClient;
108
+ private readonly walletClient: ReturnType<typeof createWalletClient>;
109
+ private readonly slack: SlackService;
110
+ private readonly config: BotConfig;
111
+ private readonly feeConfig: FeeManagerConfig;
112
+ private readonly account: Account;
113
+
114
+ private intervalId?: ReturnType<typeof setInterval>;
115
+ private currentZone: string = "";
116
+ private readonly CHECK_INTERVAL_MS: number;
117
+
118
+ constructor(
119
+ client: AspanReadClient,
120
+ slack: SlackService,
121
+ botConfig: BotConfig,
122
+ feeConfig: FeeManagerConfig
123
+ ) {
124
+ this.client = client;
125
+ this.slack = slack;
126
+ this.config = botConfig;
127
+ this.feeConfig = feeConfig;
128
+
129
+ this.CHECK_INTERVAL_MS = feeConfig.checkIntervalMs ?? 60 * 1000; // 1 min
130
+
131
+ // Setup account and client
132
+ this.account = privateKeyToAccount(feeConfig.privateKey);
133
+ const chain = feeConfig.chain ?? bsc;
134
+
135
+ this.walletClient = createWalletClient({
136
+ chain,
137
+ transport: http(feeConfig.rpcUrl),
138
+ account: this.account,
139
+ });
140
+
141
+ console.log(`[FeeManager] Initialized with address: ${this.account.address}`);
142
+ }
143
+
144
+ // ============ Lifecycle ============
145
+
146
+ start(): void {
147
+ console.log("[FeeManager] Starting fee manager service...");
148
+ console.log(`[FeeManager] Check interval: ${this.CHECK_INTERVAL_MS / 1000}s`);
149
+
150
+ // Initial check
151
+ this.checkAndAdjustFees();
152
+
153
+ // Schedule regular checks
154
+ this.intervalId = setInterval(
155
+ () => this.checkAndAdjustFees(),
156
+ this.CHECK_INTERVAL_MS
157
+ );
158
+
159
+ console.log("[FeeManager] Service started");
160
+ }
161
+
162
+ stop(): void {
163
+ if (this.intervalId) {
164
+ clearInterval(this.intervalId);
165
+ this.intervalId = undefined;
166
+ }
167
+ console.log("[FeeManager] Service stopped");
168
+ }
169
+
170
+ // ============ Core Logic ============
171
+
172
+ private async checkAndAdjustFees(): Promise<void> {
173
+ try {
174
+ // Get current CR
175
+ const sm = await withRetry(
176
+ () => this.client.getStabilityMode(),
177
+ this.config,
178
+ "getStabilityMode"
179
+ );
180
+
181
+ const cr = sm.currentCR;
182
+ const newZone = this.getZone(cr);
183
+
184
+ // Check if zone changed
185
+ if (newZone === this.currentZone) {
186
+ return; // No change needed
187
+ }
188
+
189
+ console.log(`[FeeManager] Zone change detected: ${this.currentZone || "initial"} -> ${newZone} (CR: ${cr})`);
190
+
191
+ // Get current on-chain fee tiers
192
+ const tierCount = await withRetry(
193
+ () => this.client.getFeeTierCount(),
194
+ this.config,
195
+ "getFeeTierCount"
196
+ );
197
+ const currentTiers = [];
198
+ for (let i = 0n; i < tierCount; i++) {
199
+ const tier = await withRetry(
200
+ () => this.client.getFeeTier(i),
201
+ this.config,
202
+ "getFeeTier"
203
+ );
204
+ currentTiers.push(tier);
205
+ }
206
+
207
+ // Check if update needed
208
+ if (this.tiersMatch(currentTiers)) {
209
+ console.log("[FeeManager] Fee tiers already match target, skipping update");
210
+ this.currentZone = newZone;
211
+ return;
212
+ }
213
+
214
+ // Update fee tiers
215
+ const txHash = await this.updateFeeTiers();
216
+
217
+ // Notify
218
+ await this.notifyFeeUpdate(this.currentZone, newZone, cr, txHash);
219
+
220
+ this.currentZone = newZone;
221
+ } catch (error) {
222
+ console.error("[FeeManager] Fee check/adjust failed:", error);
223
+ }
224
+ }
225
+
226
+ private getZone(cr: bigint): string {
227
+ if (cr >= ZONE_A_MIN) return "A";
228
+ if (cr >= ZONE_B_MIN) return "B";
229
+ if (cr >= ZONE_C_MIN) return "C";
230
+ return "D";
231
+ }
232
+
233
+ private tiersMatch(currentTiers: Array<{
234
+ minCR: bigint;
235
+ apUSDMintFee: number;
236
+ apUSDRedeemFee: number;
237
+ xBNBMintFee: number;
238
+ xBNBRedeemFee: number;
239
+ apUSDMintDisabled: boolean;
240
+ }>): boolean {
241
+ if (currentTiers.length !== FEE_TIERS.length) return false;
242
+
243
+ for (let i = 0; i < FEE_TIERS.length; i++) {
244
+ const target = FEE_TIERS[i];
245
+ const current = currentTiers[i];
246
+
247
+ if (
248
+ current.minCR !== target.minCR ||
249
+ current.apUSDMintFee !== target.apUSDMintFee ||
250
+ current.apUSDRedeemFee !== target.apUSDRedeemFee ||
251
+ current.xBNBMintFee !== target.xBNBMintFee ||
252
+ current.xBNBRedeemFee !== target.xBNBRedeemFee ||
253
+ current.apUSDMintDisabled !== target.apUSDMintDisabled
254
+ ) {
255
+ return false;
256
+ }
257
+ }
258
+
259
+ return true;
260
+ }
261
+
262
+ private async updateFeeTiers(): Promise<Hash> {
263
+ console.log("[FeeManager] Updating fee tiers...");
264
+
265
+ return this.walletClient.writeContract({
266
+ chain: bsc,
267
+ account: this.account, address: this.feeConfig.diamondAddress,
268
+ abi: DiamondABI,
269
+ functionName: "setFeeTiers",
270
+ args: [FEE_TIERS],
271
+ });
272
+ }
273
+
274
+ // ============ Notifications ============
275
+
276
+ private async notifyFeeUpdate(
277
+ fromZone: string,
278
+ toZone: string,
279
+ cr: bigint,
280
+ txHash: Hash
281
+ ): Promise<void> {
282
+ const zoneColors: Record<string, string> = {
283
+ A: SlackColors.success,
284
+ B: SlackColors.info,
285
+ C: SlackColors.warning,
286
+ D: SlackColors.danger,
287
+ };
288
+
289
+ await this.slack.send({
290
+ attachments: [{
291
+ color: zoneColors[toZone] || SlackColors.info,
292
+ title: `:gear: Fee Tiers Updated: Zone ${fromZone || "?"} -> ${toZone}`,
293
+ text: "Fee tiers automatically adjusted based on CR",
294
+ fields: [
295
+ { title: "CR", value: `${Number(cr) / 100}%`, short: true },
296
+ { title: "Zone", value: toZone, short: true },
297
+ { title: "TX", value: `<https://bscscan.com/tx/${txHash}|View>`, short: true },
298
+ ],
299
+ ts: Math.floor(Date.now() / 1000),
300
+ }],
301
+ });
302
+ }
303
+ }