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
5 changes: 4 additions & 1 deletion .storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Buffer } from 'buffer';

import type { Preview } from '@storybook/react';
import { initialize, mswLoader } from 'msw-storybook-addon';
import { ScanStrategyProvider } from '../src/context/ScanStrategyContext';
import '../src/index.css';

// Start the mock service worker so no story can make a real network request.
Expand All @@ -22,7 +23,9 @@ const preview: Preview = {
(Story) => (
<div className="dark min-h-screen bg-surface p-6 font-body text-on-surface antialiased">
<div className="mx-auto w-full max-w-[720px]">
<Story />
<ScanStrategyProvider>
<Story />
</ScanStrategyProvider>
</div>
</div>
),
Expand Down
109 changes: 82 additions & 27 deletions src/components/StellarReceive.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@ import {
import {
deriveStealthKeys,
encodeStealthMetaAddress,
scanAnnouncements,
signStellarTransaction,
STEALTH_SIGNING_MESSAGE,
SCHEME_ID,
bytesToHex,
} from '@wraith-protocol/sdk/chains/stellar';
import type { Announcement, MatchedAnnouncement } from '@wraith-protocol/sdk/chains/stellar';
import { useTranslation } from 'react-i18next';
import { useScanStrategy } from '@/context/ScanStrategyContext';
import { StellarReceiveView } from '@/components/StellarReceiveView';
import { QRCodeModal } from '@/components/QRCodeModal';
import { useStealthKeys } from '@/context/StealthKeysContext';
Expand All @@ -46,6 +47,11 @@ import { createStellarQrUri } from '@/utils/qr';
const ANNOUNCER_CONTRACT = 'CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL';
const REGISTRY_CONTRACT = 'CC2LAUCXYOPJ4DV4CYXNXYAXRDVOTMAWFF76W4WFD5OVQBD6TN4PYYJ5';

// Fetches announcements on the main thread. Only used to support the
// `window.scanAnnouncementsMock` test hook (see e2e/fixtures.ts) which needs
// synchronous, main-thread access to the parsed announcements. The real,
// production scan path fetches and scans off the main thread inside
// stellar-scanner.worker.ts instead.
async function fetchAnnouncementEvents(
rpcUrl: string,
contractId: string,
Expand Down Expand Up @@ -673,6 +679,7 @@ export function StellarReceive() {
useStellarWallet();
const { stellarKeys, stellarMetaAddress, setStellarKeys, setStellarMetaAddress } =
useStealthKeys();
const { strategy: scanStrategy } = useScanStrategy();
const addActivity = useActivityStore((state) => state.addEntry);
const updateActivity = useActivityStore((state) => state.updateStatus);
const notifications = useStellarNotifications();
Expand Down Expand Up @@ -1184,35 +1191,80 @@ export function StellarReceive() {
setIsScanning(true);
setError('');

try {
const announcements = await fetchAnnouncementEvents(
STELLAR_NETWORK.rpcUrl,
ANNOUNCER_CONTRACT,
);
const scanFn = (window as any).scanAnnouncementsMock || scanAnnouncements;
const results = scanFn(
announcements,
stellarKeys.viewingKey,
stellarKeys.spendingPubKey,
stellarKeys.spendingScalar,
);
if (workerRef.current) {
workerRef.current.terminate();
// Test hook: e2e/fixtures.ts injects a mock scan function on `window` so
// specs can control match results without real crypto. It runs on the
// main thread since a Worker doesn't share `window` with the page.
const mockScan = (window as any).scanAnnouncementsMock;
if (mockScan) {
try {
const announcements = await fetchAnnouncementEvents(
STELLAR_NETWORK.rpcUrl,
ANNOUNCER_CONTRACT,
);
const results = mockScan(
announcements,
stellarKeys.viewingKey,
stellarKeys.spendingPubKey,
stellarKeys.spendingScalar,
);
setMatched(results);
setHasScanned(true);
trackEvent('scan_triggered');
} catch (err) {
setError(err instanceof Error ? err.message : t('common.scanFailed'));
} finally {
setIsScanning(false);
}
return;
}

workerRef.current = new Worker(
new URL('../workers/stellar-scanner.worker.ts', import.meta.url),
{ type: 'module' },
);
setMatched(results);
setHasScanned(true);
trackEvent('scan_triggered');
} catch (err) {
setError(err instanceof Error ? err.message : t('common.scanFailed'));
} finally {
setIsScanning(false);
// Restart cleanly: tear down any in-flight scan before starting a new
// one, so a strategy change always takes effect on the next scan without
// requiring a page reload.
if (workerRef.current) {
workerRef.current.terminate();
workerRef.current = null;
}
}, [stellarKeys, t]);

const worker = new Worker(new URL('../workers/stellar-scanner.worker.ts', import.meta.url), {
type: 'module',
});
workerRef.current = worker;

worker.onmessage = (event: MessageEvent) => {
const { type, results, error: workerError } = event.data ?? {};
if (type === 'SUCCESS') {
setMatched(results ?? []);
setHasScanned(true);
trackEvent('scan_triggered');
} else {
setError(workerError || t('common.scanFailed'));
}
setIsScanning(false);
worker.terminate();
if (workerRef.current === worker) {
workerRef.current = null;
}
};

worker.onerror = () => {
setError(t('common.scanFailed'));
setIsScanning(false);
worker.terminate();
if (workerRef.current === worker) {
workerRef.current = null;
}
};

worker.postMessage({
rpcUrl: STELLAR_NETWORK.rpcUrl,
announcerContract: ANNOUNCER_CONTRACT,
viewingKey: stellarKeys.viewingKey,
spendingPubKey: stellarKeys.spendingPubKey,
spendingScalar: stellarKeys.spendingScalar,
strategy: scanStrategy,
});
}, [stellarKeys, scanStrategy, t]);

const handleToggleNotifications = useCallback(async () => {
if (notifications.state.enabled) {
Expand Down Expand Up @@ -1324,6 +1376,9 @@ export function StellarReceive() {
regHash={regHash}
isScanning={isScanning}
hasScanned={hasScanned}
scanStrategyLabel={t('scanStrategy.activeStrategy', {
strategy: t(`scanStrategy.${scanStrategy}Label`),
})}
matchCount={matched.length}
error={error}
retryStatus={retryStatus}
Expand Down
33 changes: 21 additions & 12 deletions src/components/StellarReceiveView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface StellarReceiveViewProps {
regHash: string | null;
isScanning: boolean;
hasScanned: boolean;
scanStrategyLabel?: string;
matchCount: number;
matches: ReactNode;
error: string;
Expand Down Expand Up @@ -60,6 +61,7 @@ export function StellarReceiveView({
regHash,
isScanning,
hasScanned,
scanStrategyLabel,
matchCount,
matches,
error,
Expand Down Expand Up @@ -290,20 +292,27 @@ export function StellarReceiveView({
</div>
)}

<div className="flex items-center justify-between">
<button
data-tour="scan-payments"
onClick={onScan}
disabled={isScanning}
className="h-12 w-full bg-primary px-6 font-heading text-[13px] font-semibold uppercase tracking-widest text-surface transition-colors hover:brightness-110 disabled:opacity-30 sm:w-auto"
>
{isScanning ? 'Scanning...' : 'Scan for Payments'}
</button>
{hasScanned && (
<span className="font-mono text-xs text-on-surface-variant">
{matchCount} transfer{matchCount !== 1 ? 's' : ''} found
<div className="flex flex-col gap-2">
{scanStrategyLabel && (
<span className="font-mono text-[10px] uppercase tracking-widest text-outline">
{scanStrategyLabel}
</span>
)}
<div className="flex items-center justify-between">
<button
data-tour="scan-payments"
onClick={onScan}
disabled={isScanning}
className="h-12 w-full bg-primary px-6 font-heading text-[13px] font-semibold uppercase tracking-widest text-surface transition-colors hover:brightness-110 disabled:opacity-30 sm:w-auto"
>
{isScanning ? 'Scanning...' : 'Scan for Payments'}
</button>
{hasScanned && (
<span className="font-mono text-xs text-on-surface-variant">
{matchCount} transfer{matchCount !== 1 ? 's' : ''} found
</span>
)}
</div>
</div>

{retryStatus && <p className="text-sm text-on-surface-variant">{retryStatus}</p>}
Expand Down
50 changes: 50 additions & 0 deletions src/context/ScanStrategyContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
import {
DEFAULT_SCAN_STRATEGY,
SCAN_STRATEGIES,
type ScanStrategy,
} from '@/workers/stellarScanDispatch';

export type { ScanStrategy };
export { SCAN_STRATEGIES, DEFAULT_SCAN_STRATEGY };

interface ScanStrategyContextType {
strategy: ScanStrategy;
setStrategy: (strategy: ScanStrategy) => void;
}

const ScanStrategyContext = createContext<ScanStrategyContextType | undefined>(undefined);

const SCAN_STRATEGY_STORAGE_KEY = 'wraith-scan-strategy';

function isScanStrategy(value: string | null): value is ScanStrategy {
return value !== null && (SCAN_STRATEGIES as string[]).includes(value);
}

function getInitialStrategy(): ScanStrategy {
if (typeof window === 'undefined') return DEFAULT_SCAN_STRATEGY;
const stored = localStorage.getItem(SCAN_STRATEGY_STORAGE_KEY);
return isScanStrategy(stored) ? stored : DEFAULT_SCAN_STRATEGY;
}

export function ScanStrategyProvider({ children }: { children: ReactNode }) {
const [strategy, setStrategy] = useState<ScanStrategy>(getInitialStrategy);

useEffect(() => {
localStorage.setItem(SCAN_STRATEGY_STORAGE_KEY, strategy);
}, [strategy]);

return (
<ScanStrategyContext.Provider value={{ strategy, setStrategy }}>
{children}
</ScanStrategyContext.Provider>
);
}

export function useScanStrategy() {
const context = useContext(ScanStrategyContext);
if (context === undefined) {
throw new Error('useScanStrategy must be used within a ScanStrategyProvider');
}
return context;
}
12 changes: 12 additions & 0 deletions src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,18 @@
"cellsFound_one": "{{count}} cell found",
"cellsFound_other": "{{count}} cells found"
},
"scanStrategy": {
"title": "Scanning strategy",
"description": "Choose how the receive scanner checks incoming announcements against your keys.",
"tooltip": "Every stealth payment is tagged on-chain with a one-byte view tag. Checking it locally lets your scanner skip most non-matches quickly, but a passive observer of your RPC requests can use timing and request patterns to learn that one byte of correlation about which announcements you cared about. Ignoring the tag avoids that signal at the cost of a slower scan.",
"fastLabel": "Fast (view-tag only)",
"fastDescription": "Trusts the view-tag match alone and skips the extra address confirmation. Quickest option on a slow connection.",
"balancedLabel": "Balanced",
"balancedDescription": "Uses the view tag to skip non-matches, then confirms every candidate against the full stealth address. Default.",
"fullLabel": "Full (ignore tag)",
"fullDescription": "Ignores the on-chain view tag entirely and checks every announcement in full. Slowest, but can't miss a payment due to an incorrect tag.",
"activeStrategy": "Scanning strategy: {{strategy}}"
},
"stellarSplit": {
"title": "Batch Send",
"description": "Paste a CSV of stealth meta-addresses and amounts to send in a single Stellar transaction. All rows are processed atomically — if any operation fails, the entire batch is rolled back.",
Expand Down
5 changes: 4 additions & 1 deletion src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { ChainProvider } from '@/context/ChainContext';
import { StealthKeysProvider } from '@/context/StealthKeysContext';
import { StellarWalletProvider } from '@/context/StellarWalletContext';
import { ThemeProvider, useTheme } from '@/context/ThemeContext';
import { ScanStrategyProvider } from '@/context/ScanStrategyContext';
import { ContactsProvider } from '@/store/contactsStore';
import { NameHistoryProvider } from '@/store/nameHistoryStore';
import { wagmiConfig } from '@/config';
Expand Down Expand Up @@ -75,7 +76,9 @@ function Providers({ children }: { children: React.ReactNode }) {
<StellarWalletProvider>
<ContactsProvider>
<NameHistoryProvider>
<StealthKeysProvider>{children}</StealthKeysProvider>
<StealthKeysProvider>
<ScanStrategyProvider>{children}</ScanStrategyProvider>
</StealthKeysProvider>
</NameHistoryProvider>
</ContactsProvider>
</StellarWalletProvider>
Expand Down
Loading
Loading