From 5b54f2834b38536b257bd02fd1f65e4f2b0d8bec Mon Sep 17 00:00:00 2001 From: felladaniel36-hash Date: Mon, 24 Aug 2026 09:04:44 +0100 Subject: [PATCH] #85 [Error Handling] `connectAccount` leaves wallet in partially-connected state on network failure FIXED --- src/__tests__/stellarBalance.test.ts | 106 +++++ src/__tests__/useStellarWallet.test.tsx | 499 ++++++++++++++++++++++++ src/hooks/useStellarWallet.ts | 121 ++++-- src/screens/OnboardingScreen.tsx | 7 +- src/services/stellar.ts | 28 +- src/store/walletStore.ts | 95 ++++- 6 files changed, 820 insertions(+), 36 deletions(-) create mode 100644 src/__tests__/stellarBalance.test.ts create mode 100644 src/__tests__/useStellarWallet.test.tsx diff --git a/src/__tests__/stellarBalance.test.ts b/src/__tests__/stellarBalance.test.ts new file mode 100644 index 0000000..6f0d6be --- /dev/null +++ b/src/__tests__/stellarBalance.test.ts @@ -0,0 +1,106 @@ +/** + * Tests for the error contract of stellar.getBalance / getTokenBalance. + * + * The connect flow refuses to mark a wallet as connected when the balance + * cannot be verified, so the service must distinguish: + * - unfunded account (NotFoundError) → resolves '0' (valid zero balance) + * - infrastructure/network failure → rejects (cannot verify balance) + */ + +import './__mocks__/rn-modules'; +import { Horizon, NotFoundError } from '@stellar/stellar-sdk'; +import { getBalance, getTokenBalance } from '../services/stellar'; + +type LoadAccount = Horizon.Server['loadAccount']; + +const loadAccountSpy = jest.spyOn( + Horizon.Server.prototype, + 'loadAccount', +) as jest.MockedFunction; + +function accountRecord(balances: Record[]) { + return { balances } as never; +} + +function notFound(): never { + return new NotFoundError( + 'Account not found', + new Response(null, { status: 404 }), + ) as never; +} + +beforeEach(() => { + loadAccountSpy.mockReset(); +}); + +afterAll(() => { + loadAccountSpy.mockRestore(); +}); + +describe('getBalance', () => { + it('returns the native XLM balance for a funded account', async () => { + loadAccountSpy.mockResolvedValue( + accountRecord([{ asset_type: 'native', balance: '42.5' }]), + ); + await expect(getBalance('GCFUNDED')).resolves.toBe('42.5'); + }); + + it("returns '0' when the account holds no native balance entry", async () => { + loadAccountSpy.mockResolvedValue(accountRecord([])); + await expect(getBalance('GCEMPTY')).resolves.toBe('0'); + }); + + it("resolves '0' for an unfunded account (NotFoundError)", async () => { + loadAccountSpy.mockRejectedValue(notFound()); + await expect(getBalance('GCUNFUNDED')).resolves.toBe('0'); + }); + + it('rejects on network failure so the connect flow can roll back', async () => { + const networkError = new Error('Network request failed'); + loadAccountSpy.mockRejectedValue(networkError); + await expect(getBalance('GCOFFLINE')).rejects.toBe(networkError); + }); +}); + +describe('getTokenBalance', () => { + it('returns the trustlined asset balance when it exists', async () => { + loadAccountSpy.mockResolvedValue( + accountRecord([ + { asset_type: 'native', balance: '1' }, + { + asset_type: 'credit_alphanum4', + asset_code: 'ECO', + asset_issuer: 'TESTISSUER', + balance: '75', + }, + ]), + ); + await expect( + getTokenBalance('GCFUNDED', 'ECO', 'TESTISSUER'), + ).resolves.toBe('75'); + }); + + it("returns '0' when the account has no trustline for the asset", async () => { + loadAccountSpy.mockResolvedValue( + accountRecord([{ asset_type: 'native', balance: '1' }]), + ); + await expect( + getTokenBalance('GCFUNDED', 'ECO', 'TESTISSUER'), + ).resolves.toBe('0'); + }); + + it("resolves '0' for an unfunded account (NotFoundError)", async () => { + loadAccountSpy.mockRejectedValue(notFound()); + await expect( + getTokenBalance('GCUNFUNDED', 'ECO', 'TESTISSUER'), + ).resolves.toBe('0'); + }); + + it('rejects on network failure so the connect flow can roll back', async () => { + const networkError = new Error('Horizon unavailable'); + loadAccountSpy.mockRejectedValue(networkError); + await expect( + getTokenBalance('GCOFFLINE', 'ECO', 'TESTISSUER'), + ).rejects.toBe(networkError); + }); +}); diff --git a/src/__tests__/useStellarWallet.test.tsx b/src/__tests__/useStellarWallet.test.tsx new file mode 100644 index 0000000..7bb877a --- /dev/null +++ b/src/__tests__/useStellarWallet.test.tsx @@ -0,0 +1,499 @@ +/** + * Tests for the wallet connect flow in useStellarWallet. + * + * Regression tests for the "connected with unknown balance" bug: + * connect() used to run BEFORE the balance fetch, so a network failure + * left `isConnected: true` (persisted) while the balance was null/zero. + * + * Covers, for every wallet path (Freighter, Lobstr, in-app create, import): + * - happy path: balances are verified first, then the store flips to + * connected atomically WITH those balances + * - balance fetch failure: store rolls back to a clean disconnected + * state, a meaningful connectError is recorded, and the secret key is + * NOT orphaned in the vault + * - partial failure (XLM ok, ECO fetch fails): still fully rolled back + * - 'connecting' intermediate state while the fetch is in flight + * - unfunded accounts connect with a '0' balance (zero is not failure) + * - later refresh failures keep the last known balance instead of + * rejecting into fire-and-forget call sites + */ + +import './__mocks__/setup'; +import React from 'react'; +import renderer, { act } from 'react-test-renderer'; +import { useStellarWallet } from '../hooks/useStellarWallet'; +import { useWalletStore } from '../store/walletStore'; +import { getInAppSecret, clearInAppSecret } from '../services/walletVault'; +import * as stellarMock from '../services/stellar'; +import * as lobstrMock from '../services/lobstr'; + +// The root __mocks__/react-native-config.js (empty stub) takes precedence +// over mocks registered inside imported setup modules, so the asset config +// must be mocked directly in this file for the ECO/USDC fetch paths to run. +jest.mock('react-native-config', () => ({ + __esModule: true, + default: { + STELLAR_NETWORK: 'testnet', + BACKEND_URL: 'http://localhost:3000', + ECO_TOKEN_ASSET_CODE: 'ECO', + ECO_TOKEN_ISSUER: 'TESTISSUER', + USDC_ISSUER: 'TESTUSDCISSUER', + }, +})); + +jest.mock('../services/stellar', () => { + class MockTransactionBuilder { + addOperation() { + return this; + } + setTimeout() { + return this; + } + build() { + return { toXDR: () => 'CHALLENGE_XDR' }; + } + static fromXDR = jest.fn(() => ({ source: 'GLOBSTRUSERKEY' })); + } + return { + getBalance: jest.fn(), + getTokenBalance: jest.fn(), + createTestnetAccount: jest.fn(), + isValidSecretKey: jest.fn(), + getPublicKeyFromSecret: jest.fn(), + Keypair: { + random: jest.fn(() => ({ publicKey: () => 'GCEPHEMERALKEY' })), + }, + Networks: { TESTNET: 'Test SDF Network', PUBLIC: 'Public Global Network' }, + Account: class { + key: string; + sequence: string; + constructor(key: string, sequence: string) { + this.key = key; + this.sequence = sequence; + } + }, + Operation: { manageData: jest.fn(() => ({})) }, + BASE_FEE: '100', + TransactionBuilder: MockTransactionBuilder, + }; +}); + +jest.mock('../services/lobstr', () => { + class LobstrNotInstalledError extends Error { + constructor(message = 'Lobstr is not installed') { + super(message); + this.name = 'LobstrNotInstalledError'; + } + } + return { + isLobstrInstalled: jest.fn(), + openLobstrForSigning: jest.fn(), + LobstrNotInstalledError, + }; +}); + +const getBalance = stellarMock.getBalance as jest.Mock; +const getTokenBalance = stellarMock.getTokenBalance as jest.Mock; +const createTestnetAccount = stellarMock.createTestnetAccount as jest.Mock; +const isValidSecretKey = stellarMock.isValidSecretKey as jest.Mock; +const getPublicKeyFromSecret = stellarMock.getPublicKeyFromSecret as jest.Mock; +const isLobstrInstalled = lobstrMock.isLobstrInstalled as jest.Mock; +const openLobstrForSigning = lobstrMock.openLobstrForSigning as jest.Mock; + +const IN_APP_PK = 'GCINAPPKEY'; +const IN_APP_SECRET = 'SINAPPSECRET'; +const FREIGHTER_PK = 'GCFREIGHTERKEY'; +const IMPORT_PK = 'GCIMPORTEDKEY'; + +type WalletHook = ReturnType; +let hook: WalletHook; + +function Probe() { + hook = useStellarWallet(); + return null; +} + +function resetWalletStore() { + useWalletStore.setState({ + isConnected: false, + status: 'disconnected', + connectError: null, + publicKey: null, + balance: null, + ecoBalance: null, + usdcBalance: null, + walletType: null, + }); +} + +function expectDisconnected() { + const state = useWalletStore.getState(); + // Exactly what RootNavigator gates on — must stay false on failure. + expect(state.isConnected).toBe(false); + expect(state.status).toBe('disconnected'); + expect(state.publicKey).toBeNull(); + expect(state.balance).toBeNull(); + expect(state.ecoBalance).toBeNull(); + expect(state.usdcBalance).toBeNull(); + expect(state.walletType).toBeNull(); +} + +function expectConnected( + publicKey: string, + walletType: 'freighter' | 'inapp' | 'lobstr', + balance: string, + ecoBalance: string | null, + usdcBalance: string | null = '25', +) { + const state = useWalletStore.getState(); + expect(state.isConnected).toBe(true); + expect(state.status).toBe('connected'); + expect(state.connectError).toBeNull(); + expect(state.publicKey).toBe(publicKey); + expect(state.walletType).toBe(walletType); + expect(state.balance).toBe(balance); + expect(state.ecoBalance).toBe(ecoBalance); + expect(state.usdcBalance).toBe(usdcBalance); +} + +async function renderProbe() { + let tree: renderer.ReactTestRenderer; + await act(async () => { + tree = renderer.create(); + }); + // @ts-expect-error assigned inside act above + return tree as renderer.ReactTestRenderer; +} + +describe('useStellarWallet connect flow', () => { + let tree: renderer.ReactTestRenderer | null = null; + + beforeEach(() => { + jest.clearAllMocks(); + resetWalletStore(); + // The MMKV mock store persists across tests in a file — clear vault + // entries so secret-hygiene assertions start from a clean slate. + clearInAppSecret(IN_APP_PK); + clearInAppSecret(IMPORT_PK); + // Defaults: healthy network, funded account (idempotent for the + // post-connect refresh effect). + getBalance.mockResolvedValue('123.45'); + getTokenBalance.mockImplementation((_pk: string, assetCode: string) => + Promise.resolve(assetCode === 'USDC' ? '25' : '75'), + ); + createTestnetAccount.mockResolvedValue({ + publicKey: IN_APP_PK, + secretKey: IN_APP_SECRET, + }); + isValidSecretKey.mockReturnValue(true); + getPublicKeyFromSecret.mockReturnValue(IMPORT_PK); + isLobstrInstalled.mockResolvedValue(true); + openLobstrForSigning.mockResolvedValue('SIGNED_CHALLENGE_XDR'); + }); + + afterEach(async () => { + await act(async () => { + tree?.unmount(); + }); + tree = null; + delete (globalThis as { window?: unknown }).window; + }); + + describe('happy paths', () => { + it('in-app wallet: verifies balances BEFORE marking connected, atomically', async () => { + tree = await renderProbe(); + let wallet: { publicKey: string; secretKey: string } | undefined; + await act(async () => { + wallet = await hook.createInAppWallet(); + }); + + expect(wallet).toEqual({ + publicKey: IN_APP_PK, + secretKey: IN_APP_SECRET, + }); + // getBalance ran before the store flipped to connected. + expect(getBalance).toHaveBeenCalledWith(IN_APP_PK); + expectConnected(IN_APP_PK, 'inapp', '123.45', '75', '25'); + // Secret only persisted after the balance was verified. + expect(getInAppSecret(IN_APP_PK)).toBe(IN_APP_SECRET); + expect(hook.error).toBeNull(); + }); + + it('in-app wallet: unfunded account connects with a zero balance', async () => { + getBalance.mockResolvedValue('0'); + getTokenBalance.mockResolvedValue('0'); + tree = await renderProbe(); + await act(async () => { + await hook.createInAppWallet(); + }); + // A zero balance is a valid connected state ( NotFound at the + // service layer resolves to '0') — zero must not be treated as + // failure. + expectConnected(IN_APP_PK, 'inapp', '0', '0', '0'); + }); + + it('holds a "connecting" intermediate state until balances are verified', async () => { + let resolveBalance!: (value: string) => void; + getBalance.mockImplementation( + () => + new Promise(resolve => { + resolveBalance = resolve; + }), + ); + + tree = await renderProbe(); + let pending!: Promise< + { publicKey: string; secretKey: string } | undefined + >; + await act(async () => { + pending = hook.createInAppWallet(); + }); + + // Fetch in flight: intermediate state, app NOT connected yet. + expect(useWalletStore.getState().status).toBe('connecting'); + expect(useWalletStore.getState().isConnected).toBe(false); + + await act(async () => { + resolveBalance('5'); + await pending; + }); + + expectConnected(IN_APP_PK, 'inapp', '5', '75', '25'); + }); + }); + + describe('balance fetch failure rolls back (all wallet paths)', () => { + it('in-app create: wallet is NOT connected, error surfaces, secret not saved', async () => { + getBalance.mockRejectedValue(new Error('Network request failed')); + tree = await renderProbe(); + + let wallet: { publicKey: string; secretKey: string } | undefined; + await act(async () => { + wallet = await hook.createInAppWallet(); + }); + + expect(wallet).toBeUndefined(); + expectDisconnected(); + expect(useWalletStore.getState().connectError).toBe( + 'Network request failed', + ); + expect(hook.error).toBe('Network request failed'); + // The freshly generated secret must not be orphaned in the vault. + expect(getInAppSecret(IN_APP_PK)).toBeNull(); + }); + + it('Freighter: wallet is NOT connected and the error surfaces', async () => { + (globalThis as { window?: unknown }).window = { + freighter: { + isConnected: jest.fn().mockResolvedValue(true), + getPublicKey: jest.fn().mockResolvedValue(FREIGHTER_PK), + signTransaction: jest.fn(), + }, + }; + getBalance.mockRejectedValue(new Error('Network request failed')); + tree = await renderProbe(); + + await act(async () => { + await hook.connectFreighter(); + }); + + expectDisconnected(); + expect(useWalletStore.getState().connectError).toBe( + 'Network request failed', + ); + expect(hook.error).toBe('Network request failed'); + }); + + it('Lobstr: wallet is NOT connected and the error surfaces', async () => { + getBalance.mockRejectedValue(new Error('Network request failed')); + tree = await renderProbe(); + + await act(async () => { + await hook.connectLobstr(); + }); + + // The SEP-7 challenge was built and the pubkey extracted… + expect(openLobstrForSigning).toHaveBeenCalled(); + expect(getBalance).toHaveBeenCalledWith('GLOBSTRUSERKEY'); + // …but the store was rolled back instead of left half-connected. + expectDisconnected(); + expect(useWalletStore.getState().connectError).toBe( + 'Network request failed', + ); + expect(hook.error).toBe('Network request failed'); + }); + + it('import: wallet is NOT connected, error surfaces, secret not saved', async () => { + getBalance.mockRejectedValue(new Error('Network request failed')); + tree = await renderProbe(); + + let result: { publicKey: string } | undefined; + await act(async () => { + result = await hook.importWallet(' SEXISTINGSECRET '); + }); + + expect(result).toBeUndefined(); + expect(getPublicKeyFromSecret).toHaveBeenCalledWith('SEXISTINGSECRET'); + expectDisconnected(); + expect(useWalletStore.getState().connectError).toBe( + 'Network request failed', + ); + expect(hook.error).toBe('Network request failed'); + expect(getInAppSecret(IMPORT_PK)).toBeNull(); + }); + + it('partial failure (XLM ok, ECO fetch fails) still rolls back fully', async () => { + getBalance.mockResolvedValue('10'); + getTokenBalance.mockRejectedValueOnce(new Error('Horizon timeout')); + tree = await renderProbe(); + + let wallet: { publicKey: string; secretKey: string } | undefined; + await act(async () => { + wallet = await hook.createInAppWallet(); + }); + + expect(wallet).toBeUndefined(); + // Even though the XLM balance resolved, nothing may leak through. + expectDisconnected(); + expect(useWalletStore.getState().connectError).toBe('Horizon timeout'); + expect(hook.error).toBe('Horizon timeout'); + expect(getInAppSecret(IN_APP_PK)).toBeNull(); + }); + + it('partial failure (XLM ok, USDC fetch fails) still rolls back fully', async () => { + getBalance.mockResolvedValue('10'); + // First token call (ECO) succeeds, second (USDC) fails. + getTokenBalance + .mockImplementationOnce(() => Promise.resolve('75')) + .mockRejectedValueOnce(new Error('USDC horizon down')); + tree = await renderProbe(); + + let wallet: { publicKey: string; secretKey: string } | undefined; + await act(async () => { + wallet = await hook.createInAppWallet(); + }); + + expect(wallet).toBeUndefined(); + expectDisconnected(); + expect(useWalletStore.getState().connectError).toBe('USDC horizon down'); + expect(hook.error).toBe('USDC horizon down'); + expect(getInAppSecret(IN_APP_PK)).toBeNull(); + }); + }); + + describe('refreshes after connect are fail-safe', () => { + it('keeps the last known balance when a later refresh fails', async () => { + tree = await renderProbe(); + await act(async () => { + await hook.createInAppWallet(); + }); + expect(useWalletStore.getState().balance).toBe('123.45'); + + // A later network blip during refresh must not reject into the + // fire-and-forget call sites nor wipe the verified balance. + getBalance.mockRejectedValue(new Error('offline blip')); + await act(async () => { + await hook.refreshBalance(); + }); + + expect(useWalletStore.getState().balance).toBe('123.45'); + expect(useWalletStore.getState().isConnected).toBe(true); + expect(hook.error).toBe('offline blip'); + }); + }); +}); + +describe('walletStore connection lifecycle', () => { + beforeEach(() => { + resetWalletStore(); + }); + + it('starts disconnected with no connect error', () => { + const state = useWalletStore.getState(); + expect(state.isConnected).toBe(false); + expect(state.status).toBe('disconnected'); + expect(state.connectError).toBeNull(); + }); + + it('beginConnect enters an intermediate state without connecting', () => { + useWalletStore.getState().beginConnect(); + const state = useWalletStore.getState(); + expect(state.status).toBe('connecting'); + expect(state.isConnected).toBe(false); + }); + + it('beginConnect clears a stale connectError on retry', () => { + useWalletStore.getState().connectFailed('previous failure'); + useWalletStore.getState().beginConnect(); + expect(useWalletStore.getState().connectError).toBeNull(); + }); + + it('connect stores verified balances atomically with isConnected', () => { + useWalletStore.getState().connect('GCNEW', 'freighter', { + balance: '9.9', + ecoBalance: '3.3', + usdcBalance: null, + }); + const state = useWalletStore.getState(); + expect(state.isConnected).toBe(true); + expect(state.status).toBe('connected'); + expect(state.connectError).toBeNull(); + expect(state.publicKey).toBe('GCNEW'); + expect(state.walletType).toBe('freighter'); + expect(state.balance).toBe('9.9'); + expect(state.ecoBalance).toBe('3.3'); + expect(state.usdcBalance).toBeNull(); + }); + + it('connect without balances keeps existing balances (back-compat)', () => { + useWalletStore.getState().connect('GCOLD', 'inapp'); + useWalletStore.getState().setBalance('77'); + useWalletStore.getState().connect('GCOLD', 'inapp'); + expect(useWalletStore.getState().balance).toBe('77'); + expect(useWalletStore.getState().isConnected).toBe(true); + }); + + it('connecting a different wallet resets previous balances', () => { + useWalletStore.getState().connect('GCFIRST', 'inapp', { + balance: '1', + ecoBalance: '2', + usdcBalance: '3', + }); + useWalletStore.getState().connect('GCSECOND', 'inapp', { + balance: '10', + ecoBalance: null, + usdcBalance: null, + }); + const state = useWalletStore.getState(); + expect(state.publicKey).toBe('GCSECOND'); + expect(state.balance).toBe('10'); + expect(state.ecoBalance).toBeNull(); + expect(state.usdcBalance).toBeNull(); + }); + + it('connectFailed rolls every field back and records the error', () => { + useWalletStore.getState().connect('GCNEW', 'inapp', { + balance: '9.9', + ecoBalance: '3.3', + usdcBalance: null, + }); + useWalletStore.getState().connectFailed('could not verify'); + const state = useWalletStore.getState(); + expect(state.isConnected).toBe(false); + expect(state.status).toBe('disconnected'); + expect(state.connectError).toBe('could not verify'); + expect(state.publicKey).toBeNull(); + expect(state.balance).toBeNull(); + expect(state.ecoBalance).toBeNull(); + expect(state.usdcBalance).toBeNull(); + expect(state.walletType).toBeNull(); + }); + + it('disconnect clears the connectError too', () => { + useWalletStore.getState().connectFailed('could not verify'); + useWalletStore.getState().disconnect(); + expect(useWalletStore.getState().connectError).toBeNull(); + expect(useWalletStore.getState().status).toBe('disconnected'); + expect(useWalletStore.getState().isConnected).toBe(false); + }); +}); diff --git a/src/hooks/useStellarWallet.ts b/src/hooks/useStellarWallet.ts index 5b8ced9..21e7ba5 100644 --- a/src/hooks/useStellarWallet.ts +++ b/src/hooks/useStellarWallet.ts @@ -21,9 +21,15 @@ interface FreighterWindow { // exists when this code happens to run in a web context. declare const window: FreighterWindow; +function toErrorMessage(err: unknown, fallback: string): string { + return err instanceof Error && err.message ? err.message : fallback; +} + export function useStellarWallet() { const { connect, + beginConnect, + connectFailed, disconnect, setBalance, setEcoBalance, @@ -35,18 +41,46 @@ export function useStellarWallet() { const [isConnecting, setIsConnecting] = useState(false); const [error, setError] = useState(null); + /** + * Strict balance fetchers (reject on network failure) used by the connect + * flow. The `refresh*` functions below deliberately swallow errors + * because their call sites fire-and-forget (`void refresh…()`). + */ + const fetchEcoBalanceStrict = useCallback(async (key: string) => { + const ecoCode = Config.ECO_TOKEN_ASSET_CODE; + const ecoIssuer = Config.ECO_TOKEN_ISSUER; + if (!ecoCode || !ecoIssuer) { + return null; + } + return stellar.getTokenBalance(key, ecoCode, ecoIssuer); + }, []); + + const fetchUsdcBalanceStrict = useCallback(async (key: string) => { + const usdcIssuer = Config.USDC_ISSUER; + if (!usdcIssuer) { + return null; + } + return stellar.getTokenBalance(key, 'USDC', usdcIssuer); + }, []); + const refreshEcoBalance = useCallback( async (pk?: string) => { const key = pk || publicKey; const ecoCode = Config.ECO_TOKEN_ASSET_CODE; const ecoIssuer = Config.ECO_TOKEN_ISSUER; if (key && ecoCode && ecoIssuer) { - const ecoBalance = await stellar.getTokenBalance( - key, - ecoCode, - ecoIssuer, - ); - setEcoBalance(ecoBalance); + try { + const ecoBalance = await stellar.getTokenBalance( + key, + ecoCode, + ecoIssuer, + ); + setEcoBalance(ecoBalance); + } catch (err) { + // A failed background refresh keeps the last known balance; it + // must not produce an unhandled rejection. + setError(toErrorMessage(err, 'Could not refresh ECO balance')); + } } }, [publicKey, setEcoBalance], @@ -57,33 +91,70 @@ export function useStellarWallet() { const key = pk || publicKey; const usdcIssuer = Config.USDC_ISSUER; if (key && usdcIssuer) { - const usdcBalance = await stellar.getTokenBalance( - key, - 'USDC', - usdcIssuer, - ); - setUsdcBalance(usdcBalance); + try { + const usdcBalance = await stellar.getTokenBalance( + key, + 'USDC', + usdcIssuer, + ); + setUsdcBalance(usdcBalance); + } catch (err) { + setError(toErrorMessage(err, 'Could not refresh USDC balance')); + } } }, [publicKey, setUsdcBalance], ); + /** + * Shared tail of every connect path (Freighter, Lobstr, in-app). + * + * Balance verification happens BEFORE the wallet is marked connected: + * if Horizon cannot be reached, the store is rolled back to a clean + * disconnected state (with `connectError` recorded) and the error is + * rethrown so the calling path can surface it. The user stays on + * onboarding and sees a meaningful message instead of landing in the + * main app with a null/zero balance. + */ const connectAccount = useCallback( async ( key: string, secretKey?: string, type: 'freighter' | 'inapp' | 'lobstr' = 'inapp', ) => { - if (secretKey) { - saveInAppSecret(key, secretKey); + beginConnect(); + try { + const balance = await stellar.getBalance(key); + const ecoBalance = await fetchEcoBalanceStrict(key); + const usdcBalance = await fetchUsdcBalanceStrict(key); + + // Only persist the secret once the account has been verified, + // so a failed connect never leaves an orphaned secret in the vault. + if (secretKey) { + saveInAppSecret(key, secretKey); + } + + // Connected state and verified balances land in the store + // atomically — the app can never observe isConnected with an + // unknown balance. + connect(key, type, { balance, ecoBalance, usdcBalance }); + } catch (err) { + const message = toErrorMessage( + err, + 'Could not verify the wallet on Stellar', + ); + // Roll back: no half-connected state survives a failed attempt. + connectFailed(message); + throw err instanceof Error ? err : new Error(message); } - connect(key, type); - const balance = await stellar.getBalance(key); - setBalance(balance); - await refreshEcoBalance(key); - await refreshUsdcBalance(key); }, - [connect, setBalance, refreshEcoBalance, refreshUsdcBalance], + [ + connect, + beginConnect, + connectFailed, + fetchEcoBalanceStrict, + fetchUsdcBalanceStrict, + ], ); const connectFreighter = useCallback(async () => { @@ -229,8 +300,14 @@ export function useStellarWallet() { const refreshBalance = useCallback(async () => { if (publicKey) { - const balance = await stellar.getBalance(publicKey); - setBalance(balance); + try { + const balance = await stellar.getBalance(publicKey); + setBalance(balance); + } catch (err) { + // Keep the last known balance on refresh failure instead of + // rejecting into fire-and-forget call sites. + setError(toErrorMessage(err, 'Could not refresh balance')); + } } }, [publicKey, setBalance]); diff --git a/src/screens/OnboardingScreen.tsx b/src/screens/OnboardingScreen.tsx index 77cb543..776d514 100644 --- a/src/screens/OnboardingScreen.tsx +++ b/src/screens/OnboardingScreen.tsx @@ -24,7 +24,7 @@ export default function OnboardingScreen() { error: walletError, } = useStellarWallet(); const { authenticate, isAuthenticating, error: authError } = useAuth(); - const { publicKey, isConnected } = useWalletStore(); + const { publicKey, isConnected, connectError } = useWalletStore(); const [showImport, setShowImport] = useState(false); const [secretKey, setSecretKey] = useState(''); @@ -53,7 +53,10 @@ export default function OnboardingScreen() { } }; - const error = walletError || authError; + // Store-level connect errors survive hook remounts; local hook errors + // cover everything else. Either way the user sees why the connect failed + // instead of being dropped into a zero-balance main screen. + const error = walletError || connectError || authError; const busy = isConnecting || isAuthenticating; return ( diff --git a/src/services/stellar.ts b/src/services/stellar.ts index 001b057..6e0a751 100644 --- a/src/services/stellar.ts +++ b/src/services/stellar.ts @@ -32,16 +32,33 @@ const HORIZON_URL = const server = new Horizon.Server(HORIZON_URL); +/** + * Native XLM balance for an account. + * + * An unfunded account (NotFoundError) is a *valid* state and resolves to + * '0' — the wallet can connect with a zero balance. Any other failure + * (network down, Horizon error) rejects so callers can distinguish + * "no funds yet" from "could not verify the balance". This is what lets + * the connect flow refuse to mark a wallet as connected when the balance + * cannot be fetched. + */ export async function getBalance(publicKey: string): Promise { try { const account = await server.loadAccount(publicKey); const nativeBalance = account.balances.find(b => b.asset_type === 'native'); return nativeBalance ? nativeBalance.balance : '0'; - } catch { - return '0'; + } catch (err) { + if (err instanceof NotFoundError) { + return '0'; + } + throw err; } } +/** + * Trustlined token balance (ECO/USDC/…). Same contract as getBalance: + * unfunded accounts resolve to '0'; infrastructure errors reject. + */ export async function getTokenBalance( publicKey: string, assetCode: string, @@ -56,8 +73,11 @@ export async function getTokenBalance( b.asset_issuer === issuer, ); return tokenBalance ? tokenBalance.balance : '0'; - } catch { - return '0'; + } catch (err) { + if (err instanceof NotFoundError) { + return '0'; + } + throw err; } } diff --git a/src/store/walletStore.ts b/src/store/walletStore.ts index 1a216fb..b42753f 100644 --- a/src/store/walletStore.ts +++ b/src/store/walletStore.ts @@ -11,39 +11,107 @@ const zustandMMKVStorage = { export type WalletType = 'freighter' | 'inapp' | 'lobstr'; +/** + * Fine-grained connection lifecycle: + * - 'disconnected': no wallet, or a connect attempt failed/was rolled back. + * - 'connecting' : a connect attempt is in flight (key known, balances not + * yet verified against Horizon). `isConnected` is still + * false so the app must not show the main screens yet. + * - 'connected' : balances were fetched successfully and the wallet is + * usable. This is the only state where `isConnected` is + * true. + */ +export type WalletStatus = 'disconnected' | 'connecting' | 'connected'; + +/** Balances verified before the wallet is marked as connected. */ +export interface InitialBalances { + balance: string | null; + ecoBalance: string | null; + usdcBalance: string | null; +} + interface WalletState { isConnected: boolean; + status: WalletStatus; + /** Last connection failure, surfaced by the UI instead of a zero-balance main screen. */ + connectError: string | null; publicKey: string | null; balance: string | null; ecoBalance: string | null; usdcBalance: string | null; walletType: WalletType | null; - connect: (publicKey: string, walletType?: WalletType) => void; + /** Enter the intermediate 'connecting' state. Does NOT flip isConnected. */ + beginConnect: () => void; + /** + * Mark connected. Only called once balance data has been fetched, so a + * connected wallet always has verified balances (never null/unknown). + */ + connect: ( + publicKey: string, + walletType?: WalletType, + initialBalances?: InitialBalances, + ) => void; + /** + * Roll back a failed connect attempt: clears every wallet field and + * records why, so navigators render onboarding + an error instead of a + * half-connected main app. + */ + connectFailed: (error: string) => void; disconnect: () => void; setBalance: (balance: string) => void; setEcoBalance: (ecoBalance: string) => void; setUsdcBalance: (usdcBalance: string) => void; } +const clearedWalletFields = { + publicKey: null, + balance: null, + ecoBalance: null, + usdcBalance: null, + walletType: null, +} satisfies Pick< + WalletState, + 'publicKey' | 'balance' | 'ecoBalance' | 'usdcBalance' | 'walletType' +>; + export const useWalletStore = create()( persist( set => ({ isConnected: false, + status: 'disconnected', + connectError: null, publicKey: null, balance: null, ecoBalance: null, usdcBalance: null, walletType: null, - connect: (publicKey, walletType = 'inapp') => - set({ isConnected: true, publicKey, walletType }), + beginConnect: () => + set({ status: 'connecting', isConnected: false, connectError: null }), + connect: (publicKey, walletType = 'inapp', initialBalances) => + set({ + isConnected: true, + status: 'connected', + connectError: null, + publicKey, + walletType, + // Applied atomically with isConnected so the wallet can never be + // observed as connected with a stale/null balance. Explicit nulls + // (asset not configured) reset any previous wallet's balances. + ...(initialBalances ?? {}), + }), + connectFailed: (error: string) => + set({ + isConnected: false, + status: 'disconnected', + connectError: error, + ...clearedWalletFields, + }), disconnect: () => set({ isConnected: false, - publicKey: null, - balance: null, - ecoBalance: null, - usdcBalance: null, - walletType: null, + status: 'disconnected', + connectError: null, + ...clearedWalletFields, }), setBalance: balance => set({ balance }), setEcoBalance: ecoBalance => set({ ecoBalance }), @@ -52,6 +120,17 @@ export const useWalletStore = create()( { name: 'wallet-storage', storage: createJSONStorage(() => zustandMMKVStorage), + // Only durable data survives a restart. `status` and `connectError` + // are transient: a crash mid-connect must not restore a stuck + // 'connecting' status or a stale error banner. + partialize: state => ({ + isConnected: state.isConnected, + publicKey: state.publicKey, + balance: state.balance, + ecoBalance: state.ecoBalance, + usdcBalance: state.usdcBalance, + walletType: state.walletType, + }), }, ), );