Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions src/components/CopyButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<button
type="button"
aria-label={`Copy ${text}`}
onClick={() => {
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')}
Expand Down
8 changes: 8 additions & 0 deletions src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -91,6 +92,7 @@ export function Header() {
</button>
<div className="hidden sm:flex sm:items-center sm:gap-3">
<ChainSwitcher />
<PrivacyPostureChip />
<NetworkChip />
<WalletConnect />
</div>
Expand Down Expand Up @@ -153,6 +155,12 @@ export function Header() {
</span>
<ChainSwitcher />
</div>
<div className="flex items-center justify-between gap-3">
<span className="font-heading text-[10px] uppercase tracking-widest text-outline">
Privacy posture
</span>
<PrivacyPostureChip />
</div>
<div className="flex items-center justify-between gap-3">
<span className="font-heading text-[10px] uppercase tracking-widest text-outline">
{t('header.wallet')}
Expand Down
150 changes: 150 additions & 0 deletions src/components/PrivacyPostureChip.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(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 (
<div ref={containerRef} className="relative">
<button
ref={triggerRef}
type="button"
aria-expanded={open}
aria-haspopup="dialog"
aria-controls={detailsId}
onClick={() => 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"
>
<span
aria-hidden="true"
className={`inline-block h-1.5 w-1.5 rounded-full ${
posture === 'strict' ? 'bg-tertiary' : 'bg-outline'
}`}
/>
Privacy: {posture}
</button>

{open && (
<div
id={detailsId}
role="dialog"
aria-label="Privacy infrastructure posture"
className="absolute right-0 z-50 mt-2 w-80 max-w-[calc(100vw-2rem)] border border-outline-variant bg-surface-container p-4 shadow-lg"
>
<div className="flex items-center justify-between gap-4 border-b border-outline-variant/40 pb-3">
<div>
<p className="font-heading text-xs font-semibold uppercase tracking-widest text-on-surface">
Infrastructure privacy
</p>
<p className="mt-1 font-mono text-[10px] uppercase tracking-wider text-on-surface-variant">
Telemetry: {consent === 'accepted' ? 'on' : 'off'}
</p>
</div>
<span className="font-mono text-[10px] uppercase tracking-wider text-on-surface">
{posture}
</span>
</div>

<div className="mt-3 space-y-3">
{RPC_ROUTES.map((route) => {
const host = getRpcHost(route.url);
return (
<div
key={route.label ?? route.chain}
className="flex items-start justify-between gap-3"
>
<div className="min-w-0">
<p className="font-heading text-[10px] uppercase tracking-wider text-on-surface-variant">
{route.label ?? route.chain}
</p>
<p className="truncate font-mono text-[11px] text-on-surface" title={host}>
{host}
</p>
</div>
<CopyButton text={host} />
</div>
);
})}
</div>

<div className="mt-4 border-t border-outline-variant/40 pt-3">
<Link
to="/privacy"
onClick={() => setOpen(false)}
className="font-mono text-[10px] uppercase tracking-widest text-primary underline underline-offset-2"
>
Review privacy settings
</Link>
</div>
</div>
)}
</div>
);
}
63 changes: 63 additions & 0 deletions src/lib/privacy-posture.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
42 changes: 42 additions & 0 deletions src/lib/privacy-posture.ts
Original file line number Diff line number Diff line change
@@ -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';
}
76 changes: 76 additions & 0 deletions src/lib/telemetry.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();
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();
});
});
Loading
Loading