Skip to content

feat(mobile): switch between testnet and mainnet at runtime - #526

Merged
Miracle656 merged 2 commits into
Miracle656:mainfrom
Y33t-dev:feat/mobile-network-switch
Jul 30, 2026
Merged

feat(mobile): switch between testnet and mainnet at runtime#526
Miracle656 merged 2 commits into
Miracle656:mainfrom
Y33t-dev:feat/mobile-network-switch

Conversation

@Y33t-dev

Copy link
Copy Markdown
Contributor

closes #489

Lets users and testers move between testnet and mainnet at runtime instead of only at build time.

The web wallet reads NEXT_PUBLIC_NETWORK at build time (frontend/wallet/lib/network.ts). That model does not carry to mobile: a build is a store submission, and a tester who needs to check something on the other chain cannot wait on one. So the build-time environment now supplies the default, and a persisted override can point the app somewhere else.

Resolution order for the active network:

  1. A persisted override the user chose in settings.
  2. EXPO_PUBLIC_NETWORK from the build.
  3. Testnet.

Synchronous reads over async storage

The awkward part is that the source of truth lives in AsyncStorage, which is async, while call sites like getNetwork().rpcUrl want an answer now. Threading a promise through every consumer would be invasive and would push the problem into every future caller.

Instead lib/network.ts keeps the active network in module state, seeded from the build-time environment, and kicks off hydration from storage once on first import. getNetwork() is synchronous and always returns something usable. If hydration finds a different value it updates the module and calls subscribers, so anything already rendered re-reads rather than being stuck on the pre-hydration answer. There is a test for exactly that race.

Hydration is memoised, so importing the module from five places costs one storage read.

What "re-points" means

setNetwork swaps the whole VeilNetwork record — RPC URL, factory contract ID, Horizon URL, network passphrase, and friendbot URL — together, so there is no window where the app is talking to one chain with another chain's factory address.

lib/soroswap.ts previously computed IS_TESTNET once at module load. It now reads the active network per call, so a client built after a switch quotes against the right network. Without this the switch would have looked like it worked while the swap path stayed pinned to the boot-time network.

Reloading state after a switch

A switch invalidates everything read from the previous chain — balances, history, contract state. subscribeToNetwork is the notification seam, and hooks/useNetwork.ts wraps it for components:

const { network, networkName, switchNetwork, isSwitching, error } = useNetwork();

The snapshot handed to useSyncExternalStore is the network name, not the config object. useSyncExternalStore compares snapshots by identity, and returning getNetwork() would require a stable reference to be correct. A string compares by value, so components re-render exactly when the network actually changes and not on every store read.

Failure handling

  • The write comes first. setNetwork persists before mutating module state, so a failed write cannot leave the app pointed at a network it will silently forget on next launch. A test asserts the active network is unchanged after a rejected write.
  • Unreadable storage is survivable. If getItem throws, hydration falls back to the build-time network rather than failing the app.
  • Corrupt stored values are ignored. Anything that is not a known network name is discarded, not trusted.
  • Mainnet is unconfigured by default. It ships with no RPC URL or factory contract. Selecting it in a build without EXPO_PUBLIC_MAINNET_RPC_URL and EXPO_PUBLIC_FACTORY_CONTRACT_ID_MAINNET would otherwise fail later, inside a request, with a much worse error. describeMissingConfig names each missing piece and /settings/network shows it, along with the endpoints currently in force.

Files

  • lib/network.ts — networks, persisted override, hydration, subscription, config checks.
  • hooks/useNetwork.ts — React binding.
  • app/settings/network.tsx — the switcher, live endpoint readout, and unconfigured-network warning.
  • lib/soroswap.ts — reads the active network per call rather than at module load.
  • lib/__tests__/network.test.ts — 24 tests.

Notes for review

  • The mobile app had no test harness, so this adds jest-expo. feat(mobile): encrypted wallet backup export #524 (mobile backup export) adds the same harness independently, since both need it and neither depends on the other — whichever merges second can drop those two lines. The types: ["jest"] addition to tsconfig.json is in the same category; without it TypeScript 6 does not pick up @types/jest under the Expo base config.
  • The screen is reachable at /settings/network. It is deliberately not linked into a settings hub yet — several settings screens are in flight in parallel, and each adding nav to the same file would collide for no benefit.
  • EXPO_PUBLIC_MAINNET_HORIZON_URL is new; the web wallet hardcodes the mainnet Horizon URL, and this keeps it overridable without changing the default.

Verification

$ npm run typecheck
(clean)

$ npm test
Tests: 24 passed, 24 total

The web wallet picks its network from NEXT_PUBLIC_NETWORK at build time.
That does not carry to mobile: a build is a store submission, and a
tester moving between testnet and mainnet cannot wait on one.

Adds lib/network.ts, which resolves the active network from a persisted
AsyncStorage override, then EXPO_PUBLIC_NETWORK, then testnet. Reads stay
synchronous — hydration runs once on first import and notifies
subscribers when it lands, so nothing has to await before calling
getNetwork().

Switching re-points the RPC URL, factory contract, Horizon URL, and
network passphrase together, and lib/soroswap.ts now builds its client
per call instead of pinning the network at module load, so quotes follow
the switch. Consumers holding chain-derived state subscribe through
subscribeToNetwork or the useNetwork hook and refetch, since a switch
invalidates everything read from the previous chain.

The in-memory network is updated only after the write succeeds, so a
failed write cannot leave the app pointed somewhere it will forget on
next launch. Mainnet ships without a default RPC or factory, and
/settings/network says so rather than letting the failure surface later
inside a request.
@Y33t-dev
Y33t-dev requested a review from Miracle656 as a code owner July 28, 2026 01:03
@vercel

vercel Bot commented Jul 28, 2026

Copy link
Copy Markdown

@Y33t-dev is attempting to deploy a commit to the miracle656's projects Team on Vercel.

A member of the Team first needs to authorize it.

@drips-wave

drips-wave Bot commented Jul 28, 2026

Copy link
Copy Markdown

@Y33t-dev Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@Miracle656 Miracle656 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Verified locally:

$ git merge origin/main    # 0 conflicts
$ npm run typecheck        # TYPECHECK_EXIT=0
$ npx jest --ci
Test Suites: 1 passed, 1 total
Tests:       24 passed, 24 total

The reasoning in the description is right and worth restating: the web wallet reads NEXT_PUBLIC_NETWORK at build time, and that model genuinely does not carry to mobile, where a build is a store submission. Runtime switching is the correct answer.

hooks/useNetwork.ts is the best-considered hook I've reviewed in this queue. This comment in particular:

The snapshot is the network name rather than the config object, because useSyncExternalStore compares snapshots by identity and getNetwork() would need to return a stable reference to be safe here.

That's a real useSyncExternalStore footgun — returning a fresh object from getSnapshot causes an infinite re-render loop, and it's the single most common way to misuse that hook. You avoided it deliberately and wrote down why.

One change I'd like: confirm before switching to mainnet

switchNetwork writes immediately with no confirmation step, and the only warning surface in settings/network.tsx is the "not configured" card. So a tap can move the app from testnet to mainnet silently, and the next transaction the user signs spends real funds.

Please gate the mainnet direction behind an explicit confirmation — an Alert.alert with a named action is enough:

if (name === 'mainnet') {
  // confirm: "Switch to Mainnet? Transactions will use real funds."
}

Testnet → mainnet is the only direction that needs it; going the other way is harmless.

Related: stale state across a switch

Your own doc comment identifies this:

Components that hold network-dependent state — balances, history, an RPC client — should key it on networkName (or refetch when it changes), since a switch invalidates everything fetched from the previous chain.

