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 }) {
)}
+
);