Skip to content
Open
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
89 changes: 70 additions & 19 deletions src/components/StellarReceive.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// @ts-nocheck (temporary: wave-6 merges left stale symbol names; unblocks CI)
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import {
Expand Down Expand Up @@ -43,11 +42,38 @@ import { useStealthLabels } from '@/hooks/useStealthLabels';
import { StellarBatchWithdrawModal } from '@/components/StellarBatchWithdrawModal';
import { createStellarQrUri } from '@/utils/qr';
import { readScanCursor, writeScanCursor, type RetentionGap } from '@/lib/stellar/scannerCursor';
import type { StellarAssetKey } from '@/lib/stellar/assets';
import { fetchAnnouncementEvents } from '@/lib/stellar/announcementRpc';

const ANNOUNCER_CONTRACT = 'CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL';
const REGISTRY_CONTRACT = 'CC2LAUCXYOPJ4DV4CYXNXYAXRDVOTMAWFF76W4WFD5OVQBD6TN4PYYJ5';

interface ScanWorkerMessage {
type: 'SUCCESS' | 'RETENTION_GAP' | 'ERROR';
results?: MatchedAnnouncement[];
nextLedger?: number;
requestedLedger?: number;
oldestAvailableLedger?: number;
error?: string;
}

interface SorobanServerMock {
getAccount(address: string): Promise<{ accountId(): string; sequenceNumber(): string }>;
simulateTransaction(tx: unknown): Promise<unknown>;
sendTransaction(tx: unknown): Promise<{ status: string; hash: string }>;
getTransaction(hash: string): Promise<{ status: string }>;
}

type WindowWithMocks = Window & {
scanAnnouncementsMock?: (
announcements: Announcement[],
viewingKey: Uint8Array,
spendingPubKey: Uint8Array,
spendingScalar: bigint,
) => MatchedAnnouncement[];
sorobanServerMock?: SorobanServerMock;
};

// Fetches announcements on the main thread. Only used to support the
// `window.scanAnnouncementsMock` test hook (see e2e/fixtures.ts) which needs
// main-thread access to the parsed announcements. The real, production scan
Expand Down Expand Up @@ -76,6 +102,7 @@ function StellarMatchCardContainer({
onTagClick,
showPrivacyWarning,
onDismissPrivacyWarning,
onBalanceFetched,
}: {
match: MatchedAnnouncement;
onWithdrawn: () => void;
Expand All @@ -86,6 +113,7 @@ function StellarMatchCardContainer({
onTagClick: (tag: string) => void;
showPrivacyWarning: boolean;
onDismissPrivacyWarning: () => void;
onBalanceFetched: (addr: string, bal: string) => void;
isSelected?: boolean;
onToggleSelect?: () => void;
}) {
Expand All @@ -105,13 +133,15 @@ function StellarMatchCardContainer({
const [showKey, setShowKey] = useState(false);
const [showSponsorPrompt, setShowSponsorPrompt] = useState(false);
const [showNetworkModal, setShowNetworkModal] = useState(false);
const [loadingBal, setLoadingBal] = useState(true);

const { upsert } = useActivity();
const scalarHex = match.stealthPrivateScalar.toString(16).padStart(64, '0');

useEffect(() => {
(async () => {
try {
setLoadingBal(true);
const res = await fetchWithRetry(
`${STELLAR_NETWORK.horizonUrl}/accounts/${match.stealthAddress}`,
{},
Expand All @@ -122,14 +152,15 @@ function StellarMatchCardContainer({
setBalances({ XLM: '0' });
return;
}
const data = await res.json();
const data = (await res.json()) as { balances?: Array<{ asset_type: string; balance: string }> };
const parsed = parseAssetBalances(data.balances || []);
setBalances(parsed);
} catch {
setRetryStatus('');
setBalances({ XLM: '0' });
} finally {
setBalanceState('loaded');
setLoadingBal(false);
}
})();
}, [match.stealthAddress]);
Expand All @@ -140,7 +171,7 @@ function StellarMatchCardContainer({
onBalanceFetched(match.stealthAddress, bal);
}, [balances, match.stealthAddress, onBalanceFetched]);

const hasAnyBalance = Object.values(balances).some((b) => parseFloat(b) > 0);
const balance = balances['XLM'];
const withdrawAssetInfo = getAssetByKey(withdrawAssetKey);
const withdrawBalance = parseFloat(balances[withdrawAssetKey] || '0');

Expand Down Expand Up @@ -169,14 +200,18 @@ function StellarMatchCardContainer({
);
setRetryStatus('');
if (!res.ok) throw new Error('Account not found');
const account = await res.json();
const account = (await res.json()) as {
sequence: string;
balances?: Array<{ asset_type: string; balance: string }>;
subentry_count?: number;
};

if (withdrawBalance <= 0) throw new Error(`No ${withdrawAssetKey} balance`);

const sourceAccount = new Account(match.stealthAddress, account.sequence);

if (withdrawAssetKey === 'XLM') {
const xlmBal = account.balances?.find(
const xlmBal = (account.balances as Array<{ asset_type: string; balance: string }>)?.find(
(b: { asset_type: string }) => b.asset_type === 'native',
);
if (!xlmBal || parseFloat(xlmBal.balance) === 0) throw new Error('No XLM balance');
Expand Down Expand Up @@ -237,7 +272,11 @@ function StellarMatchCardContainer({
body: `tx=${signedXdrStr}`,
});

const submitData = await submitRes.json();
const submitData = (await submitRes.json()) as {
hash: string;
title?: string;
extras?: { result_codes?: { transaction?: string } };
};
if (!submitRes.ok) {
throw new Error(
submitData.extras?.result_codes?.transaction ||
Expand Down Expand Up @@ -288,7 +327,11 @@ function StellarMatchCardContainer({
body: `tx=${signedXdrStr}`,
});

const submitData = await submitRes.json();
const submitData = (await submitRes.json()) as {
hash: string;
title?: string;
extras?: { result_codes?: { transaction?: string } };
};
if (!submitRes.ok) {
throw new Error(
submitData.extras?.result_codes?.transaction ||
Expand All @@ -302,7 +345,6 @@ function StellarMatchCardContainer({
trackEvent('withdraw');
onWithdrawn();
} catch (err) {
setError(err instanceof Error ? err.message : t('common.transactionFailed'));
setError(err instanceof Error ? err.message : 'Withdraw failed');
// In a real robust implementation we'd check if we submitted and mark failed
setRetryStatus('');
Expand Down Expand Up @@ -344,9 +386,12 @@ function StellarMatchCardContainer({
);
setRetryStatus('');
if (!stealthRes.ok) throw new Error('Stealth account not found');
const stealthAccount = await stealthRes.json();
const stealthAccount = (await stealthRes.json()) as {
sequence: string;
balances?: Array<{ asset_type: string; balance: string }>;
};

const xlmBal = stealthAccount.balances?.find(
const xlmBal = (stealthAccount.balances as Array<{ asset_type: string; balance: string }>)?.find(
(b: { asset_type: string }) => b.asset_type === 'native',
);
if (!xlmBal || parseFloat(xlmBal.balance) === 0) throw new Error('No XLM balance');
Expand Down Expand Up @@ -413,7 +458,11 @@ function StellarMatchCardContainer({
body: `tx=${encodeURIComponent(signedFeeBumpXdr)}`,
});

const submitData = await submitRes.json();
const submitData = (await submitRes.json()) as {
hash: string;
title?: string;
extras?: { result_codes?: { transaction?: string } };
};
if (!submitRes.ok) {
throw new Error(
submitData.extras?.result_codes?.transaction || submitData.title || 'Transaction failed',
Expand Down Expand Up @@ -572,6 +621,7 @@ function StellarMatchCardContainer({
onTagClick={onTagClick}
showPrivacyWarning={showPrivacyWarning}
onDismissPrivacyWarning={onDismissPrivacyWarning}
onBalanceFetched={onBalanceFetched}
/>
{showNetworkModal && <NetworkMismatchModal onClose={() => setShowNetworkModal(false)} />}
</>
Expand Down Expand Up @@ -674,7 +724,7 @@ export function StellarReceive() {
const [vaultBusy, setVaultBusy] = useState<'idle' | 'unlocking' | 'saving' | 'locking'>('idle');
const [vaultSupported, setVaultSupported] = useState(false);
const vaultRef = useRef<KeyVault | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);

const {
labels,
Expand Down Expand Up @@ -732,8 +782,8 @@ export function StellarReceive() {
(async () => {
try {
const { rpc: rpcMod } = await import('@stellar/stellar-sdk');
const soroban =
(window as any).sorobanServerMock || new rpcMod.Server(STELLAR_NETWORK.rpcUrl);
const soroban: InstanceType<typeof rpcMod.Server> =
(window as WindowWithMocks).sorobanServerMock || new rpcMod.Server(STELLAR_NETWORK.rpcUrl);
const networkPassphrase = STELLAR_NETWORK.networkPassphrase;

const onRetry = (attempt: number) => setRetryStatus(`Retrying (${attempt}/3)…`);
Expand Down Expand Up @@ -991,8 +1041,8 @@ export function StellarReceive() {
const onRetryReg = (attempt: number) => setRetryStatus(`Retrying (${attempt}/3)…`);
try {
const { rpc: rpcMod } = await import('@stellar/stellar-sdk');
const soroban =
(window as any).sorobanServerMock || new rpcMod.Server(STELLAR_NETWORK.rpcUrl);
const soroban: InstanceType<typeof rpcMod.Server> =
(window as WindowWithMocks).sorobanServerMock || new rpcMod.Server(STELLAR_NETWORK.rpcUrl);
const networkPassphrase = STELLAR_NETWORK.networkPassphrase;

const accountResponse = (await withRetry(() => soroban.getAccount(address), {
Expand Down Expand Up @@ -1101,7 +1151,7 @@ export function StellarReceive() {
// 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;
const mockScan = (window as WindowWithMocks).scanAnnouncementsMock;
if (mockScan) {
try {
const announcements = await fetchAnnouncementsBestEffort(
Expand Down Expand Up @@ -1175,7 +1225,7 @@ export function StellarReceive() {
}
trackEvent('scan_triggered');
} else if (type === 'RETENTION_GAP') {
setRetentionGap({ requestedLedger, oldestAvailableLedger });
setRetentionGap({ requestedLedger, oldestAvailableLedger } as RetentionGap);
} else {
setError(workerError || t('common.scanFailed'));
}
Expand Down Expand Up @@ -1472,6 +1522,7 @@ export function StellarReceive() {
onTagClick={() => {}}
showPrivacyWarning={false}
onDismissPrivacyWarning={() => {}}
onBalanceFetched={handleBalanceFetched}
/>
</div>
);
Expand Down Expand Up @@ -1515,4 +1566,4 @@ export function StellarReceive() {
/>
</>
);
}
}
11 changes: 8 additions & 3 deletions src/lib/stellar/scannerCursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ export interface RetentionGap {
oldestAvailableLedger: number;
}

export type RetentionErrorCode = 'WRAITH/NETWORK/RETENTION_EXCEEDED';

export class StellarRetentionExceededError extends Error implements RetentionGap {
readonly code: RetentionErrorCode = 'WRAITH/NETWORK/RETENTION_EXCEEDED';
readonly requestedLedger: number;
readonly oldestAvailableLedger: number;

Expand All @@ -20,10 +23,12 @@ export class StellarRetentionExceededError extends Error implements RetentionGap
interface RetentionErrorLike {
name?: unknown;
code?: unknown;
requestedLedger?: unknown;
oldestAvailableLedger?: unknown;
requestedLedger?: number;
oldestAvailableLedger?: number;
}

const RETENTION_ERROR_CODE: RetentionErrorCode = 'WRAITH/NETWORK/RETENTION_EXCEEDED';

const CURSOR_PREFIX = 'wraith-stellar-scan-cursor';

export function scanCursorKey(walletAddress: string): string {
Expand All @@ -47,7 +52,7 @@ export function retentionGapFromError(error: unknown): RetentionGap | undefined
const candidate = error as RetentionErrorLike;
const isRetentionError =
candidate.name === 'RetentionExceededError' ||
candidate.code === 'WRAITH/NETWORK/RETENTION_EXCEEDED';
candidate.code === RETENTION_ERROR_CODE;
if (!isRetentionError) return undefined;

const requestedLedger = Number(candidate.requestedLedger);
Expand Down
Loading
Loading