Setup react native paper (#1)

* Basic setup for react native web

* Comment unsupported react-native code

* Add interface for keychain methods

* Fix storeNetworkData method

* Use mui dialog component

* Modify key store file name

* Fix add network and edit network screens

* Fix sign message screen

* Use light theme

---------

Co-authored-by: Nabarun <nabarun@deepstacksoft.com>
This commit is contained in:
Isha Venikar 2024-07-25 13:00:03 +05:30 committed by GitHub
parent 5b99becee0
commit 640155aa4a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
64 changed files with 20128 additions and 0 deletions

23
.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

42
config-overrides.js Normal file
View File

@ -0,0 +1,42 @@
// TODO: Use Typescript
module.exports = function override(config, env) {
config.module.rules.push({
test: /\.js$/,
exclude: /node_modules[/\\](?!react-native-vector-icons)/,
use: {
loader: "babel-loader",
options: {
// Disable reading babel configuration
babelrc: false,
configFile: false,
// The configuration for compilation
presets: [
["@babel/preset-env", { useBuiltIns: "usage", "corejs": "3" }],
"@babel/preset-react",
"@babel/preset-flow",
"@babel/preset-typescript",
],
plugins: [
"@babel/plugin-proposal-class-properties",
"@babel/plugin-proposal-object-rest-spread"
]
}
}
});
config.module.rules.push({
test: /\.(jpg|png|woff|woff2|eot|ttf|svg)$/,
type: 'asset/resource'
})
config.resolve.fallback = {
crypto: require.resolve("crypto-browserify"),
stream: require.resolve("stream-browserify"),
}
config.resolve.alias['react-native$'] = require.resolve('react-native-web');
return config;
};

96
package.json Normal file
View File

@ -0,0 +1,96 @@
{
"name": "web-wallet",
"version": "0.1.0",
"private": true,
"dependencies": {
"@cerc-io/registry-sdk": "^0.2.2",
"@cosmjs/amino": "^0.32.3",
"@cosmjs/crypto": "^0.32.3",
"@cosmjs/proto-signing": "^0.32.3",
"@cosmjs/stargate": "^0.32.3",
"@emotion/react": "^11.13.0",
"@emotion/styled": "^11.13.0",
"@ethersproject/shims": "^5.7.0",
"@hookform/resolvers": "^3.3.4",
"@json-rpc-tools/utils": "^1.7.6",
"@mui/material": "^5.16.4",
"@react-native-async-storage/async-storage": "^1.22.3",
"@react-native-community/netinfo": "^11.3.1",
"@react-navigation/elements": "^1.3.30",
"@react-navigation/native": "^6.1.10",
"@react-navigation/native-stack": "^6.9.18",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^13.0.0",
"@testing-library/user-event": "^13.2.1",
"@types/jest": "^27.0.1",
"@types/node": "^16.7.13",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"assert": "^2.1.0",
"chain-registry": "^1.41.2",
"cosmjs-types": "^0.9.0",
"ethers": "5.7.2",
"http-browserify": "^1.7.0",
"https-browserify": "^1.0.0",
"lodash": "^4.17.21",
"patch-package": "^8.0.0",
"react": "^18.3.1",
"react-art": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.51.2",
"react-native": "^0.74.3",
"react-native-config": "^1.5.1",
"react-native-get-random-values": "^1.10.0",
"react-native-paper": "^5.12.3",
"react-native-quick-base64": "^2.0.8",
"react-native-quick-crypto": "^0.6.1",
"react-native-safe-area-context": "^4.10.8",
"react-native-screens": "^3.29.0",
"react-native-svg": "^15.1.0",
"react-native-url-polyfill": "^2.0.0",
"react-native-vector-icons": "^10.1.0",
"react-native-web": "^0.19.12",
"react-scripts": "5.0.1",
"text-encoding-polyfill": "^0.6.7",
"typescript": "^4.4.2",
"use-debounce": "^10.0.0",
"web-vitals": "^2.1.0",
"zod": "^3.22.4"
},
"scripts": {
"start": "react-app-rewired start",
"build": "react-app-rewired build",
"test": "react-app-rewired test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@babel/plugin-proposal-class-properties": "^7.18.6",
"@babel/plugin-proposal-object-rest-spread": "^7.20.7",
"@babel/preset-env": "^7.24.8",
"@babel/preset-flow": "^7.24.7",
"@babel/preset-react": "^7.24.7",
"@babel/preset-typescript": "^7.24.7",
"@types/lodash": "^4.17.7",
"babel-loader": "^9.1.3",
"core-js": "^3.37.1",
"react-app-rewired": "^2.2.1"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

43
public/index.html Normal file
View File

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

BIN
public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

25
public/manifest.json Normal file
View File

@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

3
public/robots.txt Normal file
View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

38
src/App.css Normal file
View File

@ -0,0 +1,38 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

9
src/App.test.tsx Normal file
View File

@ -0,0 +1,9 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

328
src/App.tsx Normal file
View File

@ -0,0 +1,328 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Button, Snackbar, Text } from 'react-native-paper';
// import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
// import { TxBody, AuthInfo } from 'cosmjs-types/cosmos/tx/v1beta1/tx';
// import { SignClientTypes } from '@walletconnect/types';
import { useNavigation } from '@react-navigation/native';
import {
NativeStackNavigationProp,
createNativeStackNavigator,
} from '@react-navigation/native-stack';
// import { getSdkError } from '@walletconnect/utils';
// import { Web3WalletTypes } from '@walletconnect/web3wallet';
// import { formatJsonRpcResult } from '@json-rpc-tools/utils';
// import PairingModal from './components/PairingModal';
// import { useWalletConnect } from './context/WalletConnectContext';
import { useAccounts } from './context/AccountsContext';
import InvalidPath from './screens/InvalidPath';
import SignMessage from './screens/SignMessage';
import HomeScreen from './screens/HomeScreen';
import SignRequest from './screens/SignRequest';
import AddSession from './screens/AddSession';
// import WalletConnect from './screens/WalletConnect';
import ApproveTransaction from './screens/ApproveTransaction';
import { StackParamsList } from './types';
// import { web3wallet } from './utils/wallet-connect/WalletConnectUtils';
// import { EIP155_SIGNING_METHODS } from './utils/wallet-connect/EIP155Data';
// import { getSignParamsMessage } from './utils/wallet-connect/helpers';
import ApproveTransfer from './screens/ApproveTransfer';
import AddNetwork from './screens/AddNetwork';
import EditNetwork from './screens/EditNetwork';
// import { COSMOS, EIP155 } from './utils/constants';
import { useNetworks } from './context/NetworksContext';
// import { NETWORK_METHODS } from './utils/wallet-connect/common-data';
// import { COSMOS_METHODS } from './utils/wallet-connect/COSMOSData';
const Stack = createNativeStackNavigator<StackParamsList>();
const App = (): React.JSX.Element => {
const navigation =
useNavigation<NativeStackNavigationProp<StackParamsList>>();
// const { setActiveSessions } = useWalletConnect();
const { accounts, setCurrentIndex } = useAccounts();
const { networksData, selectedNetwork, setSelectedNetwork } = useNetworks();
const [modalVisible, setModalVisible] = useState(false);
const [toastVisible, setToastVisible] = useState(false);
// const [currentProposal, setCurrentProposal] = useState<
// SignClientTypes.EventArguments['session_proposal'] | undefined
// >();
// const onSessionProposal = useCallback(
// async (proposal: SignClientTypes.EventArguments['session_proposal']) => {
// if (!accounts.length || !accounts.length) {
// const { id } = proposal;
// await web3wallet!.rejectSession({
// id,
// reason: getSdkError('UNSUPPORTED_ACCOUNTS'),
// });
// return;
// }
// setModalVisible(true);
// setCurrentProposal(proposal);
// },
// [accounts],
// );
// const onSessionRequest = useCallback(
// async (requestEvent: Web3WalletTypes.SessionRequest) => {
// const { topic, params, id } = requestEvent;
// const { request } = params;
// const requestSessionData =
// web3wallet!.engine.signClient.session.get(topic);
// switch (request.method) {
// case NETWORK_METHODS.GET_NETWORKS:
// const currentNetworkId = networksData.find(
// networkData => networkData.networkId === selectedNetwork!.networkId,
// )?.networkId;
// const networkNamesData = networksData.map(networkData => {
// return {
// id: networkData.networkId,
// name: networkData.networkName,
// };
// });
// const formattedResponse = formatJsonRpcResult(id, {
// currentNetworkId,
// networkNamesData,
// });
// await web3wallet!.respondSessionRequest({
// topic,
// response: formattedResponse,
// });
// break;
// case NETWORK_METHODS.CHANGE_NETWORK:
// const networkNameData = request.params[0];
// const network = networksData.find(
// networkData => networkData.networkId === networkNameData.id,
// );
// setCurrentIndex(0);
// setSelectedNetwork(network);
// const response = formatJsonRpcResult(id, {
// response: 'true',
// });
// await web3wallet!.respondSessionRequest({
// topic,
// response: response,
// });
// break;
// case EIP155_SIGNING_METHODS.ETH_SEND_TRANSACTION:
// navigation.navigate('ApproveTransfer', {
// transaction: request.params[0],
// requestEvent,
// requestSessionData,
// });
// break;
// case EIP155_SIGNING_METHODS.PERSONAL_SIGN:
// navigation.navigate('SignRequest', {
// namespace: EIP155,
// address: request.params[1],
// message: getSignParamsMessage(request.params),
// requestEvent,
// requestSessionData,
// });
// break;
// case COSMOS_METHODS.COSMOS_SIGN_DIRECT:
// const message = {
// txbody: TxBody.toJSON(
// TxBody.decode(
// Uint8Array.from(
// Buffer.from(request.params.signDoc.bodyBytes, 'hex'),
// ),
// ),
// ),
// authInfo: AuthInfo.toJSON(
// AuthInfo.decode(
// Uint8Array.from(
// Buffer.from(request.params.signDoc.authInfoBytes, 'hex'),
// ),
// ),
// ),
// };
// navigation.navigate('SignRequest', {
// namespace: COSMOS,
// address: request.params.signerAddress,
// message: JSON.stringify(message, undefined, 2),
// requestEvent,
// requestSessionData,
// });
// break;
// case COSMOS_METHODS.COSMOS_SIGN_AMINO:
// navigation.navigate('SignRequest', {
// namespace: COSMOS,
// address: request.params.signerAddress,
// message: request.params.signDoc.memo,
// requestEvent,
// requestSessionData,
// });
// break;
// case COSMOS_METHODS.COSMOS_SEND_TOKENS:
// navigation.navigate('ApproveTransfer', {
// transaction: request.params[0],
// requestEvent,
// requestSessionData,
// });
// break;
// case COSMOS_METHODS.COSMOS_SEND_TRANSACTION:
// const { transactionMessage, signer } = request.params;
// navigation.navigate('ApproveTransaction', {
// transactionMessage,
// signer,
// requestEvent,
// requestSessionData,
// });
// break;
// default:
// throw new Error('Invalid method');
// }
// },
// [
// navigation,
// networksData,
// setSelectedNetwork,
// setCurrentIndex,
// selectedNetwork,
// ],
// );
// const onSessionDelete = useCallback(() => {
// const sessions = web3wallet!.getActiveSessions();
// setActiveSessions(sessions);
// }, [setActiveSessions]);
// useEffect(() => {
// web3wallet?.on('session_proposal', onSessionProposal);
// web3wallet?.on('session_request', onSessionRequest);
// web3wallet?.on('session_delete', onSessionDelete);
// return () => {
// web3wallet?.off('session_proposal', onSessionProposal);
// web3wallet?.off('session_request', onSessionRequest);
// web3wallet?.off('session_delete', onSessionDelete);
// };
// });
return (
<>
<Stack.Navigator>
<Stack.Screen
name="Laconic"
component={HomeScreen}
options={{
// eslint-disable-next-line react/no-unstable-nested-components
headerTitle: () => <Text variant="titleLarge">Laconic Wallet</Text>,
headerBackVisible: false,
}}
/>
<Stack.Screen
name="SignMessage"
component={SignMessage}
options={{
// eslint-disable-next-line react/no-unstable-nested-components
headerTitle: () => <Text variant="titleLarge">Sign Message</Text>,
}}
/>
{/* <Stack.Screen
name="SignRequest"
component={SignRequest}
options={{
// eslint-disable-next-line react/no-unstable-nested-components
headerTitle: () => <Text variant="titleLarge">Sign Request</Text>,
}}
/> */}
<Stack.Screen
name="InvalidPath"
component={InvalidPath}
options={{
// eslint-disable-next-line react/no-unstable-nested-components
headerTitle: () => <Text variant="titleLarge">Bad Request</Text>,
headerBackVisible: false,
}}
/>
{/* <Stack.Screen
name="WalletConnect"
component={WalletConnect}
options={{
// eslint-disable-next-line react/no-unstable-nested-components
headerTitle: () => <Text variant="titleLarge">WalletConnect</Text>,
// eslint-disable-next-line react/no-unstable-nested-components
headerRight: () => (
<Button
onPress={() => {
navigation.navigate('AddSession');
}}>
{<Icon name={'qrcode-scan'} size={20} />}
</Button>
),
}}
/> */}
<Stack.Screen
name="AddSession"
component={AddSession}
options={{
title: 'New session',
}}
/>
{/* <Stack.Screen
name="ApproveTransfer"
component={ApproveTransfer}
options={{
title: 'Approve transfer',
}}
/> */}
<Stack.Screen
name="AddNetwork"
component={AddNetwork}
options={{
title: 'Add Network',
}}
/>
<Stack.Screen
name="EditNetwork"
component={EditNetwork}
options={{
title: 'Edit Network',
}}
/>
{/* <Stack.Screen
name="ApproveTransaction"
component={ApproveTransaction}
options={{
title: 'Approve Transaction',
}}
/> */}
</Stack.Navigator>
{/* <PairingModal
visible={modalVisible}
setModalVisible={setModalVisible}
currentProposal={currentProposal}
setCurrentProposal={setCurrentProposal}
setToastVisible={setToastVisible}
/> */}
<Snackbar
visible={toastVisible}
onDismiss={() => setToastVisible(false)}
duration={3000}>
Session approved
</Snackbar>
</>
);
};
export default App;

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,31 @@
import React from 'react';
import { View } from 'react-native';
import { Text } from 'react-native-paper';
import { Account } from '../types';
import styles from '../styles/stylesheet';
interface AccountDetailsProps {
account: Account | undefined;
}
const AccountDetails: React.FC<AccountDetailsProps> = ({ account }) => {
return (
<View style={styles.accountContainer}>
<Text variant="bodyLarge" selectable={true}>
<Text style={styles.highlight}>Address: </Text>
{account?.address}
</Text>
<Text variant="bodyLarge" selectable={true}>
<Text style={styles.highlight}>Public Key: </Text>
{account?.pubKey}
</Text>
<Text variant="bodyLarge">
<Text style={styles.highlight}>HD Path: </Text>
{account?.hdPath}
</Text>
</View>
);
};
export default AccountDetails;

229
src/components/Accounts.tsx Normal file
View File

@ -0,0 +1,229 @@
import React, { useEffect, useState } from 'react';
import { ScrollView, TouchableOpacity, View } from 'react-native';
import { Button, List, Text, useTheme } from 'react-native-paper';
// import { setInternetCredentials } from 'react-native-keychain';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { StackParamsList, Account } from '../types';
import { addAccount } from '../utils/accounts';
import styles from '../styles/stylesheet';
import HDPathDialog from './HDPathDialog';
import AccountDetails from './AccountDetails';
import { useAccounts } from '../context/AccountsContext';
import { web3wallet } from '../utils/wallet-connect/WalletConnectUtils';
import { useNetworks } from '../context/NetworksContext';
import ConfirmDialog from './ConfirmDialog';
import { getNamespaces } from '../utils/wallet-connect/helpers';
import ShowPKDialog from './ShowPKDialog';
const Accounts = () => {
const navigation =
useNavigation<NativeStackNavigationProp<StackParamsList>>();
const { accounts, setAccounts, setCurrentIndex, currentIndex } =
useAccounts();
const { networksData, selectedNetwork, setNetworksData, setSelectedNetwork } =
useNetworks();
const [expanded, setExpanded] = useState(false);
const [isAccountCreating, setIsAccountCreating] = useState(false);
const [hdDialog, setHdDialog] = useState(false);
const [pathCode, setPathCode] = useState('');
const [deleteNetworkDialog, setDeleteNetworkDialog] =
useState<boolean>(false);
const theme = useTheme();
const handlePress = () => setExpanded(!expanded);
const hideDeleteNetworkDialog = () => setDeleteNetworkDialog(false);
const updateAccounts = (account: Account) => {
setAccounts([...accounts, account]);
};
useEffect(() => {
const updateSessions = async () => {
const sessions = (web3wallet && web3wallet.getActiveSessions()) || {};
// Iterate through each session
for (const topic in sessions) {
const session = sessions[topic];
const { optionalNamespaces, requiredNamespaces } = session;
const updatedNamespaces = await getNamespaces(
optionalNamespaces,
requiredNamespaces,
networksData,
selectedNetwork!,
accounts,
currentIndex,
);
if (!updatedNamespaces) {
return;
}
await web3wallet!.updateSession({
topic,
namespaces: updatedNamespaces,
});
}
};
// Call the updateSessions function when the 'accounts' dependency changes
updateSessions();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [accounts]);
const addAccountHandler = async () => {
setIsAccountCreating(true);
const newAccount = await addAccount(selectedNetwork!);
setIsAccountCreating(false);
if (newAccount) {
updateAccounts(newAccount);
setCurrentIndex(newAccount.index);
}
};
const renderAccountItems = () =>
accounts.map(account => (
<List.Item
key={account.index}
title={`Account ${account.index + 1}`}
onPress={() => {
setCurrentIndex(account.index);
setExpanded(false);
}}
/>
));
const handleRemove = async () => {
const updatedNetworks = networksData.filter(
networkData => selectedNetwork!.networkId !== networkData.networkId,
);
// await setInternetCredentials(
// 'networks',
// '_',
// JSON.stringify(updatedNetworks),
// );
setSelectedNetwork(updatedNetworks[0]);
setCurrentIndex(0);
setDeleteNetworkDialog(false);
setNetworksData(updatedNetworks);
};
return (
<ScrollView>
<View>
<HDPathDialog
visible={hdDialog}
hideDialog={() => setHdDialog(false)}
updateAccounts={updateAccounts}
pathCode={pathCode}
/>
<List.Accordion
title={`Account ${currentIndex + 1}`}
expanded={expanded}
onPress={handlePress}>
{renderAccountItems()}
</List.Accordion>
<View style={styles.addAccountButton}>
<Button
mode="contained"
onPress={addAccountHandler}
loading={isAccountCreating}
disabled={isAccountCreating}>
{isAccountCreating ? 'Adding' : 'Add Account'}
</Button>
</View>
<View style={styles.addAccountButton}>
<Button
mode="contained"
onPress={() => {
setHdDialog(true);
setPathCode(`m/44'/${selectedNetwork!.coinType}'/`);
}}>
Add Account from HD path
</Button>
</View>
<AccountDetails account={accounts[currentIndex]} />
<View style={styles.signLink}>
<TouchableOpacity
onPress={() => {
navigation.navigate('SignMessage', {
selectedNamespace: selectedNetwork!.namespace,
selectedChainId: selectedNetwork!.chainId,
accountInfo: accounts[currentIndex],
});
}}>
<Text
variant="titleSmall"
style={[styles.hyperlink, { color: theme.colors.primary }]}>
Sign Message
</Text>
</TouchableOpacity>
</View>
<View style={styles.signLink}>
<TouchableOpacity
onPress={() => {
navigation.navigate('AddNetwork');
}}>
<Text
variant="titleSmall"
style={[styles.hyperlink, { color: theme.colors.primary }]}>
Add Network
</Text>
</TouchableOpacity>
</View>
<View style={styles.signLink}>
<TouchableOpacity
onPress={() => {
navigation.navigate('EditNetwork', {
selectedNetwork: selectedNetwork!,
});
}}>
<Text
variant="titleSmall"
style={[styles.hyperlink, { color: theme.colors.primary }]}>
Edit Network
</Text>
</TouchableOpacity>
</View>
{!selectedNetwork!.isDefault && (
<View style={styles.signLink}>
<TouchableOpacity
onPress={() => {
setDeleteNetworkDialog(true);
}}>
<Text
variant="titleSmall"
style={[styles.hyperlink, { color: theme.colors.primary }]}>
Delete Network
</Text>
</TouchableOpacity>
</View>
)}
<ConfirmDialog
title="Delete Network"
visible={deleteNetworkDialog}
hideDialog={hideDeleteNetworkDialog}
onConfirm={handleRemove}
/>
<ShowPKDialog />
</View>
</ScrollView>
);
};
export default Accounts;

View File

@ -0,0 +1,27 @@
import React from 'react';
import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography } from '@mui/material';
import { ResetDialogProps } from '../types';
const ConfirmDialog = ({
title,
visible,
hideDialog,
onConfirm,
}: ResetDialogProps) => {
return (
<Dialog open={visible} onClose={hideDialog}>
<DialogTitle>{title}</DialogTitle>
<DialogContent>
<Typography variant="body1">Are you sure?</Typography>
</DialogContent>
<DialogActions>
<Button color="error" onClick={onConfirm}>
Yes
</Button>
<Button onClick={hideDialog}>No</Button>
</DialogActions>
</Dialog>
);
};
export default ConfirmDialog;

View File

@ -0,0 +1,27 @@
import { View } from 'react-native';
import React from 'react';
import { Button } from 'react-native-paper';
import { CreateWalletProps } from '../types';
import styles from '../styles/stylesheet';
const CreateWallet = ({
isWalletCreating,
createWalletHandler,
}: CreateWalletProps) => {
return (
<View>
<View style={styles.createWalletContainer}>
<Button
mode="contained"
loading={isWalletCreating}
disabled={isWalletCreating}
onPress={createWalletHandler}>
{isWalletCreating ? 'Creating' : 'Create Wallet'}
</Button>
</View>
</View>
);
};
export default CreateWallet;

View File

@ -0,0 +1,17 @@
import React from 'react';
import { View, Text } from 'react-native';
import styles from '../styles/stylesheet';
const DataBox = ({ label, data }: { label: string; data: string }) => {
return (
<View style={styles.dataBoxContainer}>
<Text style={styles.dataBoxLabel}>{label}</Text>
<View style={styles.dataBox}>
<Text style={styles.dataBoxData}>{data}</Text>
</View>
</View>
);
};
export default DataBox;

31
src/components/Dialog.tsx Normal file
View File

@ -0,0 +1,31 @@
import React from 'react';
import { Dialog, DialogActions, DialogContent, DialogTitle, Button, Typography } from '@mui/material';
import styles from '../styles/stylesheet';
import GridView from './Grid';
import { CustomDialogProps } from '../types';
const DialogComponent = ({ visible, hideDialog, contentText }: CustomDialogProps) => {
const words = contentText.split(' ');
return (
<Dialog open={visible} onClose={hideDialog}>
<DialogTitle>Mnemonic</DialogTitle>
<DialogContent>
<Typography variant="h6" component="div" style={{ color: 'rgba(0, 0, 0, 0.87)' }}>
Your mnemonic provides full access to your wallet and funds. Make sure to note it down.
</Typography>
<Typography variant="h6" component="div" style={{ ...styles.dialogWarning }}>
Do not share your mnemonic with anyone
</Typography>
<GridView words={words} />
</DialogContent>
<DialogActions>
<Button onClick={hideDialog}>Done</Button>
</DialogActions>
</Dialog>
);
};
export { DialogComponent };

21
src/components/Grid.tsx Normal file
View File

@ -0,0 +1,21 @@
import React from 'react';
import { View } from 'react-native';
import { Text } from 'react-native-paper';
import styles from '../styles/stylesheet';
import { GridViewProps } from '../types';
const GridView = ({ words }: GridViewProps) => {
return (
<View style={styles.gridContainer}>
{words.map((word, index) => (
<View key={index} style={styles.gridItem}>
<Text>{index + 1}. </Text>
<Text variant="bodySmall">{word}</Text>
</View>
))}
</View>
);
};
export default GridView;

100
src/components/HDPath.tsx Normal file
View File

@ -0,0 +1,100 @@
import React, { useState } from 'react';
import { ScrollView, View, Text } from 'react-native';
import { Button, TextInput } from 'react-native-paper';
import { addAccountFromHDPath } from '../utils/accounts';
import { Account, NetworksDataState, PathState } from '../types';
import styles from '../styles/stylesheet';
import { useAccounts } from '../context/AccountsContext';
const HDPath = ({
pathCode,
updateAccounts,
hideDialog,
selectedNetwork,
}: {
pathCode: string;
updateAccounts: (account: Account) => void;
hideDialog: () => void;
selectedNetwork: NetworksDataState;
}) => {
const { setCurrentIndex } = useAccounts();
const [isAccountCreating, setIsAccountCreating] = useState(false);
const [path, setPath] = useState<PathState>({
firstNumber: '',
secondNumber: '',
thirdNumber: '',
});
const handleChange = (key: keyof PathState, value: string) => {
if (key === 'secondNumber' && value !== '' && !['0', '1'].includes(value)) {
return;
}
setPath({
...path,
[key]: value.replace(/[^0-9]/g, ''),
});
};
const createFromHDPathHandler = async () => {
setIsAccountCreating(true);
const hdPath =
pathCode +
`${path.firstNumber}'/${path.secondNumber}/${path.thirdNumber}`;
try {
const newAccount = await addAccountFromHDPath(hdPath, selectedNetwork);
if (newAccount) {
updateAccounts(newAccount);
setCurrentIndex(newAccount.index);
hideDialog();
}
} catch (error) {
console.error('Error creating account:', error);
} finally {
setIsAccountCreating(false);
}
};
return (
<ScrollView style={styles.HDcontainer}>
<View style={styles.HDrowContainer}>
<Text style={styles.HDtext}>{pathCode}</Text>
<TextInput
keyboardType="numeric"
mode="outlined"
onChangeText={text => handleChange('firstNumber', text)}
value={path.firstNumber}
style={styles.HDtextInput}
/>
<Text style={styles.HDtext}>'/</Text>
<TextInput
keyboardType="numeric"
mode="outlined"
onChangeText={text => handleChange('secondNumber', text)}
value={path.secondNumber}
style={styles.HDtextInput}
/>
<Text style={styles.HDtext}>/</Text>
<TextInput
keyboardType="numeric"
mode="outlined"
onChangeText={text => handleChange('thirdNumber', text)}
value={path.thirdNumber}
style={styles.HDtextInput}
/>
</View>
<View style={styles.HDbuttonContainer}>
<Button
mode="contained"
onPress={createFromHDPathHandler}
loading={isAccountCreating}
disabled={isAccountCreating}>
{isAccountCreating ? 'Adding' : 'Add Account'}
</Button>
</View>
</ScrollView>
);
};
export default HDPath;

View File

@ -0,0 +1,31 @@
import React from 'react';
import { Dialog, DialogTitle, DialogContent } from '@mui/material';
import { HDPathDialogProps } from '../types';
import HDPath from './HDPath';
import { useNetworks } from '../context/NetworksContext';
const HDPathDialog = ({
visible,
hideDialog,
updateAccounts,
pathCode,
}: HDPathDialogProps) => {
const { selectedNetwork } = useNetworks();
return (
<Dialog open={visible} onClose={hideDialog}>
<DialogTitle>Add account from HD path</DialogTitle>
<DialogContent>
<HDPath
selectedNetwork={selectedNetwork!}
pathCode={pathCode}
updateAccounts={updateAccounts}
hideDialog={hideDialog}
/>
</DialogContent>
</Dialog>
);
};
export default HDPathDialog;

View File

@ -0,0 +1,38 @@
import React, { useState } from 'react';
import { View } from 'react-native';
import { List } from 'react-native-paper';
import { NetworkDropdownProps, NetworksDataState } from '../types';
import styles from '../styles/stylesheet';
import { useNetworks } from '../context/NetworksContext';
const NetworkDropdown = ({ updateNetwork }: NetworkDropdownProps) => {
const { networksData, selectedNetwork, setSelectedNetwork } = useNetworks();
const [expanded, setExpanded] = useState<boolean>(false);
const handleNetworkPress = (networkData: NetworksDataState) => {
updateNetwork(networkData);
setSelectedNetwork(networkData);
setExpanded(false);
};
return (
<View style={styles.networkDropdown}>
<List.Accordion
title={selectedNetwork!.networkName}
expanded={expanded}
onPress={() => setExpanded(!expanded)}>
{networksData.map(networkData => (
<List.Item
key={networkData.networkId}
title={networkData.networkName}
onPress={() => handleNetworkPress(networkData)}
/>
))}
</List.Accordion>
</View>
);
};
export { NetworkDropdown };

View File

@ -0,0 +1,315 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Image, View, Modal, ScrollView } from 'react-native';
import { Button, Text } from 'react-native-paper';
import { SvgUri } from 'react-native-svg';
import mergeWith from 'lodash/mergeWith';
// import { buildApprovedNamespaces, getSdkError } from '@walletconnect/utils';
import { PairingModalProps } from '../types';
import styles from '../styles/stylesheet';
import { web3wallet } from '../utils/wallet-connect/WalletConnectUtils';
import { useAccounts } from '../context/AccountsContext';
import { useWalletConnect } from '../context/WalletConnectContext';
import { useNetworks } from '../context/NetworksContext';
import { getNamespaces } from '../utils/wallet-connect/helpers';
const PairingModal = ({
visible,
currentProposal,
setCurrentProposal,
setModalVisible,
setToastVisible,
}: PairingModalProps) => {
const { accounts, currentIndex } = useAccounts();
const { selectedNetwork, networksData } = useNetworks();
const [isLoading, setIsLoading] = useState(false);
const [chainError, setChainError] = useState('');
// const dappName = currentProposal?.params?.proposer?.metadata.name;
// const url = currentProposal?.params?.proposer?.metadata.url;
// const icon = currentProposal?.params.proposer?.metadata.icons[0];
const dappName = undefined;
const url = undefined;
const icon = '.svg';
const [walletConnectData, setWalletConnectData] = useState<{
walletConnectMethods: string[];
walletConnectEvents: string[];
walletConnectChains: string[];
}>({
walletConnectMethods: [],
walletConnectEvents: [],
walletConnectChains: [],
});
const [supportedNamespaces, setSupportedNamespaces] = useState<
Record<
string,
{
chains: string[];
methods: string[];
events: string[];
accounts: string[];
}
>
>();
useEffect(() => {
if (!currentProposal) {
return;
}
const { params } = currentProposal;
const { requiredNamespaces, optionalNamespaces } = params;
setWalletConnectData({
walletConnectMethods: [],
walletConnectEvents: [],
walletConnectChains: [],
});
const combinedNamespaces = mergeWith(
requiredNamespaces,
optionalNamespaces,
(obj, src) =>
Array.isArray(obj) && Array.isArray(src) ? [...src, ...obj] : undefined,
);
Object.keys(combinedNamespaces).forEach(key => {
const { methods, events, chains } = combinedNamespaces[key];
setWalletConnectData(prevData => {
return {
walletConnectMethods: [...prevData.walletConnectMethods, ...methods],
walletConnectEvents: [...prevData.walletConnectEvents, ...events],
walletConnectChains: chains
? [...prevData.walletConnectChains, ...chains]
: [...prevData.walletConnectChains],
};
});
});
}, [currentProposal]);
const { setActiveSessions } = useWalletConnect();
useEffect(() => {
const getSupportedNamespaces = async () => {
if (!currentProposal) {
return;
}
// const { optionalNamespaces, requiredNamespaces } = currentProposal.params;
const { optionalNamespaces, requiredNamespaces } = currentProposal;
try {
const nameSpaces = await getNamespaces(
optionalNamespaces,
requiredNamespaces,
networksData,
selectedNetwork!,
accounts,
currentIndex,
);
setSupportedNamespaces(nameSpaces);
} catch (err) {
setChainError((err as Error).message);
// const { id } = currentProposal;
// await web3wallet!.rejectSession({
// id,
// reason: getSdkError('UNSUPPORTED_CHAINS'),
// });
setCurrentProposal(undefined);
setWalletConnectData({
walletConnectMethods: [],
walletConnectEvents: [],
walletConnectChains: [],
});
}
};
getSupportedNamespaces();
}, [
currentProposal,
networksData,
selectedNetwork,
accounts,
currentIndex,
setCurrentProposal,
setModalVisible,
]);
const namespaces = useMemo(() => {
return (
currentProposal &&
supportedNamespaces
// &&
// buildApprovedNamespaces({
// proposal: currentProposal.params,
// supportedNamespaces,
// })
);
}, [currentProposal, supportedNamespaces]);
const handleAccept = async () => {
try {
if (currentProposal && namespaces) {
setIsLoading(true);
const { id } = currentProposal;
await web3wallet!.approveSession({
id,
namespaces,
});
const sessions = web3wallet!.getActiveSessions();
setIsLoading(false);
setActiveSessions(sessions);
setModalVisible(false);
setToastVisible(true);
setCurrentProposal(undefined);
setSupportedNamespaces(undefined);
setWalletConnectData({
walletConnectMethods: [],
walletConnectEvents: [],
walletConnectChains: [],
});
}
} catch (error) {
console.error('Error in approve session:', error);
throw error;
}
};
const handleClose = () => {
setChainError('');
setModalVisible(false);
};
const handleReject = async () => {
if (currentProposal) {
// const { id } = currentProposal;
// await web3wallet!.rejectSession({
// id,
// reason: getSdkError('USER_REJECTED_METHODS'),
// });
setModalVisible(false);
setCurrentProposal(undefined);
setWalletConnectData({
walletConnectMethods: [],
walletConnectEvents: [],
walletConnectChains: [],
});
}
};
return (
<View>
{chainError !== '' ? (
<Modal visible={visible} animationType="slide" transparent>
<View style={styles.modalContentContainer}>
<View style={styles.container}>
{icon && (
<>
{icon.endsWith('.svg') ? (
<View style={styles.dappLogo}>
<SvgUri height="50" width="50" uri={icon} />
</View>
) : (
<Image style={styles.dappLogo} source={{ uri: icon }} />
)}
</>
)}
<Text variant="titleMedium">{dappName}</Text>
<Text variant="bodyMedium">{url}</Text>
<Text variant="titleMedium">{chainError}</Text>
</View>
<View style={styles.flexRow}>
<Button mode="outlined" onPress={handleClose}>
Close
</Button>
</View>
</View>
</Modal>
) : (
<Modal visible={visible} animationType="slide" transparent>
<View style={styles.modalOuterContainer}>
<View style={styles.modalContentContainer}>
<ScrollView showsVerticalScrollIndicator={true}>
<View style={styles.container}>
{icon && (
<>
{icon.endsWith('.svg') ? (
<View style={styles.dappLogo}>
<SvgUri height="50" width="50" uri={icon} />
</View>
) : (
<Image style={styles.dappLogo} source={{ uri: icon }} />
)}
</>
)}
<Text variant="titleMedium">{dappName}</Text>
<Text variant="bodyMedium">{url}</Text>
<View style={styles.marginVertical8} />
<Text variant="titleMedium">Connect to this site?</Text>
{walletConnectData.walletConnectMethods.length > 0 && (
<View>
<Text variant="titleMedium">Chains:</Text>
{walletConnectData.walletConnectChains.map(chain => (
<Text style={styles.centerText} key={chain}>
{chain}
</Text>
))}
</View>
)}
{walletConnectData.walletConnectMethods.length > 0 && (
<View style={styles.marginVertical8}>
<Text variant="titleMedium">Methods Requested:</Text>
{walletConnectData.walletConnectMethods.map(method => (
<Text style={styles.centerText} key={method}>
{method}
</Text>
))}
</View>
)}
{walletConnectData.walletConnectEvents.length > 0 && (
<View style={styles.marginVertical8}>
<Text variant="titleMedium">Events Requested:</Text>
{walletConnectData.walletConnectEvents.map(event => (
<Text style={styles.centerText} key={event}>
{event}
</Text>
))}
</View>
)}
</View>
</ScrollView>
<View style={styles.flexRow}>
<Button
mode="contained"
onPress={handleAccept}
loading={isLoading}
disabled={isLoading}>
{isLoading ? 'Connecting' : 'Yes'}
</Button>
<View style={styles.space} />
<Button mode="outlined" onPress={handleReject}>
No
</Button>
</View>
</View>
</View>
</Modal>
)}
</View>
);
};
export default PairingModal;

View File

@ -0,0 +1,43 @@
import React, { useState } from 'react';
import { View } from 'react-native';
import { Text, List } from 'react-native-paper';
import styles from '../styles/stylesheet';
import { COSMOS, EIP155 } from '../utils/constants';
const SelectNetworkType = ({
updateNetworkType,
}: {
updateNetworkType: (networkType: string) => void;
}) => {
const [expanded, setExpanded] = useState<boolean>(false);
const [selectedNetwork, setSelectedNetwork] = useState<string>('ETH');
const networks = ['ETH', 'COSMOS'];
const handleNetworkPress = (network: string) => {
setSelectedNetwork(network);
updateNetworkType(network === 'ETH' ? EIP155 : COSMOS);
setExpanded(false);
};
return (
<View style={styles.networkDropdown}>
<Text style={styles.selectNetworkText}>Select Network Type</Text>
<List.Accordion
title={selectedNetwork}
expanded={expanded}
onPress={() => setExpanded(!expanded)}>
{networks.map(network => (
<List.Item
key={network}
title={network}
onPress={() => handleNetworkPress(network)}
/>
))}
</List.Accordion>
</View>
);
};
export { SelectNetworkType };

View File

@ -0,0 +1,101 @@
import React, { useState } from 'react';
import { TouchableOpacity, View } from 'react-native';
import { Button, Typography } from '@mui/material';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import styles from '../styles/stylesheet';
import { getPathKey } from '../utils/misc';
import { useNetworks } from '../context/NetworksContext';
import { useAccounts } from '../context/AccountsContext';
import { Text, useTheme } from 'react-native-paper';
const ShowPKDialog = () => {
const { currentIndex } = useAccounts();
const { selectedNetwork } = useNetworks();
const [privateKey, setPrivateKey] = useState<string>();
const [showPKDialog, setShowPKDialog] = useState<boolean>(false);
const theme = useTheme();
const handleShowPrivateKey = async () => {
const pathKey = await getPathKey(
`${selectedNetwork!.namespace}:${selectedNetwork!.chainId}`,
currentIndex,
);
setPrivateKey(pathKey.privKey);
};
const hideShowPKDialog = () => {
setShowPKDialog(false);
setPrivateKey(undefined);
};
return (
<>
<View style={styles.signLink}>
<TouchableOpacity
onPress={() => {
setShowPKDialog(true);
}}>
<Text
variant="titleSmall"
style={[styles.hyperlink, { color: theme.colors.primary }]}>
Show Private Key
</Text>
</TouchableOpacity>
</View>
<View>
<Dialog open={showPKDialog} onClose={hideShowPKDialog}>
<DialogTitle>
{!privateKey ? (
<Typography>Show Private Key?</Typography>
) : (
<Typography>Private Key</Typography>
)}
</DialogTitle>
<DialogContent>
{privateKey && (
<View style={[styles.dataBox, styles.dataBoxContainer]}>
<Typography
component="pre"
variant="body1"
style={styles.dataBoxData}
>
{privateKey}
</Typography>
</View>
)}
<View>
<Typography variant="body1" style={styles.dialogWarning}>
<Typography component="span">
Warning:
</Typography>
Never disclose this key. Anyone with your private keys can
steal any assets held in your account.
</Typography>
</View>
</DialogContent>
<DialogActions>
{!privateKey ? (
<>
<Button onClick={handleShowPrivateKey} color="error">
Yes
</Button>
<Button onClick={hideShowPKDialog}>No</Button>
</>
) : (
<Button onClick={hideShowPKDialog}>Ok</Button>
)}
</DialogActions>
</Dialog>
</View>
</>
);
};
export default ShowPKDialog;

View File

@ -0,0 +1,28 @@
import React from 'react';
import { Button, Dialog, Portal, Text } from 'react-native-paper';
const TxErrorDialog = ({
error,
visible,
hideDialog,
}: {
error: string;
visible: boolean;
hideDialog: () => void;
}) => {
return (
<Portal>
<Dialog visible={visible} onDismiss={hideDialog}>
<Dialog.Title>Transaction Error</Dialog.Title>
<Dialog.Content>
<Text variant="bodyMedium">{error}</Text>
</Dialog.Content>
<Dialog.Actions>
<Button onPress={hideDialog}>OK</Button>
</Dialog.Actions>
</Dialog>
</Portal>
);
};
export default TxErrorDialog;

View File

@ -0,0 +1,39 @@
import React, { createContext, useContext, useState } from 'react';
import { Account } from '../types';
const AccountsContext = createContext<{
accounts: Account[];
setAccounts: (account: Account[]) => void;
currentIndex: number;
setCurrentIndex: (index: number) => void;
}>({
accounts: [],
setAccounts: () => {},
currentIndex: 0,
setCurrentIndex: () => {},
});
const useAccounts = () => {
const accountsContext = useContext(AccountsContext);
return accountsContext;
};
const AccountsProvider = ({ children }: { children: any }) => {
const [accounts, setAccounts] = useState<Account[]>([]);
const [currentIndex, setCurrentIndex] = useState<number>(0);
return (
<AccountsContext.Provider
value={{
accounts,
setAccounts,
currentIndex,
setCurrentIndex,
}}>
{children}
</AccountsContext.Provider>
);
};
export { useAccounts, AccountsProvider };

View File

@ -0,0 +1,76 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { NetworksDataState } from '../types';
import { retrieveNetworksData, storeNetworkData } from '../utils/accounts';
import { DEFAULT_NETWORKS, EIP155 } from '../utils/constants';
const NetworksContext = createContext<{
networksData: NetworksDataState[];
setNetworksData: React.Dispatch<React.SetStateAction<NetworksDataState[]>>;
networkType: string;
setNetworkType: (networkType: string) => void;
selectedNetwork?: NetworksDataState;
setSelectedNetwork: React.Dispatch<
React.SetStateAction<NetworksDataState | undefined>
>;
}>({
networksData: [],
setNetworksData: () => {},
networkType: '',
setNetworkType: () => {},
selectedNetwork: {} as NetworksDataState,
setSelectedNetwork: () => {},
});
const useNetworks = () => {
const networksContext = useContext(NetworksContext);
return networksContext;
};
const NetworksProvider = ({ children }: { children: any }) => {
const [networksData, setNetworksData] = useState<NetworksDataState[]>([]);
const [networkType, setNetworkType] = useState<string>(EIP155);
const [selectedNetwork, setSelectedNetwork] = useState<NetworksDataState>();
useEffect(() => {
const fetchData = async () => {
const retrievedNetworks = await retrieveNetworksData();
if (retrievedNetworks.length === 0) {
for (const defaultNetwork of DEFAULT_NETWORKS) {
await storeNetworkData(defaultNetwork);
}
}
const retrievedNewNetworks = await retrieveNetworksData();
setNetworksData(retrievedNewNetworks);
setSelectedNetwork(retrievedNewNetworks[0]);
};
if (networksData.length === 0) {
fetchData();
}
}, [networksData]);
useEffect(() => {
setSelectedNetwork(prevSelectedNetwork => {
return networksData.find(
networkData => networkData.networkId === prevSelectedNetwork?.networkId,
);
});
}, [networksData]);
return (
<NetworksContext.Provider
value={{
networksData,
setNetworksData,
networkType,
setNetworkType,
selectedNetwork,
setSelectedNetwork,
}}>
{children}
</NetworksContext.Provider>
);
};
export { useNetworks, NetworksProvider };

View File

@ -0,0 +1,43 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
// import { SessionTypes } from '@walletconnect/types';
import { WalletConnectContextProps } from '../types';
import { web3wallet } from '../utils/wallet-connect/WalletConnectUtils';
import useInitialization from '../hooks/useInitialization';
const WalletConnectContext = createContext<WalletConnectContextProps>({
activeSessions: {},
setActiveSessions: () => {},
});
const useWalletConnect = () => {
const walletConnectContext = useContext(WalletConnectContext);
return walletConnectContext;
};
const WalletConnectProvider = ({ children }: { children: React.ReactNode }) => {
useInitialization();
useEffect(() => {
const sessions = (web3wallet && web3wallet.getActiveSessions()) || {};
setActiveSessions(sessions);
}, []);
const [activeSessions, setActiveSessions] = useState<
// Record<string, SessionTypes.Struct>
Record<string, any>
>({});
return (
<WalletConnectContext.Provider
value={{
activeSessions,
setActiveSessions,
}}>
{children}
</WalletConnectContext.Provider>
);
};
export { useWalletConnect, WalletConnectProvider };

View File

@ -0,0 +1,23 @@
import { useCallback, useEffect, useState } from 'react';
import { createWeb3Wallet } from '../utils/wallet-connect/WalletConnectUtils';
export default function useInitialization() {
const [initialized, setInitialized] = useState(false);
const onInitialize = useCallback(async () => {
try {
await createWeb3Wallet();
setInitialized(true);
} catch (err: unknown) {
console.error('Error for initializing', err);
}
}, []);
useEffect(() => {
if (!initialized) {
onInitialize();
}
}, [initialized, onInitialize]);
return initialized;
}

10
src/hooks/usePrevious.ts Normal file
View File

@ -0,0 +1,10 @@
import { useEffect, useRef } from 'react';
export function usePrevious<T>(value: T): T | undefined {
const ref = useRef(value);
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}

13
src/index.css Normal file
View File

@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

81
src/index.tsx Normal file
View File

@ -0,0 +1,81 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { PaperProvider, MD3LightTheme as DefaultTheme, } from 'react-native-paper';
import { NavigationContainer } from '@react-navigation/native';
import { Platform } from 'react-native';
import './index.css';
import App from './App';
import { AccountsProvider } from './context/AccountsContext';
import { NetworksProvider } from './context/NetworksContext';
import reportWebVitals from './reportWebVitals';
import { WalletConnectProvider } from './context/WalletConnectContext';
// // Generate the required CSS
// import iconFont from 'react-native-vector-icons/Fonts/FontAwesome.ttf';
// const iconFontStyles = `@font-face {
// src: url(${iconFont});
// font-family: FontAwesome;
// }`;
// // Create a stylesheet
// const style = document.createElement('style');
// style.type = 'text/css';
// // Append the iconFontStyles to the stylesheet
// if (style.styleSheet) {
// style.styleSheet.cssText = iconFontStyles;
// } else {
// style.appendChild(document.createTextNode(iconFontStyles));
// }
// // Inject the stylesheet into the document head
// document.head.appendChild(style);
const linking = {
prefixes: ['https://wallet.laconic.com'],
config: {
screens: {
SignRequest: {
path: 'sign/:namespace/:chaindId/:address/:message',
},
},
},
};
const theme = {
...DefaultTheme,
dark: false,
};
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<PaperProvider theme={theme}>
<NetworksProvider>
<AccountsProvider>
<WalletConnectProvider>
<NavigationContainer linking={linking}>
<React.Fragment>
{Platform.OS === 'web' ? (
<style type="text/css">{`
@font-face {
font-family: 'MaterialCommunityIcons';
src: url(${require('react-native-vector-icons/Fonts/MaterialCommunityIcons.ttf')}) format('truetype');
}
`}</style>
) : null}
<App />
</React.Fragment>
</NavigationContainer>
</WalletConnectProvider>
</AccountsProvider>
</NetworksProvider>
</PaperProvider>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

1
src/logo.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

1
src/react-app-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="react-scripts" />

15
src/reportWebVitals.ts Normal file
View File

@ -0,0 +1,15 @@
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

418
src/screens/AddNetwork.tsx Normal file
View File

@ -0,0 +1,418 @@
import React, { useCallback, useEffect, useState } from 'react';
import { View } from 'react-native';
import { useForm, Controller, useWatch, FieldErrors } from 'react-hook-form';
import { TextInput, Button, HelperText } from 'react-native-paper';
import { HDNode } from 'ethers/lib/utils';
import { chains } from 'chain-registry';
import { useDebouncedCallback } from 'use-debounce';
import { z } from 'zod';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useNavigation } from '@react-navigation/native';
import { zodResolver } from '@hookform/resolvers/zod';
import { StackParamsList } from '../types';
import { SelectNetworkType } from '../components/SelectNetworkType';
import { storeNetworkData } from '../utils/accounts';
import { useNetworks } from '../context/NetworksContext';
import {
COSMOS,
EIP155,
CHAINID_DEBOUNCE_DELAY,
EMPTY_FIELD_ERROR,
INVALID_URL_ERROR,
IS_NUMBER_REGEX,
} from '../utils/constants';
import { getCosmosAccounts } from '../utils/accounts';
import ETH_CHAINS from '../assets/ethereum-chains.json';
import {
getInternetCredentials,
setInternetCredentials,
} from '../utils/key-store';
const ethNetworkDataSchema = z.object({
chainId: z.string().nonempty({ message: EMPTY_FIELD_ERROR }),
networkName: z.string().nonempty({ message: EMPTY_FIELD_ERROR }),
rpcUrl: z.string().url({ message: INVALID_URL_ERROR }),
blockExplorerUrl: z
.string()
.url({ message: INVALID_URL_ERROR })
.or(z.literal('')),
coinType: z
.string()
.nonempty({ message: EMPTY_FIELD_ERROR })
.regex(IS_NUMBER_REGEX),
currencySymbol: z.string().nonempty({ message: EMPTY_FIELD_ERROR }),
});
const cosmosNetworkDataSchema = z.object({
chainId: z.string().nonempty({ message: EMPTY_FIELD_ERROR }),
networkName: z.string().nonempty({ message: EMPTY_FIELD_ERROR }),
rpcUrl: z.string().url({ message: INVALID_URL_ERROR }),
blockExplorerUrl: z
.string()
.url({ message: INVALID_URL_ERROR })
.or(z.literal('')),
coinType: z
.string()
.nonempty({ message: EMPTY_FIELD_ERROR })
.regex(IS_NUMBER_REGEX),
nativeDenom: z.string().nonempty({ message: EMPTY_FIELD_ERROR }),
addressPrefix: z.string().nonempty({ message: EMPTY_FIELD_ERROR }),
gasPrice: z
.string()
.nonempty({ message: EMPTY_FIELD_ERROR })
.regex(/^\d+(\.\d+)?$/),
});
const AddNetwork = () => {
const navigation =
useNavigation<NativeStackNavigationProp<StackParamsList>>();
const { setNetworksData } = useNetworks();
const [namespace, setNamespace] = useState<string>(EIP155);
const networksFormDataSchema =
namespace === EIP155 ? ethNetworkDataSchema : cosmosNetworkDataSchema;
const {
control,
formState: { errors, isSubmitting },
handleSubmit,
setValue,
reset,
} = useForm<z.infer<typeof networksFormDataSchema>>({
mode: 'onChange',
resolver: zodResolver(networksFormDataSchema),
});
const watchChainId = useWatch({
control,
name: 'chainId',
});
const updateNetworkType = (newNetworkType: string) => {
setNamespace(newNetworkType);
};
const fetchChainDetails = useDebouncedCallback((chainId: string) => {
if (namespace === EIP155) {
const ethChainDetails = ETH_CHAINS.find(
chain => chain.chainId === Number(chainId),
);
if (!ethChainDetails) {
return;
}
setValue('networkName', ethChainDetails.name);
setValue('rpcUrl', ethChainDetails.rpc[0]);
setValue('blockExplorerUrl', ethChainDetails.explorers?.[0].url || '');
setValue('coinType', String(ethChainDetails.slip44 ?? '60'));
setValue('currencySymbol', ethChainDetails.nativeCurrency.symbol);
return;
}
const cosmosChainDetails = chains.find(
({ chain_id }) => chain_id === chainId,
);
if (!cosmosChainDetails) {
return;
}
setValue('networkName', cosmosChainDetails.pretty_name);
setValue('rpcUrl', cosmosChainDetails.apis?.rpc?.[0]?.address || '');
setValue('blockExplorerUrl', cosmosChainDetails.explorers?.[0].url || '');
setValue('addressPrefix', cosmosChainDetails.bech32_prefix);
setValue('coinType', String(cosmosChainDetails.slip44 ?? '118'));
setValue('nativeDenom', cosmosChainDetails.fees?.fee_tokens[0].denom || '');
setValue(
'gasPrice',
String(
cosmosChainDetails.fees?.fee_tokens[0].average_gas_price ||
String(process.env.DEFAULT_GAS_PRICE),
),
);
}, CHAINID_DEBOUNCE_DELAY);
const submit = useCallback(
async (data: z.infer<typeof networksFormDataSchema>) => {
const newNetworkData = {
...data,
namespace,
isDefault: false,
};
const mnemonicServer = await getInternetCredentials('mnemonicServer');
const mnemonic = mnemonicServer;
if (!mnemonic) {
throw new Error('Mnemonic not found');
}
const hdNode = HDNode.fromMnemonic(mnemonic);
const hdPath = `m/44'/${newNetworkData.coinType}'/0'/0/0`;
const node = hdNode.derivePath(hdPath);
let address;
switch (newNetworkData.namespace) {
case EIP155:
address = node.address;
break;
case COSMOS:
address = (
await getCosmosAccounts(
mnemonic,
hdPath,
(newNetworkData as z.infer<typeof cosmosNetworkDataSchema>)
.addressPrefix,
)
).data.address;
break;
default:
throw new Error('Unsupported namespace');
}
const accountInfo = `${hdPath},${node.privateKey},${node.publicKey},${address}`;
const retrievedNetworksData = await storeNetworkData(newNetworkData);
setNetworksData(retrievedNetworksData);
await Promise.all([
setInternetCredentials(
`accounts/${newNetworkData.namespace}:${newNetworkData.chainId}/0`,
'_',
accountInfo,
),
setInternetCredentials(
`addAccountCounter/${newNetworkData.namespace}:${newNetworkData.chainId}`,
'_',
'1',
),
setInternetCredentials(
`accountIndices/${newNetworkData.namespace}:${newNetworkData.chainId}`,
'_',
'0',
),
]);
navigation.navigate('Laconic');
},
[navigation, namespace, setNetworksData],
);
useEffect(() => {
fetchChainDetails(watchChainId);
}, [watchChainId, fetchChainDetails]);
useEffect(() => {
reset();
}, [namespace, reset]);
return (
<View>
<SelectNetworkType updateNetworkType={updateNetworkType} />
<Controller
control={control}
name="chainId"
defaultValue=""
render={({ field: { onChange, onBlur, value } }) => (
<>
<TextInput
mode="outlined"
value={value}
label="Chain ID"
onBlur={onBlur}
onChangeText={textValue => onChange(textValue)}
/>
<HelperText type="error">{errors.chainId?.message}</HelperText>
</>
)}
/>
<Controller
control={control}
defaultValue=""
name="networkName"
render={({ field: { onChange, onBlur, value } }) => (
<>
<TextInput
mode="outlined"
label="Network Name"
value={value}
onBlur={onBlur}
onChangeText={textValue => onChange(textValue)}
/>
<HelperText type="error">{errors.networkName?.message}</HelperText>
</>
)}
/>
<Controller
control={control}
name="rpcUrl"
defaultValue=""
render={({ field: { onChange, onBlur, value } }) => (
<>
<TextInput
mode="outlined"
label="New RPC URL"
onBlur={onBlur}
value={value}
onChangeText={textValue => onChange(textValue)}
/>
<HelperText type="error">{errors.rpcUrl?.message}</HelperText>
</>
)}
/>
<Controller
control={control}
defaultValue=""
name="blockExplorerUrl"
render={({ field: { onChange, onBlur, value } }) => (
<>
<TextInput
mode="outlined"
value={value}
label="Block Explorer URL (Optional)"
onBlur={onBlur}
onChangeText={textValue => onChange(textValue)}
/>
<HelperText type="error">
{errors.blockExplorerUrl?.message}
</HelperText>
</>
)}
/>
<Controller
control={control}
name="coinType"
defaultValue=""
render={({ field: { onChange, onBlur, value } }) => (
<>
<TextInput
mode="outlined"
value={value}
label="Coin Type"
onBlur={onBlur}
onChangeText={onChange}
/>
<HelperText type="error">{errors.coinType?.message}</HelperText>
</>
)}
/>
{namespace === EIP155 ? (
<Controller
control={control}
name="currencySymbol"
defaultValue=""
render={({ field: { onChange, onBlur, value } }) => (
<>
<TextInput
mode="outlined"
value={value}
label="Currency Symbol"
onBlur={onBlur}
onChangeText={textValue => onChange(textValue)}
/>
<HelperText type="error">
{
(errors as FieldErrors<z.infer<typeof ethNetworkDataSchema>>)
.currencySymbol?.message
}
</HelperText>
</>
)}
/>
) : (
<>
<Controller
control={control}
name="nativeDenom"
defaultValue=""
render={({ field: { onChange, onBlur, value } }) => (
<>
<TextInput
mode="outlined"
value={value}
label="Native Denom"
onBlur={onBlur}
onChangeText={textValue => onChange(textValue)}
/>
<HelperText type="error">
{
(
errors as FieldErrors<
z.infer<typeof cosmosNetworkDataSchema>
>
).nativeDenom?.message
}
</HelperText>
</>
)}
/>
<Controller
control={control}
name="addressPrefix"
defaultValue=""
render={({ field: { onChange, onBlur, value } }) => (
<>
<TextInput
mode="outlined"
value={value}
label="Address Prefix"
onBlur={onBlur}
onChangeText={textValue => onChange(textValue)}
/>
<HelperText type="error">
{
(
errors as FieldErrors<
z.infer<typeof cosmosNetworkDataSchema>
>
).addressPrefix?.message
}
</HelperText>
</>
)}
/>
<Controller
control={control}
name="gasPrice"
defaultValue=""
render={({ field: { onChange, onBlur, value } }) => (
<>
<TextInput
mode="outlined"
value={value}
label="Gas Price"
onBlur={onBlur}
onChangeText={onChange}
/>
<HelperText type="error">
{
(
errors as FieldErrors<
z.infer<typeof cosmosNetworkDataSchema>
>
).gasPrice?.message
}
</HelperText>
</>
)}
/>
</>
)}
<Button
mode="contained"
loading={isSubmitting}
disabled={isSubmitting}
onPress={handleSubmit(submit)}>
{isSubmitting ? 'Adding' : 'Submit'}
</Button>
</View>
);
};
export default AddNetwork;

120
src/screens/AddSession.tsx Normal file
View File

@ -0,0 +1,120 @@
import React, { useEffect, useState } from 'react';
import { AppState, TouchableOpacity, View } from 'react-native';
import { Button, Text, TextInput } from 'react-native-paper';
// import {
// Camera,
// useCameraDevice,
// useCameraPermission,
// useCodeScanner,
// } from 'react-native-vision-camera';
import { Linking } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { web3WalletPair } from '../utils/wallet-connect/WalletConnectUtils';
import styles from '../styles/stylesheet';
import { StackParamsList } from '../types';
const AddSession = () => {
const navigation =
useNavigation<NativeStackNavigationProp<StackParamsList>>();
// const { hasPermission, requestPermission } = useCameraPermission();
// const device = useCameraDevice('back');
const [currentWCURI, setCurrentWCURI] = useState<string>('');
// const [isActive, setIsActive] = useState(AppState.currentState === 'active');
// const [isScanning, setScanning] = useState(true);
// const codeScanner = useCodeScanner({
// codeTypes: ['qr'],
// onCodeScanned: codes => {
// if (isScanning) {
// codes.forEach(code => {
// if (code.value) {
// setCurrentWCURI(code.value);
// setScanning(false);
// }
// });
// }
// },
// });
const linkToSettings = async () => {
await Linking.openSettings();
};
const pair = async () => {
const pairing = await web3WalletPair({ uri: currentWCURI });
navigation.navigate('WalletConnect');
return pairing;
};
// useEffect(() => {
// const handleAppStateChange = (newState: string) => {
// setIsActive(newState === 'active');
// };
// AppState.addEventListener('change', handleAppStateChange);
// if (!hasPermission) {
// requestPermission();
// }
// }, [hasPermission, requestPermission]);
return (
<View style={styles.appContainer}>
{/* {!hasPermission || !device ? ( */}
{false ? (
<>
{/* <Text>
{!hasPermission
? 'No Camera Permission granted'
: 'No Camera Selected'}
</Text> */}
<TouchableOpacity onPress={linkToSettings}>
<Text variant="titleSmall" style={[styles.hyperlink]}>
Go to settings
</Text>
</TouchableOpacity>
</>
) : (
<>
<View style={styles.cameraContainer}>
{/* {isActive ? (
<Camera
style={styles.camera}
device={device}
isActive={isActive}
codeScanner={codeScanner}
video={false}
/>
) : (
<Text>No Camera Selected!</Text>
)} */}
</View>
<View style={styles.inputContainer}>
<Text variant="titleMedium">Enter WalletConnect URI</Text>
<TextInput
mode="outlined"
onChangeText={setCurrentWCURI}
value={currentWCURI}
numberOfLines={4}
multiline={true}
style={styles.walletConnectUriText}
/>
<View style={styles.signButton}>
<Button mode="contained" onPress={pair}>
Pair Session
</Button>
</View>
</View>
</>
)}
</View>
);
};
export default AddSession;

View File

@ -0,0 +1,300 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Image, ScrollView, View } from 'react-native';
import { Button, Text, TextInput } from 'react-native-paper';
import { SvgUri } from 'react-native-svg';
import Config from 'react-native-config';
import {
NativeStackNavigationProp,
NativeStackScreenProps,
} from '@react-navigation/native-stack';
import { useNavigation } from '@react-navigation/native';
import { DirectSecp256k1Wallet } from '@cosmjs/proto-signing';
import { LaconicClient } from '@cerc-io/registry-sdk';
import { GasPrice, calculateFee } from '@cosmjs/stargate';
import { formatJsonRpcError } from '@json-rpc-tools/utils';
import { useNetworks } from '../context/NetworksContext';
import { Account, StackParamsList } from '../types';
import styles from '../styles/stylesheet';
import { COSMOS, IS_NUMBER_REGEX } from '../utils/constants';
import { retrieveSingleAccount } from '../utils/accounts';
import { getPathKey } from '../utils/misc';
import {
WalletConnectRequests,
approveWalletConnectRequest,
rejectWalletConnectRequest,
} from '../utils/wallet-connect/wallet-connect-requests';
import { web3wallet } from '../utils/wallet-connect/WalletConnectUtils';
import { MEMO } from './ApproveTransfer';
import TxErrorDialog from '../components/TxErrorDialog';
import AccountDetails from '../components/AccountDetails';
type ApproveTransactionProps = NativeStackScreenProps<
StackParamsList,
'ApproveTransaction'
>;
const ApproveTransaction = ({ route }: ApproveTransactionProps) => {
const { networksData } = useNetworks();
const requestSession = route.params.requestSessionData;
const requestName = requestSession.peer.metadata.name;
const requestIcon = requestSession.peer.metadata.icons[0];
const requestURL = requestSession.peer.metadata.url;
const transactionMessage = route.params.transactionMessage;
const signer = route.params.signer;
const requestEvent = route.params.requestEvent;
const chainId = requestEvent.params.chainId;
const requestEventId = requestEvent.id;
const topic = requestEvent.topic;
const [account, setAccount] = useState<Account>();
const [cosmosStargateClient, setCosmosStargateClient] =
useState<LaconicClient>();
const [cosmosGasLimit, setCosmosGasLimit] = useState<string>();
const [fees, setFees] = useState<string>();
const [txError, setTxError] = useState<string>();
const [isTxErrorDialogOpen, setIsTxErrorDialogOpen] = useState(false);
const [isRequestAccepted, setIsRequestAccepted] = useState(false);
const navigation =
useNavigation<NativeStackNavigationProp<StackParamsList>>();
const requestedNetwork = networksData.find(
networkData =>
`${networkData.namespace}:${networkData.chainId}` === chainId,
);
const namespace = requestedNetwork!.namespace;
useEffect(() => {
if (namespace !== COSMOS) {
return;
}
const setClient = async () => {
if (!account) {
return;
}
const cosmosPrivKey = (
await getPathKey(
`${requestedNetwork?.namespace}:${requestedNetwork?.chainId}`,
account.index,
)
).privKey;
const sender = await DirectSecp256k1Wallet.fromKey(
Buffer.from(cosmosPrivKey.split('0x')[1], 'hex'),
requestedNetwork?.addressPrefix,
);
try {
const client = await LaconicClient.connectWithSigner(
requestedNetwork?.rpcUrl!,
sender,
);
setCosmosStargateClient(client);
} catch (error: any) {
setTxError(error.message);
setIsTxErrorDialogOpen(true);
const response = formatJsonRpcError(requestEventId, error.message);
await web3wallet!.respondSessionRequest({ topic, response });
}
};
setClient();
}, [account, requestedNetwork, chainId, namespace, requestEventId, topic]);
const retrieveData = useCallback(
async (requestAddress: string) => {
const requestAccount = await retrieveSingleAccount(
requestedNetwork!.namespace,
requestedNetwork!.chainId,
requestAddress,
);
if (!requestAccount) {
navigation.navigate('InvalidPath');
return;
}
setAccount(requestAccount);
},
[navigation, requestedNetwork],
);
useEffect(() => {
retrieveData(signer);
}, [retrieveData, signer]);
useEffect(() => {
const getCosmosGas = async () => {
try {
if (!cosmosStargateClient) {
return;
}
const gasEstimation = await cosmosStargateClient!.simulate(
transactionMessage.value.participant!,
[transactionMessage],
MEMO,
);
setCosmosGasLimit(
String(
Math.round(gasEstimation * Number(Config.DEFAULT_GAS_ADJUSTMENT)),
),
);
} catch (error: any) {
setTxError(error.message);
setIsTxErrorDialogOpen(true);
const response = formatJsonRpcError(requestEventId, error.message);
await web3wallet!.respondSessionRequest({ topic, response });
}
};
getCosmosGas();
}, [cosmosStargateClient, transactionMessage, requestEventId, topic]);
useEffect(() => {
const gasPrice = GasPrice.fromString(
requestedNetwork?.gasPrice! + requestedNetwork?.nativeDenom,
);
if (!cosmosGasLimit) {
return;
}
const cosmosFees = calculateFee(Number(cosmosGasLimit), gasPrice);
setFees(cosmosFees.amount[0].amount);
}, [namespace, cosmosGasLimit, requestedNetwork]);
const acceptRequestHandler = async () => {
try {
setIsRequestAccepted(true);
if (!account) {
throw new Error('account not found');
}
let options: WalletConnectRequests;
if (!cosmosStargateClient) {
throw new Error('Cosmos stargate client not found');
}
options = {
type: 'cosmos_sendTransaction',
LaconicClient: cosmosStargateClient,
// StdFee object
cosmosFee: {
// This amount is total fees required for transaction
amount: [
{
amount: fees!,
denom: requestedNetwork!.nativeDenom!,
},
],
gas: cosmosGasLimit!,
},
txMsg: transactionMessage,
};
const response = await approveWalletConnectRequest(
requestEvent,
account,
namespace,
requestedNetwork!.chainId,
options,
);
await web3wallet!.respondSessionRequest({ topic, response });
setIsRequestAccepted(false);
navigation.navigate('Laconic');
} catch (error: any) {
setTxError(error.message);
setIsTxErrorDialogOpen(true);
const response = formatJsonRpcError(requestEventId, error.message);
await web3wallet!.respondSessionRequest({ topic, response });
}
};
const rejectRequestHandler = async () => {
const response = rejectWalletConnectRequest(requestEvent);
await web3wallet!.respondSessionRequest({
topic,
response,
});
navigation.navigate('Laconic');
};
return (
<>
<ScrollView contentContainerStyle={styles.approveTransaction}>
<View style={styles.dappDetails}>
{requestIcon && (
<>
{requestIcon.endsWith('.svg') ? (
<View style={styles.dappLogo}>
<SvgUri height="50" width="50" uri={requestIcon} />
</View>
) : (
<Image style={styles.dappLogo} source={{ uri: requestIcon }} />
)}
</>
)}
<Text>{requestName}</Text>
<Text variant="bodySmall">{requestURL}</Text>
</View>
<AccountDetails account={account} />
<Text variant="bodyLarge" style={styles.transactionLabel}>
Message:
</Text>
<View style={styles.messageBody}>
<Text variant="bodyLarge">
{JSON.stringify(transactionMessage, null, 2)}
</Text>
</View>
<>
<Text variant="bodyLarge" style={styles.transactionLabel}>
Gas Limit:
</Text>
<TextInput
mode="outlined"
style={styles.transactionFeesInput}
value={cosmosGasLimit}
onChangeText={value => {
if (IS_NUMBER_REGEX.test(value)) {
setCosmosGasLimit(value);
}
}}
/>
<View style={styles.buttonContainer}>
<Button
mode="contained"
onPress={acceptRequestHandler}
loading={isRequestAccepted}
disabled={isRequestAccepted}>
Yes
</Button>
<Button
mode="contained"
onPress={rejectRequestHandler}
buttonColor="#B82B0D">
No
</Button>
</View>
</>
</ScrollView>
<TxErrorDialog
error={txError!}
visible={isTxErrorDialogOpen}
hideDialog={async () => {
setIsTxErrorDialogOpen(false);
navigation.navigate('Laconic');
}}
/>
</>
);
};
export default ApproveTransaction;

View File

@ -0,0 +1,648 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Image, ScrollView, View } from 'react-native';
import {
ActivityIndicator,
Button,
Text,
Appbar,
TextInput,
} from 'react-native-paper';
import { providers, BigNumber } from 'ethers';
import Config from 'react-native-config';
import { Deferrable } from 'ethers/lib/utils';
import { useNavigation } from '@react-navigation/native';
import {
NativeStackNavigationProp,
NativeStackScreenProps,
} from '@react-navigation/native-stack';
import { getHeaderTitle } from '@react-navigation/elements';
import { DirectSecp256k1Wallet } from '@cosmjs/proto-signing';
import {
calculateFee,
GasPrice,
MsgSendEncodeObject,
SigningStargateClient,
} from '@cosmjs/stargate';
import { Account, StackParamsList } from '../types';
import AccountDetails from '../components/AccountDetails';
import styles from '../styles/stylesheet';
import { retrieveSingleAccount } from '../utils/accounts';
import {
approveWalletConnectRequest,
rejectWalletConnectRequest,
WalletConnectRequests,
} from '../utils/wallet-connect/wallet-connect-requests';
import { web3wallet } from '../utils/wallet-connect/WalletConnectUtils';
import DataBox from '../components/DataBox';
import { getPathKey } from '../utils/misc';
import { useNetworks } from '../context/NetworksContext';
import { COSMOS, EIP155, IS_NUMBER_REGEX } from '../utils/constants';
import TxErrorDialog from '../components/TxErrorDialog';
import { EIP155_SIGNING_METHODS } from '../utils/wallet-connect/EIP155Data';
import { COSMOS_METHODS } from '../utils/wallet-connect/COSMOSData';
export const MEMO = 'Sending signed tx from Laconic Wallet';
// Reference: https://ethereum.org/en/developers/docs/gas/#what-is-gas-limit
const ETH_MINIMUM_GAS = 21000;
type SignRequestProps = NativeStackScreenProps<
StackParamsList,
'ApproveTransfer'
>;
const ApproveTransfer = ({ route }: SignRequestProps) => {
const { networksData } = useNetworks();
const requestSession = route.params.requestSessionData;
const requestName = requestSession.peer.metadata.name;
const requestIcon = requestSession.peer.metadata.icons[0];
const requestURL = requestSession.peer.metadata.url;
const transaction = route.params.transaction;
const requestEvent = route.params.requestEvent;
const chainId = requestEvent.params.chainId;
const requestMethod = requestEvent.params.request.method;
const [account, setAccount] = useState<Account>();
const [isLoading, setIsLoading] = useState(true);
const [balance, setBalance] = useState<string>('');
const [isTxLoading, setIsTxLoading] = useState(false);
const [cosmosStargateClient, setCosmosStargateClient] =
useState<SigningStargateClient>();
const [fees, setFees] = useState<string>();
const [cosmosGasLimit, setCosmosGasLimit] = useState<string>();
const [txError, setTxError] = useState<string>();
const [isTxErrorDialogOpen, setIsTxErrorDialogOpen] = useState(false);
const [ethGasPrice, setEthGasPrice] = useState<BigNumber | null>();
const [ethGasLimit, setEthGasLimit] = useState<BigNumber>();
const [ethMaxFee, setEthMaxFee] = useState<BigNumber | null>();
const [ethMaxPriorityFee, setEthMaxPriorityFee] =
useState<BigNumber | null>();
const isSufficientFunds = useMemo(() => {
if (!transaction.value) {
return;
}
if (!balance) {
return;
}
const amountBigNum = BigNumber.from(String(transaction.value));
const balanceBigNum = BigNumber.from(balance);
if (amountBigNum.gte(balanceBigNum)) {
return false;
} else {
return true;
}
}, [balance, transaction]);
const requestedNetwork = networksData.find(
networkData =>
`${networkData.namespace}:${networkData.chainId}` === chainId,
);
const namespace = requestedNetwork!.namespace;
const sendMsg: MsgSendEncodeObject = useMemo(() => {
return {
typeUrl: '/cosmos.bank.v1beta1.MsgSend',
value: {
fromAddress: transaction.from,
toAddress: transaction.to,
amount: [
{
amount: String(transaction.value),
denom: requestedNetwork!.nativeDenom!,
},
],
},
};
}, [requestedNetwork, transaction]);
useEffect(() => {
if (namespace !== COSMOS) {
return;
}
const setClient = async () => {
if (!account) {
return;
}
const cosmosPrivKey = (
await getPathKey(
`${requestedNetwork?.namespace}:${requestedNetwork?.chainId}`,
account.index,
)
).privKey;
const sender = await DirectSecp256k1Wallet.fromKey(
Buffer.from(cosmosPrivKey.split('0x')[1], 'hex'),
requestedNetwork?.addressPrefix,
);
try {
const client = await SigningStargateClient.connectWithSigner(
requestedNetwork?.rpcUrl!,
sender,
);
setCosmosStargateClient(client);
} catch (error: any) {
setTxError(error.message);
setIsTxErrorDialogOpen(true);
}
};
setClient();
}, [account, requestedNetwork, chainId, namespace]);
const provider = useMemo(() => {
if (namespace === EIP155) {
if (!requestedNetwork) {
throw new Error('Requested chain not supported');
}
try {
const ethProvider = new providers.JsonRpcProvider(
requestedNetwork.rpcUrl,
);
return ethProvider;
} catch (error: any) {
setTxError(error.message);
setIsTxErrorDialogOpen(true);
}
}
}, [requestedNetwork, namespace]);
const navigation =
useNavigation<NativeStackNavigationProp<StackParamsList>>();
const retrieveData = useCallback(
async (requestAddress: string) => {
const requestAccount = await retrieveSingleAccount(
requestedNetwork!.namespace,
requestedNetwork!.chainId,
requestAddress,
);
if (!requestAccount) {
navigation.navigate('InvalidPath');
return;
}
setAccount(requestAccount);
},
[navigation, requestedNetwork],
);
useEffect(() => {
// Set loading to false when gas values for requested chain are fetched
// If requested chain is EVM compatible, the cosmos gas values will be undefined and vice-versa, hence the condition checks only one of them at the same time
if (
// If requested chain is EVM compatible, set loading to false when ethMaxFee and ethPriorityFee have been populated
(ethMaxFee !== undefined && ethMaxPriorityFee !== undefined) ||
// Or if requested chain is a cosmos chain, set loading to false when cosmosGasLimit has been populated
cosmosGasLimit !== undefined
) {
setIsLoading(false);
}
}, [ethMaxFee, ethMaxPriorityFee, cosmosGasLimit]);
useEffect(() => {
if (namespace === EIP155) {
const ethFees = BigNumber.from(ethGasLimit ?? 0)
.mul(BigNumber.from(ethMaxFee ?? ethGasPrice ?? 0))
.toString();
setFees(ethFees);
} else {
const gasPrice = GasPrice.fromString(
requestedNetwork?.gasPrice! + requestedNetwork?.nativeDenom,
);
if (!cosmosGasLimit) {
return;
}
const cosmosFees = calculateFee(Number(cosmosGasLimit), gasPrice);
setFees(cosmosFees.amount[0].amount);
}
}, [
transaction,
namespace,
ethGasLimit,
ethGasPrice,
cosmosGasLimit,
requestedNetwork,
ethMaxFee,
]);
useEffect(() => {
retrieveData(transaction.from!);
}, [retrieveData, transaction]);
const isEIP1559 = useMemo(() => {
if (cosmosGasLimit) {
return;
}
if (ethMaxFee !== null && ethMaxPriorityFee !== null) {
return true;
}
return false;
}, [cosmosGasLimit, ethMaxFee, ethMaxPriorityFee]);
const acceptRequestHandler = async () => {
setIsTxLoading(true);
try {
if (!account) {
throw new Error('account not found');
}
if (ethGasLimit && ethGasLimit.lt(ETH_MINIMUM_GAS)) {
throw new Error(`Atleast ${ETH_MINIMUM_GAS} gas limit is required`);
}
if (ethMaxFee && ethMaxPriorityFee && ethMaxFee.lte(ethMaxPriorityFee)) {
throw new Error(
`Max fee per gas (${ethMaxFee.toNumber()}) cannot be lower than or equal to max priority fee per gas (${ethMaxPriorityFee.toNumber()})`,
);
}
let options: WalletConnectRequests;
switch (requestMethod) {
case EIP155_SIGNING_METHODS.ETH_SEND_TRANSACTION:
if (
ethMaxFee === undefined ||
ethMaxPriorityFee === undefined ||
ethGasPrice === undefined
) {
throw new Error('Gas values not found');
}
options = {
type: 'eth_sendTransaction',
provider: provider!,
ethGasLimit: BigNumber.from(ethGasLimit),
ethGasPrice: ethGasPrice ? ethGasPrice.toHexString() : null,
maxFeePerGas: ethMaxFee,
maxPriorityFeePerGas: ethMaxPriorityFee,
};
break;
case COSMOS_METHODS.COSMOS_SEND_TOKENS:
if (!cosmosStargateClient) {
throw new Error('Cosmos stargate client not found');
}
options = {
type: 'cosmos_sendTokens',
signingStargateClient: cosmosStargateClient,
// StdFee object
cosmosFee: {
// This amount is total fees required for transaction
amount: [
{
amount: fees!,
denom: requestedNetwork!.nativeDenom!,
},
],
gas: cosmosGasLimit!,
},
sendMsg,
memo: MEMO,
};
break;
default:
throw new Error('Invalid method');
}
const response = await approveWalletConnectRequest(
requestEvent,
account,
namespace,
requestedNetwork!.chainId,
options,
);
const { topic } = requestEvent;
await web3wallet!.respondSessionRequest({ topic, response });
navigation.navigate('Laconic');
} catch (error: any) {
setTxError(error.message);
setIsTxErrorDialogOpen(true);
}
setIsTxLoading(false);
};
const rejectRequestHandler = async () => {
const response = rejectWalletConnectRequest(requestEvent);
const { topic } = requestEvent;
await web3wallet!.respondSessionRequest({
topic,
response,
});
navigation.navigate('Laconic');
};
useEffect(() => {
const getAccountBalance = async () => {
try {
if (!account) {
return;
}
if (namespace === EIP155) {
if (!provider) {
return;
}
const fetchedBalance = await provider.getBalance(account.address);
setBalance(fetchedBalance ? fetchedBalance.toString() : '0');
} else {
const cosmosBalance = await cosmosStargateClient?.getBalance(
account.address,
requestedNetwork!.nativeDenom!.toLowerCase(),
);
setBalance(cosmosBalance?.amount!);
}
} catch (error: any) {
setTxError(error.message);
setIsTxErrorDialogOpen(true);
}
};
getAccountBalance();
}, [account, provider, namespace, cosmosStargateClient, requestedNetwork]);
useEffect(() => {
navigation.setOptions({
// eslint-disable-next-line react/no-unstable-nested-components
header: ({ options, back }) => {
const title = getHeaderTitle(options, 'Approve Transaction');
return (
<Appbar.Header>
{back && (
<Appbar.BackAction
onPress={async () => {
await rejectRequestHandler();
navigation.navigate('Laconic');
}}
/>
)}
<Appbar.Content title={title} />
</Appbar.Header>
);
},
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [navigation, route.name]);
useEffect(() => {
const getEthGas = async () => {
try {
if (!isSufficientFunds || !provider) {
return;
}
const data = await provider.getFeeData();
setEthMaxFee(data.maxFeePerGas);
setEthMaxPriorityFee(data.maxPriorityFeePerGas);
setEthGasPrice(data.gasPrice);
if (transaction.gasLimit) {
setEthGasLimit(BigNumber.from(transaction.gasLimit));
} else {
const transactionObject: Deferrable<providers.TransactionRequest> = {
from: transaction.from!,
to: transaction.to!,
data: transaction.data!,
value: transaction.value!,
maxFeePerGas: data.maxFeePerGas ?? undefined,
maxPriorityFeePerGas: data.maxPriorityFeePerGas ?? undefined,
gasPrice: data.maxFeePerGas
? undefined
: data.gasPrice ?? undefined,
};
const gasLimit = await provider.estimateGas(transactionObject);
setEthGasLimit(gasLimit);
}
} catch (error: any) {
setTxError(error.message);
setIsTxErrorDialogOpen(true);
}
};
getEthGas();
}, [provider, transaction, isSufficientFunds]);
useEffect(() => {
const getCosmosGas = async () => {
try {
if (!cosmosStargateClient) {
return;
}
if (!isSufficientFunds) {
return;
}
const gasEstimation = await cosmosStargateClient.simulate(
transaction.from!,
[sendMsg],
MEMO,
);
setCosmosGasLimit(
String(
Math.round(gasEstimation * Number(Config.DEFAULT_GAS_ADJUSTMENT)),
),
);
} catch (error: any) {
setTxError(error.message);
setIsTxErrorDialogOpen(true);
}
};
getCosmosGas();
}, [cosmosStargateClient, isSufficientFunds, sendMsg, transaction]);
useEffect(() => {
if (balance && !isSufficientFunds) {
setTxError('Insufficient funds');
setIsTxErrorDialogOpen(true);
}
}, [isSufficientFunds, balance]);
return (
<>
{isLoading ? (
<View style={styles.spinnerContainer}>
<ActivityIndicator size="large" color="#0000ff" />
</View>
) : (
<>
<ScrollView contentContainerStyle={styles.appContainer}>
<View style={styles.dappDetails}>
{requestIcon && (
<Image
style={styles.dappLogo}
source={requestIcon ? { uri: requestIcon } : undefined}
/>
)}
<Text>{requestName}</Text>
<Text variant="bodyMedium">{requestURL}</Text>
</View>
<View style={styles.dataBoxContainer}>
<Text style={styles.dataBoxLabel}>From</Text>
<View style={styles.dataBox}>
<AccountDetails account={account} />
</View>
</View>
<DataBox
label={`Balance (${
namespace === EIP155 ? 'wei' : requestedNetwork!.nativeDenom
})`}
data={
balance === '' || balance === undefined
? 'Loading balance...'
: `${balance}`
}
/>
{transaction && (
<View style={styles.approveTransfer}>
<DataBox label="To" data={transaction.to!} />
<DataBox
label={`Amount (${
namespace === EIP155 ? 'wei' : requestedNetwork!.nativeDenom
})`}
data={BigNumber.from(
transaction.value?.toString(),
).toString()}
/>
{namespace === EIP155 ? (
<>
{isEIP1559 === false ? (
<>
<Text style={styles.dataBoxLabel}>
{'Gas Price (wei)'}
</Text>
<TextInput
mode="outlined"
value={ethGasPrice?.toNumber().toString()}
onChangeText={value =>
setEthGasPrice(BigNumber.from(value))
}
style={styles.transactionFeesInput}
/>
</>
) : (
<>
<Text style={styles.dataBoxLabel}>
Max Fee Per Gas (wei)
</Text>
<TextInput
mode="outlined"
value={ethMaxFee?.toNumber().toString()}
onChangeText={value => {
if (IS_NUMBER_REGEX.test(value)) {
setEthMaxFee(BigNumber.from(value));
}
}}
style={styles.transactionFeesInput}
/>
<Text style={styles.dataBoxLabel}>
Max Priority Fee Per Gas (wei)
</Text>
<TextInput
mode="outlined"
value={ethMaxPriorityFee?.toNumber().toString()}
onChangeText={value => {
if (IS_NUMBER_REGEX.test(value)) {
setEthMaxPriorityFee(BigNumber.from(value));
}
}}
style={styles.transactionFeesInput}
/>
</>
)}
<Text style={styles.dataBoxLabel}>Gas Limit</Text>
<TextInput
mode="outlined"
value={ethGasLimit?.toNumber().toString()}
onChangeText={value => {
if (IS_NUMBER_REGEX.test(value)) {
setEthGasLimit(BigNumber.from(value));
}
}}
style={styles.transactionFeesInput}
/>
<DataBox
label={`${
isEIP1559 === true ? 'Max Fee' : 'Gas Fee'
} (wei)`}
data={fees!}
/>
<DataBox label="Data" data={transaction.data!} />
</>
) : (
<>
<Text style={styles.dataBoxLabel}>{`Fee (${
requestedNetwork!.nativeDenom
})`}</Text>
<TextInput
mode="outlined"
value={fees}
onChangeText={value => setFees(value)}
style={styles.transactionFeesInput}
/>
<Text style={styles.dataBoxLabel}>Gas Limit</Text>
<TextInput
mode="outlined"
value={cosmosGasLimit}
onChangeText={value => {
if (IS_NUMBER_REGEX.test(value)) {
setCosmosGasLimit(value);
}
}}
/>
</>
)}
</View>
)}
</ScrollView>
<View style={styles.buttonContainer}>
<Button
mode="contained"
onPress={acceptRequestHandler}
loading={isTxLoading}
disabled={!balance || !fees}>
{isTxLoading ? 'Processing' : 'Yes'}
</Button>
<Button
mode="contained"
onPress={rejectRequestHandler}
buttonColor="#B82B0D">
No
</Button>
</View>
</>
)}
<TxErrorDialog
error={txError!}
visible={isTxErrorDialogOpen}
hideDialog={() => {
setIsTxErrorDialogOpen(false);
if (!isSufficientFunds || !balance || !fees) {
rejectRequestHandler();
navigation.navigate('Laconic');
}
}}
/>
</>
);
};
export default ApproveTransfer;

197
src/screens/EditNetwork.tsx Normal file
View File

@ -0,0 +1,197 @@
import React, { useCallback } from 'react';
import { View } from 'react-native';
import { useForm, Controller, FieldErrors } from 'react-hook-form';
import { TextInput, Button, HelperText, Text } from 'react-native-paper';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import {
NativeStackNavigationProp,
NativeStackScreenProps,
} from '@react-navigation/native-stack';
import { useNavigation } from '@react-navigation/native';
import { setInternetCredentials } from '../utils/key-store';
import { StackParamsList } from '../types';
import styles from '../styles/stylesheet';
import { retrieveNetworksData } from '../utils/accounts';
import { useNetworks } from '../context/NetworksContext';
import {
COSMOS,
EIP155,
EMPTY_FIELD_ERROR,
INVALID_URL_ERROR,
} from '../utils/constants';
const ethNetworksFormSchema = z.object({
// Adding type field for resolving typescript error
type: z.literal(EIP155).optional(),
networkName: z.string().nonempty({ message: EMPTY_FIELD_ERROR }),
rpcUrl: z.string().url({ message: INVALID_URL_ERROR }),
blockExplorerUrl: z
.string()
.url({ message: INVALID_URL_ERROR })
.or(z.literal('')),
});
const cosmosNetworksFormDataSchema = z.object({
type: z.literal(COSMOS).optional(),
networkName: z.string().nonempty({ message: EMPTY_FIELD_ERROR }),
rpcUrl: z.string().url({ message: INVALID_URL_ERROR }),
blockExplorerUrl: z
.string()
.url({ message: INVALID_URL_ERROR })
.or(z.literal('')),
gasPrice: z
.string()
.nonempty({ message: EMPTY_FIELD_ERROR })
.regex(/^\d+(\.\d+)?$/),
});
type EditNetworkProps = NativeStackScreenProps<StackParamsList, 'EditNetwork'>;
const EditNetwork = ({ route }: EditNetworkProps) => {
const { setNetworksData } = useNetworks();
const navigation =
useNavigation<NativeStackNavigationProp<StackParamsList>>();
const networkData = route.params.selectedNetwork;
const networksFormDataSchema =
networkData.namespace === COSMOS
? cosmosNetworksFormDataSchema
: ethNetworksFormSchema;
const {
control,
formState: { errors, isSubmitting },
handleSubmit,
} = useForm<z.infer<typeof networksFormDataSchema>>({
mode: 'onChange',
resolver: zodResolver(networksFormDataSchema),
});
const submit = useCallback(
async (data: z.infer<typeof networksFormDataSchema>) => {
const retrievedNetworksData = await retrieveNetworksData();
const { type, ...dataWithoutType } = data;
const newNetworkData = { ...networkData, ...dataWithoutType };
const index = retrievedNetworksData.findIndex(
network => network.networkId === networkData.networkId,
);
retrievedNetworksData.splice(index, 1, newNetworkData);
await setInternetCredentials(
'networks',
'_',
JSON.stringify(retrievedNetworksData),
);
setNetworksData(retrievedNetworksData);
navigation.navigate('Laconic');
},
[networkData, navigation, setNetworksData],
);
return (
<View>
<View>
<Text style={styles.subHeading}>
Edit {networkData?.networkName} details
</Text>
</View>
<Controller
control={control}
defaultValue={networkData.networkName}
name="networkName"
render={({ field: { onChange, onBlur, value } }) => (
<>
<TextInput
mode="outlined"
label="Network Name"
value={value}
onBlur={onBlur}
onChangeText={textValue => onChange(textValue)}
/>
<HelperText type="error">{errors.networkName?.message}</HelperText>
</>
)}
/>
<Controller
control={control}
name="rpcUrl"
defaultValue={networkData.rpcUrl}
render={({ field: { onChange, onBlur, value } }) => (
<>
<TextInput
mode="outlined"
label="New RPC URL"
onBlur={onBlur}
value={value}
onChangeText={textValue => onChange(textValue)}
/>
<HelperText type="error">{errors.rpcUrl?.message}</HelperText>
</>
)}
/>
<Controller
control={control}
defaultValue={networkData.blockExplorerUrl}
name="blockExplorerUrl"
render={({ field: { onChange, onBlur, value } }) => (
<>
<TextInput
mode="outlined"
value={value}
label="Block Explorer URL (Optional)"
onBlur={onBlur}
onChangeText={textValue => onChange(textValue)}
/>
<HelperText type="error">
{errors.blockExplorerUrl?.message}
</HelperText>
</>
)}
/>
{networkData.namespace === COSMOS && (
<Controller
control={control}
name="gasPrice"
defaultValue={networkData.gasPrice}
render={({ field: { onChange, onBlur, value } }) => (
<>
<TextInput
mode="outlined"
value={value}
label="Gas Price"
onBlur={onBlur}
onChangeText={onChange}
/>
<HelperText type="error">
{
(
errors as FieldErrors<
z.infer<typeof cosmosNetworksFormDataSchema>
>
).gasPrice?.message
}
</HelperText>
</>
)}
/>
)}
<Button
mode="contained"
loading={isSubmitting}
disabled={isSubmitting}
onPress={handleSubmit(submit)}>
{isSubmitting ? 'Adding' : 'Submit'}
</Button>
</View>
);
};
export default EditNetwork;

176
src/screens/HomeScreen.tsx Normal file
View File

@ -0,0 +1,176 @@
import React, { useCallback, useEffect, useState } from 'react';
import { View, ActivityIndicator, Image } from 'react-native';
import { Button, Text } from 'react-native-paper';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useNavigation } from '@react-navigation/native';
// import { getSdkError } from '@walletconnect/utils';
import { createWallet, resetWallet, retrieveAccounts } from '../utils/accounts';
import { DialogComponent } from '../components/Dialog';
import { NetworkDropdown } from '../components/NetworkDropdown';
import Accounts from '../components/Accounts';
import CreateWallet from '../components/CreateWallet';
import ConfirmDialog from '../components/ConfirmDialog';
import styles from '../styles/stylesheet';
import { useAccounts } from '../context/AccountsContext';
import { useWalletConnect } from '../context/WalletConnectContext';
import { NetworksDataState, StackParamsList } from '../types';
import { web3wallet } from '../utils/wallet-connect/WalletConnectUtils';
import { useNetworks } from '../context/NetworksContext';
const WCLogo = () => {
return (
<Image
style={styles.walletConnectLogo}
source={require('../assets/WalletConnect-Icon-Blueberry.png')}
/>
);
};
const HomeScreen = () => {
const { accounts, setAccounts, setCurrentIndex } = useAccounts();
const { networksData, selectedNetwork, setSelectedNetwork, setNetworksData } =
useNetworks();
const { setActiveSessions } = useWalletConnect();
const navigation =
useNavigation<NativeStackNavigationProp<StackParamsList>>();
useEffect(() => {
if (accounts.length > 0) {
navigation.setOptions({
// eslint-disable-next-line react/no-unstable-nested-components
headerRight: () => (
<Button onPress={() => navigation.navigate('WalletConnect')}>
{<WCLogo />}
</Button>
),
});
} else {
navigation.setOptions({
headerRight: undefined,
});
}
}, [navigation, accounts]);
const [isWalletCreated, setIsWalletCreated] = useState<boolean>(false);
const [isWalletCreating, setIsWalletCreating] = useState<boolean>(false);
const [walletDialog, setWalletDialog] = useState<boolean>(false);
const [resetWalletDialog, setResetWalletDialog] = useState<boolean>(false);
// const [isAccountsFetched, setIsAccountsFetched] = useState<boolean>(false);
const [isAccountsFetched, setIsAccountsFetched] = useState<boolean>(true);
const [phrase, setPhrase] = useState('');
const hideWalletDialog = () => setWalletDialog(false);
const hideResetDialog = () => setResetWalletDialog(false);
const fetchAccounts = useCallback(async () => {
if (!selectedNetwork) {
return;
}
const loadedAccounts = await retrieveAccounts(selectedNetwork);
if (loadedAccounts) {
setAccounts(loadedAccounts);
setIsWalletCreated(true);
}
setIsAccountsFetched(true);
}, [selectedNetwork, setAccounts]);
const createWalletHandler = async () => {
setIsWalletCreating(true);
const mnemonic = await createWallet(networksData);
if (mnemonic) {
fetchAccounts();
setWalletDialog(true);
setPhrase(mnemonic);
setSelectedNetwork(networksData[0]);
}
};
const confirmResetWallet = useCallback(async () => {
setIsWalletCreated(false);
setIsWalletCreating(false);
setAccounts([]);
setCurrentIndex(0);
setNetworksData([]);
setSelectedNetwork(undefined);
await resetWallet();
// const sessions = web3wallet!.getActiveSessions();
// Object.keys(sessions).forEach(async sessionId => {
// await web3wallet!.disconnectSession({
// topic: sessionId,
// reason: getSdkError('USER_DISCONNECTED'),
// });
// });
setActiveSessions({});
hideResetDialog();
}, [
setAccounts,
setActiveSessions,
setCurrentIndex,
setNetworksData,
setSelectedNetwork,
]);
const updateNetwork = (networkData: NetworksDataState) => {
setSelectedNetwork(networkData);
setCurrentIndex(0);
};
useEffect(() => {
fetchAccounts();
}, [networksData, setAccounts, selectedNetwork, fetchAccounts]);
return (
<View style={styles.appContainer}>
{!isAccountsFetched ? (
<View style={styles.spinnerContainer}>
<Text style={styles.LoadingText}>Loading...</Text>
<ActivityIndicator size="large" color="#0000ff" />
</View>
) : isWalletCreated && selectedNetwork ? (
<>
<NetworkDropdown updateNetwork={updateNetwork} />
<View style={styles.accountComponent}>
<Accounts />
</View>
<View style={styles.resetContainer}>
<Button
style={styles.resetButton}
mode="contained"
buttonColor="#B82B0D"
onPress={() => {
setResetWalletDialog(true);
}}>
Reset Wallet
</Button>
</View>
</>
) : (
<CreateWallet
isWalletCreating={isWalletCreating}
createWalletHandler={createWalletHandler}
/>
)}
<DialogComponent
visible={walletDialog}
hideDialog={hideWalletDialog}
contentText={phrase}
/>
<ConfirmDialog
title="Reset wallet"
visible={resetWalletDialog}
hideDialog={hideResetDialog}
onConfirm={confirmResetWallet}
/>
</View>
);
};
export default HomeScreen;

View File

@ -0,0 +1,30 @@
import React from 'react';
import { View, Text } from 'react-native';
import { Button } from 'react-native-paper';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useNavigation } from '@react-navigation/native';
import { StackParamsList } from '../types';
import styles from '../styles/stylesheet';
const InvalidPath = () => {
const navigation =
useNavigation<NativeStackNavigationProp<StackParamsList>>();
return (
<View style={styles.badRequestContainer}>
<Text style={styles.invalidMessageText}>
The signature request was invalid.
</Text>
<Button
mode="contained"
onPress={() => {
navigation.navigate('Laconic');
}}>
Home
</Button>
</View>
);
};
export default InvalidPath;

View File

@ -0,0 +1,60 @@
import React, { useState } from 'react';
import { View, Alert } from 'react-native';
import { Button, Text, TextInput } from 'react-native-paper';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { StackParamsList } from '../types';
import styles from '../styles/stylesheet';
import { signMessage } from '../utils/sign-message';
import AccountDetails from '../components/AccountDetails';
type SignProps = NativeStackScreenProps<StackParamsList, 'SignMessage'>;
const SignMessage = ({ route }: SignProps) => {
const namespace = route.params.selectedNamespace;
const chainId = route.params.selectedChainId;
const account = route.params.accountInfo;
const [message, setMessage] = useState<string>('');
const signMessageHandler = async () => {
const signedMessage = await signMessage({
message,
namespace,
chainId,
accountId: account.index,
});
Alert.alert('Signature', signedMessage);
};
return (
<View style={styles.signPage}>
<View style={styles.accountInfo}>
<View>
<Text variant="titleMedium">
{account && `Account ${account.index + 1}`}
</Text>
</View>
<View style={styles.accountContainer}>
<AccountDetails account={account} />
</View>
</View>
<TextInput
mode="outlined"
placeholder="Enter your message"
onChangeText={text => setMessage(text)}
value={message}
/>
<View style={styles.signButton}>
<Button mode="contained" onPress={signMessageHandler}>
Sign
</Button>
</View>
</View>
);
};
export default SignMessage;

330
src/screens/SignRequest.tsx Normal file
View File

@ -0,0 +1,330 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Alert, Image, ScrollView, View } from 'react-native';
import { ActivityIndicator, Button, Text, Appbar } from 'react-native-paper';
import { SvgUri } from 'react-native-svg';
import { useNavigation } from '@react-navigation/native';
import {
NativeStackNavigationProp,
NativeStackScreenProps,
} from '@react-navigation/native-stack';
import { getHeaderTitle } from '@react-navigation/elements';
import { Account, StackParamsList } from '../types';
import AccountDetails from '../components/AccountDetails';
import styles from '../styles/stylesheet';
import { signMessage } from '../utils/sign-message';
import { retrieveSingleAccount } from '../utils/accounts';
import {
approveWalletConnectRequest,
rejectWalletConnectRequest,
WalletConnectRequests,
} from '../utils/wallet-connect/wallet-connect-requests';
import { web3wallet } from '../utils/wallet-connect/WalletConnectUtils';
import { EIP155_SIGNING_METHODS } from '../utils/wallet-connect/EIP155Data';
import { useNetworks } from '../context/NetworksContext';
import { COSMOS_METHODS } from '../utils/wallet-connect/COSMOSData';
type SignRequestProps = NativeStackScreenProps<StackParamsList, 'SignRequest'>;
const SignRequest = ({ route }: SignRequestProps) => {
const { networksData } = useNetworks();
const requestSession = route.params.requestSessionData;
const requestName = requestSession?.peer?.metadata?.name;
const requestIcon = requestSession?.peer?.metadata?.icons[0];
const requestURL = requestSession?.peer?.metadata?.url;
const [account, setAccount] = useState<Account>();
const [message, setMessage] = useState<string>('');
const [namespace, setNamespace] = useState<string>('');
const [chainId, setChainId] = useState<string>('');
const [isLoading, setIsLoading] = useState(true);
const [isApproving, setIsApproving] = useState(false);
const [isRejecting, setIsRejecting] = useState(false);
const navigation =
useNavigation<NativeStackNavigationProp<StackParamsList>>();
const isCosmosSignDirect = useMemo(() => {
const requestParams = route.params.requestEvent;
if (!requestParams) {
return false;
}
return requestParams.params.request.method === 'cosmos_signDirect';
}, [route.params]);
const isEthSendTransaction = useMemo(() => {
const requestParams = route.params.requestEvent;
if (!requestParams) {
return false;
}
return (
requestParams.params.request.method ===
EIP155_SIGNING_METHODS.ETH_SEND_TRANSACTION
);
}, [route.params]);
const retrieveData = useCallback(
async (
requestNamespace: string,
requestChainId: string,
requestAddress: string,
requestMessage: string,
) => {
const requestAccount = await retrieveSingleAccount(
requestNamespace,
requestChainId,
requestAddress,
);
if (!requestAccount) {
navigation.navigate('InvalidPath');
return;
}
if (requestAccount !== account) {
setAccount(requestAccount);
}
if (requestMessage !== message) {
setMessage(decodeURIComponent(requestMessage));
}
if (requestNamespace !== namespace) {
setNamespace(requestNamespace);
}
if (requestChainId !== chainId) {
setChainId(requestChainId);
}
setIsLoading(false);
},
[account, message, navigation, namespace, chainId],
);
const sanitizePath = useCallback(
(path: string) => {
const regex = /^\/sign\/(eip155|cosmos)\/(.+)\/(.+)\/(.+)$/;
const match = path.match(regex);
if (match) {
const [, pathNamespace, pathChainId, pathAddress, pathMessage] = match;
return {
namespace: pathNamespace,
chainId: pathChainId,
address: pathAddress,
message: pathMessage,
};
} else {
navigation.navigate('InvalidPath');
}
return null;
},
[navigation],
);
useEffect(() => {
if (route.path) {
const sanitizedRoute = sanitizePath(route.path);
sanitizedRoute &&
retrieveData(
sanitizedRoute.namespace,
sanitizedRoute.chainId,
sanitizedRoute.address,
sanitizedRoute.message,
);
return;
}
const requestEvent = route.params.requestEvent;
const requestChainId = requestEvent?.params.chainId;
const requestedChain = networksData.find(
networkData => networkData.chainId === requestChainId?.split(':')[1],
);
retrieveData(
requestedChain!.namespace,
requestedChain!.chainId,
route.params.address,
route.params.message,
);
}, [retrieveData, sanitizePath, route, networksData]);
const handleWalletConnectRequest = async () => {
const { requestEvent } = route.params || {};
if (!account) {
throw new Error('account not found');
}
if (!requestEvent) {
throw new Error('Request event not found');
}
let options: WalletConnectRequests;
switch (requestEvent.params.request.method) {
case COSMOS_METHODS.COSMOS_SIGN_DIRECT:
options = {
type: 'cosmos_signDirect',
message,
};
break;
case COSMOS_METHODS.COSMOS_SIGN_AMINO:
options = {
type: 'cosmos_signAmino',
message,
};
break;
case EIP155_SIGNING_METHODS.PERSONAL_SIGN:
options = {
type: 'personal_sign',
message,
};
break;
default:
throw new Error('Invalid Method');
}
const response = await approveWalletConnectRequest(
requestEvent,
account,
namespace,
chainId,
options,
);
const { topic } = requestEvent;
await web3wallet!.respondSessionRequest({ topic, response });
};
const handleIntent = async () => {
if (!account) {
throw new Error('Account is not valid');
}
if (message) {
const signedMessage = await signMessage({
message,
namespace,
chainId,
accountId: account.index,
});
Alert.alert('Signature', signedMessage);
}
};
const signMessageHandler = async () => {
setIsApproving(true);
if (route.params.requestEvent) {
await handleWalletConnectRequest();
} else {
await handleIntent();
}
setIsApproving(false);
navigation.navigate('Laconic');
};
const rejectRequestHandler = async () => {
setIsRejecting(true);
if (route.params?.requestEvent) {
const response = rejectWalletConnectRequest(route.params?.requestEvent);
const { topic } = route.params?.requestEvent;
await web3wallet!.respondSessionRequest({
topic,
response,
});
}
setIsRejecting(false);
navigation.navigate('Laconic');
};
useEffect(() => {
navigation.setOptions({
// eslint-disable-next-line react/no-unstable-nested-components
header: ({ options, back }) => {
const title = getHeaderTitle(options, 'Sign Request');
return (
<Appbar.Header>
{back && (
<Appbar.BackAction
onPress={async () => {
await rejectRequestHandler();
navigation.navigate('Laconic');
}}
/>
)}
<Appbar.Content title={title} />
</Appbar.Header>
);
},
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [navigation, route.name]);
return (
<>
{isLoading ? (
<View style={styles.spinnerContainer}>
<ActivityIndicator size="large" color="#0000ff" />
</View>
) : (
<>
<ScrollView contentContainerStyle={styles.appContainer}>
<View style={styles.dappDetails}>
{requestIcon && (
<>
{requestIcon.endsWith('.svg') ? (
<View style={styles.dappLogo}>
<SvgUri height="50" width="50" uri={requestIcon} />
</View>
) : (
<Image
style={styles.dappLogo}
source={{ uri: requestIcon }}
/>
)}
</>
)}
<Text>{requestName}</Text>
<Text variant="bodyMedium">{requestURL}</Text>
</View>
<AccountDetails account={account} />
{isCosmosSignDirect || isEthSendTransaction ? (
<View style={styles.requestDirectMessage}>
<ScrollView nestedScrollEnabled>
<Text variant="bodyLarge">{message}</Text>
</ScrollView>
</View>
) : (
<View style={styles.requestMessage}>
<Text variant="bodyLarge">{message}</Text>
</View>
)}
</ScrollView>
<View style={styles.buttonContainer}>
<Button
mode="contained"
onPress={signMessageHandler}
loading={isApproving}
disabled={isApproving}>
Yes
</Button>
<Button
mode="contained"
onPress={rejectRequestHandler}
loading={isRejecting}
buttonColor="#B82B0D">
No
</Button>
</View>
</>
)}
</>
);
};
export default SignRequest;

View File

@ -0,0 +1,84 @@
import React, { useEffect } from 'react';
import { Image, TouchableOpacity, View } from 'react-native';
import { List, Text } from 'react-native-paper';
import { SvgUri } from 'react-native-svg';
// import { getSdkError } from '@walletconnect/utils';
import { useWalletConnect } from '../context/WalletConnectContext';
import { web3wallet } from '../utils/wallet-connect/WalletConnectUtils';
import styles from '../styles/stylesheet';
export default function WalletConnect() {
const { activeSessions, setActiveSessions } = useWalletConnect();
const disconnect = async (sessionId: string) => {
// await web3wallet!.disconnectSession({
// topic: sessionId,
// reason: getSdkError('USER_DISCONNECTED'),
// });
const sessions = web3wallet?.getActiveSessions() || {};
setActiveSessions(sessions);
return;
};
useEffect(() => {
const sessions = web3wallet!.getActiveSessions();
setActiveSessions(sessions);
}, [setActiveSessions]);
return (
<View>
{Object.keys(activeSessions).length > 0 ? (
<>
<View style={styles.sessionsContainer}>
<Text variant="titleMedium">Active Sessions</Text>
</View>
<List.Section>
{Object.entries(activeSessions).map(([sessionId, session]) => (
<List.Item
style={styles.sessionItem}
key={sessionId}
title={`${session.peer.metadata.name}`}
descriptionNumberOfLines={7}
description={`${sessionId} \n\n${session.peer.metadata.url}\n\n${session.peer.metadata.description}`}
// reference: https://github.com/react-navigation/react-navigation/issues/11371#issuecomment-1546543183
// eslint-disable-next-line react/no-unstable-nested-components
left={() => (
<>
{session.peer.metadata.icons[0].endsWith('.svg') ? (
<View style={styles.dappLogo}>
<SvgUri
height="50"
width="50"
uri={session.peer.metadata.icons[0]}
/>
</View>
) : (
<Image
style={styles.dappLogo}
source={{ uri: session.peer.metadata.icons[0] }}
/>
)}
</>
)}
// eslint-disable-next-line react/no-unstable-nested-components
right={() => (
<TouchableOpacity
onPress={() => disconnect(sessionId)}
style={styles.disconnectSession}>
<List.Icon icon="close" />
</TouchableOpacity>
)}
/>
))}
</List.Section>
</>
) : (
<View style={styles.noActiveSessions}>
<Text>You have no active sessions</Text>
</View>
)}
</View>
);
}

5
src/setupTests.ts Normal file
View File

@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

287
src/styles/stylesheet.js Normal file
View File

@ -0,0 +1,287 @@
import { StyleSheet } from 'react-native';
const styles = StyleSheet.create({
createWalletContainer: {
marginTop: 20,
width: 150,
alignSelf: 'center',
},
signLink: {
alignItems: 'flex-end',
marginTop: 24,
},
hyperlink: {
fontWeight: '500',
textDecorationLine: 'underline',
},
highlight: {
fontWeight: '700',
},
accountContainer: {
padding: 8,
paddingBottom: 0,
},
addAccountButton: {
marginTop: 24,
alignSelf: 'center',
},
accountComponent: {
flex: 4,
},
appContainer: {
flexGrow: 1,
marginTop: 24,
paddingHorizontal: 24,
},
resetContainer: {
flex: 1,
justifyContent: 'center',
},
resetButton: {
alignSelf: 'center',
},
signButton: {
marginTop: 20,
width: 150,
alignSelf: 'center',
},
signPage: {
paddingHorizontal: 24,
},
addNetwork: {
paddingHorizontal: 24,
marginTop: 30,
},
accountInfo: {
marginTop: 12,
marginBottom: 30,
},
networkDropdown: {
marginBottom: 20,
},
dialogTitle: {
padding: 10,
},
dialogContents: {
marginTop: 24,
padding: 10,
borderWidth: 1,
borderRadius: 10,
},
dialogWarning: {
color: 'red',
},
gridContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'center',
},
gridItem: {
width: '25%',
margin: 8,
padding: 6,
borderWidth: 1,
borderColor: '#ccc',
borderRadius: 8,
alignItems: 'center',
justifyContent: 'flex-start',
},
HDcontainer: {
marginTop: 24,
paddingHorizontal: 8,
},
HDrowContainer: {
flexDirection: 'row',
alignItems: 'center',
},
HDtext: {
color: 'black',
fontSize: 18,
margin: 4,
},
HDtextInput: {
flex: 1,
},
HDbuttonContainer: {
marginTop: 20,
width: 200,
alignSelf: 'center',
},
spinnerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
LoadingText: {
color: 'black',
fontSize: 18,
padding: 10,
},
requestMessage: {
borderWidth: 1,
borderRadius: 5,
marginTop: 50,
height: 'auto',
alignItems: 'center',
justifyContent: 'center',
padding: 10,
},
requestDirectMessage: {
borderWidth: 1,
borderRadius: 5,
marginTop: 20,
marginBottom: 50,
height: 500,
alignItems: 'center',
justifyContent: 'center',
padding: 8,
},
approveTransfer: {
height: '40%',
marginBottom: 30,
},
buttonContainer: {
flexDirection: 'row',
marginLeft: 20,
marginBottom: 10,
justifyContent: 'space-evenly',
},
badRequestContainer: {
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
invalidMessageText: {
color: 'black',
fontSize: 16,
textAlign: 'center',
marginBottom: 20,
},
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 10,
paddingHorizontal: 20,
},
modalContentContainer: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
borderRadius: 34,
borderBottomStartRadius: 0,
borderBottomEndRadius: 0,
borderWidth: 1,
width: '100%',
height: '50%',
position: 'absolute',
backgroundColor: 'white',
bottom: 0,
},
modalOuterContainer: { flex: 1 },
dappLogo: {
width: 50,
height: 50,
borderRadius: 8,
marginVertical: 16,
overflow: 'hidden',
},
space: {
width: 50,
},
flexRow: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 20,
paddingHorizontal: 16,
marginBottom: 10,
},
marginVertical8: {
marginVertical: 8,
textAlign: 'center',
},
subHeading: {
textAlign: 'center',
fontWeight: 'bold',
marginBottom: 10,
marginTop: 10,
},
centerText: {
textAlign: 'center',
},
messageBody: {
borderWidth: 1,
borderRadius: 6,
paddingVertical: 10,
paddingHorizontal: 10,
marginVertical: 3,
},
cameraContainer: {
justifyContent: 'center',
alignItems: 'center',
},
inputContainer: {
marginTop: 20,
},
camera: {
width: 400,
height: 400,
},
dappDetails: {
display: 'flex',
alignItems: 'center',
},
dataBoxContainer: {
marginBottom: 10,
},
dataBoxLabel: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 3,
color: 'black',
},
dataBox: {
borderWidth: 1,
borderColor: '#ccc',
padding: 10,
borderRadius: 5,
},
dataBoxData: {
fontSize: 16,
color: 'black',
},
transactionText: {
padding: 8,
fontSize: 18,
fontWeight: 'bold',
color: 'black',
},
balancePadding: {
padding: 8,
},
noActiveSessions: { display: 'flex', alignItems: 'center', marginTop: 12 },
disconnectSession: { display: 'flex', justifyContent: 'center' },
sessionItem: { paddingLeft: 12, borderBottomWidth: 0.5 },
sessionsContainer: { paddingLeft: 12, borderBottomWidth: 0.5 },
walletConnectUriText: { padding: 10 },
walletConnectLogo: { width: 24, height: 15, margin: 0 },
selectNetworkText: {
fontWeight: 'bold',
marginVertical: 10,
},
transactionFeesInput: { marginBottom: 10 },
approveTransaction: {
flexGrow: 1,
marginTop: 0,
paddingHorizontal: 24,
paddingVertical: 5,
},
transactionLabel: {
fontWeight: '700',
padding: 8,
},
});
export default styles;

