byreal-test 1.3.7 → 2.0.0

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.
Files changed (3) hide show
  1. package/cli.js +15 -0
  2. package/package.json +1 -1
  3. package/src/api-runner.js +422 -0
package/cli.js CHANGED
@@ -101,4 +101,19 @@ program
101
101
  process.exit(result.hasAlert ? 1 : 0);
102
102
  });
103
103
 
104
+ program
105
+ .command('run_api')
106
+ .description('Call byreal-cli overview and verify key metrics (TVL / Fees)')
107
+ .option('--lark-webhook <url>', 'Lark bot webhook URL', 'https://open.larksuite.com/open-apis/bot/v2/hook/c465fa0d-c3c5-42b8-9018-39ae079c4f2a')
108
+ .option('--state-file <path>', 'Path to persist state between runs (default: ~/.byreal-test/api-state.json)')
109
+ .option('--no-lark', 'Disable Lark notification')
110
+ .action(async (opts) => {
111
+ const { runApi } = require('./src/api-runner');
112
+ const result = await runApi({
113
+ larkWebhook: opts.lark ? opts.larkWebhook : undefined,
114
+ stateFile: opts.stateFile,
115
+ });
116
+ process.exit(result.hasAlert ? 1 : 0);
117
+ });
118
+
104
119
  program.parse(process.argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "byreal-test",
3
- "version": "1.3.7",
3
+ "version": "2.0.0",
4
4
  "description": "Byreal CLI test suite - runs byreal-cli commands and generates test reports",
5
5
  "main": "cli.js",
6
6
  "bin": {
@@ -0,0 +1,422 @@
1
+ 'use strict';
2
+
3
+ const { execSync } = require('child_process');
4
+ const { parseJsonOutput } = require('./runner');
5
+ const chalk = require('chalk');
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const os = require('os');
9
+
10
+ const DEFAULT_STATE_FILE = path.join(os.homedir(), '.byreal-test', 'api-state.json');
11
+
12
+ // ─── helpers ──────────────────────────────────────────────────────────────────
13
+
14
+ function utc8Label() {
15
+ const now = new Date(Date.now() + 8 * 3600_000);
16
+ const mm = String(now.getUTCMonth() + 1).padStart(2, '0');
17
+ const dd = String(now.getUTCDate()).padStart(2, '0');
18
+ const hh = String(now.getUTCHours()).padStart(2, '0');
19
+ const min = String(now.getUTCMinutes()).padStart(2, '0');
20
+ return `${mm}-${dd} ${hh}:${min}`;
21
+ }
22
+
23
+ function fmtUsd(val) {
24
+ if (val >= 1_000_000) return `$${(val / 1_000_000).toFixed(2)}M`;
25
+ if (val >= 1_000) return `$${(val / 1_000).toFixed(2)}K`;
26
+ return `$${val.toFixed(2)}`;
27
+ }
28
+
29
+ function fmtPct(val) {
30
+ return `${val >= 0 ? '+' : ''}${val.toFixed(2)}%`;
31
+ }
32
+
33
+ function loadState(filePath) {
34
+ try {
35
+ if (fs.existsSync(filePath)) {
36
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
37
+ }
38
+ } catch {}
39
+ return null;
40
+ }
41
+
42
+ function saveState(filePath, payload) {
43
+ try {
44
+ const dir = path.dirname(filePath);
45
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
46
+ fs.writeFileSync(filePath, JSON.stringify(payload, null, 2));
47
+ } catch (err) {
48
+ console.warn(chalk.yellow(` Warning: state save failed: ${err.message}`));
49
+ }
50
+ }
51
+
52
+ async function sendLark(webhookUrl, body) {
53
+ const res = await fetch(webhookUrl, {
54
+ method: 'POST',
55
+ headers: { 'Content-Type': 'application/json' },
56
+ body: JSON.stringify(body),
57
+ });
58
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
59
+ }
60
+
61
+ // ─── overview checks ──────────────────────────────────────────────────────────
62
+
63
+ function checkOverview(data, prev) {
64
+ const checks = [];
65
+
66
+ // TVL 24h change within ±30% (tvl_change_24h is already in %)
67
+ const tvlChg = data.tvl_change_24h;
68
+ const tvlOk = Math.abs(tvlChg) <= 30;
69
+ checks.push({
70
+ name: 'TVL 24h',
71
+ value: `${fmtUsd(data.tvl)} (${fmtPct(tvlChg)})`,
72
+ ok: tvlOk,
73
+ reason: tvlOk ? null : `24h 变动 ${fmtPct(tvlChg)} 超过 ±30% 阈值`,
74
+ });
75
+
76
+ // Fees (24h) > $1 000
77
+ const fee24hOk = data.fee_24h_usd >= 1_000;
78
+ checks.push({
79
+ name: 'Fees (24h)',
80
+ value: fmtUsd(data.fee_24h_usd),
81
+ ok: fee24hOk,
82
+ reason: fee24hOk ? null : `低于 $1K 最低阈值`,
83
+ });
84
+
85
+ // Fees (All Time): cumulative — flag if drops ≥ 20% vs last run
86
+ if (prev?.fee_all) {
87
+ const chgPct = (data.fee_all - prev.fee_all) / prev.fee_all * 100;
88
+ const feeAllOk = chgPct >= -20;
89
+ checks.push({
90
+ name: 'Fees (All Time)',
91
+ value: `${fmtUsd(data.fee_all)} (vs prev ${fmtUsd(prev.fee_all)}: ${fmtPct(chgPct)})`,
92
+ ok: feeAllOk,
93
+ reason: feeAllOk ? null : `相比上次下降 ${Math.abs(chgPct).toFixed(2)}%,超过 20% 警戒线`,
94
+ });
95
+ } else {
96
+ checks.push({
97
+ name: 'Fees (All Time)',
98
+ value: `${fmtUsd(data.fee_all)} (首次运行,记录基准)`,
99
+ ok: true,
100
+ reason: null,
101
+ });
102
+ }
103
+
104
+ return checks;
105
+ }
106
+
107
+ // ─── pools checks ─────────────────────────────────────────────────────────────
108
+
109
+ function checkPools(pools, prevPoolMap) {
110
+ const checks = [];
111
+
112
+ // APR: any pool ≥ 30 000% is suspicious
113
+ const highAprPools = pools.filter(p => p.apr >= 30_000);
114
+ const aprOk = highAprPools.length === 0;
115
+ checks.push({
116
+ name: 'APR 上限',
117
+ value: aprOk
118
+ ? `最高 ${Math.max(...pools.map(p => p.apr)).toFixed(0)}%,共 ${pools.length} 个池`
119
+ : `${highAprPools.length} 个池 APR ≥ 30000%`,
120
+ ok: aprOk,
121
+ reason: aprOk ? null :
122
+ highAprPools.slice(0, 5).map(p => `${p.pair} ${p.apr.toFixed(0)}%`).join(' | '),
123
+ });
124
+
125
+ // Low-APR pools: flag if ≥ 50% of all pools have apr < 1%
126
+ const lowAprCount = pools.filter(p => p.apr < 1).length;
127
+ const lowAprThreshold = Math.floor(pools.length * 0.5);
128
+ const lowAprOk = lowAprCount < lowAprThreshold;
129
+ checks.push({
130
+ name: 'APR < 1% 数量',
131
+ value: `${lowAprCount} / ${pools.length} 个池`,
132
+ ok: lowAprOk,
133
+ reason: lowAprOk ? null : `${lowAprCount} 个池 APR < 1%,超过总数 50%(${lowAprThreshold} 个)`,
134
+ });
135
+
136
+ // TVL / Volume non-negative
137
+ const negTvl = pools.filter(p => p.tvl_usd < 0);
138
+ const negVol = pools.filter(p => p.volume_24h_usd < 0);
139
+ const nonNegOk = negTvl.length === 0 && negVol.length === 0;
140
+ checks.push({
141
+ name: 'TVL/Volume ≥ 0',
142
+ value: nonNegOk ? '全部正常' : `TVL<0: ${negTvl.length} | Vol<0: ${negVol.length}`,
143
+ ok: nonNegOk,
144
+ reason: nonNegOk ? null :
145
+ [...negTvl.map(p => `${p.pair} TVL=${fmtUsd(p.tvl_usd)}`),
146
+ ...negVol.map(p => `${p.pair} Vol=${fmtUsd(p.volume_24h_usd)}`)].slice(0, 5).join(' | '),
147
+ });
148
+
149
+ // Per-pool TVL drop vs previous run: flag if ≥ 5 pools dropped > 30%
150
+ if (prevPoolMap && Object.keys(prevPoolMap).length > 0) {
151
+ const droppedPools = pools.filter(p => {
152
+ const prevTvl = prevPoolMap[p.id];
153
+ if (prevTvl == null || prevTvl === 0) return false;
154
+ return (p.tvl_usd - prevTvl) / prevTvl < -0.30;
155
+ });
156
+ const poolTvlOk = droppedPools.length < 5;
157
+ checks.push({
158
+ name: 'Pool TVL 跌幅',
159
+ value: poolTvlOk
160
+ ? `${droppedPools.length} 个池跌幅 > 30%`
161
+ : `${droppedPools.length} 个池跌幅 > 30%`,
162
+ ok: poolTvlOk,
163
+ reason: poolTvlOk ? null :
164
+ `${droppedPools.length} 个池 TVL 相比上次下降 > 30%,超过 5 个警戒线\n ` +
165
+ droppedPools.slice(0, 5).map(p => {
166
+ const chg = ((p.tvl_usd - prevPoolMap[p.id]) / prevPoolMap[p.id] * 100).toFixed(1);
167
+ return `${p.pair} ${chg}%`;
168
+ }).join(' | '),
169
+ });
170
+ } else {
171
+ checks.push({
172
+ name: 'Pool TVL 跌幅',
173
+ value: '首次运行,记录基准',
174
+ ok: true,
175
+ reason: null,
176
+ });
177
+ }
178
+
179
+ return checks;
180
+ }
181
+
182
+ // ─── tokens checks ────────────────────────────────────────────────────────────
183
+
184
+ const STABLE_SYMBOLS = new Set(['USDC', 'USDT', 'USD1', 'USDS', 'DAI', 'FDUSD']);
185
+
186
+ function checkTokens(tokens, prevTokenMap) {
187
+ const checks = [];
188
+
189
+ // Stablecoins: price_usd within ±5% of 1.0
190
+ const stables = tokens.filter(t => STABLE_SYMBOLS.has(t.symbol));
191
+ const badStables = stables.filter(t => Math.abs(t.price_usd - 1) > 0.05);
192
+ const stableOk = badStables.length === 0;
193
+ checks.push({
194
+ name: 'Stablecoin peg',
195
+ value: stableOk
196
+ ? stables.map(t => `${t.symbol} $${t.price_usd.toFixed(4)}`).join(' | ')
197
+ : badStables.map(t => `${t.symbol} $${t.price_usd.toFixed(4)}`).join(' | '),
198
+ ok: stableOk,
199
+ reason: stableOk ? null :
200
+ badStables.map(t => `${t.symbol} 偏离 1.0 超过 5%`).join(', '),
201
+ });
202
+
203
+ // Non-stable: compare price / change_24h / volume_24h vs previous run.
204
+ // Key by mint (unique) to avoid false positives from duplicate symbols.
205
+ if (prevTokenMap && Object.keys(prevTokenMap).length > 0) {
206
+ const anomalies = [];
207
+
208
+ for (const t of tokens) {
209
+ if (STABLE_SYMBOLS.has(t.symbol)) continue;
210
+ const prev = prevTokenMap[t.mint];
211
+ if (!prev) continue;
212
+
213
+ const issues = [];
214
+
215
+ // price: relative change > 50%
216
+ if (prev.price_usd > 0) {
217
+ const chg = Math.abs(t.price_usd - prev.price_usd) / prev.price_usd;
218
+ if (chg > 0.50) issues.push(`price ${(chg * 100).toFixed(0)}%↑↓`);
219
+ }
220
+
221
+ // price_change_24h (decimal, e.g. 0.06 = 6%):
222
+ // flag only when BOTH conditions met:
223
+ // 1. relative shift > 50% (or absolute shift > 0.50 when baseline is tiny)
224
+ // 2. absolute diff > 0.30 (i.e. 30 percentage-points) — filters noise
225
+ const prevChg = prev.price_change_24h;
226
+ const curChg = t.price_change_24h;
227
+ const absDiff = Math.abs(curChg - prevChg);
228
+ if (absDiff > 0.30) {
229
+ if (Math.abs(prevChg) >= 0.02) {
230
+ const relChg = absDiff / Math.abs(prevChg);
231
+ if (relChg > 0.50) issues.push(`chg24h rel ${(relChg * 100).toFixed(0)}% abs ${(absDiff * 100).toFixed(0)}pp`);
232
+ } else {
233
+ if (absDiff > 0.50) {
234
+ issues.push(`chg24h shift ${(absDiff * 100).toFixed(0)}pp`);
235
+ }
236
+ }
237
+ }
238
+
239
+ // volume_24h: relative change > 50%, only when absolute value > $10 000
240
+ if (prev.volume_24h_usd > 0 && t.volume_24h_usd > 10_000) {
241
+ const chg = Math.abs(t.volume_24h_usd - prev.volume_24h_usd) / prev.volume_24h_usd;
242
+ if (chg > 0.50) issues.push(`vol24h ${(chg * 100).toFixed(0)}%↑↓`);
243
+ }
244
+
245
+ if (issues.length > 0) {
246
+ anomalies.push(`${t.symbol}(${issues.join(', ')})`);
247
+ }
248
+ }
249
+
250
+ const tokenDriftOk = anomalies.length === 0;
251
+ checks.push({
252
+ name: 'Token 数据波动',
253
+ value: tokenDriftOk
254
+ ? `${tokens.filter(t => !STABLE_SYMBOLS.has(t.symbol)).length} 个代币均正常`
255
+ : `${anomalies.length} 个代币异常`,
256
+ ok: tokenDriftOk,
257
+ reason: tokenDriftOk ? null :
258
+ `相比上次变化 > 50%:${anomalies.slice(0, 8).join(' | ')}`,
259
+ });
260
+ } else {
261
+ checks.push({
262
+ name: 'Token 数据波动',
263
+ value: `首次运行,记录基准(${tokens.length} 个代币)`,
264
+ ok: true,
265
+ reason: null,
266
+ });
267
+ }
268
+
269
+ return checks;
270
+ }
271
+
272
+ // ─── main ─────────────────────────────────────────────────────────────────────
273
+
274
+ async function runApi({ larkWebhook, stateFile } = {}) {
275
+ const statePath = stateFile || DEFAULT_STATE_FILE;
276
+ const prev = loadState(statePath);
277
+
278
+ // 1. fetch overview
279
+ let overviewData;
280
+ try {
281
+ const stdout = execSync('byreal-cli overview -o json', {
282
+ timeout: 30_000,
283
+ encoding: 'utf8',
284
+ maxBuffer: 2 * 1024 * 1024,
285
+ });
286
+ const parsed = parseJsonOutput(stdout);
287
+ if (!parsed?.success || !parsed?.data) throw new Error('invalid overview response');
288
+ overviewData = parsed.data;
289
+ } catch (err) {
290
+ console.error(chalk.red(`\n ✗ byreal-cli overview failed: ${err.message}\n`));
291
+ return { success: false, error: err.message, hasAlert: true };
292
+ }
293
+
294
+ // 2. fetch pools
295
+ let pools = [];
296
+ try {
297
+ const stdout = execSync('byreal-cli pools list --page-size 100 -o json', {
298
+ timeout: 40_000,
299
+ encoding: 'utf8',
300
+ maxBuffer: 4 * 1024 * 1024,
301
+ });
302
+ const parsed = parseJsonOutput(stdout);
303
+ if (!Array.isArray(parsed?.data?.pools)) throw new Error('invalid pools response');
304
+ pools = parsed.data.pools;
305
+ } catch (err) {
306
+ console.error(chalk.red(`\n ✗ byreal-cli pools failed: ${err.message}\n`));
307
+ return { success: false, error: err.message, hasAlert: true };
308
+ }
309
+
310
+ // 3. fetch tokens
311
+ let tokens = [];
312
+ try {
313
+ const stdout = execSync('byreal-cli tokens list --page-size 100 -o json', {
314
+ timeout: 30_000,
315
+ encoding: 'utf8',
316
+ maxBuffer: 4 * 1024 * 1024,
317
+ });
318
+ const parsed = parseJsonOutput(stdout);
319
+ if (!Array.isArray(parsed?.data?.tokens)) throw new Error('invalid tokens response');
320
+ tokens = parsed.data.tokens;
321
+ } catch (err) {
322
+ console.error(chalk.red(`\n ✗ byreal-cli tokens failed: ${err.message}\n`));
323
+ return { success: false, error: err.message, hasAlert: true };
324
+ }
325
+
326
+ // 4. run checks
327
+ const overviewChecks = checkOverview(overviewData, prev);
328
+ const poolChecks = checkPools(pools, prev?.poolTvlMap);
329
+ const tokenChecks = checkTokens(tokens, prev?.tokenMap);
330
+ const allChecks = [...overviewChecks, ...poolChecks, ...tokenChecks];
331
+ const hasAlert = allChecks.some(c => !c.ok);
332
+
333
+ // 5. print
334
+ const time = utc8Label();
335
+ console.log('\n ' + chalk.bold('Byreal API Sanity Check') + ` ${chalk.dim(time)}\n`);
336
+
337
+ const sections = [
338
+ { label: '── Overview ──', checks: overviewChecks },
339
+ { label: '── Pools ──', checks: poolChecks },
340
+ { label: '── Tokens ──', checks: tokenChecks },
341
+ ];
342
+ for (const sec of sections) {
343
+ console.log(' ' + chalk.cyan(sec.label));
344
+ for (const c of sec.checks) {
345
+ const icon = c.ok ? chalk.green('✓') : chalk.red('✗');
346
+ console.log(` ${icon} ${c.name.padEnd(22)} ${c.value}`);
347
+ if (!c.ok) console.log(` ${chalk.red('→ ' + c.reason)}`);
348
+ }
349
+ console.log('');
350
+ }
351
+
352
+ if (hasAlert) {
353
+ console.log(chalk.red(' ⚠️ 检测到异常,请关注\n'));
354
+ } else {
355
+ console.log(chalk.green(' ✅ 所有指标正常\n'));
356
+ }
357
+
358
+ // 6. persist state
359
+ const poolTvlMap = {};
360
+ for (const p of pools) poolTvlMap[p.id] = p.tvl_usd;
361
+
362
+ // keyed by mint (unique) to handle duplicate symbols (e.g. counterfeit tokens)
363
+ const tokenMap = {};
364
+ for (const t of tokens) {
365
+ tokenMap[t.mint] = {
366
+ symbol: t.symbol,
367
+ price_usd: t.price_usd,
368
+ price_change_24h: t.price_change_24h,
369
+ volume_24h_usd: t.volume_24h_usd,
370
+ };
371
+ }
372
+
373
+ saveState(statePath, {
374
+ timestamp: new Date().toISOString(),
375
+ tvl: overviewData.tvl,
376
+ tvl_change_24h: overviewData.tvl_change_24h,
377
+ fee_24h_usd: overviewData.fee_24h_usd,
378
+ fee_all: overviewData.fee_all,
379
+ pools_count: overviewData.pools_count,
380
+ poolTvlMap,
381
+ tokenMap,
382
+ });
383
+
384
+ // 7. lark — only notify when there is an alert
385
+ if (larkWebhook && hasAlert) {
386
+ const title = `🚨 Byreal API Monitor · ${time}`;
387
+
388
+ const rows = [];
389
+
390
+ const sectionDefs = [
391
+ { label: '📊 Overview', checks: overviewChecks },
392
+ { label: '🏊 Pools', checks: poolChecks },
393
+ { label: '🪙 Tokens', checks: tokenChecks },
394
+ ];
395
+
396
+ for (const sec of sectionDefs) {
397
+ const hasIssue = sec.checks.some(c => !c.ok);
398
+ rows.push([{ tag: 'text', text: `${hasIssue ? '⚠️' : '✅'} ${sec.label}` }]);
399
+ for (const c of sec.checks) {
400
+ rows.push([{ tag: 'text', text: ` ${c.ok ? '✅' : '❌'} ${c.name}: ${c.value}` }]);
401
+ if (!c.ok) {
402
+ rows.push([{ tag: 'text', text: ` → ${c.reason}` }]);
403
+ }
404
+ }
405
+ rows.push([{ tag: 'text', text: ' ' }]);
406
+ }
407
+
408
+ try {
409
+ await sendLark(larkWebhook, {
410
+ msg_type: 'post',
411
+ content: { post: { zh_cn: { title, content: rows } } },
412
+ });
413
+ console.log(chalk.dim(' Lark alert sent.\n'));
414
+ } catch (err) {
415
+ console.warn(chalk.yellow(` Warning: Lark send failed: ${err.message}\n`));
416
+ }
417
+ }
418
+
419
+ return { success: true, hasAlert, checks: allChecks, overviewData, pools, tokens };
420
+ }
421
+
422
+ module.exports = { runApi };