+ );
+}
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.