feat: relay consistency diagnostic and recent reputation

This commit is contained in:
SkateSpots 2026-10-31 12:11:40 +07:00
parent e1a011045a
commit 618d961f72
9 changed files with 296 additions and 1 deletions

View file

@ -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 (
<Card className="bg-black/90 border-white/10">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-bold text-white">
<Activity className="h-4 w-4 text-[#ccff00]" />
{t('settingsUi.relayList.relayDiagTitle')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-xs leading-relaxed text-gray-400">
{t('settingsUi.relayList.relayDiagDesc')}
</p>
<Button
size="sm"
variant="outline"
onClick={() => void check()}
disabled={checking}
className="gap-1.5"
>
<RefreshCw className={checking ? 'h-3.5 w-3.5 animate-spin' : 'h-3.5 w-3.5'} />
{checking
? t('settingsUi.relayList.relayDiagChecking')
: t('settingsUi.relayList.relayDiagCheck')}
</Button>
{error && (
<p className="text-xs text-red-400">
{t('settingsUi.relayList.relayDiagFailed')}
</p>
)}
{result && !error && (
<div className="space-y-1 text-xs">
<p className={differences > 0 ? 'font-bold text-amber-300' : 'text-green-400'}>
{differences > 0
? t('settingsUi.relayList.relayDiagDiff', { count: differences })
: t('settingsUi.relayList.relayDiagAgree')}
</p>
{differences > 0 && (
<p className="text-gray-500">{t('settingsUi.relayList.relayDiagDetails')}</p>
)}
{result.errors.length > 0 && (
<p className="text-gray-500">
{result.errors.length} {t('settingsUi.relayList.offline')}
</p>
)}
</div>
)}
</CardContent>
</Card>
);
}

View file

@ -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')}
</p>
{data.recentVerified > 0 && (
<p className="text-xs text-gray-500">
<span className="font-bold text-[#ccff00]">{data.recentVerified}</span>{' '}
{t('marketUi.reputation.recentConfirmed')}
</p>
)}
{data.withPaymentProof > 0 && (
<p className="text-xs text-gray-500">
<span className="font-bold text-[#ccff00]">{data.withPaymentProof}</span> {t('marketUi.reputation.withPaymentProof')} <span className="text-gray-600">{t('marketUi.reputation.selfReported')}</span>

View file

@ -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<RelayDivergenceResult | null>(null);
const [checking, setChecking] = useState(false);
const [error, setError] = useState<string | null>(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<RelaySnapshot> => {
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 };
}

View file

@ -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<TradeReputation> => {
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,

View file

@ -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...',

View file

@ -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...',

View file

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

View file

@ -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<string, NostrEvent> {
const byKey = new Map<string, NostrEvent>();
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,
};
}

View file

@ -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 }) {
)}
<CitrineCard />
<RelayListManager />
<RelayDivergenceCard />
</div>
</div>
);