@relay-federation/bridge 0.3.5 → 0.3.8
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/cli.js +1072 -1063
- package/lib/bsv-peer.js +6 -4
- package/lib/data-endpoints.js +90 -0
- package/lib/data-relay.js +375 -0
- package/lib/status-server.js +1120 -1065
- package/package.json +43 -43
package/lib/status-server.js
CHANGED
|
@@ -1,1065 +1,1120 @@
|
|
|
1
|
-
import { createServer } from 'node:http'
|
|
2
|
-
import { createHash } from 'node:crypto'
|
|
3
|
-
import { readFileSync } from 'node:fs'
|
|
4
|
-
import { join, dirname } from 'node:path'
|
|
5
|
-
import { fileURLToPath } from 'node:url'
|
|
6
|
-
import https from 'node:https'
|
|
7
|
-
import { parseTx } from './output-parser.js'
|
|
8
|
-
import { scanAddress } from './address-scanner.js'
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
* @param {object}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
this.
|
|
43
|
-
this.
|
|
44
|
-
this.
|
|
45
|
-
this.
|
|
46
|
-
this.
|
|
47
|
-
this.
|
|
48
|
-
this.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
this.
|
|
52
|
-
this.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
this.
|
|
56
|
-
this.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
this.
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
this.
|
|
64
|
-
this.
|
|
65
|
-
this.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
status.
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
if (
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
// GET /
|
|
392
|
-
if (req.method === 'GET' && path === '/
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
// Add
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
res.
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
const
|
|
475
|
-
const
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
const
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
res.
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
res.
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
res.
|
|
649
|
-
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
res.
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
'
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
if (!
|
|
720
|
-
res.writeHead(
|
|
721
|
-
res.end(JSON.stringify({ error: '
|
|
722
|
-
return
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
res.
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
res.
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
})
|
|
789
|
-
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
res.
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
res.
|
|
815
|
-
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
}
|
|
860
|
-
res.
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
res.
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
})
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
res.
|
|
973
|
-
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
res.
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1
|
+
import { createServer } from 'node:http'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { join, dirname } from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import https from 'node:https'
|
|
7
|
+
import { parseTx } from './output-parser.js'
|
|
8
|
+
import { scanAddress } from './address-scanner.js'
|
|
9
|
+
import { handlePostData, handleGetTopics, handleGetData } from './data-endpoints.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* StatusServer — public-facing HTTP server exposing bridge status and APIs.
|
|
13
|
+
*
|
|
14
|
+
* Started by `relay-bridge start`, queried by `relay-bridge status`.
|
|
15
|
+
* Binds to 0.0.0.0 — accessible from outside the machine.
|
|
16
|
+
* Operator-only endpoints are gated by statusSecret authentication.
|
|
17
|
+
*
|
|
18
|
+
* Endpoints:
|
|
19
|
+
* GET / — HTML dashboard (auto-refreshes every 5s)
|
|
20
|
+
* GET /status — JSON object with bridge state
|
|
21
|
+
* GET /discover — Known bridges in the mesh
|
|
22
|
+
* POST /broadcast — Relay a raw transaction
|
|
23
|
+
* POST /data — Submit a signed data envelope
|
|
24
|
+
* GET /data/topics — List topics with cached data
|
|
25
|
+
* GET /data/:topic — Query cached envelopes by topic
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
29
|
+
const DASHBOARD_HTML = readFileSync(join(__dirname, '..', 'dashboard', 'index.html'), 'utf8')
|
|
30
|
+
export class StatusServer {
|
|
31
|
+
/**
|
|
32
|
+
* @param {object} opts
|
|
33
|
+
* @param {number} [opts.port=9333] — HTTP port for status endpoint
|
|
34
|
+
* @param {import('./peer-manager.js').PeerManager} [opts.peerManager]
|
|
35
|
+
* @param {import('./header-relay.js').HeaderRelay} [opts.headerRelay]
|
|
36
|
+
* @param {import('./tx-relay.js').TxRelay} [opts.txRelay]
|
|
37
|
+
* @param {object} [opts.config] — Bridge config (pubkeyHex, endpoint, meshId)
|
|
38
|
+
* @param {object} [opts.bsvNodeClient] — BSV P2P node client (2.26)
|
|
39
|
+
* @param {object} [opts.store] — PersistentStore for wallet balance (2.27)
|
|
40
|
+
*/
|
|
41
|
+
constructor (opts = {}) {
|
|
42
|
+
this._port = opts.port || 9333
|
|
43
|
+
this._peerManager = opts.peerManager || null
|
|
44
|
+
this._headerRelay = opts.headerRelay || null
|
|
45
|
+
this._txRelay = opts.txRelay || null
|
|
46
|
+
this._dataRelay = opts.dataRelay || null
|
|
47
|
+
this._config = opts.config || {}
|
|
48
|
+
this._scorer = opts.scorer || null
|
|
49
|
+
this._peerHealth = opts.peerHealth || null
|
|
50
|
+
this._bsvNodeClient = opts.bsvNodeClient || null
|
|
51
|
+
this._store = opts.store || null
|
|
52
|
+
this._performOutboundHandshake = opts.performOutboundHandshake || null
|
|
53
|
+
this._registeredPubkeys = opts.registeredPubkeys || null
|
|
54
|
+
this._gossipManager = opts.gossipManager || null
|
|
55
|
+
this._startedAt = Date.now()
|
|
56
|
+
this._server = null
|
|
57
|
+
|
|
58
|
+
// Job system for async actions (register, deregister)
|
|
59
|
+
this._jobs = new Map()
|
|
60
|
+
this._jobCounter = 0
|
|
61
|
+
|
|
62
|
+
// Log ring buffer — max 500 entries
|
|
63
|
+
this._logs = []
|
|
64
|
+
this._logListeners = new Set()
|
|
65
|
+
this._maxLogs = 500
|
|
66
|
+
|
|
67
|
+
// App monitoring state
|
|
68
|
+
this._appChecks = new Map()
|
|
69
|
+
this._requestTracker = new Map()
|
|
70
|
+
this._appSSLCache = new Map()
|
|
71
|
+
this._appBridgeDomains = new Set()
|
|
72
|
+
this._appCheckInterval = null
|
|
73
|
+
this._addressCache = new Map()
|
|
74
|
+
if (this._config.apps) {
|
|
75
|
+
for (const app of this._config.apps) {
|
|
76
|
+
this._appChecks.set(app.url, { checks: [], lastError: null })
|
|
77
|
+
if (app.bridgeDomain) {
|
|
78
|
+
this._appBridgeDomains.add(app.bridgeDomain)
|
|
79
|
+
this._requestTracker.set(app.bridgeDomain, { total: 0, endpoints: {}, lastSeen: null })
|
|
80
|
+
}
|
|
81
|
+
try { this._appBridgeDomains.add(new URL(app.url).hostname) } catch {}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build the status object from current bridge state.
|
|
88
|
+
* @param {object} [opts]
|
|
89
|
+
* @param {boolean} [opts.authenticated=false] — Include operator-only fields
|
|
90
|
+
* @returns {Promise<object>}
|
|
91
|
+
*/
|
|
92
|
+
async getStatus ({ authenticated = false } = {}) {
|
|
93
|
+
const peers = []
|
|
94
|
+
if (this._peerManager) {
|
|
95
|
+
for (const [pubkeyHex, conn] of this._peerManager.peers) {
|
|
96
|
+
const entry = {
|
|
97
|
+
pubkeyHex,
|
|
98
|
+
endpoint: conn.endpoint,
|
|
99
|
+
connected: !!conn.connected
|
|
100
|
+
}
|
|
101
|
+
if (this._scorer) {
|
|
102
|
+
entry.score = Math.round(this._scorer.getScore(pubkeyHex) * 100) / 100
|
|
103
|
+
const metrics = this._scorer.getMetrics(pubkeyHex)
|
|
104
|
+
if (metrics) {
|
|
105
|
+
entry.scoreBreakdown = {
|
|
106
|
+
uptime: Math.round(metrics.uptime * 100) / 100,
|
|
107
|
+
responseTime: Math.round(metrics.responseTime * 100) / 100,
|
|
108
|
+
dataAccuracy: Math.round(metrics.dataAccuracy * 100) / 100,
|
|
109
|
+
stakeAge: Math.round(metrics.stakeAge * 100) / 100,
|
|
110
|
+
raw: metrics.raw
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (this._peerHealth) {
|
|
115
|
+
entry.health = this._peerHealth.getStatus(pubkeyHex)
|
|
116
|
+
}
|
|
117
|
+
peers.push(entry)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const status = {
|
|
122
|
+
bridge: {
|
|
123
|
+
name: this._config.name || null,
|
|
124
|
+
pubkeyHex: this._config.pubkeyHex || null,
|
|
125
|
+
meshId: this._config.meshId || null,
|
|
126
|
+
uptimeSeconds: Math.floor((Date.now() - this._startedAt) / 1000)
|
|
127
|
+
},
|
|
128
|
+
peers: {
|
|
129
|
+
connected: this._peerManager ? this._peerManager.connectedCount() : 0,
|
|
130
|
+
list: peers
|
|
131
|
+
},
|
|
132
|
+
headers: {
|
|
133
|
+
bestHeight: this._headerRelay ? this._headerRelay.bestHeight : -1,
|
|
134
|
+
bestHash: this._headerRelay ? this._headerRelay.bestHash : null,
|
|
135
|
+
count: this._headerRelay ? this._headerRelay.headers.size : 0
|
|
136
|
+
},
|
|
137
|
+
txs: {
|
|
138
|
+
mempool: this._txRelay ? this._txRelay.mempool.size : 0,
|
|
139
|
+
seen: this._txRelay ? this._txRelay.seen.size : 0
|
|
140
|
+
},
|
|
141
|
+
bsvNode: {
|
|
142
|
+
connected: this._bsvNodeClient ? this._bsvNodeClient.connectedCount > 0 : false,
|
|
143
|
+
peers: this._bsvNodeClient ? this._bsvNodeClient.connectedCount : 0,
|
|
144
|
+
height: this._bsvNodeClient ? this._bsvNodeClient.bestHeight : null
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Operator-only fields
|
|
149
|
+
if (authenticated) {
|
|
150
|
+
status.operator = true
|
|
151
|
+
status.bridge.endpoint = this._config.endpoint || null
|
|
152
|
+
status.bridge.domains = this._config.domains || []
|
|
153
|
+
try {
|
|
154
|
+
const { PrivateKey } = await import('@bsv/sdk')
|
|
155
|
+
status.bridge.address = PrivateKey.fromWif(this._config.wif).toPublicKey().toAddress()
|
|
156
|
+
} catch {
|
|
157
|
+
status.bridge.address = this._config.address || null
|
|
158
|
+
}
|
|
159
|
+
status.wallet = { balanceSats: null, utxoCount: 0 }
|
|
160
|
+
if (this._store) {
|
|
161
|
+
try { status.wallet.balanceSats = await this._store.getBalance() } catch {}
|
|
162
|
+
try { status.wallet.utxoCount = (await this._store.getUnspentUtxos()).length } catch {}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return status
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Check if a request is authenticated via statusSecret.
|
|
171
|
+
* @param {import('node:http').IncomingMessage} req
|
|
172
|
+
* @returns {boolean}
|
|
173
|
+
*/
|
|
174
|
+
_checkAuth (req) {
|
|
175
|
+
const secret = this._config.statusSecret
|
|
176
|
+
if (!secret) return false
|
|
177
|
+
|
|
178
|
+
// Check ?auth= query param
|
|
179
|
+
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`)
|
|
180
|
+
const authParam = url.searchParams.get('auth')
|
|
181
|
+
if (authParam === secret) return true
|
|
182
|
+
|
|
183
|
+
// Check Authorization: Bearer header
|
|
184
|
+
const authHeader = req.headers.authorization
|
|
185
|
+
if (authHeader && authHeader.startsWith('Bearer ') && authHeader.slice(7) === secret) return true
|
|
186
|
+
|
|
187
|
+
return false
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Add a log entry to the ring buffer and notify SSE listeners.
|
|
192
|
+
* @param {string} message
|
|
193
|
+
*/
|
|
194
|
+
addLog (message) {
|
|
195
|
+
const entry = { timestamp: Date.now(), message }
|
|
196
|
+
this._logs.push(entry)
|
|
197
|
+
if (this._logs.length > this._maxLogs) {
|
|
198
|
+
this._logs.shift()
|
|
199
|
+
}
|
|
200
|
+
// Notify SSE listeners
|
|
201
|
+
for (const listener of this._logListeners) {
|
|
202
|
+
listener(entry)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Create a job for tracking async actions.
|
|
208
|
+
* @returns {{ jobId: string, log: function }}
|
|
209
|
+
*/
|
|
210
|
+
_createJob () {
|
|
211
|
+
const jobId = `job_${++this._jobCounter}_${Date.now()}`
|
|
212
|
+
const job = { status: 'running', events: [], done: false, listeners: new Set() }
|
|
213
|
+
this._jobs.set(jobId, job)
|
|
214
|
+
|
|
215
|
+
// Auto-cleanup after 5 minutes
|
|
216
|
+
setTimeout(() => this._jobs.delete(jobId), 5 * 60 * 1000)
|
|
217
|
+
|
|
218
|
+
const log = (type, message, data) => {
|
|
219
|
+
const event = { type, message, data, timestamp: Date.now() }
|
|
220
|
+
job.events.push(event)
|
|
221
|
+
if (type === 'done' || type === 'error') {
|
|
222
|
+
job.status = type === 'error' ? 'failed' : 'completed'
|
|
223
|
+
job.done = true
|
|
224
|
+
}
|
|
225
|
+
// Notify SSE listeners
|
|
226
|
+
for (const listener of job.listeners) {
|
|
227
|
+
listener(event)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { jobId, log }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Read the full JSON body from a request.
|
|
236
|
+
* @param {import('node:http').IncomingMessage} req
|
|
237
|
+
* @returns {Promise<object>}
|
|
238
|
+
*/
|
|
239
|
+
_readBody (req) {
|
|
240
|
+
return new Promise((resolve, reject) => {
|
|
241
|
+
let body = ''
|
|
242
|
+
req.on('data', chunk => { body += chunk })
|
|
243
|
+
req.on('end', () => {
|
|
244
|
+
try { resolve(body ? JSON.parse(body) : {}) } catch (e) { reject(e) }
|
|
245
|
+
})
|
|
246
|
+
req.on('error', reject)
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Check SSL certificate for a hostname.
|
|
252
|
+
*/
|
|
253
|
+
_checkSSL (hostname) {
|
|
254
|
+
return new Promise((resolve) => {
|
|
255
|
+
const req = https.request({ hostname, port: 443, method: 'HEAD', rejectUnauthorized: false, timeout: 5000 }, (res) => {
|
|
256
|
+
const cert = res.socket.getPeerCertificate()
|
|
257
|
+
if (!cert || !cert.valid_to) { resolve(null); req.destroy(); return }
|
|
258
|
+
resolve({
|
|
259
|
+
valid: res.socket.authorized,
|
|
260
|
+
issuer: cert.issuer?.O || cert.issuer?.CN || 'Unknown',
|
|
261
|
+
expiresAt: new Date(cert.valid_to).toISOString(),
|
|
262
|
+
daysRemaining: Math.floor((new Date(cert.valid_to) - Date.now()) / 86400000)
|
|
263
|
+
})
|
|
264
|
+
req.destroy()
|
|
265
|
+
})
|
|
266
|
+
req.on('error', () => resolve(null))
|
|
267
|
+
req.setTimeout(5000, () => { req.destroy(); resolve(null) })
|
|
268
|
+
req.end()
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Health-check a single app.
|
|
274
|
+
*/
|
|
275
|
+
async _checkApp (app) {
|
|
276
|
+
const entry = this._appChecks.get(app.url)
|
|
277
|
+
if (!entry) return
|
|
278
|
+
const start = Date.now()
|
|
279
|
+
let statusCode = 0
|
|
280
|
+
let up = false
|
|
281
|
+
let errorMsg = null
|
|
282
|
+
try {
|
|
283
|
+
const controller = new AbortController()
|
|
284
|
+
const timeout = setTimeout(() => controller.abort(), 5000)
|
|
285
|
+
const res = await fetch(app.healthUrl || app.url, { method: app.healthUrl ? 'GET' : 'HEAD', signal: controller.signal, redirect: 'follow' })
|
|
286
|
+
clearTimeout(timeout)
|
|
287
|
+
statusCode = res.status
|
|
288
|
+
up = statusCode >= 200 && statusCode < 400
|
|
289
|
+
} catch (err) {
|
|
290
|
+
errorMsg = err.message || 'Request failed'
|
|
291
|
+
}
|
|
292
|
+
const check = { timestamp: new Date().toISOString(), up, statusCode, responseTimeMs: Date.now() - start }
|
|
293
|
+
entry.checks.push(check)
|
|
294
|
+
if (entry.checks.length > 100) entry.checks.shift()
|
|
295
|
+
if (!up) entry.lastError = { message: errorMsg || `HTTP ${statusCode}`, timestamp: check.timestamp }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Run health checks on all configured apps.
|
|
300
|
+
*/
|
|
301
|
+
async _checkAllApps () {
|
|
302
|
+
if (!this._config.apps) return
|
|
303
|
+
for (const app of this._config.apps) {
|
|
304
|
+
await this._checkApp(app)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Start background app health monitoring (30s interval).
|
|
310
|
+
*/
|
|
311
|
+
startAppMonitoring () {
|
|
312
|
+
if (!this._config.apps || this._config.apps.length === 0) return
|
|
313
|
+
this._checkAllApps()
|
|
314
|
+
this._appCheckInterval = setInterval(() => this._checkAllApps(), 30000)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Stop background app health monitoring.
|
|
319
|
+
*/
|
|
320
|
+
stopAppMonitoring () {
|
|
321
|
+
if (this._appCheckInterval) {
|
|
322
|
+
clearInterval(this._appCheckInterval)
|
|
323
|
+
this._appCheckInterval = null
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Start the HTTP server on localhost.
|
|
329
|
+
* @returns {Promise<void>}
|
|
330
|
+
*/
|
|
331
|
+
start () {
|
|
332
|
+
return new Promise((resolve, reject) => {
|
|
333
|
+
this._server = createServer((req, res) => {
|
|
334
|
+
// CORS headers for federation dashboard
|
|
335
|
+
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
336
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
|
337
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
|
338
|
+
|
|
339
|
+
if (req.method === 'OPTIONS') {
|
|
340
|
+
res.writeHead(204)
|
|
341
|
+
res.end()
|
|
342
|
+
return
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
this._handleRequest(req, res).catch(() => {
|
|
346
|
+
res.writeHead(500)
|
|
347
|
+
res.end('Internal Server Error')
|
|
348
|
+
})
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
this._server.listen(this._port, '0.0.0.0', () => resolve())
|
|
352
|
+
this._server.on('error', reject)
|
|
353
|
+
})
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Route incoming HTTP requests.
|
|
358
|
+
* @param {import('node:http').IncomingMessage} req
|
|
359
|
+
* @param {import('node:http').ServerResponse} res
|
|
360
|
+
*/
|
|
361
|
+
async _handleRequest (req, res) {
|
|
362
|
+
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`)
|
|
363
|
+
const path = url.pathname
|
|
364
|
+
const authenticated = this._checkAuth(req)
|
|
365
|
+
|
|
366
|
+
// Track requests from known app domains
|
|
367
|
+
const origin = req.headers.origin || req.headers.referer || ''
|
|
368
|
+
const host = (req.headers.host || '').split(':')[0]
|
|
369
|
+
let trackDomain = null
|
|
370
|
+
if (origin) { try { trackDomain = new URL(origin).hostname } catch {} }
|
|
371
|
+
if (!trackDomain && host && this._appBridgeDomains.has(host)) trackDomain = host
|
|
372
|
+
if (trackDomain && this._appBridgeDomains.has(trackDomain)) {
|
|
373
|
+
let bridgeDomain = trackDomain
|
|
374
|
+
if (this._config.apps) {
|
|
375
|
+
for (const app of this._config.apps) {
|
|
376
|
+
try { if (trackDomain === new URL(app.url).hostname) { bridgeDomain = app.bridgeDomain; break } } catch {}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const data = this._requestTracker.get(bridgeDomain)
|
|
380
|
+
if (data) {
|
|
381
|
+
data.total++
|
|
382
|
+
let ep = path
|
|
383
|
+
if (path.startsWith('/tx/')) ep = '/tx/:txid'
|
|
384
|
+
else if (path.startsWith('/inscription/')) ep = '/inscription/:content'
|
|
385
|
+
else if (path.startsWith('/jobs/')) ep = '/jobs/:id'
|
|
386
|
+
data.endpoints[ep] = (data.endpoints[ep] || 0) + 1
|
|
387
|
+
data.lastSeen = new Date().toISOString()
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// GET /status — public or operator status
|
|
392
|
+
if (req.method === 'GET' && path === '/status') {
|
|
393
|
+
const status = await this.getStatus({ authenticated })
|
|
394
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
395
|
+
res.end(JSON.stringify(status))
|
|
396
|
+
return
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// GET /mempool — public decoded mempool transactions
|
|
400
|
+
if (req.method === 'GET' && path === '/mempool') {
|
|
401
|
+
const txs = []
|
|
402
|
+
if (this._txRelay) {
|
|
403
|
+
for (const [txid, rawHex] of this._txRelay.mempool) {
|
|
404
|
+
try {
|
|
405
|
+
const parsed = parseTx(rawHex)
|
|
406
|
+
txs.push({
|
|
407
|
+
txid,
|
|
408
|
+
size: rawHex.length / 2,
|
|
409
|
+
inputs: parsed.inputs,
|
|
410
|
+
outputs: parsed.outputs.map(o => ({
|
|
411
|
+
vout: o.vout,
|
|
412
|
+
satoshis: o.satoshis,
|
|
413
|
+
isP2PKH: o.isP2PKH,
|
|
414
|
+
hash160: o.hash160,
|
|
415
|
+
type: o.type,
|
|
416
|
+
data: o.data,
|
|
417
|
+
protocol: o.protocol,
|
|
418
|
+
parsed: o.parsed
|
|
419
|
+
}))
|
|
420
|
+
})
|
|
421
|
+
} catch {
|
|
422
|
+
txs.push({ txid, size: rawHex.length / 2, inputs: [], outputs: [], error: 'decode failed' })
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
427
|
+
res.end(JSON.stringify({ count: txs.length, txs }))
|
|
428
|
+
return
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// GET /discover — public list of all known bridges in the mesh
|
|
432
|
+
if (req.method === 'GET' && path === '/discover') {
|
|
433
|
+
const bridges = []
|
|
434
|
+
// Add self
|
|
435
|
+
bridges.push({
|
|
436
|
+
name: this._config.name || null,
|
|
437
|
+
pubkeyHex: this._config.pubkeyHex || null,
|
|
438
|
+
endpoint: this._config.endpoint || null,
|
|
439
|
+
meshId: this._config.meshId || null,
|
|
440
|
+
statusUrl: 'http://' + (req.headers.host || '127.0.0.1:' + this._port) + '/status'
|
|
441
|
+
})
|
|
442
|
+
// Add gossip directory (all known peers)
|
|
443
|
+
if (this._gossipManager) {
|
|
444
|
+
for (const peer of this._gossipManager.getDirectory()) {
|
|
445
|
+
// Derive statusUrl from ws endpoint: ws://host:8333 → http://host:9333
|
|
446
|
+
let statusUrl = null
|
|
447
|
+
try {
|
|
448
|
+
const u = new URL(peer.endpoint)
|
|
449
|
+
const statusPort = parseInt(u.port, 10) + 1000
|
|
450
|
+
statusUrl = 'http://' + u.hostname + ':' + statusPort + '/status'
|
|
451
|
+
} catch {}
|
|
452
|
+
bridges.push({
|
|
453
|
+
pubkeyHex: peer.pubkeyHex,
|
|
454
|
+
endpoint: peer.endpoint,
|
|
455
|
+
meshId: peer.meshId || null,
|
|
456
|
+
statusUrl
|
|
457
|
+
})
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
|
|
461
|
+
res.end(JSON.stringify({ count: bridges.length, bridges }))
|
|
462
|
+
return
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// GET / or /dashboard — built-in HTML dashboard
|
|
466
|
+
if (req.method === 'GET' && (path === '/' || path === '/dashboard')) {
|
|
467
|
+
res.writeHead(200, { 'Content-Type': 'text/html' })
|
|
468
|
+
res.end(DASHBOARD_HTML)
|
|
469
|
+
return
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// POST /broadcast — relay a raw tx to peers
|
|
473
|
+
if (req.method === 'POST' && path === '/broadcast') {
|
|
474
|
+
const body = await this._readBody(req)
|
|
475
|
+
const { rawHex } = body
|
|
476
|
+
if (!rawHex || typeof rawHex !== 'string') {
|
|
477
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
478
|
+
res.end(JSON.stringify({ error: 'rawHex required' }))
|
|
479
|
+
return
|
|
480
|
+
}
|
|
481
|
+
const buf = Buffer.from(rawHex, 'hex')
|
|
482
|
+
const hash = createHash('sha256').update(createHash('sha256').update(buf).digest()).digest()
|
|
483
|
+
const txid = Buffer.from(hash).reverse().toString('hex')
|
|
484
|
+
const sent = this._txRelay ? this._txRelay.broadcastTx(txid, rawHex) : 0
|
|
485
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
486
|
+
res.end(JSON.stringify({ txid, peers: sent }))
|
|
487
|
+
return
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// POST /data — submit a signed data envelope for relay
|
|
491
|
+
if (req.method === 'POST' && path === '/data') {
|
|
492
|
+
if (!this._dataRelay) {
|
|
493
|
+
res.writeHead(503, { 'Content-Type': 'application/json' })
|
|
494
|
+
res.end(JSON.stringify({ error: 'Data relay not available' }))
|
|
495
|
+
return
|
|
496
|
+
}
|
|
497
|
+
let body
|
|
498
|
+
try {
|
|
499
|
+
body = await this._readBody(req)
|
|
500
|
+
} catch {
|
|
501
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
502
|
+
res.end(JSON.stringify({ error: 'invalid_json' }))
|
|
503
|
+
return
|
|
504
|
+
}
|
|
505
|
+
handlePostData(this._dataRelay, body, res)
|
|
506
|
+
return
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// GET /data/topics — list topics with summary objects
|
|
510
|
+
if (req.method === 'GET' && path === '/data/topics') {
|
|
511
|
+
if (!this._dataRelay) {
|
|
512
|
+
res.writeHead(503, { 'Content-Type': 'application/json' })
|
|
513
|
+
res.end(JSON.stringify({ error: 'Data relay not available' }))
|
|
514
|
+
return
|
|
515
|
+
}
|
|
516
|
+
handleGetTopics(this._dataRelay, res)
|
|
517
|
+
return
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// GET /data/:topic — query cached envelopes with since/limit/hasMore
|
|
521
|
+
if (req.method === 'GET' && path.startsWith('/data/')) {
|
|
522
|
+
if (!this._dataRelay) {
|
|
523
|
+
res.writeHead(503, { 'Content-Type': 'application/json' })
|
|
524
|
+
res.end(JSON.stringify({ error: 'Data relay not available' }))
|
|
525
|
+
return
|
|
526
|
+
}
|
|
527
|
+
const topic = decodeURIComponent(path.slice(6))
|
|
528
|
+
if (!topic) {
|
|
529
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
530
|
+
res.end(JSON.stringify({ error: 'Topic required' }))
|
|
531
|
+
return
|
|
532
|
+
}
|
|
533
|
+
handleGetData(this._dataRelay, topic, url.searchParams, res)
|
|
534
|
+
return
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// GET /tx/:txid — fetch and parse transaction with full protocol support
|
|
538
|
+
if (req.method === 'GET' && path.startsWith('/tx/')) {
|
|
539
|
+
const txid = path.slice(4)
|
|
540
|
+
if (!txid || txid.length !== 64) {
|
|
541
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
542
|
+
res.end(JSON.stringify({ error: 'Invalid txid' }))
|
|
543
|
+
return
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
let rawHex = null
|
|
547
|
+
let source = null
|
|
548
|
+
|
|
549
|
+
// Check mempool first
|
|
550
|
+
if (this._txRelay && this._txRelay.mempool.has(txid)) {
|
|
551
|
+
rawHex = this._txRelay.mempool.get(txid)
|
|
552
|
+
source = 'mempool'
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Try P2P
|
|
556
|
+
if (!rawHex && this._bsvNodeClient) {
|
|
557
|
+
try {
|
|
558
|
+
const result = await this._bsvNodeClient.getTx(txid, 5000)
|
|
559
|
+
rawHex = result.rawHex
|
|
560
|
+
source = 'p2p'
|
|
561
|
+
} catch {}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Fall back to WoC
|
|
565
|
+
if (!rawHex) {
|
|
566
|
+
try {
|
|
567
|
+
const resp = await fetch(`https://api.whatsonchain.com/v1/bsv/main/tx/${txid}/hex`)
|
|
568
|
+
if (!resp.ok) throw new Error(`WoC ${resp.status}`)
|
|
569
|
+
rawHex = await resp.text()
|
|
570
|
+
source = 'woc'
|
|
571
|
+
} catch (err) {
|
|
572
|
+
res.writeHead(404, { 'Content-Type': 'application/json' })
|
|
573
|
+
res.end(JSON.stringify({ error: `tx not found: ${err.message}` }))
|
|
574
|
+
return
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Parse with full protocol support
|
|
579
|
+
try {
|
|
580
|
+
const parsed = parseTx(rawHex)
|
|
581
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
582
|
+
res.end(JSON.stringify({
|
|
583
|
+
txid: parsed.txid,
|
|
584
|
+
source,
|
|
585
|
+
size: rawHex.length / 2,
|
|
586
|
+
inputs: parsed.inputs,
|
|
587
|
+
outputs: parsed.outputs
|
|
588
|
+
}))
|
|
589
|
+
} catch (err) {
|
|
590
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
591
|
+
res.end(JSON.stringify({ txid, source, size: rawHex.length / 2, error: 'parse failed: ' + err.message }))
|
|
592
|
+
}
|
|
593
|
+
return
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// POST /register — operator: start async registration
|
|
597
|
+
if (req.method === 'POST' && path === '/register') {
|
|
598
|
+
if (!authenticated) {
|
|
599
|
+
res.writeHead(401, { 'Content-Type': 'application/json' })
|
|
600
|
+
res.end(JSON.stringify({ error: 'Unauthorized. Provide statusSecret via ?auth= or Authorization header.' }))
|
|
601
|
+
return
|
|
602
|
+
}
|
|
603
|
+
const { runRegister } = await import('./actions.js')
|
|
604
|
+
const { jobId, log } = this._createJob()
|
|
605
|
+
res.writeHead(202, { 'Content-Type': 'application/json' })
|
|
606
|
+
res.end(JSON.stringify({ jobId, stream: `/jobs/${jobId}` }))
|
|
607
|
+
// Run async — don't await
|
|
608
|
+
runRegister({ config: this._config, store: this._store, log }).catch(err => {
|
|
609
|
+
log('error', err.message)
|
|
610
|
+
})
|
|
611
|
+
return
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// POST /deregister — operator: start async deregistration
|
|
615
|
+
if (req.method === 'POST' && path === '/deregister') {
|
|
616
|
+
if (!authenticated) {
|
|
617
|
+
res.writeHead(401, { 'Content-Type': 'application/json' })
|
|
618
|
+
res.end(JSON.stringify({ error: 'Unauthorized. Provide statusSecret via ?auth= or Authorization header.' }))
|
|
619
|
+
return
|
|
620
|
+
}
|
|
621
|
+
const { runDeregister } = await import('./actions.js')
|
|
622
|
+
const body = await this._readBody(req)
|
|
623
|
+
const { jobId, log } = this._createJob()
|
|
624
|
+
res.writeHead(202, { 'Content-Type': 'application/json' })
|
|
625
|
+
res.end(JSON.stringify({ jobId, stream: `/jobs/${jobId}` }))
|
|
626
|
+
runDeregister({ config: this._config, store: this._store, reason: body.reason || 'shutdown', log }).catch(err => {
|
|
627
|
+
log('error', err.message)
|
|
628
|
+
})
|
|
629
|
+
return
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// POST /fund — operator: store a funding tx (synchronous)
|
|
633
|
+
if (req.method === 'POST' && path === '/fund') {
|
|
634
|
+
if (!authenticated) {
|
|
635
|
+
res.writeHead(401, { 'Content-Type': 'application/json' })
|
|
636
|
+
res.end(JSON.stringify({ error: 'Unauthorized. Provide statusSecret via ?auth= or Authorization header.' }))
|
|
637
|
+
return
|
|
638
|
+
}
|
|
639
|
+
const { runFund } = await import('./actions.js')
|
|
640
|
+
const body = await this._readBody(req)
|
|
641
|
+
if (!body.rawHex) {
|
|
642
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
643
|
+
res.end(JSON.stringify({ error: 'rawHex required' }))
|
|
644
|
+
return
|
|
645
|
+
}
|
|
646
|
+
try {
|
|
647
|
+
const result = await runFund({ config: this._config, store: this._store, rawHex: body.rawHex, log: () => {} })
|
|
648
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
649
|
+
res.end(JSON.stringify(result))
|
|
650
|
+
} catch (err) {
|
|
651
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
652
|
+
res.end(JSON.stringify({ error: err.message }))
|
|
653
|
+
}
|
|
654
|
+
return
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// POST /connect — operator: connect to a peer endpoint
|
|
658
|
+
if (req.method === 'POST' && path === '/connect') {
|
|
659
|
+
if (!authenticated) {
|
|
660
|
+
res.writeHead(401, { 'Content-Type': 'application/json' })
|
|
661
|
+
res.end(JSON.stringify({ error: 'Unauthorized. Provide statusSecret via ?auth= or Authorization header.' }))
|
|
662
|
+
return
|
|
663
|
+
}
|
|
664
|
+
const body = await this._readBody(req)
|
|
665
|
+
if (!body.endpoint) {
|
|
666
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
667
|
+
res.end(JSON.stringify({ error: 'endpoint required (e.g. ws://host:port)' }))
|
|
668
|
+
return
|
|
669
|
+
}
|
|
670
|
+
if (!this._peerManager || !this._performOutboundHandshake) {
|
|
671
|
+
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
672
|
+
res.end(JSON.stringify({ error: 'Bridge not running — peer manager unavailable' }))
|
|
673
|
+
return
|
|
674
|
+
}
|
|
675
|
+
try {
|
|
676
|
+
const conn = this._peerManager.connectToPeer({ endpoint: body.endpoint })
|
|
677
|
+
if (conn) {
|
|
678
|
+
conn.on('open', () => this._performOutboundHandshake(conn))
|
|
679
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
680
|
+
res.end(JSON.stringify({ endpoint: body.endpoint, status: 'connecting' }))
|
|
681
|
+
} else {
|
|
682
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
683
|
+
res.end(JSON.stringify({ endpoint: body.endpoint, status: 'already_connected_or_failed' }))
|
|
684
|
+
}
|
|
685
|
+
} catch (err) {
|
|
686
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
687
|
+
res.end(JSON.stringify({ error: err.message }))
|
|
688
|
+
}
|
|
689
|
+
return
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// POST /send — operator: send BSV from bridge wallet
|
|
693
|
+
if (req.method === 'POST' && path === '/send') {
|
|
694
|
+
if (!authenticated) {
|
|
695
|
+
res.writeHead(401, { 'Content-Type': 'application/json' })
|
|
696
|
+
res.end(JSON.stringify({ error: 'Unauthorized. Provide statusSecret via ?auth= or Authorization header.' }))
|
|
697
|
+
return
|
|
698
|
+
}
|
|
699
|
+
const { runSend } = await import('./actions.js')
|
|
700
|
+
const body = await this._readBody(req)
|
|
701
|
+
if (!body.toAddress || !body.amount) {
|
|
702
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
703
|
+
res.end(JSON.stringify({ error: 'toAddress and amount required' }))
|
|
704
|
+
return
|
|
705
|
+
}
|
|
706
|
+
const { jobId, log } = this._createJob()
|
|
707
|
+
res.writeHead(202, { 'Content-Type': 'application/json' })
|
|
708
|
+
res.end(JSON.stringify({ jobId, stream: `/jobs/${jobId}` }))
|
|
709
|
+
runSend({ config: this._config, store: this._store, toAddress: body.toAddress, amount: Number(body.amount), log }).catch(err => {
|
|
710
|
+
log('error', err.message)
|
|
711
|
+
})
|
|
712
|
+
return
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// GET /jobs/:id — SSE stream for job progress
|
|
716
|
+
if (req.method === 'GET' && path.startsWith('/jobs/')) {
|
|
717
|
+
const jobId = path.slice(6)
|
|
718
|
+
const job = this._jobs.get(jobId)
|
|
719
|
+
if (!job) {
|
|
720
|
+
res.writeHead(404, { 'Content-Type': 'application/json' })
|
|
721
|
+
res.end(JSON.stringify({ error: 'Job not found' }))
|
|
722
|
+
return
|
|
723
|
+
}
|
|
724
|
+
res.writeHead(200, {
|
|
725
|
+
'Content-Type': 'text/event-stream',
|
|
726
|
+
'Cache-Control': 'no-cache',
|
|
727
|
+
Connection: 'keep-alive'
|
|
728
|
+
})
|
|
729
|
+
// Replay past events
|
|
730
|
+
for (const event of job.events) {
|
|
731
|
+
res.write(`data: ${JSON.stringify(event)}\n\n`)
|
|
732
|
+
}
|
|
733
|
+
if (job.done) {
|
|
734
|
+
res.write(`data: ${JSON.stringify({ type: 'end', status: job.status })}\n\n`)
|
|
735
|
+
res.end()
|
|
736
|
+
return
|
|
737
|
+
}
|
|
738
|
+
// Stream new events
|
|
739
|
+
const listener = (event) => {
|
|
740
|
+
res.write(`data: ${JSON.stringify(event)}\n\n`)
|
|
741
|
+
if (event.type === 'done' || event.type === 'error') {
|
|
742
|
+
res.write(`data: ${JSON.stringify({ type: 'end', status: event.type === 'error' ? 'failed' : 'completed' })}\n\n`)
|
|
743
|
+
res.end()
|
|
744
|
+
job.listeners.delete(listener)
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
job.listeners.add(listener)
|
|
748
|
+
req.on('close', () => job.listeners.delete(listener))
|
|
749
|
+
return
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// GET /logs — SSE stream of live bridge logs
|
|
753
|
+
if (req.method === 'GET' && path === '/logs') {
|
|
754
|
+
res.writeHead(200, {
|
|
755
|
+
'Content-Type': 'text/event-stream',
|
|
756
|
+
'Cache-Control': 'no-cache',
|
|
757
|
+
Connection: 'keep-alive'
|
|
758
|
+
})
|
|
759
|
+
// Replay buffer
|
|
760
|
+
for (const entry of this._logs) {
|
|
761
|
+
res.write(`data: ${JSON.stringify(entry)}\n\n`)
|
|
762
|
+
}
|
|
763
|
+
// Stream new
|
|
764
|
+
const listener = (entry) => {
|
|
765
|
+
res.write(`data: ${JSON.stringify(entry)}\n\n`)
|
|
766
|
+
}
|
|
767
|
+
this._logListeners.add(listener)
|
|
768
|
+
req.on('close', () => this._logListeners.delete(listener))
|
|
769
|
+
return
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// GET /inscriptions — query indexed inscriptions
|
|
773
|
+
if (req.method === 'GET' && path === '/inscriptions') {
|
|
774
|
+
if (!this._store) {
|
|
775
|
+
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
776
|
+
res.end(JSON.stringify({ error: 'Store not available' }))
|
|
777
|
+
return
|
|
778
|
+
}
|
|
779
|
+
const mime = url.searchParams.get('mime')
|
|
780
|
+
const address = url.searchParams.get('address')
|
|
781
|
+
const limit = Math.min(parseInt(url.searchParams.get('limit') || '50', 10) || 50, 200)
|
|
782
|
+
try {
|
|
783
|
+
const inscriptions = await this._store.getInscriptions({ mime, address, limit })
|
|
784
|
+
const total = await this._store.getInscriptionCount()
|
|
785
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
786
|
+
res.end(JSON.stringify({ total, count: inscriptions.length, inscriptions, filters: { mime: mime || null, address: address || null } }))
|
|
787
|
+
} catch (err) {
|
|
788
|
+
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
789
|
+
res.end(JSON.stringify({ error: err.message }))
|
|
790
|
+
}
|
|
791
|
+
return
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// GET /address/:addr/history — transaction history for an address (via WoC)
|
|
795
|
+
const addrMatch = path.match(/^\/address\/([13][a-km-zA-HJ-NP-Z1-9]{24,33})\/history$/)
|
|
796
|
+
if (req.method === 'GET' && addrMatch) {
|
|
797
|
+
const addr = addrMatch[1]
|
|
798
|
+
const cached = this._addressCache.get(addr)
|
|
799
|
+
if (cached && Date.now() - cached.time < 60000) {
|
|
800
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
801
|
+
res.end(JSON.stringify({ address: addr, history: cached.data, cached: true }))
|
|
802
|
+
return
|
|
803
|
+
}
|
|
804
|
+
try {
|
|
805
|
+
const resp = await fetch('https://api.whatsonchain.com/v1/bsv/main/address/' + addr + '/history', { signal: AbortSignal.timeout(10000) })
|
|
806
|
+
if (!resp.ok) throw new Error('WoC returned ' + resp.status)
|
|
807
|
+
const history = await resp.json()
|
|
808
|
+
this._addressCache.set(addr, { data: history, time: Date.now() })
|
|
809
|
+
// Prune cache if it grows too large
|
|
810
|
+
if (this._addressCache.size > 100) {
|
|
811
|
+
const oldest = this._addressCache.keys().next().value
|
|
812
|
+
this._addressCache.delete(oldest)
|
|
813
|
+
}
|
|
814
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
815
|
+
res.end(JSON.stringify({ address: addr, history, cached: false }))
|
|
816
|
+
} catch (err) {
|
|
817
|
+
res.writeHead(502, { 'Content-Type': 'application/json' })
|
|
818
|
+
res.end(JSON.stringify({ error: 'Failed to fetch address history: ' + err.message }))
|
|
819
|
+
}
|
|
820
|
+
return
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// GET /price — cached BSV/USD exchange rate
|
|
824
|
+
if (req.method === 'GET' && path === '/price') {
|
|
825
|
+
const now = Date.now()
|
|
826
|
+
if (!this._priceCache || now - this._priceCache.timestamp > 60000) {
|
|
827
|
+
try {
|
|
828
|
+
const resp = await fetch('https://api.whatsonchain.com/v1/bsv/main/exchangerate')
|
|
829
|
+
if (resp.ok) {
|
|
830
|
+
const data = await resp.json()
|
|
831
|
+
this._priceCache = { data, timestamp: now }
|
|
832
|
+
}
|
|
833
|
+
} catch {}
|
|
834
|
+
}
|
|
835
|
+
if (this._priceCache) {
|
|
836
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
837
|
+
res.end(JSON.stringify({
|
|
838
|
+
usd: this._priceCache.data.rate || this._priceCache.data.USD,
|
|
839
|
+
currency: 'USD',
|
|
840
|
+
source: 'whatsonchain',
|
|
841
|
+
cached: this._priceCache.timestamp,
|
|
842
|
+
ttl: 60000
|
|
843
|
+
}))
|
|
844
|
+
return
|
|
845
|
+
}
|
|
846
|
+
res.writeHead(503, { 'Content-Type': 'application/json' })
|
|
847
|
+
res.end(JSON.stringify({ error: 'Price unavailable' }))
|
|
848
|
+
return
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// GET /tokens — list all deployed tokens
|
|
852
|
+
if (req.method === 'GET' && path === '/tokens') {
|
|
853
|
+
if (!this._store) {
|
|
854
|
+
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
855
|
+
res.end(JSON.stringify({ error: 'Store not available' }))
|
|
856
|
+
return
|
|
857
|
+
}
|
|
858
|
+
const tokens = await this._store.listTokens()
|
|
859
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
860
|
+
res.end(JSON.stringify({ tokens }))
|
|
861
|
+
return
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// GET /token/:tick — token deploy info
|
|
865
|
+
const tokenMatch = path.match(/^\/token\/([^/]+)$/)
|
|
866
|
+
if (req.method === 'GET' && tokenMatch) {
|
|
867
|
+
if (!this._store) {
|
|
868
|
+
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
869
|
+
res.end(JSON.stringify({ error: 'Store not available' }))
|
|
870
|
+
return
|
|
871
|
+
}
|
|
872
|
+
const token = await this._store.getToken(decodeURIComponent(tokenMatch[1]))
|
|
873
|
+
if (!token) {
|
|
874
|
+
res.writeHead(404, { 'Content-Type': 'application/json' })
|
|
875
|
+
res.end(JSON.stringify({ error: 'Token not found' }))
|
|
876
|
+
return
|
|
877
|
+
}
|
|
878
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
879
|
+
res.end(JSON.stringify(token))
|
|
880
|
+
return
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// GET /token/:tick/balance/:scriptHash — token balance for owner
|
|
884
|
+
const balMatch = path.match(/^\/token\/([^/]+)\/balance\/([0-9a-f]{64})$/)
|
|
885
|
+
if (req.method === 'GET' && balMatch) {
|
|
886
|
+
if (!this._store) {
|
|
887
|
+
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
888
|
+
res.end(JSON.stringify({ error: 'Store not available' }))
|
|
889
|
+
return
|
|
890
|
+
}
|
|
891
|
+
const tick = decodeURIComponent(balMatch[1])
|
|
892
|
+
const ownerScriptHash = balMatch[2]
|
|
893
|
+
const balance = await this._store.getTokenBalance(tick, ownerScriptHash)
|
|
894
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
895
|
+
res.end(JSON.stringify({ tick, ownerScriptHash, balance }))
|
|
896
|
+
return
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// GET /tx/:txid/status — tx lifecycle state
|
|
900
|
+
const statusMatch = path.match(/^\/tx\/([0-9a-f]{64})\/status$/)
|
|
901
|
+
if (req.method === 'GET' && statusMatch) {
|
|
902
|
+
if (!this._store) {
|
|
903
|
+
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
904
|
+
res.end(JSON.stringify({ error: 'Store not available' }))
|
|
905
|
+
return
|
|
906
|
+
}
|
|
907
|
+
const txid = statusMatch[1]
|
|
908
|
+
const status = await this._store.getTxStatus(txid)
|
|
909
|
+
const block = await this._store.getTxBlock(txid)
|
|
910
|
+
if (!status) {
|
|
911
|
+
res.writeHead(404, { 'Content-Type': 'application/json' })
|
|
912
|
+
res.end(JSON.stringify({ error: 'Transaction not found' }))
|
|
913
|
+
return
|
|
914
|
+
}
|
|
915
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
916
|
+
res.end(JSON.stringify({ txid, ...status, block: block || undefined }))
|
|
917
|
+
return
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// GET /proof/:txid — merkle proof for confirmed tx
|
|
921
|
+
const proofMatch = path.match(/^\/proof\/([0-9a-f]{64})$/)
|
|
922
|
+
if (req.method === 'GET' && proofMatch) {
|
|
923
|
+
if (!this._store) {
|
|
924
|
+
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
925
|
+
res.end(JSON.stringify({ error: 'Store not available' }))
|
|
926
|
+
return
|
|
927
|
+
}
|
|
928
|
+
const txid = proofMatch[1]
|
|
929
|
+
const block = await this._store.getTxBlock(txid)
|
|
930
|
+
if (!block || !block.proof) {
|
|
931
|
+
res.writeHead(404, { 'Content-Type': 'application/json' })
|
|
932
|
+
res.end(JSON.stringify({ error: 'Proof not available' }))
|
|
933
|
+
return
|
|
934
|
+
}
|
|
935
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
936
|
+
res.end(JSON.stringify({ txid, blockHash: block.blockHash, height: block.height, proof: block.proof }))
|
|
937
|
+
return
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// GET /inscription/:txid/:vout/content — serve raw inscription content
|
|
941
|
+
const inscMatch = path.match(/^\/inscription\/([0-9a-f]{64})\/(\d+)\/content$/)
|
|
942
|
+
if (req.method === 'GET' && inscMatch) {
|
|
943
|
+
if (!this._store) {
|
|
944
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' })
|
|
945
|
+
res.end('Store not available')
|
|
946
|
+
return
|
|
947
|
+
}
|
|
948
|
+
try {
|
|
949
|
+
const record = await this._store.getInscription(inscMatch[1], parseInt(inscMatch[2], 10))
|
|
950
|
+
if (!record) {
|
|
951
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' })
|
|
952
|
+
res.end('Not found')
|
|
953
|
+
return
|
|
954
|
+
}
|
|
955
|
+
// Resolve content: inline hex first, then CAS fallback
|
|
956
|
+
let buf = record.content ? Buffer.from(record.content, 'hex') : null
|
|
957
|
+
if (!buf && record.contentHash) {
|
|
958
|
+
buf = await this._store.getContentBytes(record.contentHash)
|
|
959
|
+
}
|
|
960
|
+
if (!buf) {
|
|
961
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' })
|
|
962
|
+
res.end('Content not available')
|
|
963
|
+
return
|
|
964
|
+
}
|
|
965
|
+
res.writeHead(200, {
|
|
966
|
+
'Content-Type': record.contentType || 'application/octet-stream',
|
|
967
|
+
'Content-Length': buf.length,
|
|
968
|
+
'Cache-Control': 'public, max-age=31536000, immutable'
|
|
969
|
+
})
|
|
970
|
+
res.end(buf)
|
|
971
|
+
} catch (err) {
|
|
972
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' })
|
|
973
|
+
res.end(err.message)
|
|
974
|
+
}
|
|
975
|
+
return
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// POST /scan-address — scan an address for inscriptions via WhatsOnChain
|
|
979
|
+
if (req.method === 'POST' && path === '/scan-address') {
|
|
980
|
+
if (!this._store) {
|
|
981
|
+
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
982
|
+
res.end(JSON.stringify({ error: 'Store not available' }))
|
|
983
|
+
return
|
|
984
|
+
}
|
|
985
|
+
let body = ''
|
|
986
|
+
req.on('data', chunk => { body += chunk })
|
|
987
|
+
req.on('end', async () => {
|
|
988
|
+
try {
|
|
989
|
+
const { address } = JSON.parse(body)
|
|
990
|
+
if (!address || typeof address !== 'string' || address.length < 25 || address.length > 35) {
|
|
991
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
992
|
+
res.end(JSON.stringify({ error: 'Invalid address' }))
|
|
993
|
+
return
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// Stream progress via SSE
|
|
997
|
+
res.writeHead(200, {
|
|
998
|
+
'Content-Type': 'text/event-stream',
|
|
999
|
+
'Cache-Control': 'no-cache',
|
|
1000
|
+
'Connection': 'keep-alive',
|
|
1001
|
+
'Access-Control-Allow-Origin': '*'
|
|
1002
|
+
})
|
|
1003
|
+
|
|
1004
|
+
const result = await scanAddress(address, this._store, (progress) => {
|
|
1005
|
+
res.write('data: ' + JSON.stringify(progress) + '\n\n')
|
|
1006
|
+
})
|
|
1007
|
+
|
|
1008
|
+
res.write('data: ' + JSON.stringify({ phase: 'complete', result }) + '\n\n')
|
|
1009
|
+
res.end()
|
|
1010
|
+
} catch (err) {
|
|
1011
|
+
if (!res.headersSent) {
|
|
1012
|
+
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
1013
|
+
res.end(JSON.stringify({ error: err.message }))
|
|
1014
|
+
} else {
|
|
1015
|
+
res.write('data: ' + JSON.stringify({ phase: 'error', error: err.message }) + '\n\n')
|
|
1016
|
+
res.end()
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
})
|
|
1020
|
+
return
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// POST /rebuild-inscription-index — deduplicate and rebuild secondary indexes
|
|
1024
|
+
if (req.method === 'POST' && path === '/rebuild-inscription-index') {
|
|
1025
|
+
if (!this._store) {
|
|
1026
|
+
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
1027
|
+
res.end(JSON.stringify({ error: 'Store not available' }))
|
|
1028
|
+
return
|
|
1029
|
+
}
|
|
1030
|
+
try {
|
|
1031
|
+
const count = await this._store.rebuildInscriptionIndex()
|
|
1032
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
1033
|
+
res.end(JSON.stringify({ rebuilt: count }))
|
|
1034
|
+
} catch (err) {
|
|
1035
|
+
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
1036
|
+
res.end(JSON.stringify({ error: err.message }))
|
|
1037
|
+
}
|
|
1038
|
+
return
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// GET /apps — app health, SSL, and usage data
|
|
1042
|
+
if (req.method === 'GET' && path === '/apps') {
|
|
1043
|
+
const apps = []
|
|
1044
|
+
if (this._config.apps) {
|
|
1045
|
+
for (const app of this._config.apps) {
|
|
1046
|
+
const entry = this._appChecks.get(app.url) || { checks: [], lastError: null }
|
|
1047
|
+
const checks = entry.checks
|
|
1048
|
+
const checksUp = checks.filter(c => c.up).length
|
|
1049
|
+
const latest = checks.length > 0 ? checks[checks.length - 1] : null
|
|
1050
|
+
|
|
1051
|
+
let ssl = null
|
|
1052
|
+
try {
|
|
1053
|
+
const hostname = new URL(app.url).hostname
|
|
1054
|
+
const cached = this._appSSLCache.get(hostname)
|
|
1055
|
+
if (cached && cached.data && Date.now() - cached.checkedAt < 3600000) {
|
|
1056
|
+
ssl = cached.data
|
|
1057
|
+
} else {
|
|
1058
|
+
ssl = await this._checkSSL(hostname)
|
|
1059
|
+
this._appSSLCache.set(hostname, { data: ssl, checkedAt: Date.now() })
|
|
1060
|
+
}
|
|
1061
|
+
} catch {}
|
|
1062
|
+
|
|
1063
|
+
const usage = this._requestTracker.get(app.bridgeDomain) || { total: 0, endpoints: {}, lastSeen: null }
|
|
1064
|
+
|
|
1065
|
+
apps.push({
|
|
1066
|
+
name: app.name,
|
|
1067
|
+
url: app.url,
|
|
1068
|
+
bridgeDomain: app.bridgeDomain,
|
|
1069
|
+
health: {
|
|
1070
|
+
status: latest ? (latest.up ? 'online' : 'offline') : 'unknown',
|
|
1071
|
+
statusCode: latest ? latest.statusCode : 0,
|
|
1072
|
+
responseTimeMs: latest ? latest.responseTimeMs : 0,
|
|
1073
|
+
lastCheck: latest ? latest.timestamp : null,
|
|
1074
|
+
lastError: entry.lastError,
|
|
1075
|
+
uptimePercent: checks.length > 0 ? Math.round((checksUp / checks.length) * 1000) / 10 : 0,
|
|
1076
|
+
checksTotal: checks.length,
|
|
1077
|
+
checksUp
|
|
1078
|
+
},
|
|
1079
|
+
ssl,
|
|
1080
|
+
usage: {
|
|
1081
|
+
totalRequests: usage.total,
|
|
1082
|
+
endpoints: { ...usage.endpoints },
|
|
1083
|
+
lastSeen: usage.lastSeen
|
|
1084
|
+
}
|
|
1085
|
+
})
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
1089
|
+
res.end(JSON.stringify({ apps }))
|
|
1090
|
+
return
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
res.writeHead(404)
|
|
1094
|
+
res.end('Not Found')
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* Stop the HTTP server.
|
|
1099
|
+
* @returns {Promise<void>}
|
|
1100
|
+
*/
|
|
1101
|
+
stop () {
|
|
1102
|
+
this.stopAppMonitoring()
|
|
1103
|
+
return new Promise((resolve) => {
|
|
1104
|
+
if (this._server) {
|
|
1105
|
+
this._server.close(() => resolve())
|
|
1106
|
+
this._server = null
|
|
1107
|
+
} else {
|
|
1108
|
+
resolve()
|
|
1109
|
+
}
|
|
1110
|
+
})
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* Get the port this server is configured to use.
|
|
1115
|
+
* @returns {number}
|
|
1116
|
+
*/
|
|
1117
|
+
get port () {
|
|
1118
|
+
return this._port
|
|
1119
|
+
}
|
|
1120
|
+
}
|