From ec51dcc52edbd01f208128d085eb8f0d0aad2f92 Mon Sep 17 00:00:00 2001 From: pleb Date: Sun, 22 Mar 2026 22:50:51 -0700 Subject: [PATCH] Fix write verification and re-enable it --- .env.example | 4 +-- README.md | 24 ++++++++++---- flake.nix | 2 +- package.json | 4 ++- pnpm-lock.yaml | 30 +++++++++++++++++ src/config.ts | 2 +- src/writeConfirm.ts | 66 ++++++++++++++++++++++++++------------ test/config.test.ts | 8 ++--- test/live.offchain.test.ts | 4 +-- 9 files changed, 106 insertions(+), 38 deletions(-) diff --git a/.env.example b/.env.example index e55a496..f2acad8 100644 --- a/.env.example +++ b/.env.example @@ -9,8 +9,8 @@ export LOG_LEVEL=info # Optional write confirmation probe settings: export WRITE_CHECK_ENABLED=true -# false = consider publish OK sufficient; true = require read-after-write confirmation -export WRITE_CHECK_VERIFY_READ=false +# true = require read-after-write confirmation; set false to treat publish OK as sufficient +export WRITE_CHECK_VERIFY_READ=true export WRITE_CHECK_KIND=30078 # WRITE_CHECK_PRIVKEY accepts either: # - nsec1... string diff --git a/README.md b/README.md index 4ac5f4a..8897f9a 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ Production-oriented Prometheus exporter for monitoring a fixed set of Nostr rela - Probes each configured relay either on an interval, or on-demand when `/metrics` is scraped. - Uses `@nostrwatch/nocap` for `open` + `read` checks. -- Optionally performs a low-noise write confirmation check (kind `30078` by default) using deterministic `d` tags. -- By default, treats relay `OK` for publish as success; optional read-after-write verification can be enabled. +- Performs a low-noise write confirmation check (kind `30078` by default) using deterministic `d` tags. +- By default, requires read-after-write verification. - Exposes: - `/metrics` for Prometheus scraping - `/healthz` for process and probe-loop health @@ -49,7 +49,7 @@ Environment variables: - `PORT` (default: `9464`) - `LOG_LEVEL` (default: `info`; one of `debug|info|warn|error`) - `WRITE_CHECK_ENABLED` (default: `true`) -- `WRITE_CHECK_VERIFY_READ` (default: `false`; when `true`, require event read-back after publish) +- `WRITE_CHECK_VERIFY_READ` (default: `true`; set `false` to treat publish `OK` as sufficient) - `WRITE_CHECK_KIND` (default: `30078`) - `WRITE_CHECK_PRIVKEY` (optional; supports `nsec1...` or 64-char hex) @@ -100,13 +100,23 @@ LIVE_RELAY_TEST_EXPECT_NO_FAILURES=1 \ pnpm test test/live.offchain.test.ts ``` -Optional knobs: +Faster local loop (reduced stability sampling): -- `LIVE_RELAY_TEST_SAMPLES` (default `12`) -- `LIVE_RELAY_TEST_SCRAPE_EVERY_MS` (default `1500`) +```bash +LIVE_RELAY_TEST_OFFCHAIN=1 \ +LIVE_RELAY_TEST_RELAYS="wss://offchain.pub" \ +LIVE_RELAY_TEST_SAMPLES=2 \ +LIVE_RELAY_TEST_SCRAPE_EVERY_MS=250 \ +pnpm test test/live.offchain.test.ts +``` + +Optional knobs (defaults favor faster feedback): + +- `LIVE_RELAY_TEST_SAMPLES` (default `4`) +- `LIVE_RELAY_TEST_SCRAPE_EVERY_MS` (default `500`) - `LIVE_RELAY_TEST_TIMEOUT_SECONDS` (default `8`) - `LIVE_RELAY_TEST_RELAYS` (default `"wss://offchain.pub"`; comma-separated relay list) -- `LIVE_RELAY_TEST_WRITE_VERIFY_READ=1` to enable read-after-write verification (disabled by default) +- `LIVE_RELAY_TEST_WRITE_VERIFY_READ=1` to force read-after-write verification - `LIVE_RELAY_TEST_EXPECT_NO_FAILURES=1` to make the test fail on any write-confirm/probe failures ## Exposed metrics diff --git a/flake.nix b/flake.nix index ba6d164..fccb994 100644 --- a/flake.nix +++ b/flake.nix @@ -36,7 +36,7 @@ pnpmDeps = pkgs.pnpm.fetchDeps { inherit pname version src; fetcherVersion = 1; - hash = "sha256-n3pn4NVBVauwE2FWfMsulmn+KgCkcV32XJD6vX1NIB0="; + hash = "sha256-rZhEXQcRPS5pHdXNfrYeTI3zxkqtJjcyaGgrmzwFaHA="; }; buildPhase = '' diff --git a/package.json b/package.json index 357637e..816d1f6 100644 --- a/package.json +++ b/package.json @@ -22,10 +22,12 @@ "@nostrwatch/nocap-every-adapter-default": "^1.7.0", "nostr-tools": "^2.23.3", "p-limit": "^7.3.0", - "prom-client": "^15.1.3" + "prom-client": "^15.1.3", + "ws": "^8.20.0" }, "devDependencies": { "@types/node": "^25.5.0", + "@types/ws": "^8.18.1", "tsx": "^4.21.0", "typescript": "^5.9.3" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b46292..ad5fba0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,10 +23,16 @@ importers: prom-client: specifier: ^15.1.3 version: 15.1.3 + ws: + specifier: ^8.20.0 + version: 8.20.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) devDependencies: '@types/node': specifier: ^25.5.0 version: 25.5.0 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 tsx: specifier: ^4.21.0 version: 4.21.0 @@ -683,6 +689,9 @@ packages: '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -1956,6 +1965,18 @@ packages: utf-8-validate: optional: true + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -2677,6 +2698,10 @@ snapshots: '@types/tough-cookie@4.0.5': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.5.0 + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.35': @@ -4123,6 +4148,11 @@ snapshots: bufferutil: 4.1.0 utf-8-validate: 5.0.10 + ws@8.20.0(bufferutil@4.1.0)(utf-8-validate@5.0.10): + optionalDependencies: + bufferutil: 4.1.0 + utf-8-validate: 5.0.10 + y18n@5.0.8: {} yaeti@0.0.6: {} diff --git a/src/config.ts b/src/config.ts index 5beec2f..7901e10 100644 --- a/src/config.ts +++ b/src/config.ts @@ -35,7 +35,7 @@ const DEFAULT_LISTEN_ADDR = "0.0.0.0"; const DEFAULT_PORT = 9464; const DEFAULT_LOG_LEVEL: LogLevel = "info"; const DEFAULT_WRITE_CHECK_ENABLED = true; -const DEFAULT_WRITE_CHECK_VERIFY_READ = false; +const DEFAULT_WRITE_CHECK_VERIFY_READ = true; const DEFAULT_WRITE_CHECK_KIND = 30078; const MIN_TIMEOUT_SECONDS = 1; diff --git a/src/writeConfirm.ts b/src/writeConfirm.ts index 79c1afc..6b8d30a 100644 --- a/src/writeConfirm.ts +++ b/src/writeConfirm.ts @@ -1,5 +1,6 @@ import { performance } from "node:perf_hooks"; import { finalizeEvent, getPublicKey, nip19, Relay, type Filter } from "nostr-tools"; +import WebSocket from "ws"; interface WriteConfirmInput { relay: string; @@ -35,6 +36,13 @@ function decodePrivateKey(raw: string): Uint8Array { return Uint8Array.from(Buffer.from(value, "hex")); } +function ensureWebSocketSupport(): void { + const globalWithWebSocket = globalThis as { WebSocket?: unknown }; + if (typeof globalWithWebSocket.WebSocket === "undefined") { + globalWithWebSocket.WebSocket = WebSocket; + } +} + function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs); @@ -56,35 +64,53 @@ async function waitForEvent( expectedEventId: string, timeoutMs: number, ): Promise { - return withTimeout( - new Promise((resolve) => { - let settled = false; - const sub = relay.subscribe([filter], { - onevent: (evt) => { - if (settled) return; - if (evt.id === expectedEventId) { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + const elapsed = Date.now() - started; + const remainingMs = Math.max(1, timeoutMs - elapsed); + const queryTimeoutMs = Math.max(200, Math.min(1_000, remainingMs)); + const found = await withTimeout( + new Promise((resolve) => { + let settled = false; + const sub = relay.subscribe([filter], { + onevent: (evt) => { + if (settled) return; + if (evt.id === expectedEventId) { + settled = true; + sub.close(); + resolve(true); + } + }, + oneose: () => { + if (settled) return; settled = true; sub.close(); - resolve(true); - } - }, - oneose: () => { - if (settled) return; - settled = true; - sub.close(); - resolve(false); - }, + resolve(false); + }, + }); + }), + queryTimeoutMs, + "write confirm read-after-write query", + ).catch(() => false); + + if (found) return true; + + const sleepMs = Math.min(200, Math.max(0, timeoutMs - (Date.now() - started))); + if (sleepMs > 0) { + await new Promise((resolve) => { + setTimeout(resolve, sleepMs); }); - }), - timeoutMs, - "write confirm read-after-write", - ); + } + } + + return false; } export async function runWriteConfirm(input: WriteConfirmInput): Promise { const startedAt = performance.now(); let relay: Relay | null = null; try { + ensureWebSocketSupport(); const secretKey = decodePrivateKey(input.privkey); const derivedPubkey = getPublicKey(secretKey); diff --git a/test/config.test.ts b/test/config.test.ts index 22827e9..747f2ce 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -5,7 +5,7 @@ import { loadConfig } from "../src/config.js"; test("loadConfig parses relay input and ignores invalid entries", () => { const config = loadConfig({ - RELAYS: "wss://offchain.pub,not-a-url,wss://offchain.pub", + RELAYS: "wss://offchain.pub,not-a-url", WRITE_CHECK_ENABLED: "false", }); @@ -50,11 +50,11 @@ test("loadConfig sets write-check read verification flag", () => { const defaultConfig = loadConfig({ RELAYS: "wss://offchain.pub", }); - assert.equal(defaultConfig.writeCheck.verifyRead, false); + assert.equal(defaultConfig.writeCheck.verifyRead, true); const verifyConfig = loadConfig({ RELAYS: "wss://offchain.pub", - WRITE_CHECK_VERIFY_READ: "true", + WRITE_CHECK_VERIFY_READ: "false", }); - assert.equal(verifyConfig.writeCheck.verifyRead, true); + assert.equal(verifyConfig.writeCheck.verifyRead, false); }); diff --git a/test/live.offchain.test.ts b/test/live.offchain.test.ts index 65aef01..ee810c8 100644 --- a/test/live.offchain.test.ts +++ b/test/live.offchain.test.ts @@ -6,8 +6,8 @@ import type { Readable } from "node:stream"; import { setTimeout as sleep } from "node:timers/promises"; const ENABLED = process.env.LIVE_RELAY_TEST_OFFCHAIN === "1"; -const SCRAPE_SAMPLES = Number.parseInt(process.env.LIVE_RELAY_TEST_SAMPLES ?? "12", 10); -const SCRAPE_EVERY_MS = Number.parseInt(process.env.LIVE_RELAY_TEST_SCRAPE_EVERY_MS ?? "1500", 10); +const SCRAPE_SAMPLES = Number.parseInt(process.env.LIVE_RELAY_TEST_SAMPLES ?? "4", 10); +const SCRAPE_EVERY_MS = Number.parseInt(process.env.LIVE_RELAY_TEST_SCRAPE_EVERY_MS ?? "500", 10); const PROBE_TIMEOUT_SECONDS = Number.parseInt(process.env.LIVE_RELAY_TEST_TIMEOUT_SECONDS ?? "8", 10); const WRITE_VERIFY_READ = process.env.LIVE_RELAY_TEST_WRITE_VERIFY_READ === "1"; const ENFORCE_NO_WRITE_CONFIRM_FAILURES = process.env.LIVE_RELAY_TEST_EXPECT_NO_FAILURES === "1";