diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx
index 71f4a15..f0dfb40 100644
--- a/.storybook/preview.tsx
+++ b/.storybook/preview.tsx
@@ -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.
@@ -22,7 +23,9 @@ const preview: Preview = {
(Story) => (
),
diff --git a/src/components/StellarReceive.tsx b/src/components/StellarReceive.tsx
index e030bba..2806842 100644
--- a/src/components/StellarReceive.tsx
+++ b/src/components/StellarReceive.tsx
@@ -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';
@@ -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,
@@ -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();
@@ -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) {
@@ -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}
diff --git a/src/components/StellarReceiveView.tsx b/src/components/StellarReceiveView.tsx
index 864f359..313eaba 100644
--- a/src/components/StellarReceiveView.tsx
+++ b/src/components/StellarReceiveView.tsx
@@ -18,6 +18,7 @@ export interface StellarReceiveViewProps {
regHash: string | null;
isScanning: boolean;
hasScanned: boolean;
+ scanStrategyLabel?: string;
matchCount: number;
matches: ReactNode;
error: string;
@@ -60,6 +61,7 @@ export function StellarReceiveView({
regHash,
isScanning,
hasScanned,
+ scanStrategyLabel,
matchCount,
matches,
error,
@@ -290,20 +292,27 @@ export function StellarReceiveView({
)}
-
-
- {isScanning ? 'Scanning...' : 'Scan for Payments'}
-
- {hasScanned && (
-
- {matchCount} transfer{matchCount !== 1 ? 's' : ''} found
+
+ {scanStrategyLabel && (
+
+ {scanStrategyLabel}
)}
+
+
+ {isScanning ? 'Scanning...' : 'Scan for Payments'}
+
+ {hasScanned && (
+
+ {matchCount} transfer{matchCount !== 1 ? 's' : ''} found
+
+ )}
+
{retryStatus && {retryStatus}
}
diff --git a/src/context/ScanStrategyContext.tsx b/src/context/ScanStrategyContext.tsx
new file mode 100644
index 0000000..3186ece
--- /dev/null
+++ b/src/context/ScanStrategyContext.tsx
@@ -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(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(getInitialStrategy);
+
+ useEffect(() => {
+ localStorage.setItem(SCAN_STRATEGY_STORAGE_KEY, strategy);
+ }, [strategy]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useScanStrategy() {
+ const context = useContext(ScanStrategyContext);
+ if (context === undefined) {
+ throw new Error('useScanStrategy must be used within a ScanStrategyProvider');
+ }
+ return context;
+}
diff --git a/src/i18n/en.json b/src/i18n/en.json
index d0c063c..f55a594 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -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.",
diff --git a/src/main.tsx b/src/main.tsx
index d84f182..80a0bf6 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -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';
@@ -75,7 +76,9 @@ function Providers({ children }: { children: React.ReactNode }) {
- {children}
+
+ {children}
+
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx
index 391f053..ade33c9 100644
--- a/src/pages/Settings.tsx
+++ b/src/pages/Settings.tsx
@@ -1,7 +1,9 @@
import { useState, useRef } from 'react';
+import { useTranslation } from 'react-i18next';
import { useTheme, type ThemePreference } from '@/context/ThemeContext';
import { useStealthKeys } from '@/context/StealthKeysContext';
import { useChain } from '@/context/ChainContext';
+import { useScanStrategy, SCAN_STRATEGIES, type ScanStrategy } from '@/context/ScanStrategyContext';
import { getLabels } from '@/lib/stealthLabels';
import {
exportRecoveryKit,
@@ -22,7 +24,9 @@ const preferences: Array<{ value: ThemePreference; label: string; description: s
];
export default function Settings() {
+ const { t } = useTranslation();
const { preference, setThemePreference } = useTheme();
+ const { strategy, setStrategy } = useScanStrategy();
const { chain } = useChain();
const {
evmKeys,
@@ -80,6 +84,13 @@ export default function Settings() {
const passphraseStrength = validatePassphrase(exportPassphrase);
+ const scanStrategyOptions: Array<{ value: ScanStrategy; label: string; description: string }> =
+ SCAN_STRATEGIES.map((value) => ({
+ value,
+ label: t(`scanStrategy.${value}Label`),
+ description: t(`scanStrategy.${value}Description`),
+ }));
+
const handleExportKit = async () => {
setExportMessage(null);
@@ -444,6 +455,57 @@ export default function Settings() {
)}
+ {/* Scanning Strategy */}
+
+
+ {t('scanStrategy.title')}
+
+
+ {t('scanStrategy.description')}
+
+
+
+
+ i
+
+
+ Privacy note
+
+
+ {t('scanStrategy.tooltip')}
+
+
+
+ {scanStrategyOptions.map((option) => (
+
+ setStrategy(option.value)}
+ className="mt-1 accent-[var(--color-tertiary)]"
+ />
+
+
+ {option.label}
+
+
+ {option.description}
+
+
+
+ ))}
+
+
{/* Appearance Preferences */}
diff --git a/src/workers/stellar-scanner.worker.test.ts b/src/workers/stellar-scanner.worker.test.ts
new file mode 100644
index 0000000..01e41f7
--- /dev/null
+++ b/src/workers/stellar-scanner.worker.test.ts
@@ -0,0 +1,214 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { Address, nativeToScVal, xdr } from '@stellar/stellar-sdk';
+import { deriveStealthKeys, generateStealthAddress } from '@wraith-protocol/sdk/chains/stellar';
+
+// stellar-scanner.worker.ts assigns handlers onto the Web Worker global
+// (`self`). Node doesn't define `self`, so alias it to globalThis before the
+// module is imported — this lets us exercise the *real* self.onmessage /
+// self.postMessage contract the browser Worker would use, not a re-implementation
+// of it.
+(globalThis as unknown as { self: typeof globalThis }).self = globalThis;
+
+const RPC_URL = 'https://soroban-testnet.stellar.org';
+const CONTRACT_ID = 'CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL';
+
+function randomSignature(): Uint8Array {
+ const bytes = new Uint8Array(64);
+ crypto.getRandomValues(bytes);
+ return bytes;
+}
+
+/** Builds a single JSON-RPC `getEvents` announcement entry, mirroring e2e/fixtures.ts. */
+function buildEventEntry(params: {
+ schemeId: number;
+ stealthAddress: string;
+ caller: string;
+ ephemeralPubKey: Uint8Array;
+ viewTag: number;
+}) {
+ const schemeIdScVal = nativeToScVal(params.schemeId, { type: 'u32' });
+ const stealthScVal = new Address(params.stealthAddress).toScVal();
+ const valueVec = [
+ new Address(params.caller).toScVal(),
+ xdr.ScVal.scvBytes(Buffer.from(params.ephemeralPubKey)),
+ xdr.ScVal.scvBytes(Buffer.from([params.viewTag])),
+ ];
+ const valueScVal = xdr.ScVal.scvVec(valueVec);
+
+ return {
+ topic: [
+ xdr.ScVal.scvSymbol('announce').toXDR('base64'),
+ schemeIdScVal.toXDR('base64'),
+ stealthScVal.toXDR('base64'),
+ ],
+ value: valueScVal.toXDR('base64'),
+ contractId: CONTRACT_ID,
+ };
+}
+
+function mockGetEventsResponse(events: ReturnType[]) {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async (_url: string, init?: RequestInit) => {
+ const body = JSON.parse((init?.body as string) ?? '{}');
+ if (body.method !== 'getEvents') {
+ return new Response(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: {} }));
+ }
+ return new Response(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: { events } }));
+ }),
+ );
+}
+
+describe('stellar-scanner worker message contract', () => {
+ const caller = generateStealthAddress(
+ deriveStealthKeys(randomSignature()).spendingPubKey,
+ deriveStealthKeys(randomSignature()).viewingPubKey,
+ ).stealthAddress;
+ const recipient = deriveStealthKeys(randomSignature());
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.resetModules();
+ });
+
+ async function postAndWaitForResult(message: Record) {
+ const worker = (globalThis as unknown as { self: any }).self;
+ return new Promise((resolve) => {
+ const originalPostMessage = worker.postMessage;
+ worker.postMessage = (data: unknown) => {
+ worker.postMessage = originalPostMessage;
+ resolve(data);
+ };
+ worker.onmessage({ data: message });
+ });
+ }
+
+ it('fast strategy: dispatches through the real onmessage handler and finds a genuine payment', async () => {
+ await import('./stellar-scanner.worker');
+
+ const generated = generateStealthAddress(recipient.spendingPubKey, recipient.viewingPubKey);
+ mockGetEventsResponse([
+ buildEventEntry({
+ schemeId: 1,
+ stealthAddress: generated.stealthAddress,
+ caller,
+ ephemeralPubKey: generated.ephemeralPubKey,
+ viewTag: generated.viewTag,
+ }),
+ ]);
+
+ const result = await postAndWaitForResult({
+ rpcUrl: RPC_URL,
+ announcerContract: CONTRACT_ID,
+ viewingKey: recipient.viewingKey,
+ spendingPubKey: recipient.spendingPubKey,
+ spendingScalar: recipient.spendingScalar,
+ strategy: 'fast',
+ });
+
+ expect(result.type).toBe('SUCCESS');
+ expect(result.results).toHaveLength(1);
+ expect(result.results[0].stealthAddress).toBe(generated.stealthAddress);
+ });
+
+ it('balanced strategy (default, no strategy field) matches the SDK default behavior', async () => {
+ await import('./stellar-scanner.worker');
+
+ const generated = generateStealthAddress(recipient.spendingPubKey, recipient.viewingPubKey);
+ mockGetEventsResponse([
+ buildEventEntry({
+ schemeId: 1,
+ stealthAddress: generated.stealthAddress,
+ caller,
+ ephemeralPubKey: generated.ephemeralPubKey,
+ viewTag: generated.viewTag,
+ }),
+ ]);
+
+ const result = await postAndWaitForResult({
+ rpcUrl: RPC_URL,
+ announcerContract: CONTRACT_ID,
+ viewingKey: recipient.viewingKey,
+ spendingPubKey: recipient.spendingPubKey,
+ spendingScalar: recipient.spendingScalar,
+ // strategy omitted on purpose: worker must fall back to DEFAULT_SCAN_STRATEGY
+ });
+
+ expect(result.type).toBe('SUCCESS');
+ expect(result.results).toHaveLength(1);
+ });
+
+ it('full strategy: still recovers a payment whose on-chain tag byte is wrong', async () => {
+ await import('./stellar-scanner.worker');
+
+ const generated = generateStealthAddress(recipient.spendingPubKey, recipient.viewingPubKey);
+ mockGetEventsResponse([
+ buildEventEntry({
+ schemeId: 1,
+ stealthAddress: generated.stealthAddress,
+ caller,
+ ephemeralPubKey: generated.ephemeralPubKey,
+ viewTag: (generated.viewTag + 1) % 256, // corrupted tag
+ }),
+ ]);
+
+ const fastResult = await postAndWaitForResult({
+ rpcUrl: RPC_URL,
+ announcerContract: CONTRACT_ID,
+ viewingKey: recipient.viewingKey,
+ spendingPubKey: recipient.spendingPubKey,
+ spendingScalar: recipient.spendingScalar,
+ strategy: 'fast',
+ });
+ expect(fastResult.results).toHaveLength(0);
+
+ mockGetEventsResponse([
+ buildEventEntry({
+ schemeId: 1,
+ stealthAddress: generated.stealthAddress,
+ caller,
+ ephemeralPubKey: generated.ephemeralPubKey,
+ viewTag: (generated.viewTag + 1) % 256,
+ }),
+ ]);
+
+ const fullResult = await postAndWaitForResult({
+ rpcUrl: RPC_URL,
+ announcerContract: CONTRACT_ID,
+ viewingKey: recipient.viewingKey,
+ spendingPubKey: recipient.spendingPubKey,
+ spendingScalar: recipient.spendingScalar,
+ strategy: 'full',
+ });
+
+ expect(fullResult.type).toBe('SUCCESS');
+ expect(fullResult.results).toHaveLength(1);
+ expect(fullResult.results[0].stealthAddress).toBe(generated.stealthAddress);
+ });
+
+ it('posts an ERROR message if the RPC call throws unexpectedly', async () => {
+ await import('./stellar-scanner.worker');
+
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => {
+ throw new Error('network down');
+ }),
+ );
+
+ const result = await postAndWaitForResult({
+ rpcUrl: RPC_URL,
+ announcerContract: CONTRACT_ID,
+ viewingKey: recipient.viewingKey,
+ spendingPubKey: recipient.spendingPubKey,
+ spendingScalar: recipient.spendingScalar,
+ strategy: 'balanced',
+ });
+
+ // fetchAnnouncementEvents swallows fetch errors and returns an empty
+ // list, so a scan still completes successfully with zero matches rather
+ // than surfacing an ERROR — confirm that resilience explicitly.
+ expect(result.type).toBe('SUCCESS');
+ expect(result.results).toHaveLength(0);
+ });
+});
diff --git a/src/workers/stellar-scanner.worker.ts b/src/workers/stellar-scanner.worker.ts
index f49bdcd..f73ae5d 100644
--- a/src/workers/stellar-scanner.worker.ts
+++ b/src/workers/stellar-scanner.worker.ts
@@ -1,6 +1,7 @@
-import { scanAnnouncements, bytesToHex } from '@wraith-protocol/sdk/chains/stellar';
+import { bytesToHex } from '@wraith-protocol/sdk/chains/stellar';
import type { Announcement } from '@wraith-protocol/sdk/chains/stellar';
import { Address, xdr } from '@stellar/stellar-sdk';
+import { scanWithStrategy, DEFAULT_SCAN_STRATEGY, type ScanStrategy } from './stellarScanDispatch';
async function fetchAnnouncementEvents(
rpcUrl: string,
@@ -112,11 +113,31 @@ function parseAnnouncementEvent(event: Record): Announcement |
}
self.onmessage = async (e: MessageEvent) => {
- const { rpcUrl, announcerContract, viewingKey, spendingPubKey, spendingScalar } = e.data;
+ const {
+ rpcUrl,
+ announcerContract,
+ viewingKey,
+ spendingPubKey,
+ spendingScalar,
+ strategy,
+ }: {
+ rpcUrl: string;
+ announcerContract: string;
+ viewingKey: Uint8Array;
+ spendingPubKey: Uint8Array;
+ spendingScalar: bigint;
+ strategy?: ScanStrategy;
+ } = e.data;
try {
- const announcements = await fetchAnnouncementEvents(rpcUrl, announcerContract);
- const results = scanAnnouncements(announcements, viewingKey, spendingPubKey, spendingScalar);
+ const announcements: Announcement[] = await fetchAnnouncementEvents(rpcUrl, announcerContract);
+ const results = scanWithStrategy(
+ strategy ?? DEFAULT_SCAN_STRATEGY,
+ announcements,
+ viewingKey,
+ spendingPubKey,
+ spendingScalar,
+ );
self.postMessage({ type: 'SUCCESS', results });
} catch (err) {
self.postMessage({
diff --git a/src/workers/stellarScanDispatch.test.ts b/src/workers/stellarScanDispatch.test.ts
new file mode 100644
index 0000000..a056322
--- /dev/null
+++ b/src/workers/stellarScanDispatch.test.ts
@@ -0,0 +1,125 @@
+import { describe, expect, it } from 'vitest';
+import {
+ bytesToHex,
+ deriveStealthKeys,
+ generateStealthAddress,
+ SCHEME_ID,
+} from '@wraith-protocol/sdk/chains/stellar';
+import type { Announcement } from '@wraith-protocol/sdk/chains/stellar';
+import { scanWithStrategy, type ScanStrategy } from './stellarScanDispatch';
+
+function randomSignature(): Uint8Array {
+ const bytes = new Uint8Array(64);
+ crypto.getRandomValues(bytes);
+ return bytes;
+}
+
+function makeAnnouncement(
+ spendingPubKey: Uint8Array,
+ viewingPubKey: Uint8Array,
+ overrides: Partial = {},
+): Announcement {
+ const generated = generateStealthAddress(spendingPubKey, viewingPubKey);
+ return {
+ schemeId: SCHEME_ID,
+ stealthAddress: generated.stealthAddress,
+ caller: 'GARBAGECALLERADDRESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX',
+ ephemeralPubKey: bytesToHex(generated.ephemeralPubKey),
+ metadata: bytesToHex(new Uint8Array([generated.viewTag])),
+ ...overrides,
+ };
+}
+
+describe('scanWithStrategy', () => {
+ const recipient = deriveStealthKeys(randomSignature());
+ const stranger = deriveStealthKeys(randomSignature());
+
+ it('balanced: matches a genuine announcement and cross-checks the address', () => {
+ const ann = makeAnnouncement(recipient.spendingPubKey, recipient.viewingPubKey);
+ const noise = makeAnnouncement(stranger.spendingPubKey, stranger.viewingPubKey);
+
+ const results = scanWithStrategy(
+ 'balanced',
+ [ann, noise],
+ recipient.viewingKey,
+ recipient.spendingPubKey,
+ recipient.spendingScalar,
+ );
+
+ expect(results).toHaveLength(1);
+ expect(results[0].stealthAddress).toBe(ann.stealthAddress);
+ });
+
+ it('fast: matches on the view tag alone and rejects a corrupted address without erroring', () => {
+ const ann = makeAnnouncement(recipient.spendingPubKey, recipient.viewingPubKey);
+
+ const results = scanWithStrategy(
+ 'fast',
+ [ann],
+ recipient.viewingKey,
+ recipient.spendingPubKey,
+ recipient.spendingScalar,
+ );
+
+ expect(results).toHaveLength(1);
+ expect(results[0].stealthAddress).toBe(ann.stealthAddress);
+
+ // A stranger's announcement should not produce a false match just because
+ // fast mode skips the address cross-check: the view tag itself still has
+ // to line up with the recipient's shared secret.
+ const strangerAnn = makeAnnouncement(stranger.spendingPubKey, stranger.viewingPubKey);
+ const strangerResults = scanWithStrategy(
+ 'fast',
+ [strangerAnn],
+ recipient.viewingKey,
+ recipient.spendingPubKey,
+ recipient.spendingScalar,
+ );
+ expect(strangerResults).toHaveLength(0);
+ });
+
+ it('full: still finds a genuine payment even when the on-chain tag is wrong', () => {
+ const ann = makeAnnouncement(recipient.spendingPubKey, recipient.viewingPubKey);
+ // Corrupt the on-chain view tag byte so a tag-gated scan (fast/balanced)
+ // would skip this announcement entirely.
+ const corruptedTagAnn: Announcement = {
+ ...ann,
+ metadata: bytesToHex(new Uint8Array([(parseInt(ann.metadata, 16) + 1) % 256])),
+ };
+
+ const fastResults = scanWithStrategy(
+ 'fast',
+ [corruptedTagAnn],
+ recipient.viewingKey,
+ recipient.spendingPubKey,
+ recipient.spendingScalar,
+ );
+ expect(fastResults).toHaveLength(0);
+
+ const fullResults = scanWithStrategy(
+ 'full',
+ [corruptedTagAnn],
+ recipient.viewingKey,
+ recipient.spendingPubKey,
+ recipient.spendingScalar,
+ );
+ expect(fullResults).toHaveLength(1);
+ expect(fullResults[0].stealthAddress).toBe(ann.stealthAddress);
+ });
+
+ it('defaults to the balanced dispatch for an unrecognized strategy value', () => {
+ const ann = makeAnnouncement(recipient.spendingPubKey, recipient.viewingPubKey);
+
+ // Cast to exercise the default branch of the dispatch switch for a value
+ // that shouldn't be reachable through the UI's own type-checked options.
+ const results = scanWithStrategy(
+ 'nonsense' as ScanStrategy,
+ [ann],
+ recipient.viewingKey,
+ recipient.spendingPubKey,
+ recipient.spendingScalar,
+ );
+
+ expect(results).toHaveLength(1);
+ });
+});
diff --git a/src/workers/stellarScanDispatch.ts b/src/workers/stellarScanDispatch.ts
new file mode 100644
index 0000000..dd72090
--- /dev/null
+++ b/src/workers/stellarScanDispatch.ts
@@ -0,0 +1,118 @@
+import {
+ checkStealthAddress,
+ computeSharedSecret,
+ deriveStealthPubKey,
+ hashToScalar,
+ hexToBytes,
+ pubKeyToStellarAddress,
+ scanAnnouncements,
+ SCHEME_ID,
+ L,
+} from '@wraith-protocol/sdk/chains/stellar';
+import type { Announcement, MatchedAnnouncement } from '@wraith-protocol/sdk/chains/stellar';
+
+/**
+ * The three scanning strategies exposed in Settings > Scanning strategy.
+ *
+ * - fast: Trust the view-tag match alone. Skips the extra address
+ * cross-check `scanAnnouncements` performs, so it does less
+ * work per announcement but can't recover a payment whose
+ * on-chain tag doesn't match (or catch a tag collision).
+ * - balanced: Default. Uses the view tag to cheaply skip non-matches,
+ * then confirms every candidate by deriving the full stealth
+ * address and comparing it to the announcement. Mirrors the
+ * SDK's `scanAnnouncements`.
+ * - full: Ignores the announced view tag completely and derives the
+ * full stealth address for every announcement, so a wrong or
+ * tampered tag can never hide a real payment. Slowest, most
+ * thorough — for auditors who don't want to rely on the tag.
+ */
+export type ScanStrategy = 'fast' | 'balanced' | 'full';
+
+export const SCAN_STRATEGIES: ScanStrategy[] = ['fast', 'balanced', 'full'];
+export const DEFAULT_SCAN_STRATEGY: ScanStrategy = 'balanced';
+
+function toMatched(
+ ann: Announcement,
+ stealthPubKeyBytes: Uint8Array,
+ hashScalar: bigint,
+ spendingScalar: bigint,
+): MatchedAnnouncement {
+ const stealthPrivateScalar = (spendingScalar + hashScalar) % L;
+ return { ...ann, stealthPrivateScalar, stealthPubKeyBytes };
+}
+
+/** Fast: view-tag match only, no address cross-check. */
+function scanFast(
+ announcements: Announcement[],
+ viewingKey: Uint8Array,
+ spendingPubKey: Uint8Array,
+ spendingScalar: bigint,
+): MatchedAnnouncement[] {
+ const matched: MatchedAnnouncement[] = [];
+
+ for (const ann of announcements) {
+ if (ann.schemeId !== SCHEME_ID) continue;
+
+ const metadataBytes = hexToBytes(ann.metadata);
+ if (metadataBytes.length === 0) continue;
+ const viewTag = metadataBytes[0];
+
+ const ephPubKey = hexToBytes(ann.ephemeralPubKey);
+ if (ephPubKey.length !== 32) continue;
+
+ const result = checkStealthAddress(ephPubKey, viewingKey, spendingPubKey, viewTag);
+ if (result.isMatch && result.hashScalar !== null && result.stealthPubKeyBytes !== null) {
+ matched.push(toMatched(ann, result.stealthPubKeyBytes, result.hashScalar, spendingScalar));
+ }
+ }
+
+ return matched;
+}
+
+/** Full: ignore the announced tag, derive and confirm every announcement. */
+function scanFull(
+ announcements: Announcement[],
+ viewingKey: Uint8Array,
+ spendingPubKey: Uint8Array,
+ spendingScalar: bigint,
+): MatchedAnnouncement[] {
+ const matched: MatchedAnnouncement[] = [];
+
+ for (const ann of announcements) {
+ if (ann.schemeId !== SCHEME_ID) continue;
+
+ const ephPubKey = hexToBytes(ann.ephemeralPubKey);
+ if (ephPubKey.length !== 32) continue;
+
+ const sharedSecret = computeSharedSecret(viewingKey, ephPubKey);
+ const hashScalar = hashToScalar(sharedSecret);
+ const stealthPubKeyBytes = deriveStealthPubKey(spendingPubKey, hashScalar);
+ const stealthAddress = pubKeyToStellarAddress(stealthPubKeyBytes);
+
+ if (stealthAddress === ann.stealthAddress) {
+ matched.push(toMatched(ann, stealthPubKeyBytes, hashScalar, spendingScalar));
+ }
+ }
+
+ return matched;
+}
+
+/** Dispatches to the scanning strategy selected in Settings. */
+export function scanWithStrategy(
+ strategy: ScanStrategy,
+ announcements: Announcement[],
+ viewingKey: Uint8Array,
+ spendingPubKey: Uint8Array,
+ spendingScalar: bigint,
+): MatchedAnnouncement[] {
+ switch (strategy) {
+ case 'fast':
+ return scanFast(announcements, viewingKey, spendingPubKey, spendingScalar);
+ case 'full':
+ return scanFull(announcements, viewingKey, spendingPubKey, spendingScalar);
+ case 'balanced':
+ default:
+ return scanAnnouncements(announcements, viewingKey, spendingPubKey, spendingScalar);
+ }
+}