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
1 change: 1 addition & 0 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const App = () => {
initializeRustJsiBridge();
initializeIndexer({
baseUrl: 'https://superparamount-kendal-halting.ngrok-free.dev/',
onionUrl: 'http://azzaasniov2hjxrkhpvjyse3a4jaww74n76umntatcerp4drzwotk5yd.onion',
timeout: 100000, // 100 seconds for blockchain scanning operations (increased for slower connections)
});
Comment on lines 16 to 20

Comment on lines 16 to 21
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

initializeIndexer() is configured with hard-coded endpoints (a specific ngrok baseUrl and a specific .onion URL). If this is not intended to ship, please move these to build-time config / env (or a constants file for dev only) to avoid accidentally releasing a build pointed at ephemeral or environment-specific infrastructure.

Suggested change
initializeIndexer({
baseUrl: 'https://superparamount-kendal-halting.ngrok-free.dev/',
onionUrl: 'http://azzaasniov2hjxrkhpvjyse3a4jaww74n76umntatcerp4drzwotk5yd.onion',
timeout: 100000, // 100 seconds for blockchain scanning operations (increased for slower connections)
});
const indexerBaseUrl = typeof process?.env?.INDEXER_BASE_URL === 'string' ? process.env.INDEXER_BASE_URL : '';
const indexerOnionUrl = typeof process?.env?.INDEXER_ONION_URL === 'string' ? process.env.INDEXER_ONION_URL : '';
if (indexerBaseUrl && indexerOnionUrl) {
initializeIndexer({
baseUrl: indexerBaseUrl,
onionUrl: indexerOnionUrl,
timeout: 100000, // 100 seconds for blockchain scanning operations (increased for slower connections)
});
}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pre-existing pattern. Moving to env-variable config makes sense but is out of this PR's scope.

cc @theanmolsharma Should this be taken care of, in this PR or in a follow up issue?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets do it in a followup PR

Expand Down
1 change: 1 addition & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -194,5 +194,6 @@
<action android:name="android.intent.action.VIEW" />
<data android:scheme="http" />
</intent>
<package android:name="org.torproject.android" />
</queries>
</manifest>
3 changes: 2 additions & 1 deletion blue_modules/SilentPaymentIndexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ export class SilentPaymentIndexer {

constructor(config: SilentPaymentIndexerConfig) {
const baseUrl = config.baseUrl.replace(/\/$/, '');
this.httpClient = new IndexerHttpClient(baseUrl, config.timeout);
const onionUrl = config.onionUrl?.replace(/\/$/, '');
this.httpClient = new IndexerHttpClient(baseUrl, config.timeout, onionUrl);
}

getBaseUrl(): string {
Expand Down
265 changes: 265 additions & 0 deletions blue_modules/socks5Fetch.ts
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should use Uint8Array instead of Buffer

Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
import TcpSocket from 'react-native-tcp-socket';

const DEFAULT_SOCKS_HOST = '127.0.0.1';
const DEFAULT_SOCKS_PORT = 9050;
const DEFAULT_TIMEOUT = 30000;
const DEFAULT_CONNECT_TIMEOUT = 10000;

interface Socks5FetchOptions {
method?: string;
headers?: Record<string, string>;
body?: string;
timeout?: number;
connectTimeout?: number;
socksHost?: string;
socksPort?: number;
}

interface Socks5Response {
ok: boolean;
status: number;
statusText: string;
headers: Record<string, string>;
json: () => Promise<any>;
text: () => Promise<string>;
}

function parseUrl(url: string): { host: string; port: number; path: string } {
if (url.startsWith('https://')) {
throw new Error(
'HTTPS URLs are not supported by socks5Fetch; use http:// (onion services are reached over plain HTTP through the Tor tunnel)',
);
}
const match = url.match(/^http:\/\/([^/:]+)(?::(\d+))?(\/.*)?$/);
if (!match) {
throw new Error(`Invalid URL: ${url}`);
}
const host = match[1];
const port = match[2] ? parseInt(match[2], 10) : 80;
const path = match[3] || '/';
return { host, port, path };
}

function decodeChunked(data: string): string {
let result = '';
let remaining = data;

while (remaining.length > 0) {
const lineEnd = remaining.indexOf('\r\n');
if (lineEnd === -1) break;

const chunkSizeStr = remaining.substring(0, lineEnd).trim();
if (!chunkSizeStr) break;

const chunkSize = parseInt(chunkSizeStr, 16);
if (isNaN(chunkSize) || chunkSize === 0) break;

const chunkStart = lineEnd + 2;
const chunkData = remaining.substring(chunkStart, chunkStart + chunkSize);
result += chunkData;
remaining = remaining.substring(chunkStart + chunkSize + 2); // +2 for trailing \r\n
}

return result;
}

/**
* Make an HTTP request through a SOCKS5 proxy (e.g., Orbot).
* This enables connecting to .onion addresses via Tor.
*/
export function socks5Fetch(url: string, options: Socks5FetchOptions = {}): Promise<Socks5Response> {
const {
method = 'GET',
headers = {},
body,
timeout = DEFAULT_TIMEOUT,
connectTimeout = DEFAULT_CONNECT_TIMEOUT,
socksHost = DEFAULT_SOCKS_HOST,
socksPort = DEFAULT_SOCKS_PORT,
} = options;

const { host, port, path } = parseUrl(url);

return new Promise((resolve, reject) => {
let resolved = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const chunks: Buffer[] = [];

const finish = (fn: () => void) => {
if (resolved) return;
resolved = true;
if (timer) clearTimeout(timer);
fn();
};

timer = setTimeout(() => {
finish(() => {
try {
client.destroy();
} catch {}
reject(new Error(`SOCKS5 connect timeout after ${connectTimeout}ms`));
});
}, connectTimeout);

const client = TcpSocket.createConnection({ host: socksHost, port: socksPort }, () => {
// Phase 1: SOCKS5 greeting - version 5, 1 auth method, no auth
client.write(Buffer.from([0x05, 0x01, 0x00]));
});

let phase: 'greeting' | 'connect' | 'http' = 'greeting';
let pending = Buffer.alloc(0);

// Returns the full length of a SOCKS5 CONNECT reply in `buf`, or null if
// more bytes are needed to determine it, or -1 if the address type is unknown.
const getSocks5ConnectReplyLength = (buf: Buffer): number | null => {
if (buf.length < 5) return null;
const atyp = buf[3];
if (atyp === 0x01) return 10; // IPv4: VER+REP+RSV+ATYP + 4 + PORT(2)
if (atyp === 0x04) return 22; // IPv6: VER+REP+RSV+ATYP + 16 + PORT(2)
if (atyp === 0x03) {
const domainLength = buf[4];
return buf.length >= 5 + domainLength + 2 ? 5 + domainLength + 2 : null;
}
return -1;
};

client.on('data', (data: string | Buffer) => {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
pending = pending.length ? Buffer.concat([pending, buf]) : buf;

while (pending.length > 0) {
if (phase === 'greeting') {
if (pending.length < 2) return;

// Verify SOCKS5 server accepted no-auth
if (pending[0] !== 0x05 || pending[1] !== 0x00) {
finish(() => {
client.destroy();
reject(new Error('SOCKS5 authentication negotiation failed'));
});
return;
}
pending = pending.subarray(2);

// Phase 2: Send CONNECT request with domain name
phase = 'connect';
const domainBuf = Buffer.from(host, 'ascii');
const req = Buffer.alloc(7 + domainBuf.length);
req[0] = 0x05; // SOCKS version
req[1] = 0x01; // CONNECT command
req[2] = 0x00; // Reserved
req[3] = 0x03; // Address type: domain name
req[4] = domainBuf.length; // Domain length
domainBuf.copy(req, 5);
req.writeUInt16BE(port, 5 + domainBuf.length); // Port
client.write(req);
} else if (phase === 'connect') {
const replyLength = getSocks5ConnectReplyLength(pending);
if (replyLength === null) return;

if (replyLength < 0 || pending[0] !== 0x05 || pending[1] !== 0x00) {
const errorCode = pending.length >= 2 ? pending[1] : -1;
finish(() => {
client.destroy();
reject(new Error(`SOCKS5 CONNECT failed (code: ${errorCode})`));
});
return;
}
pending = pending.subarray(replyLength);

// Phase 3: Tunnel established - swap to full request timeout and send HTTP request
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
finish(() => {
try {
client.destroy();
} catch {}
reject(new Error(`SOCKS5 request timeout after ${timeout}ms`));
});
}, timeout);
phase = 'http';
const requestHeaders: Record<string, string> = {
Host: host,
Connection: 'close',
Accept: 'application/json',
...headers,
};

let httpRequest = `${method} ${path} HTTP/1.1\r\n`;
for (const [key, value] of Object.entries(requestHeaders)) {
httpRequest += `${key}: ${value}\r\n`;
}
if (body) {
httpRequest += `Content-Length: ${Buffer.byteLength(body)}\r\n`;
}
httpRequest += '\r\n';
if (body) {
httpRequest += body;
}

client.write(Buffer.from(httpRequest));
} else if (phase === 'http') {
chunks.push(pending);
pending = Buffer.alloc(0);
}
}
});

client.on('close', () => {
finish(() => {
try {
const rawResponse = Buffer.concat(chunks).toString('utf-8');
const headerEnd = rawResponse.indexOf('\r\n\r\n');
if (headerEnd === -1) {
reject(new Error('Invalid HTTP response: no header terminator'));
return;
}

const headerPart = rawResponse.substring(0, headerEnd);
const bodyPart = rawResponse.substring(headerEnd + 4);

// Parse status line
const statusLine = headerPart.split('\r\n')[0];
const statusMatch = statusLine.match(/HTTP\/[\d.]+\s+(\d+)\s*(.*)/);
const status = statusMatch ? parseInt(statusMatch[1], 10) : 0;
const statusText = statusMatch ? statusMatch[2] : '';

// Parse headers
const responseHeaders: Record<string, string> = {};
const headerLines = headerPart.split('\r\n').slice(1);
for (const line of headerLines) {
const colonIndex = line.indexOf(':');
if (colonIndex > 0) {
const key = line.substring(0, colonIndex).trim().toLowerCase();
const value = line.substring(colonIndex + 1).trim();
responseHeaders[key] = value;
}
}

// Handle chunked transfer encoding
let responseBody = bodyPart;
if (responseHeaders['transfer-encoding']?.includes('chunked')) {
responseBody = decodeChunked(bodyPart);
}

resolve({
ok: status >= 200 && status < 300,
status,
statusText,
headers: responseHeaders,
json: async () => JSON.parse(responseBody),
text: async () => responseBody,
});
} catch (error) {
reject(new Error(`Failed to parse response: ${error instanceof Error ? error.message : String(error)}`));
}
});
});

client.on('error', (error: Error) => {
finish(() => {
reject(new Error(`SOCKS5 connection error: ${error.message}`));
});
});
});
}
Loading