Enable Tor on mobile - #473
Conversation
Arti already shipped inside every mobile binary — the tor feature and the network_privacy module are both unconditional — but nothing had taught it to run there. Its filesystem permission checks are written for a shared desktop filesystem. On a mobile app sandbox they add nothing the sandbox does not already enforce, and a rejection would surface from inside fail-closed mode, blocking every request while Tor never becomes ready. An idle client keeps guard connections and directory tasks alive, so it needs to sleep with the app. The intent is held process-wide and applied when a client is installed, because the request usually arrives while the first bootstrap is still running. A background process has no Dart, so it gets a C entry point that declares the route and one that brings Tor up under a deadline sized for a background window. Both fail closed: the route stays Tor-desired and not ready rather than falling back to the clear. Claude-Session: https://claude.ai/code/session_01R9nkJCPTCahMjGiDgqFNrV
Every transport mobile uses was already Tor-aware — lightwalletd gRPC, the shared HTTP client, and the iOS background paths that reach lightwalletd through Rust rather than URLSession. Only the entry point was gated to desktop, so mobile paid arti's cost and got nothing. Tor sleeps when this process really stops working. Desktop keeps polling with its windows hidden, and waking costs a circuit build, so firing on every hide would make it pay that repeatedly for nothing. Waking is asked only by the process that asked for the sleep. Claude-Session: https://claude.ai/code/session_01R9nkJCPTCahMjGiDgqFNrV
The desktop control is not reusable here: most of its copy is about desktop software updates, which the app stores own on mobile, and its toggle geometry belongs to a settings page rather than a grouped card. The state machine behind both is the same provider. The two failure directions read differently, because a failed disable leaves Tor connected and carrying traffic, and the recovery action names the route it will actually retry. Claude-Session: https://claude.ai/code/session_01R9nkJCPTCahMjGiDgqFNrV
Arti's directory records which guards this wallet chose. Carrying it to a second device would carry that choice along; a restored install should pick fresh ones, at the cost of one bootstrap. Rust creates the directory before it bootstraps and arti writes guard state into it immediately, so the exclusion has to run whether or not the bootstrap succeeds — the failure case is exactly the one that would otherwise be left exposed. Failing to mark it never fails the route, but it is no longer silent. On Android `allowBackup="false"` stops cloud backup and, from target SDK 31, says nothing about device transfer, which needs its own rules. Claude-Session: https://claude.ai/code/session_01R9nkJCPTCahMjGiDgqFNrV
Rust's route policy is process-local and starts Direct, and only Dart sets it. iOS kills the app often, so a background task can launch a cold process where Dart never ran and the background lightwalletd calls go over clearnet even though the user chose Tor. The confirmation tracker makes this the normal path — it holds its queries while the app is active and releases them once it is not. What that traffic costs decides the rule. A warm client is reached in 0.64 s and zero bytes, and its idle padding is ~500 B/s awake against ~27 B/s dormant; a cold bootstrap is 8.45 MB and 23.7 s. Those are fine on a charger over an unmetered link and are not fine on battery or cellular, so a background pass proceeds there and defers otherwise. Each signal fails closed while it is still unknown, and the route is declared before the first network call so a path that slips the gate is refused rather than sent in the clear. A run that is merely mid-wave is no longer parked with the runs that genuinely need the foreground, which used to leave background tracking parked after the user turned Tor back off. The copy follows the rule, and a device whose OS has no background lane at all still reads differently from a route that only constrains one. Claude-Session: https://claude.ai/code/session_01R9nkJCPTCahMjGiDgqFNrV
A failed Tor bootstrap surfaces from inside fail-closed mode, so a host test cannot reach it. This drives the real bootstrap against the app's own support directory on a booted simulator or device, without going through onboarding first. Claude-Session: https://claude.ai/code/session_01R9nkJCPTCahMjGiDgqFNrV
b9118b1 to
a2b1be1
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a2b1be1fdf
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Each C entry point built its own current-thread runtime and dropped it on return. That is fine for a request that completes inside the call, but a background pass brings Tor up and leaves the client in a process-wide slot: arti's directory, guard and circuit tasks run on whatever runtime created it, so the client outlived its own machinery. The caller saw a successful bring-up and every later call reused a client that could not carry traffic. One runtime for the lifetime of the process, multi-threaded because separate native threads block on it. The outbox wake no longer bakes the route into its request either. The route is a setting the user can change after the request is submitted, and a wake that turned direct would keep waiting for a charger it no longer needs, holding signed work past the height it was scheduled for. Claude-Session: https://claude.ai/code/session_01R9nkJCPTCahMjGiDgqFNrV
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b06cacc393
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…lined wakes Three things the background lanes lost by being allowed onto Tor at all. A migration broadcast from the outbox went out on the shared route, while every foreground broadcast takes its own circuit. One transaction per circuit is the property the isolated path exists for, and a background send that shares with wallet queries hands a single exit the linkage it buys. The send entry point now isolates; the two query entry points deliberately do not, because chain queries are linkable to each other whatever circuit carries them. A wake that declined the route completed without arming a replacement, and starting it had already consumed the pending request. A device that reached a charger an hour later got no execution opportunity at all, so signed work waited for the user to open the app, possibly past the height it was scheduled for. The wake now arms its replacement first, and stops only when there is no work left — battery, metering and a failed bring-up all change on their own, so none of them is evidence about the next wake. Losing power or gaining a metered link mid-wave ended confirmation tracking the way a failure would. It is an interruption, not an outcome: it now pauses and re-arms, like an expiry. Claude-Session: https://claude.ai/code/session_01R9nkJCPTCahMjGiDgqFNrV
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5e66ae1757
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // than on the notification-gate callback. A client that does not come up | ||
| // ready ends the wake the same way an unaffordable one does: fail-closed, | ||
| // no network work, the run left to the foreground. | ||
| guard BackgroundNetworkRoute.allowsBackgroundNetworkWork() else { |
There was a problem hiding this comment.
Honor expiration while bootstrapping Tor
When a cold Tor bootstrap overlaps task expiration, expire() cannot interrupt it because activeCancellation is not created until after this blocking guard returns. A successful bootstrap then creates a fresh, uncancelled token and starts tip queries and broadcasts after the task has expired; if iOS suspends the process first, the consumed wake is neither completed nor re-armed, potentially leaving signed outbox items past their scheduled height. Make Tor bring-up cancellable or check the expired state and re-arm before entering runOutbox.
AGENTS.md reference: AGENTS.md:L539-L541
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Valid. Fixed in 8bb5330, though only partly — worth being precise about which part.
activeCancellation is now published before the bring-up, so expiry, foreground handoff and wallet mutation all have something to cancel during it. But the bring-up is a synchronous FFI call with no cancel handle, so cancelling does not interrupt it; it makes the outcome observable. The second half is what closes the hole: after the gate returns, the wake re-reads its state and, if it was reclaimed, re-arms and completes instead of entering runOutbox. So an expired wake no longer starts tip queries or broadcasts, and no longer disappears without leaving a replacement.
Making the bring-up itself interruptible needs a cancellation token threaded through the Rust entry point; that is a larger change than this branch should carry, and the bound above holds without it.
| BGTaskScheduler.shared.cancel( | ||
| taskRequestWithIdentifier: Self.taskIdentifier | ||
| ) |
There was a problem hiding this comment.
Re-arm launch-time Tor deferrals
When a Tor-enabled continued-processing task is delivered while the device is on battery or a metered link, or when initial Tor bootstrap fails, both launch guards end in this function. Cancelling the identifier and completing without submitting a replacement consumes the only tracking request, so later connecting to power and an unmetered network cannot resume confirmation tracking; the wave instead waits for the user to reopen Vizor despite the surrounding copy promising automatic resumption. Re-arm before completing, as the mid-wave routeDeferred path does.
AGENTS.md reference: AGENTS.md:L523-L530
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Valid. Fixed in 8bb5330 by routing both launch-time stand-downs through the same re-arm decision the mid-wave path already uses, rather than adding a second mechanism. The re-arming branch no longer cancels the identifier, and it keeps the mid-wave submit-then-complete ordering and 10s latch.
One honest limit: startAuthorized refuses to submit while the route is unaffordable — deliberately, so a user-visible activity does not spin declining every query. So the re-arm is effectively a no-op for the unaffordable gate, and takes effect for the bring-up-failure gate and for the race between them. Guaranteeing "plugs in an hour later and tracking resumes" would need the silent processing task to re-arm confirmation tracking, which the already-shipped mid-wave path does not do either, so I did not widen it here.
Both files' task exits were enumerated while fixing this, and the remaining ones each have an owner (mutation-quiesce resume, foreground handoff, revoked notifications, nothing left to do, watchdog + notification).
…n launch Starting a background wake consumes its pending request, so every path that ends one has to either finish the work or leave a replacement. Two did neither. The outbox wake installed its expiration handler and then blocked for tens of seconds bringing Tor up, but the token expiry cancels was only created after that. An expiry during bring-up had nothing to cancel, and a bring-up that then succeeded started tip queries and broadcasts on an already-expired task; if iOS suspended the process first, the wake was neither completed nor re-armed and signed items waited past the height they were scheduled for. The token is now published before the bring-up, and a wake that finds itself reclaimed re-arms instead of starting network work. The bring-up itself is a synchronous call with no cancel handle, so this bounds the damage rather than removing it. Confirmation tracking already re-armed when it stood down mid-wave, but the two launch-time stand-downs — an unaffordable device, and a bring-up that failed — cancelled the identifier and completed, consuming the only tracking request while the screen promised the wave would resume on its own. They now take the same re-arm decision as the mid-wave path. Claude-Session: https://claude.ai/code/session_01R9nkJCPTCahMjGiDgqFNrV
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8bb5330fac
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| guard BackgroundNetworkRoute.allowsBackgroundNetworkPass() else { | ||
| BGTaskScheduler.shared.cancel( | ||
| taskRequestWithIdentifier: Self.taskIdentifier | ||
| ) |
There was a problem hiding this comment.
Let deferred tracking submit its replacement
When a Tor tracking task is interrupted because the charger is removed or the network becomes metered, both finishTorDeferredTask and finishConfirmationTrackingTask call start() to re-arm it, but this guard immediately tests the same still-unaffordable conditions, cancels the request, and returns false. Fresh evidence after the earlier re-arm fix is that the replacement now reaches this scheduling-time gate, so no future task exists to notice when charging or networking becomes eligible again; the wave remains paused until Vizor is reopened. Allow the replacement request to be submitted and enforce affordability when that task actually launches.
AGENTS.md reference: AGENTS.md:L589-L593
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Valid — this is the limit I flagged in the previous round, and it should not have been left there. Fixed in 890d645, but not the way the comment suggests, because the SDK rules that out.
BGTaskRequest.h (iPhoneOS26.2) on BGContinuedProcessingTaskRequest: "earliestBeginDate will be outright ignored by the scheduler in favor of NSDate.now", and the type is documented as "a request to begin a workload immediately, or shortly after submission". So a continued-processing request cannot hold a wait. Submitting it while the device is ineligible produces start → stand down → resubmit with no floor, and every turn is a user-visible activity — measured at 719 in an hour against 0 today.
So the wait moved to a request type that can hold one. An unaffordable route now schedules the silent BGProcessingTask, which does honour a start floor, and that wake re-arms tracking once it has established the device can carry the route. Same hour on an ineligible device: 5 wakes, 0 visible activities, and something is still watching at the end.
Fixing it at the scheduling gate rather than at the caller covers every caller — including the mid-wave stand-down, which reached the same dead end and which I had believed was already fixed.
Arm-now keeps its gate: with no usable delay, dropping it would show an activity that vanishes the instant a user starts a migration, and the only argument for dropping it was the gap the handoff now closes.
Device-dependent and stated as such: BGProcessingTask delivery is best-effort within ~2 days, so the 15-minute watchdog and the foreground remain the backstops; and whether a background-submitted continued-processing request is accepted at all (the header describes the initialiser as acting on behalf of the foregrounded app) is now on the list to check on a device.
Confirmation tracking stood down when the device could not afford the Tor route, and re-armed by asking to be scheduled again — but scheduling began with the same affordability check, so the replacement was never submitted and nothing was left to notice the device becoming eligible. The wave waited for the app to be reopened, which is what the re-arm existed to avoid. Submitting anyway is not the answer: the SDK documents that a continued-processing request ignores earliestBeginDate in favour of now, so a device that stays ineligible would start, stand down and resubmit in a tight loop, and each turn of it is an activity the user can see. The wait moves to a request type that can hold one. An unaffordable route now schedules the silent processing wake instead, and that wake arms tracking again once it has established the device can carry the route. Fixing it at the scheduling gate covers every caller, including the mid-wave stand-down that reached the same dead end. Arming from the foreground keeps its check: without a usable delay, dropping it would show an activity that vanishes the moment a migration starts, and the case for dropping it was the gap the handoff now fills. Claude-Session: https://claude.ai/code/session_01R9nkJCPTCahMjGiDgqFNrV
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 890d6451f3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| guard BackgroundNetworkRoute.allowsBackgroundNetworkPass() else { | ||
| finishTorDeferredWake(task) | ||
| return |
There was a problem hiding this comment.
Reset the wake state before deferring the Tor route
When the app has previously entered the foreground, handoffToForeground() leaves foregroundHandoffRequested set even if no wake is active. If a later Tor-enabled wake arrives on battery or a metered link, this guard exits before prepareForBackgroundWake() clears that flag; finishWakeWithoutNetworkWork() consequently sees .finishForegroundOnly and does not submit a replacement, consuming the request and potentially leaving signed outbox items past their scheduled height. Fresh evidence after the earlier re-arm fix is this ordering: the new route gate still precedes the only reset of the foreground-handoff state.
AGENTS.md reference: AGENTS.md:L539-L541
Useful? React with 👍 / 👎.
| torStateLock.lock() | ||
| let alreadyUp = torIsUpForBackgroundWork | ||
| torStateLock.unlock() | ||
| if alreadyUp { return true } |
There was a problem hiding this comment.
Revalidate cached Tor readiness against the Rust client
After one successful background Tor pass sets torIsUpForBackgroundWork, a foreground switch to direct drops the Rust TorClient but never clears this Swift flag. If the user subsequently enables Tor and that foreground bootstrap fails after persisting the preference, an affordable background wake returns true here without retrying the FFI bootstrap; every lightwalletd call then fails closed because Rust has no client, so confirmation tracking and outbox broadcasts cannot recover in the background until a process restart or foreground retry.
AGENTS.md reference: AGENTS.md:L539-L541
Useful? React with 👍 / 👎.
Note
Draft. Based on #472 — review the four commits after
rowan/tor-followup-fixes; merge #472 first, then retarget this tomain.Runs on the iOS simulator: the app builds with arti linked, launches, and Tor bootstraps from the app's own support directory. No device run yet, and the simulator cannot settle the filesystem-permission question either way — see "What is not verified".
Problem
Arti already ships inside every iOS and Android binary.
zcash_client_backend'storfeature and Rust'snetwork_privacymodule are both unconditional, and a merged commit already patched the iOS podspec to link liblzma for it. Mobile pays the dependency and binary-size cost today and gets nothing: the only Dart entry point is gated onkAppFormFactor == AppFormFactor.desktop.Every transport mobile uses is already Tor-aware — lightwalletd gRPC (sync, broadcast, migration preparation), the shared HTTP client (price, swap, Sapling params, wallet link), and even the iOS background paths, which reach lightwalletd through Rust rather than URLSession. What was missing was the entry point and a place to turn it on.
Issue #455's specific ask — a unique Tor circuit per Ironwood migration transaction — is implemented in shared Rust, so mobile inherits it for every foreground broadcast. The iOS background outbox reached lightwalletd through a C entry point that opened the shared route instead; that entry point now isolates too, so the property holds on both paths.
Solution
Apply the route on mobile, and give mobile settings its own control. The desktop control is not reused: six of its eight copy branches are about desktop software updates, which the app stores own here, and its toggle geometry belongs to a settings page rather than a grouped card. The state machine behind both is the same provider.
Two things the desktop route never needed, both recovered from the Tor integration this repo shipped and removed in April 2026 (
b1cb18bb6):Background work no longer ignores the route. Rust's route policy is process-local and starts Direct, and only Dart sets it. iOS kills the app often, so a background task can launch a cold process where Dart never ran:
route_decisionreturns Direct and the background lightwalletd calls go over clearnet even though the user chose Tor. The confirmation tracker makes this the normal path — it deliberately holds its queries while the app is active and releases them once it is not.A background pass may now use Tor on external power over an unmetered link, and defers to the foreground otherwise; each signal fails closed while it is still unknown. A cold background process has no Dart, so it brings Tor up itself under a deadline sized for a background window rather than the foreground's, and an expiry keeps the route Tor-desired and not ready rather than falling back to the clear. The native layer declares the persisted route before its first network call, so a path that slips the gate is refused rather than sent in the clear.
Guard state stays off device backups. Arti's directory records which guards this wallet chose, and on iOS it lands in Application Support, which iCloud backs up. A restored install should pick fresh guards; that costs one bootstrap. Android needs nothing here —
allowBackup="false"already covers it.Why the constraint is power and metering
Measured with a standalone probe against arti 0.35.0 using the feature set
zcash_client_backendenables:The recurring cost is channel padding, not CPU — and it runs even with zero wallet traffic, because the forced
onion-service-clientfeature keeps a 3+1 circuit pool alive. Dormancy removes it; it does not reduce CPU, because GuardMgr's periodic loop is dormancy-unaware.So the expensive case is a cold bootstrap, not Tor itself: a warm client is free to reach and its steady-state cost is small. Gating background work on external power and an unmetered link is what those numbers support — it keeps the 8.45 MB case off cellular and off battery without giving up background progress entirely.
Validation
fvm flutter test— 30 failures, byte-identical to the same run onorigin/main(pre-existing Ironwood and figma_compare). No new failures.fvm flutter test --tags mobile --run-skipped --dart-define=VIZOR_FORM_FACTOR=mobile— 17 failures, likewise identical to theorigin/mainbaseline.cargo test --lib— 606 passed.fvm flutter analyze— no errors; the 23 pre-existing Ironwood warnings are unchanged.cargo check --target aarch64-apple-iosand--target aarch64-linux-android(NDK 28.2), both clean.arm64-apple-ios15.0andarm64-apple-ios26.0.On a booted iPhone 17 Pro Max simulator:
fvm flutter build ios --simulatorsucceeds with arti linked into the app target, and the app launches and renders onboarding — mobile startup is not blocked by applying the route.integration_test/mobile_tor_bootstrap_probe_test.dartreachesNetworkPrivacyStatus.readyagainst the app's own support directory, which structurally requires the arti bootstrap to have succeeded.What is not verified
BGContinuedProcessingTask/BGProcessingTasklifecycles.android_jni.rs), so the iOS background work has no Android counterpart to need.Follow-ups worth raising upstream
librustzcashdoes not surface arti'sPaddingLevel::Reduced, which arti documents as being for mobile, and itstorfeature forcesonion-service-clienton, which is what creates the always-on circuit pool that makes padding run at all.https://claude.ai/code/session_01R9nkJCPTCahMjGiDgqFNrV