diff --git a/index.html b/index.html index 3076c14..a32c554 100644 --- a/index.html +++ b/index.html @@ -53,7 +53,10 @@ content="Send and receive private payments on Horizen and Stellar using stealth addresses." /> - + + + + diff --git a/src/App.tsx b/src/App.tsx index 65d0b57..3958d3c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,10 @@ import { Routes, Route, Navigate } from 'react-router-dom'; import { Header } from '@/components/Header'; import { AutoSign } from '@/components/AutoSign'; +import { TelemetryBanner } from '@/components/TelemetryBanner'; +import Send from '@/pages/Send'; +import Receive from '@/pages/Receive'; +import Privacy from '@/pages/Privacy'; import { HelpButton } from '@/components/HelpButton'; import Send from '@/pages/Send'; import Receive from '@/pages/Receive'; @@ -10,11 +14,13 @@ export function App() { return (
+
} /> } /> + } /> } /> } /> } /> @@ -23,4 +29,4 @@ export function App() {
); -} +} \ No newline at end of file diff --git a/src/components/CkbReceive.tsx b/src/components/CkbReceive.tsx index 183571e..aef6954 100644 --- a/src/components/CkbReceive.tsx +++ b/src/components/CkbReceive.tsx @@ -11,6 +11,7 @@ import { } from '@wraith-protocol/sdk/chains/ckb'; import { useStealthKeys } from '@/context/StealthKeysContext'; import { CopyButton } from '@/components/CopyButton'; +import { trackEvent } from '@/lib/telemetry'; function CkbStealthRow({ match }: { match: MatchedStealthCell }) { const [showKey, setShowKey] = useState(false); @@ -129,6 +130,7 @@ export function CkbReceive() { ); setMatched(results); setHasScanned(true); + trackEvent('scan_triggered'); } catch (err) { setError(err instanceof Error ? err.message : 'Scan failed'); } finally { diff --git a/src/components/CkbSend.tsx b/src/components/CkbSend.tsx index ae5261c..da23000 100644 --- a/src/components/CkbSend.tsx +++ b/src/components/CkbSend.tsx @@ -6,6 +6,7 @@ import { getDeployment, } from '@wraith-protocol/sdk/chains/ckb'; import { CopyButton } from '@/components/CopyButton'; +import { trackEvent } from '@/lib/telemetry'; const STEALTH_LOCK_CODE_HASH = getDeployment('ckb').contracts.stealthLockCodeHash; @@ -71,6 +72,7 @@ export function CkbSend() { await tx.completeFeeBy(signer, 1000); const hash = await signer.sendTransaction(tx); + trackEvent('send_submitted'); setTxHash(hash); } catch (err) { setError(err instanceof Error ? err.message : 'Transaction failed'); diff --git a/src/components/HorizenReceive.tsx b/src/components/HorizenReceive.tsx index 3ac860a..ebb62de 100644 --- a/src/components/HorizenReceive.tsx +++ b/src/components/HorizenReceive.tsx @@ -21,6 +21,7 @@ import { } from '@wraith-protocol/sdk/chains/evm'; import type { HexString, MatchedAnnouncement } from '@wraith-protocol/sdk/chains/evm'; import { useStealthKeys } from '@/context/StealthKeysContext'; +import { trackEvent } from '@/lib/telemetry'; import { CopyButton } from '@/components/CopyButton'; import { horizenTxUrl, horizenAddrUrl } from '@/lib/explorer'; import { horizenTestnet } from '@/config'; @@ -87,6 +88,7 @@ function StealthRow({ }); setWithdrawHash(hash); + trackEvent('withdraw'); onWithdrawn(hash); } catch (err) { setError(err instanceof Error ? err.message : 'Withdraw failed'); @@ -278,6 +280,7 @@ export function HorizenReceive() { ); setMatched(results); setHasScanned(true); + trackEvent('scan_triggered'); } catch (err) { setError(err instanceof Error ? err.message : 'Scan failed'); } finally { diff --git a/src/components/HorizenSend.tsx b/src/components/HorizenSend.tsx index b05e81a..1732619 100644 --- a/src/components/HorizenSend.tsx +++ b/src/components/HorizenSend.tsx @@ -5,6 +5,7 @@ import type { HexString, BuildSendStealthResult } from '@wraith-protocol/sdk/cha import { horizenTxUrl, horizenAddrUrl } from '@/lib/explorer'; import { horizenTestnet } from '@/config'; import { CopyButton } from '@/components/CopyButton'; +import { trackEvent } from '@/lib/telemetry'; export function HorizenSend() { const { isConnected, address } = useAccount(); @@ -61,6 +62,7 @@ export function HorizenSend() { }); setStealthResult(result); + trackEvent('send_submitted'); sendTransaction({ to: result.transaction.to as `0x${string}`, diff --git a/src/components/SolanaReceive.tsx b/src/components/SolanaReceive.tsx index 8856fa2..552daa4 100644 --- a/src/components/SolanaReceive.tsx +++ b/src/components/SolanaReceive.tsx @@ -12,6 +12,7 @@ import { import type { MatchedAnnouncement } from '@wraith-protocol/sdk/chains/solana'; import { useStealthKeys } from '@/context/StealthKeysContext'; import { CopyButton } from '@/components/CopyButton'; +import { trackEvent } from '@/lib/telemetry'; import { solanaTxUrl, solanaAddrUrl } from '@/lib/explorer'; import { SOLANA_NETWORK } from '@/config'; @@ -92,6 +93,7 @@ function SolanaStealthRow({ await connection.confirmTransaction(txId, 'confirmed'); setWithdrawHash(txId); + trackEvent('withdraw'); onWithdrawn(); } catch (err) { setError(err instanceof Error ? err.message : 'Withdraw failed'); @@ -245,6 +247,7 @@ export function SolanaReceive() { ); setMatched(results); setHasScanned(true); + trackEvent('scan_triggered'); } catch (err) { setError(err instanceof Error ? err.message : 'Scan failed'); } finally { diff --git a/src/components/SolanaSend.tsx b/src/components/SolanaSend.tsx index 2fea1bd..f19d781 100644 --- a/src/components/SolanaSend.tsx +++ b/src/components/SolanaSend.tsx @@ -11,6 +11,7 @@ import { buildSendSol, bytesToHex } from '@wraith-protocol/sdk/chains/solana'; import { solanaTxUrl, solanaAddrUrl } from '@/lib/explorer'; import { SOLANA_NETWORK } from '@/config'; import { CopyButton } from '@/components/CopyButton'; +import { trackEvent } from '@/lib/telemetry'; export function SolanaSend() { const { publicKey, connected, sendTransaction } = useWallet(); @@ -48,6 +49,7 @@ export function SolanaSend() { amount: lamports, senderPubkey: publicKey.toBase58(), }); + trackEvent('send_submitted'); setStealthResult({ stealthAddress: result.stealthAddress, ephemeralPubKey: result.ephemeralPubKey, diff --git a/src/components/StellarReceive.tsx b/src/components/StellarReceive.tsx index 225aae4..7e39dce 100644 --- a/src/components/StellarReceive.tsx +++ b/src/components/StellarReceive.tsx @@ -24,11 +24,9 @@ import type { } from '@wraith-protocol/sdk/chains/stellar'; import { useStealthKeys } from '@/context/StealthKeysContext'; import { useStellarWallet } from '@/context/StellarWalletContext'; -import { StellarMatchCard } from '@/components/StellarMatchCard'; -import { StellarReceiveView } from '@/components/StellarReceiveView'; -import { QRCodeModal } from '@/components/QRCodeModal'; -import { useStealthLabels } from '@/hooks/useStealthLabels'; -import { useStellarNotifications } from '@/hooks/useStellarNotifications'; +import { CopyButton } from '@/components/CopyButton'; +import { trackEvent } from '@/lib/telemetry'; +import { stellarTxUrl, stellarAddrUrl } from '@/lib/explorer'; import { STELLAR_NETWORK } from '@/config'; import { fetchWithRetry, withRetry, RetryExhaustedError } from '@/lib/stellar/retry'; import { useActivityStore } from '@/stores/activityStore'; @@ -318,7 +316,7 @@ function StellarMatchCardContainer({ } setWithdrawHash(submitData.hash); - updateActivity(txHashHex, 'confirmed'); + trackEvent('withdraw'); onWithdrawn(); } catch (err) { setRetryStatus(''); @@ -1026,43 +1024,9 @@ export function StellarReceive() { new URL('../workers/stellar-scanner.worker.ts', import.meta.url), { type: 'module' }, ); - workerRef.current.onmessage = (e) => { - if (e.data.type === 'SUCCESS') { - const results = e.data.results; - setMatched(results); - - results.forEach((m: MatchedAnnouncement) => { - addActivity({ - id: m.stealthAddress, // use address as unique id for receives - chain: 'stellar', - wallet: address || '', - kind: 'stealth-receive', - direction: 'in', - status: 'confirmed', // immediately confirmed since it's discovered - timestamp: Date.now(), - }); - }); - - setHasScanned(true); - setIsScanning(false); - } else if (e.data.type === 'ERROR') { - setError(e.data.error); - setIsScanning(false); - } - }; - - workerRef.current.onerror = () => { - setError('Worker crashed'); - setIsScanning(false); - }; - - workerRef.current.postMessage({ - rpcUrl: STELLAR_NETWORK.rpcUrl, - announcerContract: ANNOUNCER_CONTRACT, - viewingKey: stellarKeys.viewingKey, - spendingPubKey: stellarKeys.spendingPubKey, - spendingScalar: stellarKeys.spendingScalar, - }); + setMatched(results); + setHasScanned(true); + trackEvent('scan_triggered'); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to start worker'); setIsScanning(false); diff --git a/src/components/StellarSend.tsx b/src/components/StellarSend.tsx index b625e50..61d13ca 100644 --- a/src/components/StellarSend.tsx +++ b/src/components/StellarSend.tsx @@ -18,15 +18,8 @@ import { } from '@wraith-protocol/sdk/chains/stellar'; import { useStellarWallet } from '@/context/StellarWalletContext'; import { STELLAR_NETWORK } from '@/config'; -import { StellarSendView } from '@/components/StellarSendView'; -import { useActivityStore } from '@/stores/activityStore'; -import { QrReader } from 'react-qr-reader'; -import { - emptyStellarSendSimulation, - simulateStellarSendAnnouncement, - type StellarSendSimulationState, -} from '@/lib/stellarSimulation'; -import { fetchWithRetry, withRetry, RetryExhaustedError } from '@/lib/stellar/retry'; +import { CopyButton } from '@/components/CopyButton'; +import { trackEvent } from '@/lib/telemetry'; const ANNOUNCER_CONTRACT = 'CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL'; const STELLAR_BASE_FEE_XLM = 0.00001; @@ -403,6 +396,7 @@ export function StellarSend() { const decoded = decodeStealthMetaAddress(metaAddress); const result = generateStealthAddress(decoded.spendingPubKey, decoded.viewingPubKey); setStealthResult(result); + trackEvent('send_submitted'); const horizonUrl = STELLAR_NETWORK.horizonUrl; const networkPassphrase = STELLAR_NETWORK.networkPassphrase; diff --git a/src/components/TelemetryBanner.tsx b/src/components/TelemetryBanner.tsx new file mode 100644 index 0000000..3922d99 --- /dev/null +++ b/src/components/TelemetryBanner.tsx @@ -0,0 +1,51 @@ +import { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import { getConsent, setConsent, type ConsentState } from '@/lib/telemetry'; + +export function TelemetryBanner() { + const [consent, setConsentState] = useState(null); + + useEffect(() => { + setConsentState(getConsent()); + }, []); + + if (consent !== null) return null; + + function handleAccept() { + setConsent('accepted'); + setConsentState('accepted'); + } + + function handleDecline() { + setConsent('declined'); + setConsentState('declined'); + } + + return ( +
+
+

