feat: relay consistency diagnostic and recent reputation
This commit is contained in:
parent
e1a011045a
commit
618d961f72
9 changed files with 296 additions and 1 deletions
69
src/components/RelayDivergenceCard.tsx
Normal file
69
src/components/RelayDivergenceCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
62
src/hooks/useRelayDivergence.ts
Normal file
62
src/hooks/useRelayDivergence.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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...',
|
||||
|
|
|
|||
|
|
@ -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...',
|
||||
|
|
|
|||
60
src/lib/relayDivergence.test.ts
Normal file
60
src/lib/relayDivergence.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
71
src/lib/relayDivergence.ts
Normal file
71
src/lib/relayDivergence.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue