Skip to content

Commit bb180b9

Browse files
committed
perf(mobile): solve enroll PoW natively — fast connect + responsive UI
The enroll anti-flood proof-of-work is ~1M SHA-256 hashes (20-bit difficulty). Solving it in JS on Hermes takes many seconds AND saturates the single JS thread, so "Connecting…" dragged, multi-hop (which solved twice, sequentially) often hit the 40s watchdog ("Connection timed out. Try another location."), and the UI felt janky mid-connect. - core: `enroll` accepts an optional `powSolver` override (EnrollOptions). - Android/iOS: native `solvePow` (Kotlin MessageDigest / Swift CryptoKit) loops off the JS thread — ~100x faster, sub-second for 20 bits — mirroring core's hasLeadingZeroBits contract exactly. Random start nonce (no replay). - mobile: `lib/pow.ts` prefers the native solver, falls back to the JS solver when absent; single-hop and both multi-hop enrolls use it. The two multi-hop enrolls now run concurrently (Promise.all) so their solves overlap. Also fixes a timing-flaky probe test (inject a clock into pingGateway instead of measuring real setTimeout jitter). UI: - Boot: "Connecting to the decentralized Flux network…" + a "Powered by Flux" footer (leads to runonflux.com); same attribution added to Settings. - Settings tagline: "Decentralized WireGuard VPN on Flux Cloud." - ConnectScreen is now scrollable with a floored orb area, so on short screens the orb no longer overlaps the Fast / Multi-hop toggle.
1 parent 170fa75 commit bb180b9

15 files changed

Lines changed: 317 additions & 29 deletions

File tree

clients/core-ts/src/enroll.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ export async function enroll(
2424
): Promise<EnrollResponse> {
2525
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
2626
const bits = options.powBits ?? POW_BITS;
27-
const nonce = await solvePoW(publicKeyB64, bits);
27+
const solve = options.powSolver ?? solvePoW;
28+
const nonce = await solve(publicKeyB64, bits);
2829

2930
const { data } = await fetchSigned<EnrollResponse>(
3031
`http://${gatewayIp}:${CONTROL_PORT}/v1/enroll`,

clients/core-ts/src/probe.test.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,61 @@
11
import { describe, expect, it, vi } from 'vitest';
22
import { pingGateway } from './probe.js';
33

4-
/** A fetch that resolves after `delay` ms with a 200 (or a rejection). */
5-
function fakeFetch(plan: Array<number | 'fail'>) {
4+
/** A monotonic virtual clock, advanced by the fake fetch to simulate RTT. */
5+
function makeClock() {
6+
let t = 1000;
7+
return { now: () => t, advance: (ms: number) => void (t += ms) };
8+
}
9+
10+
/**
11+
* A fetch that "takes" `step` ms per call by advancing `clock` (or rejects on
12+
* `'fail'`). Using a virtual clock instead of real `setTimeout` makes the RTT
13+
* each sample records exact — so the summary is deterministic and the test
14+
* can't flake on OS timer jitter.
15+
*/
16+
function fakeFetch(plan: Array<number | 'fail'>, clock: ReturnType<typeof makeClock>) {
617
let i = 0;
718
return vi.fn(async () => {
819
const step = plan[i++ % plan.length];
920
if (step === 'fail') {
1021
throw new Error('network');
1122
}
12-
await new Promise((r) => setTimeout(r, step));
23+
clock.advance(step);
1324
return { ok: true, arrayBuffer: async () => new ArrayBuffer(0) } as unknown as Response;
1425
});
1526
}
1627

1728
describe('pingGateway', () => {
1829
it('summarises RTT + jitter over samples', async () => {
30+
const clock = makeClock();
1931
const r = await pingGateway('http://x:51821', {
2032
samples: 3,
21-
fetchImpl: fakeFetch([10, 10, 10]),
33+
fetchImpl: fakeFetch([10, 10, 10], clock),
34+
now: clock.now,
2235
});
2336
expect(r.rttMs).not.toBeNull();
2437
expect(r.loss).toBe(0);
2538
expect(r.jitterMs).toBe(0); // identical samples → no jitter
2639
});
2740

2841
it('reports loss when samples fail', async () => {
42+
const clock = makeClock();
2943
const r = await pingGateway('http://x:51821', {
3044
samples: 4,
31-
fetchImpl: fakeFetch(['fail', 'fail', 'fail', 'fail']),
45+
fetchImpl: fakeFetch(['fail', 'fail', 'fail', 'fail'], clock),
46+
now: clock.now,
3247
});
3348
expect(r.rttMs).toBeNull();
3449
expect(r.jitterMs).toBeNull();
3550
expect(r.loss).toBe(1);
3651
});
3752

3853
it('computes partial loss + a non-zero jitter for varied samples', async () => {
54+
const clock = makeClock();
3955
const r = await pingGateway('http://x:51821', {
4056
samples: 4,
41-
fetchImpl: fakeFetch([5, 25, 'fail', 15]),
57+
fetchImpl: fakeFetch([5, 25, 'fail', 15], clock),
58+
now: clock.now,
4259
});
4360
expect(r.loss).toBeCloseTo(0.25, 5);
4461
expect(r.rttMs).not.toBeNull();

clients/core-ts/src/probe.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,24 +22,32 @@ export interface PingResult {
2222
*
2323
* @param controlUrl - The gateway control base URL, e.g. `http://<ip>:51821`.
2424
* @param options - `samples` (default 4), `timeoutMs` per sample (default 4000),
25-
* and an optional `fetchImpl`.
25+
* an optional `fetchImpl`, and an optional `now` clock (default `Date.now`;
26+
* injectable so tests are deterministic rather than dependent on OS timer
27+
* jitter).
2628
* @returns Median RTT, jitter, and loss over the samples.
2729
*/
2830
export async function pingGateway(
2931
controlUrl: string,
30-
options: { samples?: number; timeoutMs?: number; fetchImpl?: FetchImpl } = {},
32+
options: {
33+
samples?: number;
34+
timeoutMs?: number;
35+
fetchImpl?: FetchImpl;
36+
now?: () => number;
37+
} = {},
3138
): Promise<PingResult> {
3239
const samples = Math.max(1, options.samples ?? 4);
3340
const timeoutMs = options.timeoutMs ?? 4000;
3441
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
42+
const now = options.now ?? Date.now;
3543

3644
const rtts: number[] = [];
3745
let failures = 0;
3846

3947
for (let i = 0; i < samples; i += 1) {
4048
const controller = new AbortController();
4149
const timer = setTimeout(() => controller.abort(), timeoutMs);
42-
const started = Date.now();
50+
const started = now();
4351
try {
4452
const res = await fetchImpl(`${controlUrl}/v1/info`, {
4553
method: 'GET',
@@ -48,7 +56,7 @@ export async function pingGateway(
4856
// Drain the body so the socket can be reused/closed on all runtimes.
4957
await res.arrayBuffer().catch(() => undefined);
5058
if (res.ok) {
51-
rtts.push(Date.now() - started);
59+
rtts.push(now() - started);
5260
} else {
5361
failures += 1;
5462
}

clients/core-ts/src/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,16 @@ export interface EnrollOptions {
131131
readonly fetchImpl?: FetchImpl;
132132
/** Proof-of-work difficulty; defaults to {@link POW_BITS}. */
133133
readonly powBits?: number;
134+
/**
135+
* Override the proof-of-work solver. The default JS solver runs ~1M SHA-256
136+
* hashes on the calling thread — seconds on a phone's Hermes engine, which
137+
* both stalls the connect and janks the UI. Native clients pass a solver that
138+
* loops in Kotlin/Swift (millions of hashes/sec, off the JS thread), so a
139+
* 20-bit solve finishes in well under a second. Must satisfy the same
140+
* contract as core `solvePoW` (return a decimal-string nonce whose
141+
* `sha256(pubkey||nonce)` has `bits` leading zero bits).
142+
*/
143+
readonly powSolver?: (publicKeyB64: string, bits: number) => Promise<string>;
134144
/**
135145
* Pinned gateway signing pubkey (base64). When set, the response signature
136146
* must verify against it; otherwise the pubkey advertised in the response

clients/mobile/App.tsx

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import { useEffect, useState } from 'react';
1010
import { ActivityIndicator, BackHandler, StatusBar, StyleSheet, Text, View } from 'react-native';
1111
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
12+
import { PoweredByFlux } from './src/components/PoweredByFlux';
1213
import { useVpn } from './src/state/useVpn';
1314
import { ConnectScreen } from './src/screens/ConnectScreen';
1415
import { CountryPickerScreen } from './src/screens/CountryPickerScreen';
@@ -44,8 +45,13 @@ function App(): React.JSX.Element {
4445
<View style={styles.screen}>
4546
{vpn.booting && vpn.countries.length === 0 ? (
4647
<View style={styles.boot}>
47-
<ActivityIndicator color={color.cyan} />
48-
<Text style={styles.bootText}>Connecting to the Flux network…</Text>
48+
<View style={styles.bootCenter}>
49+
<ActivityIndicator color={color.cyan} />
50+
<Text style={styles.bootText}>Connecting to the decentralized Flux network…</Text>
51+
</View>
52+
<View style={styles.bootFooter}>
53+
<PoweredByFlux />
54+
</View>
4955
</View>
5056
) : route === 'countries' ? (
5157
<CountryPickerScreen
@@ -106,8 +112,16 @@ const styles = StyleSheet.create({
106112
// future react-native-linear-gradient pass renders the full 3-stop gradient.
107113
safe: { flex: 1, backgroundColor: color.sky1 },
108114
screen: { flex: 1, backgroundColor: color.sky2 },
109-
boot: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 14 },
110-
bootText: { color: color.inkDim, fontSize: 14 },
115+
boot: { flex: 1 },
116+
bootCenter: {
117+
flex: 1,
118+
alignItems: 'center',
119+
justifyContent: 'center',
120+
gap: 14,
121+
paddingHorizontal: 32,
122+
},
123+
bootText: { color: color.inkDim, fontSize: 14, textAlign: 'center' },
124+
bootFooter: { paddingBottom: 20, alignItems: 'center' },
111125
});
112126

113127
export default App;

clients/mobile/android/app/src/main/java/com/cumulusvpn/tunnel/CumulusTunnelModule.kt

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import android.app.Activity
44
import android.content.Intent
55
import android.net.VpnService
66
import android.provider.Settings
7+
import java.security.MessageDigest
8+
import java.util.concurrent.Executors
9+
import kotlin.random.Random
710
import com.facebook.react.bridge.ActivityEventListener
811
import com.facebook.react.bridge.Arguments
912
import com.facebook.react.bridge.BaseActivityEventListener
@@ -32,6 +35,11 @@ class CumulusTunnelModule(
3235

3336
private var permissionPromise: Promise? = null
3437

38+
// A small pool so the two multi-hop enrollments can solve their PoW
39+
// concurrently on separate cores instead of serialising. Native SHA-256 is
40+
// ~100x the Hermes JS solver, so each 20-bit solve is well under a second.
41+
private val powExecutor = Executors.newCachedThreadPool()
42+
3543
private val activityListener: ActivityEventListener =
3644
object : BaseActivityEventListener() {
3745
override fun onActivityResult(
@@ -161,6 +169,63 @@ class CumulusTunnelModule(
161169
activity.startActivityForResult(consent, VPN_PERMISSION_REQUEST)
162170
}
163171

172+
/**
173+
* solvePow(publicKeyB64, bits): Promise<String>
174+
*
175+
* Solve the enroll anti-flood proof-of-work natively: find a decimal-string
176+
* `nonce` such that `sha256(pubkey||nonce)` has `bits` leading zero bits.
177+
* Runs off the JS thread on [powExecutor]; native SHA-256 clears the 20-bit
178+
* difficulty in well under a second (the pure-JS Hermes solver takes many
179+
* seconds and freezes the UI). Mirrors core `solvePoW`/`hasLeadingZeroBits`.
180+
*/
181+
@ReactMethod
182+
fun solvePow(publicKeyB64: String, bits: Double, promise: Promise) {
183+
powExecutor.execute {
184+
try {
185+
promise.resolve(solvePowSync(publicKeyB64, bits.toInt()))
186+
} catch (t: Throwable) {
187+
promise.reject("E_POW", t.message, t)
188+
}
189+
}
190+
}
191+
192+
private fun solvePowSync(publicKeyB64: String, bits: Int): String {
193+
val md = MessageDigest.getInstance("SHA-256")
194+
val pub = publicKeyB64.toByteArray(Charsets.UTF_8)
195+
// Random start so repeated solves for the same key yield DIFFERENT valid
196+
// nonces — the gateway single-uses each (pubkey, nonce) pair, so a fixed
197+
// start would make a re-enroll look like a replay ("bad_pow").
198+
var i = Random.nextLong(0, 0x40000000L)
199+
while (true) {
200+
val nonce = i.toString()
201+
md.reset()
202+
md.update(pub)
203+
md.update(nonce.toByteArray(Charsets.UTF_8))
204+
if (hasLeadingZeroBits(md.digest(), bits)) {
205+
return nonce
206+
}
207+
i++
208+
}
209+
}
210+
211+
/** True if `digest` starts with at least `bits` zero bits (mirrors core). */
212+
private fun hasLeadingZeroBits(digest: ByteArray, bits: Int): Boolean {
213+
val full = bits / 8
214+
for (k in 0 until full) {
215+
if (digest[k].toInt() != 0) {
216+
return false
217+
}
218+
}
219+
val rem = bits % 8
220+
if (rem != 0) {
221+
val mask = (0xff shl (8 - rem)) and 0xff
222+
if ((digest[full].toInt() and mask) != 0) {
223+
return false
224+
}
225+
}
226+
return true
227+
}
228+
164229
// Required for RN's NativeEventEmitter (JS-side addListener/removeListeners).
165230
@ReactMethod
166231
fun addListener(eventName: String) {

clients/mobile/ios/CumulusTunnel/CumulusTunnelModule.m

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,9 @@ @interface RCT_EXTERN_MODULE(CumulusTunnel, RCTEventEmitter)
3737
RCT_EXTERN_METHOD(requestPermission:(RCTPromiseResolveBlock)resolve
3838
rejecter:(RCTPromiseRejectBlock)reject)
3939

40+
RCT_EXTERN_METHOD(solvePow:(NSString *)publicKeyB64
41+
bits:(nonnull NSNumber *)bits
42+
resolver:(RCTPromiseResolveBlock)resolve
43+
rejecter:(RCTPromiseRejectBlock)reject)
44+
4045
@end

clients/mobile/ios/CumulusTunnel/CumulusTunnelModule.swift

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
// POC: bodies are scaffolded with the real NetworkExtension calls; the manager
1010
// save/load flow is outlined but not exhaustively error-handled.
1111

12+
import CryptoKit
1213
import Foundation
1314
import NetworkExtension
1415
import React
@@ -193,6 +194,58 @@ final class CumulusTunnelModule: RCTEventEmitter {
193194
}
194195
}
195196

197+
// solvePow(publicKeyB64, bits): Promise<String>
198+
// Solve the enroll anti-flood proof-of-work natively: find a decimal-string
199+
// nonce such that sha256(pubkey||nonce) has `bits` leading zero bits. Runs on
200+
// a background queue; CryptoKit SHA-256 clears the 20-bit difficulty in well
201+
// under a second (the pure-JS Hermes solver takes seconds and freezes the UI).
202+
// Mirrors core `solvePoW`/`hasLeadingZeroBits`.
203+
@objc(solvePow:bits:resolver:rejecter:)
204+
func solvePow(
205+
_ publicKeyB64: String,
206+
bits: NSNumber,
207+
resolver resolve: @escaping RCTPromiseResolveBlock,
208+
rejecter reject: @escaping RCTPromiseRejectBlock
209+
) {
210+
let n = bits.intValue
211+
DispatchQueue.global(qos: .userInitiated).async {
212+
resolve(Self.solvePowSync(publicKeyB64, bits: n))
213+
}
214+
}
215+
216+
private static func solvePowSync(_ publicKeyB64: String, bits: Int) -> String {
217+
let pub = Array(publicKeyB64.utf8)
218+
// Random start so repeated solves for the same key yield DIFFERENT valid
219+
// nonces (the gateway single-uses each (pubkey, nonce) pair).
220+
var i = UInt64.random(in: 0..<0x4000_0000)
221+
while true {
222+
let nonce = String(i)
223+
var buf = pub
224+
buf.append(contentsOf: nonce.utf8)
225+
if hasLeadingZeroBits(SHA256.hash(data: buf), bits: bits) {
226+
return nonce
227+
}
228+
i += 1
229+
}
230+
}
231+
232+
/// True if `digest` starts with at least `bits` zero bits (mirrors core).
233+
private static func hasLeadingZeroBits(_ digest: SHA256.Digest, bits: Int) -> Bool {
234+
let bytes = Array(digest)
235+
let full = bits / 8
236+
for k in 0..<full where bytes[k] != 0 {
237+
return false
238+
}
239+
let rem = bits % 8
240+
if rem != 0 {
241+
let mask = UInt8((0xff << (8 - rem)) & 0xff)
242+
if (bytes[full] & mask) != 0 {
243+
return false
244+
}
245+
}
246+
return true
247+
}
248+
196249
// MARK: - internals
197250

198251
private func loadOrCreateManager(
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* "Powered by Flux" attribution — the app runs on the Flux decentralized cloud.
3+
* Tapping it opens runonflux.com. Shown on the boot screen and in Settings.
4+
*
5+
* A lightweight text mark (no SVG dependency): a small lightning glyph + label,
6+
* styled to sit quietly at the bottom of a screen.
7+
*/
8+
import { Linking, Pressable, StyleSheet, Text } from 'react-native';
9+
import { color } from '../theme/tokens';
10+
11+
const FLUX_URL = 'https://runonflux.com';
12+
13+
export function PoweredByFlux({ compact = false }: { compact?: boolean }): React.JSX.Element {
14+
return (
15+
<Pressable
16+
accessibilityRole="link"
17+
accessibilityLabel="Powered by Flux — opens runonflux.com"
18+
hitSlop={8}
19+
onPress={() => void Linking.openURL(FLUX_URL).catch(() => undefined)}
20+
style={({ pressed }) => [styles.row, pressed && styles.pressed]}
21+
>
22+
<Text style={[styles.text, compact && styles.compact]}>
23+
Powered by <Text style={styles.brand}>⚡ Flux</Text>
24+
</Text>
25+
</Pressable>
26+
);
27+
}
28+
29+
const styles = StyleSheet.create({
30+
row: { alignItems: 'center', justifyContent: 'center', paddingVertical: 6 },
31+
pressed: { opacity: 0.6 },
32+
text: { color: color.inkFaint, fontSize: 12, letterSpacing: 0.3 },
33+
compact: { fontSize: 11 },
34+
brand: { color: color.cyan, fontWeight: '600' },
35+
});

0 commit comments

Comments
 (0)