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