Fix write verification and re-enable it

This commit is contained in:
pleb 2026-03-22 22:50:51 -07:00
parent ef7463a29a
commit ec51dcc52e
9 changed files with 106 additions and 38 deletions

View File

@ -9,8 +9,8 @@ export LOG_LEVEL=info
# Optional write confirmation probe settings: # Optional write confirmation probe settings:
export WRITE_CHECK_ENABLED=true export WRITE_CHECK_ENABLED=true
# false = consider publish OK sufficient; true = require read-after-write confirmation # true = require read-after-write confirmation; set false to treat publish OK as sufficient
export WRITE_CHECK_VERIFY_READ=false export WRITE_CHECK_VERIFY_READ=true
export WRITE_CHECK_KIND=30078 export WRITE_CHECK_KIND=30078
# WRITE_CHECK_PRIVKEY accepts either: # WRITE_CHECK_PRIVKEY accepts either:
# - nsec1... string # - nsec1... string

View File

@ -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. - Probes each configured relay either on an interval, or on-demand when `/metrics` is scraped.
- Uses `@nostrwatch/nocap` for `open` + `read` checks. - Uses `@nostrwatch/nocap` for `open` + `read` checks.
- Optionally performs a low-noise write confirmation check (kind `30078` by default) using deterministic `d` tags. - 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. - By default, requires read-after-write verification.
- Exposes: - Exposes:
- `/metrics` for Prometheus scraping - `/metrics` for Prometheus scraping
- `/healthz` for process and probe-loop health - `/healthz` for process and probe-loop health
@ -49,7 +49,7 @@ Environment variables:
- `PORT` (default: `9464`) - `PORT` (default: `9464`)
- `LOG_LEVEL` (default: `info`; one of `debug|info|warn|error`) - `LOG_LEVEL` (default: `info`; one of `debug|info|warn|error`)
- `WRITE_CHECK_ENABLED` (default: `true`) - `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_KIND` (default: `30078`)
- `WRITE_CHECK_PRIVKEY` (optional; supports `nsec1...` or 64-char hex) - `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 pnpm test test/live.offchain.test.ts
``` ```
Optional knobs: Faster local loop (reduced stability sampling):
- `LIVE_RELAY_TEST_SAMPLES` (default `12`) ```bash
- `LIVE_RELAY_TEST_SCRAPE_EVERY_MS` (default `1500`) 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_TIMEOUT_SECONDS` (default `8`)
- `LIVE_RELAY_TEST_RELAYS` (default `"wss://offchain.pub"`; comma-separated relay list) - `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 - `LIVE_RELAY_TEST_EXPECT_NO_FAILURES=1` to make the test fail on any write-confirm/probe failures
## Exposed metrics ## Exposed metrics

View File

@ -36,7 +36,7 @@
pnpmDeps = pkgs.pnpm.fetchDeps { pnpmDeps = pkgs.pnpm.fetchDeps {
inherit pname version src; inherit pname version src;
fetcherVersion = 1; fetcherVersion = 1;
hash = "sha256-n3pn4NVBVauwE2FWfMsulmn+KgCkcV32XJD6vX1NIB0="; hash = "sha256-rZhEXQcRPS5pHdXNfrYeTI3zxkqtJjcyaGgrmzwFaHA=";
}; };
buildPhase = '' buildPhase = ''

View File

@ -22,10 +22,12 @@
"@nostrwatch/nocap-every-adapter-default": "^1.7.0", "@nostrwatch/nocap-every-adapter-default": "^1.7.0",
"nostr-tools": "^2.23.3", "nostr-tools": "^2.23.3",
"p-limit": "^7.3.0", "p-limit": "^7.3.0",
"prom-client": "^15.1.3" "prom-client": "^15.1.3",
"ws": "^8.20.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"@types/ws": "^8.18.1",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }

30
pnpm-lock.yaml generated
View File

@ -23,10 +23,16 @@ importers:
prom-client: prom-client:
specifier: ^15.1.3 specifier: ^15.1.3
version: 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: devDependencies:
'@types/node': '@types/node':
specifier: ^25.5.0 specifier: ^25.5.0
version: 25.5.0 version: 25.5.0
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
tsx: tsx:
specifier: ^4.21.0 specifier: ^4.21.0
version: 4.21.0 version: 4.21.0
@ -683,6 +689,9 @@ packages:
'@types/tough-cookie@4.0.5': '@types/tough-cookie@4.0.5':
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} 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': '@types/yargs-parser@21.0.3':
resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==}
@ -1956,6 +1965,18 @@ packages:
utf-8-validate: utf-8-validate:
optional: true 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: y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -2677,6 +2698,10 @@ snapshots:
'@types/tough-cookie@4.0.5': {} '@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-parser@21.0.3': {}
'@types/yargs@17.0.35': '@types/yargs@17.0.35':
@ -4123,6 +4148,11 @@ snapshots:
bufferutil: 4.1.0 bufferutil: 4.1.0
utf-8-validate: 5.0.10 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: {} y18n@5.0.8: {}
yaeti@0.0.6: {} yaeti@0.0.6: {}

