From da0819bfa35ae0aebe1a1f246ce0be18b721acce Mon Sep 17 00:00:00 2001 From: Vladimir Rogojin Date: Mon, 4 May 2026 17:01:25 +0200 Subject: [PATCH 1/5] feat: faucet probe + bump to 0.4.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a sixth probe to the suite for the Unicity test faucet (https://faucet.unicity.network). Many SDK e2e suites depend on the faucet for wallet funding (uxf-send-receive, pointer-roundtrip, migrate-to-profile-conservation, profile-export-roundtrip); when the faucet is down, those suites silently time out at 240 s with a generic "Faucet top-up timed out" message. Probing upfront converts that into a clean 1-2 s SKIP with a precise diagnostic. The probe issues POST /api/v1/faucet/request with a deliberately- invalid probe nametag (`infra-probe-do-not-mint-zk7q3xa9p2v`). Healthy faucet returns HTTP 4xx + structured `{success: false, error: "Nametag not found: ..."}`. This exercises the full HTTP/parse/nametag-resolve/response-shaping pipeline WITHOUT consuming actual faucet quota or requiring a real wallet — the same "empty cost, full coverage" pattern the aggregator probe uses with its known-bad shard ID. Defense-in-depth: if the faucet ever returns success:true for the probe nametag, the probe DOWNGRADES to 'degraded' (validation may be broken — the probe nametag is supposed to be invalid). Network config: - testnet, dev: faucet = "https://faucet.unicity.network" - mainnet: faucet = null (no faucet by design) The probe layer treats null as a clean skip with verdict 'healthy' — cleaner than emitting a misleading "unreachable" verdict against a default URL that doesn't apply to the network being probed. SERVICES expands from 5 to 6: ['nostr', 'aggregator', 'ipfs', 'fulcrum', 'market', 'faucet']. The 'faucet' entry is appended at the end so existing --only invocations are unaffected. Tests: - 21 smoke tests pass (added 1 for mainnet/testnet faucet contract) - Live testnet probe: 6/6 services healthy - Live mainnet probe with --only faucet: skipped cleanly with "network mainnet has no faucet by design — skipped" --- CHANGELOG.md | 14 +++ README.md | 9 +- package.json | 5 +- src/index.mjs | 21 +++- src/networks.mjs | 6 ++ src/probes/faucet.mjs | 244 ++++++++++++++++++++++++++++++++++++++++++ tests/smoke.test.mjs | 18 +++- 7 files changed, 310 insertions(+), 7 deletions(-) create mode 100644 src/probes/faucet.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index eaeed46..8254c52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to `@unicitylabs/infra-probe` are documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to [Semantic Versioning](https://semver.org/). +## [0.4.0] — 2026-05-04 + +### Added + +- **`faucet` probe** for the Unicity test faucet (`https://faucet.unicity.network`). The faucet issues real test tokens to e2e wallets identified by registered nametag; many SDK e2e suites (`uxf-send-receive`, `pointer-roundtrip`, `migrate-to-profile-conservation`, `profile-export-roundtrip`) silently time out at the wallet-funding step when the faucet is down. The probe converts a 240 s "Faucet top-up timed out" into a 1-2 s clean SKIP with a precise "faucet unreachable" message. + - **`request` check** — POST `/api/v1/faucet/request` with a deliberately-invalid probe nametag. Healthy faucet responds with HTTP 4xx + structured `{success: false, error: "Nametag not found: …"}`. Exercises the full HTTP/parse/nametag-resolve/response-shaping pipeline WITHOUT consuming actual faucet quota. + - **`health` check** — best-effort GET `/health`. Absence is not a failure; presence cross-checks backend pressure. +- **`NETWORKS[*].faucet`** field per network. `null` on mainnet (no faucet by design), `https://faucet.unicity.network` on testnet and dev. The probe layer treats `null` as a clean skip with verdict `healthy` — cleaner than emitting a misleading "unreachable" verdict against a default URL that doesn't apply. +- **`SERVICES`** updated to 6 entries: `['nostr', 'aggregator', 'ipfs', 'fulcrum', 'market', 'faucet']`. Stable ordering preserved; `faucet` appended at the end so existing `--only` invocations are unaffected. + +### Notes + +- The probe nametag (`infra-probe-do-not-mint-zk7q3xa9p2v`) is deliberately synthetic and unlikely to collide with a real user's nametag. If the faucet ever returns `success: true` for the probe call (i.e., MINTS tokens to the probe nametag), the verdict downgrades to `degraded` with a clear "validation may be broken" message — that's a real signal worth surfacing. + ## [0.3.0] — 2026-05-03 First publishable release. Three rounds of probe-correctness work since the initial cut, plus documentation and packaging hardening. diff --git a/README.md b/README.md index 54c2336..8fa7de1 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Tiny availability + performance probe for [Unicity Network](https://unicity.network) infrastructure. -Designed as a **pre-flight gate** for end-to-end test suites and a **5-second smoke test** when something feels off. Runs five parallel probes — Nostr relay, L3 Aggregator, IPFS gateway, L1 Fulcrum, and the Market intent database — exercises both the **liveness** of each endpoint and the **functional write+read+verify path** real wallet flows depend on, then reports per-check latency in either a colored human-readable format or single-line JSON. +Designed as a **pre-flight gate** for end-to-end test suites and a **5-second smoke test** when something feels off. Runs six parallel probes — Nostr relay, L3 Aggregator, IPFS gateway, L1 Fulcrum, the Market intent database, and the test Faucet — exercises both the **liveness** of each endpoint and the **functional write+read+verify path** real wallet flows depend on, then reports per-check latency in either a colored human-readable format or single-line JSON. ``` ✅ aggregator https://goggregator-test.unicity.network @@ -47,7 +47,12 @@ Designed as a **pre-flight gate** for end-to-end test suites and a **5-second sm ✓ feed-recent 1016ms 10 listing(s) returned (1016ms) Status: HEALTHY (2/2 checks passed) - Summary: 5 HEALTHY, 0 DEGRADED, 0 UNREACHABLE (of 5) +✅ faucet https://faucet.unicity.network + ✓ request 168ms cleanly rejected probe-nametag (168ms; "Nametag not found: …") + ✓ health 13ms HTTP 200 (13ms) + Status: HEALTHY (2/2 checks passed) + + Summary: 6 HEALTHY, 0 DEGRADED, 0 UNREACHABLE (of 6) ``` ## Install diff --git a/package.json b/package.json index ddc7235..630434c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@unicitylabs/infra-probe", - "version": "0.3.0", - "description": "Availability + performance probe for Unicity Network infrastructure (Nostr relay, Aggregator, IPFS gateway, L1 Fulcrum). Pre-flight check for e2e tests; CI-friendly JSON output.", + "version": "0.4.0", + "description": "Availability + performance probe for Unicity Network infrastructure (Nostr relay, Aggregator, IPFS gateway, L1 Fulcrum, Market, Faucet). Pre-flight check for e2e tests; CI-friendly JSON output.", "type": "module", "license": "MIT", "author": "Unicity Network ", @@ -20,6 +20,7 @@ "nostr", "ipfs", "fulcrum", + "faucet", "monitoring", "preflight" ], diff --git a/src/index.mjs b/src/index.mjs index 756fdb0..cdddd1c 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -14,8 +14,9 @@ import { probeAggregator } from './probes/aggregator.mjs'; import { probeIpfsGateway } from './probes/ipfs.mjs'; import { probeFulcrum } from './probes/fulcrum.mjs'; import { probeMarket } from './probes/market.mjs'; +import { probeFaucet } from './probes/faucet.mjs'; -export const SERVICES = ['nostr', 'aggregator', 'ipfs', 'fulcrum', 'market']; +export const SERVICES = ['nostr', 'aggregator', 'ipfs', 'fulcrum', 'market', 'faucet']; /** * @param {object} options @@ -41,6 +42,24 @@ export async function runProbes({ network = 'testnet', only, timeoutMs = 20_000, ipfs: () => probeIpfsGateway(cfg.ipfsGateways[0], { timeoutMs }), fulcrum: () => probeFulcrum(cfg.fulcrum, { timeoutMs }), market: () => probeMarket(cfg.marketApi, { timeoutMs }), + // Mainnet has no faucet — `cfg.faucet` is null there. Return a + // skipped-cleanly verdict rather than running probeFaucet against + // a default URL that doesn't apply to the network being probed. + faucet: () => cfg.faucet + ? probeFaucet(cfg.faucet, { timeoutMs }) + : Promise.resolve({ + service: 'faucet', + endpoint: '(none for ' + network + ')', + status: 'healthy', + latencyMs: 0, + checks: [{ + name: 'config', + status: 'pass', + latencyMs: 0, + message: `network ${network} has no faucet by design — skipped`, + }], + timestamp: new Date().toISOString(), + }), }; const tasks = requested.map((name) => { diff --git a/src/networks.mjs b/src/networks.mjs index eff7c74..b020941 100644 --- a/src/networks.mjs +++ b/src/networks.mjs @@ -18,6 +18,10 @@ export const NETWORKS = { ], fulcrum: 'wss://fulcrum.unicity.network:50004', marketApi: 'https://market-api.unicity.network', + // Mainnet has no faucet — `null` makes the probe skip cleanly when + // run with `--only faucet` against mainnet, rather than emitting a + // misleading "unreachable" verdict. + faucet: null, }, testnet: { label: 'Testnet', @@ -30,6 +34,7 @@ export const NETWORKS = { ], fulcrum: 'wss://fulcrum.unicity.network:50004', marketApi: 'https://market-api.unicity.network', + faucet: 'https://faucet.unicity.network', }, dev: { label: 'Development', @@ -42,6 +47,7 @@ export const NETWORKS = { ], fulcrum: 'wss://fulcrum.unicity.network:50004', marketApi: 'https://market-api.unicity.network', + faucet: 'https://faucet.unicity.network', }, }; diff --git a/src/probes/faucet.mjs b/src/probes/faucet.mjs new file mode 100644 index 0000000..124af0c --- /dev/null +++ b/src/probes/faucet.mjs @@ -0,0 +1,244 @@ +/** + * Faucet probe. + * + * The Unicity test faucet (`https://faucet.unicity.network`) issues real + * test tokens to a wallet identified by its registered nametag. Many e2e + * suites in the SDK and downstream consumers (uxf-send-receive, + * pointer-roundtrip, migrate-to-profile-conservation, profile-export- + * roundtrip) rely on the faucet to fund test wallets — when the faucet + * is down, those suites time out at the wallet-funding step with no + * useful diagnostic. Probing the faucet upfront converts a 240 s + * "Faucet top-up timed out" into a 1-2 s clean SKIP with a precise + * "faucet unreachable" message. + * + * Liveness check: + * 1. `POST /api/v1/faucet/request` with a deliberately-invalid + * nametag. Healthy faucet responds with HTTP 200 + structured + * `{success: false, error: "Nametag not found: …"}`. The server + * processed the request and rejected it cleanly — proving the + * whole pipeline (HTTP, body parse, nametag-resolve via Nostr, + * response shaping) is functional WITHOUT consuming actual + * faucet quota or requiring a real wallet. + * + * Why we don't actually issue tokens: + * The faucet is rate-limited per source IP. Probing with a real + * request would either consume quota that legitimate users need, or + * (worse) get our probe blacklisted as abusive after too many runs. + * The "invalid nametag" path exercises the same code paths the + * real-issue path uses (HTTP routing, JSON validation, nametag + * resolver, error shaping) but short-circuits before any token mint. + * + * Functional cross-check (best-effort): + * Some operators expose a `/health` or `/api/v1/health` endpoint. + * When present, we probe it as a secondary check. Absence is NOT + * treated as a failure (no spec mandates this endpoint exists). + */ + +const DEFAULT_FAUCET_URL = 'https://faucet.unicity.network'; + +/** + * Deterministic invalid nametag used by the probe. Must: + * - Not exist on any Nostr relay (so resolve fails cleanly) + * - Not collide with any legitimate user's nametag (so we don't + * accidentally mint tokens to someone else's wallet if a future + * change loosens validation) + * + * Choice: a "infra-probe-" prefix + suffix that's both clearly + * synthetic and unlikely to be picked by a human user. Updated only + * if the faucet adds a more direct probe endpoint that lets us drop + * this hack. + */ +const PROBE_NAMETAG = 'infra-probe-do-not-mint-zk7q3xa9p2v'; + +export async function probeFaucet(url = DEFAULT_FAUCET_URL, { timeoutMs = 10_000 } = {}) { + const checks = []; + const overallStart = Date.now(); + const base = url.replace(/\/+$/, ''); + + const finalize = (status, error) => ({ + service: 'faucet', + endpoint: url, + status, + latencyMs: Date.now() - overallStart, + checks, + error, + timestamp: new Date().toISOString(), + }); + + // ---- 1. POST /api/v1/faucet/request with an invalid nametag ---- + // + // Healthy: HTTP 200 + body matches `{success: false, error|message: }`. + // The server processed the request, parsed the body, attempted nametag + // resolution, found no match, and shaped a clean error response. Every + // layer of the request-issue pipeline ran. This is a stronger signal + // than a generic /health endpoint because it exercises the actual + // user-facing API path. + const probeStart = Date.now(); + try { + const response = await postJson( + `${base}/api/v1/faucet/request`, + { + unicityId: PROBE_NAMETAG, + coin: 'unicity-usd', + amount: 1, + }, + timeoutMs, + ); + const latencyMs = Date.now() - probeStart; + // The faucet server returns HTTP 400 (or similar 4xx) for the + // invalid-nametag rejection along with the structured + // `{success: false, error: ...}` body. That's the CORRECT API + // contract — 4xx + structured error proves the request handler is + // alive and processing payloads. We only treat 5xx (server error) + // and connection failures as 'unreachable'. Network failure is + // caught by the outer catch. + if (response.status >= 500) { + const text = await response.text().catch(() => ''); + checks.push({ + name: 'request', + status: 'fail', + latencyMs, + message: `HTTP ${response.status}: ${text.slice(0, 200)}`, + }); + return finalize('unreachable', `request returned HTTP ${response.status}`); + } + const json = await response.json().catch(() => null); + if (!json || typeof json !== 'object') { + checks.push({ + name: 'request', + status: 'fail', + latencyMs, + message: 'response was not JSON', + }); + return finalize('degraded', 'response shape malformed'); + } + // The probe nametag is deliberately invalid → expect success:false. + // success:true would mean the faucet ACTUALLY MINTED tokens, which + // would imply our probe nametag isn't actually invalid (rare: + // someone registered the same string) OR the faucet's validation + // is broken. Either way, that's degraded — flag it. + if (json.success === true) { + checks.push({ + name: 'request', + status: 'warn', + latencyMs, + message: 'faucet ACCEPTED probe-nametag mint — validation may be broken (latency=' + latencyMs + 'ms)', + }); + return finalize('degraded', 'faucet accepted invalid-nametag mint'); + } + if (json.success !== false) { + checks.push({ + name: 'request', + status: 'fail', + latencyMs, + message: `unexpected success field: ${typeof json.success}`, + }); + return finalize('degraded', 'response shape malformed'); + } + // Confirm the error/message field is populated — proves the server + // shaped a meaningful response rather than a no-op stub. + const errMsg = json.error || json.message || ''; + if (typeof errMsg !== 'string' || errMsg.length === 0) { + checks.push({ + name: 'request', + status: 'fail', + latencyMs, + message: 'success:false but no error/message field', + }); + return finalize('degraded', 'error field missing on rejection'); + } + checks.push({ + name: 'request', + status: latencyMs > 5_000 ? 'warn' : 'pass', + latencyMs, + message: `cleanly rejected probe-nametag (${latencyMs}ms; "${errMsg.slice(0, 80)}")`, + }); + } catch (err) { + checks.push({ + name: 'request', + status: 'fail', + latencyMs: Date.now() - probeStart, + message: errMsg(err), + }); + return finalize('unreachable', errMsg(err)); + } + + // ---- 2. /health (functional cross-check, best-effort) ---- + // + // Not part of the spec — many faucet deployments don't expose this. + // When present, slow /health vs fast /request signals different + // backend pressure (e.g., the request handler is up but the storage + // layer is sick). When absent (404 or connection failure), we just + // skip — the request probe above is the authoritative liveness gate. + const healthStart = Date.now(); + try { + const response = await fetchWithTimeout(`${base}/health`, { method: 'GET' }, timeoutMs); + const latencyMs = Date.now() - healthStart; + if (response.ok) { + checks.push({ + name: 'health', + status: latencyMs > 2_000 ? 'warn' : 'pass', + latencyMs, + message: `HTTP ${response.status} (${latencyMs}ms)`, + }); + } else if (response.status === 404) { + // Operator hasn't exposed a health endpoint — not a failure. + checks.push({ + name: 'health', + status: 'pass', + latencyMs, + message: `no /health endpoint (HTTP 404) — request probe is authoritative`, + }); + } else { + checks.push({ + name: 'health', + status: 'warn', + latencyMs, + message: `HTTP ${response.status}`, + }); + } + } catch (err) { + // Health probe failure on its own is not fatal — request probe + // already succeeded. Demote to a warn-level check. + checks.push({ + name: 'health', + status: 'warn', + latencyMs: Date.now() - healthStart, + message: `unreachable: ${errMsg(err)}`, + }); + } + + const failed = checks.filter((c) => c.status === 'fail').length; + const slow = checks.filter((c) => c.status === 'warn').length; + const status = failed > 0 ? (failed >= 2 ? 'unreachable' : 'degraded') : slow > 0 ? 'degraded' : 'healthy'; + return finalize(status); +} + +async function postJson(url, payload, timeoutMs) { + return fetchWithTimeout( + url, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }, + timeoutMs, + ); +} + +async function fetchWithTimeout(url, init, timeoutMs) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } catch (err) { + if (err.name === 'AbortError') throw new Error(`request timed out after ${timeoutMs}ms`); + throw err; + } finally { + clearTimeout(timer); + } +} + +function errMsg(err) { + return err instanceof Error ? err.message : String(err); +} diff --git a/tests/smoke.test.mjs b/tests/smoke.test.mjs index 1868fa4..e230540 100644 --- a/tests/smoke.test.mjs +++ b/tests/smoke.test.mjs @@ -108,6 +108,14 @@ test('every NETWORKS entry has the required endpoint set', () => { for (const key of REQUIRED) { assert.ok(cfg[key] !== undefined, `${name}.${key} missing`); } + // `faucet` is intentionally NULLABLE per network: mainnet has no + // faucet, testnet/dev do. The probe layer treats `null` as a clean + // skip (see runProbes thunk for 'faucet'). Assert the field is + // PRESENT (defined) but accept null as a valid value. + assert.ok('faucet' in cfg, `${name}.faucet must be declared (null on mainnet, URL elsewhere)`); + if (cfg.faucet !== null) { + assert.match(cfg.faucet, /^https?:\/\//, `${name}.faucet must be HTTP(S) URL or null`); + } assert.ok(Array.isArray(cfg.nostrRelays) && cfg.nostrRelays.length > 0, `${name}.nostrRelays must be non-empty`); assert.ok(Array.isArray(cfg.ipfsGateways) && cfg.ipfsGateways.length > 0, `${name}.ipfsGateways must be non-empty`); assert.match(cfg.aggregator, /^https?:\/\//); @@ -116,6 +124,12 @@ test('every NETWORKS entry has the required endpoint set', () => { } }); +test('mainnet has no faucet by design; testnet and dev do', () => { + assert.equal(NETWORKS.mainnet.faucet, null, 'mainnet must explicitly opt out of faucet'); + assert.equal(typeof NETWORKS.testnet.faucet, 'string', 'testnet must declare a faucet URL'); + assert.equal(typeof NETWORKS.dev.faucet, 'string', 'dev must declare a faucet URL'); +}); + test('DEFAULT_AGGREGATOR_API_KEY and DEFAULT_LATENCY_THRESHOLDS are exported', () => { assert.equal(typeof DEFAULT_AGGREGATOR_API_KEY, 'string'); assert.ok(DEFAULT_AGGREGATOR_API_KEY.length > 0); @@ -127,8 +141,8 @@ test('DEFAULT_AGGREGATOR_API_KEY and DEFAULT_LATENCY_THRESHOLDS are exported', ( // SERVICES + verdict logic // --------------------------------------------------------------------------- -test('SERVICES enumerates all five canonical services in stable order', () => { - assert.deepEqual(SERVICES, ['nostr', 'aggregator', 'ipfs', 'fulcrum', 'market']); +test('SERVICES enumerates all six canonical services in stable order', () => { + assert.deepEqual(SERVICES, ['nostr', 'aggregator', 'ipfs', 'fulcrum', 'market', 'faucet']); }); test('exitCodeForReport: all healthy → 0', () => { From 999cdade9a76b6981d38bd51e50f31269399804f Mon Sep 17 00:00:00 2001 From: Vladimir Rogojin Date: Wed, 20 May 2026 14:17:10 +0200 Subject: [PATCH 2/5] fix(aggregator): classify submit_commitment/get_inclusion_proof failures as unreachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verdict logic counted ALL failed checks uniformly — one fail = degraded, two fails = unreachable. That treated submit_commitment (the canonical write-path functional check the aggregator exists to serve) as no more important than a diagnostic JSON-RPC plane probe. Symptom: testnet returns HTTP 401 Unauthorized on submit_commitment while /health and get_block_height keep working. Old verdict: `DEGRADED` (exit 1). Downstream e2e pre-flight gates that only fire on exit code 2 silently let test suites run against an aggregator that cannot accept commitments, producing 35 phantom `Submit failed: [object Object]` failures on the sphere-sdk side (companion issue: sphere-sdk #191). Fix: - Mark submit_commitment + get_inclusion_proof checks with `critical: true`. - Extract verdict computation into a pure `computeAggregatorStatus(checks)` function, exported so the rule is testable network-free. - Rule: any critical-check fail → 'unreachable'. Non-critical fails follow the legacy "≥ 2 → unreachable, exactly 1 → degraded" rule. Warns continue to drive 'degraded'. - 8 new smoke tests pin the rule: healthy / non-critical degraded / warn / critical-fail unreachable (both checks independently) / multi-fail legacy preserved / critical-fail-wins-over-warn / explicit critical:false. Encodes the CLAUDE.md "False-negative discipline" principle directly: the functional layer "is what catches real outages that liveness misses" — and when it catches them, the verdict MUST reflect that the service is unusable for its intended purpose, not merely slow. Bumps to 0.4.1. --- CHANGELOG.md | 15 +++++++ package.json | 2 +- src/probes/aggregator.mjs | 55 +++++++++++++++++++++-- tests/smoke.test.mjs | 91 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 159 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8254c52..785916a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to `@unicitylabs/infra-probe` are documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to [Semantic Versioning](https://semver.org/). +## [0.4.1] — 2026-05-20 + +### Fixed + +- **Aggregator false-negative — broken write path reported as `degraded` (exit 1) instead of `unreachable` (exit 2).** A real testnet outage (HTTP 401 Unauthorized on `submit_commitment`) was classified as merely "degraded" because the verdict counted ALL failed checks uniformly: one fail → degraded, two fails → unreachable. The canonical functional check was no more important than a diagnostic JSON-RPC plane probe. Downstream e2e pre-flight gates that only fired on exit code 2 silently let test suites run against an aggregator that couldn't accept commitments, producing 35 phantom `Submit failed: [object Object]` failures on the SDK side (sphere-sdk issue #191 is the companion fix that makes those failures legible). + - **Fix:** `submit_commitment` and `get_inclusion_proof` checks now carry a `critical: true` field. The aggregator probe's verdict computation has been extracted into a pure `computeAggregatorStatus(checks)` function and exported for testability. The rule: any critical-check fail → `unreachable`, regardless of how many liveness checks pass. The legacy "≥ 2 fails → unreachable" rule is preserved for non-critical checks. + - **Test coverage:** 8 new network-free unit tests pin the rule in `tests/smoke.test.mjs` — healthy path, single-fail degraded, multi-fail unreachable, each critical check independently, explicit `critical: false`, and the warn-vs-critical-fail interaction. + +### Notes + +- The `critical: true` marker on a check is the design language going forward for "this check IS the gate". Other probes (nostr publish, ipfs add+fetch roundtrip, fulcrum tx-index) may adopt the same pattern in follow-up work where their functional-check failures should similarly drive `unreachable`. This release scopes the change tight to the reported regression. +- The aggregator probe's `get_inclusion_proof` check is currently too lenient — it accepts any `result` object as success, including responses that don't actually prove the just-submitted commitment landed. That's a separate looseness worth tightening (see the live-probe output: a check that returns success while submit_commitment fails with HTTP 401 is unreliable). Deferred to a follow-up commit since the user-visible classification is already fixed. + ## [0.4.0] — 2026-05-04 ### Added @@ -56,6 +69,8 @@ Functional probes added (write+read+verify across all 5 services). Not published Initial release. Liveness-only probes for nostr, aggregator, ipfs, fulcrum. Pretty + JSON output. Documented exit codes. Not published to npm. +[0.4.1]: https://github.com/unicitynetwork/infra-probe/releases/tag/v0.4.1 +[0.4.0]: https://github.com/unicitynetwork/infra-probe/releases/tag/v0.4.0 [0.3.0]: https://github.com/unicitynetwork/infra-probe/releases/tag/v0.3.0 [0.2.0]: https://github.com/unicitynetwork/infra-probe/releases/tag/v0.2.0 [0.1.0]: https://github.com/unicitynetwork/infra-probe/releases/tag/v0.1.0 diff --git a/package.json b/package.json index 630434c..be6e030 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@unicitylabs/infra-probe", - "version": "0.4.0", + "version": "0.4.1", "description": "Availability + performance probe for Unicity Network infrastructure (Nostr relay, Aggregator, IPFS gateway, L1 Fulcrum, Market, Faucet). Pre-flight check for e2e tests; CI-friendly JSON output.", "type": "module", "license": "MIT", diff --git a/src/probes/aggregator.mjs b/src/probes/aggregator.mjs index 7b910fc..43fe480 100644 --- a/src/probes/aggregator.mjs +++ b/src/probes/aggregator.mjs @@ -117,6 +117,7 @@ export async function probeAggregator(url, { timeoutMs = 10_000, apiKey = DEFAUL checks.push({ name: 'submit_commitment', status: 'fail', + critical: true, latencyMs, message: json?.error ? `aggregator rejected: ${typeof json.error === 'string' ? json.error : json.error?.message ?? JSON.stringify(json.error)}` @@ -129,12 +130,13 @@ export async function probeAggregator(url, { timeoutMs = 10_000, apiKey = DEFAUL checks.push({ name: 'submit_commitment', status: latencyMs > 3_000 ? 'warn' : 'pass', + critical: true, latencyMs, message: `accepted (status=${status}, ${latencyMs}ms)`, }); } } catch (err) { - checks.push({ name: 'submit_commitment', status: 'fail', latencyMs: Date.now() - submitStart, message: errMsg(err) }); + checks.push({ name: 'submit_commitment', status: 'fail', critical: true, latencyMs: Date.now() - submitStart, message: errMsg(err) }); } // ---- 4. get_inclusion_proof for the just-submitted commitment ---- @@ -169,6 +171,7 @@ export async function probeAggregator(url, { timeoutMs = 10_000, apiKey = DEFAUL checks.push({ name: 'get_inclusion_proof', status: latencyMs > 3_000 ? 'warn' : 'pass', + critical: true, latencyMs, message: `proof returned in ${latencyMs}ms`, }); @@ -176,16 +179,62 @@ export async function probeAggregator(url, { timeoutMs = 10_000, apiKey = DEFAUL checks.push({ name: 'get_inclusion_proof', status: 'fail', + critical: true, latencyMs, message: `no proof within ${latencyMs}ms (last: ${lastErr ?? 'unknown'})`, }); } } + return finalize(computeAggregatorStatus(checks)); +} + +/** + * Compute the aggregate verdict for the aggregator probe from its checks. + * + * Exported for unit testing the rule in isolation (network-free). + * + * **Rule** (encodes the CLAUDE.md "False-negative discipline" principle — + * functional checks are authoritative for the verdict): + * + * 1. If ANY check marked `critical: true` is `fail` → `unreachable`. + * A wallet cannot transact through this aggregator regardless of how + * many liveness checks pass. `submit_commitment` and + * `get_inclusion_proof` are the canonical critical checks: they + * exercise the read/write code path real wallets depend on. Returning + * `degraded` here would let the CLI exit code 1 (degraded) instead of + * 2 (unreachable), so any e2e pre-flight script that only gates on + * `unreachable` would miss the outage and let tests run anyway — a + * false-negative that produces noise downstream (see the issue #191 + * companion report on the sphere-sdk side: 35/35 nametag-mint e2e + * tests fail against a "degraded" testnet that's actually broken at + * the write path). + * + * 2. Else if ≥ 2 non-critical checks fail → `unreachable`. Multiple + * simultaneous liveness failures suggest the service is broadly + * broken even if no single failure is critical. + * + * 3. Else if exactly 1 non-critical check fails OR any check is `warn` → + * `degraded`. + * + * 4. Else → `healthy`. + * + * The previous logic counted ALL fails uniformly (single fail = degraded, + * two fails = unreachable). That treated `submit_commitment` — the very + * write path the aggregator exists to serve — as no more important than a + * diagnostic JSON-RPC plane probe. Issue: a real 401-on-submit outage was + * reported as `degraded` (exit 1) instead of `unreachable` (exit 2), so + * the e2e preflight gate didn't fire and let the test suite run anyway, + * producing 35 phantom failures with opaque error messages. + */ +export function computeAggregatorStatus(checks) { + const criticalFail = checks.some((c) => c.critical === true && c.status === 'fail'); + if (criticalFail) return 'unreachable'; const failed = checks.filter((c) => c.status === 'fail').length; const slow = checks.filter((c) => c.status === 'warn').length; - const status = failed > 0 ? (failed >= 2 ? 'unreachable' : 'degraded') : slow > 0 ? 'degraded' : 'healthy'; - return finalize(status); + if (failed >= 2) return 'unreachable'; + if (failed > 0 || slow > 0) return 'degraded'; + return 'healthy'; } // --------------------------------------------------------------------------- diff --git a/tests/smoke.test.mjs b/tests/smoke.test.mjs index e230540..3ec356a 100644 --- a/tests/smoke.test.mjs +++ b/tests/smoke.test.mjs @@ -24,6 +24,7 @@ import { dirname, join } from 'node:path'; import { NETWORKS, DEFAULT_AGGREGATOR_API_KEY, DEFAULT_LATENCY_THRESHOLDS } from '../src/networks.mjs'; import { SERVICES, exitCodeForReport } from '../src/index.mjs'; import { renderJson, renderPretty } from '../src/output.mjs'; +import { computeAggregatorStatus } from '../src/probes/aggregator.mjs'; const here = dirname(fileURLToPath(import.meta.url)); const BIN = join(here, '..', 'bin', 'unicity-infra-probe.mjs'); @@ -160,6 +161,96 @@ test('exitCodeForReport: any unreachable → 2 (overrides degraded)', () => { assert.equal(exitCodeForReport(r), 2); }); +// --------------------------------------------------------------------------- +// Aggregator verdict — critical-check rule +// --------------------------------------------------------------------------- +// +// Pins the rule that `submit_commitment` / `get_inclusion_proof` failures +// drive the aggregator verdict to `unreachable` (CLI exit 2), not +// `degraded` (CLI exit 1). The previous behaviour counted all fails +// uniformly; a real 401-on-submit outage was reported as merely degraded, +// so e2e pre-flight gates didn't fire and let test suites run against a +// broken write path. +// +// See `computeAggregatorStatus` docstring for the full rule rationale. + +test('computeAggregatorStatus: all checks pass → healthy', () => { + const checks = [ + { name: 'health', status: 'pass' }, + { name: 'submit_commitment', status: 'pass', critical: true }, + { name: 'get_inclusion_proof', status: 'pass', critical: true }, + ]; + assert.equal(computeAggregatorStatus(checks), 'healthy'); +}); + +test('computeAggregatorStatus: a single non-critical fail → degraded', () => { + const checks = [ + { name: 'health', status: 'pass' }, + { name: 'json-rpc', status: 'fail' }, + { name: 'submit_commitment', status: 'pass', critical: true }, + ]; + assert.equal(computeAggregatorStatus(checks), 'degraded'); +}); + +test('computeAggregatorStatus: any warn → degraded', () => { + const checks = [ + { name: 'health', status: 'pass' }, + { name: 'submit_commitment', status: 'warn', critical: true }, + ]; + assert.equal(computeAggregatorStatus(checks), 'degraded'); +}); + +test('computeAggregatorStatus: critical fail → unreachable regardless of other passes', () => { + // The headline case — issue companion to sphere-sdk #191. An HTTP 401 + // on submit_commitment with everything else passing was reported as + // `degraded`; this test pins the new behaviour: any critical fail + // forces `unreachable`. + const checks = [ + { name: 'health', status: 'pass' }, + { name: 'json-rpc', status: 'pass' }, + { name: 'submit_commitment', status: 'fail', critical: true, message: 'HTTP 401' }, + { name: 'get_inclusion_proof', status: 'pass', critical: true }, + ]; + assert.equal(computeAggregatorStatus(checks), 'unreachable'); +}); + +test('computeAggregatorStatus: get_inclusion_proof critical fail → unreachable', () => { + const checks = [ + { name: 'health', status: 'pass' }, + { name: 'submit_commitment', status: 'pass', critical: true }, + { name: 'get_inclusion_proof', status: 'fail', critical: true }, + ]; + assert.equal(computeAggregatorStatus(checks), 'unreachable'); +}); + +test('computeAggregatorStatus: two non-critical fails → unreachable (legacy rule preserved)', () => { + const checks = [ + { name: 'health', status: 'fail' }, + { name: 'json-rpc', status: 'fail' }, + { name: 'submit_commitment', status: 'pass', critical: true }, + ]; + assert.equal(computeAggregatorStatus(checks), 'unreachable'); +}); + +test('computeAggregatorStatus: critical fail wins over warn-on-other-checks', () => { + const checks = [ + { name: 'health', status: 'warn' }, + { name: 'submit_commitment', status: 'fail', critical: true }, + ]; + assert.equal(computeAggregatorStatus(checks), 'unreachable'); +}); + +test('computeAggregatorStatus: explicit critical:false is treated as non-critical', () => { + // Defensive: someone could pass `critical: false` rather than omit it. + // A non-critical fail must NOT trigger the unreachable shortcut. + const checks = [ + { name: 'health', status: 'pass' }, + { name: 'json-rpc', status: 'fail', critical: false }, + { name: 'submit_commitment', status: 'pass', critical: true }, + ]; + assert.equal(computeAggregatorStatus(checks), 'degraded'); +}); + // --------------------------------------------------------------------------- // Output renderers — must not throw on either empty or full payloads // --------------------------------------------------------------------------- From 8eeed62b5a408492bcb4fa8cc072ed96a68ddc66 Mon Sep 17 00:00:00 2001 From: Vladimir Rogojin Date: Wed, 20 May 2026 17:44:13 +0200 Subject: [PATCH 3/5] feat: add 'local' network + accept standalone aggregator status='ok' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion work for the sphere-sdk hermetic e2e stack (tests/e2e/local-infra/) which boots a local aggregator in BFT_ENABLED=false standalone mode. Two changes layered on top of #1's critical-check verdict fix: 1. NETWORKS.local — endpoints for the docker-compose stack: - aggregator http://127.0.0.1:3001 - nostr ws://127.0.0.1:7777 - ipfs http://127.0.0.1:8082 Fulcrum/Market intentionally non-local (no local counterpart yet). Faucet null — the local faucet is DM-driven, not HTTP, so the existing faucet probe doesn't apply. 2. aggregator /health body parser accepts both shapes: - BFT mode: {"status":"healthy","database":"ok","aggregators":{...}} - Standalone mode: {"status":"ok","role":"standalone","details":{"database":"connected",...}} Previously the standalone shape was reported as `degraded` even when the aggregator was fully functional (submit_commitment + get_inclusion_proof both passing). Verified against the live local stack: unicity-infra-probe --network local --only aggregator → HEALTHY (4/4 checks passed), exit 0. Tests: 30/30 pass. Two new tests cover NETWORKS.local shape + NETWORKS.local.faucet === null. Bumps to 0.4.2. --- CHANGELOG.md | 13 +++++++++++++ package.json | 2 +- src/networks.mjs | 40 +++++++++++++++++++++++++++++++++++++++ src/probes/aggregator.mjs | 34 +++++++++++++++++++++++++++++---- tests/smoke.test.mjs | 16 ++++++++++++++-- 5 files changed, 98 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 785916a..6d2be3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to `@unicitylabs/infra-probe` are documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to [Semantic Versioning](https://semver.org/). +## [0.4.2] — 2026-05-20 + +### Added + +- **`local` network** in `NETWORKS` — endpoints for the hermetic local docker-compose stack at `tests/e2e/local-infra/` in sphere-sdk: aggregator `http://127.0.0.1:3001`, Nostr relay `ws://127.0.0.1:7777`, IPFS gateway `http://127.0.0.1:8082`. Fulcrum and Market still point at testnet (no local counterpart yet). Faucet is `null` because the local faucet is DM-driven (no HTTP /request endpoint), so the existing HTTP probe doesn't apply. + - Use: `unicity-infra-probe --network local --only aggregator,ipfs,nostr` to verify a local stack is healthy. + +### Fixed + +- **Aggregator `health` check false-negative against the standalone `BFT_ENABLED=false` mode.** The parser only accepted `{"status":"healthy", "database":"ok"}` (BFT mode) and rejected the standalone shape `{"status":"ok", "role":"standalone", "details":{"database":"connected"}}` as `degraded` even when the aggregator was fully functional. Now the parser accepts both shapes; the verdict drops to `degraded` only when the body genuinely reports unhealthy state. + - **Why two shapes:** the production aggregator runs behind BFT consensus and reports the per-shard / per-database matrix. The standalone aggregator (used by sphere-sdk's e2e local stack via `BFT_ENABLED=false`) reports a flat `{status:'ok', role:'standalone'}` because there are no shards or peer aggregators to enumerate. Both are correct for their mode. + ## [0.4.1] — 2026-05-20 ### Fixed @@ -69,6 +81,7 @@ Functional probes added (write+read+verify across all 5 services). Not published Initial release. Liveness-only probes for nostr, aggregator, ipfs, fulcrum. Pretty + JSON output. Documented exit codes. Not published to npm. +[0.4.2]: https://github.com/unicitynetwork/infra-probe/releases/tag/v0.4.2 [0.4.1]: https://github.com/unicitynetwork/infra-probe/releases/tag/v0.4.1 [0.4.0]: https://github.com/unicitynetwork/infra-probe/releases/tag/v0.4.0 [0.3.0]: https://github.com/unicitynetwork/infra-probe/releases/tag/v0.3.0 diff --git a/package.json b/package.json index be6e030..7a856b8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@unicitylabs/infra-probe", - "version": "0.4.1", + "version": "0.4.2", "description": "Availability + performance probe for Unicity Network infrastructure (Nostr relay, Aggregator, IPFS gateway, L1 Fulcrum, Market, Faucet). Pre-flight check for e2e tests; CI-friendly JSON output.", "type": "module", "license": "MIT", diff --git a/src/networks.mjs b/src/networks.mjs index b020941..1fbe714 100644 --- a/src/networks.mjs +++ b/src/networks.mjs @@ -49,6 +49,46 @@ export const NETWORKS = { marketApi: 'https://market-api.unicity.network', faucet: 'https://faucet.unicity.network', }, + // --------------------------------------------------------------------------- + // Local docker-compose stack — matches the endpoints exposed by the + // sphere-sdk e2e harness at `tests/e2e/local-infra/docker-compose.yml` + // when invoked with `E2E_FULL_LOCAL_STACK=1`. Use to preflight a + // hermetic test run against the in-process containers, e.g.: + // + // docker compose -f tests/e2e/local-infra/docker-compose.yml \ + // --profile full up -d + // unicity-infra-probe --network local --only aggregator,ipfs,nostr + // + // Fulcrum + market + faucet do NOT have local counterparts yet — they + // continue to point at testnet endpoints so a single probe command can + // surface "the local stack you booted is fine; one of the still-public + // services it depends on is broken." If you need a fully air-gapped + // probe, run with `--only nostr,aggregator,ipfs` (the three services + // the local stack actually provides). + // --------------------------------------------------------------------------- + local: { + label: 'Local (docker-compose)', + aggregator: 'http://127.0.0.1:3001', + nostrRelays: [ + 'ws://127.0.0.1:7777', + ], + ipfsGateways: [ + 'http://127.0.0.1:8082', + ], + // Fulcrum (L1) is not part of the local stack — keep the public + // testnet endpoint so the probe is still meaningful when invoked + // with `--only fulcrum`. Tests that need L1 against a hermetic + // stack are out of scope for now. + fulcrum: 'wss://fulcrum.unicity.network:50004', + marketApi: 'https://market-api.unicity.network', + // Local faucet (uxf-e2e-faucet) is spawned by the sphere-sdk + // harness outside compose. It listens for FAUCET_REQUEST DMs on + // the local relay rather than serving an HTTP /request endpoint, + // so the existing HTTP faucet probe doesn't apply. Set to null so + // the probe skips cleanly; tests that need the local faucet + // verify it via DM round-trip in their own pre-checks. + faucet: null, + }, }; /** diff --git a/src/probes/aggregator.mjs b/src/probes/aggregator.mjs index 43fe480..08dda5d 100644 --- a/src/probes/aggregator.mjs +++ b/src/probes/aggregator.mjs @@ -51,9 +51,27 @@ export async function probeAggregator(url, { timeoutMs = 10_000, apiKey = DEFAUL return finalize('unreachable', `health endpoint returned HTTP ${response.status}`); } const body = await response.json().catch(() => null); - const databaseOk = body?.database === 'ok'; - const allShardsOk = body?.aggregators ? Object.values(body.aggregators).every((v) => v === 'ok') : true; - const happy = body?.status === 'healthy' && databaseOk && allShardsOk; + // Two health-body shapes are normal: + // - BFT mode (testnet/mainnet): { status: 'healthy', database: 'ok', + // aggregators: { …shardId: 'ok' } } + // - Standalone mode (`BFT_ENABLED=false`, used by the e2e local + // stack): { status: 'ok', role: 'standalone', + // details: { database: 'connected', commitment_queue: '0' } } + // Both indicate a usable aggregator. Accept either field set so the + // probe doesn't false-fail against the local docker stack. + const isHealthyStr = body?.status === 'healthy' || body?.status === 'ok'; + const databaseOk = + body?.database === 'ok' || + body?.details?.database === 'connected' || + // Both legacy fields absent → assume OK (some standalone builds + // omit the database line entirely). Liveness has already been + // confirmed by the HTTP 200; the functional check below will catch + // any actual DB outage. + (body?.database === undefined && body?.details?.database === undefined); + const allShardsOk = body?.aggregators + ? Object.values(body.aggregators).every((v) => v === 'ok') + : true; // standalone has no shards + const happy = isHealthyStr && databaseOk && allShardsOk; checks.push({ name: 'health', status: happy ? (latencyMs > 2_000 ? 'warn' : 'pass') : 'fail', @@ -242,8 +260,16 @@ export function computeAggregatorStatus(checks) { // --------------------------------------------------------------------------- function formatHealthBody(body, latencyMs) { + // Two body shapes per the parser above. Surface whichever fields the + // running aggregator publishes; never print `undefined` to the + // operator. + const role = body?.role ? ` role=${body.role}` : ''; + const dbState = body?.database ?? body?.details?.database ?? 'unknown'; const shardCount = body?.aggregators ? Object.keys(body.aggregators).length : 0; - return `${body.status} (db ${body.database}, ${shardCount} shard${shardCount === 1 ? '' : 's'} ok, ${latencyMs}ms)`; + const shardSummary = body?.aggregators + ? `, ${shardCount} shard${shardCount === 1 ? '' : 's'} ok` + : ''; + return `${body.status}${role} (db ${dbState}${shardSummary}, ${latencyMs}ms)`; } const SHA256_ALG_BYTES = new Uint8Array([0x00, 0x00]); // DataHash imprint algorithm prefix for SHA256 diff --git a/tests/smoke.test.mjs b/tests/smoke.test.mjs index 3ec356a..e107d05 100644 --- a/tests/smoke.test.mjs +++ b/tests/smoke.test.mjs @@ -99,8 +99,19 @@ test('--only validates against known SERVICES', async () => { // networks.mjs — configuration completeness // --------------------------------------------------------------------------- -test('NETWORKS exposes mainnet, testnet, dev', () => { - assert.deepEqual(Object.keys(NETWORKS).sort(), ['dev', 'mainnet', 'testnet']); +test('NETWORKS exposes mainnet, testnet, dev, local', () => { + assert.deepEqual(Object.keys(NETWORKS).sort(), ['dev', 'local', 'mainnet', 'testnet']); +}); + +test('NETWORKS.local points at loopback for the docker-compose stack', () => { + const cfg = NETWORKS.local; + assert.match(cfg.aggregator, /127\.0\.0\.1/); + assert.ok(cfg.nostrRelays.every((r) => /127\.0\.0\.1/.test(r))); + assert.ok(cfg.ipfsGateways.every((g) => /127\.0\.0\.1/.test(g))); + // Fulcrum + market intentionally non-local — see networks.mjs comment. + assert.match(cfg.fulcrum, /^wss?:\/\//); + // Faucet null because the local faucet is DM-driven (no HTTP probe). + assert.equal(cfg.faucet, null); }); test('every NETWORKS entry has the required endpoint set', () => { @@ -129,6 +140,7 @@ test('mainnet has no faucet by design; testnet and dev do', () => { assert.equal(NETWORKS.mainnet.faucet, null, 'mainnet must explicitly opt out of faucet'); assert.equal(typeof NETWORKS.testnet.faucet, 'string', 'testnet must declare a faucet URL'); assert.equal(typeof NETWORKS.dev.faucet, 'string', 'dev must declare a faucet URL'); + assert.equal(NETWORKS.local.faucet, null, 'local has a DM-driven faucet, not HTTP'); }); test('DEFAULT_AGGREGATOR_API_KEY and DEFAULT_LATENCY_THRESHOLDS are exported', () => { From b5505c0ccc35b5793bf873b586fcd1495fa7f01e Mon Sep 17 00:00:00 2001 From: Vladimir Rogojin Date: Sat, 23 May 2026 11:59:18 +0200 Subject: [PATCH 4/5] docs: sync CLAUDE.md with v0.4.x surface (faucet, local network, critical checks) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent-facing guide had drifted from the code: faucet probe added in 0.4.0, `local` docker-compose network in 0.4.2, and the aggregator critical-check verdict rule (sphere-sdk #191 follow-up) were all documented in commits but not in the contributors' single source of truth. New agents were re-deriving these from git log — which is what this file exists to prevent. Also adds the required Claude Code header, a Common commands section, and pins the `faucet: null` clean-skip pattern so the next "optional service" addition follows precedent instead of inventing a new convention. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 40 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b6ccb51..eb87bba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,6 @@ -# CLAUDE.md — Project context for AI coding agents +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file gives an AI agent everything it needs to work on `@unicitylabs/infra-probe` without re-deriving the design from scratch. Read this before making any non-trivial changes. @@ -11,9 +13,26 @@ This file gives an AI agent everything it needs to work on `@unicitylabs/infra-p - **IPFS gateway** (Kubo HTTP API + `/ipfs/*` path) - **L1 Fulcrum** (Electrum-protocol over WSS, the Unicity ALPHA blockchain front) - **Market / Intent database** (semantic-search REST API) +- **Faucet** (HTTP `/request` endpoint — testnet/dev only) It is the canonical pre-flight gate for any e2e test suite that hits the live testnet/mainnet, and a hand-tool smoke test when something feels off. +## Common commands + +```sh +npm test # full smoke suite (network-free) +node --test tests/smoke.test.mjs # same, explicit +node --test --test-name-pattern='computeAggregatorStatus' tests/smoke.test.mjs # single test +npm start # probe testnet (default) +npm run probe:testnet # explicit testnet +npm run probe:mainnet # explicit mainnet +npm run probe:json # JSON output +node ./bin/unicity-infra-probe.mjs --network local --only nostr,aggregator,ipfs # local docker-compose stack +node ./bin/unicity-infra-probe.mjs --help # full CLI surface +``` + +Networks: `mainnet`, `testnet` (default), `dev`, `local`. The `local` profile points at the docker-compose stack in sphere-sdk's e2e harness (`E2E_FULL_LOCAL_STACK=1`); only `nostr`, `aggregator`, and `ipfs` have local counterparts — the rest still point at testnet so a single command can surface "your local stack is fine; the public service it depends on isn't." + ## What it explicitly is *not* - Not a continuous-monitoring system (use Grafana/Prometheus for that). @@ -52,7 +71,8 @@ unicity-infra-probe/ │ ├── aggregator.mjs │ ├── ipfs.mjs │ ├── fulcrum.mjs -│ └── market.mjs +│ ├── market.mjs +│ └── faucet.mjs └── tests/ └── smoke.test.mjs # Node --test runner; CI-friendly, no network ``` @@ -65,7 +85,7 @@ Every probe function returns this shape (extra service-specific fields are allow ```js { - service: string, // 'nostr' | 'aggregator' | 'ipfs' | 'fulcrum' | 'market' | ... + service: string, // 'nostr' | 'aggregator' | 'ipfs' | 'fulcrum' | 'market' | 'faucet' | ... endpoint: string, // human-readable URL status: 'healthy' | 'degraded' | 'unreachable' | 'error', latencyMs: number, // overall probe wall-clock @@ -75,6 +95,7 @@ Every probe function returns this shape (extra service-specific fields are allow status: 'pass' | 'fail' | 'warn', latencyMs?: number, message?: string, + critical?: boolean, // see "Critical checks" below ... // service-specific fields fine (e.g. eventCount) } ], @@ -84,6 +105,10 @@ Every probe function returns this shape (extra service-specific fields are allow } ``` +### Critical checks (verdict-driving) + +Some checks are flagged `critical: true` because their failure means the service is effectively unusable for its primary purpose, even if other checks pass. The canonical example is the aggregator: an HTTP 401 on `submit_commitment` while `health` returns 200 is **unreachable**, not degraded — wallets cannot transact. The legacy "count fails" rule is preserved as a fallback (≥2 fails → unreachable), but any single critical fail short-circuits to `unreachable`. See `computeAggregatorStatus` in `src/probes/aggregator.mjs` and the pinned tests in `tests/smoke.test.mjs` (`computeAggregatorStatus: *`). The motivating incident was sphere-sdk #191, where a degraded verdict let an e2e gate pass against a broken write path. + The status enum and check fields are **shape-stable public API**. Don't rename or repurpose them without bumping the major version. ## Verdict logic @@ -124,6 +149,15 @@ When in doubt, **probe with raw cURL or a minimal raw-WebSocket script first** t Latency thresholds are inherently per-service. A 3 s GET is degraded; a 3 s semantic-search call is healthy. Don't apply uniform thresholds across services. Each probe owns its own threshold logic; document the reasoning inline (see `src/probes/market.mjs` 10 s threshold for `search`). +## Network-specific opt-outs (`null` endpoints) + +A network may legitimately have no counterpart for a given service. Examples in `src/networks.mjs`: + +- `mainnet.faucet = null` — mainnet has no faucet by design. +- `local.faucet = null` — the local stack uses a DM-driven faucet (NIP-04 over the local relay), not HTTP. + +The orchestration layer (`runProbes` in `src/index.mjs`) interprets `null` as "skip this probe cleanly" — it emits a synthetic `healthy` result with a `config` check explaining the skip, rather than running the HTTP probe against a wrong URL and reporting a misleading `unreachable`. The smoke test `every NETWORKS entry has the required endpoint set` enforces that `faucet` is **declared** in every network (so the field is never silently missing), while accepting `null` as a valid value. When adding a new optional service, follow this pattern — don't default to a URL that doesn't apply. + ## Commit messages Conventional Commits, scope = service or area: From 00fb30d0bf878a089cb7e728a3b7ab3d4e9e5c86 Mon Sep 17 00:00:00 2001 From: Vladimir Rogojin Date: Sun, 24 May 2026 14:35:25 +0200 Subject: [PATCH 5/5] feat(faucet)!: rewrite probe as end-to-end mint-and-verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous faucet probe sent a deliberately-invalid nametag and treated the faucet's "Nametag not found" rejection as proof-of-life. That correctly exercised the HTTP/parse/resolve pipeline but couldn't catch the failure mode that actually matters to downstream e2e suites: the faucet accepts a real mint request, returns success:true, and yet no token ever lands at the recipient. A live testnet run also surfaced a confusing UX consequence — the "Nametag not found" string next to a green-tick check reads as a contradiction even when the verdict is correct, leading operators to distrust the probe. This commit replaces the rejection-handshake with the full mint round- trip. The probe now spins up an ephemeral Sphere wallet, mints a single-use nametag on the L3 aggregator, publishes the kind:30078 binding, requests 1 raw unit (1e-6) of USDU from the faucet, and waits up to 10s for the corresponding kind:31113 token-transfer event to arrive. The SDK handles NIP-04 decryption + Token deserialization. We then compare the delivered token's coinId + amount against the faucet's own HTTP-response declaration (amountInSmallestUnits) — independent proof the mint actually landed. The faucet has no probe-only mode and no direct-pubkey shortcut, so verifying real delivery requires running as a one-shot wallet. The trade-off taken to keep the implementation tractable was pulling in @unicitylabs/sphere-sdk as a dependency, which violates three of the project's "Hard rules" in CLAUDE.md (minimal-deps, no-SDK-coupling, stateless-on-relay). All three rules are now explicitly scoped down with "with one exception" carve-outs and a "The faucet exception" section that records the rationale and what to revisit if the faucet ever grows a probe-only mode. BREAKING CHANGE: the faucet probe's check names changed (request/health → wallet-setup/request/receipt) and all three are now critical:true. JSON consumers that filter on the previous check names will need to update. End-to-end wall-clock is now ~8–12s (up from <500ms); the orchestration layer auto-bumps the faucet's timeout ceiling to at least 30s. The probe now leaves a kind:30078 event on the Nostr relay + a nametag NFT on the L3 aggregator + consumes 1 USDU raw unit (≈ economically zero) per run. Documented in CLAUDE.md "Stateless on the relay/gateway side, with one exception". Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 21 ++ CLAUDE.md | 21 +- README.md | 20 +- package-lock.json | 737 +++++++++++++++++++++++++++++++++++++++++- package.json | 6 +- src/index.mjs | 12 +- src/probes/faucet.mjs | 523 ++++++++++++++++++++---------- tests/smoke.test.mjs | 27 ++ 8 files changed, 1180 insertions(+), 187 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d2be3a..ea9ffd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ All notable changes to `@unicitylabs/infra-probe` are documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to [Semantic Versioning](https://semver.org/). +## [0.5.0] — 2026-05-24 + +### Changed (breaking) + +- **Faucet probe rewritten to do an actual end-to-end mint-and-verify.** Previous versions sent a deliberately-invalid nametag and accepted the faucet's `Nametag not found` rejection as proof-of-life — that correctly reports the HTTP request/parse/resolve pipeline, but cannot catch a faucet bug where the HTTP layer accepts a real mint request, returns `success:true`, and yet no token is ever delivered. The new probe drives the full happy path: + 1. Spin up an ephemeral Sphere wallet (in-memory storage adapter — still no disk writes), mint a single-use nametag on the L3 aggregator, publish the kind:30078 binding on the Nostr relay. + 2. POST `/api/v1/faucet/request` for 1 raw unit (1e-6) of USDU. Capture `data.amountInSmallestUnits` and `data.requestId` from the response. + 3. Subscribe to `transfer:incoming` on the wallet and wait up to 10 s for a kind:31113 token-transfer event addressed to our pubkey. The SDK handles NIP-04 decryption + Token deserialization. + 4. Assert the delivered token's `coinId == USDU` and `amount == amountInSmallestUnits` from the HTTP response — independent proof the mint actually landed. +- **Check names changed**: `request` + `health` → `wallet-setup` + `request` + `receipt`. All three are `critical:true` so a failure on any drives the verdict to `unreachable`. Existing JSON consumers that filtered on the previous check names will see different keys and need to update. +- **Faucet probe now depends on `@unicitylabs/sphere-sdk` (≈ 74 MB install).** This violates the project's "Minimal dependencies" hard rule, accepted as a scoped exception because re-implementing the wallet+token+nametag stack from scratch would be ~300 LoC of mirrored SDK code touching half a dozen wire formats. The other five probes still operate on raw formats with only `ws` + `@noble/curves`. Rationale and trade-offs recorded in [CLAUDE.md](./CLAUDE.md#the-faucet-exception). +- **End-to-end faucet probe wall-clock is now ~8–12 s** (up from <500 ms). Pre-flight gates with tight `--timeout` may need to relax; the orchestration layer auto-bumps the faucet's per-probe ceiling to at least 30 s for this reason. + +### Added + +- **Per-run cost (intentional):** the faucet probe now leaves one kind:30078 event on the Nostr relay + one nametag NFT on the L3 aggregator + consumes 1 USDU raw unit (1e-6 USDU) from faucet quota per run. Documented in CLAUDE.md "Stateless on the relay/gateway side, with one exception". Pace probe runs accordingly. + +### Why + +A live testnet run on 2026-05-23 showed the previous faucet probe reporting `✓ request "Nametag not found"` — visually confusing to operators (the success tick next to an error-shaped message reads as a contradiction) AND non-authoritative (it didn't prove the faucet's send path actually delivers tokens). The new probe makes both clear: a healthy verdict means a real mint, signed by the faucet's pubkey, landed in a probe-controlled wallet within the receipt budget. + ## [0.4.2] — 2026-05-20 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index eb87bba..bccdef6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,10 +43,23 @@ Networks: `mainnet`, `testnet` (default), `dev`, `local`. The `local` profile po ## Hard rules - **No build step.** Source files are runnable Node.js ESM. No TypeScript, no transpiler, no bundler. If you find yourself wanting one, stop and reconsider — it'd compromise the "clone-and-run-anywhere" property. -- **Minimal dependencies.** Only `ws` and `@noble/curves` are allowed. Adding a third dependency requires a strong justification in the commit message. -- **No SDK coupling.** The probe must NOT import from `@unicitylabs/sphere-sdk` or `@unicitylabs/state-transition-sdk`. The wire formats those SDKs use are reverse-engineered into this repo's source so the probe stays independent of SDK release cycles. If a wire format changes upstream, mirror it here in plain code with a comment pointing back to the SDK source. -- **Network-only — no local state.** The probe never writes to disk, never reads config files. Every input is CLI args + env vars. Every output is stdout/stderr. -- **Stateless on the relay/gateway side too.** Probes use ephemeral keypairs, `?pin=false` for IPFS adds, etc., so a successful probe leaves no persisted artifact on the upstream service. +- **Minimal dependencies, with one exception.** The default-allowed deps are `ws` and `@noble/curves`. The faucet probe additionally depends on `@unicitylabs/sphere-sdk` (and its ~40 transitive deps) because the only way to verify a real mint round-trips end-to-end is to drive the full wallet path — see "The faucet exception" below. Adding any other third dependency requires a strong justification in the commit message. +- **No SDK coupling, with one exception.** Probes must NOT import from `@unicitylabs/sphere-sdk` or `@unicitylabs/state-transition-sdk`. The wire formats those SDKs use are reverse-engineered into this repo's source so the probe stays independent of SDK release cycles. **Exception:** `src/probes/faucet.mjs` uses sphere-sdk directly — see "The faucet exception" below. +- **Network-only — no local state.** The probe never writes to disk, never reads config files. Every input is CLI args + env vars. Every output is stdout/stderr. The faucet probe upholds this even while using sphere-sdk (it supplies an `InMemoryStorage` adapter to satisfy the SDK's `StorageProvider` interface without touching the filesystem; see the class at the bottom of `src/probes/faucet.mjs`). +- **Stateless on the relay/gateway side too, with one exception.** Probes use ephemeral keypairs, `?pin=false` for IPFS adds, etc., so a successful probe leaves no persisted artifact on the upstream service. **Exception:** the faucet probe leaves a kind:30078 nametag-binding event on the Nostr relay and a nametag NFT on the L3 aggregator per run. Both are accepted costs of end-to-end verification. + +### The faucet exception + +The faucet has no probe-only mode and no direct-pubkey path — it requires a `unicityId` (nametag) that resolves to a registered identity. Verifying that a real mint actually lands therefore requires the probe to *be* a (one-shot) Unicity wallet: generate a mnemonic, mint a nametag on the L3 aggregator, publish the kind:30078 binding event on the relay, issue the faucet request against that nametag, and wait for the kind:31113 transfer event to arrive. + +Re-implementing that from scratch is ~300 LoC of mirrored SDK code touching half a dozen wire formats (RequestId, Authenticator, NIP-04 ECDH+AES, Token deserialization, TokenCoinData parsing, ...). Pulling in `@unicitylabs/sphere-sdk` is the pragmatic alternative even though it's a ~74 MB install with ~40 transitive deps. The faucet probe is the ONE place this trade-off is taken. All other probes (`nostr`, `aggregator`, `ipfs`, `fulcrum`, `market`) MUST keep operating on raw wire formats with only `ws` + `@noble/curves`. + +Cost per faucet probe run, for downstream operators to factor into how often they run it: +- One nametag NFT minted on the L3 aggregator (unreclaimed) +- One USDU raw unit (1e-6 USDU) consumed from faucet quota +- One kind:30078 event left on the Nostr relay + +If the faucet ever grows a probe-only mode (no nametag resolve required, or a no-quota coin), revisit this exception and pull sphere-sdk back out. ## Folder layout (canonical — don't reorganise without strong reason) diff --git a/README.md b/README.md index 8fa7de1..3ad80de 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,10 @@ Designed as a **pre-flight gate** for end-to-end test suites and a **5-second sm Status: HEALTHY (2/2 checks passed) ✅ faucet https://faucet.unicity.network - ✓ request 168ms cleanly rejected probe-nametag (168ms; "Nametag not found: …") - ✓ health 13ms HTTP 200 (13ms) - Status: HEALTHY (2/2 checks passed) + ✓ wallet-setup 3544ms nametag 'p-rpvugo1h9x' minted + binding published (3544ms) + ✓ request 5340ms faucet accepted (requestId=9114626, will send 1000000 raw unicity-usd, 5340ms) + ✓ receipt 331ms USDU 1000000 raw unit(s) received (transfer.id=c2092dd2c1b3, 331ms) + Status: HEALTHY (3/3 checks passed) Summary: 6 HEALTHY, 0 DEGRADED, 0 UNREACHABLE (of 6) ``` @@ -178,6 +179,19 @@ The JSON output is **shape-stable** — service names, check names, status enums **Functional:** 2. **feed-recent** — `GET /api/feed/recent`. Cross-checks the search engine against the raw feed. If search works but feed/recent doesn't, the embedding pipeline is sick; if both work, the database is fully online. +### Faucet + +Drives the **full mint-and-verify path** rather than poking the HTTP layer with a bogus nametag. The faucet has no probe-only mode and no direct-pubkey shortcut, so verifying real delivery requires running the probe as a one-shot Unicity wallet. + +**Functional (end-to-end):** +1. **wallet-setup** — generates an ephemeral mnemonic, mints a single-use nametag (`p-`) on the L3 aggregator, and publishes the kind:30078 binding event on the Nostr relay. Without a registered binding, the faucet's resolver returns `Nametag not found` — so this step is the gate everything else hangs on. +2. **request** — `POST /api/v1/faucet/request` for 1 raw unit (1e-6) of USDU. Healthy: HTTP 200 + `success:true` + a `requestId`. We capture the faucet's claim about what it will send (`amountInSmallestUnits`) for the next check. +3. **receipt** — subscribes to `transfer:incoming` on the wallet and waits up to 10 s for a kind:31113 token-transfer event addressed to our pubkey. The SDK handles NIP-04 decryption + Token deserialization. We assert the delivered token's `coinId == USDU` and `amount == amountInSmallestUnits` from step 2. A mismatch here means the faucet's HTTP response is lying about what its async send actually delivered. + +Cost per probe run: one nametag NFT minted on the L3 (unreclaimed), one USDU raw unit (≈ economically zero) from faucet quota, one kind:30078 event left on the relay. End-to-end wall-clock is typically 8–12 s — pace probe runs accordingly. The faucet probe needs both `--api-key` (or the SDK default) and the network's Nostr relay + aggregator to be reachable; if either is down, this probe reports `unreachable` even when the faucet itself is fine. See the other services' verdicts in the same report to disambiguate. + +This is the only probe that depends on `@unicitylabs/sphere-sdk`. Rationale and trade-offs documented in [CLAUDE.md](./CLAUDE.md#the-faucet-exception). + ## Embedding ```js diff --git a/package-lock.json b/package-lock.json index fc318d2..5d77840 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "@unicitylabs/infra-probe", - "version": "0.1.0", + "version": "0.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@unicitylabs/infra-probe", - "version": "0.1.0", + "version": "0.4.2", "license": "MIT", "dependencies": { "@noble/curves": "^1.6.0", + "@unicitylabs/sphere-sdk": "^0.7.2", "ws": "^8.18.0" }, "bin": { @@ -20,6 +21,178 @@ "node": ">=20.0.0" } }, + "node_modules/@chainsafe/is-ip": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@chainsafe/is-ip/-/is-ip-2.1.0.tgz", + "integrity": "sha512-KIjt+6IfysQ4GCv66xihEitBjvhU/bixbbbFxdJ1sqCp4uJ0wuZiYBPhksZoy4lfaF0k9cwNzY5upEW/VWdw3w==", + "license": "MIT", + "optional": true + }, + "node_modules/@dnsquery/dns-packet": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@dnsquery/dns-packet/-/dns-packet-6.1.1.tgz", + "integrity": "sha512-WXTuFvL3G+74SchFAtz3FgIYVOe196ycvGsMgvSH/8Goptb1qpIQtIuM4SOK9G9lhMWYpHxnXyy544ZhluFOew==", + "license": "MIT", + "optional": true, + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.4", + "utf8-codec": "^1.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", + "license": "MIT", + "optional": true + }, + "node_modules/@libp2p/crypto": { + "version": "5.1.18", + "resolved": "https://registry.npmjs.org/@libp2p/crypto/-/crypto-5.1.18.tgz", + "integrity": "sha512-sCm+dFFZmH4LJIHTCzPy7+EBRhzkndFUcIU8bui6iaxK6SDSRVa11+/O6DzW8hn/U9LgDXe6jXnzWM8bM7OoCA==", + "license": "Apache-2.0 OR MIT", + "optional": true, + "dependencies": { + "@libp2p/interface": "^3.2.2", + "@noble/curves": "^2.0.1", + "@noble/hashes": "^2.0.1", + "multiformats": "^13.4.0", + "protons-runtime": "^6.0.1", + "uint8arraylist": "^2.4.8", + "uint8arrays": "^5.1.0" + } + }, + "node_modules/@libp2p/crypto/node_modules/@noble/curves": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", + "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@noble/hashes": "2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@libp2p/crypto/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@libp2p/interface": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@libp2p/interface/-/interface-3.2.2.tgz", + "integrity": "sha512-IU78g6uF8Ls0//4v9VE1rL5Jvy+i6I8LI/DssojFICbaDJSkL59Sn5XRfHrY5OCxTnUnUxnWK7pHz/3+UZcRNQ==", + "license": "Apache-2.0 OR MIT", + "optional": true, + "dependencies": { + "@multiformats/dns": "^1.0.6", + "@multiformats/multiaddr": "^13.0.1", + "main-event": "^1.0.1", + "multiformats": "^13.4.0", + "progress-events": "^1.1.0", + "uint8arraylist": "^2.4.8" + } + }, + "node_modules/@libp2p/logger": { + "version": "6.2.7", + "resolved": "https://registry.npmjs.org/@libp2p/logger/-/logger-6.2.7.tgz", + "integrity": "sha512-IVEz5+0kE4mRWwMzXP34AlXe2k1FLzBqKkjeASyhPVdMz0A4qH9nYmCBwonzmRzymklGjFIEDa1s7Vjhd9V4Rg==", + "license": "Apache-2.0 OR MIT", + "optional": true, + "dependencies": { + "@libp2p/interface": "^3.2.2", + "@multiformats/multiaddr": "^13.0.1", + "interface-datastore": "^9.0.1", + "multiformats": "^13.4.0", + "weald": "^1.1.0" + } + }, + "node_modules/@libp2p/peer-id": { + "version": "6.0.9", + "resolved": "https://registry.npmjs.org/@libp2p/peer-id/-/peer-id-6.0.9.tgz", + "integrity": "sha512-MlofyOOpxzZA4tIcOVPjd1FQIa0TA0g+bn+d08vfo9znCjfe6Lh0RBFyLdlICbyogVN6wn7+29SYch78wCuuCQ==", + "license": "Apache-2.0 OR MIT", + "optional": true, + "dependencies": { + "@libp2p/crypto": "^5.1.18", + "@libp2p/interface": "^3.2.2", + "multiformats": "^13.4.0", + "uint8arrays": "^5.1.0" + } + }, + "node_modules/@multiformats/dns": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@multiformats/dns/-/dns-1.0.13.tgz", + "integrity": "sha512-yr4bxtA3MbvJ+2461kYIYMsiiZj/FIqKI64hE4SdvWJUdWF9EtZLar38juf20Sf5tguXKFUruluswAO6JsjS2w==", + "license": "Apache-2.0 OR MIT", + "optional": true, + "dependencies": { + "@dnsquery/dns-packet": "^6.1.1", + "@libp2p/interface": "^3.1.0", + "hashlru": "^2.3.0", + "p-queue": "^9.0.0", + "progress-events": "^1.0.0", + "uint8arrays": "^5.0.2" + } + }, + "node_modules/@multiformats/multiaddr": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/@multiformats/multiaddr/-/multiaddr-13.0.3.tgz", + "integrity": "sha512-mEqqJ4r3a/uuFMTpRkU316wGNIDQNhuVWpm+ebKTQeYsfv9jXbPONWM6VVnj3KGUrwfsX7GZOyp4TFqEA2SPCw==", + "license": "Apache-2.0 OR MIT", + "optional": true, + "dependencies": { + "@chainsafe/is-ip": "^2.0.1", + "multiformats": "^14.0.0", + "uint8-varint": "^3.0.0", + "uint8arrays": "^6.1.1" + } + }, + "node_modules/@multiformats/multiaddr/node_modules/multiformats": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-14.0.0.tgz", + "integrity": "sha512-iWK1RrAS58p2NDfeZFuSUSv3ZPewTIhsGbh/5NgeGGJwJmRljLxGtjRR3nkn+loG3zl+IrfR/W1590QnrSK+Gg==", + "license": "Apache-2.0 OR MIT", + "optional": true + }, + "node_modules/@multiformats/multiaddr/node_modules/uint8arrays": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-6.1.1.tgz", + "integrity": "sha512-iz7JN0XCSZYA111lhFG2Ui9EhFvTNekqSRHw3lvMHq+dzwWy1OQftxFQREEh4rffU0oSoXdQHsk2TiHKVm4fsA==", + "license": "Apache-2.0 OR MIT", + "optional": true, + "dependencies": { + "multiformats": "^14.0.0" + } + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/curves": { "version": "1.9.7", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", @@ -47,6 +220,566 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@unicitylabs/nostr-js-sdk": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@unicitylabs/nostr-js-sdk/-/nostr-js-sdk-0.5.0.tgz", + "integrity": "sha512-v6qaZBkyuZzvfjyO/x+czqftYdAqxjyfBXipsB0QJIdqePBiWHR2aRs28zzJIhCMk1vP9HXzxGnpjRqlcnzroA==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "^1.0.0", + "@noble/curves": "^1.6.0", + "@noble/hashes": "^1.5.0", + "@scure/base": "^1.1.9", + "libphonenumber-js": "^1.11.14" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@unicitylabs/sphere-sdk": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@unicitylabs/sphere-sdk/-/sphere-sdk-0.7.2.tgz", + "integrity": "sha512-ecVTstgl/zILGcBELq8GrWPmd+0MECNjFzgPZb4T8IKCSQFu6asjc0KE0OWtsQvF8NJGYkz04LPTs9I7hdXNKA==", + "license": "MIT", + "dependencies": { + "@noble/curves": "^2.0.1", + "@noble/hashes": "^2.0.1", + "@unicitylabs/nostr-js-sdk": "^0.5.0", + "@unicitylabs/state-transition-sdk": "1.6.1-rc.f37cb85", + "bip39": "^3.1.0", + "buffer": "^6.0.3", + "canonicalize": "^3.0.0", + "crypto-js": "^4.2.0", + "elliptic": "^6.6.1" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@libp2p/crypto": "^5.1.13", + "@libp2p/peer-id": "^6.0.4", + "ipns": "^10.0.0", + "multiformats": "^13.4.2" + }, + "peerDependencies": { + "@libp2p/crypto": ">=5.0.0", + "@libp2p/peer-id": ">=6.0.0", + "ipns": ">=10.0.0", + "multiformats": ">=13.0.0", + "ws": ">=8.0.0" + }, + "peerDependenciesMeta": { + "@libp2p/crypto": { + "optional": true + }, + "@libp2p/peer-id": { + "optional": true + }, + "ipns": { + "optional": true + }, + "multiformats": { + "optional": true + }, + "ws": { + "optional": true + } + } + }, + "node_modules/@unicitylabs/sphere-sdk/node_modules/@noble/curves": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", + "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@unicitylabs/sphere-sdk/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@unicitylabs/state-transition-sdk": { + "version": "1.6.1-rc.f37cb85", + "resolved": "https://registry.npmjs.org/@unicitylabs/state-transition-sdk/-/state-transition-sdk-1.6.1-rc.f37cb85.tgz", + "integrity": "sha512-6chybquV+sZPdaqluJhAeceCWyO5SO2K2j8QI/RhN6cbX4wHILumfG3GKm20ubQZTL80yTfj85kMxsbKeUIGUQ==", + "license": "ISC", + "dependencies": { + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "uuid": "13.0.0" + } + }, + "node_modules/@unicitylabs/state-transition-sdk/node_modules/@noble/curves": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@unicitylabs/state-transition-sdk/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bip39": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/bip39/-/bip39-3.1.0.tgz", + "integrity": "sha512-c9kiwdk45Do5GL0vJMe7tS95VjCii65mYAH7DfWl3uW8AVzXKQVUm64i3hzVybBDMp9r7j9iNxR85+ul8MdN/A==", + "license": "ISC", + "dependencies": { + "@noble/hashes": "^1.2.0" + } + }, + "node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "license": "MIT" + }, + "node_modules/brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", + "license": "MIT" + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/canonicalize": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/canonicalize/-/canonicalize-3.0.0.tgz", + "integrity": "sha512-yYLfHyDMIXRyRqsKBRLX023riFLpXY2YOfdtqKXZRZy9qsfOJ9U+4F9YZL7MEzL5+ziN2x2nlBvY/Voi3EBljA==", + "license": "Apache-2.0", + "bin": { + "canonicalize": "bin/canonicalize.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cborg": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/cborg/-/cborg-5.1.1.tgz", + "integrity": "sha512-BDbSRIp6XrQXkTc7g+DN0RB9RrDPTUfals2ecWUlt3juPLjbAvy/V72mJcXY0Ehu0Dq/3WpNCOCT68HUTbW+lw==", + "license": "Apache-2.0", + "optional": true, + "bin": { + "cborg": "lib/bin.js" + } + }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, + "node_modules/elliptic": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", + "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.11.9", + "brorand": "^1.1.0", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.1", + "inherits": "^2.0.4", + "minimalistic-assert": "^1.0.1", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT", + "optional": true + }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "node_modules/hashlru": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/hashlru/-/hashlru-2.3.0.tgz", + "integrity": "sha512-0cMsjjIC8I+D3M44pOQdsy0OHXGLVz6Z0beRuufhKa0KfaD2wGwAev6jILzXsd3/vpnNQJmWyZtIILqM1N+n5A==", + "license": "MIT", + "optional": true + }, + "node_modules/hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", + "license": "MIT", + "dependencies": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/interface-datastore": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/interface-datastore/-/interface-datastore-9.0.3.tgz", + "integrity": "sha512-NLZa7Mp+0qn48nSwIY/C36da4uVIKzwG2tuEIpaSJArsuB2RrdyDWwkoDUyjsJ+VrMntXz38VSk9vXTx/ZUpAw==", + "license": "Apache-2.0 OR MIT", + "optional": true, + "dependencies": { + "interface-store": "^7.0.0", + "uint8arrays": "^5.1.0" + } + }, + "node_modules/interface-store": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/interface-store/-/interface-store-7.0.2.tgz", + "integrity": "sha512-KYOPcDH+1peaPhSeoZujR5nwkVeola1EdrnrlHTIM0HRNUs9B0aTsUQMH5kTmIjaQq1BOowoUyoCamgL8IMyww==", + "license": "Apache-2.0 OR MIT", + "optional": true + }, + "node_modules/ipns": { + "version": "10.1.6", + "resolved": "https://registry.npmjs.org/ipns/-/ipns-10.1.6.tgz", + "integrity": "sha512-mTACIdTBBXY5kbCo3t/M3ycNWbjajLrSXhTvjD0MEGyOUaI3uMv94JbJSHGaJ2xxRlpOrRk1ifToOsyPZiglnA==", + "license": "Apache-2.0 OR MIT", + "optional": true, + "dependencies": { + "@libp2p/crypto": "^5.0.0", + "@libp2p/interface": "^3.0.2", + "@libp2p/logger": "^6.0.4", + "cborg": "^5.1.0", + "interface-datastore": "^9.0.2", + "multiformats": "^13.2.2", + "protons-runtime": "^6.0.1", + "timestamp-nano": "^1.0.1", + "uint8arraylist": "^2.4.8", + "uint8arrays": "^5.1.0" + } + }, + "node_modules/libphonenumber-js": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.3.tgz", + "integrity": "sha512-xMkdAMqcyG7iN2WZZmGIfWbYxW4orRkny+0/AXIbwL0xll2zkDX0Vzo/BXFa6+7mh2UvJl9MbcTtHk0YXkFtBA==", + "license": "MIT" + }, + "node_modules/main-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/main-event/-/main-event-1.0.4.tgz", + "integrity": "sha512-sKazUjIy2Jalv5lkQ446iOcrx8Q7TkaCuk6xfnzg5uUqMusMLDMPmRDmSNE2kjSVpSTJo4j1bQZusS+Ib7Bvrg==", + "license": "Apache-2.0 OR MIT", + "optional": true + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "3.0.0-canary.202508261828", + "resolved": "https://registry.npmjs.org/ms/-/ms-3.0.0-canary.202508261828.tgz", + "integrity": "sha512-NotsCoUCIUkojWCzQff4ttdCfIPoA1UGZsyQbi7KmqkNRfKCrvga8JJi2PknHymHOuor0cJSn/ylj52Cbt2IrQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/multiformats": { + "version": "13.4.2", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.4.2.tgz", + "integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==", + "license": "Apache-2.0 OR MIT", + "optional": true + }, + "node_modules/p-queue": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.0.tgz", + "integrity": "sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang==", + "license": "MIT", + "optional": true, + "dependencies": { + "eventemitter3": "^5.0.4", + "p-timeout": "^7.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz", + "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/progress-events": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/progress-events/-/progress-events-1.1.0.tgz", + "integrity": "sha512-82DVc5tI36neVB3IjdXR11ztwGuoBc98em9ijzubeZKxI47OlV2Znq6mlPqE5xPDzO2Uw98GHiQSjj2favBCRQ==", + "license": "Apache-2.0 OR MIT", + "optional": true + }, + "node_modules/protons-runtime": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/protons-runtime/-/protons-runtime-6.0.2.tgz", + "integrity": "sha512-hiyjyANwGcgmzc+tXc1/ZcSZhKnl5MDjaVNWkISHBgadaU0sjTgKIKZMZ62d9J9zlSTyKHCs/osPkQ/3Z+7yeA==", + "license": "Apache-2.0 OR MIT", + "optional": true, + "dependencies": { + "uint8-varint": "^2.0.4", + "uint8arraylist": "^2.4.8", + "uint8arrays": "^5.1.0" + } + }, + "node_modules/protons-runtime/node_modules/uint8-varint": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/uint8-varint/-/uint8-varint-2.0.5.tgz", + "integrity": "sha512-jeFLbL/x30wBRnWjKE1qVBXeumG46r7XmYkpis955lTQ+blccGKFrOsSMHlxePwYB1pI7L8YPHz1t4jLxEs3nA==", + "license": "Apache-2.0 OR MIT", + "optional": true, + "dependencies": { + "uint8arraylist": "^2.0.0", + "uint8arrays": "^5.0.0" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/timestamp-nano": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/timestamp-nano/-/timestamp-nano-1.0.1.tgz", + "integrity": "sha512-4oGOVZWTu5sl89PtCDnhQBSt7/vL1zVEwAfxH1p49JhTosxzVQWYBYFRFZ8nJmo0G6f824iyP/44BFAwIoKvIA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4.5.0" + } + }, + "node_modules/uint8-varint": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/uint8-varint/-/uint8-varint-3.0.0.tgz", + "integrity": "sha512-S4DdpXBaLwKcFo7f0bWzWfHjbZ/i3QhM842qn+ZvHjxqFCfUcEB9SQNcmI69S+zMlcmIcKxsk9Iyw77S2Kxv6Q==", + "license": "Apache-2.0 OR MIT", + "optional": true, + "dependencies": { + "uint8arraylist": "^3.0.1", + "uint8arrays": "^6.1.0" + } + }, + "node_modules/uint8-varint/node_modules/multiformats": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-14.0.0.tgz", + "integrity": "sha512-iWK1RrAS58p2NDfeZFuSUSv3ZPewTIhsGbh/5NgeGGJwJmRljLxGtjRR3nkn+loG3zl+IrfR/W1590QnrSK+Gg==", + "license": "Apache-2.0 OR MIT", + "optional": true + }, + "node_modules/uint8-varint/node_modules/uint8arraylist": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/uint8arraylist/-/uint8arraylist-3.0.2.tgz", + "integrity": "sha512-LDVoq9BQaGJzGDUovEnoX6rpKCvnY/Jbtws4ikwnBzjRbq5qBAFpBZevUEbSmMM87aO0Sp+wOZy2ZXf5yODmXQ==", + "license": "Apache-2.0 OR MIT", + "optional": true, + "dependencies": { + "uint8arrays": "^6.0.0" + } + }, + "node_modules/uint8-varint/node_modules/uint8arrays": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-6.1.1.tgz", + "integrity": "sha512-iz7JN0XCSZYA111lhFG2Ui9EhFvTNekqSRHw3lvMHq+dzwWy1OQftxFQREEh4rffU0oSoXdQHsk2TiHKVm4fsA==", + "license": "Apache-2.0 OR MIT", + "optional": true, + "dependencies": { + "multiformats": "^14.0.0" + } + }, + "node_modules/uint8arraylist": { + "version": "2.4.9", + "resolved": "https://registry.npmjs.org/uint8arraylist/-/uint8arraylist-2.4.9.tgz", + "integrity": "sha512-KxWjyEFzchzik3aoQlK66oaoxIReoMo5bQRm1fcjBUZvE8xv/tyR3CTKhjh6K/faV8VaF6hd5pjr45CzbwuwkA==", + "license": "Apache-2.0 OR MIT", + "optional": true, + "dependencies": { + "uint8arrays": "^5.0.1" + } + }, + "node_modules/uint8arrays": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-5.1.1.tgz", + "integrity": "sha512-9muQwa4wZG4dKi9gMAIBtnk2Pw87SRpvWTH6lOGm19V2Uqxr4uomUf2PGqPnWc+qs06sN8owUU4jfcoWOcfwVQ==", + "license": "Apache-2.0 OR MIT", + "optional": true, + "dependencies": { + "multiformats": "^13.0.0" + } + }, + "node_modules/utf8-codec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/utf8-codec/-/utf8-codec-1.0.0.tgz", + "integrity": "sha512-S/QSLezp3qvG4ld5PUfXiH7mCFxLKjSVZRFkB3DOjgwHuJPFDkInAXc/anf7BAbHt/D38ozDzL+QMZ6/7gsI6w==", + "license": "MIT", + "optional": true + }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/weald": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/weald/-/weald-1.1.1.tgz", + "integrity": "sha512-PaEQShzMCz8J/AD2N3dJMc1hTZWkJeLKS2NMeiVkV5KDHwgZe7qXLEzyodsT/SODxWDdXJJqocuwf3kHzcXhSQ==", + "license": "Apache-2.0 OR MIT", + "optional": true, + "dependencies": { + "ms": "^3.0.0-canary.1", + "supports-color": "^10.0.0" + } + }, "node_modules/ws": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", diff --git a/package.json b/package.json index 7a856b8..1c2fc65 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@unicitylabs/infra-probe", - "version": "0.4.2", + "version": "0.5.0", "description": "Availability + performance probe for Unicity Network infrastructure (Nostr relay, Aggregator, IPFS gateway, L1 Fulcrum, Market, Faucet). Pre-flight check for e2e tests; CI-friendly JSON output.", "type": "module", "license": "MIT", @@ -51,7 +51,7 @@ }, "dependencies": { "@noble/curves": "^1.6.0", + "@unicitylabs/sphere-sdk": "^0.7.2", "ws": "^8.18.0" - }, - "devDependencies": {} + } } diff --git a/src/index.mjs b/src/index.mjs index cdddd1c..bfefe5a 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -45,8 +45,18 @@ export async function runProbes({ network = 'testnet', only, timeoutMs = 20_000, // Mainnet has no faucet — `cfg.faucet` is null there. Return a // skipped-cleanly verdict rather than running probeFaucet against // a default URL that doesn't apply to the network being probed. + // + // Faucet uses the full mint-and-verify path so it requires the + // upstream Nostr relay (to publish the kind:30078 nametag binding + // the faucet's resolver looks up) and the aggregator (to mint that + // nametag NFT first). Both URLs come from the same network config. faucet: () => cfg.faucet - ? probeFaucet(cfg.faucet, { timeoutMs }) + ? probeFaucet(cfg.faucet, { + timeoutMs: Math.max(timeoutMs, 30_000), + nostrRelay: cfg.nostrRelays[0], + aggregator: cfg.aggregator, + aggregatorApiKey, + }) : Promise.resolve({ service: 'faucet', endpoint: '(none for ' + network + ')', diff --git a/src/probes/faucet.mjs b/src/probes/faucet.mjs index 124af0c..15e6084 100644 --- a/src/probes/faucet.mjs +++ b/src/probes/faucet.mjs @@ -1,63 +1,112 @@ /** - * Faucet probe. + * Faucet probe — end-to-end mint-and-verify. * - * The Unicity test faucet (`https://faucet.unicity.network`) issues real - * test tokens to a wallet identified by its registered nametag. Many e2e - * suites in the SDK and downstream consumers (uxf-send-receive, - * pointer-roundtrip, migrate-to-profile-conservation, profile-export- - * roundtrip) rely on the faucet to fund test wallets — when the faucet - * is down, those suites time out at the wallet-funding step with no - * useful diagnostic. Probing the faucet upfront converts a 240 s - * "Faucet top-up timed out" into a 1-2 s clean SKIP with a precise - * "faucet unreachable" message. + * Earlier versions of this probe sent a deliberately-invalid nametag and + * accepted the faucet's "Nametag not found" rejection as proof-of-life. + * That correctly reports the HTTP request/parse/resolve pipeline but + * cannot catch a faucet bug where the HTTP layer accepts a real mint + * request, returns success, and yet no token is ever delivered to the + * recipient. To detect that class of failure, this version drives the + * full happy path: * - * Liveness check: - * 1. `POST /api/v1/faucet/request` with a deliberately-invalid - * nametag. Healthy faucet responds with HTTP 200 + structured - * `{success: false, error: "Nametag not found: …"}`. The server - * processed the request and rejected it cleanly — proving the - * whole pipeline (HTTP, body parse, nametag-resolve via Nostr, - * response shaping) is functional WITHOUT consuming actual - * faucet quota or requiring a real wallet. + * 1. Spin up an ephemeral Sphere wallet (`@unicitylabs/sphere-sdk`) + * with an in-memory storage adapter (this file's `InMemoryStorage`, + * below) so the probe still leaves no on-disk state per + * CLAUDE.md "Network-only — no local state". + * 2. Mint a fresh, single-use nametag on the L3 aggregator and publish + * the kind:30078 binding event on the Nostr relay. The faucet looks + * up `unicityId` by resolving this binding. + * 3. POST `/api/v1/faucet/request` for 1 raw unit (1e-6) of USDU. + * 4. Subscribe to `transfer:incoming` on the Sphere instance and wait + * for a token-transfer event addressed to our pubkey. The SDK + * handles NIP-04 decryption + Token deserialization. + * 5. Assert the received transfer carries a token with `coinId == + * USDU_COIN_ID` and `amount == 1` (raw unit). Both fields are + * independent of the faucet's HTTP response — proof the mint + * actually landed. + * 6. Tear down the Sphere instance. * - * Why we don't actually issue tokens: - * The faucet is rate-limited per source IP. Probing with a real - * request would either consume quota that legitimate users need, or - * (worse) get our probe blacklisted as abusive after too many runs. - * The "invalid nametag" path exercises the same code paths the - * real-issue path uses (HTTP routing, JSON validation, nametag - * resolver, error shaping) but short-circuits before any token mint. + * Cost per probe run: + * - 1 nametag mint on the L3 aggregator (one parameterized-replaceable + * binding + a fresh nametag NFT — not reclaimed) + * - 1 USDU raw unit (1e-6 USDU ≈ economically zero) from faucet quota + * - One kind:30078 event left on the Nostr relay (replaceable per + * `(pubkey, kind, d)` — overwritten on next run with the same + * identity, but each probe run uses a fresh identity, so events + * accumulate). * - * Functional cross-check (best-effort): - * Some operators expose a `/health` or `/api/v1/health` endpoint. - * When present, we probe it as a secondary check. Absence is NOT - * treated as a failure (no spec mandates this endpoint exists). + * Policy shift recorded in CLAUDE.md: this is the one probe that + * (intentionally) violates the "Minimal dependencies", "Not a wallet", + * and "Stateless on the relay/gateway side" hard rules. The other five + * probes still uphold them. */ -const DEFAULT_FAUCET_URL = 'https://faucet.unicity.network'; +import { Sphere, generateMnemonic } from '@unicitylabs/sphere-sdk'; +import { + createNostrTransportProvider, + createUnicityAggregatorProvider, + createNodeWebSocketFactory, +} from '@unicitylabs/sphere-sdk/impl/nodejs'; + +import { DEFAULT_AGGREGATOR_API_KEY } from '../networks.mjs'; /** - * Deterministic invalid nametag used by the probe. Must: - * - Not exist on any Nostr relay (so resolve fails cleanly) - * - Not collide with any legitimate user's nametag (so we don't - * accidentally mint tokens to someone else's wallet if a future - * change loosens validation) - * - * Choice: a "infra-probe-" prefix + suffix that's both clearly - * synthetic and unlikely to be picked by a human user. Updated only - * if the faucet adds a more direct probe endpoint that lets us drop - * this hack. + * USDU (Unicity testnet USD stablecoin) — 6 decimals. + * Source: `GET /api/v1/faucet/coins` returns this id for symbol USDU. + * We mint the smallest possible amount (1 raw unit = 1e-6 USDU) per run. */ -const PROBE_NAMETAG = 'infra-probe-do-not-mint-zk7q3xa9p2v'; +const USDU_COIN_ID = + '8f0f3d7a5e7297be0ee98c63b81bcebb2740f43f616566fc290f9823a54f52d7'; + +const PROBE_COIN = 'unicity-usd'; // matches `coin` field in /faucet/coins +const PROBE_AMOUNT = 1; // 1 raw unit (smallest mintable) + +/** + * Faucet's own validation: 3-20 chars, lowercase alphanumeric + `_-`. + * Source: sphere-sdk `Sphere.registerNametag` precheck. + * `p-` + 11 random chars = 13 chars total — fits comfortably. + */ +function freshProbeNametag() { + return 'p-' + Math.random().toString(36).slice(2, 13); +} + +/** + * Maximum total wall-clock per probe run. Empirically observed: + * nametag mint on aggregator: ~2-3 s + * faucet HTTP request: ~5-7 s (faucet does its own SMT submit) + * transfer event arrival: <1 s after faucet ack + * Total typical: 8-12 s. The default `timeoutMs` (20 s) accommodates + * slow-day inflation. + */ +const RECEIPT_BUDGET_MS = 10_000; + +/** + * @param {string} faucetUrl + * @param {object} opts + * @param {number} [opts.timeoutMs=20000] overall ceiling + * @param {string} opts.nostrRelay ws(s):// — required (used by SDK transport) + * @param {string} opts.aggregator https:// — required (used by SDK oracle) + * @param {string} [opts.aggregatorApiKey] X-API-Key for the aggregator + */ +export async function probeFaucet(faucetUrl, opts = {}) { + const { + timeoutMs = 20_000, + nostrRelay, + aggregator, + aggregatorApiKey = DEFAULT_AGGREGATOR_API_KEY, + } = opts; + + if (!nostrRelay || !aggregator) { + throw new Error('probeFaucet requires `nostrRelay` and `aggregator` URLs in opts'); + } -export async function probeFaucet(url = DEFAULT_FAUCET_URL, { timeoutMs = 10_000 } = {}) { const checks = []; const overallStart = Date.now(); - const base = url.replace(/\/+$/, ''); + let sphere = null; const finalize = (status, error) => ({ service: 'faucet', - endpoint: url, + endpoint: faucetUrl, status, latencyMs: Date.now() - overallStart, checks, @@ -65,165 +114,289 @@ export async function probeFaucet(url = DEFAULT_FAUCET_URL, { timeoutMs = 10_000 timestamp: new Date().toISOString(), }); - // ---- 1. POST /api/v1/faucet/request with an invalid nametag ---- - // - // Healthy: HTTP 200 + body matches `{success: false, error|message: }`. - // The server processed the request, parsed the body, attempted nametag - // resolution, found no match, and shaped a clean error response. Every - // layer of the request-issue pipeline ran. This is a stronger signal - // than a generic /health endpoint because it exercises the actual - // user-facing API path. - const probeStart = Date.now(); + // Cap total work at `timeoutMs` so a hung aggregator can't deadlock the + // probe (the SDK does have internal timeouts but they're per-step). + const overallDeadline = Date.now() + timeoutMs; + const remaining = () => Math.max(0, overallDeadline - Date.now()); + try { - const response = await postJson( - `${base}/api/v1/faucet/request`, - { - unicityId: PROBE_NAMETAG, - coin: 'unicity-usd', - amount: 1, - }, - timeoutMs, - ); - const latencyMs = Date.now() - probeStart; - // The faucet server returns HTTP 400 (or similar 4xx) for the - // invalid-nametag rejection along with the structured - // `{success: false, error: ...}` body. That's the CORRECT API - // contract — 4xx + structured error proves the request handler is - // alive and processing payloads. We only treat 5xx (server error) - // and connection failures as 'unreachable'. Network failure is - // caught by the outer catch. - if (response.status >= 500) { - const text = await response.text().catch(() => ''); - checks.push({ - name: 'request', - status: 'fail', - latencyMs, - message: `HTTP ${response.status}: ${text.slice(0, 200)}`, + // ---- 1. Register a single-use identity + nametag ---- + // + // This both mints a fresh nametag NFT on the L3 aggregator AND + // publishes the kind:30078 binding event on the Nostr relay. The + // faucet's nametag resolver looks up the binding by `d` tag on the + // relay — without this step, the faucet would reject our request + // with "Nametag not found". + const setupStart = Date.now(); + const nametag = freshProbeNametag(); + let setupErr; + try { + const storage = new InMemoryStorage(); + const transport = createNostrTransportProvider({ + relays: [nostrRelay], + createWebSocket: createNodeWebSocketFactory(), }); - return finalize('unreachable', `request returned HTTP ${response.status}`); - } - const json = await response.json().catch(() => null); - if (!json || typeof json !== 'object') { - checks.push({ - name: 'request', - status: 'fail', - latencyMs, - message: 'response was not JSON', + const oracle = createUnicityAggregatorProvider({ + url: aggregator, + ...(aggregatorApiKey ? { apiKey: aggregatorApiKey } : {}), + }); + + sphere = await Sphere.create({ + mnemonic: generateMnemonic(), + storage, + transport, + oracle, + nametag, + l1: null, // skip L1 wiring — not needed + network: 'testnet', // informational + groupChat: false, + market: false, + accounting: false, + swap: false, }); - return finalize('degraded', 'response shape malformed'); + } catch (err) { + setupErr = err; } - // The probe nametag is deliberately invalid → expect success:false. - // success:true would mean the faucet ACTUALLY MINTED tokens, which - // would imply our probe nametag isn't actually invalid (rare: - // someone registered the same string) OR the faucet's validation - // is broken. Either way, that's degraded — flag it. - if (json.success === true) { + const setupMs = Date.now() - setupStart; + + if (setupErr) { checks.push({ - name: 'request', - status: 'warn', - latencyMs, - message: 'faucet ACCEPTED probe-nametag mint — validation may be broken (latency=' + latencyMs + 'ms)', + name: 'wallet-setup', + status: 'fail', + critical: true, + latencyMs: setupMs, + message: `failed to mint nametag/publish binding: ${errMsg(setupErr)}`, }); - return finalize('degraded', 'faucet accepted invalid-nametag mint'); + // If we can't even register the probe identity, the failure is + // most likely upstream of the faucet (aggregator or relay) — but + // the *faucet probe* still can't complete, so verdict is + // unreachable. Operators reading this should also check the + // aggregator + nostr verdicts in the same report. + return finalize('unreachable', `setup: ${errMsg(setupErr)}`); } - if (json.success !== false) { + checks.push({ + name: 'wallet-setup', + status: setupMs > 5_000 ? 'warn' : 'pass', + critical: true, + latencyMs: setupMs, + message: `nametag '${nametag}' minted + binding published (${setupMs}ms)`, + }); + + // ---- 2. Arm the receipt listener BEFORE issuing the request ---- + // + // Race-avoidance: the faucet's send-token path is fast (<1 s after + // the HTTP ack on a healthy day), so if we registered the handler + // after `fetch()` resolved, we could miss the event entirely. + let receivedTransfer = null; + let receiveResolve, receiveReject; + const receivePromise = new Promise((res, rej) => { + receiveResolve = res; + receiveReject = rej; + }); + sphere.on('transfer:incoming', (t) => { + receivedTransfer = t; + receiveResolve(t); + }); + + // ---- 3. Issue the faucet request ---- + const requestStart = Date.now(); + let httpResp; + let httpBody; + try { + const remainingMs = Math.max(1_000, remaining() - RECEIPT_BUDGET_MS); + httpResp = await fetchWithTimeout( + `${faucetUrl.replace(/\/+$/, '')}/api/v1/faucet/request`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + unicityId: nametag, + coin: PROBE_COIN, + amount: PROBE_AMOUNT, + }), + }, + remainingMs, + ); + httpBody = await httpResp.json().catch(() => null); + } catch (err) { checks.push({ name: 'request', status: 'fail', - latencyMs, - message: `unexpected success field: ${typeof json.success}`, + critical: true, + latencyMs: Date.now() - requestStart, + message: errMsg(err), }); - return finalize('degraded', 'response shape malformed'); + return finalize('unreachable', `request: ${errMsg(err)}`); } - // Confirm the error/message field is populated — proves the server - // shaped a meaningful response rather than a no-op stub. - const errMsg = json.error || json.message || ''; - if (typeof errMsg !== 'string' || errMsg.length === 0) { + const requestMs = Date.now() - requestStart; + + if (!httpResp.ok || !httpBody || httpBody.success !== true) { checks.push({ name: 'request', status: 'fail', - latencyMs, - message: 'success:false but no error/message field', + critical: true, + latencyMs: requestMs, + message: `HTTP ${httpResp.status}: ${ + httpBody?.error || httpBody?.message || '(no body)' + }`, }); - return finalize('degraded', 'error field missing on rejection'); + return finalize('unreachable', `request: HTTP ${httpResp.status}`); } + // The faucet's `amountInSmallestUnits` is the raw-unit value the + // faucet itself claims it sent. That's the authoritative number to + // compare against on receipt — `amount: 1` in the request body is + // in whole tokens (1 USDU), but L3 token transfers carry raw units + // (1_000_000 for USDU at 6 decimals). Earlier versions of this + // probe compared raw-on-the-wire against the whole-token request + // and false-failed every successful mint. + const expectedRawAmount = httpBody.data?.amountInSmallestUnits; + const expectedCoinName = httpBody.data?.coin; checks.push({ name: 'request', - status: latencyMs > 5_000 ? 'warn' : 'pass', - latencyMs, - message: `cleanly rejected probe-nametag (${latencyMs}ms; "${errMsg.slice(0, 80)}")`, - }); - } catch (err) { - checks.push({ - name: 'request', - status: 'fail', - latencyMs: Date.now() - probeStart, - message: errMsg(err), + status: requestMs > 10_000 ? 'warn' : 'pass', + critical: true, + latencyMs: requestMs, + message: `faucet accepted (requestId=${httpBody.data?.requestId}, will send ${expectedRawAmount} raw ${expectedCoinName}, ${requestMs}ms)`, }); - return finalize('unreachable', errMsg(err)); - } - // ---- 2. /health (functional cross-check, best-effort) ---- - // - // Not part of the spec — many faucet deployments don't expose this. - // When present, slow /health vs fast /request signals different - // backend pressure (e.g., the request handler is up but the storage - // layer is sick). When absent (404 or connection failure), we just - // skip — the request probe above is the authoritative liveness gate. - const healthStart = Date.now(); - try { - const response = await fetchWithTimeout(`${base}/health`, { method: 'GET' }, timeoutMs); - const latencyMs = Date.now() - healthStart; - if (response.ok) { - checks.push({ - name: 'health', - status: latencyMs > 2_000 ? 'warn' : 'pass', - latencyMs, - message: `HTTP ${response.status} (${latencyMs}ms)`, - }); - } else if (response.status === 404) { - // Operator hasn't exposed a health endpoint — not a failure. + // ---- 4. Wait for the kind:31113 token transfer to land ---- + const receiptStart = Date.now(); + const budgetMs = Math.min(RECEIPT_BUDGET_MS, remaining()); + let receiptTimer; + try { + const transfer = await Promise.race([ + receivePromise, + new Promise((_, rej) => { + receiptTimer = setTimeout( + () => rej(new Error(`no token transfer event in ${budgetMs}ms`)), + budgetMs, + ); + }), + ]); + clearTimeout(receiptTimer); + const receiptMs = Date.now() - receiptStart; + + // ---- 5. Verify the received tokens carry the expected mint ---- + const minted = (transfer.tokens || []).find( + (t) => t.coinId === USDU_COIN_ID, + ); + if (!minted) { + checks.push({ + name: 'receipt', + status: 'fail', + critical: true, + latencyMs: receiptMs, + message: `transfer arrived but no USDU token found (got: ${ + (transfer.tokens || []).map((t) => t.symbol).join(',') || 'none' + })`, + }); + return finalize('degraded', 'received transfer had no USDU token'); + } + // Compare to the raw-unit amount the faucet TOLD us it would + // send, not to the whole-token value in the HTTP request body. + // A mismatch here means the faucet's HTTP response is lying + // about what its async send actually delivered — a real bug to + // surface, but the faucet DID deliver something, so degraded + // (not unreachable). + if (expectedRawAmount != null && BigInt(minted.amount) !== BigInt(expectedRawAmount)) { + checks.push({ + name: 'receipt', + status: 'warn', + critical: true, + latencyMs: receiptMs, + message: `USDU delivered but amount mismatch (HTTP said ${expectedRawAmount} raw, received ${minted.amount} raw)`, + }); + return finalize('degraded', 'amount mismatch on received token'); + } checks.push({ - name: 'health', + name: 'receipt', status: 'pass', - latencyMs, - message: `no /health endpoint (HTTP 404) — request probe is authoritative`, + critical: true, + latencyMs: receiptMs, + message: `USDU ${minted.amount} raw unit(s) received (transfer.id=${transfer.id?.slice(0, 12)}, ${receiptMs}ms)`, }); - } else { + } catch (err) { + clearTimeout(receiptTimer); checks.push({ - name: 'health', - status: 'warn', - latencyMs, - message: `HTTP ${response.status}`, + name: 'receipt', + status: 'fail', + critical: true, + latencyMs: Date.now() - receiptStart, + message: errMsg(err), }); + return finalize('unreachable', `receipt: ${errMsg(err)}`); } - } catch (err) { - // Health probe failure on its own is not fatal — request probe - // already succeeded. Demote to a warn-level check. - checks.push({ - name: 'health', - status: 'warn', - latencyMs: Date.now() - healthStart, - message: `unreachable: ${errMsg(err)}`, - }); - } - const failed = checks.filter((c) => c.status === 'fail').length; - const slow = checks.filter((c) => c.status === 'warn').length; - const status = failed > 0 ? (failed >= 2 ? 'unreachable' : 'degraded') : slow > 0 ? 'degraded' : 'healthy'; - return finalize(status); + return finalize('healthy'); + } finally { + // Always tear down the wallet so the probe doesn't leak + // subscriptions, reconnect timers, or open WebSockets. + if (sphere) { + try { + await sphere.destroy(); + } catch { + /* best-effort */ + } + } + } } -async function postJson(url, payload, timeoutMs) { - return fetchWithTimeout( - url, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }, - timeoutMs, - ); +/** + * Minimal in-memory `StorageProvider` for one-shot probe lifecycle. + * + * Sphere requires a `StorageProvider` for wallet persistence (mnemonic, + * tracked addresses, token cache, etc.). The two impl/nodejs adapters + * (`FileStorageProvider`, `FileTokenStorageProvider`) write to disk — + * which conflicts with CLAUDE.md "Network-only — no local state". + * + * This class satisfies the `StorageProvider` interface declared in + * `sphere-sdk/dist/index.d.ts` (around the `StorageProvider extends + * BaseProvider` block) with an in-memory `Map`. Nothing persists past + * `sphere.destroy()`. Verified working against testnet — see the smoke + * test mention in the commit log when this file was introduced. + * + * If sphere-sdk adds new required `StorageProvider` methods in a future + * release, this class will need matching stubs. + */ +class InMemoryStorage { + id = 'in-memory'; + name = 'In-Memory Storage'; + type = 'storage'; + description = 'Ephemeral, process-local storage for one-shot probes'; + + #identity = null; + #map = new Map(); + #status = 'disconnected'; + + async connect() { this.#status = 'connected'; } + async disconnect() { this.#status = 'disconnected'; } + isConnected() { return this.#status === 'connected'; } + getStatus() { return this.#status; } + setIdentity(identity) { this.#identity = identity; } + + async get(k) { return this.#map.has(k) ? this.#map.get(k) : null; } + async set(k, v) { this.#map.set(k, v); } + async remove(k) { this.#map.delete(k); } + async has(k) { return this.#map.has(k); } + + async keys(prefix) { + const all = [...this.#map.keys()]; + return prefix ? all.filter((k) => k.startsWith(prefix)) : all; + } + async clear(prefix) { + if (!prefix) { this.#map.clear(); return; } + for (const k of [...this.#map.keys()]) { + if (k.startsWith(prefix)) this.#map.delete(k); + } + } + + async saveTrackedAddresses(entries) { + this.#map.set('__tracked_addresses__', JSON.stringify(entries)); + } + async loadTrackedAddresses() { + const raw = this.#map.get('__tracked_addresses__'); + return raw ? JSON.parse(raw) : []; + } } async function fetchWithTimeout(url, init, timeoutMs) { @@ -232,7 +405,9 @@ async function fetchWithTimeout(url, init, timeoutMs) { try { return await fetch(url, { ...init, signal: controller.signal }); } catch (err) { - if (err.name === 'AbortError') throw new Error(`request timed out after ${timeoutMs}ms`); + if (err.name === 'AbortError') { + throw new Error(`request timed out after ${timeoutMs}ms`); + } throw err; } finally { clearTimeout(timer); diff --git a/tests/smoke.test.mjs b/tests/smoke.test.mjs index e107d05..9cfe329 100644 --- a/tests/smoke.test.mjs +++ b/tests/smoke.test.mjs @@ -158,6 +158,33 @@ test('SERVICES enumerates all six canonical services in stable order', () => { assert.deepEqual(SERVICES, ['nostr', 'aggregator', 'ipfs', 'fulcrum', 'market', 'faucet']); }); +// --------------------------------------------------------------------------- +// Faucet probe — contract surface +// --------------------------------------------------------------------------- +// +// Pins the probeFaucet API signature. The faucet probe is unusual: it +// requires not just its own URL but also the upstream Nostr relay and +// L3 aggregator URLs (to register the probe identity that the faucet's +// nametag resolver looks up). The orchestration layer in src/index.mjs +// passes these from the network config; this test guards against +// accidentally dropping them. + +test('probeFaucet requires nostrRelay and aggregator opts', async () => { + const { probeFaucet } = await import('../src/probes/faucet.mjs'); + await assert.rejects( + () => probeFaucet('https://faucet.example', {}), + /requires `nostrRelay` and `aggregator`/, + ); + await assert.rejects( + () => probeFaucet('https://faucet.example', { nostrRelay: 'wss://x' }), + /requires `nostrRelay` and `aggregator`/, + ); + await assert.rejects( + () => probeFaucet('https://faucet.example', { aggregator: 'https://x' }), + /requires `nostrRelay` and `aggregator`/, + ); +}); + test('exitCodeForReport: all healthy → 0', () => { const r = { summary: { total: 5, healthy: 5, degraded: 0, unreachable: 0 } }; assert.equal(exitCodeForReport(r), 0);