140
src/types.ts Normal file
View File

@ -0,0 +1,140 @@
import { PopulatedTransaction } from 'ethers';
// import { SignClientTypes, SessionTypes } from '@walletconnect/types';
// import { Web3WalletTypes } from '@walletconnect/web3wallet';
import { EncodeObject } from '@cosmjs/proto-signing';
export type StackParamsList = {
Laconic: undefined;
SignMessage: {
selectedNamespace: string;
selectedChainId: string;
accountInfo: Account;
};
SignRequest: {
namespace: string;
address: string;
message: string;
// requestEvent?: Web3WalletTypes.SessionRequest;
// requestSessionData?: SessionTypes.Struct;
requestEvent?: any;
requestSessionData?: any;
};
ApproveTransfer: {
transaction: PopulatedTransaction;
// requestEvent: Web3WalletTypes.SessionRequest;
// requestSessionData: SessionTypes.Struct;
requestEvent: any;
requestSessionData: any;
};
InvalidPath: undefined;
WalletConnect: undefined;
AddSession: undefined;
AddNetwork: undefined;
EditNetwork: {
selectedNetwork: NetworksDataState;
};
ApproveTransaction: {
transactionMessage: EncodeObject;
signer: string;
// requestEvent: Web3WalletTypes.SessionRequest;
// requestSessionData: SessionTypes.Struct;
requestEvent: any;
requestSessionData: any;
};
};
export type Account = {
index: number;
pubKey: string;
address: string;
hdPath: string;
};
export type NetworkDropdownProps = {
updateNetwork: (networksData: NetworksDataState) => void;
};
export type NetworksFormData = {
networkName: string;
rpcUrl: string;
chainId: string;
currencySymbol?: string;
blockExplorerUrl?: string;
namespace: string;
nativeDenom?: string;
addressPrefix?: string;
coinType?: string;
gasPrice?: string;
isDefault: boolean;
};
export interface NetworksDataState extends NetworksFormData {
networkId: string;
}
export type SignMessageParams = {
message: string;
namespace: string;
chainId: string;
accountId: number;
};
export type CreateWalletProps = {
isWalletCreating: boolean;
createWalletHandler: () => Promise<void>;
};
export type ResetDialogProps = {
title: string;
visible: boolean;
hideDialog: () => void;
onConfirm: () => void;
};
export type HDPathDialogProps = {
pathCode: string;
visible: boolean;
hideDialog: () => void;
updateAccounts: (account: Account) => void;
};
export type CustomDialogProps = {
visible: boolean;
hideDialog: () => void;
contentText: string;
titleText?: string;
};
export type GridViewProps = {
words: string[];
};
export type PathState = {
firstNumber: string;
secondNumber: string;
thirdNumber: string;
};
export interface PairingModalProps {
visible: boolean;
setModalVisible: (arg1: boolean) => void;
currentProposal:
// | SignClientTypes.EventArguments['session_proposal']
| undefined;
setCurrentProposal: (
arg1:
// | SignClientTypes.EventArguments['session_proposal']
| undefined,
) => void;
setToastVisible: (arg1: boolean) => void;
}
export interface WalletConnectContextProps {
// activeSessions: Record<string, SessionTypes.Struct>;
activeSessions: Record<string, any>;
setActiveSessions: (
// activeSessions: Record<string, SessionTypes.Struct>,
activeSessions: Record<string, any>,
) => void;
}

346
src/utils/accounts.ts Normal file
View File

@ -0,0 +1,346 @@
/* Importing this library provides react native with a secure random source.
For more information, "visit https://docs.ethers.org/v5/cookbook/react-native/#cookbook-reactnative-security" */
import 'react-native-get-random-values';
import '@ethersproject/shims';
import { utils } from 'ethers';
import { HDNode } from 'ethers/lib/utils';
import {
setInternetCredentials,
resetInternetCredentials,
getInternetCredentials,
} from './key-store';
import { Secp256k1HdWallet } from '@cosmjs/amino';
import { AccountData } from '@cosmjs/proto-signing';
import { stringToPath } from '@cosmjs/crypto';
import { Account, NetworksDataState, NetworksFormData } from '../types';
import {
getHDPath,
getPathKey,
resetKeyServers,
updateAccountIndices,
} from './misc';
import { COSMOS, EIP155 } from './constants';
const createWallet = async (
networksData: NetworksDataState[],
): Promise<string> => {
const mnemonic = utils.entropyToMnemonic(utils.randomBytes(16));
await setInternetCredentials('mnemonicServer', 'mnemonic', mnemonic);
const hdNode = HDNode.fromMnemonic(mnemonic);
for (const network of networksData) {
const hdPath = `m/44'/${network.coinType}'/0'/0/0`;
const node = hdNode.derivePath(hdPath);
let address;
switch (network.namespace) {
case EIP155:
address = node.address;
break;
case COSMOS:
address = (
await getCosmosAccounts(mnemonic, hdPath, network.addressPrefix)
).data.address;
break;
default:
throw new Error('Unsupported namespace');
}
const accountInfo = `${hdPath},${node.privateKey},${node.publicKey},${address}`;
await Promise.all([
setInternetCredentials(
`accounts/${network.namespace}:${network.chainId}/0`,
'_',
accountInfo,
),
setInternetCredentials(
`addAccountCounter/${network.namespace}:${network.chainId}`,
'_',
'1',
),
setInternetCredentials(
`accountIndices/${network.namespace}:${network.chainId}`,
'_',
'0',
),
]);
}
return mnemonic;
};
const addAccount = async (
networkData: NetworksDataState,
): Promise<Account | undefined> => {
try {
const namespaceChainId = `${networkData.namespace}:${networkData.chainId}`;
const id = await getNextAccountId(namespaceChainId);
const hdPath = getHDPath(namespaceChainId, `0'/0/${id}`);
const accounts = await addAccountFromHDPath(hdPath, networkData);
await updateAccountCounter(namespaceChainId, id);
return accounts;
} catch (error) {
console.error('Error creating account:', error);
}
};
const addAccountFromHDPath = async (
hdPath: string,
networkData: NetworksDataState,
): Promise<Account | undefined> => {
try {
const account = await accountInfoFromHDPath(hdPath, networkData);
if (!account) {
throw new Error('Error while creating account');
}
const { privKey, pubKey, address } = account;
const namespaceChainId = `${networkData.namespace}:${networkData.chainId}`;
const index = (await updateAccountIndices(namespaceChainId)).index;
await Promise.all([
setInternetCredentials(
`accounts/${namespaceChainId}/${index}`,
'_',
`${hdPath},${privKey},${pubKey},${address}`,
),
]);
return { index, pubKey, address, hdPath };
} catch (error) {
console.error(error);
}
};
const storeNetworkData = async (
networkData: NetworksFormData,
): Promise<NetworksDataState[]> => {
const networks = await getInternetCredentials('networks');
let retrievedNetworks = [];
if (networks) {
retrievedNetworks = JSON.parse(networks!);
}
let networkId = 0;
if (retrievedNetworks.length > 0) {
networkId = retrievedNetworks[retrievedNetworks.length - 1].networkId + 1;
}
const updatedNetworks: NetworksDataState[] = [
...retrievedNetworks,
{
...networkData,
networkId: String(networkId),
},
];
await setInternetCredentials(
'networks',
'_',
JSON.stringify(updatedNetworks),
);
return updatedNetworks;
};
const retrieveNetworksData = async (): Promise<NetworksDataState[]> => {
const networks = await getInternetCredentials('networks');
if(!networks){
return [];
}
const parsedNetworks: NetworksDataState[] = JSON.parse(networks);
return parsedNetworks;
};
export const retrieveAccountsForNetwork = async (
namespaceChainId: string,
accountsIndices: string,
): Promise<Account[]> => {
const accountsIndexArray = accountsIndices.split(',');
const loadedAccounts = await Promise.all(
accountsIndexArray.map(async i => {
const { address, path, pubKey } = await getPathKey(
namespaceChainId,
Number(i),
);
const account: Account = {
index: Number(i),
pubKey,
address,
hdPath: path,
};
return account;
}),
);
return loadedAccounts;
};
const retrieveAccounts = async (
currentNetworkData: NetworksDataState,
): Promise<Account[] | undefined> => {
const accountIndicesServer = await getInternetCredentials(
`accountIndices/${currentNetworkData.namespace}:${currentNetworkData.chainId}`,
);
const accountIndices = accountIndicesServer;
if (!accountIndices) {
return;
}
const loadedAccounts = await retrieveAccountsForNetwork(
`${currentNetworkData.namespace}:${currentNetworkData.chainId}`,
accountIndices,
)
return loadedAccounts;
};
const retrieveSingleAccount = async (
namespace: string,
chainId: string,
address: string,
) => {
let loadedAccounts;
const accountIndicesServer = await getInternetCredentials(
`accountIndices/${namespace}:${chainId}`,
);
const accountIndices = accountIndicesServer;
if (!accountIndices) {
throw new Error('Indices for given chain not found');
}
loadedAccounts = await retrieveAccountsForNetwork(
`${namespace}:${chainId}`,
accountIndices,
);
if (!loadedAccounts) {
throw new Error('Accounts for given chain not found');
}
return loadedAccounts.find(account => account.address === address);
};
const resetWallet = async () => {
try {
await Promise.all([
resetInternetCredentials('mnemonicServer'),
resetKeyServers(EIP155),
resetKeyServers(COSMOS),
setInternetCredentials('networks', '_', JSON.stringify([])),
]);
} catch (error) {
console.error('Error resetting wallet:', error);
throw error;
}
};
const accountInfoFromHDPath = async (
hdPath: string,
networkData: NetworksDataState,
): Promise<
{ privKey: string; pubKey: string; address: string } | undefined
> => {
const mnemonicStore = await getInternetCredentials('mnemonicServer');
if (!mnemonicStore) {
throw new Error('Mnemonic not found!');
}
const mnemonic = mnemonicStore;
const hdNode = HDNode.fromMnemonic(mnemonic);
const node = hdNode.derivePath(hdPath);
const privKey = node.privateKey;
const pubKey = node.publicKey;
let address: string;
switch (networkData.namespace) {
case EIP155:
address = node.address;
break;
case COSMOS:
address = (
await getCosmosAccounts(mnemonic, hdPath, networkData.addressPrefix)
).data.address;
break;
default:
throw new Error('Invalid wallet type');
}
return { privKey, pubKey, address };
};
const getNextAccountId = async (namespaceChainId: string): Promise<number> => {
const idStore = await getInternetCredentials(
`addAccountCounter/${namespaceChainId}`,
);
if (!idStore) {
throw new Error('Account id not found');
}
const accountCounter = idStore;
const nextCounter = Number(accountCounter);
return nextCounter;
};
const updateAccountCounter = async (
namespaceChainId: string,
id: number,
): Promise<void> => {
const idStore = await getInternetCredentials(
`addAccountCounter/${namespaceChainId}`,
);
if (!idStore) {
throw new Error('Account id not found');
}
const updatedCounter = String(id + 1);
await resetInternetCredentials(`addAccountCounter/${namespaceChainId}`);
await setInternetCredentials(
`addAccountCounter/${namespaceChainId}`,
'_',
updatedCounter,
);
};
const getCosmosAccounts = async (
mnemonic: string,
path: string,
prefix: string = COSMOS,
): Promise<{ cosmosWallet: Secp256k1HdWallet; data: AccountData }> => {
const cosmosWallet = await Secp256k1HdWallet.fromMnemonic(mnemonic, {
hdPaths: [stringToPath(path)],
prefix,
});
const accountsData = await cosmosWallet.getAccounts();
const data = accountsData[0];
return { cosmosWallet, data };
};
export {
createWallet,
addAccount,
addAccountFromHDPath,
storeNetworkData,
retrieveNetworksData,
retrieveAccounts,
retrieveSingleAccount,
resetWallet,
accountInfoFromHDPath,
getNextAccountId,
updateAccountCounter,
getCosmosAccounts,
};

