From c7666f308af7f1829c1d93773f43efd8d917e58e Mon Sep 17 00:00:00 2001 From: Adedoyinjr <110180947+Adedoyinjr@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:47:52 +0100 Subject: [PATCH 01/18] feat(privacy): add infrastructure posture chip --- src/components/Header.tsx | 8 ++ src/components/PrivacyPostureChip.tsx | 134 ++++++++++++++++++++++++++ src/lib/privacy-posture.test.ts | 42 ++++++++ src/lib/privacy-posture.ts | 35 +++++++ src/lib/telemetry.ts | 22 ++++- src/pages/Privacy.tsx | 56 ++++++++++- 6 files changed, 289 insertions(+), 8 deletions(-) create mode 100644 src/components/PrivacyPostureChip.tsx create mode 100644 src/lib/privacy-posture.test.ts create mode 100644 src/lib/privacy-posture.ts 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..284a970 --- /dev/null +++ b/src/components/PrivacyPostureChip.tsx @@ -0,0 +1,134 @@ +import { useEffect, 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 { getConsent, subscribeToConsent } from '@/lib/telemetry'; +import { getPrivacyPosture, getRpcHost, type RpcRoute } from '@/lib/privacy-posture'; + +const RPC_ROUTES: RpcRoute[] = [ + { + chain: 'Horizen', + url: horizenTestnet.rpcUrls.default.http[0], + defaultUrl: 'https://horizen-testnet.rpc.caldera.xyz/http', + }, + { + chain: 'Stellar RPC', + url: STELLAR_NETWORK.rpcUrl, + defaultUrl: 'https://soroban-testnet.stellar.org', + }, + { + chain: 'Stellar Horizon', + url: STELLAR_NETWORK.horizonUrl, + defaultUrl: 'https://horizon-testnet.stellar.org', + }, + { + chain: 'Solana', + url: SOLANA_NETWORK.rpcUrl, + defaultUrl: 'https://api.devnet.solana.com', + }, + { + chain: 'CKB', + url: CKB_NETWORK.rpcUrl, + defaultUrl: 'https://testnet.ckb.dev/rpc', + }, +]; + +export function PrivacyPostureChip() { + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + const consent = useSyncExternalStore(subscribeToConsent, getConsent, () => null); + const posture = getPrivacyPosture(consent === 'accepted', RPC_ROUTES); + + 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); + } + + 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..3ad911f --- /dev/null +++ b/src/lib/privacy-posture.test.ts @@ -0,0 +1,42 @@ +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 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('requires every configured RPC route to be non-default for strict mode', () => { + expect(getPrivacyPosture(false, [privateRoute, defaultRoute])).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..71686b4 --- /dev/null +++ b/src/lib/privacy-posture.ts @@ -0,0 +1,35 @@ +export type PrivacyPosture = 'strict' | 'relaxed'; + +export interface RpcRoute { + chain: string; + url: string; + defaultUrl: string; +} + +function normalizeRpcUrl(url: string): string { + try { + const parsed = new URL(url); + return `${parsed.protocol}//${parsed.host}${parsed.pathname.replace(/\/$/, '')}`; + } 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[], +): PrivacyPosture { + const allRoutesAreNonDefault = + routes.length > 0 && + routes.every((route) => normalizeRpcUrl(route.url) !== normalizeRpcUrl(route.defaultUrl)); + + return !telemetryEnabled && allRoutesAreNonDefault ? 'strict' : 'relaxed'; +} diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index 3025a3a..91daa97 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -1,4 +1,5 @@ const STORAGE_KEY = 'wraith-telemetry-consent'; +const CONSENT_CHANGE_EVENT = 'wraith-telemetry-consent-change'; export type ConsentState = 'accepted' | 'declined' | null; @@ -10,20 +11,35 @@ export function getConsent(): ConsentState { export function setConsent(state: 'accepted' | 'declined'): void { localStorage.setItem(STORAGE_KEY, state); + window.dispatchEvent(new Event(CONSENT_CHANGE_EVENT)); } -function isEnabled(): boolean { +export function subscribeToConsent(onChange: () => void): () => void { + function handleStorage(event: StorageEvent) { + if (event.key === STORAGE_KEY) onChange(); + } + + window.addEventListener(CONSENT_CHANGE_EVENT, onChange); + window.addEventListener('storage', handleStorage); + + return () => { + window.removeEventListener(CONSENT_CHANGE_EVENT, onChange); + window.removeEventListener('storage', handleStorage); + }; +} + +export function isTelemetryEnabled(): boolean { return getConsent() === 'accepted'; } export function trackPageView(path: string): void { - if (!isEnabled()) return; + if (!isTelemetryEnabled()) return; if (typeof window.plausible === 'undefined') return; window.plausible('pageview', { u: window.location.origin + path }); } export function trackEvent(name: string): void { - if (!isEnabled()) return; + if (!isTelemetryEnabled()) return; if (typeof window.plausible === 'undefined') return; window.plausible(name); } diff --git a/src/pages/Privacy.tsx b/src/pages/Privacy.tsx index 9f1f9a3..0a877c6 100644 --- a/src/pages/Privacy.tsx +++ b/src/pages/Privacy.tsx @@ -1,17 +1,34 @@ -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 +54,9 @@ export default function Privacy() {

  • Transaction amounts
  • +
  • + RPC URLs or RPC hostnames +
  • IP addresses
  • @@ -61,9 +81,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.

    +
    + + +
    ); From c98e38e3f0083b54ac18116905dea5d6aa234916 Mon Sep 17 00:00:00 2001 From: Adedoyinjr <110180947+Adedoyinjr@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:49:29 +0100 Subject: [PATCH 02/18] test(privacy): cover consent reactivity --- src/lib/telemetry.test.ts | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/lib/telemetry.test.ts diff --git a/src/lib/telemetry.test.ts b/src/lib/telemetry.test.ts new file mode 100644 index 0000000..d2e28d9 --- /dev/null +++ b/src/lib/telemetry.test.ts @@ -0,0 +1,37 @@ +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), + }; +} + +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); + }); +}); From b19c08775b5779460bb8f4e5278258f5cde3f4d5 Mon Sep 17 00:00:00 2001 From: Adedoyinjr <110180947+Adedoyinjr@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:44:28 +0100 Subject: [PATCH 03/18] fix(privacy): derive posture from active chain routes --- src/lib/privacy-posture.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/lib/privacy-posture.ts b/src/lib/privacy-posture.ts index 71686b4..1339855 100644 --- a/src/lib/privacy-posture.ts +++ b/src/lib/privacy-posture.ts @@ -2,6 +2,7 @@ export type PrivacyPosture = 'strict' | 'relaxed'; export interface RpcRoute { chain: string; + label?: string; url: string; defaultUrl: string; } @@ -9,7 +10,7 @@ export interface RpcRoute { function normalizeRpcUrl(url: string): string { try { const parsed = new URL(url); - return `${parsed.protocol}//${parsed.host}${parsed.pathname.replace(/\/$/, '')}`; + return `${parsed.protocol}//${parsed.host}${parsed.pathname.replace(/\/$/, '')}${parsed.search}`; } catch { return url.replace(/\/$/, ''); } @@ -26,10 +27,14 @@ export function getRpcHost(url: string): string { export function getPrivacyPosture( telemetryEnabled: boolean, routes: readonly RpcRoute[], + activeChain?: string, ): PrivacyPosture { + const relevantRoutes = activeChain ? routes.filter((route) => route.chain === activeChain) : routes; const allRoutesAreNonDefault = - routes.length > 0 && - routes.every((route) => normalizeRpcUrl(route.url) !== normalizeRpcUrl(route.defaultUrl)); + relevantRoutes.length > 0 && + relevantRoutes.every( + (route) => normalizeRpcUrl(route.url) !== normalizeRpcUrl(route.defaultUrl), + ); return !telemetryEnabled && allRoutesAreNonDefault ? 'strict' : 'relaxed'; } From c740406d5b37301e2c8cb61f78bd4dc19962db7b Mon Sep 17 00:00:00 2001 From: Adedoyinjr <110180947+Adedoyinjr@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:44:46 +0100 Subject: [PATCH 04/18] fix(privacy): harden posture chip interaction --- src/components/PrivacyPostureChip.tsx | 39 ++++++++++++++++++--------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/src/components/PrivacyPostureChip.tsx b/src/components/PrivacyPostureChip.tsx index 284a970..d2ce31f 100644 --- a/src/components/PrivacyPostureChip.tsx +++ b/src/components/PrivacyPostureChip.tsx @@ -1,33 +1,39 @@ -import { useEffect, useRef, useState, useSyncExternalStore } from 'react'; +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', + chain: 'horizen', + label: 'Horizen', url: horizenTestnet.rpcUrls.default.http[0], defaultUrl: 'https://horizen-testnet.rpc.caldera.xyz/http', }, { - chain: 'Stellar RPC', + chain: 'stellar', + label: 'Stellar RPC', url: STELLAR_NETWORK.rpcUrl, defaultUrl: 'https://soroban-testnet.stellar.org', }, { - chain: 'Stellar Horizon', + chain: 'stellar', + label: 'Stellar Horizon', url: STELLAR_NETWORK.horizonUrl, defaultUrl: 'https://horizon-testnet.stellar.org', }, { - chain: 'Solana', + chain: 'solana', + label: 'Solana', url: SOLANA_NETWORK.rpcUrl, defaultUrl: 'https://api.devnet.solana.com', }, { - chain: 'CKB', + chain: 'ckb', + label: 'CKB', url: CKB_NETWORK.rpcUrl, defaultUrl: 'https://testnet.ckb.dev/rpc', }, @@ -36,8 +42,11 @@ const RPC_ROUTES: RpcRoute[] = [ 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); + const posture = getPrivacyPosture(consent === 'accepted', RPC_ROUTES, chain); useEffect(() => { if (!open) return; @@ -47,7 +56,10 @@ export function PrivacyPostureChip() { } function handleKeyDown(event: KeyboardEvent) { - if (event.key === 'Escape') setOpen(false); + if (event.key === 'Escape') { + setOpen(false); + triggerRef.current?.focus(); + } } document.addEventListener('pointerdown', handlePointerDown); @@ -62,12 +74,13 @@ export function PrivacyPostureChip() { return (