mobile: add React Native app scaffold + mock bridge and tests
This commit is contained in:
parent
9d4a005a1b
commit
b49225b0a5
8 changed files with 231 additions and 0 deletions
17
mobile/__tests__/nativeBridge.test.ts
Normal file
17
mobile/__tests__/nativeBridge.test.ts
Normal 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
8
mobile/jest.config.js
Normal 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
33
mobile/package.json
Normal 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
18
mobile/src/App.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
mobile/src/nativeBridge.ts
Normal file
55
mobile/src/nativeBridge.ts
Normal 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
|
||||||
|
};
|
||||||
41
mobile/src/screens/Onboarding.tsx
Normal file
41
mobile/src/screens/Onboarding.tsx
Normal 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 }
|
||||||
|
});
|
||||||
45
mobile/src/screens/WalletHome.tsx
Normal file
45
mobile/src/screens/WalletHome.tsx
Normal 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
14
mobile/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue