diff --git a/mobile/.env b/mobile/.env
new file mode 100644
index 0000000..ce6ef6c
--- /dev/null
+++ b/mobile/.env
@@ -0,0 +1 @@
+EXPO_PUBLIC_SOLANA_RPC_URL=https://mainnet.helius-rpc.com/?api-key=dd39f964-79fe-4373-a22b-7cac000f163b
diff --git a/mobile/.gitignore b/mobile/.gitignore
new file mode 100644
index 0000000..ad99ee2
--- /dev/null
+++ b/mobile/.gitignore
@@ -0,0 +1,25 @@
+# Dependencies
+node_modules
+
+# Lockfiles
+bun.lock
+
+# Contracts (Anchor / Rust)
+contracts/node_modules
+contracts/target/debug
+contracts/target/release
+contracts/target/sbpf-solana-solana
+contracts/target/.rustc_info.json
+
+# Expo
+.expo
+expo-env.d.ts
+
+# Metro bundler
+.metro-cache
+
+# Build output
+dist
+
+# OS files
+.DS_Store
diff --git a/mobile/app.json b/mobile/app.json
new file mode 100644
index 0000000..2ffc6b8
--- /dev/null
+++ b/mobile/app.json
@@ -0,0 +1,38 @@
+{
+ "expo": {
+ "name": "expo-starter",
+ "slug": "expo-starter",
+ "version": "1.0.0",
+ "orientation": "portrait",
+ "icon": "./assets/images/icon.png",
+ "scheme": "myapp",
+ "userInterfaceStyle": "automatic",
+ "newArchEnabled": true,
+ "ios": {
+ "supportsTablet": true,
+ "bundleIdentifier": "com.trynoah"
+ },
+ "android": {
+ "package": "com.trynoah",
+ "versionCode": 1,
+ "adaptiveIcon": {
+ "foregroundImage": "./assets/images/icon.png",
+ "backgroundColor": "#ffffff"
+ }
+ },
+ "web": {
+ "bundler": "metro",
+ "output": "single",
+ "favicon": "./assets/images/favicon.png",
+ "baseUrl": "/mobile"
+ },
+ "plugins": [
+ "expo-router",
+ "expo-font",
+ "expo-web-browser"
+ ],
+ "experiments": {
+ "typedRoutes": true
+ }
+ }
+}
\ No newline at end of file
diff --git a/mobile/app/(tabs)/_layout.tsx b/mobile/app/(tabs)/_layout.tsx
new file mode 100644
index 0000000..aa3f431
--- /dev/null
+++ b/mobile/app/(tabs)/_layout.tsx
@@ -0,0 +1,40 @@
+import { Tabs } from 'expo-router';
+import { Home } from 'lucide-react-native';
+import { StyleSheet } from 'react-native';
+
+export default function TabLayout() {
+ return (
+
+ ,
+ }}
+ />
+
+ );
+}
+
+const styles = StyleSheet.create({
+ tabBar: {
+ backgroundColor: '#FFFFFF',
+ borderTopWidth: 0.5,
+ borderTopColor: '#E5E5EA',
+ paddingBottom: 8,
+ paddingTop: 8,
+ height: 84,
+ },
+ tabBarLabel: {
+ fontSize: 12,
+ fontWeight: '500',
+ },
+});
diff --git a/mobile/app/(tabs)/index.tsx b/mobile/app/(tabs)/index.tsx
new file mode 100644
index 0000000..3538d55
--- /dev/null
+++ b/mobile/app/(tabs)/index.tsx
@@ -0,0 +1,612 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import {
+ View,
+ Text,
+ TextInput,
+ Pressable,
+ FlatList,
+ ActivityIndicator,
+ StyleSheet,
+ RefreshControl,
+ KeyboardAvoidingView,
+ Platform,
+} from 'react-native';
+import { toast } from 'sonner-native';
+import {
+ CheckCircle2,
+ Circle,
+ Trash2,
+ Plus,
+ RefreshCw,
+ Edit2,
+ X,
+ Check,
+ Activity,
+ Database,
+ HardDrive,
+} from 'lucide-react-native';
+import { getApiUrl } from '../../utils/api';
+
+type Todo = {
+ id: number;
+ title: string;
+ description: string | null;
+ completed: boolean;
+ createdAt: string;
+ updatedAt: string;
+ attachment: unknown | null;
+};
+
+type HealthResponse = {
+ status: string;
+ projectId: string;
+ ready: boolean;
+ config: {
+ apiPrefix: string;
+ databaseConfigured: boolean;
+ storageConfigured: boolean;
+ missingEnv: string[];
+ };
+};
+
+export default function Index() {
+ const [todos, setTodos] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ // Form State
+ const [title, setTitle] = useState('');
+ const [description, setDescription] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+
+ // Edit State
+ const [editingId, setEditingId] = useState(null);
+ const [editTitle, setEditTitle] = useState('');
+ const [editDescription, setEditDescription] = useState('');
+
+ // Health State
+ const [health, setHealth] = useState(null);
+ const [healthLoading, setHealthLoading] = useState(true);
+
+ // Fetch Health Check Status
+ const fetchHealth = useCallback(async () => {
+ try {
+ setHealthLoading(true);
+ const res = await fetch(getApiUrl('/health'));
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data = await res.json();
+ setHealth(data);
+ } catch (err) {
+ console.warn('Health check failed', err);
+ setHealth(null);
+ } finally {
+ setHealthLoading(false);
+ }
+ }, []);
+
+ // Fetch Todos List
+ const fetchTodos = useCallback(async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const res = await fetch(getApiUrl('/todos'));
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data = await res.json();
+ setTodos(data.todos ?? []);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to fetch todos');
+ toast.error('Fetch Failed', {
+ description: 'Could not retrieve todo items from backend.',
+ });
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ // Initial Load
+ useEffect(() => {
+ fetchHealth();
+ fetchTodos();
+ }, [fetchHealth, fetchTodos]);
+
+ const handleRefresh = async () => {
+ await Promise.all([fetchHealth(), fetchTodos()]);
+ toast.success('Refreshed', {
+ description: 'Dashboard updated successfully.',
+ });
+ };
+
+ // Create Todo
+ const createTodo = async () => {
+ if (!title.trim()) return;
+ try {
+ setSubmitting(true);
+ const res = await fetch(getApiUrl('/todos'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ title: title.trim(),
+ description: description.trim() || null,
+ }),
+ });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ setTitle('');
+ setDescription('');
+ toast.success('Success', {
+ description: 'Todo item created.',
+ });
+ await fetchTodos();
+ } catch (err) {
+ toast.error('Error', {
+ description: 'Failed to create todo item.',
+ });
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ // Toggle Completed
+ const toggleCompleted = async (todo: Todo) => {
+ try {
+ const res = await fetch(getApiUrl(`/todos/${todo.id}`), {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ completed: !todo.completed }),
+ });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ toast.success(todo.completed ? 'Reopened' : 'Completed', {
+ description: `"${todo.title}" updated.`,
+ });
+ await fetchTodos();
+ } catch (err) {
+ toast.error('Error', {
+ description: 'Failed to update todo status.',
+ });
+ }
+ };
+
+ // Update Title/Description
+ const saveEdit = async (id: number) => {
+ if (!editTitle.trim()) return;
+ try {
+ const res = await fetch(getApiUrl(`/todos/${id}`), {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ title: editTitle.trim(),
+ description: editDescription.trim() || null,
+ }),
+ });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ setEditingId(null);
+ toast.success('Updated', {
+ description: 'Todo changes saved.',
+ });
+ await fetchTodos();
+ } catch (err) {
+ toast.error('Error', {
+ description: 'Failed to update todo details.',
+ });
+ }
+ };
+
+ // Delete Todo
+ const deleteTodo = async (id: number) => {
+ try {
+ const res = await fetch(getApiUrl(`/todos/${id}`), {
+ method: 'DELETE',
+ });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ toast.success('Deleted', {
+ description: 'Todo item removed.',
+ });
+ await fetchTodos();
+ } catch (err) {
+ toast.error('Error', {
+ description: 'Failed to delete todo.',
+ });
+ }
+ };
+
+ return (
+
+ {/* Header Banner */}
+
+
+ Todo Workspace
+
+
+
+
+
+ {/* Health Check Panel */}
+ {healthLoading ? (
+
+
+ Verifying system health...
+
+ ) : health ? (
+
+
+
+
+ Status: {health.status.toUpperCase()}
+
+
+
+
+
+ DB
+
+
+
+ Storage
+
+
+
+ ) : (
+
+
+ Backend Offline
+
+ )}
+
+
+ {/* Main Content List */}
+ item.id.toString()}
+ refreshControl={
+
+ }
+ contentContainerStyle={styles.listContent}
+ ListHeaderComponent={
+ /* Add Todo Panel */
+
+ Add New Task
+
+
+
+ {submitting ? (
+
+ ) : (
+ <>
+
+ Create Todo
+ >
+ )}
+
+
+ }
+ ListEmptyComponent={
+ !loading ? (
+
+ No tasks yet. Create one above!
+
+ ) : null
+ }
+ renderItem={({ item }) => (
+
+ {editingId === item.id ? (
+ /* Editing State */
+
+
+
+
+ saveEdit(item.id)}
+ >
+
+
+ setEditingId(null)}
+ >
+
+
+
+
+ ) : (
+ /* Display State */
+
+ toggleCompleted(item)} style={styles.checkboxContainer}>
+ {item.completed ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {item.title}
+
+ {item.description ? (
+
+ {item.description}
+
+ ) : null}
+
+
+
+ {
+ setEditingId(item.id);
+ setEditTitle(item.title);
+ setEditDescription(item.description ?? '');
+ }}
+ >
+
+
+ deleteTodo(item.id)}>
+
+
+
+
+ )}
+
+ )}
+ />
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#F9FAFB',
+ },
+ header: {
+ backgroundColor: '#FFFFFF',
+ paddingTop: 50,
+ paddingHorizontal: 20,
+ paddingBottom: 16,
+ borderBottomWidth: 1,
+ borderBottomColor: '#E5E7EB',
+ },
+ headerRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: 12,
+ },
+ headerTitle: {
+ fontSize: 22,
+ fontWeight: 'bold',
+ color: '#111827',
+ },
+ refreshBtn: {
+ padding: 8,
+ borderRadius: 8,
+ backgroundColor: '#EEF2F6',
+ },
+ healthContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingVertical: 8,
+ paddingHorizontal: 12,
+ borderRadius: 8,
+ backgroundColor: '#F3F4F6',
+ },
+ healthOk: {
+ backgroundColor: '#ECFDF5',
+ },
+ healthErr: {
+ backgroundColor: '#FEF2F2',
+ },
+ healthRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 6,
+ },
+ healthStatusText: {
+ fontSize: 12,
+ fontWeight: '600',
+ color: '#374151',
+ },
+ healthIcons: {
+ flexDirection: 'row',
+ gap: 12,
+ },
+ healthIconItem: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 4,
+ },
+ healthIconLabel: {
+ fontSize: 10,
+ fontWeight: '500',
+ color: '#6B7280',
+ },
+ healthText: {
+ fontSize: 12,
+ color: '#6B7280',
+ marginLeft: 8,
+ },
+ listContent: {
+ padding: 20,
+ paddingBottom: 40,
+ },
+ formContainer: {
+ backgroundColor: '#FFFFFF',
+ borderRadius: 12,
+ padding: 16,
+ marginBottom: 20,
+ borderWidth: 1,
+ borderColor: '#E5E7EB',
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 1 },
+ shadowOpacity: 0.05,
+ shadowRadius: 2,
+ elevation: 2,
+ },
+ sectionTitle: {
+ fontSize: 16,
+ fontWeight: '600',
+ color: '#374151',
+ marginBottom: 12,
+ },
+ input: {
+ backgroundColor: '#F9FAFB',
+ borderWidth: 1,
+ borderColor: '#D1D5DB',
+ borderRadius: 8,
+ paddingHorizontal: 12,
+ paddingVertical: 8,
+ fontSize: 14,
+ color: '#111827',
+ marginBottom: 10,
+ },
+ textArea: {
+ height: 60,
+ textAlignVertical: 'top',
+ },
+ createBtn: {
+ backgroundColor: '#4F46E5',
+ flexDirection: 'row',
+ justifyContent: 'center',
+ alignItems: 'center',
+ paddingVertical: 10,
+ borderRadius: 8,
+ marginTop: 4,
+ },
+ createBtnDisabled: {
+ backgroundColor: '#93C5FD',
+ },
+ btnIcon: {
+ marginRight: 6,
+ },
+ createBtnText: {
+ color: '#FFFFFF',
+ fontWeight: '600',
+ fontSize: 14,
+ },
+ todoCard: {
+ backgroundColor: '#FFFFFF',
+ borderRadius: 12,
+ padding: 16,
+ marginBottom: 12,
+ borderWidth: 1,
+ borderColor: '#E5E7EB',
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 1 },
+ shadowOpacity: 0.03,
+ shadowRadius: 2,
+ elevation: 1,
+ },
+ todoCardCompleted: {
+ backgroundColor: '#FAFAFA',
+ borderColor: '#E5E7EB',
+ },
+ todoRow: {
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ },
+ checkboxContainer: {
+ marginRight: 12,
+ marginTop: 2,
+ },
+ todoTexts: {
+ flex: 1,
+ marginRight: 8,
+ },
+ todoTitle: {
+ fontSize: 15,
+ fontWeight: '600',
+ color: '#111827',
+ marginBottom: 4,
+ },
+ todoDesc: {
+ fontSize: 13,
+ color: '#6B7280',
+ },
+ lineThrough: {
+ textDecorationLine: 'line-through',
+ color: '#9CA3AF',
+ },
+ todoActions: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ },
+ actionIconBtn: {
+ padding: 6,
+ borderRadius: 6,
+ backgroundColor: '#F3F4F6',
+ },
+ editForm: {
+ gap: 8,
+ },
+ editInput: {
+ backgroundColor: '#F9FAFB',
+ borderWidth: 1,
+ borderColor: '#D1D5DB',
+ borderRadius: 6,
+ paddingHorizontal: 10,
+ paddingVertical: 6,
+ fontSize: 14,
+ color: '#111827',
+ },
+ editActions: {
+ flexDirection: 'row',
+ justifyContent: 'flex-end',
+ gap: 8,
+ marginTop: 4,
+ },
+ iconBtn: {
+ paddingVertical: 6,
+ paddingHorizontal: 12,
+ borderRadius: 6,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ saveBtn: {
+ backgroundColor: '#10B981',
+ },
+ cancelBtn: {
+ backgroundColor: '#EF4444',
+ },
+ emptyContainer: {
+ alignItems: 'center',
+ paddingVertical: 40,
+ },
+ emptyText: {
+ fontSize: 14,
+ color: '#9CA3AF',
+ },
+});
diff --git a/mobile/app/+not-found.tsx b/mobile/app/+not-found.tsx
new file mode 100644
index 0000000..329a09f
--- /dev/null
+++ b/mobile/app/+not-found.tsx
@@ -0,0 +1,33 @@
+import { Link, Stack } from 'expo-router';
+import { StyleSheet, Text, View } from 'react-native';
+
+export default function NotFoundScreen() {
+ return (
+ <>
+
+
+ This screen doesn't exist.
+
+ Go to home screen!
+
+
+ >
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: 20,
+ },
+ text: {
+ fontSize: 20,
+ fontWeight: 600,
+ },
+ link: {
+ marginTop: 15,
+ paddingVertical: 15,
+ },
+});
diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx
new file mode 100644
index 0000000..ef4861e
--- /dev/null
+++ b/mobile/app/_layout.tsx
@@ -0,0 +1,20 @@
+import { Stack } from 'expo-router';
+import { StatusBar } from 'expo-status-bar';
+import { GestureHandlerRootView } from 'react-native-gesture-handler';
+import { Toaster } from 'sonner-native';
+import { useFrameworkReady } from '@/hooks/useFrameworkReady';
+
+export default function RootLayout() {
+ useFrameworkReady(); // Assuming this handles side effects
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/mobile/assets/images/favicon.png b/mobile/assets/images/favicon.png
new file mode 100644
index 0000000..e75f697
Binary files /dev/null and b/mobile/assets/images/favicon.png differ
diff --git a/mobile/assets/images/icon.png b/mobile/assets/images/icon.png
new file mode 100644
index 0000000..a0b1526
Binary files /dev/null and b/mobile/assets/images/icon.png differ
diff --git a/mobile/eslint.config.js b/mobile/eslint.config.js
new file mode 100644
index 0000000..76effc0
--- /dev/null
+++ b/mobile/eslint.config.js
@@ -0,0 +1,9 @@
+const { defineConfig } = require('eslint/config');
+const expoConfig = require('eslint-config-expo/flat');
+
+module.exports = defineConfig([
+ ...expoConfig,
+ {
+ ignores: ['dist/**', 'node_modules/**', '.expo/**'],
+ },
+]);
diff --git a/mobile/hooks/useFrameworkReady.ts b/mobile/hooks/useFrameworkReady.ts
new file mode 100644
index 0000000..1e292cb
--- /dev/null
+++ b/mobile/hooks/useFrameworkReady.ts
@@ -0,0 +1,13 @@
+import { useEffect } from 'react';
+
+declare global {
+ interface Window {
+ frameworkReady?: () => void;
+ }
+}
+
+export function useFrameworkReady() {
+ useEffect(() => {
+ window.frameworkReady?.();
+ });
+}
diff --git a/mobile/index.ts b/mobile/index.ts
new file mode 100644
index 0000000..5d3db20
--- /dev/null
+++ b/mobile/index.ts
@@ -0,0 +1,13 @@
+import 'react-native-reanimated';
+// Import crypto polyfills first
+import './utils/crypto-setup';
+import './scripts/network-interceptor';
+
+if (typeof document !== 'undefined') {
+ const utilityScript = document.createElement('script');
+ utilityScript.src = 'https://content.trynoah.ai/expo-utils.js';
+ document.head.appendChild(utilityScript);
+}
+
+// Then import the main expo-router entry
+import 'expo-router/entry';
diff --git a/mobile/metro.config.ts b/mobile/metro.config.ts
new file mode 100644
index 0000000..0fd9738
--- /dev/null
+++ b/mobile/metro.config.ts
@@ -0,0 +1,30 @@
+const { getDefaultConfig } = require('expo/metro-config');
+const { FileStore } = require('metro-cache');
+const path = require('path');
+
+const config = getDefaultConfig(__dirname);
+
+// Add resolver configuration for Node.js polyfills
+config.resolver.alias = {
+ ...config.resolver.alias,
+ crypto: 'react-native-get-random-values',
+ stream: 'readable-stream',
+ buffer: 'buffer',
+};
+
+
+config.resolver.unstable_conditionNames = ['require', 'react-native'];
+
+// Add node_modules to resolver platforms
+config.resolver.platforms = ['native', 'android', 'ios', 'web'];
+
+// Configure metro to handle the polyfills
+config.resolver.resolverMainFields = ['react-native', 'browser', 'main'];
+
+config.cacheStores = [
+ new FileStore({
+ root: path.join(__dirname, '.metro-cache'),
+ }),
+];
+
+module.exports = config;
diff --git a/mobile/package.json b/mobile/package.json
new file mode 100644
index 0000000..df1231c
--- /dev/null
+++ b/mobile/package.json
@@ -0,0 +1,63 @@
+{
+ "name": "expo-starter",
+ "main": "index.ts",
+ "version": "1.0.0",
+ "private": true,
+ "scripts": {
+ "dev": "expo start",
+ "build:web": "expo export --platform web",
+ "lint": "expo lint",
+ "android": "expo run:android",
+ "ios": "expo run:ios"
+ },
+ "dependencies": {
+ "@expo/vector-icons": "15.1.1",
+ "@lucide/lab": "0.1.2",
+ "@react-native-async-storage/async-storage": "2.2.0",
+ "@react-navigation/bottom-tabs": "^7.4.0",
+ "@react-navigation/native": "^7.1.8",
+ "@solana/spl-token": "0.4.13",
+ "@solana/web3.js": "1.98.2",
+ "@supabase/supabase-js": "2.57.4",
+ "bs58": "6.0.0",
+ "buffer": "6.0.3",
+ "expo": "~54.0.35",
+ "expo-blur": "~15.0.8",
+ "expo-camera": "~17.0.10",
+ "expo-clipboard": "~8.0.8",
+ "expo-constants": "~18.0.11",
+ "expo-font": "~14.0.10",
+ "expo-haptics": "~15.0.8",
+ "expo-linear-gradient": "~15.0.8",
+ "expo-linking": "~8.0.10",
+ "expo-router": "~6.0.17",
+ "expo-splash-screen": "~31.0.12",
+ "expo-status-bar": "~3.0.9",
+ "expo-symbols": "~1.0.8",
+ "expo-system-ui": "~6.0.9",
+ "expo-web-browser": "~15.0.10",
+ "lucide-react-native": "0.555.0",
+ "react": "19.1.0",
+ "react-dom": "19.1.0",
+ "react-native": "0.81.5",
+ "react-native-gesture-handler": "~2.28.0",
+ "react-native-get-random-values": "1.11.0",
+ "react-native-reanimated": "~4.1.1",
+ "react-native-safe-area-context": "~5.6.0",
+ "react-native-screens": "~4.16.0",
+ "react-native-svg": "15.12.1",
+ "react-native-url-polyfill": "2.0.0",
+ "react-native-web": "0.21.2",
+ "react-native-webview": "13.15.0",
+ "react-native-worklets": "0.5.1",
+ "sonner-native": "^0.24.0",
+ "tweetnacl": "1.0.3"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.26.0",
+ "@types/react": "~19.1.10",
+ "eslint": "^9.0.0",
+ "eslint-config-expo": "~10.0.0",
+ "typescript": "~5.9.2"
+ }
+}
diff --git a/mobile/scripts/network-interceptor.ts b/mobile/scripts/network-interceptor.ts
new file mode 100644
index 0000000..99e3314
--- /dev/null
+++ b/mobile/scripts/network-interceptor.ts
@@ -0,0 +1,51 @@
+if (typeof window !== 'undefined') {
+ const _origFetch = window.fetch;
+ if (typeof _origFetch === 'function' && !(window as any).__noahNetworkPatched) {
+ (window as any).__noahNetworkPatched = true;
+ (window as any).__noahOrigFetch = _origFetch;
+ window.fetch = function (input: any, init?: any) {
+ let url = 'unknown';
+ try { url = input instanceof Request ? input.url : String(input); } catch (_) {}
+ const method = ((input instanceof Request ? input.method : init?.method) || 'GET').toUpperCase();
+ const start = Date.now();
+ const post = (status: number, statusText: string, error: string | null) => {
+ try {
+ window.parent?.postMessage(
+ {type: 'iframe-network', data: {method, url, status, statusText, duration: Date.now() - start, requestBody: null, responseBody: null, error, timestamp: Date.now()}},
+ '*'
+ );
+ } catch (_) {}
+ };
+ let promise: Promise;
+ try { promise = _origFetch.apply(this, arguments as any); }
+ catch (e: any) { post(0, '', e?.message || String(e)); throw e; }
+ return promise.then(
+ (resp) => { post(resp.status, resp.statusText, null); return resp; },
+ (err: any) => { post(0, '', err?.message || String(err)); throw err; }
+ );
+ };
+ }
+
+ if (typeof XMLHttpRequest !== 'undefined' && !(window as any).__noahXHRPatched) {
+ (window as any).__noahXHRPatched = true;
+ const _open = XMLHttpRequest.prototype.open;
+ const _send = XMLHttpRequest.prototype.send;
+ XMLHttpRequest.prototype.open = function (method: string, url: string) {
+ (this as any)._noahMethod = method;
+ (this as any)._noahUrl = url;
+ (this as any)._noahStart = Date.now();
+ return _open.apply(this, arguments as any);
+ };
+ XMLHttpRequest.prototype.send = function () {
+ this.addEventListener('loadend', () => {
+ try {
+ window.parent?.postMessage(
+ {type: 'iframe-network', data: {method: (this as any)._noahMethod || 'GET', url: (this as any)._noahUrl || '', status: this.status, statusText: this.statusText, duration: Date.now() - ((this as any)._noahStart || Date.now()), requestBody: null, responseBody: null, error: this.status === 0 ? 'Network error' : null, timestamp: Date.now()}},
+ '*'
+ );
+ } catch (_) {}
+ });
+ return _send.apply(this, arguments as any);
+ };
+ }
+}
diff --git a/mobile/tsconfig.json b/mobile/tsconfig.json
new file mode 100644
index 0000000..ce27fee
--- /dev/null
+++ b/mobile/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "expo/tsconfig.base",
+ "compilerOptions": {
+ "strict": true,
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
+}
diff --git a/mobile/types/wallet.ts b/mobile/types/wallet.ts
new file mode 100644
index 0000000..d99da46
--- /dev/null
+++ b/mobile/types/wallet.ts
@@ -0,0 +1,5 @@
+
+export interface WalletData {
+ publicKey: string;
+ privateKey: string;
+}
diff --git a/mobile/utils/api.ts b/mobile/utils/api.ts
new file mode 100644
index 0000000..e58fe3a
--- /dev/null
+++ b/mobile/utils/api.ts
@@ -0,0 +1,23 @@
+export function getApiUrl(path: string): string {
+ const cleanPath = path.startsWith('/') ? path : `/${path}`;
+
+ if (typeof window !== 'undefined' && window.location) {
+ const origin = window.location.origin;
+ // Local Expo dev server direct port
+ if (origin.includes(':8081')) {
+ return `http://localhost:8080/api${cleanPath}`;
+ }
+ // Deployed Modal Sandbox direct port
+ if (origin.includes('-8081.modal.run')) {
+ const backendOrigin = origin.replace('-8081.modal.run', '-8080.modal.run');
+ return `${backendOrigin}/api${cleanPath}`;
+ }
+
+ // Relative path works when accessed via port 5173 /mobile proxy
+ return `/api${cleanPath}`;
+ }
+
+ // Fallback for native devices
+ const baseUrl = process.env.EXPO_PUBLIC_API_URL || 'http://localhost:8080/api';
+ return `${baseUrl}${cleanPath}`;
+}
diff --git a/mobile/utils/crypto-setup.ts b/mobile/utils/crypto-setup.ts
new file mode 100644
index 0000000..9ed4ce9
--- /dev/null
+++ b/mobile/utils/crypto-setup.ts
@@ -0,0 +1,31 @@
+import 'react-native-get-random-values';
+import 'react-native-url-polyfill/auto';
+import { Buffer } from 'buffer';
+
+if (typeof global !== 'undefined') {
+ if (typeof global.Buffer === 'undefined') {
+ global.Buffer = Buffer;
+ }
+} else if (typeof window !== 'undefined') {
+ if (typeof (window as any).Buffer === 'undefined') {
+ (window as any).Buffer = Buffer;
+ }
+}
+
+const { getRandomValues } = require('react-native-get-random-values');
+
+if (typeof global !== 'undefined') {
+ if (typeof global.crypto === 'undefined') {
+ (global as any).crypto = {
+ getRandomValues: getRandomValues,
+ };
+ }
+} else if (typeof window !== 'undefined') {
+ if (typeof window.crypto === 'undefined') {
+ (window as any).crypto = {
+ getRandomValues: getRandomValues,
+ };
+ }
+}
+
+export {};
diff --git a/website/vite.config.ts b/website/vite.config.ts
index 64f147e..49936fd 100644
--- a/website/vite.config.ts
+++ b/website/vite.config.ts
@@ -22,6 +22,10 @@ export default defineConfig(({ mode }) => ({
target: "http://localhost:8080",
changeOrigin: true,
},
+ "/mobile": {
+ target: "http://localhost:8081",
+ changeOrigin: true,
+ },
},
},
plugins: [react(), mode === "development" && componentTagger()],