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 (
{
- navigator.clipboard.writeText(text);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- }}
+ onClick={() => void copyText()}
className="shrink-0 font-mono text-[10px] uppercase tracking-widest text-outline transition-colors hover:text-primary"
>
{copied ? t('copyButton.copied') : t('copyButton.copy')}
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
index 70d439a..151012a 100644
--- a/src/components/Header.tsx
+++ b/src/components/Header.tsx
@@ -5,6 +5,7 @@ import { ChainSwitcher } from './ChainSwitcher';
import { WalletConnect } from './WalletConnect';
import { LocaleSwitcher } from './LocaleSwitcher';
import { NetworkChip } from './NetworkChip';
+import { PrivacyPostureChip } from './PrivacyPostureChip';
import { useTheme } from '@/context/ThemeContext';
import { useNotificationsStore } from '@/stores/notificationsStore';
@@ -91,6 +92,7 @@ export function Header() {
@@ -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 (
+
+
setOpen((value) => !value)}
+ className="inline-flex items-center gap-1.5 border border-outline-variant px-2 py-0.5 font-mono text-[9px] tracking-wider text-on-surface transition-colors hover:border-outline"
+ >
+
+ Privacy: {posture}
+
+
+ {open && (
+
+
+
+
+ Infrastructure privacy
+
+
+ Telemetry: {consent === 'accepted' ? 'on' : 'off'}
+
+
+
+ {posture}
+
+
+
+
+ {RPC_ROUTES.map((route) => {
+ const host = getRpcHost(route.url);
+ return (
+
+
+
+ {route.label ?? route.chain}
+
+
+ {host}
+
+
+
+
+ );
+ })}
+
+
+
+ setOpen(false)}
+ className="font-mono text-[10px] uppercase tracking-widest text-primary underline underline-offset-2"
+ >
+ Review privacy settings
+
+
+
+ )}
+
+ );
+}
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.
+
+ setConsent('declined')}
+ className={`border px-3 py-1.5 font-mono text-[10px] uppercase tracking-widest transition-colors ${
+ consent !== 'accepted'
+ ? 'border-on-surface bg-on-surface text-surface'
+ : 'border-outline-variant text-on-surface-variant hover:border-outline'
+ }`}
+ >
+ Telemetry off
+
+ setConsent('accepted')}
+ className={`border px-3 py-1.5 font-mono text-[10px] uppercase tracking-widest transition-colors ${
+ consent === 'accepted'
+ ? 'border-on-surface bg-on-surface text-surface'
+ : 'border-outline-variant text-on-surface-variant hover:border-outline'
+ }`}
+ >
+ Telemetry on
+
+
);