poly-whales 0.0.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 +77 -0
- package/assets/details-popup.png +0 -0
- package/assets/tui.png +0 -0
- package/bun.lock +47 -0
- package/dist/hypersync-client.linux-x64-gnu-m2naa63z.node +0 -0
- package/dist/hypersync-client.linux-x64-musl-y1dmvqdm.node +0 -0
- package/dist/index.js +12505 -0
- package/package.json +23 -0
- package/src/cli.ts +25 -0
- package/src/config.ts +55 -0
- package/src/hypersync.ts +18 -0
- package/src/index.ts +125 -0
- package/src/trades.ts +67 -0
- package/src/types.ts +26 -0
- package/src/ui/main.ts +838 -0
- package/src/ui/startup.ts +115 -0
- package/src/utils.ts +36 -0
- package/tsconfig.json +22 -0
package/src/ui/main.ts
ADDED
|
@@ -0,0 +1,838 @@
|
|
|
1
|
+
import blessed from "blessed";
|
|
2
|
+
import type { Query } from "@envio-dev/hypersync-client";
|
|
3
|
+
import type { ParsedTrade, CliArgs } from "../types";
|
|
4
|
+
import { shortAddr, usdcFromRaw, tradeKey, parseAddressInput } from "../utils";
|
|
5
|
+
import {
|
|
6
|
+
EXCHANGE_ADDRESSES,
|
|
7
|
+
ORDER_FILLED_TOPIC,
|
|
8
|
+
} from "../config";
|
|
9
|
+
import { getClient, getDecoder } from "../hypersync";
|
|
10
|
+
import { classifyTrade } from "../trades";
|
|
11
|
+
import { sleep } from "../utils";
|
|
12
|
+
|
|
13
|
+
// ─── State Variables ────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
let thresholdUsd = 100;
|
|
16
|
+
let watchAddresses: string[] = [];
|
|
17
|
+
let trades: ParsedTrade[] = [];
|
|
18
|
+
let selectedIndex = 0;
|
|
19
|
+
let isDetailView = false;
|
|
20
|
+
let isThresholdPopupOpen = false;
|
|
21
|
+
let isAddressPopupOpen = false;
|
|
22
|
+
let isPolling = false;
|
|
23
|
+
let pollAbort = false;
|
|
24
|
+
let queryFromResetBlock: number | null = null;
|
|
25
|
+
let resetCounter = 0;
|
|
26
|
+
const seen = new Set<string>();
|
|
27
|
+
|
|
28
|
+
// ─── Main UI Boot Function ──────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
export const bootUI = (screen: blessed.Widgets.Screen, cliArgs: CliArgs) => {
|
|
31
|
+
// Initialize state from CLI args
|
|
32
|
+
thresholdUsd = cliArgs.threshold;
|
|
33
|
+
watchAddresses = cliArgs.addresses;
|
|
34
|
+
|
|
35
|
+
// ─── Title Bar ──────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
const titleBar = blessed.box({
|
|
38
|
+
parent: screen,
|
|
39
|
+
top: 0,
|
|
40
|
+
left: 0,
|
|
41
|
+
width: "100%",
|
|
42
|
+
height: 3,
|
|
43
|
+
content: "{center}{bold} POLYMARKET WHALE TRACKER {/bold}{/center}",
|
|
44
|
+
tags: true,
|
|
45
|
+
style: {
|
|
46
|
+
fg: "white",
|
|
47
|
+
bg: "blue",
|
|
48
|
+
bold: true,
|
|
49
|
+
},
|
|
50
|
+
border: { type: "line" },
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// ─── Left Column: Threshold + Addresses ──────────────────────────────
|
|
54
|
+
|
|
55
|
+
const leftCol = blessed.box({
|
|
56
|
+
parent: screen,
|
|
57
|
+
top: 3,
|
|
58
|
+
left: 0,
|
|
59
|
+
width: "30%",
|
|
60
|
+
height: "100%-6",
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const thresholdBox = blessed.box({
|
|
64
|
+
parent: leftCol,
|
|
65
|
+
top: 0,
|
|
66
|
+
left: 0,
|
|
67
|
+
width: "100%",
|
|
68
|
+
height: 7,
|
|
69
|
+
label: " {bold}Threshold (USD){/bold} ",
|
|
70
|
+
tags: true,
|
|
71
|
+
border: { type: "line" },
|
|
72
|
+
style: {
|
|
73
|
+
border: { fg: "yellow" },
|
|
74
|
+
fg: "white",
|
|
75
|
+
label: { fg: "yellow" },
|
|
76
|
+
},
|
|
77
|
+
padding: { left: 1, right: 1 },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const thresholdDisplay = blessed.text({
|
|
81
|
+
parent: thresholdBox,
|
|
82
|
+
top: 0,
|
|
83
|
+
left: 0,
|
|
84
|
+
content: "",
|
|
85
|
+
tags: true,
|
|
86
|
+
style: { fg: "white" },
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const updateThresholdDisplay = () => {
|
|
90
|
+
thresholdDisplay.setContent(
|
|
91
|
+
`{yellow-fg}{bold}$${thresholdUsd.toLocaleString()}{/bold}{/yellow-fg}\n\n{gray-fg}Only BUY trades above this{/gray-fg}`,
|
|
92
|
+
);
|
|
93
|
+
screen.render();
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const addressBox = blessed.box({
|
|
97
|
+
parent: leftCol,
|
|
98
|
+
top: 7,
|
|
99
|
+
left: 0,
|
|
100
|
+
width: "100%",
|
|
101
|
+
height: "100%-7",
|
|
102
|
+
label: " {bold}Watch Addresses{/bold} {gray-fg}(optional){/gray-fg} ",
|
|
103
|
+
tags: true,
|
|
104
|
+
border: { type: "line" },
|
|
105
|
+
style: {
|
|
106
|
+
border: { fg: "cyan" },
|
|
107
|
+
fg: "white",
|
|
108
|
+
label: { fg: "cyan" },
|
|
109
|
+
},
|
|
110
|
+
padding: { left: 1, right: 1 },
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const addressDisplay = blessed.text({
|
|
114
|
+
parent: addressBox,
|
|
115
|
+
top: 0,
|
|
116
|
+
left: 0,
|
|
117
|
+
tags: true,
|
|
118
|
+
content: "",
|
|
119
|
+
style: { fg: "white" },
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const updateAddressDisplay = () => {
|
|
123
|
+
if (watchAddresses.length === 0) {
|
|
124
|
+
addressDisplay.setContent(
|
|
125
|
+
"{gray-fg}No filter — showing all\nwhale BUY trades.\n\nUse {bold}-a{/bold} flag to filter.{/gray-fg}",
|
|
126
|
+
);
|
|
127
|
+
} else {
|
|
128
|
+
const list = watchAddresses
|
|
129
|
+
.map((a, i) => `{cyan-fg}${i + 1}.{/cyan-fg} ${shortAddr(a)}`)
|
|
130
|
+
.join("\n");
|
|
131
|
+
addressDisplay.setContent(
|
|
132
|
+
`${list}\n\n{gray-fg}${watchAddresses.length} address${watchAddresses.length > 1 ? "es" : ""}{/gray-fg}`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
screen.render();
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// ─── Right Column: Trade List ───────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
const tradeListBox = blessed.box({
|
|
141
|
+
parent: screen,
|
|
142
|
+
top: 3,
|
|
143
|
+
left: "30%",
|
|
144
|
+
right: 0,
|
|
145
|
+
height: "100%-6",
|
|
146
|
+
label: " {bold}Live Trades{/bold} ",
|
|
147
|
+
tags: true,
|
|
148
|
+
border: { type: "line" },
|
|
149
|
+
style: {
|
|
150
|
+
border: { fg: "green" },
|
|
151
|
+
fg: "white",
|
|
152
|
+
label: { fg: "green" },
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const tradeList = blessed.list({
|
|
157
|
+
parent: tradeListBox,
|
|
158
|
+
top: 0,
|
|
159
|
+
left: 1,
|
|
160
|
+
right: 1,
|
|
161
|
+
bottom: 1,
|
|
162
|
+
scrollable: true,
|
|
163
|
+
mouse: true,
|
|
164
|
+
keys: false,
|
|
165
|
+
tags: true,
|
|
166
|
+
scrollbar: {
|
|
167
|
+
ch: "█",
|
|
168
|
+
style: { bg: "green" },
|
|
169
|
+
},
|
|
170
|
+
style: {
|
|
171
|
+
fg: "white",
|
|
172
|
+
selected: {
|
|
173
|
+
fg: "white",
|
|
174
|
+
bg: "blue",
|
|
175
|
+
bold: true,
|
|
176
|
+
},
|
|
177
|
+
item: {
|
|
178
|
+
fg: "white",
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const statusLine = blessed.text({
|
|
184
|
+
parent: tradeListBox,
|
|
185
|
+
bottom: 0,
|
|
186
|
+
left: 1,
|
|
187
|
+
right: 1,
|
|
188
|
+
height: 1,
|
|
189
|
+
tags: true,
|
|
190
|
+
content: "",
|
|
191
|
+
style: { fg: "gray" },
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// ─── Popups and Detail View ─────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
const detailBox = blessed.box({
|
|
197
|
+
parent: screen,
|
|
198
|
+
top: "center",
|
|
199
|
+
left: "center",
|
|
200
|
+
width: "70%",
|
|
201
|
+
height: "70%",
|
|
202
|
+
label: " {bold}Trade Details{/bold} ",
|
|
203
|
+
tags: true,
|
|
204
|
+
border: { type: "line" },
|
|
205
|
+
style: {
|
|
206
|
+
border: { fg: "magenta" },
|
|
207
|
+
fg: "white",
|
|
208
|
+
bg: "black",
|
|
209
|
+
label: { fg: "magenta" },
|
|
210
|
+
},
|
|
211
|
+
padding: { left: 2, right: 2, top: 1, bottom: 1 },
|
|
212
|
+
hidden: true,
|
|
213
|
+
scrollable: true,
|
|
214
|
+
keys: true,
|
|
215
|
+
mouse: true,
|
|
216
|
+
scrollbar: {
|
|
217
|
+
ch: "█",
|
|
218
|
+
style: { bg: "magenta" },
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const thresholdPopup = blessed.box({
|
|
223
|
+
parent: screen,
|
|
224
|
+
top: "center",
|
|
225
|
+
left: "center",
|
|
226
|
+
width: 52,
|
|
227
|
+
height: 11,
|
|
228
|
+
label: " {bold}Set Threshold (USD){/bold} ",
|
|
229
|
+
tags: true,
|
|
230
|
+
border: { type: "line" },
|
|
231
|
+
style: {
|
|
232
|
+
border: { fg: "yellow" },
|
|
233
|
+
fg: "white",
|
|
234
|
+
bg: "black",
|
|
235
|
+
label: { fg: "yellow" },
|
|
236
|
+
},
|
|
237
|
+
hidden: true,
|
|
238
|
+
padding: { left: 1, right: 1 },
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const thresholdPopupText = blessed.text({
|
|
242
|
+
parent: thresholdPopup,
|
|
243
|
+
top: 0,
|
|
244
|
+
left: 0,
|
|
245
|
+
right: 1,
|
|
246
|
+
tags: true,
|
|
247
|
+
content:
|
|
248
|
+
"Enter a new minimum BUY value in USD\n{gray-fg}Applying clears current trades and restarts from latest blocks.{/gray-fg}",
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const thresholdInput = blessed.textbox({
|
|
252
|
+
parent: thresholdPopup,
|
|
253
|
+
top: 3,
|
|
254
|
+
left: 0,
|
|
255
|
+
right: 1,
|
|
256
|
+
height: 3,
|
|
257
|
+
inputOnFocus: true,
|
|
258
|
+
mouse: true,
|
|
259
|
+
keys: true,
|
|
260
|
+
border: { type: "line" },
|
|
261
|
+
style: {
|
|
262
|
+
border: { fg: "white" },
|
|
263
|
+
fg: "white",
|
|
264
|
+
bg: "black",
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const thresholdPopupError = blessed.text({
|
|
269
|
+
parent: thresholdPopup,
|
|
270
|
+
top: 6,
|
|
271
|
+
left: 0,
|
|
272
|
+
right: 1,
|
|
273
|
+
height: 2,
|
|
274
|
+
tags: true,
|
|
275
|
+
content: "",
|
|
276
|
+
style: { fg: "red" },
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const thresholdPopupHint = blessed.text({
|
|
280
|
+
parent: thresholdPopup,
|
|
281
|
+
bottom: 0,
|
|
282
|
+
left: 0,
|
|
283
|
+
tags: true,
|
|
284
|
+
content: "{gray-fg}Enter submit | Esc cancel{/gray-fg}",
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const addressPopup = blessed.box({
|
|
288
|
+
parent: screen,
|
|
289
|
+
top: "center",
|
|
290
|
+
left: "center",
|
|
291
|
+
width: 66,
|
|
292
|
+
height: 12,
|
|
293
|
+
label: " {bold}Set Watch Addresses{/bold} ",
|
|
294
|
+
tags: true,
|
|
295
|
+
border: { type: "line" },
|
|
296
|
+
style: {
|
|
297
|
+
border: { fg: "cyan" },
|
|
298
|
+
fg: "white",
|
|
299
|
+
bg: "black",
|
|
300
|
+
label: { fg: "cyan" },
|
|
301
|
+
},
|
|
302
|
+
hidden: true,
|
|
303
|
+
padding: { left: 1, right: 1 },
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
const addressPopupText = blessed.text({
|
|
307
|
+
parent: addressPopup,
|
|
308
|
+
top: 0,
|
|
309
|
+
left: 0,
|
|
310
|
+
right: 1,
|
|
311
|
+
tags: true,
|
|
312
|
+
content:
|
|
313
|
+
"Enter comma-separated wallet addresses (0x...). Leave empty to clear filter.",
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const addressInput = blessed.textbox({
|
|
317
|
+
parent: addressPopup,
|
|
318
|
+
top: 2,
|
|
319
|
+
left: 0,
|
|
320
|
+
right: 1,
|
|
321
|
+
height: 4,
|
|
322
|
+
inputOnFocus: true,
|
|
323
|
+
mouse: true,
|
|
324
|
+
keys: true,
|
|
325
|
+
border: { type: "line" },
|
|
326
|
+
style: {
|
|
327
|
+
border: { fg: "white" },
|
|
328
|
+
fg: "white",
|
|
329
|
+
bg: "black",
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const addressPopupError = blessed.text({
|
|
334
|
+
parent: addressPopup,
|
|
335
|
+
top: 6,
|
|
336
|
+
left: 0,
|
|
337
|
+
right: 1,
|
|
338
|
+
height: 2,
|
|
339
|
+
tags: true,
|
|
340
|
+
content: "",
|
|
341
|
+
style: { fg: "red" },
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const addressPopupHint = blessed.text({
|
|
345
|
+
parent: addressPopup,
|
|
346
|
+
bottom: 0,
|
|
347
|
+
left: 0,
|
|
348
|
+
tags: true,
|
|
349
|
+
content: "{gray-fg}Enter submit | Esc cancel{/gray-fg}",
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// ─── Help Bar ────────────────────────────────────────────────────────
|
|
353
|
+
|
|
354
|
+
const helpBar = blessed.box({
|
|
355
|
+
parent: screen,
|
|
356
|
+
bottom: 0,
|
|
357
|
+
left: 0,
|
|
358
|
+
width: "100%",
|
|
359
|
+
height: 3,
|
|
360
|
+
tags: true,
|
|
361
|
+
border: { type: "line" },
|
|
362
|
+
style: {
|
|
363
|
+
fg: "white",
|
|
364
|
+
bg: "#333333",
|
|
365
|
+
border: { fg: "gray" },
|
|
366
|
+
},
|
|
367
|
+
padding: { left: 1, right: 1 },
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
const updateHelpBar = () => {
|
|
371
|
+
if (isDetailView) {
|
|
372
|
+
helpBar.setContent(
|
|
373
|
+
"{bold}{blue-fg}Esc{/blue-fg}/{blue-fg}Backspace{/blue-fg}{/bold} Back to list " +
|
|
374
|
+
"{bold}{blue-fg}↑/↓{/blue-fg}{/bold} Scroll " +
|
|
375
|
+
"{bold}{blue-fg}Q{/blue-fg}{/bold} Quit",
|
|
376
|
+
);
|
|
377
|
+
} else if (isThresholdPopupOpen) {
|
|
378
|
+
helpBar.setContent(
|
|
379
|
+
"{bold}{blue-fg}Enter{/blue-fg}{/bold} Apply threshold " +
|
|
380
|
+
"{bold}{blue-fg}Esc{/blue-fg}{/bold} Cancel " +
|
|
381
|
+
"{gray-fg}| applies immediately and clears old trades{/gray-fg}",
|
|
382
|
+
);
|
|
383
|
+
} else if (isAddressPopupOpen) {
|
|
384
|
+
helpBar.setContent(
|
|
385
|
+
"{bold}{blue-fg}Enter{/blue-fg}{/bold} Apply address filter " +
|
|
386
|
+
"{bold}{blue-fg}Esc{/blue-fg}{/bold} Cancel " +
|
|
387
|
+
"{gray-fg}| applies immediately and clears old trades{/gray-fg}",
|
|
388
|
+
);
|
|
389
|
+
} else {
|
|
390
|
+
helpBar.setContent(
|
|
391
|
+
"{bold}{blue-fg}↑/↓{/blue-fg}{/bold} Navigate " +
|
|
392
|
+
"{bold}{blue-fg}Enter{/blue-fg}{/bold} View details " +
|
|
393
|
+
"{bold}{blue-fg}T{/blue-fg}{/bold} Set threshold " +
|
|
394
|
+
"{bold}{blue-fg}A{/blue-fg}/{blue-fg}a{/blue-fg}{/bold} Set addresses " +
|
|
395
|
+
"{bold}{blue-fg}C{/blue-fg}{/bold} Clear trades " +
|
|
396
|
+
"{bold}{blue-fg}L{/blue-fg}{/bold} Latest trade " +
|
|
397
|
+
"{bold}{blue-fg}Q{/blue-fg}{/bold} Quit " +
|
|
398
|
+
"{gray-fg}| -t <usd> -a <addr1,addr2,...>{/gray-fg}",
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
screen.render();
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
// ─── UI Rendering ───────────────────────────────────────────────────
|
|
405
|
+
|
|
406
|
+
const renderTradeList = () => {
|
|
407
|
+
tradeList.clearItems();
|
|
408
|
+
|
|
409
|
+
if (trades.length === 0) {
|
|
410
|
+
tradeList.addItem(
|
|
411
|
+
"{gray-fg} Waiting for trades above threshold...{/gray-fg}" as any,
|
|
412
|
+
);
|
|
413
|
+
statusLine.setContent(
|
|
414
|
+
`{gray-fg}Threshold: $${thresholdUsd.toLocaleString()} | 0 trades{/gray-fg}`,
|
|
415
|
+
);
|
|
416
|
+
screen.render();
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
for (let i = 0; i < trades.length; i++) {
|
|
421
|
+
const t = trades[i]!;
|
|
422
|
+
const dir =
|
|
423
|
+
t.direction === "BUY"
|
|
424
|
+
? "{green-fg}{bold}BUY {/bold}{/green-fg}"
|
|
425
|
+
: t.direction === "SELL"
|
|
426
|
+
? "{red-fg}{bold}SELL{/bold}{/red-fg}"
|
|
427
|
+
: "{gray-fg}??? {/gray-fg}";
|
|
428
|
+
const usd = `{yellow-fg}$${t.usdc.toFixed(2).padStart(12)}{/yellow-fg}`;
|
|
429
|
+
const addr = shortAddr(t.buyer ?? t.maker);
|
|
430
|
+
const time = t.timestamp.toLocaleTimeString();
|
|
431
|
+
const line = ` ${dir} ${usd} {cyan-fg}${addr}{/cyan-fg} {gray-fg}${time}{/gray-fg}`;
|
|
432
|
+
tradeList.addItem(line as any);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (selectedIndex >= trades.length) selectedIndex = trades.length - 1;
|
|
436
|
+
if (selectedIndex < 0) selectedIndex = 0;
|
|
437
|
+
tradeList.select(selectedIndex);
|
|
438
|
+
|
|
439
|
+
statusLine.setContent(
|
|
440
|
+
`{gray-fg}Threshold: $${thresholdUsd.toLocaleString()} | ${trades.length} trade${trades.length !== 1 ? "s" : ""} | ${selectedIndex + 1}/${trades.length}{/gray-fg}`,
|
|
441
|
+
);
|
|
442
|
+
screen.render();
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
const showDetail = (trade: ParsedTrade) => {
|
|
446
|
+
isDetailView = true;
|
|
447
|
+
const dir =
|
|
448
|
+
trade.direction === "BUY"
|
|
449
|
+
? "{green-fg}{bold}BUY{/bold}{/green-fg}"
|
|
450
|
+
: trade.direction === "SELL"
|
|
451
|
+
? "{red-fg}{bold}SELL{/bold}{/red-fg}"
|
|
452
|
+
: "{gray-fg}UNKNOWN{/gray-fg}";
|
|
453
|
+
|
|
454
|
+
const content = [
|
|
455
|
+
`{bold}Direction:{/bold} ${dir}`,
|
|
456
|
+
`{bold}USD Value:{/bold} {yellow-fg}{bold}$${trade.usdc.toFixed(6)}{/bold}{/yellow-fg}`,
|
|
457
|
+
``,
|
|
458
|
+
`{bold}Buyer:{/bold} {cyan-fg}${trade.buyer ?? "unknown"}{/cyan-fg}`,
|
|
459
|
+
`{bold}Buyer Side:{/bold} ${trade.buyerSide ?? "unknown"}`,
|
|
460
|
+
``,
|
|
461
|
+
`{bold}Maker:{/bold} ${trade.maker}`,
|
|
462
|
+
`{bold}Taker:{/bold} ${trade.taker}`,
|
|
463
|
+
``,
|
|
464
|
+
`{bold}Maker Asset:{/bold} ${trade.makerAssetId}`,
|
|
465
|
+
`{bold}Taker Asset:{/bold} ${trade.takerAssetId}`,
|
|
466
|
+
`{bold}Maker Filled:{/bold} ${trade.makerAmountFilled}`,
|
|
467
|
+
`{bold}Taker Filled:{/bold} ${trade.takerAmountFilled}`,
|
|
468
|
+
`{bold}Fee:{/bold} ${trade.fee}`,
|
|
469
|
+
``,
|
|
470
|
+
`{bold}Block:{/bold} ${trade.blockNumber ?? "unknown"}`,
|
|
471
|
+
`{bold}Order Hash:{/bold} ${trade.orderHash}`,
|
|
472
|
+
`{bold}Tx Hash:{/bold} ${trade.txHash}`,
|
|
473
|
+
`{bold}Polygonscan:{/bold} https://polygonscan.com/tx/${trade.txHash}`,
|
|
474
|
+
``,
|
|
475
|
+
`{bold}Detected:{/bold} ${trade.timestamp.toLocaleString()}`,
|
|
476
|
+
].join("\n");
|
|
477
|
+
|
|
478
|
+
detailBox.setContent(content);
|
|
479
|
+
detailBox.show();
|
|
480
|
+
detailBox.focus();
|
|
481
|
+
updateHelpBar();
|
|
482
|
+
screen.render();
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
const hideDetail = () => {
|
|
486
|
+
isDetailView = false;
|
|
487
|
+
detailBox.hide();
|
|
488
|
+
tradeList.focus();
|
|
489
|
+
updateHelpBar();
|
|
490
|
+
screen.render();
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
// ─── Popup Handlers ─────────────────────────────────────────────────
|
|
494
|
+
|
|
495
|
+
const closeThresholdPopup = () => {
|
|
496
|
+
isThresholdPopupOpen = false;
|
|
497
|
+
thresholdPopup.hide();
|
|
498
|
+
thresholdPopupError.setContent("");
|
|
499
|
+
tradeList.focus();
|
|
500
|
+
updateHelpBar();
|
|
501
|
+
screen.render();
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
const applyThresholdFromInput = async (rawValue: string) => {
|
|
505
|
+
const nextThreshold = Number(rawValue.trim());
|
|
506
|
+
if (!Number.isFinite(nextThreshold) || nextThreshold <= 0) {
|
|
507
|
+
thresholdPopupError.setContent(
|
|
508
|
+
"{red-fg}Please enter a positive number, e.g. 500{/red-fg}",
|
|
509
|
+
);
|
|
510
|
+
screen.render();
|
|
511
|
+
thresholdInput.readInput();
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
thresholdUsd = nextThreshold;
|
|
516
|
+
trades = [];
|
|
517
|
+
seen.clear();
|
|
518
|
+
selectedIndex = 0;
|
|
519
|
+
resetCounter += 1;
|
|
520
|
+
|
|
521
|
+
const client = getClient();
|
|
522
|
+
if (!client) return;
|
|
523
|
+
const currentHeight = await client.getHeight();
|
|
524
|
+
queryFromResetBlock = Math.max(0, currentHeight - 1);
|
|
525
|
+
|
|
526
|
+
updateThresholdDisplay();
|
|
527
|
+
renderTradeList();
|
|
528
|
+
closeThresholdPopup();
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
const openThresholdPopup = () => {
|
|
532
|
+
if (isDetailView || isThresholdPopupOpen || isAddressPopupOpen) return;
|
|
533
|
+
|
|
534
|
+
isThresholdPopupOpen = true;
|
|
535
|
+
thresholdPopupError.setContent("");
|
|
536
|
+
thresholdInput.setValue(String(thresholdUsd));
|
|
537
|
+
thresholdPopup.show();
|
|
538
|
+
thresholdInput.focus();
|
|
539
|
+
updateHelpBar();
|
|
540
|
+
screen.render();
|
|
541
|
+
thresholdInput.readInput();
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
const closeAddressPopup = () => {
|
|
545
|
+
isAddressPopupOpen = false;
|
|
546
|
+
addressPopup.hide();
|
|
547
|
+
addressPopupError.setContent("");
|
|
548
|
+
tradeList.focus();
|
|
549
|
+
updateHelpBar();
|
|
550
|
+
screen.render();
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
const applyAddressesFromInput = async (rawValue: string) => {
|
|
554
|
+
const parsedAddresses = parseAddressInput(rawValue);
|
|
555
|
+
if (parsedAddresses === null) {
|
|
556
|
+
addressPopupError.setContent(
|
|
557
|
+
"{red-fg}Invalid format. Use comma-separated 0x... addresses.{/red-fg}",
|
|
558
|
+
);
|
|
559
|
+
screen.render();
|
|
560
|
+
addressInput.readInput();
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
watchAddresses = parsedAddresses;
|
|
565
|
+
trades = [];
|
|
566
|
+
seen.clear();
|
|
567
|
+
selectedIndex = 0;
|
|
568
|
+
resetCounter += 1;
|
|
569
|
+
|
|
570
|
+
const client = getClient();
|
|
571
|
+
if (!client) return;
|
|
572
|
+
const currentHeight = await client.getHeight();
|
|
573
|
+
queryFromResetBlock = Math.max(0, currentHeight - 1);
|
|
574
|
+
|
|
575
|
+
updateAddressDisplay();
|
|
576
|
+
renderTradeList();
|
|
577
|
+
closeAddressPopup();
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
const openAddressPopup = () => {
|
|
581
|
+
if (isDetailView || isThresholdPopupOpen || isAddressPopupOpen) return;
|
|
582
|
+
|
|
583
|
+
isAddressPopupOpen = true;
|
|
584
|
+
addressPopupError.setContent("");
|
|
585
|
+
addressInput.setValue(watchAddresses.join(","));
|
|
586
|
+
addressPopup.show();
|
|
587
|
+
addressInput.focus();
|
|
588
|
+
updateHelpBar();
|
|
589
|
+
screen.render();
|
|
590
|
+
addressInput.readInput();
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
// ─── Popup Event Handlers ───────────────────────────────────────────
|
|
594
|
+
|
|
595
|
+
thresholdInput.on("submit", (value) => {
|
|
596
|
+
void applyThresholdFromInput(String(value ?? ""));
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
thresholdInput.on("cancel", () => {
|
|
600
|
+
closeThresholdPopup();
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
addressInput.on("submit", (value) => {
|
|
604
|
+
void applyAddressesFromInput(String(value ?? ""));
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
addressInput.on("cancel", () => {
|
|
608
|
+
closeAddressPopup();
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
// ─── Key Bindings ───────────────────────────────────────────────────
|
|
612
|
+
|
|
613
|
+
screen.key(["q", "C-c"], () => {
|
|
614
|
+
pollAbort = true;
|
|
615
|
+
process.exit(0);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
screen.key(["c"], () => {
|
|
619
|
+
if (isThresholdPopupOpen || isAddressPopupOpen) return;
|
|
620
|
+
if (!isDetailView) {
|
|
621
|
+
trades = [];
|
|
622
|
+
seen.clear();
|
|
623
|
+
selectedIndex = 0;
|
|
624
|
+
renderTradeList();
|
|
625
|
+
}
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
screen.key(["t"], () => {
|
|
629
|
+
openThresholdPopup();
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
screen.key(["a", "A", "S-a"], () => {
|
|
633
|
+
openAddressPopup();
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
screen.key(["l", "home"], () => {
|
|
637
|
+
if (isDetailView || isThresholdPopupOpen || isAddressPopupOpen) return;
|
|
638
|
+
if (trades.length === 0) return;
|
|
639
|
+
selectedIndex = 0;
|
|
640
|
+
tradeList.select(0);
|
|
641
|
+
statusLine.setContent(
|
|
642
|
+
`{gray-fg}Threshold: $${thresholdUsd.toLocaleString()} | ${trades.length} trade${trades.length !== 1 ? "s" : ""} | 1/${trades.length}{/gray-fg}`,
|
|
643
|
+
);
|
|
644
|
+
screen.render();
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
screen.key(["up", "k"], () => {
|
|
648
|
+
if (isDetailView || isThresholdPopupOpen || isAddressPopupOpen) return;
|
|
649
|
+
if (trades.length === 0) return;
|
|
650
|
+
selectedIndex = Math.max(0, selectedIndex - 1);
|
|
651
|
+
tradeList.select(selectedIndex);
|
|
652
|
+
statusLine.setContent(
|
|
653
|
+
`{gray-fg}Threshold: $${thresholdUsd.toLocaleString()} | ${trades.length} trade${trades.length !== 1 ? "s" : ""} | ${selectedIndex + 1}/${trades.length}{/gray-fg}`,
|
|
654
|
+
);
|
|
655
|
+
screen.render();
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
screen.key(["down", "j"], () => {
|
|
659
|
+
if (isDetailView || isThresholdPopupOpen || isAddressPopupOpen) return;
|
|
660
|
+
if (trades.length === 0) return;
|
|
661
|
+
selectedIndex = Math.min(trades.length - 1, selectedIndex + 1);
|
|
662
|
+
tradeList.select(selectedIndex);
|
|
663
|
+
statusLine.setContent(
|
|
664
|
+
`{gray-fg}Threshold: $${thresholdUsd.toLocaleString()} | ${trades.length} trade${trades.length !== 1 ? "s" : ""} | ${selectedIndex + 1}/${trades.length}{/gray-fg}`,
|
|
665
|
+
);
|
|
666
|
+
screen.render();
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
screen.key(["enter", "return"], () => {
|
|
670
|
+
if (isThresholdPopupOpen || isAddressPopupOpen) return;
|
|
671
|
+
if (isDetailView) return;
|
|
672
|
+
if (trades.length === 0) return;
|
|
673
|
+
const trade = trades[selectedIndex];
|
|
674
|
+
if (trade) showDetail(trade);
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
screen.key(["escape", "backspace"], () => {
|
|
678
|
+
if (isThresholdPopupOpen) {
|
|
679
|
+
closeThresholdPopup();
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
if (isAddressPopupOpen) {
|
|
683
|
+
closeAddressPopup();
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
if (isDetailView) hideDetail();
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
// ─── Polling Loop ───────────────────────────────────────────────────
|
|
690
|
+
|
|
691
|
+
const startPolling = async () => {
|
|
692
|
+
const client = getClient();
|
|
693
|
+
const decoder = getDecoder();
|
|
694
|
+
if (isPolling || !client) return;
|
|
695
|
+
isPolling = true;
|
|
696
|
+
|
|
697
|
+
const currentHeight = await client.getHeight();
|
|
698
|
+
let queryFrom = Math.max(0, currentHeight - 2);
|
|
699
|
+
|
|
700
|
+
while (!pollAbort) {
|
|
701
|
+
try {
|
|
702
|
+
if (queryFromResetBlock !== null) {
|
|
703
|
+
queryFrom = queryFromResetBlock;
|
|
704
|
+
queryFromResetBlock = null;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const cycleResetCounter = resetCounter;
|
|
708
|
+
|
|
709
|
+
const query: Query = {
|
|
710
|
+
fromBlock: queryFrom,
|
|
711
|
+
logs: [
|
|
712
|
+
{
|
|
713
|
+
address: EXCHANGE_ADDRESSES,
|
|
714
|
+
topics: [[ORDER_FILLED_TOPIC], [], [], []],
|
|
715
|
+
},
|
|
716
|
+
],
|
|
717
|
+
fieldSelection: {
|
|
718
|
+
log: [
|
|
719
|
+
"Data",
|
|
720
|
+
"Address",
|
|
721
|
+
"Topic0",
|
|
722
|
+
"Topic1",
|
|
723
|
+
"Topic2",
|
|
724
|
+
"Topic3",
|
|
725
|
+
"TransactionHash",
|
|
726
|
+
"BlockNumber",
|
|
727
|
+
],
|
|
728
|
+
},
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
const res = await client.get(query);
|
|
732
|
+
const logs = (res.data as any).logs ?? [];
|
|
733
|
+
const decodedLogs = decoder ? decoder.decodeLogsSync(logs) : [];
|
|
734
|
+
let newCount = 0;
|
|
735
|
+
|
|
736
|
+
for (let i = 0; i < logs.length; i++) {
|
|
737
|
+
if (cycleResetCounter !== resetCounter) break;
|
|
738
|
+
|
|
739
|
+
const log = logs[i];
|
|
740
|
+
const dec = decodedLogs[i];
|
|
741
|
+
if (!dec) continue;
|
|
742
|
+
|
|
743
|
+
const orderHash = String(dec.indexed?.[0]?.val ?? "");
|
|
744
|
+
const maker = String(dec.indexed?.[1]?.val ?? "").toLowerCase();
|
|
745
|
+
const taker = String(dec.indexed?.[2]?.val ?? "").toLowerCase();
|
|
746
|
+
const makerAssetId = String(dec.body?.[0]?.val ?? "0");
|
|
747
|
+
const takerAssetId = String(dec.body?.[1]?.val ?? "0");
|
|
748
|
+
const makerAmountFilled = String(dec.body?.[2]?.val ?? "0");
|
|
749
|
+
const takerAmountFilled = String(dec.body?.[3]?.val ?? "0");
|
|
750
|
+
const fee = String(dec.body?.[4]?.val ?? "0");
|
|
751
|
+
const tx = log.transactionHash;
|
|
752
|
+
|
|
753
|
+
const key = tradeKey(
|
|
754
|
+
tx,
|
|
755
|
+
orderHash,
|
|
756
|
+
maker,
|
|
757
|
+
makerAssetId,
|
|
758
|
+
makerAmountFilled,
|
|
759
|
+
takerAssetId,
|
|
760
|
+
takerAmountFilled,
|
|
761
|
+
);
|
|
762
|
+
if (seen.has(key)) continue;
|
|
763
|
+
seen.add(key);
|
|
764
|
+
|
|
765
|
+
const { usdc, buyer, buyerSide, direction } = classifyTrade({
|
|
766
|
+
makerAssetId,
|
|
767
|
+
takerAssetId,
|
|
768
|
+
makerAmountFilled,
|
|
769
|
+
takerAmountFilled,
|
|
770
|
+
maker,
|
|
771
|
+
taker,
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
// Only show BUY trades above threshold
|
|
775
|
+
if (direction !== "BUY") continue;
|
|
776
|
+
if (usdc <= thresholdUsd) continue;
|
|
777
|
+
|
|
778
|
+
// Filter by watched addresses if any are set
|
|
779
|
+
if (watchAddresses.length > 0) {
|
|
780
|
+
const matchesBuyer = buyer && watchAddresses.includes(buyer);
|
|
781
|
+
const matchesMaker = watchAddresses.includes(maker);
|
|
782
|
+
const matchesTaker = watchAddresses.includes(taker);
|
|
783
|
+
if (!matchesBuyer && !matchesMaker && !matchesTaker) continue;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const trade: ParsedTrade = {
|
|
787
|
+
txHash: tx,
|
|
788
|
+
blockNumber: log.blockNumber,
|
|
789
|
+
orderHash,
|
|
790
|
+
maker,
|
|
791
|
+
taker,
|
|
792
|
+
makerAssetId,
|
|
793
|
+
takerAssetId,
|
|
794
|
+
makerAmountFilled,
|
|
795
|
+
takerAmountFilled,
|
|
796
|
+
fee,
|
|
797
|
+
buyer,
|
|
798
|
+
buyerSide,
|
|
799
|
+
direction,
|
|
800
|
+
usdc,
|
|
801
|
+
timestamp: new Date(),
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
trades.unshift(trade);
|
|
805
|
+
newCount++;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
if (newCount > 0) {
|
|
809
|
+
selectedIndex = Math.min(trades.length - 1, selectedIndex + newCount);
|
|
810
|
+
renderTradeList();
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Wait for chain to advance
|
|
814
|
+
let height = res.archiveHeight;
|
|
815
|
+
while ((height ?? queryFrom) < res.nextBlock) {
|
|
816
|
+
if (pollAbort) return;
|
|
817
|
+
height = await client.getHeight();
|
|
818
|
+
await sleep(1000);
|
|
819
|
+
}
|
|
820
|
+
queryFrom = res.nextBlock as number;
|
|
821
|
+
} catch (err) {
|
|
822
|
+
// Silently retry on transient errors
|
|
823
|
+
await sleep(3000);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
};
|
|
827
|
+
|
|
828
|
+
// ─── Initialize UI ──────────────────────────────────────────────────
|
|
829
|
+
|
|
830
|
+
updateThresholdDisplay();
|
|
831
|
+
updateAddressDisplay();
|
|
832
|
+
renderTradeList();
|
|
833
|
+
updateHelpBar();
|
|
834
|
+
tradeList.focus();
|
|
835
|
+
screen.render();
|
|
836
|
+
|
|
837
|
+
startPolling();
|
|
838
|
+
};
|