Problem
src/components/NetworkMismatchBanner/NetworkMismatchBanner.test.tsx (104 lines) tests that the banner renders/hides correctly based on isNetworkMismatch/networkName. Separately, DepositModal and UnlockModal both check isNetworkMismatch to disable their submit buttons (isDisabled={... || isNetworkMismatch}) and show a local error if handleSubmit/handleUnlock is invoked while mismatched. However, there is no test — anywhere in the suite — that asserts the actual lockAssets/unlockAssets service-layer calls in src/lib/soroban.ts themselves refuse to proceed when the wallet's network doesn't match networkPassphrase/stellarNetwork. Today, network-mismatch protection is enforced only at the UI layer (disabled buttons, early-return checks in handleSubmit/handleUnlock), meaning any code path that reaches lockAssets/unlockAssets/setBoost directly — a future programmatic caller, a race where the button's disabled state hasn't yet re-rendered after a network change, or a bug in one of the two independent deposit-flow implementations described elsewhere in this batch — has no defense-in-depth check at the point where a transaction is actually built and submitted.
Acceptance Criteria
Relevant Files
src/lib/soroban.ts — lockAssets/unlockAssets/setBoost have no explicit network-passphrase verification independent of the UI's isNetworkMismatch gating
src/components/NetworkMismatchBanner/NetworkMismatchBanner.test.tsx — only tests the banner's own render logic, not that it actually prevents a transaction from being built
src/context/StellarWalletContext.tsx — computes isNetworkMismatch, consumed only by UI components today
Additional Notes
Confirmed: SorobanService never checks the wallet's actual active network before building or submitting
src/lib/soroban.ts imports a module-level networkPassphrase constant (from config, presumably derived the same way stellarNetwork is in src/context/StellarWalletContext.tsx L3) and passes it in exactly two places per flow:
- Building the transaction itself:
new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase }) (e.g. setBoost, L1180-1183; same pattern in lockAssets/unlockAssets).
- Telling the wallet what network to sign for:
walletApi.signTransaction(preparedTransaction.toXDR(), { networkPassphrase }) (lockAssets L940-942, unlockAssets similarly ~L1082, setBoost L1209-1211).
Neither of these is a check — they're both just telling Freighter/the transaction builder "build/sign this for network X." Nothing in SorobanService ever calls freighter.getNetworkDetails() (or accepts an already-fetched network value) and compares it against networkPassphrase/stellarNetwork before proceeding. That comparison exists in exactly one place in the whole codebase: StellarWalletContext.tsx L180-182:
const isNetworkMismatch = Boolean(
networkName && networkName !== stellarNetwork,
);
This is computed for UI consumption only (exposed via context value, L184-194) and is never passed into or consulted by soroban.ts. SorobanService's methods take a FreighterWalletApi (the raw wallet API) as a parameter, not the wallet context's derived isNetworkMismatch boolean — there is no plumbing today by which the service layer could consult it even if someone wanted to add a guard quickly, since soroban.ts has no dependency on StellarWalletContext at all (confirmed via grep — isNetworkMismatch only appears in UI-layer files: NetworkMismatchBanner.tsx, UnlockModal.tsx, EarningRow.tsx, farm/page.tsx, PoolDetailClient.tsx).
What actually happens today if a mismatched wallet reaches walletApi.signTransaction(...) directly (bypassing the UI's disabled-button gating): Freighter's extension itself may refuse to sign if its own active network differs from the networkPassphrase argument passed in — that's a property of Freighter's implementation, not this codebase's. The issue's premise is correct that this repo provides no defense-in-depth of its own; whatever protection exists today is entirely incidental to Freighter's behavior, not a guarantee this codebase makes and tests.
Concrete race window this misses (AC #3)
isNetworkMismatch is derived from networkName state (StellarWalletContext.tsx L63, set via refreshNetworkDetails() — triggered on mount, on visibilitychange per L170-177, and presumably on an interval or wallet event elsewhere). Between a user switching Freighter's network and this context re-rendering with the new networkName, any component reading isNetworkMismatch at click-time (e.g. UnlockModal.tsx L131 if (isNetworkMismatch) {...} inside handleUnlock, or the disabled-button check at L460) is working off stale state. Concretely: if refreshNetworkDetails is debounced or only fires on visibilitychange/interval (not a live wallet-pushed event), a user who switches networks and immediately clicks Submit — without changing tab focus or waiting for the next poll — can have isNetworkMismatch === false in React state at the moment handleSubmit/handleUnlock runs, straight through to walletApi.signTransaction.
Implementation sketch for a service-layer guard
- Add an optional
expectedNetworkPassphrase check inside SorobanService's shared pre-flight path (there's already a natural seam: every mutating method — lockAssets, unlockAssets, setBoost — calls walletApi.signTransaction(xdr, { networkPassphrase }); wrap that call site, or better, call walletApi.getNetworkDetails() — the Freighter API this same file already imports types for — immediately before signing and compare its networkPassphrase/network field against the module-level networkPassphrase constant).
- On mismatch, return the same
TransactionResult shape used elsewhere ({ success: false, error: '...' }, matching the existing error-return convention seen throughout lockAssets/unlockAssets/setBoost) or throw a typed error (there's already a SecurityError class used by validateSimulationAuth — reusing/extending that class for a network-mismatch case would keep error-handling consistent for callers that already catch SecurityError specially, e.g. lockAssets's catch block explicitly re-throws SecurityError instances at L1153-1156 rather than swallowing them into a generic result).
- This guard should run after simulation but before
walletApi.signTransaction is called, since simulation itself doesn't require wallet signing and doesn't care about the connected wallet's active network — only the actual signing/submission step does.
Testing strategy
- New/extended test file: given the existing pattern of
soroban.test.ts, soroban.partial-unlock.test.ts, soroban.feebump.test.ts, add soroban.network-guard.test.ts (or extend soroban.test.ts) that mocks a walletApi whose getNetworkDetails/network-reporting mechanism returns a passphrase different from the module's configured networkPassphrase, and asserts lockAssets/unlockAssets/setBoost each refuse before reaching sendTransaction — assert this.rpcServer.sendTransaction (or the mocked equivalent) was never called, not just that the return value looks like an error, to prove no transaction was actually submitted.
- For the race-condition AC: this is more of a
NetworkMismatchBanner/UnlockModal/DepositModal component-level concurrency test — mock useStellarWallet to initially return isNetworkMismatch: false, render UnlockModal with the submit button enabled, then before invoking fireEvent.click, update the mock to return isNetworkMismatch: true (simulating the flip happening between render and click) and assert handleUnlock's internal check (UnlockModal.tsx L131) still catches it — this tests the component's internal re-check, not just the initial render-time isDisabled prop, since the button's isDisabled value was already baked in from the earlier render pass. If this internal re-check doesn't already re-read fresh context state (React should provide fresh context on next render/click naturally in most cases, but worth explicitly verifying since isDisabled and the handler's internal check could theoretically diverge if one reads a memoized/stale closure value — check whether handleUnlock is defined inline in the component body (fresh closure per render, likely fine) or memoized with a stale dependency array).
- Existing
NetworkMismatchBanner.test.tsx (104 lines) should remain untouched/unaffected — it correctly tests only the banner's own render logic; the new tests belong in the modals/service layer, not that file.
Cross-reference
Related to #67 ("StellarWalletContext never detects a Freighter account switch while connected, leaving publicKey stale") and #70 ("lockAssets/unlockAssets have no guard against the wallet disconnecting between simulate and sign") — both are the same category of gap: StellarWalletContext/SorobanService trusting stale or externally-mutable wallet state (network, account, connection status) without a synchronous re-check at the point of signing. A single shared "pre-flight wallet-state verification" utility, called from lockAssets/unlockAssets/setBoost right before signTransaction, could plausibly resolve #70, #93, and lay groundwork for #67's fix all at once — worth flagging to whoever picks these up so the fixes aren't implemented three separate, divergent ways.
Problem
src/components/NetworkMismatchBanner/NetworkMismatchBanner.test.tsx(104 lines) tests that the banner renders/hides correctly based onisNetworkMismatch/networkName. Separately,DepositModalandUnlockModalboth checkisNetworkMismatchto disable their submit buttons (isDisabled={... || isNetworkMismatch}) and show a local error ifhandleSubmit/handleUnlockis invoked while mismatched. However, there is no test — anywhere in the suite — that asserts the actuallockAssets/unlockAssetsservice-layer calls insrc/lib/soroban.tsthemselves refuse to proceed when the wallet's network doesn't matchnetworkPassphrase/stellarNetwork. Today, network-mismatch protection is enforced only at the UI layer (disabled buttons, early-return checks inhandleSubmit/handleUnlock), meaning any code path that reacheslockAssets/unlockAssets/setBoostdirectly — a future programmatic caller, a race where the button's disabled state hasn't yet re-rendered after a network change, or a bug in one of the two independent deposit-flow implementations described elsewhere in this batch — has no defense-in-depth check at the point where a transaction is actually built and submitted.Acceptance Criteria
lockAssets/unlockAssets/setBoostdirectly (bypassing the UI's disabled-button gating) either refuses to build/submit the transaction or surfaces a clear error, rather than relying solely on UI-level prevention.SorobanServicemethods should verify the transaction's targetnetworkPassphraseagainst the configured network before building/submitting).isNetworkMismatchflips totrueafter a modal's submit button was already enabled but before the click handler runs.Relevant Files
src/lib/soroban.ts— lockAssets/unlockAssets/setBoost have no explicit network-passphrase verification independent of the UI's isNetworkMismatch gatingsrc/components/NetworkMismatchBanner/NetworkMismatchBanner.test.tsx— only tests the banner's own render logic, not that it actually prevents a transaction from being builtsrc/context/StellarWalletContext.tsx— computes isNetworkMismatch, consumed only by UI components todayAdditional Notes
Confirmed:
SorobanServicenever checks the wallet's actual active network before building or submittingsrc/lib/soroban.tsimports a module-levelnetworkPassphraseconstant (from config, presumably derived the same waystellarNetworkis insrc/context/StellarWalletContext.tsxL3) and passes it in exactly two places per flow:new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase })(e.g.setBoost, L1180-1183; same pattern inlockAssets/unlockAssets).walletApi.signTransaction(preparedTransaction.toXDR(), { networkPassphrase })(lockAssetsL940-942,unlockAssetssimilarly ~L1082,setBoostL1209-1211).Neither of these is a check — they're both just telling Freighter/the transaction builder "build/sign this for network X." Nothing in
SorobanServiceever callsfreighter.getNetworkDetails()(or accepts an already-fetched network value) and compares it againstnetworkPassphrase/stellarNetworkbefore proceeding. That comparison exists in exactly one place in the whole codebase:StellarWalletContext.tsxL180-182:This is computed for UI consumption only (exposed via context value, L184-194) and is never passed into or consulted by
soroban.ts.SorobanService's methods take aFreighterWalletApi(the raw wallet API) as a parameter, not the wallet context's derivedisNetworkMismatchboolean — there is no plumbing today by which the service layer could consult it even if someone wanted to add a guard quickly, sincesoroban.tshas no dependency onStellarWalletContextat all (confirmed via grep —isNetworkMismatchonly appears in UI-layer files:NetworkMismatchBanner.tsx,UnlockModal.tsx,EarningRow.tsx,farm/page.tsx,PoolDetailClient.tsx).What actually happens today if a mismatched wallet reaches
walletApi.signTransaction(...)directly (bypassing the UI's disabled-button gating): Freighter's extension itself may refuse to sign if its own active network differs from thenetworkPassphraseargument passed in — that's a property of Freighter's implementation, not this codebase's. The issue's premise is correct that this repo provides no defense-in-depth of its own; whatever protection exists today is entirely incidental to Freighter's behavior, not a guarantee this codebase makes and tests.Concrete race window this misses (AC #3)
isNetworkMismatchis derived fromnetworkNamestate (StellarWalletContext.tsxL63, set viarefreshNetworkDetails()— triggered on mount, onvisibilitychangeper L170-177, and presumably on an interval or wallet event elsewhere). Between a user switching Freighter's network and this context re-rendering with the newnetworkName, any component readingisNetworkMismatchat click-time (e.g.UnlockModal.tsxL131if (isNetworkMismatch) {...}insidehandleUnlock, or the disabled-button check at L460) is working off stale state. Concretely: ifrefreshNetworkDetailsis debounced or only fires onvisibilitychange/interval (not a live wallet-pushed event), a user who switches networks and immediately clicks Submit — without changing tab focus or waiting for the next poll — can haveisNetworkMismatch === falsein React state at the momenthandleSubmit/handleUnlockruns, straight through towalletApi.signTransaction.Implementation sketch for a service-layer guard
expectedNetworkPassphrasecheck insideSorobanService's shared pre-flight path (there's already a natural seam: every mutating method —lockAssets,unlockAssets,setBoost— callswalletApi.signTransaction(xdr, { networkPassphrase }); wrap that call site, or better, callwalletApi.getNetworkDetails()— the Freighter API this same file already imports types for — immediately before signing and compare itsnetworkPassphrase/networkfield against the module-levelnetworkPassphraseconstant).TransactionResultshape used elsewhere ({ success: false, error: '...' }, matching the existing error-return convention seen throughoutlockAssets/unlockAssets/setBoost) or throw a typed error (there's already aSecurityErrorclass used byvalidateSimulationAuth— reusing/extending that class for a network-mismatch case would keep error-handling consistent for callers that already catchSecurityErrorspecially, e.g.lockAssets'scatchblock explicitly re-throwsSecurityErrorinstances at L1153-1156 rather than swallowing them into a generic result).walletApi.signTransactionis called, since simulation itself doesn't require wallet signing and doesn't care about the connected wallet's active network — only the actual signing/submission step does.Testing strategy
soroban.test.ts,soroban.partial-unlock.test.ts,soroban.feebump.test.ts, addsoroban.network-guard.test.ts(or extendsoroban.test.ts) that mocks awalletApiwhosegetNetworkDetails/network-reporting mechanism returns a passphrase different from the module's configurednetworkPassphrase, and assertslockAssets/unlockAssets/setBoosteach refuse before reachingsendTransaction— assertthis.rpcServer.sendTransaction(or the mocked equivalent) was never called, not just that the return value looks like an error, to prove no transaction was actually submitted.NetworkMismatchBanner/UnlockModal/DepositModalcomponent-level concurrency test — mockuseStellarWalletto initially returnisNetworkMismatch: false, renderUnlockModalwith the submit button enabled, then before invokingfireEvent.click, update the mock to returnisNetworkMismatch: true(simulating the flip happening between render and click) and asserthandleUnlock's internal check (UnlockModal.tsxL131) still catches it — this tests the component's internal re-check, not just the initial render-timeisDisabledprop, since the button'sisDisabledvalue was already baked in from the earlier render pass. If this internal re-check doesn't already re-read fresh context state (React should provide fresh context on next render/click naturally in most cases, but worth explicitly verifying sinceisDisabledand the handler's internal check could theoretically diverge if one reads a memoized/stale closure value — check whetherhandleUnlockis defined inline in the component body (fresh closure per render, likely fine) or memoized with a stale dependency array).NetworkMismatchBanner.test.tsx(104 lines) should remain untouched/unaffected — it correctly tests only the banner's own render logic; the new tests belong in the modals/service layer, not that file.Cross-reference
Related to #67 ("StellarWalletContext never detects a Freighter account switch while connected, leaving publicKey stale") and #70 ("lockAssets/unlockAssets have no guard against the wallet disconnecting between simulate and sign") — both are the same category of gap:
StellarWalletContext/SorobanServicetrusting stale or externally-mutable wallet state (network, account, connection status) without a synchronous re-check at the point of signing. A single shared "pre-flight wallet-state verification" utility, called fromlockAssets/unlockAssets/setBoostright beforesignTransaction, could plausibly resolve #70, #93, and lay groundwork for #67's fix all at once — worth flagging to whoever picks these up so the fixes aren't implemented three separate, divergent ways.