Repository navigation
Expand file tree
/
Copy pathserver.js
More file actions
1844 lines (1809 loc) · 148 KB
/
Copy pathserver.js
File metadata and controls
1844 lines (1809 loc) · 148 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
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
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
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
392
393
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
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
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
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
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
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
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
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
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
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
801
802
803
804
805
806
807
808
809
810
811
812
813
814
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
861
862
863
864
865
866
867
868
869
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
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Pay-per-call HTTP API paid in Nano. No accounts, no keys.
// Flow: call an endpoint -> 402 with price and address -> send Nano -> retry with
// header X-Nano-Payment: <send block hash>. Overpayment stays as credit on that hash.
// Also speaks x402 v2 (scheme "exact", network "nano:mainnet"): the 402 carries a
// PAYMENT-REQUIRED header, the client retries with PAYMENT-SIGNATURE carrying a signed
// send block, and this process verifies and broadcasts it itself (see x402.js).
'use strict';
const zlib = require('node:zlib');
const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const dns = require('dns').promises;
const net = require('net');
const { DatabaseSync } = require('node:sqlite');
const site = require('./site');
const cohorts = require('./cohorts');
const x402 = require('./x402');
const facilitator = require('./facilitator');
const { withHashLock } = require('./hashlock'); // per-hash serialisation shared with the facilitator
const { writeJsonAtomic, readJsonStore } = require('./creditstore'); // durable money files (see creditstore.js)
const nanocurrency = require('nanocurrency');
const { Helper } = require('@x402nano/helper');
const { ExactNanoScheme: X402Reference } = require('@x402nano/exact/facilitator');
const PORT = Number(process.env.PORT || 3000);
const RPC = process.env.NANO_RPC || 'http://127.0.0.1:7076';
const ADDRESS = 'nano_1xug1q5t7nxoj3ywwzokiea9jz8fq8qfgzp8pbyfr3co3e5xgj755uofu8ue';
const PRICE_RAW = 10n ** 27n; // 0.001 NANO per call
const MAX_CREDIT_RAW = 10n ** 30n; // ignore sends above 1 NANO (tranches are not credit)
const NOT_BEFORE = Number(process.env.NOT_BEFORE || 1757210000); // unix time service went live
const DATA = path.join(__dirname, 'data', 'credits.json');
// Source account of every credited hash (data/credit-accounts.json). The purpose registry excludes whole
// accounts (own, funding, donors); a credit stored before its account was listed has to be revoked when the
// ten-minute reload learns the exclusion, and that needs the account (Ops Control HQ, 2026-09-28, from e1001b3).
const CREDIT_ACCOUNTS = path.join(__dirname, 'data', 'credit-accounts.json');
let creditAccounts = {};
{ const r = readJsonStore(CREDIT_ACCOUNTS); if (r.damaged) console.error('credit-accounts: ' + CREDIT_ACCOUNTS + ' is damaged (' + r.error + ')' + (r.recovered ? '; recovered the last complete copy from .bak' : '; no complete copy to fall back on')); if (r.value && typeof r.value === 'object') creditAccounts = r.value; }
const saveCreditAccounts = () => { try { writeJsonAtomic(CREDIT_ACCOUNTS, creditAccounts); } catch {} };
// Hashes credited at the 1 NANO cap, with what the block carried (data/credit-capped.json): hash -> { amount_raw, excess_raw }.
// README: "A send past the cap is credited at 1 NANO and the reply names the excess". The excess used to be named only in
// the reply of the presentation that first credited the hash, and only when that was GET /v1/credit: presented first as
// X-Nano-Payment on a paid route (the documented flow) the reply said nothing, and every later /v1/credit read took the
// stored-credit branch, which never carried it. Kept so every reply about the hash can name it.
const CREDIT_CAPPED = path.join(__dirname, 'data', 'credit-capped.json');
let creditCapped = {};
{ const r = readJsonStore(CREDIT_CAPPED); if (r.value && typeof r.value === 'object' && !Array.isArray(r.value)) creditCapped = r.value; }
const saveCreditCapped = () => { try { writeJsonAtomic(CREDIT_CAPPED, creditCapped); } catch {} };
const cappedOf = hash => { const c = creditCapped[hash]; return c ? { capped: true, amount_raw: c.amount_raw, excess_raw: c.excess_raw } : {}; };
const X402_LOG = path.join(__dirname, 'data', 'x402.json'); // settled x402 blocks: hash, payer, amount, resource, at
// Work sources, tried in order: WORK_URLS (comma-separated RPC-style work_generate endpoints,
// e.g. a keyed hosted GPU work server), then the local node. Clients may omit block.work on
// the x402 path (extra.work = 'optional'); we compute it here before broadcasting.
const WORK_URLS = (process.env.WORK_URLS || '').split(',').map(s => s.trim()).filter(Boolean);
// PAID_WORK_URLS: a GPU reached over a private tunnel, tried first. Paid requests (x402 settles
// and paid /v1/work) always get it. Free requests get it too, within a shared budget of
// FREE_GPU_PER_MIN proofs a minute across all callers (default 30), so a first payment through
// here takes about a second instead of the 15 to 60 s the CPU path costs. Callers whose account
// has paid this server before (x402 settle, X-Nano-Payment credit, or a settle through the
// facilitator) skip the shared budget: they have already shown they are a payer, not a flood.
// Over budget, free work falls back to WORK_URLS then the node and the reply says which source.
const PAID_WORK_URLS = (process.env.PAID_WORK_URLS || '').split(',').map(s => s.trim()).filter(Boolean);
// A URL fragment names the source (http://127.0.0.1:3007#cpu-simd); otherwise a loopback host is
// the GPU tunnel and anything else is shown by host. Loopback CPU sources get a longer timeout.
const workName = u => { const url = new URL(u); if (url.hash) return url.hash.slice(1); return /^(127\.|localhost)/.test(url.host) ? 'gpu' : url.host; };
const workLocal = u => /^(127\.|localhost)/.test(new URL(u).host);
const FREE_GPU_PER_MIN = Number(process.env.FREE_GPU_PER_MIN || 30);
const workSources = () => ({ paid: [...PAID_WORK_URLS.map(workName), ...WORK_URLS.map(workName), 'node'],
free: [...PAID_WORK_URLS.map(u => workName(u) + ' (' + FREE_GPU_PER_MIN + '/min shared; prior payers skip the shared GPU budget but stay under the free per-IP cap of ' + FREE_WORK_PER_MIN + '/min)'), ...WORK_URLS.map(workName), 'node'] });
const freeGpu = { tokens: FREE_GPU_PER_MIN, at: Date.now() };
function takeFreeGpu() { // token bucket: FREE_GPU_PER_MIN a minute, burst the same
const now = Date.now();
freeGpu.tokens = Math.min(FREE_GPU_PER_MIN, freeGpu.tokens + (now - freeGpu.at) / 60_000 * FREE_GPU_PER_MIN); freeGpu.at = now;
if (freeGpu.tokens < 1) return false;
freeGpu.tokens -= 1; return true;
}
let gpuDownUntil = 0; // circuit breaker: after a network failure the GPU is skipped for 60 s (the home machine may be off)
const X402_REQ = x402.requirements({ payTo: ADDRESS, amountRaw: PRICE_RAW, maxTimeoutSeconds: 60, workOptional: true });
const x402Reference = new X402Reference(new Helper({ NANO_RPC_URL: RPC })); // reference verify() runs in addition to ours
const settling = new Set(); // block hashes with a "process" call in flight
// Sends that reached this address through a fee-taking checkout wallet paid for something else.
// Subnano post purchases and tips arrive this way: the buyer pays a one-time wallet, which forwards
// our share to us and the platform fee to Subnano's collector. Such a hash is never API credit,
// whoever presents it (the chain is public, so anyone could). Marked from the ledger at startup
// and every ten minutes; wallets with an incomplete history are re-checked next round.
const FEE_COLLECTORS = new Set(['nano_1gip4yjax4jzyqfpa3f3pzt1fefbbh4w1wt67wgw75wij34qy7yyio9jeuj4']); // Subnano (7.5% sales, 5% tips)
const NO_CREDIT_REASON = 'this send came through a marketplace checkout wallet (a Subnano post purchase or tip); it paid for that, not for API calls';
const noCredit = new Map(); // send hash -> reason
// Sends this box knows paid for something else (ladder stakes, own moves, tranches, donations): api/purposes.js.
// Any hash listed there is refused and any credit already on it is zeroed; any send FROM a listed account likewise.
const purposes = require('./purposes');
let purposeReg = { hashes: new Map(), accounts: new Map() };
// purposeRegOk: the registry in memory was built from every source, read and parsed. Until then no bearer
// hash is credited (creditFor answers "retry shortly"): an unreadable or malformed source used to load as
// "absent", so a stake it listed would have been accepted until a later successful reload (Ops Control HQ,
// 2026-09-28, later-fix on 6a03a21). A failed reload after a good one keeps the last complete registry and
// says so in the log; the next round retries. x402 payments are fresh signed blocks and never consult this.
let purposeRegOk = false;
let reloading = null; // one reload at a time; startup awaits the first (Ops Control HQ, 2026-09-28: the async reload raced the first requests)
function loadPurposes() { return reloading || (reloading = loadPurposesNow().finally(() => { reloading = null; })); }
async function loadPurposesNow() {
try { purposeReg = purposes.load(); purposeRegOk = true; }
catch (e) { console.error('purposes: registry NOT reloaded (' + e.message + '); ' + (purposeRegOk ? 'keeping the last complete one' : 'bearer credit stays refused until a load succeeds')); if (!purposeRegOk) setTimeout(loadPurposes, 30_000).unref(); return; }
for (const [h, reason] of purposeReg.hashes) {
noCredit.set(h, reason);
if (h in credits && credits[h] !== '0') { credits[h] = '0'; save(); }
}
// A credit whose source account is now excluded is revoked as well, not only one whose hash is listed
// (the first version zeroed by hash only, so an account exclusion held from the next presentation on, never
// retroactively; Ops Control HQ, 2026-09-28). The account is looked up once per hash and remembered.
// The lookups are best effort and bounded: the first failure ends them for this round (one RPC timeout at
// most, not one per credit), and a credit whose account is still unknown is looked up, or refused, when it
// is presented (creditForUnlocked), so the barrier cannot hang startup on a stalled node (Ops Control HQ, 2026-09-28).
let nodeDown = false;
for (const [h, v] of Object.entries(credits)) {
if (v === '0' || noCredit.has(h)) continue;
let acct = creditAccounts[h];
// The first failed lookup ends the round's lookups, whether rpc() threw or the node answered {"error"} / no
// block_account (a degraded node or proxy answers JSON errors without throwing; Enrico, 2026-09-28).
if (!acct && !nodeDown) { try { const b = await rpc({ action: 'block_info', json_block: 'true', hash: h }); acct = b && !b.error && b.block_account; if (!acct) throw new Error(b && b.error || 'no block_account'); } catch (e) { nodeDown = true; console.error('purposes: node unavailable (' + e.message + '), remaining account lookups left to presentation time'); } if (acct) { creditAccounts[h] = acct; saveCreditAccounts(); } }
const reason = acct && purposeReg.accounts.get(acct);
if (reason) { noCredit.set(h, reason); credits[h] = '0'; save(); }
}
}
const feeSend = new Map(); // send hash -> true (a fee send follows it: a checkout wallet's share) | false (positively not); null answers are not cached
// Pure. rows = the payer's account_history from `head: <the send>, reverse: true, count: 2`, oldest first: the send that paid
// us, then the block after it if there is one. A marketplace checkout wallet sends the seller's share and then the fee in
// consecutive blocks, so the fee send is the block AFTER the share, never before it: until 2026-09-30 the fee on either side
// counted, so an ordinary wallet that bought a Subnano post and then paid this API in its next block was refused as a checkout
// send (trollhunters, 2026-09-29). Judged per send, not per account: a wallet's other sends say nothing about this one, and the
// per-account cache repeated a refusal for every later hash from the same wallet (pyfile-toolkit, 2026-09-29). Answers true
// (a fee send follows), false (another block follows, or nothing follows a send older than a minute), or null when it cannot
// tell: nothing follows yet and the send is under a minute old (the fee block followed the share by 1-3 s in every case seen),
// or the rows do not start with the send (an empty history included: not "proven real payer"; pyfile-toolkit, 2026-09-29).
function feePassthrough(rows, fees, address, hash, now = Date.now()) {
if (!Array.isArray(rows) || !rows.length) return null;
const first = rows[0];
if (!first || String(first.hash || '').toUpperCase() !== String(hash || '').toUpperCase() || first.type !== 'send' || first.account !== address) return null;
const next = rows[1];
if (next) return next.type === 'send' && fees.has(next.account);
return now / 1000 - (Number(first.local_timestamp) || 0) > 60 ? false : null;
}
async function feeRows(account, hash) {
const a = await rpc({ action: 'account_history', account, head: hash, count: '2', reverse: 'true' });
return a && !a.error && Array.isArray(a.history) ? a.history : null;
}
async function markFeePassthroughs() {
let rows;
try {
const db = new DatabaseSync(site.DB_PATH, { readOnly: true });
try { rows = db.prepare("select counterparty, meta_json from ledger where kind = 'payment_in' and counterparty like 'nano_%'").all(); } finally { db.close(); }
} catch (e) { console.error('markFeePassthroughs: ledger:', e.message); return; }
for (const r of rows) {
let src; try { src = JSON.parse(r.meta_json || '{}').source_hash; } catch { /* no source hash */ }
if (!src) continue;
const h = src.toUpperCase();
if (noCredit.has(h)) continue;
let isFee = feeSend.get(h);
if (isFee === undefined) {
let rows;
try { rows = await feeRows(r.counterparty, h); } catch { continue; } // node unavailable: next round
if (!rows) continue;
isFee = feePassthrough(rows, FEE_COLLECTORS, ADDRESS, h);
if (isFee === null) continue; // nothing follows the send yet: next round
feeSend.set(h, isFee);
}
if (!isFee) continue;
noCredit.set(h, NO_CREDIT_REASON);
if (h in credits && credits[h] !== '0') { credits[h] = '0'; save(); } // credited before the wallet's fee block was visible
}
}
// Is the send `hash` from `account` a checkout wallet's share (a fee send to a known collector is the block right after it)?
// Asked when a hash is first presented, so a receipt cannot be spent in the minutes before the ten-minute round sees it. The
// answer is per send and cached per hash (feePassthrough says why). A send under a minute old with nothing after it yet may
// still be followed by its fee block: look once more after 3 s. true: a checkout share (cached). false: positively not
// (cached). null: could not tell: node unavailable, an RPC error, the send not found on the account, or still nothing after
// a send under a minute old after the retry (not cached; the caller answers "retry in a minute"). A null must not become
// credit: it used to collapse to false, so a checkout send presented during an RPC outage became bearer credit until the
// ten-minute pass caught it (Ops Control HQ, 2026-09-28).
async function checkoutWallet(account, hash) {
hash = String(hash || '').toUpperCase();
if (feeSend.has(hash)) return feeSend.get(hash);
for (let attempt = 0; attempt < 2; attempt++) {
let rows;
try { rows = await feeRows(account, hash); } catch { return null; }
if (!rows) return null;
const r = feePassthrough(rows, FEE_COLLECTORS, ADDRESS, hash);
if (r !== null) { if (feeSend.size > 10_000) feeSend.clear(); feeSend.set(hash, r); return r; }
if (attempt) return null; // still nothing after a send under a minute old: indeterminate, not cached
await new Promise(r => setTimeout(r, 3000));
}
return null;
}
const RAW_PER_NANO = 10n ** 30n;
// A credit is a payment already on chain; this file is the only record that it has not been spent. Until 2026-10-20 it
// was rewritten in place, so a process killed mid-write left truncated JSON, the loader's `catch {}` answered {}, and every
// unspent balance silently became zero. Writes are now atomic (temp + fsync + rename, previous file kept as .bak) and a
// damaged file is reported instead of being read as "no credits" (llmrt, item 5, 2026-10-20).
const store = readJsonStore(DATA);
let credits = store.value;
if (store.damaged) console.error('credits: ' + DATA + ' is damaged (' + store.error + ')' + (store.recovered ? '; recovered the last complete copy from .bak' : '; no complete copy to fall back on'));
const save = () => writeJsonAtomic(DATA, credits);
// The paid-call counters, durable since 2026-10-23. They lived only in the process: every restart reset them to zero while
// `started` moved to the restart time, so /v1/stats answered "0 paid calls since 17:05 today" beside x402_settled 39 and
// credited_hashes 45 from the same durable files (llmrt, 2026-10-23, measured on the live service). A reader checking whether
// anything was ever sold here read zero, and the free-route counters next to them were already durable (checks.json), so the two
// halves of one answer disagreed about whether the service had been used at all. `started` is the first boot that recorded a
// number; `process_started_at` is this boot, so a reader can tell the two apart.
// Under `node --test` (NODE_TEST_CONTEXT set) the counters stay in memory unless a test names its own file: most of the suite
// boots this module with a mocked node and would otherwise rewrite the live counter file on every run. A test that is about
// durability passes STATS_FILE, and so does production.
const STATS_FILE = process.env.STATS_FILE || (process.env.NODE_TEST_CONTEXT ? null : path.join(__dirname, 'data', 'stats.json'));
let stats = { calls_paid: 0, calls_x402: 0, calls_402: 0, started: null };
if (STATS_FILE) { const r = readJsonStore(STATS_FILE); if (r.damaged) console.error('stats: ' + STATS_FILE + ' is damaged (' + r.error + ')' + (r.recovered ? '; recovered the last complete copy from .bak' : '; no complete copy to fall back on')); if (r.value && typeof r.value === 'object') stats = { ...stats, ...r.value }; }
stats.process_started_at = new Date().toISOString();
if (!stats.started) stats.started = stats.process_started_at;
function saveStats() {
if (!STATS_FILE) return;
try { writeJsonAtomic(STATS_FILE, { calls_paid: stats.calls_paid, calls_x402: stats.calls_x402, calls_402: stats.calls_402, started: stats.started }); }
catch {}
}
// A 402 challenge is cheap and frequent (thousands a day), so the write is coalesced: at most one write per two seconds while
// counters move, plus one flush at exit. A counter is not money, so losing the last second of a killed process is acceptable;
// losing the whole history to a restart was not.
let statsFlushTimer = null;
function saveStatsSoon() {
if (statsFlushTimer) return;
statsFlushTimer = setTimeout(() => { statsFlushTimer = null; saveStats(); }, 2000);
if (statsFlushTimer.unref) statsFlushTimer.unref();
}
process.on('exit', () => { if (statsFlushTimer) { clearTimeout(statsFlushTimer); statsFlushTimer = null; saveStats(); } });
// `systemctl restart` sends SIGTERM, and a process killed by a signal does not run the 'exit' listener, so a restart could
// drop the interval the coalescing timer held (pursekeeper, 2026-10-24 01:40). Both signals flush first and then exit.
for (const sig of ['SIGTERM', 'SIGINT']) process.on(sig, () => { if (statsFlushTimer) { clearTimeout(statsFlushTimer); statsFlushTimer = null; } saveStats(); process.exit(0); });
// A handed-back call is one that was charged and then not served (a target that never answered, a work_generate failure, a
// redirect that could not be followed). Its price goes back on the hash as credit, so it must stop counting as a paid call:
// until 2026-10-23 only calls_paid was rolled back, and an x402 payment that bought nothing left calls_x402 raised, so
// /v1/stats could answer calls_x402 > calls_paid (llmrt, 2026-10-23, from the source). `rail` is how the call was charged:
// 'x402' when the payment arrived as a PAYMENT-SIGNATURE, 'bearer' when it was an X-Nano-Payment hash.
function unpay(rail) { stats.calls_paid--; if (rail === 'x402' && stats.calls_x402 > 0) stats.calls_x402--; saveStatsSoon(); }
// The settled x402 blocks: the record that a block paid for a call, and the source of the known-payer list. Rewritten in
// place, a crash mid-write emptied it and the catch answered [], so every settled payment left the public record and every
// payer left the known-payer set (llmrt, item 5, 2026-10-20).
const x402Store = readJsonStore(X402_LOG);
let x402Log = Array.isArray(x402Store.value) ? x402Store.value : [];
if (x402Store.damaged) console.error('x402 log: ' + X402_LOG + ' is damaged (' + x402Store.error + ')' + (x402Store.recovered ? '; recovered the last complete copy from .bak' : '; no complete copy to fall back on'));
const saveX402Log = () => writeJsonAtomic(X402_LOG, x402Log);
// Every node call is bounded: a node that accepts the connection and never answers would otherwise hold
// whatever awaited it forever, and since 6a03a21 the startup barrier awaits a block_info per unrecorded
// credit (Ops Control HQ, 2026-09-28). A local node answers these in milliseconds; 15 s is generous.
const RPC_TIMEOUT_MS = Number(process.env.RPC_TIMEOUT_MS || 15_000);
async function rpc(body) {
const r = await fetch(RPC, { method: 'POST', body: JSON.stringify(body), signal: AbortSignal.timeout(RPC_TIMEOUT_MS) });
if (!r.ok) throw new Error('rpc HTTP ' + r.status); // a proxy's 429/5xx page is a transport failure, not an answer (Enrico, 2026-09-28)
return r.json();
}
// work_generate for `hash` at the send threshold via the first source that answers.
// Returns { work, source, ms } or throws with the last error.
const workStats = { generated: 0, by_source: {}, by_tier: {}, last_error: '' };
// tier: 'paid' (GPU, no per-minute limit; at most four proofs at once), 'payer' (free call from an account that has paid before: GPU, no
// shared budget), 'free' (GPU while the shared budget lasts), 'free-slow' (budget spent: hosted key, then node).
async function workForOne(hash, { timeoutMs = 30_000, paid = false, knownPayer = false, threshold = x402.WORK_THRESHOLD } = {}) {
let tier = paid ? 'paid' : knownPayer ? 'payer' : takeFreeGpu() ? 'free' : 'free-slow';
const gpuOk = tier !== 'free-slow' && Date.now() >= gpuDownUntil;
const sources = [...(gpuOk ? PAID_WORK_URLS : []).map(u => ({ name: workName(u), url: u, timeoutMs: paid ? 15_000 : 10_000, gpu: true })),
...WORK_URLS.map(u => ({ name: workName(u), url: u, timeoutMs: workLocal(u) ? Math.max(timeoutMs, 90_000) : timeoutMs })), { name: 'node', url: RPC, timeoutMs: 180_000 }];
let lastErr = 'no work source';
for (const src of sources) {
const t0 = Date.now();
try {
const r = await fetch(src.url, { method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: 'work_generate', hash, difficulty: x402.WORK_THRESHOLD }),
signal: AbortSignal.timeout(src.timeoutMs) });
const j = await r.json();
if (j && j.work) {
// Every proof is checked against the hash and the threshold before it is passed on: a source answering with work for
// another hash, or below threshold, used to be served as if valid, and the block built on it was refused by the node
// after the call was paid for (uknwplayer, 2026-09-29). Such an answer falls through to the next source; when every
// source fails the caller hands the call back (502, price restored on the hash).
const w = String(j.work).toLowerCase();
let valid = false;
try { valid = nanocurrency.validateWork({ blockHash: hash, work: w, threshold }); } catch { valid = false; }
if (valid) {
workStats.generated++; workStats.by_source[src.name] = (workStats.by_source[src.name] || 0) + 1;
workStats.by_tier[tier] = (workStats.by_tier[tier] || 0) + 1;
return { work: w, source: src.name, ms: Date.now() - t0, tier };
}
lastErr = src.name + ': returned work that is not valid for this hash at ' + threshold;
workStats.invalid = (workStats.invalid || 0) + 1;
} else lastErr = src.name + ': ' + (j && (j.error || j.message) || 'no work in response');
} catch (e) {
lastErr = src.name + ': ' + e.message;
if (src.gpu) { gpuDownUntil = Date.now() + 60_000; workStats.gpu_down_until = new Date(gpuDownUntil).toISOString(); }
}
workStats.last_error = lastErr;
}
throw new Error(lastErr);
}
// One proof per hash at a time, kept for a minute. Several distinct blocks on the same frontier (differing in
// representative, say) presented at once used to cost one proof each: the x402 lock is per block hash, and verify
// computes work over block.previous for every block that passes its cheaper checks, so a payer with one funded
// account could buy K proofs for one payment by presenting K signed variants of the same send together. Now the
// first computes it and the rest share it: at most one proof per frontier, which is what one block on that frontier
// is worth, and a payer who moves their own frontier to make the next one spends a proof of their own each time
// (kilkelly's question on kilkelly/x402-nano-exact-scheme-proposal#1, 2026-10-16). Work over a frontier is usable
// only by that account's owner, so sharing it leaks nothing. A failed attempt is dropped at once so a retry asks again.
const workPending = new Map(); // hash -> Promise<{ work, source, ms, tier }>
const WORK_KEEP_MS = 60_000;
// Paid proofs in flight are capped, as /v1/work's own slot count already was. Distinct accounts are distinct frontiers,
// so a payer with many funded accounts buys one proof per account; each is paid at the route price, so the flood is
// not a loss, but without a cap it queued every other workless payer behind it (kilkelly's Sybil question on the same
// thread, 2026-10-16). Past the cap a workless block is refused before anything is broadcast, with the 402 naming the
// alternative: attach work computed over block.previous and re-present the same signed block, since work sits outside
// the signature. Blocks that arrive with work never touch this path.
const PAID_WORK_MAX = Number(process.env.PAID_WORK_MAX || 4);
let paidWorkBusy = 0;
async function workFor(hash, opts = {}) {
const key = String(hash).toUpperCase();
const pending = workPending.get(key);
if (pending) { const r = await pending; return { ...r, shared: true }; }
if (opts.paid && paidWorkBusy >= PAID_WORK_MAX)
{
// Code first, prose after: the scheme thread (kilkelly, scheme-proposal#1, 2026-10-17) asked where a defined refusal
// code would live; on the wire it is the leading token of the 402's `error`, so a client can match it without parsing.
const e = new Error(PAID_WORK_MAX + ' proofs are being generated already; attach work computed over block.previous at ' + x402.WORK_THRESHOLD + ' and re-present the same signed block (work is outside the signature), or retry in a few seconds');
e.code = 'work_unavailable';
throw e;
}
if (opts.paid) paidWorkBusy++;
workStats.paid_busy = paidWorkBusy;
const p = workForOne(hash, opts);
const release = () => { if (opts.paid) paidWorkBusy--; workStats.paid_busy = paidWorkBusy; };
workPending.set(key, p);
p.then(() => { release(); const t = setTimeout(() => { if (workPending.get(key) === p) workPending.delete(key); }, WORK_KEEP_MS); if (t.unref) t.unref(); },
() => { release(); if (workPending.get(key) === p) workPending.delete(key); });
return p;
}
// Accounts that have paid this server before, by any route. Free work for their frontier goes
// to the GPU without touching the shared budget. Work over a frontier is only usable by that
// account's owner, so requesting it for someone else's frontier gains an attacker nothing.
const PAYERS = path.join(__dirname, 'data', 'payers.json');
const knownPayers = new Set();
{ const r = readJsonStore(PAYERS); if (r.damaged) console.error('payers: ' + PAYERS + ' is damaged (' + r.error + ')' + (r.recovered ? '; recovered from .bak' : '')); if (Array.isArray(r.value)) for (const a of r.value) knownPayers.add(a); }
for (const e of x402Log) if (e.payer) knownPayers.add(e.payer);
{ const r = readJsonStore(path.join(__dirname, 'data', 'facilitator.json')); for (const e of ((r.value && r.value.settled) || [])) if (e.payer) knownPayers.add(e.payer); }
function rememberPayer(account) {
if (!account || knownPayers.has(account)) return;
knownPayers.add(account);
try { writeJsonAtomic(PAYERS, [...knownPayers]); } catch {}
}
// Is `hash` the frontier of an account that has paid before? One local block_info; false for
// anything that is not a block on this node (e.g. a public key for an open block).
async function isKnownPayerFrontier(hash) {
if (knownPayers.size === 0) return false;
try { const b = await rpc({ action: 'block_info', json_block: 'true', hash }); return !!(b && b.block_account && knownPayers.has(b.block_account)); }
catch { return false; }
}
// Look up a send block hash and turn it into credit (once).
// Every first-credit initialisation for a hash runs under that hash's lock, whoever asks. The 12:25 UTC fix locked only
// charge(), and GET /v1/credit still called the unlocked body: a status read racing a first paid call could finish after
// the debit and write the full block amount back, restoring spent credit (Ops Control HQ, 2026-09-27, from the source).
function facilitatorSeen(h) {
h = String(h || '').toUpperCase();
return facilitator.broadcast.has(h) || Boolean(facilitator.settledPayTo(h));
}
async function creditFor(hash) {
if (!/^[0-9A-F]{64}$/i.test(hash)) return { error: 'bad hash' };
return withHashLock(hash.toUpperCase(), () => creditForUnlocked(hash));
}
async function creditForUnlocked(hash) {
if (!/^[0-9A-F]{64}$/i.test(hash)) return { error: 'bad hash' };
hash = hash.toUpperCase();
if (noCredit.has(hash)) return { error: noCredit.get(hash) };
// A block this server broadcast for an x402 payment and answered 402 for is its payer's, not bearer credit: it is served
// only to the re-presentation carrying the X-Nano-Represent token from that 402 (see REPRESENT; pyfile-toolkit, 2026-09-29).
if (represent[hash]) return { error: 'this block was broadcast through this server for an x402 payment and is waiting for its payer to re-present the same PAYMENT-SIGNATURE with the X-Nano-Represent token from that 402; it is not spendable as X-Nano-Payment credit' };
if (facilitatorSeen(hash)) {
if (hash in credits && credits[hash] !== '0') { credits[hash] = '0'; save(); }
return { error: 'this block was broadcast or settled through the x402 facilitator and is not spendable as X-Nano-Payment credit' };
}
if (!purposeRegOk) return { error: 'the list of sends that are not API credit could not be loaded; bearer credit is refused until it is, retry shortly (x402 payments are unaffected)' };
if (hash in credits) {
const hashReason = purposeReg.hashes.get(hash);
if (hashReason) { noCredit.set(hash, hashReason); if (credits[hash] !== '0') { credits[hash] = '0'; save(); } return { error: hashReason }; }
if (!creditAccounts[hash] && credits[hash] !== '0') { // stored before accounts were kept: look it up now rather than trust the reload to have finished
try { const b = await rpc({ action: 'block_info', json_block: 'true', hash }); if (b && b.block_account) { creditAccounts[hash] = b.block_account; saveCreditAccounts(); } } catch { /* node unavailable */ }
if (!creditAccounts[hash]) return { error: 'credit source not verifiable right now; retry shortly' };
}
const reason = creditAccounts[hash] && purposeReg.accounts.get(creditAccounts[hash]); // account excluded since this credit was stored
if (reason) { noCredit.set(hash, reason); if (credits[hash] !== '0') { credits[hash] = '0'; save(); } return { error: reason }; }
return { remaining: BigInt(credits[hash]), ...cappedOf(hash) };
}
// Same root cause as /v1/verify below: any node error used to read as "this send is not on the chain yet". Here the answer
// costs more than a wrong field, because this route is what a payer polls to learn whether the Nano it sent became credit: a
// payer told "not found yet" during a node outage may send again, and the second send is a second payment (llmrt, 2026-10-23).
// Only the node's own words mean absent; anything else says the credit is unknown right now.
let b;
try { b = await rpc({ action: 'block_info', json_block: 'true', hash }); }
catch (e) { return { error: 'the node could not be reached to look up this hash (' + String((e && e.message) || e) + '); the credit is unknown right now, not zero: retry, do not send again', unknown: true }; }
if (b.error) {
if (/\bblock not found\b/i.test(String(b.error))) return { error: 'block not found on this node yet; wait a second and retry' };
return { error: 'the node could not answer for this hash (' + String(b.error).slice(0, 160) + '); the credit is unknown right now, not zero: retry, do not send again', unknown: true };
}
if (b.confirmed !== 'true') return { error: 'block not confirmed yet; retry shortly' };
if (b.subtype !== 'send' || b.contents.link_as_account !== ADDRESS)
return { error: 'not a send to ' + ADDRESS };
if (Number(b.local_timestamp) < NOT_BEFORE) return { error: 'block predates this service' };
const known = purposeReg.hashes.get(hash) || purposeReg.accounts.get(b.block_account); // a stake, an own move, a tranche, a donation (JoanAbad82, api#74)
if (known) { noCredit.set(hash, known); return { error: known }; }
const checkout = await checkoutWallet(b.block_account, hash);
// null is also a send under a minute old with nothing after it yet (its fee block may still be settling), so the
// reason names both and asks for a minute, not "shortly" (PlatinumVera, 2026-09-28 22:30 UTC).
if (checkout === null) return { error: 'cannot check the payer account right now (node unavailable, or the send is under a minute old and a fee block may still follow it); nothing credited, retry in a minute' };
if (checkout) { noCredit.set(hash, NO_CREDIT_REASON); return { error: NO_CREDIT_REASON }; }
const amount = BigInt(b.amount);
// README: "Overpayment stays as credit on that hash, capped at 1 NANO per hash". The cap is a ceiling on the
// credit, not a refusal of the payment: a send above it is real money on chain, so refusing it whole left the payer
// with nothing recorded while the block sat confirmed. Credit the ceiling and name the excess, so the payer can see
// what was not applied. A tranche is excluded earlier, by the purpose registry and the fee-passthrough check, so
// this path only sees payments.
const credited = amount > MAX_CREDIT_RAW ? MAX_CREDIT_RAW : amount;
credits[hash] = credited.toString();
save();
creditAccounts[hash] = b.block_account; saveCreditAccounts();
rememberPayer(b.block_account);
if (credited < amount) {
creditCapped[hash] = { amount_raw: amount.toString(), excess_raw: (amount - credited).toString() }; saveCreditCapped();
return { remaining: credited, ...cappedOf(hash) };
}
return { remaining: credited };
}
function nano(raw) { // exact decimal NANO string for a raw amount (no float rounding)
const r = BigInt(raw); const i = r / RAW_PER_NANO; const f = (r % RAW_PER_NANO).toString().padStart(30, '0').replace(/0+$/, '');
return f ? i + '.' + f : i.toString();
}
// RFC 9110 12.5.3: a coding listed with q=0 is not acceptable and "*" stands for codings not named. The first version
// of the in-process gzip (12:25 UTC the same day) tested for the bare token, so "Accept-Encoding: gzip;q=0" still got a
// gzip body (Ops Control HQ from the source, uknwplayer against /.well-known/x402, 2026-09-27). No header means identity.
function acceptsGzip(header) {
let gzip = null, star = null;
for (const part of String(header || '').split(',')) {
const [coding, ...params] = part.split(';');
const c = coding.trim().toLowerCase();
if (!c) continue;
let q = 1;
for (const prm of params) { const m = /^\s*q\s*=\s*([0-9]*\.?[0-9]*)\s*$/i.exec(prm); if (m) q = parseFloat(m[1]) || 0; }
if (c === 'gzip' || c === 'x-gzip') gzip = q > 0;
else if (c === '*') star = q > 0;
}
return gzip !== null ? gzip : star === true;
}
function send(res, code, body, type = 'application/json') {
const data = typeof body === 'string' ? body : JSON.stringify(body, null, 1);
const headers = { 'content-type': type + '; charset=utf-8', 'access-control-allow-origin': '*',
'access-control-allow-headers': 'X-Nano-Payment, PAYMENT-SIGNATURE, X-PAYMENT, X-Nano-Represent, Content-Type',
'access-control-expose-headers': 'PAYMENT-REQUIRED, PAYMENT-RESPONSE, X-Nano-Credit-Remaining-Raw, X-Nano-Credit-Excess-Raw, X-Nano-Payment-Hash, X-Nano-Represent, X-Nano-Replay' };
// A reply served for a payment is remembered for a re-presentation of the same payment (replayIfServed).
// Handed-back errors (400 redirect refusal or 502 noAnswer/work failure after charge) restore the credit and are not
// served replies: caching >=200 non-2xx replayed the 400/502 (with its stale restored-credit header) and overwrote any
// earlier 200 replay on a multi-call bearer hash.
if (res.paidHash) {
if (code >= 200 && code < 300) {
const kept = {};
for (const k of ['payment-response', 'x-nano-credit-remaining-raw']) { const v = res.getHeader(k); if (v !== undefined) kept[k] = v; }
rememberServed(res.paidHash, res.replayKey, code, data, type, kept, res.replayBind);
}
res.paidHash = null;
}
// Declared length since 2026-09-26: a body cut by any proxy or sandbox between here and the reader is then an
// HTTP error at the client, not a 200 with unparseable JSON (a reporter saw 16 KiB bodies on their own egress).
// Compressed here, not by the proxy, since 2026-09-27: the proxy's encoder answered HEAD with Content-Length: 20
// (the length of an empty gzip stream) on every route, so a HEAD then GET pair disagreed (pyfile-toolkit). With the
// encoding done in-process the proxy passes the response through and HEAD and GET carry the same true length.
let out = data, status = code;
// Byte ranges since 2026-09-28: one reader's path (pyfile-toolkit; capture in research/fetch-stall) drops every
// flow past about 20 KB, so /log.json (about 1 MB) never arrived whole there and "Range: bytes=500000-" answered
// 200 with the start of the document. A single bytes range over the identity body now answers 206, never
// compressed, so a big document can be fetched in pieces from behind such a path. Range on gzip is not offered.
if (code === 200 && res.byteRange) {
const total = Buffer.byteLength(data);
const m = /^bytes=(\d*)-(\d*)$/.exec(String(res.byteRange).trim());
if (m && (m[1] !== '' || m[2] !== '')) {
let start, end;
if (m[1] === '') { start = Math.max(0, total - Number(m[2])); end = total - 1; }
else { start = Number(m[1]); end = m[2] === '' ? total - 1 : Math.min(Number(m[2]), total - 1); }
if (start >= total || start > end) {
headers['content-range'] = 'bytes */' + total; headers['content-length'] = 0;
res.writeHead(416, headers); return res.end();
}
out = Buffer.from(data).subarray(start, end + 1); status = 206;
headers['content-range'] = 'bytes ' + start + '-' + end + '/' + total;
}
}
if (code === 200) headers['accept-ranges'] = 'bytes';
if (status === 200 && res.acceptsGzip && Buffer.byteLength(data) >= 1024) {
out = zlib.gzipSync(data); headers['content-encoding'] = 'gzip'; headers['vary'] = 'Accept-Encoding';
}
if (code !== 204 && code !== 304) headers['content-length'] = Buffer.byteLength(out);
res.writeHead(status, headers);
res.end(out);
}
const DESCRIPTIONS = { '/v1/echo': 'returns what you sent', '/v1/fetch': 'fetches a URL and returns the page as plain text', '/v1/hash': 'sha256 of the request body, with server time' };
function hostOf(req) { return String(req.headers['x-forwarded-host'] || req.headers.host || 'pursekeeper.dev').split(',')[0].trim(); }
// x402 v2 PaymentRequired for this request: header value plus the same object for the JSON body.
function x402Required(req, hint) {
const u = new URL(req.url, 'http://x');
// Every 402 carries a fresh extra.session in its accepts entry. A v2 client copies the entry it accepted into the
// payload (`accepted`), so the value comes back with the payment over the same connection and never touches the chain:
// it is the one thing a reader of the public block cannot rebuild, and the replay cache is bound to it (replayIfServed;
// PlatinumVera, 2026-10-03, from the source). Neither stored nor checked for origin: any echoed value binds the replay
// to the client that sent it; a payload that echoes none is replayed on method, URL and body alone (pyfile-toolkit,
// 2026-10-03: extra is optional in the v2 schema, so a conforming client may have nothing to echo).
const requirements = { ...X402_REQ, extra: { ...X402_REQ.extra, session: crypto.randomBytes(16).toString('hex') } };
return x402.paymentRequired({ requirements, url: 'https://' + hostOf(req) + req.url,
description: DESCRIPTIONS[u.pathname] || 'pursekeeper.dev paid call', error: hint || 'payment required' });
}
function paymentRequired(res, hint, req, extra) {
stats.calls_402++;
saveStatsSoon();
// Count the refused side as well as the paid side: distinct callers of the payment door,
// as IP hashes only (adopted 2026-09-20 after a Guild Hall cross-reading; see checkStats).
if (req) {
try {
const ip = String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim();
const key = crypto.createHash('sha256').update(ip).digest('hex').slice(0, 12);
checks.challenges_402 = (checks.challenges_402 || 0) + 1;
checks.ips402 = checks.ips402 || {}; checks.ips402[key] = (checks.ips402[key] || 0) + 1;
checks.since_402 = checks.since_402 || new Date().toISOString();
writeJsonAtomic(CHECKS_FILE, checks);
} catch {}
}
const pr = req ? x402Required(req, hint) : null;
if (pr) res.setHeader(x402.REQUIRED_HEADER, pr.header);
send(res, 402, {
error: hint || 'payment required',
pay_to: ADDRESS,
price_nano: nano(PRICE_RAW),
price_raw: PRICE_RAW.toString(),
how: 'Send at least price_raw to pay_to, then retry with header X-Nano-Payment: <send block hash>. ' +
'Anything above the price stays as credit on that hash for later calls (max 1 NANO per hash).',
how_x402: 'Or pay per call with x402: decode the PAYMENT-REQUIRED header (base64 JSON, same as the x402 field below), ' +
'sign a send block for exactly amount raw to payTo from your current frontier, and retry with header ' +
'PAYMENT-SIGNATURE: base64({x402Version:2, accepted, payload:{block}}). See /v1/x402 and /examples/client-x402.js.',
x402: pr ? pr.body : undefined,
docs: '/',
...(extra || {})
});
}
// x402 path: verify the signed send block locally, broadcast it, then serve. The settled
// hash is written to credits.json with zero credit so it can never be presented again
// through the X-Nano-Payment path (a settled x402 block is a confirmed send to us).
// The whole charge runs under the block's hash lock, keyed before verify: two concurrent requests carrying the same
// PAYMENT-SIGNATURE both passed verify's seen() check (the alreadyLanded branch never entered `settling`, and the normal
// branch entered it only after verify returned), and since b438d56 the second one's "Old block" rejection was turned into
// a settled success by the landed() fallback, so one block paid for two calls. Serialised, the second request sees the
// first's credits write and is refused as already used. A block that does not hash (malformed) runs unlocked so verify
// reports what is wrong with it (Ops Control HQ, 2026-09-28 22:34 UTC; llmrt, 2026-09-29 00:17 UTC).
async function chargeX402(req, res, headerValue) {
const d = x402.decodePayment(headerValue);
if (d.error) return paymentRequired(res, 'x402: ' + d.error, req), false;
let key = null;
try { key = x402.blockHash(d.payload.payload.block).toUpperCase(); } catch { /* verify names the fault */ }
// The replay check (the same payment for the same call, served within ten minutes: the same reply again) runs under
// the per-hash lock, so a duplicate that arrives while the first presentation is still being served waits for it
// instead of racing it to "already used" (PlatinumVera, 2026-10-03).
const run = async () => (key && replayIfServed(req, res, key, { secret: replaySecret(d.payload) })) ? false : chargeX402Locked(req, res, d.payload, key);
return key ? withHashLock(key, run) : run();
}
// The session the client echoes from the 402 it paid (accepted.extra.session, see x402Required); null when it echoes none.
function replaySecret(payload) {
try { const v = payload.accepted && payload.accepted.extra && payload.accepted.extra.session; return typeof v === 'string' && v.length >= 16 && v.length <= 128 ? v : null; } catch { return null; }
}
// After the broadcast the call is served only once the node reports the block confirmed: block_info is polled every
// X402_CONFIRM.intervalMs up to X402_CONFIRM.boundMs (250 ms, 8 s; both injectable for tests). Until 2026-09-29 the call was
// served the moment process accepted the block, the landed() branch included, so a block the node held unconfirmed had paid
// for a served call (uknwplayer; Ops Control HQ on the landed branch, 2026-09-28). Not confirmed within the bound: nothing is
// marked spent and the 402 names the hash and asks for the same payment again; the resend is served through verify's
// alreadyLanded branch, which requires confirmation.
const X402_CONFIRM = { intervalMs: 250, boundMs: 8_000 };
// A block this server broadcast and answered 402 for (not confirmed within the bound) is public on the chain from the
// broadcast, and until 2026-09-29 16:xx UTC the hash was not marked spent until served, so anyone reading the ledger could
// wrap the block in their own PAYMENT-SIGNATURE and be served through verify's alreadyLanded branch before the payer
// re-presented (trollhunters, 2026-09-29 16:29 UTC). The 402 now carries a single-use token (x-nano-represent header,
// represent_token in the body, named in the note) that binds the re-presentation to the payer: the resend must carry
// X-Nano-Represent: <token>. The token is the only binding: until 2026-09-29 20:5x UTC a resend from the same client
// address was accepted too, and an address is not a payer (agents behind one platform egress share it; a forwarded-for
// header can be shaped by the caller) (Ops Control HQ, 17:38 UTC). The record also closes the bearer path: a hash with a
// record here is refused as X-Nano-Payment credit (creditForUnlocked), because the block is a confirmed send to this
// address and the bearer path would otherwise credit whoever read it off the chain first (pyfile-toolkit, 18:42 UTC).
// Kept on disk; a record the payer never used is closed after 30 days (the hash is marked spent, so nobody can take it
// later either, and the reason names the mail address for a refund). A landed block with no record here (broadcast
// elsewhere, or before the record existed) is served as before, since this server never handed anyone a 402 for it.
const REPRESENT = path.join(__dirname, 'data', 'represent.json');
const REPRESENT_TTL_MS = 30 * 86_400_000;
const REPRESENT_EXPIRED = 'this block was broadcast through this server and answered 402 for, and its payer did not re-present it within 30 days; it is not spendable as credit; write to agent@pursekeeper.dev with the hash';
let represent = {};
// The X-Nano-Represent tokens bind a broadcast-but-unconfirmed block to the payer who was handed the 402. A damaged file
// read as {} turns every live token into "no record": the payer's own re-presentation is then served as before, which is the
// takeover the tokens exist to prevent. Read through the store and report damage instead (llmrt, item 5, 2026-10-20).
{ const r = readJsonStore(REPRESENT); if (r.damaged) console.error('represent: ' + REPRESENT + ' is damaged (' + r.error + ')' + (r.recovered ? '; recovered the last complete copy from .bak' : '; no complete copy to fall back on')); if (r.value && typeof r.value === 'object') represent = r.value; }
function saveRepresent() {
const now = Date.now(); let spent = false;
for (const [h, r] of Object.entries(represent)) if (now - r.at > REPRESENT_TTL_MS) { delete represent[h]; credits[h] = '0'; noCredit.set(h, REPRESENT_EXPIRED); spent = true; }
if (spent) save();
try { writeJsonAtomic(REPRESENT, represent); } catch {}
}
// Hand-backs on an x402 payment. The price of a call that was charged and then not served goes back on the paying hash as
// X-Nano-Payment credit; for a bearer hash that is the payer's own credential, but for an x402 payment the hash is the block
// this server broadcast, public on the chain from the broadcast. Restored as plain bearer credit, anyone who read the block
// off the chain (GET /v1/credit answers remaining_raw for any hash, so a hand-back is visible) could spend it before the payer
// retried: the takeover the X-Nano-Represent token closes for a broadcast-but-unconfirmed block (api#81). The restored credit
// is bound the same way: the 400/502 that hands it back carries a token, and the credit is spent only with that token.
const HANDBACK = path.join(__dirname, 'data', 'handback.json');
let handback = {}; // hash -> { token, at }
{ const r = readJsonStore(HANDBACK); if (r.damaged) console.error('handback: ' + HANDBACK + ' is damaged (' + r.error + ')' + (r.recovered ? '; recovered the last complete copy from .bak' : '; no complete copy to fall back on')); if (r.value && typeof r.value === 'object') handback = r.value;
// A record whose hash holds no credit binds nothing: it is dropped at boot so the file stays the size of the open hand-backs.
// A later hand-back on the same hash needs a new charge, and a charge on an x402 hash needs credit, so nothing is reopened.
let pruned = 0; for (const h of Object.keys(handback)) if (!(BigInt((credits[h] || '0')) > 0n)) { delete handback[h]; pruned++; }
if (pruned) { try { writeJsonAtomic(HANDBACK, handback); } catch {} } }
const saveHandback = () => { try { writeJsonAtomic(HANDBACK, handback); } catch {} };
// Restore the price on hash h under its lock; for an x402 charge, bind the credit to a token. Returns { left, token }.
async function handBack(h, res) {
return withHashLock(h, async () => {
credits[h] = (BigInt(credits[h] || '0') + PRICE_RAW).toString(); save(); unpay(res.chargeRail);
let token = handback[h] ? handback[h].token : null;
if (res.chargeRail === 'x402') { if (!token) { token = crypto.randomBytes(16).toString('hex'); handback[h] = { token, at: Date.now() }; saveHandback(); } }
if (token) res.setHeader('x-nano-represent', token);
return { left: credits[h], token };
});
}
async function confirmedWithin(hash) {
const deadline = Date.now() + X402_CONFIRM.boundMs;
for (;;) {
try { const b = await rpc({ action: 'block_info', json_block: 'true', hash }); if (b && b.confirmed === 'true') return true; } catch { /* node hiccup: poll again */ }
if (Date.now() >= deadline) return false;
await new Promise(r => setTimeout(r, X402_CONFIRM.intervalMs));
}
}
async function chargeX402Locked(req, res, payload, key) {
let landedBlock = null;
const v = await x402.verify(payload, X402_REQ, {
accountInfo: account => rpc({ action: 'account_info', account, representative: 'true', include_confirmed: 'true' }),
// A hash this server already ruled is not API credit (noCredit: a checkout share, a stake, an own move, a donation, an
// expired represent record) paid for something else whoever presents it; a landed block reaches verify's alreadyLanded
// branch, so without this check anyone could wrap that public block in a PAYMENT-SIGNATURE and be served on it.
// A hash in `credits` whose credit is untouched and has no hand-back record (a confirmed 0.001 NANO send inspected via
// GET /v1/credit before PAYMENT-SIGNATURE arrived) has bought no call yet and is treated as unseen; a handed-back x402
// payment (`handback[h]`) is spendable over PAYMENT-SIGNATURE only with the matching X-Nano-Represent token.
seen: async h => {
if (h in credits) {
if (BigInt(credits[h] || '0') < PRICE_RAW) return true;
if (handback[h] || x402Log.some(e => e.hash === h)) {
const hb = handback[h];
if (!hb || String(req.headers['x-nano-represent'] || '').trim() !== hb.token) return true;
}
}
if (settling.has(h)) return true;
if (noCredit.has(h)) return noCredit.get(h);
if (facilitatorSeen(h)) return 'this block was already broadcast or settled through the x402 facilitator';
if (feeSend.get(h) === true) { noCredit.set(h, NO_CREDIT_REASON); return NO_CREDIT_REASON; }
const pr = purposeReg.hashes.get(h);
if (pr) { noCredit.set(h, pr); return pr; }
return false;
},
reference: x402Reference,
blockInfo: async h => { const b = await rpc({ action: 'block_info', json_block: 'true', hash: h }); landedBlock = b; return b; }, // amount and confirmation of a block that already landed (alreadyLanded)
workGenerate: async hash => (await workFor(hash, { paid: true })).work // a paying block earns its work
});
if (!v.ok) {
logRefusedX402(req, key, payload, v.payer, v.reason);
// A re-presentation that arrives while the broadcast block is still unconfirmed fails verify's alreadyLanded gate
// ("on the node but not confirmed yet"), and until 2026-10-03 that 402 carried neither the note nor the token, so the
// skill's loop stopped on it and the payer was left with a landed block and no token to re-present it with
// (PlatinumVera, 2026-10-03 00:29 UTC, from the source and a mock). The holder of the token gets the note and the same
// token again, so its loop goes on; anyone else gets a 402 that names the wait and not the token, since the block is
// public from the broadcast and the token is the only binding.
const rep = key && represent[key];
if (rep && /on the node but not confirmed yet/.test(v.reason)) {
const given = String(req.headers['x-nano-represent'] || '').trim();
if (given === rep.token) {
res.setHeader('x-nano-represent', rep.token);
const note = 'block ' + key + ' was broadcast but not yet confirmed (' + Math.round((Date.now() - rep.at) / 1000) + ' s since the broadcast); nothing was charged for this call; re-present the same PAYMENT-SIGNATURE with the same header X-Nano-Represent: ' + rep.token + ' (this reply carries it again as the x-nano-represent header and as represent_token) and it is served once the block is confirmed; do not sign a new block, that would pay twice';
return paymentRequired(res, 'x402: block ' + key + ' was broadcast but not yet confirmed; re-present the same PAYMENT-SIGNATURE with X-Nano-Represent', req, { note, represent_token: rep.token }), false;
}
return paymentRequired(res, 'x402: block ' + key + ' was broadcast through this server, is not confirmed yet and is waiting for its payer to re-present it with the X-Nano-Represent token from the 402 that announced the broadcast; nothing served, nothing marked spent', req), false;
}
// A used block whose reply is still in the replay cache but was refused there: say what the replay needs.
const hint = key && served.has(key) && /already used/.test(v.reason) ? '; the reply it bought is replayed within ten minutes for the same method, URL and body only, and when the payment echoed the extra.session of the 402 it paid (a v2 client copies the accepted entry, so this is automatic) only to a payload echoing the same session; a payment that echoed no session is replayed on method, URL and body alone' : '';
// A refusal that carries a defined code (error_work_unavailable, the scheme draft's name since 2026-10-20) keeps the
// code as the first token of `error`.
return paymentRequired(res, (/^error_work_unavailable\b/.test(v.reason) ? '' : 'x402: ') + v.reason + hint, req), false;
}
// In `settling` for the whole serve, the alreadyLanded branch included: the facilitator shares the set, so its /verify
// refuses the hash meanwhile (Ops Control HQ, 2026-09-28 22:34 UTC); its /settle queues on the same per-hash lock
// (hashlock.js) and runs after this serve, when block_info finds the block (2026-09-29).
settling.add(v.hash);
try {
// Only a landed block is held to the account exclusions here (parity with the bearer door, which sees landed blocks
// only): a fresh send from one of the listed accounts is a paid call like any other, and the own x402 test client
// (own-addresses.json) relies on that to test the seller end to end. PR #117 applied it to fresh blocks too; narrowed at merge.
const acctReason = v.alreadyLanded ? purposeReg.accounts.get(v.payer) : null;
if (acctReason) {
noCredit.set(v.hash, acctReason);
return paymentRequired(res, 'x402: ' + acctReason, req), false;
}
if (v.alreadyLanded && landedBlock && landedBlock.local_timestamp !== undefined && Number(landedBlock.local_timestamp) < NOT_BEFORE) {
return paymentRequired(res, 'x402: block predates this service', req), false;
}
// A landed block this server broadcast and answered 402 for is served only to its payer: the token from that 402 (see
// REPRESENT). Refused here nothing is marked spent, so the payer's own re-presentation still works.
const rep = v.alreadyLanded ? represent[v.hash] : null;
if (rep) {
const given = String(req.headers['x-nano-represent'] || '').trim();
if (given !== rep.token)
return paymentRequired(res, 'x402: block ' + v.hash + ' was broadcast through this server and is waiting for its payer to re-present it; send X-Nano-Represent with the token from that 402 (its x-nano-represent header and represent_token field); nothing served, nothing marked spent', req), false;
}
let s;
// A block that is already the payer's frontier landed on an earlier attempt whose settle reply was lost: no second
// broadcast, served as settled. A lost or failed process reply is likewise settled from the node's view of the hash
// (deps.landed), not reported as a failed payment (Ops Control HQ, 2026-09-28).
if (v.alreadyLanded) s = { success: true, network: x402.NETWORK, transaction: v.hash, payer: v.payer };
else s = await x402.settle(v.block, v.payer, { hash: v.hash, process: block => rpc({ action: 'process', json_block: 'true', subtype: 'send', block }),
landed: async h => { try { const b = await rpc({ action: 'block_info', json_block: 'true', hash: h }); if (b && b.error) return /not found/i.test(b.error) ? false : null; return b && b.contents ? true : null; } catch { return null; } } });
if (!s.success) { logRefusedX402(req, key, payload, v.payer, s.errorReason); return paymentRequired(res, 'x402: ' + s.errorReason, req), false; }
// The block was broadcast through this server (and its work computed here when work_by is 'seller'): /v1/requests says
// "absence means pursekeeper.dev was not used for that block", so the request log has to hold it. Until 2026-10-23 only
// /v1/work and /v1/process wrote it, and a block this server broadcast as an x402 payment answered found:false.
if (!v.alreadyLanded) logReq(req, { kind: 'x402', hash: String(s.transaction).toUpperCase(), previous: String(v.block && v.block.previous || '').toUpperCase() || null, account: v.payer, ok: true, work_by: v.workBy || 'client', resource: req.url.split('?')[0] });
if (!v.alreadyLanded && !await confirmedWithin(s.transaction)) {
const token = crypto.randomBytes(16).toString('hex');
represent[s.transaction] = { token, at: Date.now() }; saveRepresent();
const note = 'block ' + s.transaction + ' was broadcast but not yet confirmed within ' + X402_CONFIRM.boundMs / 1000 + ' s; nothing was charged for this call; re-present the same payment (the unchanged PAYMENT-SIGNATURE) with the header X-Nano-Represent: ' + token + ' (this reply carries it as the x-nano-represent header and as represent_token) and it is served once the block is confirmed; the token binds the re-presentation to you, because the block is public on the chain from the broadcast; do not sign a new block, that would pay twice';
res.setHeader('x-nano-represent', token);
return paymentRequired(res, 'x402: block ' + s.transaction + ' was broadcast but not yet confirmed; re-present the same PAYMENT-SIGNATURE with X-Nano-Represent', req, { note, represent_token: token }), false;
}
const rem = (s.transaction in credits ? BigInt(credits[s.transaction] || '0') : PRICE_RAW) - PRICE_RAW;
credits[s.transaction] = (rem > 0n ? rem : 0n).toString();
creditAccounts[s.transaction] = v.payer;
if (v.hash !== s.transaction) { credits[v.hash] = '0'; creditAccounts[v.hash] = v.payer; }
save(); saveCreditAccounts();
if (represent[s.transaction] || represent[v.hash]) { delete represent[s.transaction]; delete represent[v.hash]; saveRepresent(); }
const prevLog = x402Log.find(e => e.hash === s.transaction);
if (prevLog) { prevLog.resource = req.url; prevLog.at = new Date().toISOString(); }
else x402Log.push({ hash: s.transaction, payer: v.payer, amount_raw: PRICE_RAW.toString(), resource: req.url, at: new Date().toISOString(), work_by: v.workBy || 'client' });
rememberPayer(v.payer);
try { saveX402Log(); } catch {}
stats.calls_paid++; stats.calls_x402++;
saveStatsSoon();
res.setHeader(x402.RESPONSE_HEADER, x402.settleHeader(s));
res.setHeader('x-nano-payment-hash', s.transaction);
res.chargeRail = 'x402'; // read by unpay() when the call is handed back
res.paidHash = s.transaction; res.replayKey = replayKey(req); res.replayBind = { secret: replaySecret(payload) };
return true;
} finally { settling.delete(v.hash); }
}
// Rate-limited proxy to the node's work_generate, for clients without a work server.
const workHits = new Map(); // ip -> [timestamps]
let workInFlight = 0;
// Free: FREE_WORK_PER_MIN per minute per IP (default 6), GPU while the shared budget lasts.
// Paid (X-Nano-Payment credit hash or an x402 PAYMENT-SIGNATURE at the standard price): no
// per-IP limit, GPU always. A paying x402 block gets its own work computed here too, so an
// agent with Nano but no PoW can buy its first work without doing any.
const FREE_WORK_PER_MIN = Number(process.env.FREE_WORK_PER_MIN || 6);
async function workGenerate(req, res) {
const paid = !!(req.headers['x-nano-payment'] || x402.paymentHeader(req.headers));
// Body and the busy cap before the charge: a paid call used to be debited and then answered 400 or 503 with no
// hand-back, so a payer following the 503's "retry" paid again (PlatinumVera, 2026-09-28; same class as /v1/hash).
// An unpaid call with no usable body gets the 402 challenge with the body rule in its note, not a bare 400: a directory
// that checks a listed resource exactly as listed, with no body, reads a 400 as a broken payment challenge (SealKeeper's
// listing checks, 12 of 23 valid, 2026-10-28). A paid call with a bad body is still refused with 400 before the charge.
const bodyRule = 'a JSON body {"hash": H} is required, H 64 hex characters (your account frontier); with it, ' + FREE_WORK_PER_MIN + ' calls per minute per IP are free and need no payment, and beyond that, or with a payment attached, there is no per-minute limit; nothing charged';
const refuseBody = (error) => paid ? send(res, 400, { error }) : paymentRequired(res, undefined, req, { body_error: error, note: bodyRule });
let body;
try { body = JSON.parse((await readBody(req, 10_000)).toString('utf8') || '{}'); } catch { return refuseBody('body must be JSON {hash}; nothing charged'); }
const hash = String(body.hash || '').toUpperCase();
if (!/^[0-9A-F]{64}$/.test(hash)) return refuseBody('hash must be 64 hex characters (your account frontier); nothing charged');
// A busy refusal is logged too: /v1/requests is the only public record of "was this hash presented here", and
// without the line a caller whose valid hash was turned away by a busy server reads exactly like one that never
// called, the same gap a refused x402 presentation had before 2026-11-01 (ccclosed, api#138). Logged under the
// same per-IP cap as the other refusals so a stranger cannot fill the file.
if (workInFlight >= 4) {
logRefusedWork(req, hash, paid, 'work server busy: ' + workInFlight + ' calls in flight, at most 4 at a time; nothing charged');
return send(res, 503, { error: 'work server busy; retry in a few seconds; nothing charged' });
}
// The slot is taken here, synchronously, before the charge awaits: taken only after charge() returned, five
// concurrent paid calls all passed the check above and ran at once (tao wang, 2026-09-28). Every return below,
// charge failed or free limit 402 included, releases it in the finally.
workInFlight++;
try {
if (paid) {
if (!await charge(req, res)) return;
} else {
const ip = String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim();
const now = Date.now();
const hits = (workHits.get(ip) || []).filter(t => now - t < 60_000);
// Over the free limit the answer is a normal 402 with PAYMENT-REQUIRED, so an x402 client just pays.
if (hits.length >= FREE_WORK_PER_MIN) return paymentRequired(res, 'free limit is ' + FREE_WORK_PER_MIN + ' work_generate calls per minute per IP; pay ' + nano(PRICE_RAW) + ' NANO per work (X-Nano-Payment credit or x402 PAYMENT-SIGNATURE) to continue without limit', req);
hits.push(now); workHits.set(ip, hits);
if (workHits.size > 10_000) workHits.clear();
}
try {
const knownPayer = !paid && await isKnownPayerFrontier(hash);
const r = await workFor(hash, { paid, knownPayer });
if (paid) workStats.paid = (workStats.paid || 0) + 1;
logReq(req, { kind: 'work', hash, ok: true, paid, tier: r.tier, source: r.source, ms: r.ms });
return send(res, 200, { hash, work: r.work, threshold: x402.WORK_THRESHOLD, source: r.source, ms: r.ms, paid, tier: r.tier });
} catch (e) {
logReq(req, { kind: 'work', hash, ok: false, paid, error: e.message });
// No work, no price: the call is handed back as credit on the hash that paid (an x402 block's hash is
// reusable through X-Nano-Payment), under the same per-hash lock as charge(). The note carries the full
// hash: an 8-character abbreviation cannot be retried with (PlatinumVera, 2026-09-28).
let note = '';
const h = String(req.headers['x-nano-payment'] || res.getHeader('x-nano-payment-hash') || '').toUpperCase();
if (paid && h && h in credits) {
const { left, token } = await handBack(h, res);
res.setHeader('x-nano-credit-remaining-raw', left); note = '; the ' + nano(PRICE_RAW) + ' NANO for this call was handed back as credit on ' + h + ', retry with X-Nano-Payment: ' + h + (token ? ' and X-Nano-Represent: ' + token + ' (the block is public on the chain; the token binds the credit to you)' : '');
}
return send(res, 502, { error: 'work_generate: ' + e.message + note });
}
} finally { workInFlight--; }
}
// Free payment checks for sellers without a Nano node: is block H a confirmed send of at least
// N raw to address A (/v1/verify), and which confirmed sends to A are still unpocketed
// (/v1/receivable). Read-only against this node; 60 per minute per IP.
// One counter per route, because README gives each of the five free routes its own
// 60 per minute per IP (llmrt, 2026-10-22, api#103). A single shared map made the
// real allowance 60 in total across verify + receivable + account_info + process +
// requests, so a caller that polled /v1/verify could not read /v1/receivable in the
// same minute, and scripts/no-node.js receive died at the 30th pending send.
// /v1/credit is the sixth free route and carries the same allowance: it is the only one whose every miss reaches the
// node (creditForUnlocked answers a hash it has not stored with a block_info RPC), and until 2026-10-23 it had no counter
// at all, so a caller could ask it about fresh hashes without limit and every call was a node lookup (llmrt, 2026-10-23).
const checkHits = { verify: new Map(), receivable: new Map(), account_info: new Map(), process: new Map(), requests: new Map(), credit: new Map() };
// Persisted counters for the free checks, so third-party use of /v1/verify and
// /v1/receivable survives restarts and can be cited at review. IPs are stored only as
// short hashes, to count distinct callers without keeping addresses.
const CHECKS_FILE = path.join(__dirname, 'data', 'checks.json');
let checks = { verify: 0, receivable: 0, ips: {}, since: new Date().toISOString() };
// Durable since 2026-10-23 (llmrt on api#95, 2026-10-21; credited): this file holds no Nano, but it is the only record of
// third-party use of the free routes and of the 402 challenges, and it is what the reviews cite, so it is written through
// writeJsonAtomic and loaded through readJsonStore like the money files. A truncated file is named here at boot and the
// last complete copy is loaded from .bak, instead of every counter silently starting again from zero.
{ const r = readJsonStore(CHECKS_FILE); if (r.damaged) console.error('checks: ' + CHECKS_FILE + ' is damaged (' + r.error + ')' + (r.recovered ? '; recovered the last complete copy from .bak' : '; no complete copy to fall back on')); if (r.value && typeof r.value === 'object' && !Array.isArray(r.value)) checks = { ...checks, ...r.value }; }
// Request log for /v1/work and /v1/process: one JSON line per call, no raw IPs (sha256 prefix
// like checks.json). Exists so a report that says "I did not use pursekeeper.dev for work or
// broadcast" can be checked from here, and so a hosted-runtime run can point at its own line.
const REQ_LOG = path.join(__dirname, 'data', 'requests.jsonl');
function ipKey(req) {
const ip = String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim();
return crypto.createHash('sha256').update(ip).digest('hex').slice(0, 12);
}
function logReq(req, entry) {
const line = JSON.stringify({ ts: new Date().toISOString(), ip_key: ipKey(req), ...entry });
try { fs.appendFileSync(REQ_LOG, line + '\n'); } catch {}
}
// A refused x402 presentation leaves a line too: kind x402, ok false, `refused` = the reason the 402 carried. Until
// 2026-11-01 only accepted presentations were logged, so a payer whose valid signed block was refused (its frontier, an
// open block 0.3 s old, not yet confirmed) and never broadcast looked, from this log, exactly like a payer who had never
// presented anything, and was ruled so (ccclosed, api#138, 2026-10-30; the same block landed here two days later). Only
// payloads that parse to a block (a hash to key the line on) are logged, at most REFUSED_LOG_PER_MIN lines a minute per
// ip_key so a stranger cannot fill the file with refusals, and the reason is cut to 200 characters.
const REFUSED_LOG_PER_MIN = 10;
const refusedLogHits = new Map();
function logRefusedX402(req, key, payload, payer, reason) {
if (!key) return;
const k = ipKey(req), now = Date.now();
let h = refusedLogHits.get(k);
if (!h || now - h.at > 60_000) { if (refusedLogHits.size > 10_000) refusedLogHits.clear(); h = { at: now, n: 0 }; refusedLogHits.set(k, h); }
if (++h.n > REFUSED_LOG_PER_MIN) return;
let previous = null;
try { const p = payload && payload.payload && payload.payload.block && payload.payload.block.previous; if (/^[0-9a-fA-F]{64}$/.test(String(p || ''))) previous = String(p).toUpperCase(); } catch {}
logReq(req, { kind: 'x402', hash: key, previous, account: payer || null, ok: false, refused: String(reason || '').slice(0, 200), resource: req.url.split('?')[0] });
}
// A /v1/work call the busy cap turned away leaves a line too, for the reason the x402 refusal above does: the hash
// was presented here and answered 503, and without the line /v1/requests says it never was. The hash parsed before the
// cap check, so the line can be keyed on it; the same per-IP budget bounds how much of the file a stranger can fill.
function logRefusedWork(req, hash, paid, reason) {
if (!hash) return;
const k = ipKey(req), now = Date.now();
let h = refusedLogHits.get(k);
if (!h || now - h.at > 60_000) { if (refusedLogHits.size > 10_000) refusedLogHits.clear(); h = { at: now, n: 0 }; refusedLogHits.set(k, h); }
if (++h.n > REFUSED_LOG_PER_MIN) return;
logReq(req, { kind: 'work', hash, ok: false, paid: Boolean(paid), refused: String(reason || '').slice(0, 200) });
}
// Entries whose `hash` or `previous` equals H, newest first, at most `limit`. Reads the whole
// file; fine at the sizes this log sees. Returns [] if the log does not exist yet.
// At most REQUESTS_MAX entries (default 20, env override), newest first. The answer says when it hit the bound: until
// 2026-10-23 it was a silent 20, and the route's promise is that a reader can tell whether a hash was used here at all
// (llmrt, 2026-10-23: 25 /v1/process calls on one previous answered `entries: 20` with no word that five were dropped).
const REQUESTS_MAX = Number(process.env.REQUESTS_MAX || 20);
// The most one answer will list even when asked: a log line is small, but the answer is served per request and 20 000
// entries of one hash is already a 2 MB reply from a file read on the request path.
const REQUESTS_HARD_MAX = 5000;
// Paging (llmrt, 2026-10-23, api#116): limit alone cannot reach entries past REQUESTS_HARD_MAX. A hash worked 5,001
// times answered truncated:true with a next line telling the caller to pass limit=5000 "for all of them", which is the same
// newest 5,000 again: the oldest entry was unreachable, and this route exists so a reader can prove a hash was not used
// here. cursor is the number of matching entries to skip, counting from the newest (0, the default, is the newest page). The
// answer carries next_cursor when more matching entries follow, so a reader walks the whole log one page at a time without
// gaps or repeats. An index rather than a timestamp: two entries in the same millisecond would make a timestamp cursor skip
// or repeat a line.
function reqLogFor(hash, limit = REQUESTS_MAX, cursor = 0) {
let lines = [];
try { lines = fs.readFileSync(REQ_LOG, 'utf8').split('\n').filter(Boolean); } catch { return []; }
const out = [];
let seen = 0;
for (let i = lines.length - 1; i >= 0 && out.length < limit; i--) {
let e; try { e = JSON.parse(lines[i]); } catch { continue; }
if (e.hash !== hash && e.previous !== hash) continue;
if (seen++ < cursor) continue; // skip the matching entries this page already showed
out.push(e);
}
return out;
}
// How many entries the log holds for this hash in total: the same scan without the cap, so the answer can say whether
// `entries` is everything. Counted rather than collected, so a hash used thousands of times costs no array.
function reqLogCount(hash) {
let lines = [];
try { lines = fs.readFileSync(REQ_LOG, 'utf8').split('\n').filter(Boolean); } catch { return 0; }
let n = 0;
for (let i = lines.length - 1; i >= 0; i--) {
let e; try { e = JSON.parse(lines[i]); } catch { continue; }
if (e.hash === hash || e.previous === hash) n++;
}
return n;
}
function countCheck(kind, req) {
const ip = String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim();
const key = crypto.createHash('sha256').update(ip).digest('hex').slice(0, 12);
checks[kind] = (checks[kind] || 0) + 1;
checks.ips[key] = (checks.ips[key] || 0) + 1;
try { writeJsonAtomic(CHECKS_FILE, checks); } catch {}
}
function checkStats() {
// Every free route with a counter appears here. /v1/requests and /v1/credit carry the same 60/min allowance as the other
// four and were never counted, so /v1/stats reported use of four routes out of six while its README row is the only public
// record of third-party use and the reviews cite it (llmrt, 2026-10-23: 15 calls each answered 200 and 400 against the
// live service and every counter in this object stayed at zero).
return { verify: checks.verify, receivable: checks.receivable, account_info: checks.account_info || 0, process: checks.process || 0,
requests: checks.requests || 0, credit: checks.credit || 0, distinct_ips: Object.keys(checks.ips).length, since: checks.since,
challenges_402: checks.challenges_402 || 0, challenges_402_distinct_callers: Object.keys(checks.ips402 || {}).length, since_402: checks.since_402 || null };
}
function overFreeLimit(req, map, limit, res) {
const ip = String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim();
const now = Date.now();
const hits = (map.get(ip) || []).filter(t => now - t < 60_000);
if (hits.length >= limit) {
// Retry-After in whole seconds: when the oldest hit in the window ages out, one call is allowed again. A client that
// reads it (scripts/no-node.js receive since 2026-10-23) waits that long instead of a fixed guess (llmrt on api#103,
// 2026-10-22, the line that asked for the header; credited, Ӿ0).
if (res) res.setHeader('Retry-After', String(Math.max(1, Math.ceil((hits[0] + 60_000 - now) / 1000))));
return true;
}
hits.push(now); map.set(ip, hits);
if (map.size > 10_000) map.clear();
return false;
}
const MAX_RAW = 2n ** 128n - 1n; // a Nano balance is a u128; the node answers "Bad threshold number" above it
// How many receivable hashes one /v1/receivable call asks the node for. A node answers at most this many per call; the answer
// says so when it hits the bound (llmrt, 2026-10-23).
const RECEIVABLE_MAX = Number(process.env.RECEIVABLE_MAX || 100);
function minRawOf(u) {
const raw = u.searchParams.get('min_raw'), n = u.searchParams.get('min_nano');
// Above 2^128-1 the node refused the threshold and the refusal was served as 502 (pyfile-toolkit, item 5, 2026-09-30);
// since 2026-10-01 the parser refuses it with 400 on both routes, min_nano included.
if (raw != null) {
if (!/^\d{1,40}$/.test(raw)) throw new Error('min_raw must be an integer in raw');
const v = BigInt(raw); if (v > MAX_RAW) throw new Error('min_raw must be at most ' + MAX_RAW + ' raw (2^128-1)'); return v;
}
if (n != null) {
const m = /^(\d+)(?:\.(\d{1,30}))?$/.exec(n); if (!m) throw new Error('min_nano must be a decimal NANO amount');
const v = BigInt(m[1]) * RAW_PER_NANO + BigInt((m[2] || '').padEnd(30, '0'));
if (v > MAX_RAW) throw new Error('min_nano must be at most ' + nano(MAX_RAW) + ' NANO (' + MAX_RAW + ' raw, 2^128-1)'); return v;
}
return null; // neither given: /v1/verify requires one (or any=1), /v1/receivable lists everything
}
async function verifyBlock(req, res, u) {
if (overFreeLimit(req, checkHits.verify, 60, res)) return send(res, 429, { error: 'limit is 60 checks per minute per IP' });
countCheck('verify', req);
const hash = String(u.searchParams.get('hash') || '').toUpperCase();
if (!/^[0-9A-F]{64}$/.test(hash)) return send(res, 400, { error: 'hash must be 64 hex characters (a block hash)' });
// A Nano address has two spellings of the same account: nano_ and the pre-rename xrb_. checkAddress accepts both, and the
// chain reports every destination as nano_, so an xrb_ spelling used to compare unequal to the block's own link and answer
// ok:false "sent to nano_..., not to xrb_..." for the very account the block paid (llmrt, 2026-10-23). The facilitator