mobile: add React Native app scaffold + mock bridge and tests

This commit is contained in:
MoCipher 2026-02-20 09:52:21 +01:00
parent 9d4a005a1b
commit b49225b0a5
8 changed files with 231 additions and 0 deletions

View file

@ -0,0 +1,17 @@
import nativeBridge from '../src/nativeBridge';
test('generate mnemonic and derive address', async () => {
const m = await nativeBridge.generateMnemonic(128);
expect(typeof m).toBe('string');
const addr = nativeBridge.firstReceiveAddressFromMnemonic(m);
expect(typeof addr).toBe('string');
expect(addr.length).toBeGreaterThan(0);
});
test('create and sign psbt mock', async () => {
const mn = await nativeBridge.generateMnemonic(128);
const psbt = await nativeBridge.createPsbtMock(mn, 'tb1qexampleaddress', 5000);
expect(psbt).toBeTruthy();
const signed = await nativeBridge.signPsbtMock(mn, psbt);
expect(signed).toBeTruthy();
});

8
mobile/jest.config.js Normal file
View file

@ -0,0 +1,8 @@
module.exports = {
preset: 'react-native',
transform: {
'^.+\\.tsx?$': 'ts-jest'
},
testEnvironment: 'node',
setupFilesAfterEnv: ['@testing-library/jest-native/extend-expect']
};

33
mobile/package.json Normal file
View file

@ -0,0 +1,33 @@
{
"name": "cryptec-mobile",
"version": "0.1.0",
"private": true,
"main": "node_modules/expo/AppEntry.js",
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"test": "jest --config ./jest.config.js --runInBand",
"lint": "eslint . --ext .ts,.tsx"
},
"dependencies": {
"expo": "~48.0.0",
"react": "18.2.0",
"react-native": "0.71.8",
"@react-navigation/native": "^6.1.6",
"@react-navigation/native-stack": "^6.9.12",
"bip39": "^3.0.4",
"bitcoinjs-lib": "^6.1.0"
},
"devDependencies": {
"@testing-library/react-native": "^11.5.0",
"@types/jest": "^29.5.2",
"@types/react": "18.0.28",
"@types/react-native": "0.70.14",
"jest": "^29.5.0",
"react-test-renderer": "18.2.0",
"ts-jest": "^29.1.0",
"typescript": "^4.9.5"
}
}

18
mobile/src/App.tsx Normal file
View file

@ -0,0 +1,18 @@
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import Onboarding from './screens/Onboarding';
import WalletHome from './screens/WalletHome';
const Stack = createNativeStackNavigator();
export default function App() {
return (
<NavigationContainer>
<Stack.Navigator initialRouteName="Onboarding">
<Stack.Screen name="Onboarding" component={Onboarding} options={{ title: 'Welcome' }} />
<Stack.Screen name="Wallet" component={WalletHome} options={{ title: 'Wallet' }} />
</Stack.Navigator>
</NavigationContainer>
);
}

View file

@ -0,0 +1,55 @@
import { NativeModules } from 'react-native';
import * as bip39 from 'bip39';
import * as bitcoin from 'bitcoinjs-lib';
const NETWORK = bitcoin.networks.testnet;
// If a native mobile binding (UniFFI) is installed, use it. Otherwise fall back to JS mock.
const NativeCryptec: any = (NativeModules && (NativeModules.Cryptec || NativeModules.CryptecModule)) || null;
async function generateMnemonicNative(strength = 128): Promise<string> {
if (NativeCryptec && NativeCryptec.generate_mnemonic) {
return await NativeCryptec.generate_mnemonic(strength);
}
return bip39.generateMnemonic(strength);
}
function firstReceiveAddressFromMnemonicNative(mnemonic: string): string {
if (NativeCryptec && NativeCryptec.first_receive_address) {
try {
return NativeCryptec.first_receive_address(mnemonic);
} catch (e) {
// fallback to JS
}
}
const seed = bip39.mnemonicToSeedSync(mnemonic);
const root = bitcoin.bip32.fromSeed(seed, NETWORK);
const child = root.derivePath("m/84'/1'/0'/0/0");
const { publicKey } = child;
const { address } = bitcoin.payments.p2wpkh({ pubkey: publicKey, network: NETWORK });
return address || '';
}
async function createPsbtNative(mnemonic: string, toAddress: string, satoshis: number): Promise<string> {
if (NativeCryptec && NativeCryptec.create_psbt) {
return await NativeCryptec.create_psbt(mnemonic, toAddress, satoshis);
}
const payload = { type: 'psbt-mock', to: toAddress, sats: satoshis };
return Buffer.from(JSON.stringify(payload)).toString('base64');
}
async function signPsbtNative(mnemonic: string, psbtB64: string): Promise<string> {
if (NativeCryptec && NativeCryptec.sign_psbt) {
return await NativeCryptec.sign_psbt(mnemonic, psbtB64);
}
const decoded = Buffer.from(psbtB64, 'base64').toString('utf8');
const payload = { signed: true, original: decoded };
return Buffer.from(JSON.stringify(payload)).toString('base64');
}
export default {
generateMnemonic: generateMnemonicNative,
firstReceiveAddressFromMnemonic: firstReceiveAddressFromMnemonicNative,
createPsbtMock: createPsbtNative,
signPsbtMock: signPsbtNative
};

