From 618d961f72058f41c487749eee45b9541c56a1c5 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Sat, 31 Oct 2026 12:11:40 +0700 Subject: [PATCH] feat: relay consistency diagnostic and recent reputation --- src/components/RelayDivergenceCard.tsx | 69 +++++++++++++++++++++++++ src/components/TradeReputationCard.tsx | 7 +++ src/hooks/useRelayDivergence.ts | 62 ++++++++++++++++++++++ src/hooks/useTradeAttestations.ts | 8 ++- src/i18n/en.ts | 9 ++++ src/i18n/es.ts | 9 ++++ src/lib/relayDivergence.test.ts | 60 ++++++++++++++++++++++ src/lib/relayDivergence.ts | 71 ++++++++++++++++++++++++++ src/pages/SettingsPage.tsx | 2 + 9 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 src/components/RelayDivergenceCard.tsx create mode 100644 src/hooks/useRelayDivergence.ts create mode 100644 src/lib/relayDivergence.test.ts create mode 100644 src/lib/relayDivergence.ts diff --git a/src/components/RelayDivergenceCard.tsx b/src/components/RelayDivergenceCard.tsx new file mode 100644 index 0000000..a20e491 --- /dev/null +++ b/src/components/RelayDivergenceCard.tsx @@ -0,0 +1,69 @@ +import { Activity, RefreshCw } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { useTranslation } from '@/contexts/language'; +import { useRelayDivergence } from '@/hooks/useRelayDivergence'; + +/** + * Settings diagnostic: compare the community relay with the user's read relays. + * No merge, no vote, no data mutation. + */ +export function RelayDivergenceCard() { + const { t } = useTranslation(); + const { result, checking, error, check } = useRelayDivergence(); + + const differences = result?.differences.length ?? 0; + + return ( + + + + + {t('settingsUi.relayList.relayDiagTitle')} + + + +

+ {t('settingsUi.relayList.relayDiagDesc')} +

+ + + + {error && ( +

+ {t('settingsUi.relayList.relayDiagFailed')} +

+ )} + + {result && !error && ( +
+

0 ? 'font-bold text-amber-300' : 'text-green-400'}> + {differences > 0 + ? t('settingsUi.relayList.relayDiagDiff', { count: differences }) + : t('settingsUi.relayList.relayDiagAgree')} +

+ {differences > 0 && ( +

{t('settingsUi.relayList.relayDiagDetails')}

+ )} + {result.errors.length > 0 && ( +

+ {result.errors.length} {t('settingsUi.relayList.offline')} +

+ )} +
+ )} +
+
+ ); +} diff --git a/src/components/TradeReputationCard.tsx b/src/components/TradeReputationCard.tsx index f819fdf..056d35e 100644 --- a/src/components/TradeReputationCard.tsx +++ b/src/components/TradeReputationCard.tsx @@ -79,6 +79,13 @@ export function TradeReputationCard({ pubkey, className }: { pubkey: string; cla ? `${data.verified} ${data.verified === 1 ? t('marketUi.reputation.tradeConfirmedOne') : t('marketUi.reputation.tradeConfirmedMany')} ${t('marketUi.reputation.with')} ${people} ${people === 1 ? t('marketUi.reputation.personOne') : t('marketUi.reputation.personMany')}` : t('marketUi.reputation.awaiting')}

+ + {data.recentVerified > 0 && ( +

+ {data.recentVerified}{' '} + {t('marketUi.reputation.recentConfirmed')} +

+ )} {data.withPaymentProof > 0 && (

{data.withPaymentProof} {t('marketUi.reputation.withPaymentProof')} {t('marketUi.reputation.selfReported')} diff --git a/src/hooks/useRelayDivergence.ts b/src/hooks/useRelayDivergence.ts new file mode 100644 index 0000000..bd8bf2b --- /dev/null +++ b/src/hooks/useRelayDivergence.ts @@ -0,0 +1,62 @@ +import { useCallback, useState } from 'react'; +import { useNostr } from '@nostrify/react'; +import { useRelays } from '@/lib/relays'; +import { SKATE_RELAY } from '@/lib/types'; +import { onlyVerified } from '@/lib/verifyEvents'; +import { + compareRelaySnapshots, + type RelayDivergenceResult, + type RelaySnapshot, +} from '@/lib/relayDivergence'; + +/** + * On-demand relay consistency diagnostic. + * + * Queries a bounded profile snapshot from the community relay and the user's + * read relays, then reports newer/different signed profiles. This is not a vote + * and it never mutates canonical map/community data. + */ +export function useRelayDivergence() { + const { nostr } = useNostr(); + const { read } = useRelays(); + const [result, setResult] = useState(null); + const [checking, setChecking] = useState(false); + const [error, setError] = useState(null); + + const check = useCallback(async () => { + setChecking(true); + setError(null); + try { + const relays = Array.from(new Set([SKATE_RELAY, ...read])).slice(0, 5); + const signal = AbortSignal.timeout(12_000); + + const snapshots = await Promise.all( + relays.map(async (relayUrl): Promise => { + try { + const relay = nostr.relay(relayUrl); + const events = await relay.query([{ kinds: [0], limit: 100 }], { signal }); + return { relay: relayUrl, events: onlyVerified(events) }; + } catch (e) { + return { + relay: relayUrl, + events: [], + error: e instanceof Error ? e.message : 'relay error', + }; + } + }), + ); + + const canonical = snapshots.find((s) => s.relay === SKATE_RELAY); + if (!canonical) throw new Error('Community relay snapshot missing.'); + const others = snapshots.filter((s) => s.relay !== SKATE_RELAY); + setResult(compareRelaySnapshots(canonical, others)); + } catch (e) { + setResult(null); + setError(e instanceof Error ? e.message : 'check failed'); + } finally { + setChecking(false); + } + }, [nostr, read]); + + return { result, checking, error, check }; +} diff --git a/src/hooks/useTradeAttestations.ts b/src/hooks/useTradeAttestations.ts index 404afad..b85666f 100644 --- a/src/hooks/useTradeAttestations.ts +++ b/src/hooks/useTradeAttestations.ts @@ -178,6 +178,8 @@ export interface TradeReputation { purchases: number; /** Deals where both sides attested the same listing. */ verified: number; + /** Mutually confirmed deals with an attestation in the last 90 days. */ + recentVerified: number; /** * Mutually-confirmed trades that ALSO carry a `proof` tag (a payment claim). * ⚠️ The `proof` is self-reported and the app does NOT verify it — it is a @@ -222,7 +224,7 @@ export function useTradeReputation(pubkey: string | undefined) { enabled: !!pubkey, staleTime: 5 * 60 * 1000, queryFn: async (c): Promise => { - const empty: TradeReputation = { sales: 0, purchases: 0, verified: 0, withPaymentProof: 0, paymentVerified: 0, verifiedCounterparties: 0, verifiedWith: [], counterparties: [], attestations: [] }; + const empty: TradeReputation = { sales: 0, purchases: 0, verified: 0, recentVerified: 0, withPaymentProof: 0, paymentVerified: 0, verifiedCounterparties: 0, verifiedWith: [], counterparties: [], attestations: [] }; if (!pubkey) return empty; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(12000)]); @@ -256,7 +258,9 @@ export function useTradeReputation(pubkey: string | undefined) { let sales = 0; let purchases = 0; let verified = 0; + let recentVerified = 0; let withPaymentProof = 0; + const recentCutoff = Math.floor(Date.now() / 1000) - 90 * 24 * 60 * 60; for (const [key, attestations] of byListing) { const coord = parseListingCoord(key); @@ -271,6 +275,7 @@ export function useTradeReputation(pubkey: string | undefined) { else purchases++; if (isVerified) { verified++; + if (Math.max(...attestations.map((e) => e.created_at)) >= recentCutoff) recentVerified++; // Attribute the verified trade to the other attestor(s). for (const a of attestors) { if (a !== pubkey) verifiedWith.set(a, (verifiedWith.get(a) ?? 0) + 1); @@ -329,6 +334,7 @@ export function useTradeReputation(pubkey: string | undefined) { sales, purchases, verified, + recentVerified, withPaymentProof, paymentVerified, verifiedCounterparties: verifiedWith.size, diff --git a/src/i18n/en.ts b/src/i18n/en.ts index d5991b3..04249ec 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -1959,6 +1959,7 @@ marketUi: { newTrader: 'New trader', title: 'Trades', verified: 'Verified', + recentConfirmed: 'confirmed in the last 90 days', sales: 'Sales', purchases: 'Purchases', tradeConfirmedOne: 'mutually-confirmed trade', @@ -5018,6 +5019,14 @@ settingsUi: { localDesc: 'App-only (e.g. a LAN node). Used for reads and writes; never published.', blockedTitle: 'Blocked relays', blockedDesc: 'Never connect to these — excluded from every read and write.', + relayDiagTitle: 'Relay consistency', + relayDiagDesc: 'Compare the community relay with your own read relays. This is a diagnostic, not a vote.', + relayDiagCheck: 'Check profiles', + relayDiagChecking: 'Checking…', + relayDiagAgree: 'Relays agree', + relayDiagDiff: '{{count}} differences', + relayDiagFailed: 'Could not check relays.', + relayDiagDetails: 'A newer or different profile exists on one relay. No data was changed.', }, relatr: { searchingTitle: 'Searching for Relatr service...', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 2802009..2099489 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -1959,6 +1959,7 @@ marketUi: { newTrader: 'Comerciante nuevo', title: 'Tratos', verified: 'Verificados', + recentConfirmed: 'confirmados en los últimos 90 días', sales: 'Ventas', purchases: 'Compras', tradeConfirmedOne: 'trato confirmado mutuamente', @@ -5018,6 +5019,14 @@ settingsUi: { localDesc: 'Solo de la app (p. ej. un nodo de LAN). Se usan para lectura y escritura; nunca se publican.', blockedTitle: 'Relays bloqueados', blockedDesc: 'No te conectes nunca a estos: se excluyen de toda lectura y escritura.', + relayDiagTitle: 'Consistencia de relays', + relayDiagDesc: 'Compara el relay comunitario con tus relays de lectura. Es un diagnóstico, no una votación.', + relayDiagCheck: 'Comprobar perfiles', + relayDiagChecking: 'Comprobando…', + relayDiagAgree: 'Los relays coinciden', + relayDiagDiff: '{{count}} diferencias', + relayDiagFailed: 'No se pudieron comprobar los relays.', + relayDiagDetails: 'Hay un perfil más nuevo o distinto en otro relay. No se cambió ningún dato.', }, relatr: { searchingTitle: 'Buscando el servicio Relatr...', diff --git a/src/lib/relayDivergence.test.ts b/src/lib/relayDivergence.test.ts new file mode 100644 index 0000000..afc6341 --- /dev/null +++ b/src/lib/relayDivergence.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import type { NostrEvent } from '@nostrify/nostrify'; +import { compareRelaySnapshots } from './relayDivergence'; + +function profile(id: string, pubkey: string, created_at: number): NostrEvent { + return { + id, + pubkey, + created_at, + kind: 0, + tags: [], + content: '{}', + sig: '0'.repeat(128), + }; +} + +describe('compareRelaySnapshots', () => { + it('reports a newer profile on another relay', () => { + const pubkey = 'a'.repeat(64); + const result = compareRelaySnapshots( + { relay: 'canonical', events: [profile('old', pubkey, 10)] }, + [{ relay: 'personal', events: [profile('new', pubkey, 20)] }], + ); + + expect(result.differences).toHaveLength(1); + expect(result.differences[0].relay).toBe('personal'); + }); + + it('reports a profile missing from canonical', () => { + const pubkey = 'b'.repeat(64); + const result = compareRelaySnapshots( + { relay: 'canonical', events: [] }, + [{ relay: 'personal', events: [profile('only', pubkey, 10)] }], + ); + + expect(result.differences).toHaveLength(1); + expect(result.differences[0].canonical).toBeNull(); + }); + + it('does not report equal snapshots', () => { + const pubkey = 'c'.repeat(64); + const event = profile('same', pubkey, 10); + const result = compareRelaySnapshots( + { relay: 'canonical', events: [event] }, + [{ relay: 'personal', events: [event] }], + ); + + expect(result.differences).toEqual([]); + }); + + it('records relay errors instead of treating them as empty', () => { + const result = compareRelaySnapshots( + { relay: 'canonical', events: [] }, + [{ relay: 'personal', events: [], error: 'timeout' }], + ); + + expect(result.errors).toEqual(['personal: timeout']); + expect(result.checkedCount).toBe(0); + }); +}); diff --git a/src/lib/relayDivergence.ts b/src/lib/relayDivergence.ts new file mode 100644 index 0000000..80b1573 --- /dev/null +++ b/src/lib/relayDivergence.ts @@ -0,0 +1,71 @@ +import type { NostrEvent } from '@nostrify/nostrify'; +import { eventMergeKey, eventReplaces } from './eventMerge'; + +/** + * Compare profile snapshots from several relays. + * + * This is deliberately a diagnostic, not a consensus mechanism. A difference + * means "another relay served a newer or different signed profile", not that + * either relay is lying. We never merge canonical map/community data from this + * result. + */ +export interface RelaySnapshot { + relay: string; + events: NostrEvent[]; + error?: string; +} + +export interface RelayDifference { + key: string; + canonical: NostrEvent | null; + other: NostrEvent; + relay: string; +} + +export interface RelayDivergenceResult { + relayCount: number; + checkedCount: number; + differences: RelayDifference[]; + errors: string[]; +} + +function latestByKey(events: NostrEvent[]): Map { + const byKey = new Map(); + for (const event of events) { + const key = eventMergeKey(event); + const current = byKey.get(key); + if (!current || eventReplaces(current, event)) byKey.set(key, event); + } + return byKey; +} + +export function compareRelaySnapshots( + canonical: RelaySnapshot, + others: RelaySnapshot[], +): RelayDivergenceResult { + const canonicalByKey = latestByKey(canonical.events); + const differences: RelayDifference[] = []; + const errors: string[] = []; + let checkedCount = 0; + + for (const snapshot of others) { + if (snapshot.error) { + errors.push(`${snapshot.relay}: ${snapshot.error}`); + continue; + } + checkedCount += 1; + for (const [key, event] of latestByKey(snapshot.events)) { + const current = canonicalByKey.get(key) ?? null; + if (!current || eventReplaces(current, event)) { + differences.push({ key, canonical: current, other: event, relay: snapshot.relay }); + } + } + } + + return { + relayCount: 1 + others.length, + checkedCount, + differences, + errors, + }; +} diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 78aae9d..57f3dde 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -6,6 +6,7 @@ import { useSasquatchHidden } from '@/hooks/useSasquatchHidden'; import { SkaterDirectoryCard } from '@/components/SkaterDirectoryCard'; import { ProfilePreviewCard } from '@/components/ProfilePreviewCard'; import { RelayListManager } from '@/components/RelayListManager'; +import { RelayDivergenceCard } from '@/components/RelayDivergenceCard'; import { WalletModal } from '@/components/WalletModal'; import { SecuritySettings } from '@/components/SecuritySettings'; @@ -607,6 +608,7 @@ function RelaysScreen({ onBack }: { onBack: () => void }) { )} + );