diff --git a/src/components/CopyButton.tsx b/src/components/CopyButton.tsx index c79ea2c..84c9e08 100644 --- a/src/components/CopyButton.tsx +++ b/src/components/CopyButton.tsx @@ -4,15 +4,23 @@ import { useTranslation } from 'react-i18next'; export function CopyButton({ text }: { text: string }) { const { t } = useTranslation(); const [copied, setCopied] = useState(false); + + async function copyText() { + try { + if (!navigator.clipboard?.writeText) return; + await navigator.clipboard.writeText(text); + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + } catch { + setCopied(false); + } + } + return (
+
@@ -153,6 +155,12 @@ export function Header() { +
+ + Privacy posture + + +
{t('header.wallet')} diff --git a/src/components/PrivacyPostureChip.tsx b/src/components/PrivacyPostureChip.tsx new file mode 100644 index 0000000..c34ead1 --- /dev/null +++ b/src/components/PrivacyPostureChip.tsx @@ -0,0 +1,150 @@ +import { useEffect, useId, useRef, useState, useSyncExternalStore } from 'react'; +import { Link } from 'react-router-dom'; +import { CopyButton } from './CopyButton'; +import { CKB_NETWORK, SOLANA_NETWORK, STELLAR_NETWORK, horizenTestnet } from '@/config'; +import { useChain } from '@/context/ChainContext'; +import { getConsent, subscribeToConsent } from '@/lib/telemetry'; +import { getPrivacyPosture, getRpcHost, type RpcRoute } from '@/lib/privacy-posture'; + +const RPC_ROUTES: RpcRoute[] = [ + { + chain: 'horizen', + label: 'Horizen', + url: horizenTestnet.rpcUrls.default.http[0], + defaultUrl: 'https://horizen-testnet.rpc.caldera.xyz/http', + }, + { + chain: 'stellar', + label: 'Stellar RPC', + url: STELLAR_NETWORK.rpcUrl, + defaultUrl: 'https://soroban-testnet.stellar.org', + }, + { + chain: 'stellar', + label: 'Stellar Horizon', + url: STELLAR_NETWORK.horizonUrl, + defaultUrl: 'https://horizon-testnet.stellar.org', + }, + { + chain: 'solana', + label: 'Solana', + url: SOLANA_NETWORK.rpcUrl, + defaultUrl: 'https://api.devnet.solana.com', + }, + { + chain: 'ckb', + label: 'CKB', + url: CKB_NETWORK.rpcUrl, + defaultUrl: 'https://testnet.ckb.dev/rpc', + }, +]; + +export function PrivacyPostureChip() { + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + const triggerRef = useRef(null); + const detailsId = useId(); + const { chain } = useChain(); + const consent = useSyncExternalStore(subscribeToConsent, getConsent, () => null); + const posture = getPrivacyPosture(consent === 'accepted', RPC_ROUTES, chain); + + useEffect(() => { + if (!open) return; + + function handlePointerDown(event: PointerEvent) { + if (!containerRef.current?.contains(event.target as Node)) setOpen(false); + } + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') { + setOpen(false); + triggerRef.current?.focus(); + } + } + + document.addEventListener('pointerdown', handlePointerDown); + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('pointerdown', handlePointerDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [open]); + + return ( +
+ + + {open && ( + + )} +
+ ); +} diff --git a/src/lib/privacy-posture.test.ts b/src/lib/privacy-posture.test.ts new file mode 100644 index 0000000..a1a5b33 --- /dev/null +++ b/src/lib/privacy-posture.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { getPrivacyPosture, getRpcHost, type RpcRoute } from './privacy-posture'; + +const defaultRoute: RpcRoute = { + chain: 'stellar', + url: 'https://soroban-testnet.stellar.org', + defaultUrl: 'https://soroban-testnet.stellar.org', +}; + +const privateRoute: RpcRoute = { + chain: 'stellar', + url: 'https://rpc.example.internal', + defaultUrl: 'https://soroban-testnet.stellar.org', +}; + +describe('getPrivacyPosture', () => { + it('is strict when telemetry is off and all relevant RPC routes are non-default', () => { + expect(getPrivacyPosture(false, [privateRoute])).toBe('strict'); + }); + + it('is relaxed when telemetry is on with a non-default RPC', () => { + expect(getPrivacyPosture(true, [privateRoute])).toBe('relaxed'); + }); + + it('is relaxed when telemetry is off with a default RPC', () => { + expect(getPrivacyPosture(false, [defaultRoute])).toBe('relaxed'); + }); + + it('is relaxed when telemetry is on with a default RPC', () => { + expect(getPrivacyPosture(true, [defaultRoute])).toBe('relaxed'); + }); + + it('uses only the active chain when deriving posture', () => { + const defaultSolanaRoute: RpcRoute = { + chain: 'solana', + url: 'https://api.devnet.solana.com', + defaultUrl: 'https://api.devnet.solana.com', + }; + + expect(getPrivacyPosture(false, [privateRoute, defaultSolanaRoute], 'stellar')).toBe('strict'); + expect(getPrivacyPosture(false, [privateRoute, defaultSolanaRoute], 'solana')).toBe('relaxed'); + }); + + it('treats RPC query-string differences as non-default routing', () => { + const proxiedRoute: RpcRoute = { + chain: 'stellar', + url: 'https://rpc.example.test/http?upstream=private', + defaultUrl: 'https://rpc.example.test/http?upstream=public', + }; + + expect(getPrivacyPosture(false, [proxiedRoute], 'stellar')).toBe('strict'); + }); + + it('is relaxed when the active chain has no configured routes', () => { + expect(getPrivacyPosture(false, [privateRoute], 'ckb')).toBe('relaxed'); + }); +}); + +describe('getRpcHost', () => { + it('extracts a copyable host without leaking path details', () => { + expect(getRpcHost('https://testnet.ckb.dev/rpc')).toBe('testnet.ckb.dev'); + }); +}); diff --git a/src/lib/privacy-posture.ts b/src/lib/privacy-posture.ts new file mode 100644 index 0000000..dca4a04 --- /dev/null +++ b/src/lib/privacy-posture.ts @@ -0,0 +1,42 @@ +export type PrivacyPosture = 'strict' | 'relaxed'; + +export interface RpcRoute { + chain: string; + label?: string; + url: string; + defaultUrl: string; +} + +function normalizeRpcUrl(url: string): string { + try { + const parsed = new URL(url); + return `${parsed.protocol}//${parsed.host}${parsed.pathname.replace(/\/$/, '')}${parsed.search}`; + } catch { + return url.replace(/\/$/, ''); + } +} + +export function getRpcHost(url: string): string { + try { + return new URL(url).host; + } catch { + return url; + } +} + +export function getPrivacyPosture( + telemetryEnabled: boolean, + routes: readonly RpcRoute[], + activeChain?: string, +): PrivacyPosture { + const relevantRoutes = activeChain + ? routes.filter((route) => route.chain === activeChain) + : routes; + const allRoutesAreNonDefault = + relevantRoutes.length > 0 && + relevantRoutes.every( + (route) => normalizeRpcUrl(route.url) !== normalizeRpcUrl(route.defaultUrl), + ); + + return !telemetryEnabled && allRoutesAreNonDefault ? 'strict' : 'relaxed'; +} diff --git a/src/lib/telemetry.test.ts b/src/lib/telemetry.test.ts new file mode 100644 index 0000000..7dee7ef --- /dev/null +++ b/src/lib/telemetry.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getConsent, setConsent, subscribeToConsent } from './telemetry'; + +function createMemoryStorage() { + const values = new Map(); + return { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + clear: () => values.clear(), + }; +} + +describe('telemetry consent subscriptions', () => { + beforeEach(() => { + vi.stubGlobal('localStorage', createMemoryStorage()); + vi.stubGlobal('window', new EventTarget()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('notifies same-tab subscribers immediately when consent changes', () => { + let updates = 0; + const unsubscribe = subscribeToConsent(() => { + updates += 1; + }); + + setConsent('accepted'); + + expect(getConsent()).toBe('accepted'); + expect(updates).toBe(1); + + unsubscribe(); + setConsent('declined'); + expect(updates).toBe(1); + }); + + it('notifies subscribers when another tab clears storage', () => { + let updates = 0; + const unsubscribe = subscribeToConsent(() => { + updates += 1; + }); + + const event = new Event('storage') as StorageEvent; + Object.defineProperty(event, 'key', { value: null }); + window.dispatchEvent(event); + + expect(updates).toBe(1); + unsubscribe(); + }); + + it('returns null when storage access is blocked', () => { + vi.stubGlobal('localStorage', { + getItem: () => { + throw new Error('blocked'); + }, + setItem: () => { + throw new Error('blocked'); + }, + }); + + expect(getConsent()).toBeNull(); + expect(() => setConsent('accepted')).not.toThrow(); + }); + + it('is safe when window and localStorage are unavailable', () => { + vi.stubGlobal('window', undefined); + vi.stubGlobal('localStorage', undefined); + + expect(getConsent()).toBeNull(); + expect(() => setConsent('accepted')).not.toThrow(); + expect(() => subscribeToConsent(() => undefined)()).not.toThrow(); + }); +}); diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index 3025a3a..6869c2f 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -1,30 +1,62 @@ const STORAGE_KEY = 'wraith-telemetry-consent'; +const CONSENT_CHANGE_EVENT = 'wraith-telemetry-consent-change'; export type ConsentState = 'accepted' | 'declined' | null; export function getConsent(): ConsentState { - const val = localStorage.getItem(STORAGE_KEY); - if (val === 'accepted' || val === 'declined') return val; + if (typeof localStorage === 'undefined') return null; + + try { + const val = localStorage.getItem(STORAGE_KEY); + if (val === 'accepted' || val === 'declined') return val; + } catch { + return null; + } + return null; } export function setConsent(state: 'accepted' | 'declined'): void { - localStorage.setItem(STORAGE_KEY, state); + if (typeof window === 'undefined' || typeof localStorage === 'undefined') return; + + try { + localStorage.setItem(STORAGE_KEY, state); + } catch { + return; + } + + window.dispatchEvent(new Event(CONSENT_CHANGE_EVENT)); +} + +export function subscribeToConsent(onChange: () => void): () => void { + if (typeof window === 'undefined') return () => undefined; + + function handleStorage(event: StorageEvent) { + if (event.key === STORAGE_KEY || event.key === null) onChange(); + } + + window.addEventListener(CONSENT_CHANGE_EVENT, onChange); + window.addEventListener('storage', handleStorage); + + return () => { + window.removeEventListener(CONSENT_CHANGE_EVENT, onChange); + window.removeEventListener('storage', handleStorage); + }; } -function isEnabled(): boolean { +export function isTelemetryEnabled(): boolean { return getConsent() === 'accepted'; } export function trackPageView(path: string): void { - if (!isEnabled()) return; - if (typeof window.plausible === 'undefined') return; + if (!isTelemetryEnabled()) return; + if (typeof window === 'undefined' || typeof window.plausible === 'undefined') return; window.plausible('pageview', { u: window.location.origin + path }); } export function trackEvent(name: string): void { - if (!isEnabled()) return; - if (typeof window.plausible === 'undefined') return; + if (!isTelemetryEnabled()) return; + if (typeof window === 'undefined' || typeof window.plausible === 'undefined') return; window.plausible(name); } diff --git a/src/pages/Privacy.tsx b/src/pages/Privacy.tsx index 9f1f9a3..4dc5b70 100644 --- a/src/pages/Privacy.tsx +++ b/src/pages/Privacy.tsx @@ -1,17 +1,32 @@ -import { useEffect } from 'react'; -import { trackPageView } from '@/lib/telemetry'; +import { useEffect, useSyncExternalStore } from 'react'; +import { getConsent, setConsent, subscribeToConsent, trackPageView } from '@/lib/telemetry'; export default function Privacy() { + const consent = useSyncExternalStore(subscribeToConsent, getConsent, () => null); + useEffect(() => { trackPageView('/privacy'); }, []); + return (

Privacy Policy

-

Last updated: June 2026

+

Last updated: August 2026

+
+

Infrastructure privacy

+

+ The privacy posture chip in the header reads RPC hostnames and telemetry consent locally + in your browser. It makes no network requests of its own. +

+

+ RPC URLs and hostnames are never included in analytics events. The chip is separate from + the per-scan privacy score shown during receive flows. +

+
+

What we collect

@@ -37,6 +52,9 @@ export default function Privacy() {

  • Transaction amounts
  • +
  • + RPC URLs or RPC hostnames +
  • IP addresses
  • @@ -61,9 +79,35 @@ export default function Privacy() {

    Your choice

    - Analytics is strictly opt-in. You are asked once on your first visit. You can change your - choice at any time by clearing your browser's local storage for this site. + Analytics is strictly opt-in. You can change your choice at any time; the privacy posture + chip updates immediately.

    +
    + + +
    );