View File

@ -35,7 +35,7 @@ const DEFAULT_LISTEN_ADDR = "0.0.0.0";
const DEFAULT_PORT = 9464; const DEFAULT_PORT = 9464;
const DEFAULT_LOG_LEVEL: LogLevel = "info"; const DEFAULT_LOG_LEVEL: LogLevel = "info";
const DEFAULT_WRITE_CHECK_ENABLED = true; 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 DEFAULT_WRITE_CHECK_KIND = 30078;
const MIN_TIMEOUT_SECONDS = 1; const MIN_TIMEOUT_SECONDS = 1;

View File

@ -1,5 +1,6 @@
import { performance } from "node:perf_hooks"; import { performance } from "node:perf_hooks";
import { finalizeEvent, getPublicKey, nip19, Relay, type Filter } from "nostr-tools"; import { finalizeEvent, getPublicKey, nip19, Relay, type Filter } from "nostr-tools";
import WebSocket from "ws";
interface WriteConfirmInput { interface WriteConfirmInput {
relay: string; relay: string;
@ -35,6 +36,13 @@ function decodePrivateKey(raw: string): Uint8Array {
return Uint8Array.from(Buffer.from(value, "hex")); 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<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> { function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs); const timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
@ -56,7 +64,12 @@ async function waitForEvent(
expectedEventId: string, expectedEventId: string,
timeoutMs: number, timeoutMs: number,
): Promise<boolean> { ): Promise<boolean> {
return withTimeout<boolean>( 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<boolean>(
new Promise<boolean>((resolve) => { new Promise<boolean>((resolve) => {
let settled = false; let settled = false;
const sub = relay.subscribe([filter], { const sub = relay.subscribe([filter], {
@ -76,15 +89,28 @@ async function waitForEvent(
}, },
}); });
}), }),
timeoutMs, queryTimeoutMs,
"write confirm read-after-write", "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<void>((resolve) => {
setTimeout(resolve, sleepMs);
});
}
}
return false;
} }
export async function runWriteConfirm(input: WriteConfirmInput): Promise<WriteConfirmResult> { export async function runWriteConfirm(input: WriteConfirmInput): Promise<WriteConfirmResult> {
const startedAt = performance.now(); const startedAt = performance.now();
let relay: Relay | null = null; let relay: Relay | null = null;
try { try {
ensureWebSocketSupport();
const secretKey = decodePrivateKey(input.privkey); const secretKey = decodePrivateKey(input.privkey);
const derivedPubkey = getPublicKey(secretKey); const derivedPubkey = getPublicKey(secretKey);

View File

@ -5,7 +5,7 @@ import { loadConfig } from "../src/config.js";
test("loadConfig parses relay input and ignores invalid entries", () => { test("loadConfig parses relay input and ignores invalid entries", () => {
const config = loadConfig({ const config = loadConfig({
RELAYS: "wss://offchain.pub,not-a-url,wss://offchain.pub", RELAYS: "wss://offchain.pub,not-a-url",
WRITE_CHECK_ENABLED: "false", WRITE_CHECK_ENABLED: "false",
}); });
@ -50,11 +50,11 @@ test("loadConfig sets write-check read verification flag", () => {
const defaultConfig = loadConfig({ const defaultConfig = loadConfig({
RELAYS: "wss://offchain.pub", RELAYS: "wss://offchain.pub",
}); });
assert.equal(defaultConfig.writeCheck.verifyRead, false); assert.equal(defaultConfig.writeCheck.verifyRead, true);
const verifyConfig = loadConfig({ const verifyConfig = loadConfig({
RELAYS: "wss://offchain.pub", 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);
}); });

View File

@ -6,8 +6,8 @@ import type { Readable } from "node:stream";
import { setTimeout as sleep } from "node:timers/promises"; import { setTimeout as sleep } from "node:timers/promises";
const ENABLED = process.env.LIVE_RELAY_TEST_OFFCHAIN === "1"; const ENABLED = process.env.LIVE_RELAY_TEST_OFFCHAIN === "1";
const SCRAPE_SAMPLES = Number.parseInt(process.env.LIVE_RELAY_TEST_SAMPLES ?? "12", 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 ?? "1500", 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 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 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"; const ENFORCE_NO_WRITE_CONFIRM_FAILURES = process.env.LIVE_RELAY_TEST_EXPECT_NO_FAILURES === "1";