From 6ead60f35a0455b5b79a3d88af77fe5a13b9025d Mon Sep 17 00:00:00 2001 From: Ahbiz Date: Sat, 25 Jul 2026 23:50:36 +0100 Subject: [PATCH 1/5] fix(build): resolve metro sdk path and update tsconfig moduleResolution --- metro.config.js | 2 +- package.json | 1 + tsconfig.json | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/metro.config.js b/metro.config.js index 07778a0..fb5a509 100644 --- a/metro.config.js +++ b/metro.config.js @@ -34,7 +34,7 @@ config.resolver.resolveRequest = (context, moduleName, platform) => { // Babel-compiled Node build instead, which is already down-leveled. if (moduleName === '@stellar/stellar-sdk') { return { - filePath: path.resolve(__dirname, 'node_modules/@stellar/stellar-sdk/lib/index.js'), + filePath: path.resolve(__dirname, 'node_modules/@stellar/stellar-sdk/lib/cjs/index.js'), type: 'sourceFile', }; } diff --git a/package.json b/package.json index 20f2a4a..496d312 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@testing-library/jest-native/extend-expect" ], "moduleNameMapper": { + "^@/(.*)$": "/src/$1", "^expo-router$": "/__mocks__/expo-router.ts", "^expo-secure-store$": "/__mocks__/expo-secure-store.ts", "^pocketpay-sdk$": "/__mocks__/pocketpay-sdk.ts", diff --git a/tsconfig.json b/tsconfig.json index 521a4c1..4c21ead 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,7 @@ "lib": [ "esnext" ], - "moduleResolution": "node", + "moduleResolution": "bundler", "allowSyntheticDefaultImports": true, "esModuleInterop": true, "skipLibCheck": true, From de4cd0941d5d48f26a72d7c73069cb4b173546a6 Mon Sep 17 00:00:00 2001 From: Ahbiz Date: Sat, 25 Jul 2026 23:50:47 +0100 Subject: [PATCH 2/5] feat(vault): add vault availability utility and reactive hook --- src/hooks/index.ts | 4 + src/hooks/useVaultAvailability.ts | 26 +++++ src/utils/__tests__/vaultAvailability.test.ts | 83 ++++++++++++++++ src/utils/vaultAvailability.ts | 98 +++++++++++++++++++ 4 files changed, 211 insertions(+) create mode 100644 src/hooks/useVaultAvailability.ts create mode 100644 src/utils/__tests__/vaultAvailability.test.ts create mode 100644 src/utils/vaultAvailability.ts diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 22114ef..85fa5b4 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,3 +1,7 @@ export { useDirtyForm } from "./useDirtyForm"; export { useOnlineStatus } from "./useOnlineStatus"; export { useSignerHandoff } from "./useSignerHandoff"; +export { useTheme } from "./useTheme"; +export { useVault } from "./useVault"; +export { useVaultAvailability } from "./useVaultAvailability"; + diff --git a/src/hooks/useVaultAvailability.ts b/src/hooks/useVaultAvailability.ts new file mode 100644 index 0000000..e6380ec --- /dev/null +++ b/src/hooks/useVaultAvailability.ts @@ -0,0 +1,26 @@ +import { useMemo } from 'react'; +import { useWalletStore } from '../store/walletStore'; +import { useVaultStore } from '../store/vaultStore'; +import { + evaluateVaultAvailability, + VaultAvailability, +} from '../utils/vaultAvailability'; + +/** + * Reactive hook that returns the current vault availability state. + * Re-evaluates whenever the wallet or vault store changes. + */ +export function useVaultAvailability(): VaultAvailability { + const publicKey = useWalletStore((s) => s.publicKey); + const isConfigured = useVaultStore((s) => s.isConfigured); + + return useMemo( + () => + evaluateVaultAvailability({ + publicKey, + isVaultConfigured: isConfigured, + vaultEnabledFlag: process.env.EXPO_PUBLIC_VAULT_ENABLED, + }), + [publicKey, isConfigured] + ); +} diff --git a/src/utils/__tests__/vaultAvailability.test.ts b/src/utils/__tests__/vaultAvailability.test.ts new file mode 100644 index 0000000..be017bd --- /dev/null +++ b/src/utils/__tests__/vaultAvailability.test.ts @@ -0,0 +1,83 @@ +import { + evaluateVaultAvailability, + describeUnavailableReason, + VaultUnavailableReason, +} from '../vaultAvailability'; + +describe('evaluateVaultAvailability', () => { + it('returns isAvailable true when wallet is loaded and flag is default/true', () => { + const result = evaluateVaultAvailability({ + publicKey: 'GPUBLIC123', + isVaultConfigured: true, + }); + expect(result.isAvailable).toBe(true); + expect(result.reasons).toEqual([]); + expect(result.isContractConfigured).toBe(true); + }); + + it('returns isAvailable false with no-wallet reason when publicKey is null', () => { + const result = evaluateVaultAvailability({ + publicKey: null, + isVaultConfigured: true, + }); + expect(result.isAvailable).toBe(false); + expect(result.reasons).toEqual(['no-wallet']); + }); + + it('returns isAvailable false with feature-disabled reason when flag is false', () => { + const result = evaluateVaultAvailability({ + publicKey: 'GPUBLIC123', + isVaultConfigured: true, + vaultEnabledFlag: 'false', + }); + expect(result.isAvailable).toBe(false); + expect(result.reasons).toEqual(['feature-disabled']); + }); + + it('returns isAvailable false with feature-disabled reason when flag is 0', () => { + const result = evaluateVaultAvailability({ + publicKey: 'GPUBLIC123', + isVaultConfigured: false, + vaultEnabledFlag: '0', + }); + expect(result.isAvailable).toBe(false); + expect(result.reasons).toEqual(['feature-disabled']); + }); + + it('returns multiple reasons when both flag is false and wallet is missing', () => { + const result = evaluateVaultAvailability({ + publicKey: null, + isVaultConfigured: false, + vaultEnabledFlag: 'false', + }); + expect(result.isAvailable).toBe(false); + expect(result.reasons).toEqual(['feature-disabled', 'no-wallet']); + expect(result.isContractConfigured).toBe(false); + }); + + it('mirrors isVaultConfigured input correctly in isContractConfigured', () => { + const configured = evaluateVaultAvailability({ + publicKey: 'GPUBLIC123', + isVaultConfigured: true, + }); + expect(configured.isContractConfigured).toBe(true); + + const unconfigured = evaluateVaultAvailability({ + publicKey: 'GPUBLIC123', + isVaultConfigured: false, + }); + expect(unconfigured.isContractConfigured).toBe(false); + }); +}); + +describe('describeUnavailableReason', () => { + const reasons: VaultUnavailableReason[] = ['no-wallet', 'feature-disabled', 'sdk-not-ready']; + + reasons.forEach((reason) => { + it(`returns non-empty title and message for ${reason}`, () => { + const copy = describeUnavailableReason(reason); + expect(copy.title.length).toBeGreaterThan(0); + expect(copy.message.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/utils/vaultAvailability.ts b/src/utils/vaultAvailability.ts new file mode 100644 index 0000000..282eb16 --- /dev/null +++ b/src/utils/vaultAvailability.ts @@ -0,0 +1,98 @@ +/** + * Vault availability — centralised readiness checks. + * + * Determines whether the vault tab should render its interactive UI or + * show the "unavailable" state. Each check is a named reason so the UI + * can display targeted guidance. + * + * SDK capability assumptions are documented in + * docs/vault-sdk-capability-assumptions.md. + */ + +export type VaultUnavailableReason = + | 'no-wallet' // No wallet loaded (publicKey is null) + | 'feature-disabled' // EXPO_PUBLIC_VAULT_ENABLED is explicitly 'false' + | 'sdk-not-ready'; // Future: SDK reports vault capability as missing + +export interface VaultAvailability { + /** True when the vault UI should be fully interactive. */ + isAvailable: boolean; + /** Set only when isAvailable is false. */ + reasons: VaultUnavailableReason[]; + /** Whether a live Soroban contract is configured (independent of availability). */ + isContractConfigured: boolean; +} + +export interface VaultAvailabilityInput { + publicKey: string | null; + isVaultConfigured: boolean; + /** Env-var feature toggle; defaults to true when unset. */ + vaultEnabledFlag?: string; +} + +/** + * Evaluate whether the vault is available for interaction. + * + * Pure function — all dependencies are injected so unit tests + * don't need to mock process.env or store hooks. + */ +export function evaluateVaultAvailability( + input: VaultAvailabilityInput +): VaultAvailability { + const reasons: VaultUnavailableReason[] = []; + + // 1. Feature flag gate + const flagValue = (input.vaultEnabledFlag ?? 'true').trim().toLowerCase(); + if (flagValue === 'false' || flagValue === '0') { + reasons.push('feature-disabled'); + } + + // 2. Wallet gate + if (!input.publicKey) { + reasons.push('no-wallet'); + } + + // 3. Future: SDK capability gate + // When the PocketPay SDK exposes a readiness signal, add an + // 'sdk-not-ready' check here. See docs/vault-sdk-capability-assumptions.md. + + return { + isAvailable: reasons.length === 0, + reasons, + isContractConfigured: input.isVaultConfigured, + }; +} + +export interface UnavailableReasonCopy { + title: string; + message: string; + hint?: string; +} + +/** Map a reason code to user-facing copy. */ +export function describeUnavailableReason( + reason: VaultUnavailableReason +): UnavailableReasonCopy { + switch (reason) { + case 'no-wallet': + return { + title: 'No wallet connected', + message: 'Create or import a wallet to use the Soroban Savings Vault.', + hint: 'Go to Settings → Create Wallet or Import Wallet.', + }; + case 'feature-disabled': + return { + title: 'Vault feature disabled', + message: + 'The vault is currently disabled by configuration. This may be temporary while the backend is being updated.', + hint: 'EXPO_PUBLIC_VAULT_ENABLED is set to false.', + }; + case 'sdk-not-ready': + return { + title: 'Vault backend not ready', + message: + 'The Soroban Savings Vault contract or SDK is not yet available. Vault actions will be enabled once the backend integration is complete.', + hint: 'See docs/vault-sdk-capability-assumptions.md for details.', + }; + } +} From 2d337f5a51b872f89895cb9b81b917274ebb8541 Mon Sep 17 00:00:00 2001 From: Ahbiz Date: Sat, 25 Jul 2026 23:51:00 +0100 Subject: [PATCH 3/5] feat(vault): create accessible VaultUnavailableState component --- __tests__/VaultUnavailableState.test.tsx | 64 ++++++++++ src/components/VaultUnavailableState.tsx | 146 +++++++++++++++++++++++ src/components/index.ts | 2 + 3 files changed, 212 insertions(+) create mode 100644 __tests__/VaultUnavailableState.test.tsx create mode 100644 src/components/VaultUnavailableState.tsx diff --git a/__tests__/VaultUnavailableState.test.tsx b/__tests__/VaultUnavailableState.test.tsx new file mode 100644 index 0000000..c564291 --- /dev/null +++ b/__tests__/VaultUnavailableState.test.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { VaultUnavailableState } from '../src/components/VaultUnavailableState'; + +jest.mock('lucide-react-native', () => ({ + XCircle: () => null, +})); + +describe('VaultUnavailableState', () => { + it('renders the title and primary message for no-wallet reason', () => { + const { getByText } = render( + + ); + + expect(getByText('Vault Unavailable')).toBeTruthy(); + expect(getByText('Create or import a wallet to use the Soroban Savings Vault.')).toBeTruthy(); + }); + + it('renders detail boxes for multiple reasons', () => { + const { getByTestId, getByText } = render( + + ); + + expect(getByTestId('reason-detail-feature-disabled')).toBeTruthy(); + expect(getByTestId('reason-detail-no-wallet')).toBeTruthy(); + expect(getByText('EXPO_PUBLIC_VAULT_ENABLED is set to false.')).toBeTruthy(); + }); + + it('calls onNavigateToSettings when Go to Settings button is pressed', () => { + const onNavigateToSettings = jest.fn(); + const { getByText } = render( + + ); + + const button = getByText('Go to Settings'); + fireEvent.press(button); + expect(onNavigateToSettings).toHaveBeenCalledTimes(1); + }); + + it('calls onRetry when Try Again button is pressed for feature-disabled reason', () => { + const onRetry = jest.fn(); + const { getByText } = render( + + ); + + const button = getByText('Try Again'); + fireEvent.press(button); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it('renders the docs reference link', () => { + const { getByText } = render( + + ); + + expect(getByText('See docs/vault-ui-guidance.md for more information.')).toBeTruthy(); + }); +}); diff --git a/src/components/VaultUnavailableState.tsx b/src/components/VaultUnavailableState.tsx new file mode 100644 index 0000000..57c5bdd --- /dev/null +++ b/src/components/VaultUnavailableState.tsx @@ -0,0 +1,146 @@ +import React, { useMemo } from 'react'; +import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; +import { SIZES, RADIUS, ThemeColors } from '../constants/theme'; +import { useTheme } from '../hooks/useTheme'; +import { XCircle } from 'lucide-react-native'; +import { + VaultUnavailableReason, + describeUnavailableReason, +} from '../utils/vaultAvailability'; +import { Button } from './Button'; + +export interface VaultUnavailableStateProps { + reasons: VaultUnavailableReason[]; + onNavigateToSettings?: () => void; + onRetry?: () => void; +} + +export const VaultUnavailableState: React.FC = ({ + reasons, + onNavigateToSettings, + onRetry, +}) => { + const { colors } = useTheme(); + const styles = useMemo(() => createStyles(colors), [colors]); + + const primaryReason = reasons[0] ?? 'sdk-not-ready'; + const primaryCopy = describeUnavailableReason(primaryReason); + + const showSettingsButton = reasons.includes('no-wallet') && onNavigateToSettings; + const showRetryButton = + (reasons.includes('feature-disabled') || reasons.includes('sdk-not-ready')) && onRetry; + + return ( + + + Vault Unavailable + + The Soroban Savings Vault cannot be used right now because the required configuration or wallet is missing. + + + {reasons.map((reason) => { + const copy = describeUnavailableReason(reason); + return ( + + {copy.title} + {copy.message} + {copy.hint ? {copy.hint} : null} + + ); + })} + + + {showSettingsButton ? ( +