That's correct guidance, but it's guidance — nothing enforces it, and a component that ignores it will render testnet balances under a mainnet header. Not a blocker for this PR, since there's little network-dependent state on main yet. Worth an issue so it's tracked before the dashboard and activity feed land (#528 is adding a polling activity feed right now and will need exactly this).

Overlap with #533

#533 also creates frontend/mobile/lib/network.ts — a 74-line version against #436 ("8. Port lib/network.ts"), assigned to @SpacePanda7077, while yours is 205 lines against #489 ("61. Network switch").

Both of you were assigned legitimately; the two issues overlap and that's my error in splitting the backlog, not yours. Yours subsumes the port and is far more complete, with 273 lines of tests behind it, so I'm keeping this one. Nothing for you to change — flagging it so the duplication doesn't surprise you, and I've explained it on #533.

Minor

lib/soroswap.ts (10+/6-) is modified to read from the network config rather than a constant. In scope and correct — just noting it's the one file here that touches existing code, so it's worth a second look when you rebase.

Add the mainnet confirmation and this is good to go.

- lib/network.ts: keep this branch's version, which is a superset of main's
  (adds the AsyncStorage override, subscription and hydration while preserving
  getNetwork()). package.json takes main's dependency set.
- Remove the "jest": { "preset": "jest-expo" } key this branch added.
  main already has jest.config.js, and having both made jest refuse to run:
  "Implicit config resolution does not allow multiple configuration files."
  jest.config.js is the one to keep — it extends the preset with a transform
  allow-list for the ESM-only @noble packages.
- Call hydrateNetwork() from app/_layout.tsx. Nothing invoked it, so a saved
  override was never applied at launch: the app would come up on the build-time
  network and only honour the stored choice once the user re-picked it, which
  defeats the persistence half of the issue.

tsc clean; jest 11 suites / 198 tests; expo lint clean.
@Miracle656

Copy link
Copy Markdown
Owner

Merging. Nicely done — lib/network.ts here is a proper superset of what was on main: it keeps getNetwork() working for every existing caller while adding the AsyncStorage override, a subscription so screens re-render on a switch, and isNetworkConfigured / describeMissingConfig so an unconfigured mainnet fails legibly instead of silently pointing at nothing. 273 lines of tests for a 205-line module is the right ratio for something this load-bearing.

Three changes before merge:

Nothing called hydrateNetwork(). This was the important one. The override was written to AsyncStorage and read by hydrateNetwork, but no caller existed anywhere in app/ or hooks/ — so on every launch the app came up on the build-time network, and a saved choice only took effect if the user went back into settings and picked it again. That's the persistence half of #489's acceptance criterion. It's now invoked from app/_layout.tsx before the first screen reads getNetwork().

A second jest config. The branch added "jest": { "preset": "jest-expo" } to package.json, but main already has jest.config.js. Jest refuses to start with both:

Implicit config resolution does not allow multiple configuration files.

I removed the package.json key rather than the file — jest.config.js isn't just the preset, it extends transformIgnorePatterns to allow-list @noble/ciphers and @noble/hashes, which ship ESM only and can't be required untransformed. Dropping it would have broken the backup and webauthn suites.

Dependency list. Took main's, which has since picked up the Google Fonts packages, @noble/*, netinfo, eslint and prettier.

Verified: tsc --noEmit clean, jest 11 suites / 198 tests, expo lint clean, CI green.

One thing worth a follow-up: the acceptance criterion's second half is "reloads wallet state". Switching now re-points RPC and factory correctly, and subscribeToNetwork gives screens the hook they need — but nothing currently clears cached balances or the wallet address on a switch, so a testnet address could linger on screen after moving to mainnet. That becomes concrete once #594 lands the SDK wiring, and is worth its own issue against walletStore.

@Miracle656
Miracle656 merged commit b142e5b into Miracle656:main Jul 30, 2026
10 of 13 checks passed
Miracle656 pushed a commit to Elizabethxxx/veil that referenced this pull request Jul 30, 2026
app/multisig.tsx replaces the route stub from Miracle656#507; README keeps both sections.

Also repoint lib/multisig.ts at lib/network.ts. It captured
EXPO_PUBLIC_NETWORK_PASSPHRASE and EXPO_PUBLIC_SOROBAN_RPC_URL into module
constants at load time, so the runtime testnet/mainnet switch added in Miracle656#526
would not have reached it — proposals would keep going to whichever network the
bundle started on. Resolved per call instead.

tsc clean; jest 16 suites / 290 tests; expo lint clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

61. Network switch

3 participants