feat(mobile): switch between testnet and mainnet at runtime - #526
Conversation
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 is attempting to deploy a commit to the miracle656's projects Team on Vercel. A member of the Team first needs to authorize it. |
|
@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! 🚀 |
Miracle656
left a comment
There was a problem hiding this comment.
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
useSyncExternalStorecompares snapshots by identity andgetNetwork()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.
|
Merging. Nicely done — Three changes before merge: Nothing called A second jest config. The branch added I removed the Dependency list. Took Verified: 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 |
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.
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_NETWORKat 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:
EXPO_PUBLIC_NETWORKfrom the build.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().rpcUrlwant an answer now. Threading a promise through every consumer would be invasive and would push the problem into every future caller.Instead
lib/network.tskeeps 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
setNetworkswaps the wholeVeilNetworkrecord — 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.tspreviously computedIS_TESTNETonce 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.
subscribeToNetworkis the notification seam, andhooks/useNetwork.tswraps it for components:The snapshot handed to
useSyncExternalStoreis the network name, not the config object.useSyncExternalStorecompares snapshots by identity, and returninggetNetwork()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
setNetworkpersists 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.getItemthrows, hydration falls back to the build-time network rather than failing the app.EXPO_PUBLIC_MAINNET_RPC_URLandEXPO_PUBLIC_FACTORY_CONTRACT_ID_MAINNETwould otherwise fail later, inside a request, with a much worse error.describeMissingConfignames each missing piece and/settings/networkshows 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
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. Thetypes: ["jest"]addition totsconfig.jsonis in the same category; without it TypeScript 6 does not pick up@types/jestunder the Expo base config./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_URLis new; the web wallet hardcodes the mainnet Horizon URL, and this keeps it overridable without changing the default.Verification