Skip to content

No test asserts NetworkMismatchBanner's warning actually blocks signing, only that the button is visually disabled #93

Description

@prodbycorne

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

  • Add an integration test that mocks a network-mismatched wallet state and asserts that calling lockAssets/unlockAssets/setBoost directly (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.
  • If no such server/service-layer guard currently exists, add one (e.g. SorobanService methods should verify the transaction's target networkPassphrase against the configured network before building/submitting).
  • Add a test for the race condition where isNetworkMismatch flips to true after a modal's submit button was already enabled but before the click handler runs.
  • Document the layered defense (UI-level + service-level) once implemented.

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

  1. 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).
  2. 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).
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial CampaignCampaign: Official CampaignOfficial Campaign | FWC26Campaign: Official Campaign | FWC26testingUnit, integration, or e2e test coveragevery hardExtremely hard — deep expertise, careful design, and significant time requiredwalletFreighter wallet integration, session, and network switching

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions