diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..481a886
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,7 @@
+# Sentry Configuration
+# EXPO_PUBLIC_SENTRY_DSN=
+
+# Sentry Source Map Upload (set these as EAS Secrets for production builds)
+# SENTRY_AUTH_TOKEN=
+# SENTRY_ORG=stepfi-org
+# SENTRY_PROJECT=stepfi-app
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index a23cacd..3fe909d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,7 @@
+# Environment secrets
+.env
+.env.local
+
node_modules/
.expo/
dist/
diff --git a/app.json b/app.json
index 26c77e8..154bee3 100644
--- a/app.json
+++ b/app.json
@@ -17,7 +17,15 @@
"expo-router",
"expo-secure-store",
"expo-font",
- "expo-local-authentication"
+ "expo-local-authentication",
+ [
+ "@sentry/react-native",
+ {
+ "organization": "stepfi-org",
+ "project": "stepfi-app",
+ "url": "https://sentry.io/"
+ }
+ ]
],
"orientation": "portrait",
"icon": "./assets/icon.png",
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 3c630f5..f253af0 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -10,8 +10,19 @@ import { BiometricGate } from '../src/components/BiometricGate';
import { useConnectivityStore } from '../src/offline/connectivity.store';
import { processQueue } from '../src/offline/offline-sync';
import { initPromise } from '../src/locales/i18n';
+import { SentryErrorBoundary } from '../components/SentryErrorBoundary';
+import {
+ initSentry,
+ setSentryUser,
+ clearSentryUser,
+ addBreadcrumb,
+ Sentry,
+} from '../services/sentry';
import '../global.css';
+// Initialise Sentry as early as possible (module‑level, before any component)
+initSentry();
+
const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
function useAuthGuard() {
@@ -33,7 +44,22 @@ function useAuthGuard() {
}, [isAuthenticated, isLoading, segments, router]);
}
-export default function RootLayout() {
+// Sentry user‑context sync — attach / clear wallet identity on auth changes
+function useSentryUserContext() {
+ const walletAddress = useAuthStore((s) => s.walletAddress);
+ const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
+
+ useEffect(() => {
+ if (isAuthenticated && walletAddress) {
+ setSentryUser(walletAddress);
+ addBreadcrumb('auth', 'User context set', { hasWallet: true });
+ } else {
+ clearSentryUser();
+ }
+ }, [isAuthenticated, walletAddress]);
+}
+
+function RootLayout() {
const [i18nReady, setI18nReady] = useState(false);
const hydrate = useAuthStore((s) => s.hydrate);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
@@ -66,6 +92,7 @@ export default function RootLayout() {
}, [hydrate]);
useAuthGuard();
+ useSentryUserContext();
useEffect(() => {
if (!isLoading && isAuthenticated && !biometricCheckDone) {
@@ -84,6 +111,8 @@ export default function RootLayout() {
useEffect(() => {
const subscription = AppState.addEventListener('change', (state) => {
+ addBreadcrumb('app.lifecycle', `AppState → ${state}`);
+
if (state === 'active') {
const elapsed = Date.now() - lastActiveRef.current;
if (
@@ -140,24 +169,28 @@ export default function RootLayout() {
}
return (
-
- {isLocked && isAuthenticated ? (
-
- ) : (
- {
- if (!useSecurityStore.getState().isLocked) {
- startIdleTimer();
- }
- }}
- >
-
-
-
-
-
- )}
-
+
+
+ {isLocked && isAuthenticated ? (
+
+ ) : (
+ {
+ if (!useSecurityStore.getState().isLocked) {
+ startIdleTimer();
+ }
+ }}
+ >
+
+
+
+
+
+ )}
+
+
);
}
+
+export default Sentry.wrap(RootLayout);
\ No newline at end of file
diff --git a/components/SentryErrorBoundary.tsx b/components/SentryErrorBoundary.tsx
new file mode 100644
index 0000000..9239001
--- /dev/null
+++ b/components/SentryErrorBoundary.tsx
@@ -0,0 +1,76 @@
+import React from 'react';
+import { View, Text, Pressable, StyleSheet } from 'react-native';
+import * as Sentry from '@sentry/react-native';
+
+interface FallbackProps {
+ resetError: () => void;
+}
+
+function ErrorFallback({ resetError }: FallbackProps) {
+ return (
+
+ ⚠️
+ Something went wrong
+
+ An unexpected error occurred. Our team has been notified.
+
+
+ Reload App
+
+
+ );
+}
+
+interface Props {
+ children: React.ReactNode;
+}
+
+export function SentryErrorBoundary({ children }: Props) {
+ return (
+ }
+ showDialog={false}
+ >
+ {children}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#080F1A',
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingHorizontal: 32,
+ },
+ emoji: {
+ fontSize: 48,
+ marginBottom: 16,
+ },
+ title: {
+ fontSize: 22,
+ fontWeight: '700',
+ color: '#FFFFFF',
+ marginBottom: 8,
+ textAlign: 'center',
+ },
+ subtitle: {
+ fontSize: 15,
+ color: '#94A3B8',
+ textAlign: 'center',
+ lineHeight: 22,
+ marginBottom: 32,
+ },
+ button: {
+ backgroundColor: '#6366F1',
+ paddingHorizontal: 32,
+ paddingVertical: 14,
+ borderRadius: 12,
+ },
+ buttonText: {
+ color: '#FFFFFF',
+ fontSize: 16,
+ fontWeight: '600',
+ },
+});
diff --git a/package-lock.json b/package-lock.json
index 73fc7a3..90b47bb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-native-community/netinfo": "^11.4.1",
"@react-navigation/native": "^7.1.8",
+ "@sentry/react-native": "~7.2.0",
"@walletconnect/sign-client": "^2.23.9",
"@walletconnect/types": "^2.23.9",
"axios": "^1.16.0",
@@ -3864,6 +3865,342 @@
"url": "https://paulmillr.com/funding/"
}
},
+ "node_modules/@sentry-internal/browser-utils": {
+ "version": "10.12.0",
+ "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.12.0.tgz",
+ "integrity": "sha512-dozbx389jhKynj0d657FsgbBVOar7pX3mb6GjqCxslXF0VKpZH2Xks0U32RgDY/nK27O+o095IWz7YvjVmPkDw==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry/core": "10.12.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@sentry-internal/feedback": {
+ "version": "10.12.0",
+ "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.12.0.tgz",
+ "integrity": "sha512-0+7ceO6yQPPqfxRc9ue/xoPHKcnB917ezPaehGQNfAFNQB9PNTG1y55+8mRu0Fw+ANbZeCt/HyoCmXuRdxmkpg==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry/core": "10.12.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@sentry-internal/replay": {
+ "version": "10.12.0",
+ "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.12.0.tgz",
+ "integrity": "sha512-/1093gSNGN5KlOBsuyAl33JkzGiG38kCnxswQLZWpPpR6LBbR1Ddb18HjhDpoQNNEZybJBgJC3a5NKl43C2TSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry-internal/browser-utils": "10.12.0",
+ "@sentry/core": "10.12.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@sentry-internal/replay-canvas": {
+ "version": "10.12.0",
+ "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.12.0.tgz",
+ "integrity": "sha512-W/z1/+69i3INNfPjD1KuinSNaRQaApjzwb37IFmiyF440F93hxmEYgXHk3poOlYYaigl2JMYbysGPWOiXnqUXA==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry-internal/replay": "10.12.0",
+ "@sentry/core": "10.12.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@sentry/babel-plugin-component-annotate": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.3.0.tgz",
+ "integrity": "sha512-OuxqBprXRyhe8Pkfyz/4yHQJc5c3lm+TmYWSSx8u48g5yKewSQDOxkiLU5pAk3WnbLPy8XwU/PN+2BG0YFU9Nw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/@sentry/browser": {
+ "version": "10.12.0",
+ "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.12.0.tgz",
+ "integrity": "sha512-lKyaB2NFmr7SxPjmMTLLhQ7xfxaY3kdkMhpzuRI5qwOngtKt4+FtvNYHRuz+PTtEFv4OaHhNNbRn6r91gWguQg==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry-internal/browser-utils": "10.12.0",
+ "@sentry-internal/feedback": "10.12.0",
+ "@sentry-internal/replay": "10.12.0",
+ "@sentry-internal/replay-canvas": "10.12.0",
+ "@sentry/core": "10.12.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@sentry/cli": {
+ "version": "2.55.0",
+ "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.55.0.tgz",
+ "integrity": "sha512-cynvcIM2xL8ddwELyFRSpZQw4UtFZzoM2rId2l9vg7+wDREPDocMJB9lEQpBIo3eqhp9JswqUT037yjO6iJ5Sw==",
+ "hasInstallScript": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "https-proxy-agent": "^5.0.0",
+ "node-fetch": "^2.6.7",
+ "progress": "^2.0.3",
+ "proxy-from-env": "^1.1.0",
+ "which": "^2.0.2"
+ },
+ "bin": {
+ "sentry-cli": "bin/sentry-cli"
+ },
+ "engines": {
+ "node": ">= 10"
+ },
+ "optionalDependencies": {
+ "@sentry/cli-darwin": "2.55.0",
+ "@sentry/cli-linux-arm": "2.55.0",
+ "@sentry/cli-linux-arm64": "2.55.0",
+ "@sentry/cli-linux-i686": "2.55.0",
+ "@sentry/cli-linux-x64": "2.55.0",
+ "@sentry/cli-win32-arm64": "2.55.0",
+ "@sentry/cli-win32-i686": "2.55.0",
+ "@sentry/cli-win32-x64": "2.55.0"
+ }
+ },
+ "node_modules/@sentry/cli-darwin": {
+ "version": "2.55.0",
+ "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.55.0.tgz",
+ "integrity": "sha512-jGHE7SHHzqXUmnsmRLgorVH6nmMmTjQQXdPZbSL5tRtH8d3OIYrVNr5D72DSgD26XAPBDMV0ibqOQ9NKoiSpfA==",
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@sentry/cli-linux-arm": {
+ "version": "2.55.0",
+ "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.55.0.tgz",
+ "integrity": "sha512-ATjU0PsiWADSPLF/kZroLZ7FPKd5W9TDWHVkKNwIUNTei702LFgTjNeRwOIzTgSvG3yTmVEqtwFQfFN/7hnVXQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "os": [
+ "linux",
+ "freebsd",
+ "android"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@sentry/cli-linux-arm64": {
+ "version": "2.55.0",
+ "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.55.0.tgz",
+ "integrity": "sha512-jNB/0/gFcOuDCaY/TqeuEpsy/k52dwyk1SOV3s1ku4DUsln6govTppeAGRewY3T1Rj9B2vgIWTrnB8KVh9+Rgg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "os": [
+ "linux",
+ "freebsd",
+ "android"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@sentry/cli-linux-i686": {
+ "version": "2.55.0",
+ "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.55.0.tgz",
+ "integrity": "sha512-8LZjo6PncTM6bWdaggscNOi5r7F/fqRREsCwvd51dcjGj7Kp1plqo9feEzYQ+jq+KUzVCiWfHrUjddFmYyZJrg==",
+ "cpu": [
+ "x86",
+ "ia32"
+ ],
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "os": [
+ "linux",
+ "freebsd",
+ "android"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@sentry/cli-linux-x64": {
+ "version": "2.55.0",
+ "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.55.0.tgz",
+ "integrity": "sha512-5LUVvq74Yj2cZZy5g5o/54dcWEaX4rf3myTHy73AKhRj1PABtOkfexOLbF9xSrZy95WXWaXyeH+k5n5z/vtHfA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "os": [
+ "linux",
+ "freebsd",
+ "android"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@sentry/cli-win32-arm64": {
+ "version": "2.55.0",
+ "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.55.0.tgz",
+ "integrity": "sha512-cWIQdzm1pfLwPARsV6dUb8TVd6Y3V1A2VWxjTons3Ift6GvtVmiAe0OWL8t2Yt95i8v61kTD/6Tq21OAaogqzA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@sentry/cli-win32-i686": {
+ "version": "2.55.0",
+ "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.55.0.tgz",
+ "integrity": "sha512-ldepCn2t9r4I0wvgk7NRaA7coJyy4rTQAzM66u9j5nTEsUldf66xym6esd5ZZRAaJUjffqvHqUIr/lrieTIrVg==",
+ "cpu": [
+ "x86",
+ "ia32"
+ ],
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@sentry/cli-win32-x64": {
+ "version": "2.55.0",
+ "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.55.0.tgz",
+ "integrity": "sha512-4hPc/I/9tXx+HLTdTGwlagtAfDSIa2AoTUP30tl32NAYQhx9a6niUbPAemK2qfxesiufJ7D2djX83rCw6WnJVA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@sentry/cli/node_modules/agent-base": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/@sentry/cli/node_modules/https-proxy-agent": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+ "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/@sentry/cli/node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+ "license": "MIT"
+ },
+ "node_modules/@sentry/core": {
+ "version": "10.12.0",
+ "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.12.0.tgz",
+ "integrity": "sha512-Jrf0Yo7DvmI/ZQcvBnA0xKNAFkJlVC/fMlvcin+5IrFNRcqOToZ2vtF+XqTgjRZymXQNE8s1QTD7IomPHk0TAw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@sentry/react": {
+ "version": "10.12.0",
+ "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.12.0.tgz",
+ "integrity": "sha512-TpqgdoYbkf5JynmmW2oQhHQ/h5w+XPYk0cEb/UrsGlvJvnBSR+5tgh0AqxCSi3gvtp82rAXI5w1TyRPBbhLDBw==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry/browser": "10.12.0",
+ "@sentry/core": "10.12.0",
+ "hoist-non-react-statics": "^3.3.2"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "react": "^16.14.0 || 17.x || 18.x || 19.x"
+ }
+ },
+ "node_modules/@sentry/react-native": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/@sentry/react-native/-/react-native-7.2.0.tgz",
+ "integrity": "sha512-rjqYgEjntPz1sPysud78wi4B9ui7LBVPsG6qr8s/htLMYho9GPGFA5dF+eqsQWqMX8NDReAxNkLTC4+gCNklLQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry/babel-plugin-component-annotate": "4.3.0",
+ "@sentry/browser": "10.12.0",
+ "@sentry/cli": "2.55.0",
+ "@sentry/core": "10.12.0",
+ "@sentry/react": "10.12.0",
+ "@sentry/types": "10.12.0"
+ },
+ "bin": {
+ "sentry-expo-upload-sourcemaps": "scripts/expo-upload-sourcemaps.js"
+ },
+ "peerDependencies": {
+ "expo": ">=49.0.0",
+ "react": ">=17.0.0",
+ "react-native": ">=0.65.0"
+ },
+ "peerDependenciesMeta": {
+ "expo": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@sentry/types": {
+ "version": "10.12.0",
+ "resolved": "https://registry.npmjs.org/@sentry/types/-/types-10.12.0.tgz",
+ "integrity": "sha512-sKGj3l3V8ZKISh2Tu88bHfnm5ztkRtSLdmpZ6TmCeJdSM9pV+RRd6CMJ0RnSEXmYHselPNUod521t2NQFd4W1w==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry/core": "10.12.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@sinclair/typebox": {
"version": "0.27.8",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
@@ -9371,6 +9708,21 @@
"hermes-estree": "0.32.0"
}
},
+ "node_modules/hoist-non-react-statics": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
+ "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "react-is": "^16.7.0"
+ }
+ },
+ "node_modules/hoist-non-react-statics/node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "license": "MIT"
+ },
"node_modules/hosted-git-info": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz",
diff --git a/package.json b/package.json
index 659534b..cc77450 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-native-community/netinfo": "^11.4.1",
"@react-navigation/native": "^7.1.8",
+ "@sentry/react-native": "~7.2.0",
"@walletconnect/sign-client": "^2.23.9",
"@walletconnect/types": "^2.23.9",
"axios": "^1.16.0",
diff --git a/services/api.ts b/services/api.ts
index 45b6075..d44ff3e 100644
--- a/services/api.ts
+++ b/services/api.ts
@@ -1,4 +1,4 @@
-import axios, { AxiosRequestConfig } from 'axios';
+import axios, { AxiosError, AxiosRequestConfig } from 'axios';
import { router } from 'expo-router';
import { config } from '../constants/config';
import { useAuthStore } from '../stores/auth.store';
@@ -6,6 +6,7 @@ import { getFromCache, setToCache } from '../src/offline/cache';
import { useConnectivityStore } from '../src/offline/connectivity.store';
import { enqueueAction } from '../src/offline/offline-queue';
import type { QueueAction, QueueActionType } from '../src/offline/offline-queue';
+import { addBreadcrumb, captureServiceError } from './sentry';
const api = axios.create({
baseURL: config.API_BASE_URL,
@@ -19,6 +20,12 @@ api.interceptors.request.use(async (req) => {
(req.headers as Record).Authorization = `Bearer ${accessToken}`;
}
+ // Breadcrumb for every outgoing request (URL only, no auth headers)
+ addBreadcrumb('http.request', `${req.method?.toUpperCase()} ${req.url}`, {
+ baseURL: req.baseURL ?? '',
+ timeout: req.timeout ?? 0,
+ });
+
const method = req.method?.toLowerCase();
if (!method || !['post', 'put', 'patch', 'delete'].includes(method)) {
return req;
@@ -27,6 +34,7 @@ api.interceptors.request.use(async (req) => {
const { isConnected } = useConnectivityStore.getState();
if (isConnected) return req;
+ // Offline mitigation queue
const action = await enqueueAction({
type: getActionType(req.url ?? '', req.method ?? 'POST'),
endpoint: req.url ?? '',
@@ -76,7 +84,8 @@ api.interceptors.response.use(
}
return res;
},
- async (error) => {
+ async (error: any) => {
+ // Intercept offline-queued mock items immediately
if (error?.__offline_queued) {
return {
data: { queued: true, actionId: error.__action.id, unsignedXdr: '' },
@@ -90,6 +99,16 @@ api.interceptors.response.use(
const original = error.config as RetriableRequest | undefined;
const status = error.response?.status;
+ // Capture non-401 production exceptions to Sentry
+ if (status !== 401) {
+ captureServiceError('api', 'response', error as AxiosError);
+ addBreadcrumb('http.error', `HTTP ${status ?? 'network'} error`, {
+ url: original?.url ?? 'unknown',
+ status: status ?? 0,
+ }, 'error');
+ }
+
+ // Handle Token Expiration Refresh Sequence
if (status === 401 && original && !original._retry) {
original._retry = true;
@@ -111,6 +130,7 @@ api.interceptors.response.use(
return api.request(original);
}
+ // Offline read strategy fallback for standard broken GET failures
if (original?.method?.toLowerCase() === 'get' && original?.url) {
const cached = await getFromCache(`GET:${original.url}`);
if (cached !== null) {
@@ -131,4 +151,4 @@ function getActionType(url: string, method: string): QueueActionType {
return 'SUBMIT_SIGNED_XDR';
}
-export default api;
+export default api;
\ No newline at end of file
diff --git a/services/auth.service.ts b/services/auth.service.ts
index 317a6ac..c249e5b 100644
--- a/services/auth.service.ts
+++ b/services/auth.service.ts
@@ -1,4 +1,5 @@
import api from './api';
+import { addBreadcrumb, captureServiceError } from './sentry';
export interface NonceResponse {
nonce: string;
@@ -13,17 +14,38 @@ export interface AuthTokens {
export const authService = {
async getNonce(wallet: string): Promise {
- const res = await api.post('/auth/nonce', { wallet });
- return res.data;
+ addBreadcrumb('auth.service', 'Requesting nonce');
+ try {
+ const res = await api.post('/auth/nonce', { wallet });
+ addBreadcrumb('auth.service', 'Nonce received');
+ return res.data;
+ } catch (error) {
+ captureServiceError('auth', 'getNonce', error);
+ throw error;
+ }
},
async verify(wallet: string, nonce: string, signature: string): Promise {
- const res = await api.post('/auth/verify', { wallet, nonce, signature });
- return res.data;
+ addBreadcrumb('auth.service', 'Verifying wallet signature');
+ try {
+ const res = await api.post('/auth/verify', { wallet, nonce, signature });
+ addBreadcrumb('auth.service', 'Wallet verified successfully');
+ return res.data;
+ } catch (error) {
+ captureServiceError('auth', 'verify', error);
+ throw error;
+ }
},
async refresh(refreshToken: string): Promise {
- const res = await api.post('/auth/refresh', { refreshToken });
- return res.data;
+ addBreadcrumb('auth.service', 'Refreshing auth tokens');
+ try {
+ const res = await api.post('/auth/refresh', { refreshToken });
+ addBreadcrumb('auth.service', 'Tokens refreshed');
+ return res.data;
+ } catch (error) {
+ captureServiceError('auth', 'refresh', error);
+ throw error;
+ }
},
};
diff --git a/services/loans.service.ts b/services/loans.service.ts
index fe54229..ca73d3e 100644
--- a/services/loans.service.ts
+++ b/services/loans.service.ts
@@ -1,4 +1,5 @@
import api from './api';
+import { addBreadcrumb, captureServiceError } from './sentry';
import type { Installment, Loan } from '../types/loan.types';
export interface AvailableCredit {
@@ -20,23 +21,52 @@ export interface UnsignedXdrResponse {
export const loansService = {
async getMyLoans(): Promise {
- const res = await api.get<{ data: Loan[] }>('/loans/my-loans');
- return res.data.data;
+ addBreadcrumb('loans.service', 'Fetching user loans');
+ try {
+ const res = await api.get<{ data: Loan[] }>('/loans/my-loans');
+ addBreadcrumb('loans.service', 'Loans fetched', { count: res.data.data.length });
+ return res.data.data;
+ } catch (error) {
+ captureServiceError('loans', 'getMyLoans', error);
+ throw error;
+ }
},
async getLoanById(id: string): Promise {
- const res = await api.get(`/loans/${id}`);
- return res.data;
+ addBreadcrumb('loans.service', 'Fetching loan by ID', { loanId: id });
+ try {
+ const res = await api.get(`/loans/${id}`);
+ return res.data;
+ } catch (error) {
+ captureServiceError('loans', 'getLoanById', error);
+ throw error;
+ }
},
async getAvailableCredit(): Promise {
- const res = await api.get('/loans/available-credit');
- return res.data;
+ addBreadcrumb('loans.service', 'Fetching available credit');
+ try {
+ const res = await api.get('/loans/available-credit');
+ return res.data;
+ } catch (error) {
+ captureServiceError('loans', 'getAvailableCredit', error);
+ throw error;
+ }
},
async createLoan(dto: CreateLoanDto): Promise {
- const res = await api.post('/loans/create', dto);
- return res.data;
+ addBreadcrumb('loans.service', 'Creating loan', {
+ vendorId: dto.vendorId,
+ totalAmount: dto.totalAmount,
+ });
+ try {
+ const res = await api.post('/loans/create', dto);
+ addBreadcrumb('loans.service', 'Loan created');
+ return res.data;
+ } catch (error) {
+ captureServiceError('loans', 'createLoan', error);
+ throw error;
+ }
},
async repayInstallment(
@@ -44,10 +74,21 @@ export const loansService = {
installmentIndex: number,
amount: number,
): Promise {
- const res = await api.post(`/loans/${loanId}/repay-installment`, {
+ addBreadcrumb('loans.service', 'Repaying installment', {
+ loanId,
installmentIndex,
amount,
});
- return res.data;
+ try {
+ const res = await api.post(`/loans/${loanId}/repay-installment`, {
+ installmentIndex,
+ amount,
+ });
+ addBreadcrumb('loans.service', 'Installment repaid');
+ return res.data;
+ } catch (error) {
+ captureServiceError('loans', 'repayInstallment', error);
+ throw error;
+ }
},
};
diff --git a/services/reputation.service.ts b/services/reputation.service.ts
index c8b8f46..9bb8acf 100644
--- a/services/reputation.service.ts
+++ b/services/reputation.service.ts
@@ -1,4 +1,5 @@
import api from './api';
+import { addBreadcrumb, captureServiceError } from './sentry';
export interface ReputationScore {
score: number;
@@ -9,7 +10,16 @@ export interface ReputationScore {
export const reputationService = {
async getScore(wallet: string): Promise {
- const res = await api.get(`/reputation/${wallet}`);
- return res.data;
+ addBreadcrumb('reputation.service', 'Fetching reputation score');
+ try {
+ const res = await api.get(`/reputation/${wallet}`);
+ addBreadcrumb('reputation.service', 'Reputation score received', {
+ tier: res.data.tier,
+ });
+ return res.data;
+ } catch (error) {
+ captureServiceError('reputation', 'getScore', error);
+ throw error;
+ }
},
};
diff --git a/services/sentry.ts b/services/sentry.ts
new file mode 100644
index 0000000..a7a1907
--- /dev/null
+++ b/services/sentry.ts
@@ -0,0 +1,161 @@
+import * as Sentry from '@sentry/react-native';
+import Constants from 'expo-constants';
+
+// Sensitive field names that must never be sent to Sentry
+const SENSITIVE_KEYS = new Set([
+ 'accessToken',
+ 'refreshToken',
+ 'signature',
+ 'nonce',
+ 'walletAddress',
+ 'publicKey',
+ 'authorization',
+ 'Authorization',
+]);
+
+// Helpers
+
+/**
+ * Recursively strip sensitive keys from a plain object.
+ * Returns a shallow‑cloned object — the original is never mutated.
+ */
+function scrub(obj: T): T {
+ if (obj === null || obj === undefined || typeof obj !== 'object') {
+ return obj;
+ }
+
+ if (Array.isArray(obj)) {
+ return obj.map(scrub) as unknown as T;
+ }
+
+ const cleaned: Record = {};
+ for (const [key, value] of Object.entries(obj as Record)) {
+ if (SENSITIVE_KEYS.has(key)) {
+ cleaned[key] = '[REDACTED]';
+ } else {
+ cleaned[key] = scrub(value);
+ }
+ }
+ return cleaned as T;
+}
+
+// Initialisation
+
+let _initialised = false;
+
+export function initSentry(): void {
+ if (_initialised) return;
+
+ const dsn = process.env.EXPO_PUBLIC_SENTRY_DSN;
+ if (!dsn) {
+ if (__DEV__) {
+ // eslint-disable-next-line no-console
+ console.warn('[Sentry] EXPO_PUBLIC_SENTRY_DSN is not set — skipping init');
+ }
+ return;
+ }
+
+ const release = Constants.expoConfig?.version
+ ? `${Constants.expoConfig.slug ?? 'stepfi-app'}@${Constants.expoConfig.version}`
+ : undefined;
+
+ Sentry.init({
+ dsn,
+ release,
+ environment: __DEV__ ? 'development' : 'production',
+
+ // Sessions
+ enableAutoSessionTracking: true,
+
+ // Performance
+ tracesSampleRate: __DEV__ ? 1.0 : 0.2,
+
+ // Data scrubbing — strip tokens / secrets before they leave the device
+ beforeSend(event) {
+ // Scrub extra data
+ if (event.extra) {
+ event.extra = scrub(event.extra);
+ }
+
+ // Scrub breadcrumb data
+ if (event.breadcrumbs) {
+ event.breadcrumbs = event.breadcrumbs.map((bc) => ({
+ ...bc,
+ data: bc.data ? scrub(bc.data) : bc.data,
+ }));
+ }
+
+ // Scrub context values
+ if (event.contexts) {
+ event.contexts = scrub(event.contexts);
+ }
+
+ return event;
+ },
+
+ // Only send events in production unless DSN is explicitly provided in dev
+ enabled: !__DEV__ || Boolean(dsn),
+ });
+
+ _initialised = true;
+}
+
+// User context
+
+/**
+ * Set the Sentry user context when a wallet connects.
+ */
+export function setSentryUser(walletAddress: string): void {
+ Sentry.setUser({ id: walletAddress });
+}
+
+/** Clear user context on logout / wallet disconnect. */
+export function clearSentryUser(): void {
+ Sentry.setUser(null);
+}
+
+// Breadcrumbs
+
+export function addBreadcrumb(
+ category: string,
+ message: string,
+ data?: Record,
+ level: Sentry.SeverityLevel = 'info',
+): void {
+ Sentry.addBreadcrumb({
+ category,
+ message,
+ data: data ? scrub(data) : undefined,
+ level,
+ });
+}
+
+// Error capture
+
+/**
+ * Capture a service‑layer error with useful tags so errors can be filtered
+ * by service name and operation in the Sentry dashboard.
+ */
+export function captureServiceError(
+ service: string,
+ operation: string,
+ error: unknown,
+): void {
+ Sentry.withScope((scope) => {
+ scope.setTag('service', service);
+ scope.setTag('operation', operation);
+
+ if (error instanceof Error) {
+ scope.setExtra('errorMessage', error.message);
+ Sentry.captureException(error);
+ } else {
+ Sentry.captureMessage(`[${service}.${operation}] Non-Error thrown`, {
+ level: 'error',
+ extra: { rawError: scrub(error as Record) },
+ });
+ }
+ });
+}
+
+// Re-export Sentry's wrap helper for the root component
+export { Sentry };