@toon-protocol/relay 2.0.1 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -2
- package/dist/{chunk-FXQSNOCG.js → chunk-QZQRHQEQ.js} +356 -22
- package/dist/chunk-QZQRHQEQ.js.map +1 -0
- package/dist/chunk-SMT6G3XD.js +92 -0
- package/dist/chunk-SMT6G3XD.js.map +1 -0
- package/dist/cli.js +58 -5
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +412 -7
- package/dist/index.js +25 -3
- package/dist/index.js.map +1 -1
- package/dist/verify-worker.d.ts +2 -0
- package/dist/verify-worker.js +17 -0
- package/dist/verify-worker.js.map +1 -0
- package/package.json +2 -1
- package/dist/chunk-FXQSNOCG.js.map +0 -1
package/README.md
CHANGED
|
@@ -29,8 +29,39 @@ NOSTR_SECRET_KEY=<64-char-hex> npx @toon-protocol/relay
|
|
|
29
29
|
| `TOON_MNEMONIC` | — | BIP-39 mnemonic (NIP-06 derivation) |
|
|
30
30
|
| `TOON_RELAY_PORT` | `7100` | WebSocket read port |
|
|
31
31
|
| `TOON_BLS_PORT` | `3100` | HTTP write/health port |
|
|
32
|
+
| `TOON_HOST` | `0.0.0.0` | WebSocket bind host |
|
|
33
|
+
| `TOON_WRITE_HOST` | `0.0.0.0` | HTTP write/health bind host (see [write-port exposure](#paid-ephemeral-verify-skip-relay85)) |
|
|
32
34
|
| `TOON_DATA_DIR` | `./data` | SQLite data directory |
|
|
33
35
|
| `TOON_DEV_MODE` | `false` | Skip event-signature verification on `POST /write` |
|
|
36
|
+
| `TOON_VERIFY_EPHEMERAL` | `false` | Run FULL schnorr verification on ephemeral kinds too (see below) |
|
|
37
|
+
| `TOON_VERIFY_WORKERS` | CPUs − 1 | Worker threads for persistent-kind signature verification; `0` = inline on the event loop (automatic on 1-core boxes) |
|
|
38
|
+
| `TOON_MAX_CONNECTIONS` | `4096` | Maximum concurrent WebSocket read connections (each costs one file descriptor — mind `ulimit -n`) |
|
|
39
|
+
|
|
40
|
+
## Paid-ephemeral verify skip (relay#85)
|
|
41
|
+
|
|
42
|
+
By default the relay **skips schnorr verification for ephemeral kinds**
|
|
43
|
+
(NIP-16, `20000 <= kind < 30000`) on `POST /write` and keeps only the SHA-256
|
|
44
|
+
event-id check. This is a deliberate, payment-gated bypass:
|
|
45
|
+
|
|
46
|
+
- **Why it is safe here:** every request reaching `POST /write` has already
|
|
47
|
+
passed the upstream connector's payment claim gate — payment is the
|
|
48
|
+
admission/spam gate. Protocol rule: clients trust the signature chain and
|
|
49
|
+
verify every event themselves, never the relay. Relay-side schnorr on paid
|
|
50
|
+
ephemeral frames buys no additional trust; forging a speaker costs real
|
|
51
|
+
money to emit frames every client discards.
|
|
52
|
+
- **What is still checked:** the SHA-256 id check always runs, so the relay
|
|
53
|
+
never broadcasts bytes that disagree with the event id clients verify by.
|
|
54
|
+
- **When you MUST turn it off:** if your write port is fronted by anything
|
|
55
|
+
other than a payment-gating connector — or you ever add a FREE ephemeral
|
|
56
|
+
write lane — set `TOON_VERIFY_EPHEMERAL=true` (`--verify-ephemeral`,
|
|
57
|
+
`verifyEphemeral: true`). A free lane must NOT reuse this skip.
|
|
58
|
+
- **Exposure guard:** the write port must be reachable only via the
|
|
59
|
+
connector. In docker, never host-publish it (`expose:`, not `ports:` —
|
|
60
|
+
docker-published ports bypass ufw). Outside docker, bind it internally via
|
|
61
|
+
`TOON_WRITE_HOST=127.0.0.1`. At startup the relay logs a prominent warning
|
|
62
|
+
if the write listener binds a non-internal interface while the skip is
|
|
63
|
+
active (warning only — container topologies legitimately bind `0.0.0.0`
|
|
64
|
+
and stay private by not publishing the port).
|
|
34
65
|
|
|
35
66
|
## Run (programmatic)
|
|
36
67
|
|
|
@@ -46,8 +77,9 @@ await relay.stop();
|
|
|
46
77
|
|
|
47
78
|
| Method | Path | Description |
|
|
48
79
|
|--------|------|-------------|
|
|
49
|
-
| `POST` | `/write` | Store an event. Body `{ "event": <NostrEvent> }`. Trusts injected `X-TOON-Payer`/`-Amount`/`-Chain` headers (echoed, not validated); verifies only the event signature. |
|
|
80
|
+
| `POST` | `/write` | Store an event. Body `{ "event": <NostrEvent> }`. Trusts injected `X-TOON-Payer`/`-Amount`/`-Chain` headers (echoed, not validated); verifies only the event signature (ephemeral kinds: id check only by default, see above). |
|
|
50
81
|
| `GET` | `/health` | Liveness, identity (`pubkey`), `capabilities`, and `version`. |
|
|
82
|
+
| `GET` | `/metrics` | JSON telemetry: `eventLoopDelayMs` (mean/p50/p99/max — loop lag is ephemeral-frame tail latency) and `verify` (per-event verify wall time incl. pool queueing, active implementation, worker count). The trigger metrics for scaling decisions (relay#85). |
|
|
51
83
|
|
|
52
84
|
## WebSocket Relay Server
|
|
53
85
|
|
|
@@ -79,7 +111,7 @@ const results = memStore.query([{ kinds: [1], limit: 10 }]);
|
|
|
79
111
|
|
|
80
112
|
## TOON Codec
|
|
81
113
|
|
|
82
|
-
|
|
114
|
+
Vendored in-repo (`src/toon/codec.ts`) so the relay depends only on the lightweight `@toon-format/toon` encoder rather than `@toon-protocol/core`'s full transitive tree. The relay has no runtime dependency on `@toon-protocol/core`.
|
|
83
115
|
|
|
84
116
|
```ts
|
|
85
117
|
import { encodeEventToToon, decodeEventFromToon } from '@toon-protocol/relay';
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import {
|
|
2
|
+
verifyEventId,
|
|
3
|
+
verifyEventSignature,
|
|
4
|
+
verifyImplementation
|
|
5
|
+
} from "./chunk-SMT6G3XD.js";
|
|
6
|
+
|
|
1
7
|
// src/version.ts
|
|
2
8
|
var VERSION = "0.1.0";
|
|
3
9
|
|
|
@@ -5,7 +11,7 @@ var VERSION = "0.1.0";
|
|
|
5
11
|
var DEFAULT_RELAY_CONFIG = {
|
|
6
12
|
port: 7e3,
|
|
7
13
|
host: "0.0.0.0",
|
|
8
|
-
maxConnections:
|
|
14
|
+
maxConnections: 4096,
|
|
9
15
|
maxSubscriptionsPerConnection: 20,
|
|
10
16
|
maxFiltersPerSubscription: 10,
|
|
11
17
|
databasePath: ":memory:"
|
|
@@ -132,6 +138,7 @@ function getDTagValue(tags) {
|
|
|
132
138
|
var SqliteEventStore = class {
|
|
133
139
|
db;
|
|
134
140
|
insertStmt;
|
|
141
|
+
insertOrIgnoreStmt;
|
|
135
142
|
getStmt;
|
|
136
143
|
deleteByPubkeyKindStmt;
|
|
137
144
|
deleteByPubkeyKindDTagStmt;
|
|
@@ -144,11 +151,17 @@ var SqliteEventStore = class {
|
|
|
144
151
|
constructor(dbPath = ":memory:") {
|
|
145
152
|
try {
|
|
146
153
|
this.db = new Database(dbPath);
|
|
154
|
+
this.db.pragma("journal_mode = WAL");
|
|
155
|
+
this.db.pragma("synchronous = NORMAL");
|
|
147
156
|
initializeSchema(this.db);
|
|
148
157
|
this.insertStmt = this.db.prepare(`
|
|
149
158
|
INSERT OR REPLACE INTO events (id, pubkey, kind, content, tags, created_at, sig, received_at)
|
|
150
159
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
151
160
|
`);
|
|
161
|
+
this.insertOrIgnoreStmt = this.db.prepare(`
|
|
162
|
+
INSERT OR IGNORE INTO events (id, pubkey, kind, content, tags, created_at, sig, received_at)
|
|
163
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
164
|
+
`);
|
|
152
165
|
this.getStmt = this.db.prepare("SELECT * FROM events WHERE id = ?");
|
|
153
166
|
this.deleteByPubkeyKindStmt = this.db.prepare(
|
|
154
167
|
"DELETE FROM events WHERE pubkey = ? AND kind = ?"
|
|
@@ -182,11 +195,7 @@ var SqliteEventStore = class {
|
|
|
182
195
|
} else if (isParameterizedReplaceableKind(event.kind)) {
|
|
183
196
|
this.storeParameterizedReplaceableEvent(event, tagsJson, receivedAt);
|
|
184
197
|
} else {
|
|
185
|
-
|
|
186
|
-
INSERT OR IGNORE INTO events (id, pubkey, kind, content, tags, created_at, sig, received_at)
|
|
187
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
188
|
-
`);
|
|
189
|
-
insertOrIgnore.run(
|
|
198
|
+
this.insertOrIgnoreStmt.run(
|
|
190
199
|
event.id,
|
|
191
200
|
event.pubkey,
|
|
192
201
|
event.kind,
|
|
@@ -419,7 +428,124 @@ var SqliteEventStore = class {
|
|
|
419
428
|
}
|
|
420
429
|
};
|
|
421
430
|
|
|
431
|
+
// src/crypto/verify-pool.ts
|
|
432
|
+
import { existsSync } from "fs";
|
|
433
|
+
import { cpus } from "os";
|
|
434
|
+
import { fileURLToPath } from "url";
|
|
435
|
+
import { Worker } from "worker_threads";
|
|
436
|
+
import { performance } from "perf_hooks";
|
|
437
|
+
import { verifiedSymbol } from "nostr-tools/pure";
|
|
438
|
+
function defaultVerifyWorkers() {
|
|
439
|
+
return Math.max(0, cpus().length - 1);
|
|
440
|
+
}
|
|
441
|
+
function resolveWorkerUrl() {
|
|
442
|
+
for (const candidate of [
|
|
443
|
+
new URL("./verify-worker.js", import.meta.url),
|
|
444
|
+
new URL("../../dist/verify-worker.js", import.meta.url)
|
|
445
|
+
]) {
|
|
446
|
+
try {
|
|
447
|
+
if (existsSync(fileURLToPath(candidate))) return candidate;
|
|
448
|
+
} catch {
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
function createVerifyPool(options = {}) {
|
|
454
|
+
const requestedSize = options.size ?? defaultVerifyWorkers();
|
|
455
|
+
const onMeasure = options.onMeasure;
|
|
456
|
+
const measured = (fn) => {
|
|
457
|
+
if (!onMeasure) return fn();
|
|
458
|
+
const start = performance.now();
|
|
459
|
+
const result = fn();
|
|
460
|
+
onMeasure(performance.now() - start);
|
|
461
|
+
return result;
|
|
462
|
+
};
|
|
463
|
+
const inlineVerify = (event) => Promise.resolve(measured(() => verifyEventSignature(event)));
|
|
464
|
+
const workerUrl = requestedSize > 0 ? resolveWorkerUrl() : null;
|
|
465
|
+
if (requestedSize > 0 && !workerUrl) {
|
|
466
|
+
console.warn(
|
|
467
|
+
"[relay] verify pool: compiled worker (dist/verify-worker.js) not found -- falling back to inline verification (build the package to enable workers)"
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
const workers = [];
|
|
471
|
+
let seq = 0;
|
|
472
|
+
let destroyed = false;
|
|
473
|
+
const retireWorker = (pw, reason) => {
|
|
474
|
+
const index = workers.indexOf(pw);
|
|
475
|
+
if (index === -1) return;
|
|
476
|
+
workers.splice(index, 1);
|
|
477
|
+
if (!destroyed) {
|
|
478
|
+
console.warn(
|
|
479
|
+
`[relay] verify pool: worker retired (${reason}); ` + (workers.length > 0 ? `${workers.length} worker(s) remain` : "falling back to inline verification")
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
for (const { resolve, event } of pw.pending.values()) {
|
|
483
|
+
resolve(verifyEventSignature(event));
|
|
484
|
+
}
|
|
485
|
+
pw.pending.clear();
|
|
486
|
+
};
|
|
487
|
+
if (workerUrl) {
|
|
488
|
+
for (let i = 0; i < requestedSize; i++) {
|
|
489
|
+
const worker = new Worker(workerUrl);
|
|
490
|
+
const pw = { worker, pending: /* @__PURE__ */ new Map() };
|
|
491
|
+
worker.on("message", (reply) => {
|
|
492
|
+
const entry = pw.pending.get(reply.seq);
|
|
493
|
+
if (!entry) return;
|
|
494
|
+
pw.pending.delete(reply.seq);
|
|
495
|
+
entry.event[verifiedSymbol] = reply.ok;
|
|
496
|
+
entry.resolve(reply.ok);
|
|
497
|
+
});
|
|
498
|
+
worker.on(
|
|
499
|
+
"error",
|
|
500
|
+
(error) => retireWorker(pw, `error: ${error.message}`)
|
|
501
|
+
);
|
|
502
|
+
worker.on("exit", () => retireWorker(pw, "exit"));
|
|
503
|
+
workers.push(pw);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
const poolVerify = (event) => {
|
|
507
|
+
const cached = event[verifiedSymbol];
|
|
508
|
+
if (typeof cached === "boolean") return Promise.resolve(cached);
|
|
509
|
+
let target = workers[0];
|
|
510
|
+
if (!target) return inlineVerify(event);
|
|
511
|
+
for (const pw of workers) {
|
|
512
|
+
if (pw.pending.size < target.pending.size) target = pw;
|
|
513
|
+
}
|
|
514
|
+
const start = performance.now();
|
|
515
|
+
return new Promise((resolve) => {
|
|
516
|
+
const id = ++seq;
|
|
517
|
+
target.pending.set(id, {
|
|
518
|
+
event,
|
|
519
|
+
resolve: (ok) => {
|
|
520
|
+
onMeasure?.(performance.now() - start);
|
|
521
|
+
resolve(ok);
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
target.worker.postMessage({ seq: id, event });
|
|
525
|
+
});
|
|
526
|
+
};
|
|
527
|
+
return {
|
|
528
|
+
verify(event) {
|
|
529
|
+
return workers.length > 0 ? poolVerify(event) : inlineVerify(event);
|
|
530
|
+
},
|
|
531
|
+
get size() {
|
|
532
|
+
return workers.length;
|
|
533
|
+
},
|
|
534
|
+
async destroy() {
|
|
535
|
+
destroyed = true;
|
|
536
|
+
const toTerminate = [...workers];
|
|
537
|
+
for (const pw of toTerminate) {
|
|
538
|
+
retireWorker(pw, "destroy");
|
|
539
|
+
}
|
|
540
|
+
await Promise.all(toTerminate.map((pw) => pw.worker.terminate()));
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
422
545
|
// src/websocket/ConnectionHandler.ts
|
|
546
|
+
function serializeEventFrame(subscriptionId, eventJson) {
|
|
547
|
+
return `["EVENT",${JSON.stringify(subscriptionId)},${eventJson}]`;
|
|
548
|
+
}
|
|
423
549
|
var ConnectionHandler = class {
|
|
424
550
|
constructor(ws, eventStore, config = {}) {
|
|
425
551
|
this.ws = ws;
|
|
@@ -519,12 +645,21 @@ var ConnectionHandler = class {
|
|
|
519
645
|
/**
|
|
520
646
|
* Push a new event to all matching subscriptions on this connection.
|
|
521
647
|
* Used when events are stored outside the WebSocket flow (e.g., via ILP).
|
|
648
|
+
*
|
|
649
|
+
* @param event - The event to fan out (used for filter matching).
|
|
650
|
+
* @param eventJson - Optional pre-serialized `JSON.stringify(event)`.
|
|
651
|
+
* `NostrRelayServer.broadcastEvent` serializes the event ONCE and passes
|
|
652
|
+
* it here so a 500-subscriber fan-out costs one serialization, not 500
|
|
653
|
+
* (relay#91). When omitted (direct callers), the event is serialized
|
|
654
|
+
* on first matching send.
|
|
522
655
|
*/
|
|
523
|
-
notifyNewEvent(event) {
|
|
656
|
+
notifyNewEvent(event, eventJson) {
|
|
657
|
+
let json = eventJson;
|
|
524
658
|
for (const sub of this.subscriptions.values()) {
|
|
525
659
|
const matches = sub.filters.some((f) => matchFilter(event, f));
|
|
526
660
|
if (matches) {
|
|
527
|
-
|
|
661
|
+
json ??= JSON.stringify(event);
|
|
662
|
+
this.send(serializeEventFrame(sub.id, json));
|
|
528
663
|
}
|
|
529
664
|
}
|
|
530
665
|
}
|
|
@@ -548,9 +683,11 @@ var ConnectionHandler = class {
|
|
|
548
683
|
* with the event as a plain JSON object — so any standard nostr client can
|
|
549
684
|
* parse it and verify `id`/`sig` from the wire bytes (#46). Never re-encode
|
|
550
685
|
* the event (TOON text, double-JSON-stringify, etc.) at this boundary.
|
|
686
|
+
* serializeEventFrame is byte-identical to the full JSON.stringify
|
|
687
|
+
* envelope (pinned by tests).
|
|
551
688
|
*/
|
|
552
689
|
sendEvent(subscriptionId, event) {
|
|
553
|
-
this.send(
|
|
690
|
+
this.send(serializeEventFrame(subscriptionId, JSON.stringify(event)));
|
|
554
691
|
}
|
|
555
692
|
sendEose(subscriptionId) {
|
|
556
693
|
this.send(["EOSE", subscriptionId]);
|
|
@@ -561,15 +698,32 @@ var ConnectionHandler = class {
|
|
|
561
698
|
sendNotice(message) {
|
|
562
699
|
this.send(["NOTICE", message]);
|
|
563
700
|
}
|
|
701
|
+
/** Send a message: pre-serialized frames go out as-is (relay#91). */
|
|
564
702
|
send(message) {
|
|
565
703
|
if (this.ws.readyState === 1) {
|
|
566
|
-
this.ws.send(
|
|
704
|
+
this.ws.send(
|
|
705
|
+
typeof message === "string" ? message : JSON.stringify(message)
|
|
706
|
+
);
|
|
567
707
|
}
|
|
568
708
|
}
|
|
569
709
|
};
|
|
570
710
|
|
|
571
711
|
// src/websocket/NostrRelayServer.ts
|
|
712
|
+
import { readFileSync } from "fs";
|
|
572
713
|
import { WebSocketServer } from "ws";
|
|
714
|
+
var FD_HEADROOM = 128;
|
|
715
|
+
function readOpenFilesSoftLimit(read = (p) => readFileSync(p, "utf8")) {
|
|
716
|
+
try {
|
|
717
|
+
const line = read("/proc/self/limits").split("\n").find((l) => l.startsWith("Max open files"));
|
|
718
|
+
const match = line?.match(/Max open files\s+(\S+)/);
|
|
719
|
+
if (!match?.[1]) return null;
|
|
720
|
+
if (match[1] === "unlimited") return Infinity;
|
|
721
|
+
const limit = parseInt(match[1], 10);
|
|
722
|
+
return Number.isNaN(limit) ? null : limit;
|
|
723
|
+
} catch {
|
|
724
|
+
return null;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
573
727
|
var NostrRelayServer = class {
|
|
574
728
|
constructor(config = {}, eventStore) {
|
|
575
729
|
this.eventStore = eventStore;
|
|
@@ -599,6 +753,12 @@ var NostrRelayServer = class {
|
|
|
599
753
|
if (address && typeof address === "object") {
|
|
600
754
|
console.log(`[NostrRelayServer] Listening on port ${address.port}`);
|
|
601
755
|
}
|
|
756
|
+
const fdLimit = readOpenFilesSoftLimit();
|
|
757
|
+
if (fdLimit !== null && Number.isFinite(fdLimit) && this.config.maxConnections > fdLimit - FD_HEADROOM) {
|
|
758
|
+
console.warn(
|
|
759
|
+
`[NostrRelayServer] maxConnections (${this.config.maxConnections}) exceeds the process fd soft limit (${fdLimit}) minus ${FD_HEADROOM} headroom -- connections will fail with EMFILE before the cap. Raise \`ulimit -n\` or lower maxConnections.`
|
|
760
|
+
);
|
|
761
|
+
}
|
|
602
762
|
resolve();
|
|
603
763
|
});
|
|
604
764
|
} catch (error) {
|
|
@@ -648,14 +808,24 @@ var NostrRelayServer = class {
|
|
|
648
808
|
* Broadcast an event to all connected clients with matching subscriptions.
|
|
649
809
|
* Call this after storing an event outside the WebSocket flow (e.g., via ILP)
|
|
650
810
|
* so that discovery subscribers are notified.
|
|
811
|
+
*
|
|
812
|
+
* Serialize-once fan-out (relay#91): the event payload is stringified ONE
|
|
813
|
+
* time here and reused for every matching subscriber -- only the small
|
|
814
|
+
* per-subscription `["EVENT",<subId>,...]` envelope is spliced per send.
|
|
815
|
+
* Previously each of N subscribers re-serialized the identical event
|
|
816
|
+
* (N=500 pinned a core doing 500 identical stringifies per frame).
|
|
651
817
|
*/
|
|
652
818
|
broadcastEvent(event) {
|
|
819
|
+
const eventJson = JSON.stringify(event);
|
|
653
820
|
for (const handler of this.handlers.values()) {
|
|
654
|
-
handler.notifyNewEvent(event);
|
|
821
|
+
handler.notifyNewEvent(event, eventJson);
|
|
655
822
|
}
|
|
656
823
|
}
|
|
657
824
|
handleConnection(ws) {
|
|
658
825
|
if (this.handlers.size >= this.config.maxConnections) {
|
|
826
|
+
console.warn(
|
|
827
|
+
`[NostrRelayServer] connection rejected: maxConnections (${this.config.maxConnections}) reached -- raise TOON_MAX_CONNECTIONS if this box has headroom (relay#90)`
|
|
828
|
+
);
|
|
659
829
|
ws.close(1013, "max connections reached");
|
|
660
830
|
return;
|
|
661
831
|
}
|
|
@@ -742,9 +912,81 @@ var RelaySubscriber = class {
|
|
|
742
912
|
}
|
|
743
913
|
};
|
|
744
914
|
|
|
915
|
+
// src/launcher/metrics.ts
|
|
916
|
+
import { monitorEventLoopDelay } from "perf_hooks";
|
|
917
|
+
var VERIFY_WINDOW = 2048;
|
|
918
|
+
var NS_PER_MS = 1e6;
|
|
919
|
+
function percentileOf(sorted, fraction) {
|
|
920
|
+
if (sorted.length === 0) return 0;
|
|
921
|
+
const index = Math.min(
|
|
922
|
+
sorted.length - 1,
|
|
923
|
+
Math.ceil(fraction * sorted.length) - 1
|
|
924
|
+
);
|
|
925
|
+
return sorted[Math.max(0, index)] ?? 0;
|
|
926
|
+
}
|
|
927
|
+
function round(value) {
|
|
928
|
+
if (!Number.isFinite(value)) return 0;
|
|
929
|
+
return Math.round(value * 1e3) / 1e3;
|
|
930
|
+
}
|
|
931
|
+
function createMetricsRegistry(info) {
|
|
932
|
+
const loopDelay = monitorEventLoopDelay({
|
|
933
|
+
resolution: 20
|
|
934
|
+
});
|
|
935
|
+
loopDelay.enable();
|
|
936
|
+
let verifyWorkers = info.verifyWorkers;
|
|
937
|
+
let count = 0;
|
|
938
|
+
let totalMs = 0;
|
|
939
|
+
let maxMs = 0;
|
|
940
|
+
const window = new Array(VERIFY_WINDOW);
|
|
941
|
+
let windowFill = 0;
|
|
942
|
+
let windowCursor = 0;
|
|
943
|
+
return {
|
|
944
|
+
recordVerify(ms) {
|
|
945
|
+
count += 1;
|
|
946
|
+
totalMs += ms;
|
|
947
|
+
if (ms > maxMs) maxMs = ms;
|
|
948
|
+
window[windowCursor] = ms;
|
|
949
|
+
windowCursor = (windowCursor + 1) % VERIFY_WINDOW;
|
|
950
|
+
if (windowFill < VERIFY_WINDOW) windowFill += 1;
|
|
951
|
+
},
|
|
952
|
+
snapshot() {
|
|
953
|
+
const recent = window.slice(0, windowFill).sort((a, b) => a - b);
|
|
954
|
+
return {
|
|
955
|
+
timestamp: Date.now(),
|
|
956
|
+
eventLoopDelayMs: {
|
|
957
|
+
mean: round(loopDelay.mean / NS_PER_MS),
|
|
958
|
+
p50: round(loopDelay.percentile(50) / NS_PER_MS),
|
|
959
|
+
p99: round(loopDelay.percentile(99) / NS_PER_MS),
|
|
960
|
+
max: round(loopDelay.max / NS_PER_MS)
|
|
961
|
+
},
|
|
962
|
+
verify: {
|
|
963
|
+
implementation: info.verifyImplementation,
|
|
964
|
+
workers: verifyWorkers,
|
|
965
|
+
count,
|
|
966
|
+
meanMs: round(count > 0 ? totalMs / count : 0),
|
|
967
|
+
maxMs: round(maxMs),
|
|
968
|
+
p50Ms: round(percentileOf(recent, 0.5)),
|
|
969
|
+
p99Ms: round(percentileOf(recent, 0.99))
|
|
970
|
+
}
|
|
971
|
+
};
|
|
972
|
+
},
|
|
973
|
+
setVerifyWorkers(workers) {
|
|
974
|
+
verifyWorkers = workers;
|
|
975
|
+
},
|
|
976
|
+
stop() {
|
|
977
|
+
loopDelay.disable();
|
|
978
|
+
}
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
|
|
745
982
|
// src/launcher/handlers/write-handler.ts
|
|
746
|
-
|
|
983
|
+
function isEphemeralKind(kind) {
|
|
984
|
+
return kind >= 2e4 && kind < 3e4;
|
|
985
|
+
}
|
|
747
986
|
function createWriteHandler(config) {
|
|
987
|
+
const logWrites = config.logWrites ?? false;
|
|
988
|
+
const verifyEphemeral = config.verifyEphemeral ?? false;
|
|
989
|
+
const verifyEvent2 = config.verifyEvent ?? verifyEventSignature;
|
|
748
990
|
return {
|
|
749
991
|
async handleWrite(c) {
|
|
750
992
|
let body;
|
|
@@ -760,13 +1002,23 @@ function createWriteHandler(config) {
|
|
|
760
1002
|
const payer = c.req.header("X-TOON-Payer");
|
|
761
1003
|
const amount = c.req.header("X-TOON-Amount");
|
|
762
1004
|
const chain = c.req.header("X-TOON-Chain");
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
1005
|
+
if (logWrites) {
|
|
1006
|
+
console.log(
|
|
1007
|
+
`[write] event=${event.id} payer=${payer ?? "-"} amount=${amount ?? "-"} chain=${chain ?? "-"}`
|
|
1008
|
+
);
|
|
1009
|
+
}
|
|
1010
|
+
if (!config.devMode) {
|
|
1011
|
+
if (isEphemeralKind(event.kind) && !verifyEphemeral) {
|
|
1012
|
+
if (!verifyEventId(event)) {
|
|
1013
|
+
return c.json({ error: "Invalid event id" }, 422);
|
|
1014
|
+
}
|
|
1015
|
+
} else if (!await verifyEvent2(event)) {
|
|
1016
|
+
return c.json({ error: "Invalid event signature" }, 422);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
if (!isEphemeralKind(event.kind)) {
|
|
1020
|
+
config.eventStore.store(event);
|
|
768
1021
|
}
|
|
769
|
-
config.eventStore.store(event);
|
|
770
1022
|
config.onStored?.(event);
|
|
771
1023
|
return c.json(
|
|
772
1024
|
{
|
|
@@ -814,6 +1066,41 @@ function deriveIdentity(config) {
|
|
|
814
1066
|
const secretKey = hasMnemonic ? privateKeyFromSeedWords(config.mnemonic) : config.secretKey;
|
|
815
1067
|
return { secretKey, pubkey: getPublicKey(secretKey) };
|
|
816
1068
|
}
|
|
1069
|
+
function isInternalBindHost(host) {
|
|
1070
|
+
const h = host.trim().toLowerCase();
|
|
1071
|
+
if (h === "localhost" || h === "::1" || h === "[::1]") return true;
|
|
1072
|
+
if (h.startsWith("127.")) return true;
|
|
1073
|
+
if (h.startsWith("10.")) return true;
|
|
1074
|
+
if (h.startsWith("192.168.")) return true;
|
|
1075
|
+
if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true;
|
|
1076
|
+
if (/^f[cd][0-9a-f]{2}:/.test(h)) return true;
|
|
1077
|
+
if (h.startsWith("fe80:")) return true;
|
|
1078
|
+
return false;
|
|
1079
|
+
}
|
|
1080
|
+
function warnIfWritePortExposed(writeHost, blsPort, options) {
|
|
1081
|
+
const skipActive = options.devMode || !options.verifyEphemeral;
|
|
1082
|
+
if (!skipActive || isInternalBindHost(writeHost)) {
|
|
1083
|
+
return false;
|
|
1084
|
+
}
|
|
1085
|
+
console.warn(
|
|
1086
|
+
[
|
|
1087
|
+
"",
|
|
1088
|
+
"!".repeat(72),
|
|
1089
|
+
`[relay] WARNING: POST /write is binding ${writeHost}:${blsPort} (a`,
|
|
1090
|
+
"[relay] non-loopback/non-internal interface) while event verification",
|
|
1091
|
+
options.devMode ? "[relay] is fully DISABLED (devMode)." : "[relay] is SKIPPED for paid ephemeral kinds (relay#85 default).",
|
|
1092
|
+
"[relay] This is safe ONLY if the write port is reachable exclusively",
|
|
1093
|
+
"[relay] through the payment-gating connector. In docker, do NOT",
|
|
1094
|
+
"[relay] host-publish this port (`expose:`, never `ports:` -- published",
|
|
1095
|
+
"[relay] ports bypass ufw). If the port is directly reachable, either",
|
|
1096
|
+
"[relay] bind it internally (TOON_WRITE_HOST=127.0.0.1) or restore full",
|
|
1097
|
+
"[relay] verification (TOON_VERIFY_EPHEMERAL=true).",
|
|
1098
|
+
"!".repeat(72),
|
|
1099
|
+
""
|
|
1100
|
+
].join("\n")
|
|
1101
|
+
);
|
|
1102
|
+
return true;
|
|
1103
|
+
}
|
|
817
1104
|
function createSubscription(relayUrl, filter, eventStore, activeSubscriptions) {
|
|
818
1105
|
if (!relayUrl.startsWith("ws://") && !relayUrl.startsWith("wss://")) {
|
|
819
1106
|
throw new Error(
|
|
@@ -846,14 +1133,24 @@ async function startRelay(config) {
|
|
|
846
1133
|
const relayPort = config.relayPort ?? 7100;
|
|
847
1134
|
const blsPort = config.blsPort ?? 3100;
|
|
848
1135
|
const host = config.host ?? "0.0.0.0";
|
|
1136
|
+
const writeHost = config.writeHost ?? "0.0.0.0";
|
|
1137
|
+
const maxConnections = config.maxConnections ?? DEFAULT_RELAY_CONFIG.maxConnections;
|
|
849
1138
|
const dataDir = config.dataDir ?? "./data";
|
|
850
1139
|
const devMode = config.devMode ?? false;
|
|
1140
|
+
const verifyEphemeral = config.verifyEphemeral ?? false;
|
|
1141
|
+
const verifyWorkers = config.verifyWorkers ?? defaultVerifyWorkers();
|
|
1142
|
+
const logWrites = config.logWrites ?? false;
|
|
851
1143
|
const resolvedConfig = {
|
|
852
1144
|
relayPort,
|
|
853
1145
|
blsPort,
|
|
854
1146
|
host,
|
|
1147
|
+
writeHost,
|
|
1148
|
+
maxConnections,
|
|
855
1149
|
dataDir,
|
|
856
|
-
devMode
|
|
1150
|
+
devMode,
|
|
1151
|
+
verifyEphemeral,
|
|
1152
|
+
verifyWorkers,
|
|
1153
|
+
logWrites
|
|
857
1154
|
};
|
|
858
1155
|
let eventStore;
|
|
859
1156
|
if (config.eventStore) {
|
|
@@ -862,15 +1159,43 @@ async function startRelay(config) {
|
|
|
862
1159
|
mkdirSync(dataDir, { recursive: true });
|
|
863
1160
|
eventStore = new SqliteEventStore(join(dataDir, "events.db"));
|
|
864
1161
|
}
|
|
865
|
-
const wsRelay = new NostrRelayServer(
|
|
1162
|
+
const wsRelay = new NostrRelayServer(
|
|
1163
|
+
{ port: relayPort, host, maxConnections },
|
|
1164
|
+
eventStore
|
|
1165
|
+
);
|
|
866
1166
|
const app = new Hono();
|
|
867
1167
|
app.get(
|
|
868
1168
|
"/health",
|
|
869
1169
|
(c) => c.json(createHealthResponse({ pubkey: identity.pubkey }))
|
|
870
1170
|
);
|
|
1171
|
+
const metrics = createMetricsRegistry({
|
|
1172
|
+
verifyImplementation,
|
|
1173
|
+
verifyWorkers: 0
|
|
1174
|
+
// updated once the pool reports its live size below
|
|
1175
|
+
});
|
|
1176
|
+
const verifyPool = createVerifyPool({
|
|
1177
|
+
size: verifyWorkers,
|
|
1178
|
+
onMeasure: (ms) => metrics.recordVerify(ms)
|
|
1179
|
+
});
|
|
1180
|
+
metrics.setVerifyWorkers(verifyPool.size);
|
|
1181
|
+
app.get("/metrics", (c) => c.json(metrics.snapshot()));
|
|
1182
|
+
console.log(`[relay] event signature verify: ${verifyImplementation}`);
|
|
1183
|
+
console.log(
|
|
1184
|
+
`[relay] ephemeral-kind schnorr verify: ${devMode ? "skipped (devMode)" : verifyEphemeral ? "full (TOON_VERIFY_EPHEMERAL)" : "skipped -- payment-gated write path, id check kept (relay#85)"}`
|
|
1185
|
+
);
|
|
1186
|
+
warnIfWritePortExposed(writeHost, blsPort, { verifyEphemeral, devMode });
|
|
1187
|
+
console.log(
|
|
1188
|
+
`[relay] verify pool: ${verifyPool.size > 0 ? `${verifyPool.size} worker thread(s)` : "inline (0 workers -- verification on the event loop)"}`
|
|
1189
|
+
);
|
|
871
1190
|
const writeHandler = createWriteHandler({
|
|
872
1191
|
eventStore,
|
|
873
1192
|
devMode,
|
|
1193
|
+
verifyEphemeral,
|
|
1194
|
+
verifyEvent: (event) => {
|
|
1195
|
+
metrics.setVerifyWorkers(verifyPool.size);
|
|
1196
|
+
return verifyPool.verify(event);
|
|
1197
|
+
},
|
|
1198
|
+
logWrites,
|
|
874
1199
|
onStored: (event) => {
|
|
875
1200
|
try {
|
|
876
1201
|
wsRelay.broadcastEvent(event);
|
|
@@ -881,7 +1206,7 @@ async function startRelay(config) {
|
|
|
881
1206
|
app.post("/write", (c) => writeHandler.handleWrite(c));
|
|
882
1207
|
const blsServer = await new Promise((resolve) => {
|
|
883
1208
|
const server = serve(
|
|
884
|
-
{ fetch: app.fetch, port: blsPort },
|
|
1209
|
+
{ fetch: app.fetch, port: blsPort, hostname: writeHost },
|
|
885
1210
|
() => resolve(server)
|
|
886
1211
|
);
|
|
887
1212
|
});
|
|
@@ -912,6 +1237,8 @@ async function startRelay(config) {
|
|
|
912
1237
|
activeSubscriptions.clear();
|
|
913
1238
|
await wsRelay.stop();
|
|
914
1239
|
blsServer.close();
|
|
1240
|
+
metrics.stop();
|
|
1241
|
+
await verifyPool.destroy();
|
|
915
1242
|
if (!config.eventStore) {
|
|
916
1243
|
eventStore.close?.();
|
|
917
1244
|
}
|
|
@@ -929,11 +1256,18 @@ export {
|
|
|
929
1256
|
InMemoryEventStore,
|
|
930
1257
|
RelayError,
|
|
931
1258
|
SqliteEventStore,
|
|
1259
|
+
defaultVerifyWorkers,
|
|
1260
|
+
createVerifyPool,
|
|
1261
|
+
serializeEventFrame,
|
|
932
1262
|
ConnectionHandler,
|
|
1263
|
+
readOpenFilesSoftLimit,
|
|
933
1264
|
NostrRelayServer,
|
|
934
1265
|
RelaySubscriber,
|
|
1266
|
+
createMetricsRegistry,
|
|
935
1267
|
createWriteHandler,
|
|
936
1268
|
createHealthResponse,
|
|
1269
|
+
isInternalBindHost,
|
|
1270
|
+
warnIfWritePortExposed,
|
|
937
1271
|
startRelay
|
|
938
1272
|
};
|
|
939
|
-
//# sourceMappingURL=chunk-
|
|
1273
|
+
//# sourceMappingURL=chunk-QZQRHQEQ.js.map
|