+ We use cookieless analytics to improve this demo.{' '} + + Privacy page + + . No wallet addresses or amounts are ever collected. +

+
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/WalletConnect.tsx b/src/components/WalletConnect.tsx index beedf5c..0cdee19 100644 --- a/src/components/WalletConnect.tsx +++ b/src/components/WalletConnect.tsx @@ -4,6 +4,13 @@ import { useWallet } from '@solana/wallet-adapter-react'; import { WalletMultiButton } from '@solana/wallet-adapter-react-ui'; import { ccc } from '@ckb-ccc/connector-react'; import { useChain } from '@/context/ChainContext'; +import { useStellarWallet } from '@/context/StellarWalletContext'; +import { trackEvent } from '@/lib/telemetry'; + +const btnBase = + 'bg-transparent border border-outline-variant px-3 py-1.5 font-heading text-[10px] uppercase tracking-widest text-primary transition-colors hover:bg-surface-bright disabled:opacity-50 sm:px-4 sm:py-2 sm:text-xs h-8 sm:h-9'; +const btnConnected = + 'bg-transparent border border-outline-variant px-3 py-1.5 font-mono text-[10px] text-primary transition-colors hover:bg-surface-bright sm:px-4 sm:py-2 sm:text-xs h-8 sm:h-9'; import { useStellarWallet as useStellarWalletContext } from '@/context/StellarWalletContext'; import { useStellarWallet as useStellarWalletHook } from '@/hooks/useStellarWallet'; import { StellarWalletPicker } from '@/components/StellarWalletPicker'; @@ -26,7 +33,7 @@ function HorizenButton() { })} > {!connected ? ( - ) : ( @@ -74,6 +81,9 @@ function FreighterButton() { : 'disconnected'; return ( +
); } - - return ; +return trackEvent('connect_wallet')} />; + } function CkbButton() { @@ -136,7 +146,7 @@ function CkbButton() { } return ( - ); diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts new file mode 100644 index 0000000..88538f1 --- /dev/null +++ b/src/lib/telemetry.ts @@ -0,0 +1,35 @@ +const STORAGE_KEY = 'wraith-telemetry-consent'; + +export type ConsentState = 'accepted' | 'declined' | null; + +export function getConsent(): ConsentState { + const val = localStorage.getItem(STORAGE_KEY); + if (val === 'accepted' || val === 'declined') return val; + return null; +} + +export function setConsent(state: 'accepted' | 'declined'): void { + localStorage.setItem(STORAGE_KEY, state); +} + +function isEnabled(): boolean { + return getConsent() === 'accepted'; +} + +export function trackPageView(path: string): void { + if (!isEnabled()) 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 (typeof window.plausible === 'undefined') return; + window.plausible(name); +} + +declare global { + interface Window { + plausible?: (event: string, options?: { u?: string }) => void; + } +} \ No newline at end of file diff --git a/src/pages/Privacy.tsx b/src/pages/Privacy.tsx new file mode 100644 index 0000000..d5ddd79 --- /dev/null +++ b/src/pages/Privacy.tsx @@ -0,0 +1,55 @@ +import { useEffect } from 'react'; +import { trackPageView } from '@/lib/telemetry'; + +export default function Privacy() { + useEffect(() => { + trackPageView('/privacy'); + }, []); + return ( +
+
+

Privacy Policy

+

Last updated: June 2026

+
+ +
+

What we collect

+

+ If you opt in to analytics, we collect the following — nothing more: +

+
    +
  • Page views (which page you visited)
  • +
  • Key flow events: connect wallet, send submitted, scan triggered, withdraw
  • +
+
+ +
+

What we never collect

+
    +
  • Wallet addresses
  • +
  • Transaction amounts
  • +
  • IP addresses
  • +
  • Cookies or cross-site tracking
  • +
  • Any personally identifiable information
  • +
+
+ +
+

Provider

+

+ We use Plausible Analytics — a + cookieless, privacy-respecting analytics tool that does not use cookies and is fully + compliant with GDPR, CCPA, and PECR. No data is sold or shared with third parties. +

+
+ +
+

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. +

+
+
+ ); +} \ No newline at end of file diff --git a/src/pages/Receive.tsx b/src/pages/Receive.tsx index dd0cc35..8069e77 100644 --- a/src/pages/Receive.tsx +++ b/src/pages/Receive.tsx @@ -1,14 +1,20 @@ +import { useEffect } from 'react'; import { useChain } from '@/context/ChainContext'; import { HorizenReceive } from '@/components/HorizenReceive'; import { StellarReceive } from '@/components/StellarReceive'; import { SolanaReceive } from '@/components/SolanaReceive'; import { CkbReceive } from '@/components/CkbReceive'; +import { trackPageView } from '@/lib/telemetry'; export default function Receive() { const { chain } = useChain(); + useEffect(() => { + trackPageView('/receive'); + }, []); + if (chain === 'stellar') return ; if (chain === 'solana') return ; if (chain === 'ckb') return ; return ; -} +} \ No newline at end of file diff --git a/src/pages/Send.tsx b/src/pages/Send.tsx index 8b1d43e..3a5e88b 100644 --- a/src/pages/Send.tsx +++ b/src/pages/Send.tsx @@ -1,14 +1,20 @@ +import { useEffect } from 'react'; import { useChain } from '@/context/ChainContext'; import { HorizenSend } from '@/components/HorizenSend'; import { StellarSend } from '@/components/StellarSend'; import { SolanaSend } from '@/components/SolanaSend'; import { CkbSend } from '@/components/CkbSend'; +import { trackPageView } from '@/lib/telemetry'; export default function Send() { const { chain } = useChain(); + useEffect(() => { + trackPageView('/send'); + }, []); + if (chain === 'stellar') return ; if (chain === 'solana') return ; if (chain === 'ckb') return ; return ; -} +} \ No newline at end of file