From b825c80460bc57f6cf532f4934e05241ffd4b20c Mon Sep 17 00:00:00 2001 From: Chidubem Mac-Anthony Date: Wed, 29 Jul 2026 18:22:15 +0100 Subject: [PATCH] fix: hydrate wallet state from SecureStore on cold start (fixes #34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add hydrate() action to walletStore that reads secret from SecureStore and restores publicKey/isOnboarded before routing decisions - Update app/index.tsx to await hydration before rendering redirect, showing a loading spinner during the async check - No plaintext secret stored outside SecureStore — only the derived publicKey enters the zustand store - Regression test: verifies cold start with pre-existing SecureStore entry restores onboarded state; verifies no secret leaves state as not onboarded --- app/index.tsx | 17 +++++++- package-lock.json | 56 ++++++++++++++----------- src/store/__tests__/walletStore.test.ts | 34 +++++++++++++++ src/store/walletStore.ts | 16 +++++++ 4 files changed, 97 insertions(+), 26 deletions(-) create mode 100644 src/store/__tests__/walletStore.test.ts diff --git a/app/index.tsx b/app/index.tsx index e4dbbae..c3e8773 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -1,8 +1,23 @@ +import { useEffect } from "react"; import { Redirect } from "expo-router"; +import { View, ActivityIndicator } from "react-native"; import { useWalletStore } from "@/store/walletStore"; export default function Index() { - const { isOnboarded } = useWalletStore(); + const { isOnboarded, hydrated, hydrate } = useWalletStore(); + + useEffect(() => { + if (!hydrated) hydrate(); + }, [hydrated, hydrate]); + + if (!hydrated) { + return ( + + + + ); + } + return isOnboarded ? ( ) : ( diff --git a/package-lock.json b/package-lock.json index e38e4c1..670ec89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "expo-font": "~11.10.3", "expo-linking": "~6.2.2", "expo-router": "~3.4.0", + "expo-screen-capture": "~6.0.1", "expo-secure-store": "~12.8.1", "expo-splash-screen": "~0.26.4", "expo-status-bar": "~1.11.1", @@ -73,6 +74,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -2045,6 +2047,7 @@ "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.7.tgz", "integrity": "sha512-GYzX36n1nsciIb0uyH0GHwxwtNwPQIcpxSeiVLDtG/B7jB5xXgchnmL1f/jCX5o+pwnaDBtO60ONSJhEBJfxYA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", @@ -5618,7 +5621,6 @@ "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.86.0.tgz", "integrity": "sha512-qdsABWNW7uTll90l4Vh03gjeyu3WVDi2CyiiyvYGMRDcoYbjbQi6df3BMAm9lQI2yslZ1T14LlDDAsgTwNxplA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/traverse": "^7.29.0", "@react-native/codegen": "0.86.0" @@ -5680,7 +5682,6 @@ "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.86.0.tgz", "integrity": "sha512-uTs9DBo3+/lUqinsGZK0FKJRBVClrwMXoZToaDxE1Q2SL2e55vs2GwyZfIKzPl5uJnbu4PfFMIp0/mLXLWUMuA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "^7.25.2", "@babel/parser": "^7.29.0", @@ -6161,6 +6162,7 @@ "integrity": "sha512-mIT9MiL/vMm4eirLcmw2h6h/Nm5FICtnYSdohq4vTLA2FF/6PNhByM7s8ffqoVfE5L0uAa6Xda1B7oddolUiGg==", "deprecated": "This version is no longer supported", "license": "MIT", + "peer": true, "dependencies": { "@react-navigation/core": "^6.4.17", "escape-string-regexp": "^4.0.0", @@ -6540,6 +6542,7 @@ "integrity": "sha512-RwGAGXPl9kSXwdNTafkOEuFrTBD5SA2B3iEB96xi8+xu5ddUa/cpvyVCSNn+asgLCTHkb5ZxN8gbuibYJi4s1w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -6646,6 +6649,7 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -6922,6 +6926,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7542,7 +7547,6 @@ "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.36.0.tgz", "integrity": "sha512-LhD0xdoedDw7ansQgXbB2DADLZIK/LRXuWNBPuVzMc5S2WK5GyT89tCM+cQzxFGO0mGyLK6D5TrVOJJzAoDy8Q==", "license": "MIT", - "peer": true, "dependencies": { "hermes-parser": "0.36.0" } @@ -8015,6 +8019,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001803", @@ -9812,6 +9817,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -10218,6 +10224,7 @@ "resolved": "https://registry.npmjs.org/expo/-/expo-50.0.21.tgz", "integrity": "sha512-lY+HJdQcsTUbEtPhgT3Y2+WwKZdJiYN0Zq5yAOT9293N1TbdLbHCNkOUtFfTmK0JjwgSKbbH4kRlue7a4MJflg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "0.17.13", @@ -10279,7 +10286,6 @@ "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.6.tgz", "integrity": "sha512-OV+4XUshdO18TKNlo1cxUkXeJWgUOPgalvl8ofmc7kmPPHoyfz2hGJ94tyY/RND/GG5RREE+me9YHClNEzo+Ow==", "license": "MIT", - "peer": true, "dependencies": { "@expo/env": "~2.4.2" }, @@ -10293,7 +10299,6 @@ "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.4.2.tgz", "integrity": "sha512-28pqaEqwnmLduZ00Pq9HkSzE5wbj1MTwp5/n8nm8rD8MCjR9eUnVOwmNksPI3Be2ReAPO/DbPn1puy0mvoocsQ==", "license": "MIT", - "peer": true, "dependencies": { "chalk": "^4.0.0", "debug": "^4.3.4", @@ -10308,7 +10313,6 @@ "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -10339,6 +10343,7 @@ "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-11.10.3.tgz", "integrity": "sha512-q1Td2zUvmLbCA9GV4OG4nLPw5gJuNY1VrPycsnemN1m8XWTzzs8nyECQQqrcBhgulCgcKZZJJ6U0kC2iuSoQHQ==", "license": "MIT", + "peer": true, "dependencies": { "fontfaceobserver": "^2.1.0" }, @@ -10513,6 +10518,15 @@ "react-native-screens": ">= 3.0.0" } }, + "node_modules/expo-screen-capture": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/expo-screen-capture/-/expo-screen-capture-6.0.1.tgz", + "integrity": "sha512-rJnwcKxZ6anfEx2xFeRo9MAVp2JgI4S+TuhUurHRf9rildPVorS2JvLey3i5xZPs7CxEe2xcijTmr5WT5Lw4dA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-secure-store": { "version": "12.8.1", "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-12.8.1.tgz", @@ -10538,7 +10552,8 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-1.11.1.tgz", "integrity": "sha512-ddQEtCOgYHTLlFUe/yH67dDBIoct5VIULthyT3LRJbEwdpzAgueKsX2FYK02ldh440V87PWKCamh7R9evk1rrg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/exponential-backoff": { "version": "3.1.3", @@ -10681,7 +10696,6 @@ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "license": "MIT", - "peer": true, "engines": { "node": ">=12.0.0" }, @@ -11350,6 +11364,7 @@ "resolved": "https://registry.npmjs.org/graphql/-/graphql-15.8.0.tgz", "integrity": "sha512-5gghUc24tP9HRznNpV2+FIoq3xKkj5dTQqf4v0CpdPbFVwFkWoxOM+o+2OC9ZSvjEMTjfmG9QT+gcvggTwW1zw==", "license": "MIT", + "peer": true, "engines": { "node": ">= 10.x" } @@ -11460,15 +11475,13 @@ "version": "0.36.0", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.36.0.tgz", "integrity": "sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/hermes-parser": { "version": "0.36.0", "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.36.0.tgz", "integrity": "sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w==", "license": "MIT", - "peer": true, "dependencies": { "hermes-estree": "0.36.0" } @@ -12524,6 +12537,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -13857,9 +13871,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -13880,9 +13891,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -13903,9 +13911,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -13926,9 +13931,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -16215,6 +16217,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -16282,6 +16285,7 @@ "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.73.4.tgz", "integrity": "sha512-VtS+Yr6OOTIuJGDECIYWzNU8QpJjASQYvMtfa/Hvm/2/h5GdB6W9H9TOmh13x07Lj4AOhNMx3XSsz6TdrO4jIg==", "license": "MIT", + "peer": true, "dependencies": { "@jest/create-cache-key-function": "^29.6.3", "@react-native-community/cli": "12.3.2", @@ -16351,6 +16355,7 @@ "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-4.8.2.tgz", "integrity": "sha512-ffUOv8BJQ6RqO3nLml5gxJ6ab3EestPiyWekxdzO/1MQ7NF8fW1Mzh1C5QE9yq573Xefnc7FuzGXjtesZGv7cQ==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "*", "react-native": "*" @@ -16361,6 +16366,7 @@ "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-3.29.0.tgz", "integrity": "sha512-yB1GoAMamFAcYf4ku94uBPn0/ani9QG7NdI98beJ5cet2YFESYYzuEIuU+kt+CNRcO8qqKeugxlfgAa3HyTqlg==", "license": "MIT", + "peer": true, "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" @@ -17022,6 +17028,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -18371,7 +18378,6 @@ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "license": "MIT", - "peer": true, "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" @@ -18388,7 +18394,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -18686,6 +18691,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/store/__tests__/walletStore.test.ts b/src/store/__tests__/walletStore.test.ts new file mode 100644 index 0000000..747562c --- /dev/null +++ b/src/store/__tests__/walletStore.test.ts @@ -0,0 +1,34 @@ +jest.mock("@/services/secureStorage", () => ({ + getSecretKey: jest.fn(), +})); + +import { getSecretKey } from "@/services/secureStorage"; +import { useWalletStore } from "@/store/walletStore"; + +const mockedGetSecretKey = getSecretKey as jest.Mock; + +beforeEach(() => { + useWalletStore.setState({ isOnboarded: false, publicKey: null, balances: {}, hydrated: false }); + mockedGetSecretKey.mockReset(); +}); + +describe("walletStore", () => { + it("restores onboarded state when a secret exists in SecureStore", async () => { + mockedGetSecretKey.mockResolvedValue("SBGELNIUVE6A5I43FCPBKX7ZIO3AO63XPWP5WEQBOE4F6NTK6RNKIG3X"); + await useWalletStore.getState().hydrate(); + const state = useWalletStore.getState(); + expect(state.hydrated).toBe(true); + expect(state.isOnboarded).toBe(true); + expect(state.publicKey).toBeTruthy(); + expect(state.publicKey).toMatch(/^G[A-Z0-9]{55}$/); + }); + + it("leaves isOnboarded false when no secret exists", async () => { + mockedGetSecretKey.mockResolvedValue(null); + await useWalletStore.getState().hydrate(); + const state = useWalletStore.getState(); + expect(state.hydrated).toBe(true); + expect(state.isOnboarded).toBe(false); + expect(state.publicKey).toBeNull(); + }); +}); diff --git a/src/store/walletStore.ts b/src/store/walletStore.ts index b0a54e1..39dd199 100644 --- a/src/store/walletStore.ts +++ b/src/store/walletStore.ts @@ -1,21 +1,37 @@ import { create } from "zustand"; +import { getSecretKey } from "@/services/secureStorage"; +import * as StellarSdk from "@stellar/stellar-sdk"; interface WalletState { isOnboarded: boolean; publicKey: string | null; balances: Record; + hydrated: boolean; setOnboarded: (value: boolean) => void; setPublicKey: (key: string) => void; setBalances: (balances: Record) => void; reset: () => void; + hydrate: () => Promise; } export const useWalletStore = create((set) => ({ isOnboarded: false, publicKey: null, balances: {}, + hydrated: false, + setOnboarded: (value) => set({ isOnboarded: value }), setPublicKey: (key) => set({ publicKey: key }), setBalances: (balances) => set({ balances }), reset: () => set({ isOnboarded: false, publicKey: null, balances: {} }), + + hydrate: async () => { + const secret = await getSecretKey(); + if (secret) { + const keypair = StellarSdk.Keypair.fromSecret(secret); + set({ isOnboarded: true, publicKey: keypair.publicKey(), hydrated: true }); + } else { + set({ hydrated: true }); + } + }, }));