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:
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

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.
- 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

View File

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

View File

@ -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"
}

30
pnpm-lock.yaml generated
View File

@ -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: {}

View File

@ -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;

View File

@ -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<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
return new Promise<T>((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<boolean> {
return withTimeout<boolean>(
new Promise<boolean>((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<boolean>(
new Promise<boolean>((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<void>((resolve) => {
setTimeout(resolve, sleepMs);
});
}),
timeoutMs,
"write confirm read-after-write",
);
}
}
return false;
}
export async function runWriteConfirm(input: WriteConfirmInput): Promise<WriteConfirmResult> {
const startedAt = performance.now();
let relay: Relay | null = null;
try {
ensureWebSocketSupport();
const secretKey = decodePrivateKey(input.privkey);
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", () => {
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);
});

View File

@ -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";