36
src/utils/constants.ts Normal file
View File

@ -0,0 +1,36 @@
import { COSMOS_TESTNET_CHAINS } from './wallet-connect/COSMOSData';
import { EIP155_CHAINS } from './wallet-connect/EIP155Data';
export const EIP155 = 'eip155';
export const COSMOS = 'cosmos';
export const DEFAULT_NETWORKS = [
{
chainId: '1',
networkName: EIP155_CHAINS['eip155:1'].name,
namespace: EIP155,
rpcUrl: EIP155_CHAINS['eip155:1'].rpc,
blockExplorerUrl: '',
currencySymbol: 'ETH',
coinType: '60',
isDefault: true,
},
{
chainId: 'theta-testnet-001',
networkName: COSMOS_TESTNET_CHAINS['cosmos:theta-testnet-001'].name,
namespace: COSMOS,
rpcUrl: COSMOS_TESTNET_CHAINS['cosmos:theta-testnet-001'].rpc,
blockExplorerUrl: '',
nativeDenom: 'uatom',
addressPrefix: 'cosmos',
coinType: '118',
gasPrice: '0.025',
isDefault: true,
},
];
export const CHAINID_DEBOUNCE_DELAY = 250;
export const EMPTY_FIELD_ERROR = 'Field cannot be empty';
export const INVALID_URL_ERROR = 'Invalid URL';
export const IS_NUMBER_REGEX = /^\d+$/;

17
src/utils/key-store.ts Normal file
View File

@ -0,0 +1,17 @@
const setInternetCredentials = (name:string, username:string, password:string) => {
localStorage.setItem(name, password);
};
const getInternetCredentials = (name:string) : string | null => {
return localStorage.getItem(name);
};
const resetInternetCredentials = (name:string) => {
localStorage.removeItem(name);
};
export {
setInternetCredentials,
getInternetCredentials,
resetInternetCredentials
}

158
src/utils/misc.ts Normal file
View File

@ -0,0 +1,158 @@
/* Importing this library provides react native with a secure random source.
For more information, "visit https://docs.ethers.org/v5/cookbook/react-native/#cookbook-reactnative-security" */
import 'react-native-get-random-values';
import '@ethersproject/shims';
import {
getInternetCredentials,
resetInternetCredentials,
setInternetCredentials,
} from './key-store';
import { AccountData } from '@cosmjs/amino';
import { DirectSecp256k1HdWallet } from '@cosmjs/proto-signing';
import { stringToPath } from '@cosmjs/crypto';
import { EIP155 } from './constants';
import { NetworksDataState } from '../types';
const getMnemonic = async (): Promise<string> => {
const mnemonicStore = await getInternetCredentials('mnemonicServer');
if (!mnemonicStore) {
throw new Error('Mnemonic not found!');
}
const mnemonic = mnemonicStore;
return mnemonic;
};
const getHDPath = (namespaceChainId: string, path: string): string => {
const namespace = namespaceChainId.split(':')[0];
return namespace === EIP155 ? `m/44'/60'/${path}` : `m/44'/118'/${path}`;
};
export const getDirectWallet = async (
mnemonic: string,
path: string,
): Promise<{ directWallet: DirectSecp256k1HdWallet; data: AccountData }> => {
const directWallet = await DirectSecp256k1HdWallet.fromMnemonic(mnemonic, {
hdPaths: [stringToPath(`m/44'/118'/${path}`)],
});
const accountsData = await directWallet.getAccounts();
const data = accountsData[0];
return { directWallet, data };
};
const getPathKey = async (
namespaceChainId: string,
accountId: number,
): Promise<{
path: string;
privKey: string;
pubKey: string;
address: string;
}> => {
const pathKeyStore = await getInternetCredentials(
`accounts/${namespaceChainId}/${accountId}`,
);
if (!pathKeyStore) {
throw new Error('Error while fetching counter');
}
const pathKeyVal = pathKeyStore;
const pathkey = pathKeyVal.split(',');
const path = pathkey[0];
const privKey = pathkey[1];
const pubKey = pathkey[2];
const address = pathkey[3];
return { path, privKey, pubKey, address };
};
const getAccountIndices = async (
namespaceChainId: string,
): Promise<{
accountIndices: string;
indices: number[];
index: number;
}> => {
const counterStore = await getInternetCredentials(
`accountIndices/${namespaceChainId}`,
);
if (!counterStore) {
throw new Error('Error while fetching counter');
}
let accountIndices = counterStore;
const indices = accountIndices.split(',').map(Number);
const index = indices[indices.length - 1] + 1;
return { accountIndices, indices, index };
};
const updateAccountIndices = async (
namespaceChainId: string,
): Promise<{ accountIndices: string; index: number }> => {
const accountIndicesData = await getAccountIndices(namespaceChainId);
const accountIndices = accountIndicesData.accountIndices;
const index = accountIndicesData.index;
const updatedAccountIndices = `${accountIndices},${index.toString()}`;
await resetInternetCredentials(`accountIndices/${namespaceChainId}`);
await setInternetCredentials(
`accountIndices/${namespaceChainId}`,
'_',
updatedAccountIndices,
);
return { accountIndices: updatedAccountIndices, index };
};
const resetKeyServers = async (namespace: string) => {
const networksServer = await getInternetCredentials('networks');
if (!networksServer) {
throw new Error('Networks not found.');
}
const networksData: NetworksDataState[] = JSON.parse(networksServer);
const filteredNetworks = networksData.filter(
(network: any) => network.namespace === namespace,
);
if (filteredNetworks.length === 0) {
throw new Error(`No networks found for namespace ${namespace}.`);
}
filteredNetworks.forEach(async (network: any) => {
const { chainId } = network;
const namespaceChainId = `${namespace}:${chainId}`;
const idStore = await getInternetCredentials(
`accountIndices/${namespaceChainId}`,
);
if (!idStore) {
throw new Error(`Account indices not found for ${namespaceChainId}.`);
}
const accountIds = idStore;
const ids = accountIds.split(',').map(Number);
const latestId = Math.max(...ids);
for (let i = 0; i <= latestId; i++) {
await resetInternetCredentials(`accounts/${namespaceChainId}/${i}`);
}
await resetInternetCredentials(`addAccountCounter/${namespaceChainId}`);
await resetInternetCredentials(`accountIndices/${namespaceChainId}`);
});
};
export {
getMnemonic,
getPathKey,
updateAccountIndices,
getHDPath,
resetKeyServers,
};

111
src/utils/sign-message.ts Normal file
View File

