Skip to content

Commit 3c4df86

Browse files
committed
FIX: flatten Tor guard, refresh Orbot status, sanitize persisted settings
1 parent b53841d commit 3c4df86

4 files changed

Lines changed: 81 additions & 49 deletions

File tree

blue_modules/torManager.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,13 @@ class TorManager {
6060
try {
6161
const stored = await AsyncStorage.getItem(TOR_SETTINGS_KEY);
6262
if (stored) {
63-
this._settings = { ...DEFAULT_SETTINGS, ...JSON.parse(stored) };
63+
const parsed = JSON.parse(stored) as Partial<TorSettings>;
64+
const port = parsed.socksPort;
65+
this._settings = {
66+
enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : DEFAULT_SETTINGS.enabled,
67+
torOnly: typeof parsed.torOnly === 'boolean' ? parsed.torOnly : DEFAULT_SETTINGS.torOnly,
68+
socksPort: typeof port === 'number' && Number.isInteger(port) && port >= 1 && port <= 65535 ? port : DEFAULT_SETTINGS.socksPort,
69+
};
6470
}
6571
} catch (e) {
6672
console.warn('[TorManager] Failed to load settings:', e);
@@ -120,6 +126,12 @@ class TorManager {
120126
}
121127
}
122128

129+
markUnavailable(): void {
130+
if (this._status === 'connected') {
131+
this._setStatus('unavailable');
132+
}
133+
}
134+
123135
/** Android only. On iOS, returns false — users must configure manually. */
124136
static async isOrbotInstalled(): Promise<boolean> {
125137
if (Platform.OS !== 'android') return false;

helpers/silent-payments/IndexerHttpClient.ts

Lines changed: 35 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -18,51 +18,50 @@ export class IndexerHttpClient {
1818
private async executeGet<T>(endpoint: string, errorContext: string): Promise<T> {
1919
const torManager = TorManager.getInstance();
2020

21-
// Try Tor/onion route first when available
22-
if (torManager.settings.enabled && this.onionUrl) {
23-
if (torManager.isReady) {
24-
for (let attempt = 1; attempt <= RETRY_ATTEMPTS; attempt++) {
25-
try {
26-
const response = await socks5Fetch(`${this.onionUrl}${endpoint}`, {
27-
method: 'GET',
28-
headers: { 'Content-Type': 'application/json' },
29-
timeout: this.timeout,
30-
socksHost: DEFAULT_SOCKS_HOST,
31-
socksPort: torManager.socksPort,
32-
});
33-
34-
if (!response.ok) {
35-
throw new Error(`HTTP error! status: ${response.status}`);
36-
}
37-
38-
return await response.json();
39-
} catch (torError) {
40-
const message = torError instanceof Error ? torError.message : String(torError);
41-
console.warn(`[IndexerHttpClient] Tor attempt ${attempt}/${RETRY_ATTEMPTS} failed: ${message}`);
42-
43-
if (attempt < RETRY_ATTEMPTS) {
44-
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 8000);
45-
await new Promise(resolve => setTimeout(resolve, delay));
46-
}
21+
if (torManager.settings.enabled && this.onionUrl && torManager.isReady) {
22+
for (let attempt = 1; attempt <= RETRY_ATTEMPTS; attempt++) {
23+
try {
24+
const response = await socks5Fetch(`${this.onionUrl}${endpoint}`, {
25+
method: 'GET',
26+
headers: { 'Content-Type': 'application/json' },
27+
timeout: this.timeout,
28+
socksHost: DEFAULT_SOCKS_HOST,
29+
socksPort: torManager.socksPort,
30+
});
31+
32+
if (!response.ok) {
33+
throw new Error(`HTTP error! status: ${response.status}`);
34+
}
35+
36+
return await response.json();
37+
} catch (torError) {
38+
const message = torError instanceof Error ? torError.message : String(torError);
39+
console.warn(`[IndexerHttpClient] Tor attempt ${attempt}/${RETRY_ATTEMPTS} failed: ${message}`);
40+
41+
if (attempt < RETRY_ATTEMPTS) {
42+
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 8000);
43+
await new Promise(resolve => setTimeout(resolve, delay));
4744
}
4845
}
4946
}
5047

51-
if (torManager.isTorOnly) {
52-
throw new Error(
53-
`${errorContext}: Tor-only mode is enabled but Tor is unavailable. ` +
54-
'Clearnet fallback is blocked. Ensure Orbot is running.',
55-
);
56-
}
48+
torManager.markUnavailable();
49+
}
5750

58-
console.warn('[IndexerHttpClient] Tor unavailable, falling back to clearnet');
59-
} else if (torManager.isTorOnly) {
51+
if (torManager.isTorOnly) {
6052
throw new Error(
61-
`${errorContext}: Tor-only mode is enabled but no .onion URL is configured. ` +
62-
'Set an onion URL or disable Tor-only mode.',
53+
this.onionUrl
54+
? `${errorContext}: Tor-only mode is enabled but Tor is unavailable. ` +
55+
'Clearnet fallback is blocked. Ensure Orbot is running.'
56+
: `${errorContext}: Tor-only mode is enabled but no .onion URL is configured. ` +
57+
'Set an onion URL or disable Tor-only mode.',
6358
);
6459
}
6560

61+
if (torManager.settings.enabled && this.onionUrl) {
62+
console.warn('[IndexerHttpClient] Tor unavailable, falling back to clearnet');
63+
}
64+
6665
// Clearnet fallback
6766
try {
6867
const response = await fetchWithRetries(`${this.baseUrl}${endpoint}`, {

screen/settings/TorSettings.tsx

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { useCallback, useEffect, useMemo, useState } from 'react';
2-
import { StyleSheet, View, Switch, TextInput, ActivityIndicator, Platform, TouchableOpacity } from 'react-native';
2+
import { StyleSheet, View, Switch, TextInput, ActivityIndicator, Platform, TouchableOpacity, AppState } from 'react-native';
33
import { BlueCard, BlueText } from '../../BlueComponents';
44
import { useSettings } from '../../hooks/context/useSettings';
55
import { useTheme } from '../../components/themes';
@@ -9,17 +9,16 @@ import { BlueSpacing20 } from '../../components/BlueSpacing';
99
import TorManager, { type TorStatus } from '../../blue_modules/torManager';
1010
import loc from '../../loc';
1111

12-
const STATUS_LABELS: Record<TorStatus, string> = {
13-
disabled: loc.settings.tor_status_disabled,
14-
checking: loc.settings.tor_status_checking,
15-
connected: loc.settings.tor_status_connected,
16-
unavailable: loc.settings.tor_status_unavailable,
17-
};
18-
1912
const TorSettings: React.FC = () => {
2013
const { colors } = useTheme();
2114
const { isTorEnabled, setIsTorEnabled, isTorOnly, setIsTorOnly, torSocksPort, setTorSocksPort, torStatus, settingsInitialized } =
2215
useSettings();
16+
const statusLabels: Record<TorStatus, string> = {
17+
disabled: loc.settings.tor_status_disabled,
18+
checking: loc.settings.tor_status_checking,
19+
connected: loc.settings.tor_status_connected,
20+
unavailable: loc.settings.tor_status_unavailable,
21+
};
2322
const [portInput, setPortInput] = useState(String(torSocksPort));
2423
const [orbotInstalled, setOrbotInstalled] = useState<boolean | null>(null);
2524
const [showAdvanced, setShowAdvanced] = useState(false);
@@ -41,9 +40,16 @@ const TorSettings: React.FC = () => {
4140
);
4241

4342
useEffect(() => {
44-
TorManager.isOrbotInstalled()
45-
.then(setOrbotInstalled)
46-
.catch(() => setOrbotInstalled(null));
43+
const check = () => {
44+
TorManager.isOrbotInstalled()
45+
.then(setOrbotInstalled)
46+
.catch(() => setOrbotInstalled(null));
47+
};
48+
check();
49+
const sub = AppState.addEventListener('change', state => {
50+
if (state === 'active') check();
51+
});
52+
return () => sub.remove();
4753
}, []);
4854

4955
useEffect(() => {
@@ -146,7 +152,7 @@ const TorSettings: React.FC = () => {
146152

147153
<View style={styles.statusRow}>
148154
<BlueText>{loc.settings.tor_status_label}</BlueText>
149-
<BlueText style={[styles.statusValue, { color: statusColors[torStatus] }]}>{STATUS_LABELS[torStatus]}</BlueText>
155+
<BlueText style={[styles.statusValue, { color: statusColors[torStatus] }]}>{statusLabels[torStatus]}</BlueText>
150156
{torStatus === 'checking' && <ActivityIndicator size="small" style={styles.spinner} />}
151157
</View>
152158

tests/unit/IndexerHttpClient.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ const torState = (overrides: Partial<{ enabled: boolean; isReady: boolean; isTor
3232
isReady: overrides.isReady ?? true,
3333
isTorOnly: overrides.isTorOnly ?? false,
3434
socksPort: overrides.socksPort ?? 9050,
35+
markUnavailable: jest.fn(),
3536
});
3637

3738
describe('IndexerHttpClient routing', () => {
@@ -131,4 +132,18 @@ describe('IndexerHttpClient routing', () => {
131132
expect(mockedSocks5Fetch).not.toHaveBeenCalled();
132133
expect(mockedFetchWithRetries).toHaveBeenCalledTimes(1);
133134
});
135+
136+
it('marks Tor unavailable after exhausting retries so subsequent calls skip the loop', async () => {
137+
const state = torState();
138+
torManagerGetInstance.mockReturnValue(state);
139+
mockedSocks5Fetch.mockRejectedValue(new Error('SOCKS5 timeout'));
140+
mockedFetchWithRetries.mockResolvedValue(okResponse({ height: 900004 }));
141+
142+
const client = new IndexerHttpClient(baseUrl, 30000, onionUrl);
143+
const promise = client.get<{ height: number }>(endpoint, errCtx);
144+
await jest.advanceTimersByTimeAsync(3000);
145+
await promise;
146+
147+
expect(state.markUnavailable).toHaveBeenCalledTimes(1);
148+
});
134149
});

0 commit comments

Comments
 (0)