View file

@ -0,0 +1,41 @@
import React, { useState } from 'react';
import { View, Text, Button, StyleSheet, TextInput } from 'react-native';
import nativeBridge from '../nativeBridge';
export default function Onboarding({ navigation }: any) {
const [mnemonic, setMnemonic] = useState('');
const createNew = async () => {
const m = await nativeBridge.generateMnemonic(128);
setMnemonic(m);
navigation.navigate('Wallet', { mnemonic: m });
};
const importExisting = () => {
if (!mnemonic) return;
navigation.navigate('Wallet', { mnemonic });
};
return (
<View style={styles.container}>
<Text style={styles.title}>CrypteCipher Onboarding</Text>
<Button title="Create new wallet" onPress={createNew} />
<Text style={styles.or}> or </Text>
<TextInput
style={styles.input}
placeholder="paste mnemonic phrase to import"
value={mnemonic}
onChangeText={setMnemonic}
multiline
/>
<Button title="Import wallet" onPress={importExisting} />
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 20, justifyContent: 'center' },
title: { fontSize: 20, fontWeight: '600', marginBottom: 20 },
or: { textAlign: 'center', marginVertical: 12, color: '#666' },
input: { borderWidth: 1, borderColor: '#ddd', padding: 8, marginBottom: 12, borderRadius: 6 }
});

View file

@ -0,0 +1,45 @@
import React, { useEffect, useState } from 'react';
import { View, Text, Button, StyleSheet, TextInput, Alert } from 'react-native';
import nativeBridge from '../nativeBridge';
export default function WalletHome({ route }: any) {
const { mnemonic } = route.params || {};
const [address, setAddress] = useState('');
const [to, setTo] = useState('');
const [amount, setAmount] = useState('1000');
useEffect(() => {
const addr = nativeBridge.firstReceiveAddressFromMnemonic(mnemonic);
setAddress(addr);
}, [mnemonic]);
const createPsbt = async () => {
const psbt = await nativeBridge.createPsbtMock(mnemonic, to, parseInt(amount || '0', 10));
Alert.alert('PSBT created (mock)', psbt);
};
return (
<View style={styles.container}>
<Text style={styles.title}>Wallet</Text>
<Text style={styles.label}>Receive address (first):</Text>
<Text style={styles.addr}>{address}</Text>
<Text style={styles.section}>Send (mock)</Text>
<TextInput placeholder="to address" style={styles.input} value={to} onChangeText={setTo} />
<TextInput placeholder="satoshis" style={styles.input} value={amount} onChangeText={setAmount} keyboardType="numeric" />
<Button title="Create PSBT (mock)" onPress={createPsbt} />
<View style={{ height: 24 }} />
<Button title="Export mnemonic (copy)" onPress={() => Alert.alert('Mnemonic', mnemonic)} />
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 20 },
title: { fontSize: 20, fontWeight: '600', marginBottom: 12 },
label: { fontWeight: '500', marginTop: 8 },
addr: { marginVertical: 8, color: '#333' },
section: { marginTop: 16, fontWeight: '600' },
input: { borderWidth: 1, borderColor: '#ddd', padding: 8, marginVertical: 8, borderRadius: 6 }
});

14
mobile/tsconfig.json Normal file
View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "es2019",
"module": "esnext",
"jsx": "react-native",
"strict": true,
"moduleResolution": "node",
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"noEmit": true
},
"exclude": ["node_modules", "babel.config.js", "metro.config.js", "jest.config.js"]
}