@ -0,0 +1,111 @@
/* Importing this library provides react native with a secure random source.
For more information, "visit https://docs.ethers.org/v5/cookbook/react-native/#cookbook-reactnative-security" */
import 'react-native-get-random-values';
import '@ethersproject/shims';
import { Wallet } from 'ethers';
import { SignDoc } from 'cosmjs-types/cosmos/tx/v1beta1/tx';
import { SignMessageParams } from '../types';
import { getDirectWallet, getMnemonic, getPathKey } from './misc';
import { getCosmosAccounts } from './accounts';
import { COSMOS, EIP155 } from './constants';
const signMessage = async ({
message,
namespace,
chainId,
accountId,
}: SignMessageParams): Promise<string | undefined> => {
const path = await getPathKey(`${namespace}:${chainId}`, accountId);
switch (namespace) {
case EIP155:
return await signEthMessage(message, accountId, chainId);
case COSMOS:
return await signCosmosMessage(message, path.path);
default:
throw new Error('Invalid wallet type');
}
};
const signEthMessage = async (
message: string,
accountId: number,
chainId: string,
): Promise<string | undefined> => {
try {
const privKey = (await getPathKey(`${EIP155}:${chainId}`, accountId))
.privKey;
const wallet = new Wallet(privKey);
const signature = await wallet.signMessage(message);
return signature;
} catch (error) {
console.error('Error signing Ethereum message:', error);
throw error;
}
};
const signCosmosMessage = async (
message: string,
path: string,
): Promise<string | undefined> => {
try {
const mnemonic = await getMnemonic();
const cosmosAccount = await getCosmosAccounts(mnemonic, path);
const address = cosmosAccount.data.address;
const cosmosSignature = await cosmosAccount.cosmosWallet.signAmino(
address,
{
chain_id: '',
account_number: '0',
sequence: '0',
fee: {
gas: '0',
amount: [],
},
msgs: [
{
type: 'sign/MsgSignData',
value: {
signer: address,
data: btoa(message),
},
},
],
memo: '',
},
);
return cosmosSignature.signature.signature;
} catch (error) {
console.error('Error signing Cosmos message:', error);
throw error;
}
};
const signDirectMessage = async (
namespaceChainId: string,
accountId: number,
signDoc: SignDoc,
): Promise<string | undefined> => {
try {
const path = (await getPathKey(namespaceChainId, accountId)).path;
const mnemonic = await getMnemonic();
const { directWallet, data } = await getDirectWallet(mnemonic, path);
const directSignature = await directWallet.signDirect(
data.address,
signDoc,
);
return directSignature.signature.signature;
} catch (error) {
console.error('Error signing Cosmos message:', error);
throw error;
}
};
export { signMessage, signEthMessage, signCosmosMessage, signDirectMessage };

View File

@ -0,0 +1,42 @@
// Taken from https://github.com/WalletConnect/web-examples/blob/main/advanced/wallets/react-wallet-v2/src/data/COSMOSData.ts
/**
* Types
*/
export type TCosmosChain = keyof typeof COSMOS_TESTNET_CHAINS;
/**
* Chains
*/
// Added for pay.laconic.com
export const COSMOS_TESTNET_CHAINS: Record<
string,
{
chainId: string;
name: string;
rpc: string;
namespace: string;
}
> = {
'cosmos:theta-testnet-001': {
chainId: 'theta-testnet-001',
name: 'Cosmos Hub Testnet',
rpc: 'https://rpc-t.cosmos.nodestake.top',
namespace: 'cosmos',
},
};
/**
* Methods
*/
export const COSMOS_SIGNING_METHODS = {
COSMOS_SIGN_DIRECT: 'cosmos_signDirect',
COSMOS_SIGN_AMINO: 'cosmos_signAmino',
};
export const COSMOS_METHODS = {
...COSMOS_SIGNING_METHODS,
COSMOS_SEND_TOKENS: 'cosmos_sendTokens', // Added for pay.laconic.com
COSMOS_SEND_TRANSACTION: 'cosmos_sendTransaction', // Added for testnet onboarding app
};

View File

@ -0,0 +1,43 @@
/**
* @desc Refference list of eip155 chains
* @url https://chainlist.org
*/
// Taken from https://github.com/WalletConnect/web-examples/blob/main/advanced/wallets/react-wallet-v2/src/data/EIP155Data.ts
/**
* Types
*/
export type TEIP155Chain = keyof typeof EIP155_CHAINS;
export type EIP155Chain = {
chainId: number;
name: string;
logo: string;
rgb: string;
rpc: string;
namespace: string;
smartAccountEnabled?: boolean;
};
/**
* Chains
*/
export const EIP155_CHAINS: Record<string, EIP155Chain> = {
'eip155:1': {
chainId: 1,
name: 'Ethereum',
logo: '/chain-logos/eip155-1.png',
rgb: '99, 125, 234',
rpc: 'https://cloudflare-eth.com/',
namespace: 'eip155',
},
};
/**
* Methods
*/
export const EIP155_SIGNING_METHODS = {
PERSONAL_SIGN: 'personal_sign',
ETH_SEND_TRANSACTION: 'eth_sendTransaction',
};

View File

@ -0,0 +1,36 @@
import Config from 'react-native-config';
// import '@walletconnect/react-native-compat';
import '@ethersproject/shims';
// import { Core } from '@walletconnect/core';
// import { ICore } from '@walletconnect/types';
// import { Web3Wallet, IWeb3Wallet } from '@walletconnect/web3wallet';
export let web3wallet:
// | IWeb3Wallet
| any;
export let core:
// | ICore
| any;
export async function createWeb3Wallet() {
// core = new Core({
// projectId: Config.WALLET_CONNECT_PROJECT_ID,
// });
// web3wallet = await Web3Wallet.init({
// core,
// metadata: {
// name: 'Laconic Wallet',
// description: 'Laconic Wallet',
// url: 'https://wallet.laconic.com/',
// icons: ['https://avatars.githubusercontent.com/u/92608123'],
// },
// });
}
export async function web3WalletPair(params: { uri: string }) {
if (web3wallet) {
// return await web3wallet.core.pairing.pair({ uri: params.uri });
}
}

View File

@ -0,0 +1,4 @@
export const NETWORK_METHODS = {
GET_NETWORKS: 'getNetworks',
CHANGE_NETWORK: 'changeNetwork',
};

View File

@ -0,0 +1,220 @@
// Taken from https://medium.com/walletconnect/how-to-build-a-wallet-in-react-native-with-the-web3wallet-sdk-b6f57bf02f9a
import { utils } from 'ethers';
// import { ProposalTypes } from '@walletconnect/types';
import { Account, NetworksDataState } from '../../types';
import { EIP155_SIGNING_METHODS } from './EIP155Data';
import { mergeWith } from 'lodash';
import { retrieveAccounts } from '../accounts';
import { COSMOS, EIP155 } from '../constants';
import { NETWORK_METHODS } from './common-data';
import { COSMOS_METHODS } from './COSMOSData';
/**
* Converts hex to utf8 string if it is valid bytes
*/
export function convertHexToUtf8(value: string) {
if (utils.isHexString(value)) {
return utils.toUtf8String(value);
}
return value;
}
/**
* Gets message from various signing request methods by filtering out
* a value that is not an address (thus is a message).
* If it is a hex string, it gets converted to utf8 string
*/
export function getSignParamsMessage(params: string[]) {
const message = params.filter(p => !utils.isAddress(p))[0];
return convertHexToUtf8(message);
}
export const getNamespaces = async (
// optionalNamespaces: ProposalTypes.OptionalNamespaces,
// requiredNamespaces: ProposalTypes.RequiredNamespaces,
optionalNamespaces: any,
requiredNamespaces: any,
networksData: NetworksDataState[],
selectedNetwork: NetworksDataState,
accounts: Account[],
currentIndex: number,
) => {
const namespaceChainId = `${selectedNetwork.namespace}:${selectedNetwork.chainId}`;
const combinedNamespaces = mergeWith(
requiredNamespaces,
optionalNamespaces,
(obj, src) =>
Array.isArray(obj) && Array.isArray(src) ? [...src, ...obj] : undefined,
);
const walletConnectChains: string[] = [];
Object.keys(combinedNamespaces).forEach(key => {
const { chains } = combinedNamespaces[key];
chains && walletConnectChains.push(...chains);
});
// If combinedNamespaces is not empty, send back namespaces object based on requested chains
// Else send back namespaces object using currently selected network
if (Object.keys(combinedNamespaces).length > 0) {
if (!(walletConnectChains.length > 0)) {
return;
}
// Check for unsupported chains
const networkChains = networksData.map(
network => `${network.namespace}:${network.chainId}`,
);
if (!walletConnectChains.every(chain => networkChains.includes(chain))) {
const unsupportedChains = walletConnectChains.filter(
chain => !networkChains.includes(chain),
);
throw new Error(`Unsupported chains : ${unsupportedChains.join(',')}`);
}
// Get required networks
const requiredNetworks = networksData.filter(network =>
walletConnectChains.includes(`${network.namespace}:${network.chainId}`),
);
// Get accounts for required networks
const requiredAddressesPromise = requiredNetworks.map(
async requiredNetwork => {
const retrievedAccounts = await retrieveAccounts(requiredNetwork);
if (!retrievedAccounts) {
throw new Error('Accounts for given network not found');
}
const addresses = retrievedAccounts.map(
retrieveAccount =>
`${requiredNetwork.namespace}:${requiredNetwork.chainId}:${retrieveAccount.address}`,
);
return addresses;
},
);
const requiredAddressesArray = await Promise.all(requiredAddressesPromise);
const requiredAddresses = requiredAddressesArray.flat();
let sortedAccounts = requiredAddresses;
// If selected network is included in chains requested from dApp,
// Put selected account as first account
if (walletConnectChains.includes(namespaceChainId)) {
const currentAddresses = requiredAddresses.filter(address =>
address.includes(namespaceChainId),
);
sortedAccounts = [
currentAddresses[currentIndex],
...currentAddresses.filter((address, index) => index !== currentIndex),
...requiredAddresses.filter(
address => !currentAddresses.includes(address),
),
];
}
// construct namespace object
const newNamespaces = {
eip155: {
chains: walletConnectChains.filter(chain => chain.includes(EIP155)),
// TODO: Debug optional namespace methods and events being required for approval
methods: [
...Object.values(EIP155_SIGNING_METHODS),
...Object.values(NETWORK_METHODS),
...(optionalNamespaces.eip155?.methods ?? []),
...(requiredNamespaces.eip155?.methods ?? []),
],
events: [
...(optionalNamespaces.eip155?.events ?? []),
...(requiredNamespaces.eip155?.events ?? []),
],
accounts: sortedAccounts.filter(account => account.includes(EIP155)),
},
cosmos: {
chains: walletConnectChains.filter(chain => chain.includes(COSMOS)),
methods: [
...Object.values(COSMOS_METHODS),
...Object.values(NETWORK_METHODS),
...(optionalNamespaces.cosmos?.methods ?? []),
...(requiredNamespaces.cosmos?.methods ?? []),
],
events: [
...(optionalNamespaces.cosmos?.events ?? []),
...(requiredNamespaces.cosmos?.events ?? []),
],
accounts: sortedAccounts.filter(account => account.includes(COSMOS)),
},
};
return newNamespaces;
} else {
// Set selected account as the first account in supported namespaces
const sortedAccounts = [
accounts[currentIndex],
...accounts.filter((account, index) => index !== currentIndex),
];
switch (selectedNetwork.namespace) {
case EIP155:
return {
eip155: {
chains: [namespaceChainId],
// TODO: Debug optional namespace methods and events being required for approval
methods: [
...Object.values(EIP155_SIGNING_METHODS),
...Object.values(NETWORK_METHODS),
...(optionalNamespaces.eip155?.methods ?? []),
...(requiredNamespaces.eip155?.methods ?? []),
],
events: [
...(optionalNamespaces.eip155?.events ?? []),
...(requiredNamespaces.eip155?.events ?? []),
],
accounts: sortedAccounts.map(ethAccount => {
return `${namespaceChainId}:${ethAccount.address}`;
}),
},
cosmos: {
chains: [],
methods: [],
events: [],
accounts: [],
},
};
case COSMOS:
return {
cosmos: {
chains: [namespaceChainId],
methods: [
...Object.values(COSMOS_METHODS),
...Object.values(NETWORK_METHODS),
...(optionalNamespaces.cosmos?.methods ?? []),
...(requiredNamespaces.cosmos?.methods ?? []),
],
events: [
...(optionalNamespaces.cosmos?.events ?? []),
...(requiredNamespaces.cosmos?.events ?? []),
],
accounts: sortedAccounts.map(cosmosAccount => {
return `${namespaceChainId}:${cosmosAccount.address}`;
}),
},
eip155: {
chains: [],
methods: [],
events: [],
accounts: [],
},
};
default:
break;
}
}
};

View File

@ -0,0 +1,221 @@
// Taken from https://medium.com/walletconnect/how-to-build-a-wallet-in-react-native-with-the-web3wallet-sdk-b6f57bf02f9a
import { BigNumber, Wallet, providers } from 'ethers';
import { formatJsonRpcError, formatJsonRpcResult } from '@json-rpc-tools/utils';
// import { SignClientTypes } from '@walletconnect/types';
// import { getSdkError } from '@walletconnect/utils';
import {
SigningStargateClient,
StdFee,
MsgSendEncodeObject,
} from '@cosmjs/stargate';
import { EncodeObject } from '@cosmjs/proto-signing';
import { LaconicClient } from '@cerc-io/registry-sdk';
import { EIP155_SIGNING_METHODS } from './EIP155Data';
import { signDirectMessage, signEthMessage } from '../sign-message';
import { Account } from '../../types';
import { getMnemonic, getPathKey } from '../misc';
import { getCosmosAccounts } from '../accounts';
import { COSMOS_METHODS } from './COSMOSData';
interface EthSendTransaction {
type: 'eth_sendTransaction';
provider: providers.JsonRpcProvider;
ethGasLimit: BigNumber;
ethGasPrice: string | null;
maxPriorityFeePerGas: BigNumber | null;
maxFeePerGas: BigNumber | null;
}
interface SignMessage {
message: string;
}
interface EthPersonalSign extends SignMessage {
type: 'personal_sign';
}
interface CosmosSignDirect extends SignMessage {
type: 'cosmos_signDirect';
}
interface CosmosSignAmino extends SignMessage {
type: 'cosmos_signAmino';
}
interface CosmosSendTokens {
type: 'cosmos_sendTokens';
signingStargateClient: SigningStargateClient;
cosmosFee: StdFee;
sendMsg: MsgSendEncodeObject;
memo: string;
}
interface CosmosSendTransaction {
type: 'cosmos_sendTransaction';
LaconicClient: LaconicClient;
cosmosFee: StdFee;
txMsg: EncodeObject;
}
export type WalletConnectRequests =
| EthSendTransaction
| EthPersonalSign
| CosmosSignDirect
| CosmosSignAmino
| CosmosSendTokens
| CosmosSendTransaction;
export async function approveWalletConnectRequest(
// requestEvent: SignClientTypes.EventArguments['session_request'],
requestEvent: any,
account: Account,
namespace: string,
chainId: string,
options: WalletConnectRequests,
) {
const { params, id } = requestEvent;
const { request } = params;
const path = (await getPathKey(`${namespace}:${chainId}`, account.index))
.path;
const mnemonic = await getMnemonic();
const cosmosAccount = await getCosmosAccounts(mnemonic, path);
const address = account.address;
switch (request.method) {
case EIP155_SIGNING_METHODS.ETH_SEND_TRANSACTION:
if (!(options.type === 'eth_sendTransaction')) {
throw new Error('Incorrect parameters passed');
}
const privKey = (
await getPathKey(`${namespace}:${chainId}`, account.index)
).privKey;
const wallet = new Wallet(privKey);
const sendTransaction = request.params[0];
const updatedTransaction =
options.maxFeePerGas && options.maxPriorityFeePerGas
? {
...sendTransaction,
gasLimit: options.ethGasLimit,
maxFeePerGas: options.maxFeePerGas,
maxPriorityFeePerGas: options.maxPriorityFeePerGas,
type: 2,
}
: {
...sendTransaction,
gasLimit: options.ethGasLimit,
gasPrice: options.ethGasPrice,
type: 0,
};
const connectedWallet = wallet.connect(options.provider);
const hash = await connectedWallet.sendTransaction(updatedTransaction);
const receipt = typeof hash === 'string' ? hash : hash?.hash;
return formatJsonRpcResult(id, {
signature: receipt,
});
case EIP155_SIGNING_METHODS.PERSONAL_SIGN:
if (!(options.type === 'personal_sign')) {
throw new Error('Incorrect parameters passed');
}
const ethSignature = await signEthMessage(
options.message,
account.index,
chainId,
);
return formatJsonRpcResult(id, ethSignature);
case COSMOS_METHODS.COSMOS_SIGN_DIRECT:
// Reference: https://github.com/confio/cosmjs-types/blob/66e52711914fccd2a9d1a03e392d3628fdf499e2/src/cosmos/tx/v1beta1/tx.ts#L51
// According above doc, in the signDoc interface 'bodyBytes' and 'authInfoBytes' have Uint8Array type
if (!(options.type === 'cosmos_signDirect')) {
throw new Error('Incorrect parameters passed');
}
const bodyBytesArray = Uint8Array.from(
Buffer.from(request.params.signDoc.bodyBytes, 'hex'),
);
const authInfoBytesArray = Uint8Array.from(
Buffer.from(request.params.signDoc.authInfoBytes, 'hex'),
);
const cosmosDirectSignature = await signDirectMessage(
`${namespace}:${chainId}`,
account.index,
{
...request.params.signDoc,
bodyBytes: bodyBytesArray,
authInfoBytes: authInfoBytesArray,
},
);
return formatJsonRpcResult(id, {
signature: cosmosDirectSignature,
});
case COSMOS_METHODS.COSMOS_SIGN_AMINO:
if (!(options.type === 'cosmos_signAmino')) {
throw new Error('Incorrect parameters passed');
}
const cosmosAminoSignature = await cosmosAccount.cosmosWallet.signAmino(
address,
request.params.signDoc,
);
if (!cosmosAminoSignature) {
throw new Error('Error signing message');
}
return formatJsonRpcResult(id, {
signature: cosmosAminoSignature.signature.signature,
});
case COSMOS_METHODS.COSMOS_SEND_TOKENS:
if (!(options.type === 'cosmos_sendTokens')) {
throw new Error('Incorrect parameters passed');
}
const result = await options.signingStargateClient.signAndBroadcast(
address,
[options.sendMsg],
options.cosmosFee,
options.memo,
);
return formatJsonRpcResult(id, {
signature: result.transactionHash,
});
case COSMOS_METHODS.COSMOS_SEND_TRANSACTION:
if (!(options.type === 'cosmos_sendTransaction')) {
throw new Error('Incorrect parameters passed');
}
const resultFromTx = await options.LaconicClient.signAndBroadcast(
address,
[options.txMsg],
options.cosmosFee,
);
return formatJsonRpcResult(id, {
code: resultFromTx.code,
});
default:
// throw new Error(getSdkError('INVALID_METHOD').message);
}
}
export function rejectWalletConnectRequest(
// request: SignClientTypes.EventArguments['session_request'],
request: any,
) {
// const { id } = request;
// return formatJsonRpcError(id, getSdkError('USER_REJECTED_METHODS').message);
}

26
tsconfig.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}

14124
yarn.lock Normal file

File diff suppressed because it is too large Load Diff