Integrate wallet-connect and add functionality to configure networks #3

Merged
ashwin merged 64 commits from wallet-connect-integration into main 2024-04-25 12:16:12 +00:00
67 changed files with 6590 additions and 1336 deletions

4
.env.example Normal file
View File

@ -0,0 +1,4 @@
WALLET_CONNECT_PROJECT_ID=
DEFAULT_GAS_PRICE=
# Reference: https://github.com/cosmos/cosmos-sdk/issues/16020
DEFAULT_GAS_ADJUSTMENT=2

View File

@ -1,4 +1,12 @@
module.exports = {
root: true,
extends: '@react-native',
rules: {
'@typescript-eslint/no-unused-vars': [
'error',
{
ignoreRestSiblings: true,
},
],
},
};

2
.gitignore vendored
View File

@ -64,3 +64,5 @@ yarn-error.log
# testing
/coverage
.env

1
.husky/pre-commit Normal file
View File

@ -0,0 +1 @@
yarn lint

66
App.tsx
View File

@ -1,66 +0,0 @@
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import SignMessage from './components/SignMessage';
import HomeScreen from './components/HomeScreen';
import SignRequest from './components/SignRequest';
import InvalidPath from './components/InvalidPath';
import { StackParamsList } from './types';
const Stack = createNativeStackNavigator<StackParamsList>();
const App = (): React.JSX.Element => {
const linking = {
prefixes: ['https://www.laconic-wallet.com'],
config: {
screens: {
SignRequest: {
path: 'sign/:network/:address/:message',
},
},
},
};
return (
<NavigationContainer linking={linking}>
<Stack.Navigator>
<Stack.Screen
name="Laconic"
component={HomeScreen}
options={{
title: 'Laconic Wallet',
headerBackVisible: false,
}}
/>
<Stack.Screen
name="SignMessage"
component={SignMessage}
options={{
title: 'Sign Message',
}}
initialParams={{ selectedNetwork: 'Ethereum' }}
/>
<Stack.Screen
name="SignRequest"
component={SignRequest}
options={{
title: 'Sign Message?',
}}
/>
<Stack.Screen
name="InvalidPath"
component={InvalidPath}
options={{
title: 'Bad Request',
headerBackVisible: false,
}}
/>
</Stack.Navigator>
</NavigationContainer>
);
};
export default App;

View File

@ -1,6 +1,6 @@
# laconic-wallet
### Install
## Install
- Install [Node](https://nodejs.org/en/download/package-manager/)
@ -55,7 +55,7 @@
echo $ANDROID_HOME
```
- and that the appropriate directories have been added to your path by running
- Check that the appropriate directories have been added to your path by running
```bash
echo $PATH
@ -63,53 +63,66 @@
## Setup for laconic-wallet
1. Clone this repository:
1. Clone the repository
```
git clone git@git.vdb.to:cerc-io/laconic-wallet.git
```
2. Enter the project directory:
2. Enter the project directory
```
cd laconic-wallet
```
3. Install the dependencies:
3. Install dependencies
```
yarn
```
4. Set up the Android device:
4. Setup .env
- Copy and update [`.env`](./.env)
- For a physical device, refer to the [React Native documentation for running on a physical device]("https://reactnative.dev/docs/running-on-device)
```
cp .env.example .env
```
- For a virtual device, continue with the steps.
- In the `.env` file add your WalletConnect project id. You can generate your own ProjectId at https://cloud.walletconnect.com
5. Start the application
```
WALLET_CONNECT_PROJECT_ID=39bc93c...
```
5. Set up the Android device
- For a physical device, refer to the [React Native documentation for running on a physical device](https://reactnative.dev/docs/running-on-device)
- For a virtual device, continue with the steps
6. Start the application
```
yarn start
```
6. Press `a` to run the application on android
7. Press `a` to run the application on android
## Setup for example-signer-app
## Setup for signature-requester-app
1. Clone the repository:
1. Clone the repository
```
git clone git@git.vdb.to:cerc-io/signature-requester-app.git
```
2. Enter the project directory:
2. Enter the project directory
```
cd example-signer-app
cd signature-requester-app
```
3. Install the dependancies
3. Install dependencies
```
yarn
```
@ -120,4 +133,32 @@
```
5. Press `a` to run the application on android
You should see both the apps running on your emulator or physical device.
You should see both the apps running on your emulator or physical device
## Flow for the app
- User scans QR Code on dApp from wallet to connect
- After clicking on pair button, dApp emits an event 'session_proposal'
- Wallet listens to this event and opens a modal to either accept or reject the proposal
- Modal shows information about methods, chains and events the dApp is requesting for
- This information is taken from [namespaces](https://docs.walletconnect.com/advanced/glossary#namespaces) object received from dApp
- On accepting, wallet sends the [namespaces](https://docs.walletconnect.com/advanced/glossary#namespaces) object with information about the accounts that are present in the wallet in response
- Once the [session](https://docs.walletconnect.com/advanced/glossary#session) is established, it is shown on the active sessions page
- The wallet can be connected to multiple dApps
## Troubleshooting
- To clean the build
```
cd android
./gradlew clean
```

View File

@ -4,10 +4,10 @@
import 'react-native';
import React from 'react';
import App from '../App';
import App from '../src/App';
// Note: import explicitly to use the types shipped with jest.
import {it} from '@jest/globals';
import { it } from '@jest/globals';
// Note: test renderer must be required after react-native.
import renderer from 'react-test-renderer';

View File

@ -1,7 +1,8 @@
apply plugin: "com.android.application"
apply from: project(':react-native-config').projectDir.getPath() + "/dotenv.gradle"
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "com.facebook.react"
apply from: file("../../node_modules/react-native-vector-icons/fonts.gradle")
apply from: file("../../node_modules/react-native-vector-icons/fonts.gradle")
/**
* This is the configuration block to customize your React Native Android app.
@ -75,7 +76,7 @@ android {
packagingOptions {
pickFirst '**/libcrypto.so'
}
ndkVersion rootProject.ext.ndkVersion
buildToolsVersion rootProject.ext.buildToolsVersion
compileSdk rootProject.ext.compileSdkVersion

View File

@ -1,6 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.laconic.wallet">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<application
android:name=".MainApplication"
@ -25,9 +26,9 @@
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="www.laconic-wallet.com"
android:host="wallet.laconic.com"
android:pathPrefix="/sign"
android:scheme="https" />
android:scheme="https"/>
</intent-filter>
</activity>
</application>

View File

@ -39,3 +39,5 @@ newArchEnabled=false
# Use this property to enable or disable the Hermes JS engine.
# If set to false, you will be using JSC instead.
hermesEnabled=true
VisionCamera_enableCodeScanner=true

View File

@ -1,123 +0,0 @@
import React, { useState } from 'react';
import { ScrollView, TouchableOpacity, View } from 'react-native';
import { Button, List, Text, useTheme } from 'react-native-paper';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { AccountsProps, StackParamsList, Account } from '../types';
import { addAccount } from '../utils/accounts';
import styles from '../styles/stylesheet';
import HDPathDialog from './HDPathDialog';
import AccountDetails from './AccountDetails';
const Accounts = ({
network,
accounts,
updateAccounts,
currentIndex,
updateIndex: updateId,
}: AccountsProps) => {
const navigation =
useNavigation<NativeStackNavigationProp<StackParamsList>>();
const [expanded, setExpanded] = useState(false);
const [isAccountCreating, setIsAccountCreating] = useState(false);
const [hdDialog, setHdDialog] = useState(false);
const [pathCode, setPathCode] = useState('');
const theme = useTheme();
const handlePress = () => setExpanded(!expanded);
const addAccountHandler = async () => {
setIsAccountCreating(true);
const newAccount = await addAccount(network);
setIsAccountCreating(false);
if (newAccount) {
updateAccounts(newAccount);
updateId(newAccount.counterId);
}
};
const selectedAccounts: Account[] = (() => {
switch (network) {
case 'eth':
return accounts.ethAccounts;
case 'cosmos':
return accounts.cosmosAccounts;
default:
return [];
}
})();
const renderAccountItems = () =>
selectedAccounts.map(account => (
<List.Item
key={account.counterId}
title={`Account ${account.counterId + 1}`}
onPress={() => {
updateId(account.counterId);
setExpanded(false);
}}
/>
));
return (
<ScrollView>
<View>
<HDPathDialog
visible={hdDialog}
hideDialog={() => setHdDialog(false)}
updateAccounts={updateAccounts}
updateIndex={updateId}
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}>
{isAccountCreating ? 'Adding' : 'Add Account'}
</Button>
</View>
<View style={styles.addAccountButton}>
<Button
mode="contained"
onPress={() => {
setHdDialog(true);
setPathCode(network === 'eth' ? "m/44'/60'/" : "m/44'/118'/");
}}>
Add Account from HD path
</Button>
</View>
<AccountDetails account={selectedAccounts[currentIndex]} />
<View style={styles.signLink}>
<TouchableOpacity
onPress={() => {
navigation.navigate('SignMessage', {
selectedNetwork: network,
accountInfo: selectedAccounts[currentIndex],
});
}}>
<Text
variant="titleSmall"
style={[styles.hyperlink, { color: theme.colors.primary }]}>
Sign Message
</Text>
</TouchableOpacity>
</View>
</View>
</ScrollView>
);
};
export default Accounts;

View File

@ -1,163 +0,0 @@
import React, { useEffect, useState } from 'react';
import { View, ActivityIndicator } from 'react-native';
import { Button, Text } from 'react-native-paper';
import { createWallet, resetWallet, retrieveAccounts } from '../utils/accounts';
import { DialogComponent } from './Dialog';
import { NetworkDropdown } from './NetworkDropdown';
import { Account, AccountsState } from '../types';
import Accounts from './Accounts';
import CreateWallet from './CreateWallet';
import ResetWalletDialog from './ResetWalletDialog';
import styles from '../styles/stylesheet';
const HomeScreen = () => {
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 [network, setNetwork] = useState<string>('eth');
const [currentIndex, setCurrentIndex] = useState<number>(0);
const [isAccountsFetched, setIsAccountsFetched] = useState<boolean>(false);
const [phrase, setPhrase] = useState('');
const [accounts, setAccounts] = useState<AccountsState>({
ethAccounts: [],
cosmosAccounts: [],
});
const hideWalletDialog = () => setWalletDialog(false);
const hideResetDialog = () => setResetWalletDialog(false);
const createWalletHandler = async () => {
setIsWalletCreating(true);
const { mnemonic, ethAccounts, cosmosAccounts } = await createWallet();
if (ethAccounts && cosmosAccounts) {
setAccounts({
ethAccounts: [...accounts.ethAccounts, ethAccounts],
cosmosAccounts: [...accounts.cosmosAccounts, cosmosAccounts],
});
setWalletDialog(true);
setIsWalletCreated(true);
setPhrase(mnemonic);
}
};
const confirmResetWallet = async () => {
await resetWallet();
setIsWalletCreated(false);
setIsWalletCreating(false);
setAccounts({
ethAccounts: [],
cosmosAccounts: [],
});
setCurrentIndex(0);
hideResetDialog();
setNetwork('eth');
};
const updateNetwork = (newNetwork: string) => {
setNetwork(newNetwork);
setCurrentIndex(0);
};
const updateIndex = (index: number) => {
setCurrentIndex(index);
};
const updateAccounts = (account: Account) => {
switch (network) {
case 'eth':
setAccounts({
...accounts,
ethAccounts: [...accounts.ethAccounts, account],
});
break;
case 'cosmos':
setAccounts({
...accounts,
cosmosAccounts: [...accounts.cosmosAccounts, account],
});
break;
default:
console.error('Select a valid network!');
}
};
useEffect(() => {
const fetchAccounts = async () => {
if (isAccountsFetched) {
return;
}
const { ethLoadedAccounts, cosmosLoadedAccounts } =
await retrieveAccounts();
if (ethLoadedAccounts && cosmosLoadedAccounts) {
setAccounts({
ethAccounts: ethLoadedAccounts,
cosmosAccounts: cosmosLoadedAccounts,
});
setIsWalletCreated(true);
}
setIsAccountsFetched(true);
};
fetchAccounts();
}, [isAccountsFetched]);
return (
<View style={styles.appContainer}>
{!isAccountsFetched ? (
<View style={styles.spinnerContainer}>
<Text style={styles.LoadingText}>Loading...</Text>
<ActivityIndicator size="large" color="#0000ff" />
</View>
) : isWalletCreated ? (
<>
<NetworkDropdown
selectedNetwork={network}
updateNetwork={updateNetwork}
/>
<View style={styles.accountComponent}>
<Accounts
network={network}
accounts={accounts}
currentIndex={currentIndex}
updateIndex={updateIndex}
updateAccounts={updateAccounts}
/>
</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}
/>
<ResetWalletDialog
visible={resetWalletDialog}
hideDialog={hideResetDialog}
onConfirm={confirmResetWallet}
/>
</View>
);
};
export default HomeScreen;

View File

@ -1,43 +0,0 @@
import React, { useState } from 'react';
import { View } from 'react-native';
import { List } from 'react-native-paper';
import { NetworkDropdownProps } from '../types';
import styles from '../styles/stylesheet';
const NetworkDropdown = ({ updateNetwork }: NetworkDropdownProps) => {
const [expanded, setExpanded] = useState<boolean>(false);
const [selectedNetwork, setSelectedNetwork] = useState<string>('Ethereum');
const handleNetworkPress = (network: string, displayName: string) => {
updateNetwork(network);
setSelectedNetwork(displayName);
setExpanded(false);
};
return (
<View style={styles.networkDropdown}>
<List.Accordion
title={selectedNetwork}
expanded={expanded}
onPress={() => setExpanded(!expanded)}>
{networks.map(network => (
<List.Item
key={network.value}
title={network.displayName}
onPress={() =>
handleNetworkPress(network.value, network.displayName)
}
/>
))}
</List.Accordion>
</View>
);
};
const networks = [
{ value: 'eth', displayName: 'Ethereum' },
{ value: 'cosmos', displayName: 'Cosmos' },
];
export { NetworkDropdown };

View File

@ -1,129 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Alert, View } from 'react-native';
import { ActivityIndicator, Button, Text } from 'react-native-paper';
import { useNavigation } from '@react-navigation/native';
import {
NativeStackNavigationProp,
NativeStackScreenProps,
} from '@react-navigation/native-stack';
import { Account, StackParamsList } from '../types';
import AccountDetails from './AccountDetails';
import styles from '../styles/stylesheet';
import { signMessage } from '../utils/sign-message';
import { retrieveSingleAccount } from '../utils/accounts';
type SignRequestProps = NativeStackScreenProps<StackParamsList, 'SignRequest'>;
const SignRequest = ({ route }: SignRequestProps) => {
const [account, setAccount] = useState<Account>();
const [message, setMessage] = useState<string>('');
const [network, setNetwork] = useState<string>('');
const [loaded, setLoaded] = useState(false);
const navigation =
useNavigation<NativeStackNavigationProp<StackParamsList>>();
const retrieveData = async (
requestNetwork: string,
requestAddress: string,
requestMessage: string,
) => {
const requestAccount = await retrieveSingleAccount(
requestNetwork,
requestAddress,
);
if (!requestAccount) {
navigation.navigate('InvalidPath');
}
if (requestAccount && requestAccount !== account) {
setAccount(requestAccount);
}
if (requestMessage && requestMessage !== message) {
setMessage(decodeURIComponent(requestMessage));
}
if (requestNetwork && requestNetwork !== network) {
setNetwork(requestNetwork);
}
};
const sanitizePath = (path: string) => {
const regex = /^\/sign\/(eth|cosmos)\/(.+)\/(.+)$/;
const match = path.match(regex);
if (match) {
const [network, address, message] = match;
return {
network,
address,
message,
};
} else {
navigation.navigate('InvalidPath');
}
return null;
};
useEffect(() => {
setLoaded(true);
if (route.path) {
const sanitizedRoute = sanitizePath(route.path);
sanitizedRoute &&
route.params &&
retrieveData(
route.params?.network,
route.params?.address,
route.params?.message,
);
}
setLoaded(false);
}, [route]);
const signMessageHandler = async () => {
if (!account) {
throw new Error('Account is not valid');
}
if (message) {
const signedMessage = await signMessage({
message,
network,
accountId: account.counterId,
});
Alert.alert('Signature', signedMessage);
navigation.navigate('Laconic');
}
};
const rejectRequestHandler = async () => {
navigation.navigate('Laconic');
};
return (
<>
{!loaded ? (
<View style={styles.appContainer}>
<AccountDetails account={account} />
<View style={styles.requestMessage}>
<Text variant="bodyLarge">{message}</Text>
</View>
<View style={styles.buttonContainer}>
<Button
mode="contained"
onPress={rejectRequestHandler}
buttonColor="#B82B0D">
No
</Button>
<Button mode="contained" onPress={signMessageHandler}>
Yes
</Button>
</View>
</View>
) : (
<ActivityIndicator />
)}
</>
);
};
export default SignRequest;

160
development.md Normal file
View File

@ -0,0 +1,160 @@
# Development
## WalletConnect details
- Docs - https://docs.walletconnect.com/api/sign/overview
- Doc for terminologies - https://docs.walletconnect.com/advanced/glossary
- Function for creating web3 wallet
```js
export let web3wallet: IWeb3Wallet;
export let core: ICore;
export async function createWeb3Wallet() {
const 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'],
},
});
}
```
- Hook used for intializing web3 wallet
```js
export default function useInitialization() {
const [initialized, setInitialized] = useState(false);
const onInitialize = useCallback(async () => {
try {
await createWeb3Wallet();
setInitialized(true);
} catch (err: unknown) {
console.log('Error for initializing', err);
}
}, []);
useEffect(() => {
if (!initialized) {
onInitialize();
}
}, [initialized, onInitialize]);
return initialized;
}
```
- Once user clicks on pair, this function is triggered
```js
export async function web3WalletPair(params: { uri: string }) {
return await web3wallet.core.pairing.pair({ uri: params.uri });
}
```
- In a useEffect, we keep on listening to events that are emitted by dapp and do actions based on it
```js
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);
}
})
```
- Signing messages
- Cosmos methods info
- The signDoc format for signAmino and signDirect is different
- Reference - https://docs.leapwallet.io/cosmos/for-dapps-connect-to-leap/api-reference
- For signDirect, the message is protobuf encoded , hence to sign it , we have to first convert it to uint8Array
```js
const bodyBytesArray = Uint8Array.from(
Buffer.from(request.params.signDoc.bodyBytes, 'hex'),
);
const authInfoBytesArray = Uint8Array.from(
Buffer.from(request.params.signDoc.authInfoBytes, 'hex'),
);
```
- This will give us correct signature
## Data structure of keystore
```json
{
// Accounts data -> hdpath, privateKey, publicKey, address
"accounts/eip155:1/0":{
"username": "",
"password": "m/44'/60'/0'/0/0,0x0654623fe7a0e3d74f518e22818c1cfd58517e80a232df5d6d20a3afd99fd3bb,0x02cced178c903835bb29e337fa227ba0204a3285eb35797b766ed975b478b4beb6,0xe5fA0Dd92659e287e5e3Fa582Ee20fcf74fb1116"
},
"accounts/cosmos:cosmoshub-4/0":{
"username": "",
"password": "m/44'/118'/0'/0/0,0xbb9ccc3029178a61ba847c22108318ba119220451c99e6358efd7b85d7d49ed5,0x03a8002f56f99126f930ca3ac1963731e3f08df30822031db7c1dd50851bdfcc3c,cosmos1s5a5ls3d6xmks5fc8tst9x7tx2jv8mfjmn0aq8"
},
// Networks Data
"networks":{
"username":"",
"password":[
{
"networkId":"1",
"namespace": "eip155",
"chainId": "1",
"networkName": "Ethereum",
"rpcUrl": "https://cloudflare-eth.com/",
"currencySymbol": "ETH",
"coinType": "60"
},
{
"networkId":"2",
"namespace": "cosmos",
"chainId": "theta-testnet-001",
"networkName": "Cosmos Hub Testnet",
"rpcUrl": "https://rpc-t.cosmos.nodestake.top",
"nativeDenom": "ATOM",
"addressPrefix": "cosmos",
"coinType": "118"
}
]
},
// Stores count of total accounts for specific chain
"addAccountCounter/eip155:1":{
"username": "",
"password": "3"
},
"addAccountCounter/cosmos:cosmoshub-4":{
"username": "",
"password": "3"
},
// Stores ids of accounts for specific chain
"accountIndices/eip155:1":{
"username": "",
"password": "0,1,2"
},
"accountIndices/cosmos:cosmoshub-4":{
"username": "",
"password": "0,1,2"
}
}
```

View File

@ -3,13 +3,36 @@ import 'text-encoding-polyfill';
import { AppRegistry } from 'react-native';
import { PaperProvider } from 'react-native-paper';
import App from './App';
import { NavigationContainer } from '@react-navigation/native';
import App from './src/App';
import { AccountsProvider } from './src/context/AccountsContext';
import { NetworksProvider } from './src/context/NetworksContext';
import { WalletConnectProvider } from './src/context/WalletConnectContext';
import { name as appName } from './app.json';
export default function Main() {
const linking = {
prefixes: ['https://wallet.laconic.com'],
config: {
screens: {
SignRequest: {
path: 'sign/:namespace/:chaindId/:address/:message',
},
},
},
};
return (
<PaperProvider>
<App />
<PaperProvider theme={'light'}>
<NetworksProvider>
<AccountsProvider>
<WalletConnectProvider>
<NavigationContainer linking={linking}>
<App />
</NavigationContainer>
</WalletConnectProvider>
</AccountsProvider>
</NetworksProvider>
</PaperProvider>
);
}

View File

@ -1,4 +1,4 @@
const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config');
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
/**
* Metro configuration

View File

@ -5,23 +5,38 @@
"scripts": {
"android": "react-native run-android",
"ios": "react-native run-ios",
"lint": "eslint .",
"lint": "eslint . --max-warnings=0",
"start": "react-native start",
"test": "jest",
"postinstall": "patch-package"
"postinstall": "patch-package",
"prepare": "husky"
},
"dependencies": {
"@cosmjs/amino": "^0.32.2",
"@cosmjs/crypto": "^0.32.2",
"@cosmjs/amino": "^0.32.3",
"@cosmjs/crypto": "^0.32.3",
"@cosmjs/proto-signing": "^0.32.3",
"@cosmjs/stargate": "^0.32.3",
"@ethersproject/shims": "^5.7.0",
"@hookform/resolvers": "^3.3.4",
"@json-rpc-tools/utils": "^1.7.6",
"@react-native-async-storage/async-storage": "^1.22.3",
"@react-native-community/netinfo": "^11.3.1",
"@react-navigation/native": "^6.1.10",
"@react-navigation/native-stack": "^6.9.18",
"ethers": "5",
"@walletconnect/react-native-compat": "^2.11.2",
"@walletconnect/web3wallet": "^1.10.2",
"chain-registry": "^1.41.2",
"cosmjs-types": "^0.9.0",
"ethers": "5.7.2",
"fast-text-encoding": "^1.0.6",
"lodash": "^4.17.21",
"metro-react-native-babel-preset": "^0.77.0",
"patch-package": "^8.0.0",
"postinstall-postinstall": "^2.1.0",
"react": "18.2.0",
"react-hook-form": "^7.51.2",
"react-native": "0.73.3",
"react-native-config": "^1.5.1",
"react-native-get-random-values": "^1.10.0",
"react-native-keychain": "^8.1.2",
"react-native-paper": "^5.12.3",
@ -29,8 +44,12 @@
"react-native-quick-crypto": "^0.6.1",
"react-native-safe-area-context": "^4.9.0",
"react-native-screens": "^3.29.0",
"react-native-svg": "^15.1.0",
"react-native-vector-icons": "^10.0.3",
"text-encoding-polyfill": "^0.6.7"
"react-native-vision-camera": "^3.9.0",
"text-encoding-polyfill": "^0.6.7",
"use-debounce": "^10.0.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@babel/core": "^7.20.0",
@ -40,11 +59,15 @@
"@react-native/eslint-config": "0.73.2",
"@react-native/metro-config": "0.73.4",
"@react-native/typescript-config": "0.73.1",
"@types/lodash": "^4.17.0",
"@types/react": "^18.2.6",
"@types/react-native-vector-icons": "^6.4.18",
"@types/react-test-renderer": "^18.0.0",
"@walletconnect/jsonrpc-types": "^1.0.3",
"babel-jest": "^29.6.3",
"babel-plugin-module-resolver": "^5.0.0",
"eslint": "^8.19.0",
"husky": "^9.0.11",
"jest": "^29.6.3",
"metro-babel7-plugin-react-transform": "^0.54.1",
"prettier": "2.8.8",

11
react-native-config.d.ts vendored Normal file
View File

@ -0,0 +1,11 @@
// Reference: https://github.com/lugg/react-native-config?tab=readme-ov-file#typescript-declaration-for-your-env-file
declare module 'react-native-config' {
export interface NativeConfig {
WALLET_CONNECT_PROJECT_ID: string;
DEFAULT_GAS_PRICE: string;
DEFAULT_GAS_ADJUSTMENT: string;
}
export const Config: NativeConfig;
export default Config;
}

310
src/App.tsx Normal file
View File

@ -0,0 +1,310 @@
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 { 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 ApproveTransaction from './screens/ApproveTransaction';
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';
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('ApproveTransaction', {
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_signDirect':
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_signAmino':
navigation.navigate('SignRequest', {
namespace: COSMOS,
address: request.params.signerAddress,
message: request.params.signDoc.memo,
requestEvent,
requestSessionData,
});
break;
case 'cosmos_sendTokens':
navigation.navigate('ApproveTransaction', {
transaction: request.params[0],
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);
};
//TODO: Investigate dependencies
});
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="ApproveTransaction"
component={ApproveTransaction}
options={{
title: 'Approve transaction',
}}
/>
<Stack.Screen
name="AddNetwork"
component={AddNetwork}
options={{
title: 'Add Network',
}}
/>
<Stack.Screen
name="EditNetwork"
component={EditNetwork}
options={{
title: 'Edit Network',
}}
/>
</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

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

@ -0,0 +1,226 @@
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 { AccountsProps, 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';
const Accounts = ({ currentIndex, updateIndex }: AccountsProps) => {
const navigation =
useNavigation<NativeStackNavigationProp<StackParamsList>>();
const { accounts, setAccounts, setCurrentIndex } = 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);
updateIndex(newAccount.index);
}
};
const renderAccountItems = () =>
accounts.map(account => (
<List.Item
key={account.index}
title={`Account ${account.index + 1}`}
onPress={() => {
updateIndex(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}
updateIndex={updateIndex}
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}
/>
</View>
</ScrollView>
);
};
export default Accounts;

View File

@ -2,7 +2,8 @@ import React from 'react';
import { Portal, Dialog, Button, Text } from 'react-native-paper';
import { ResetDialogProps } from '../types';
const ResetWalletDialog = ({
const ConfirmDialog = ({
title,
visible,
hideDialog,
onConfirm,
@ -10,7 +11,7 @@ const ResetWalletDialog = ({
return (
<Portal>
<Dialog visible={visible} onDismiss={hideDialog}>
<Dialog.Title>Reset Wallet</Dialog.Title>
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Content>
<Text variant="bodyMedium">Are you sure?</Text>
</Dialog.Content>
@ -25,4 +26,4 @@ const ResetWalletDialog = ({
);
};
export default ResetWalletDialog;
export default ConfirmDialog;

View File

@ -15,6 +15,7 @@ const CreateWallet = ({
<Button
mode="contained"
loading={isWalletCreating}
disabled={isWalletCreating}
onPress={createWalletHandler}>
{isWalletCreating ? 'Creating' : 'Create Wallet'}
</Button>

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;

View File

@ -3,7 +3,7 @@ import { ScrollView, View, Text } from 'react-native';
import { Button, TextInput } from 'react-native-paper';
import { addAccountFromHDPath } from '../utils/accounts';
import { Account, PathState } from '../types';
import { Account, NetworksDataState, PathState } from '../types';
import styles from '../styles/stylesheet';
const HDPath = ({
@ -11,11 +11,13 @@ const HDPath = ({
updateAccounts,
updateIndex,
hideDialog,
selectedNetwork,
}: {
pathCode: string;
updateIndex: (index: number) => void;
updateAccounts: (account: Account) => void;
hideDialog: () => void;
selectedNetwork: NetworksDataState;
}) => {
const [isAccountCreating, setIsAccountCreating] = useState(false);
const [path, setPath] = useState<PathState>({
@ -41,10 +43,10 @@ const HDPath = ({
pathCode +
`${path.firstNumber}'/${path.secondNumber}/${path.thirdNumber}`;
try {
const newAccount = await addAccountFromHDPath(hdPath);
const newAccount = await addAccountFromHDPath(hdPath, selectedNetwork);
if (newAccount) {
updateAccounts(newAccount);
updateIndex(newAccount.counterId);
updateIndex(newAccount.index);
hideDialog();
}
} catch (error) {
@ -86,7 +88,8 @@ const HDPath = ({
<Button
mode="contained"
onPress={createFromHDPathHandler}
loading={isAccountCreating}>
loading={isAccountCreating}
disabled={isAccountCreating}>
{isAccountCreating ? 'Adding' : 'Add Account'}
</Button>
</View>

View File

@ -1,7 +1,9 @@
import React from 'react';
import { Portal, Dialog } from 'react-native-paper';
import { HDPathDialogProps } from '../types';
import HDPath from './HDPath';
import { useNetworks } from '../context/NetworksContext';
const HDPathDialog = ({
visible,
@ -10,12 +12,15 @@ const HDPathDialog = ({
updateAccounts,
pathCode,
}: HDPathDialogProps) => {
const { selectedNetwork } = useNetworks();
return (
<Portal>
<Dialog visible={visible} onDismiss={hideDialog}>
<Dialog.Title>Add account from HD path</Dialog.Title>
<Dialog.Content>
<HDPath
selectedNetwork={selectedNetwork!}
pathCode={pathCode}
updateIndex={updateIndex}
updateAccounts={updateAccounts}

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,310 @@
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 [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;
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,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,42 @@
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>
>({});
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;
}

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

@ -0,0 +1,418 @@
import React, { useCallback, useEffect, useState } from 'react';
import { ScrollView } from 'react-native';
import { useForm, Controller, useWatch, FieldErrors } from 'react-hook-form';
import { TextInput, Button, HelperText } from 'react-native-paper';
import {
getInternetCredentials,
setInternetCredentials,
} from 'react-native-keychain';
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 styles from '../styles/stylesheet';
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';
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 && mnemonicServer.password;
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 (
<ScrollView contentContainerStyle={styles.signPage}>
<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>
</ScrollView>
);
};
export default AddNetwork;

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

@ -0,0 +1,119 @@
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 ? (
<>
<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,612 @@
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,
} from '../utils/wallet-connect/WalletConnectRequests';
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';
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,
'ApproveTransaction'
>;
const ApproveTransaction = ({ 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 [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()})`,
);
}
const response = await approveWalletConnectRequest({
requestEvent,
account,
namespace,
chainId: requestedNetwork!.chainId,
provider: namespace === EIP155 ? provider : cosmosStargateClient,
// StdFee object
cosmosFee: {
// This amount is total fees required for transaction
amount: [
{
amount: fees!,
denom: requestedNetwork!.nativeDenom!,
},
],
gas: cosmosGasLimit!,
},
ethGasLimit:
namespace === EIP155 ? BigNumber.from(ethGasLimit) : undefined,
ethGasPrice: ethGasPrice?.toHexString(),
maxPriorityFeePerGas: ethMaxPriorityFee ?? undefined,
maxFeePerGas: ethMaxFee ?? undefined,
sendMsg,
memo: MEMO,
});
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.approveTransaction}>
<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 ApproveTransaction;

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

@ -0,0 +1,197 @@
import React, { useCallback } from 'react';
import { ScrollView, View } from 'react-native';
import { useForm, Controller, FieldErrors } from 'react-hook-form';
import { TextInput, Button, HelperText, Text } from 'react-native-paper';
import { setInternetCredentials } from 'react-native-keychain';
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 { 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 (
<ScrollView contentContainerStyle={styles.signPage}>
<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>
</ScrollView>
);
};
export default EditNetwork;

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

@ -0,0 +1,180 @@
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, currentIndex, 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 [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);
};
const updateIndex = (index: number) => {
setCurrentIndex(index);
};
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 currentIndex={currentIndex} updateIndex={updateIndex} />
</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

@ -13,7 +13,9 @@ const InvalidPath = () => {
useNavigation<NativeStackNavigationProp<StackParamsList>>();
return (
<View style={styles.badRequestContainer}>
<Text style={styles.messageText}>The signature request was invalid.</Text>
<Text style={styles.invalidMessageText}>
The signature request was invalid.
</Text>
<Button
mode="contained"
onPress={() => {

View File

@ -7,36 +7,33 @@ 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 './AccountDetails';
import AccountDetails from '../components/AccountDetails';
type SignProps = NativeStackScreenProps<StackParamsList, 'SignMessage'>;
const SignMessage = ({ route }: SignProps) => {
const network = route.params?.selectedNetwork;
const account = route.params?.accountInfo;
const namespace = route.params.selectedNamespace;
const chainId = route.params.selectedChainId;
const account = route.params.accountInfo;
const [message, setMessage] = useState<string>('');
const signMessageHandler = async () => {
if (network) {
if (!account) {
throw new Error('Account is not valid');
}
const signedMessage = await signMessage({
message,
network,
accountId: account.counterId,
});
Alert.alert('Signature', signedMessage);
}
const signedMessage = await signMessage({
message,
namespace,
chainId,
accountId: account.index,
});
Alert.alert('Signature', signedMessage);
};
return (
<ScrollView style={styles.signPage}>
<View style={styles.accountInfo}>
<View>
<Text variant="headlineSmall">
{account && `Account ${account.counterId + 1}`}
<Text variant="titleMedium">
{account && `Account ${account.index + 1}`}
</Text>
</View>
<View style={styles.accountContainer}>

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

@ -0,0 +1,302 @@
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,
} from '../utils/wallet-connect/WalletConnectRequests';
import { web3wallet } from '../utils/wallet-connect/WalletConnectUtils';
import { EIP155_SIGNING_METHODS } from '../utils/wallet-connect/EIP155Data';
import { useNetworks } from '../context/NetworksContext';
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');
}
const response = await approveWalletConnectRequest({
requestEvent,
account,
namespace,
chainId,
message,
});
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>
);
}

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

@ -0,0 +1,277 @@
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,
},
approveTransaction: {
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: 10,
paddingVertical: 10,
paddingHorizontal: 10,
marginVertical: 20,
},
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 },
});
export default styles;

129
src/types.ts Normal file
View File

@ -0,0 +1,129 @@
import { PopulatedTransaction } from 'ethers';
import { SignClientTypes, SessionTypes } from '@walletconnect/types';
import { Web3WalletTypes } from '@walletconnect/web3wallet';
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;
};
ApproveTransaction: {
transaction: PopulatedTransaction;
requestEvent: Web3WalletTypes.SessionRequest;
requestSessionData: SessionTypes.Struct;
};
InvalidPath: undefined;
WalletConnect: undefined;
AddSession: undefined;
AddNetwork: undefined;
EditNetwork: {
selectedNetwork: NetworksDataState;
};
};
export type Account = {
index: number;
pubKey: string;
address: string;
hdPath: string;
};
export type AccountsProps = {
currentIndex: number;
updateIndex: (index: number) => void;
};
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;
updateIndex: (index: number) => 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>;
setActiveSessions: (
activeSessions: Record<string, SessionTypes.Struct>,
) => void;
}

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

@ -0,0 +1,342 @@
/* 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 'react-native-keychain';
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');
const retrievedNetworks =
networks && networks.password ? JSON.parse(networks.password) : [];
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');
const retrievedNetworks: NetworksDataState[] =
networks && networks.password ? JSON.parse(networks.password) : [];
return retrievedNetworks;
};
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 && accountIndicesServer.password;
const loadedAccounts =
accountIndices !== false
? await retrieveAccountsForNetwork(
`${currentNetworkData.namespace}:${currentNetworkData.chainId}`,
accountIndices,
)
: undefined;
return loadedAccounts;
};
const retrieveSingleAccount = async (
namespace: string,
chainId: string,
address: string,
) => {
let loadedAccounts;
const accountIndicesServer = await getInternetCredentials(
`accountIndices/${namespace}:${chainId}`,
);
const accountIndices = accountIndicesServer && accountIndicesServer.password;
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.password;
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.password;
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+$/;

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 'react-native-keychain';
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.password;
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.password;
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.password;
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.password);
const filteredNetworks = networksData.filter(
network => network.namespace === namespace,
);
if (filteredNetworks.length === 0) {
throw new Error(`No networks found for namespace ${namespace}.`);
}
filteredNetworks.forEach(async network => {
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.password;
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,
};

View File

@ -1,25 +1,30 @@
/* 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 { getCosmosAccounts, getMnemonic, getPathKey } from './utils';
import { getDirectWallet, getMnemonic, getPathKey } from './misc';
import { getCosmosAccounts } from './accounts';
import { COSMOS, EIP155 } from './constants';
const signMessage = async ({
message,
network,
namespace,
chainId,
accountId,
}: SignMessageParams): Promise<string | undefined> => {
const path = (await getPathKey(network, accountId)).path;
const path = await getPathKey(`${namespace}:${chainId}`, accountId);
switch (network) {
case 'eth':
return await signEthMessage(message, accountId);
case 'cosmos':
return await signCosmosMessage(message, path);
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');
}
@ -28,16 +33,18 @@ const signMessage = async ({
const signEthMessage = async (
message: string,
accountId: number,
chainId: string,
): Promise<string | undefined> => {
try {
const privKey = (await getPathKey('eth', accountId)).privKey;
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);
return undefined;
throw error;
}
};
@ -75,8 +82,30 @@ const signCosmosMessage = async (
return cosmosSignature.signature.signature;
} catch (error) {
console.error('Error signing Cosmos message:', error);
return undefined;
throw error;
}
};
export { signMessage, signEthMessage, signCosmosMessage };
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,57 @@
// 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_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',
},
};
export const COSMOS_MAINNET_CHAINS = {
'cosmos:cosmoshub-4': {
chainId: 'cosmoshub-4',
name: 'Cosmos Hub',
logo: '/chain-logos/cosmos-cosmoshub-4.png',
rgb: '107, 111, 147',
rpc: '',
namespace: 'cosmos',
},
};
export const COSMOS_CHAINS = {
...COSMOS_MAINNET_CHAINS,
...COSMOS_TESTNET_CHAINS,
};
/**
* 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
};

View File

@ -0,0 +1,144 @@
/**
* @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_MAINNET_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',
},
'eip155:43114': {
chainId: 43114,
name: 'Avalanche C-Chain',
logo: '/chain-logos/eip155-43113.png',
rgb: '232, 65, 66',
rpc: 'https://api.avax.network/ext/bc/C/rpc',
namespace: 'eip155',
},
'eip155:137': {
chainId: 137,
name: 'Polygon',
logo: '/chain-logos/eip155-137.png',
rgb: '130, 71, 229',
rpc: 'https://polygon-rpc.com/',
namespace: 'eip155',
},
'eip155:10': {
chainId: 10,
name: 'Optimism',
logo: '/chain-logos/eip155-10.png',
rgb: '235, 0, 25',
rpc: 'https://mainnet.optimism.io',
namespace: 'eip155',
},
'eip155:324': {
chainId: 324,
name: 'zkSync Era',
logo: '/chain-logos/eip155-324.svg',
rgb: '242, 242, 242',
rpc: 'https://mainnet.era.zksync.io/',
namespace: 'eip155',
},
// Required chain by SIWE
'eip155:42161': {
chainId: 42161,
name: 'Arbitrum One',
logo: '',
rgb: '242, 242, 242',
rpc: 'https://arb1.arbitrum.io/rpc',
namespace: 'eip155',
},
};
export const EIP155_TEST_CHAINS: Record<string, EIP155Chain> = {
'eip155:5': {
chainId: 5,
name: 'Ethereum Goerli',
logo: '/chain-logos/eip155-1.png',
rgb: '99, 125, 234',
rpc: 'https://goerli.infura.io/v3/9aa3d95b3bc440fa88ea12eaa4456161',
namespace: 'eip155',
smartAccountEnabled: true,
},
'eip155:11155111': {
chainId: 11155111,
name: 'Ethereum Sepolia',
logo: '/chain-logos/eip155-1.png',
rgb: '99, 125, 234',
rpc: 'https://gateway.tenderly.co/public/sepolia',
namespace: 'eip155',
smartAccountEnabled: true,
},
'eip155:43113': {
chainId: 43113,
name: 'Avalanche Fuji',
logo: '/chain-logos/eip155-43113.png',
rgb: '232, 65, 66',
rpc: 'https://api.avax-test.network/ext/bc/C/rpc',
namespace: 'eip155',
},
'eip155:80001': {
chainId: 80001,
name: 'Polygon Mumbai',
logo: '/chain-logos/eip155-137.png',
rgb: '130, 71, 229',
rpc: 'https://matic-mumbai.chainstacklabs.com',
namespace: 'eip155',
smartAccountEnabled: true,
},
'eip155:420': {
chainId: 420,
name: 'Optimism Goerli',
logo: '/chain-logos/eip155-10.png',
rgb: '235, 0, 25',
rpc: 'https://goerli.optimism.io',
namespace: 'eip155',
},
'eip155:280': {
chainId: 280,
name: 'zkSync Era Testnet',
logo: '/chain-logos/eip155-324.svg',
rgb: '242, 242, 242',
rpc: 'https://testnet.era.zksync.dev/',
namespace: 'eip155',
},
};
export const EIP155_CHAINS = {
...EIP155_MAINNET_CHAINS,
...EIP155_TEST_CHAINS,
};
/**
* Methods
*/
export const EIP155_SIGNING_METHODS = {
PERSONAL_SIGN: 'personal_sign',
ETH_SEND_TRANSACTION: 'eth_sendTransaction',
};

View File

@ -0,0 +1,179 @@
// 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 { EIP155_SIGNING_METHODS } from './EIP155Data';
import { signDirectMessage, signEthMessage } from '../sign-message';
import { Account } from '../../types';
import { getMnemonic, getPathKey } from '../misc';
import { getCosmosAccounts } from '../accounts';
export async function approveWalletConnectRequest({
requestEvent,
account,
namespace,
chainId,
message,
provider,
cosmosFee,
ethGasLimit,
ethGasPrice,
sendMsg,
memo,
maxPriorityFeePerGas,
maxFeePerGas,
}: {
requestEvent: SignClientTypes.EventArguments['session_request'];
account: Account;
namespace: string;
chainId: string;
message?: string;
provider?: providers.JsonRpcProvider | SigningStargateClient;
cosmosFee?: StdFee;
ethGasLimit?: BigNumber;
ethGasPrice?: string;
maxPriorityFeePerGas?: BigNumber;
maxFeePerGas?: BigNumber;
sendMsg?: MsgSendEncodeObject;
memo?: string;
}) {
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 (!provider) {
throw new Error('JSON RPC provider not found');
}
const privKey = (
await getPathKey(`${namespace}:${chainId}`, account.index)
).privKey;
const wallet = new Wallet(privKey);
const sendTransaction = request.params[0];
const updatedTransaction =
maxFeePerGas && maxPriorityFeePerGas
? {
...sendTransaction,
gasLimit: ethGasLimit,
maxFeePerGas,
maxPriorityFeePerGas,
}
: {
...sendTransaction,
gasLimit: ethGasLimit,
gasPrice: ethGasPrice,
type: 0,
};
if (!(provider instanceof providers.JsonRpcProvider)) {
throw new Error('Provider not found');
}
const connectedWallet = wallet.connect(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 (!message) {
throw new Error('Message to be signed not found');
}
const ethSignature = await signEthMessage(
message,
account.index,
chainId,
);
return formatJsonRpcResult(id, ethSignature);
case 'cosmos_signDirect':
// 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
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_signAmino':
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_sendTokens':
if (!(provider instanceof SigningStargateClient)) {
throw new Error('Cosmos stargate client not found');
}
if (!cosmosFee) {
throw new Error('Cosmos fee not found');
}
if (!sendMsg) {
throw new Error('Message to be sent not found');
}
const result = await provider.signAndBroadcast(
address,
[sendMsg],
cosmosFee,
memo,
);
return formatJsonRpcResult(id, {
signature: result.transactionHash,
});
default:
throw new Error(getSdkError('INVALID_METHOD').message);
}
}
export function rejectWalletConnectRequest(
request: SignClientTypes.EventArguments['session_request'],
) {
const { id } = request;
return formatJsonRpcError(id, getSdkError('USER_REJECTED_METHODS').message);
}

View File

@ -0,0 +1,32 @@
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 | undefined;
export let core: ICore;
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,218 @@
// 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,
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

@ -1,142 +0,0 @@
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: {
marginTop: 24,
},
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,
},
accountInfo: {
marginTop: 24,
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: 50,
alignItems: 'center',
justifyContent: 'center',
},
buttonContainer: {
marginTop: 50,
flexDirection: 'row',
marginLeft: 20,
justifyContent: 'space-evenly',
},
badRequestContainer: {
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
messageText: {
color: 'black',
fontSize: 16,
textAlign: 'center',
marginBottom: 20,
},
});
export default styles;

View File

@ -1,3 +1,3 @@
{
"extends": "@react-native/typescript-config/tsconfig.json"
"extends": "@react-native/typescript-config/tsconfig.json",
}

View File

@ -1,84 +0,0 @@
export type StackParamsList = {
Laconic: undefined;
SignMessage: { selectedNetwork: string; accountInfo: Account } | undefined;
SignRequest:
| { network: string; address: string; message: string }
| undefined;
InvalidPath: undefined;
};
export type Account = {
counterId: number;
pubKey: string;
address: string;
hdPath: string;
};
export type WalletDetails = {
mnemonic: string;
ethAccounts: Account | undefined;
cosmosAccounts: Account | undefined;
};
export type AccountsProps = {
network: string;
accounts: {
ethAccounts: Account[];
cosmosAccounts: Account[];
};
currentIndex: number;
updateIndex: (index: number) => void;
updateAccounts: (account: Account) => void;
};
export type NetworkDropdownProps = {
selectedNetwork: string;
updateNetwork: (network: string) => void;
};
export type AccountsState = {
ethAccounts: Account[];
cosmosAccounts: Account[];
};
export type SignMessageParams = {
message: string;
network: string;
accountId: number;
};
export type CreateWalletProps = {
isWalletCreating: boolean;
createWalletHandler: () => Promise<void>;
};
export type ResetDialogProps = {
visible: boolean;
hideDialog: () => void;
onConfirm: () => void;
};
export type HDPathDialogProps = {
pathCode: string;
visible: boolean;
hideDialog: () => void;
updateIndex: (index: number) => 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;
};

View File

@ -1,249 +0,0 @@
/* 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 'react-native-keychain';
import { Account, WalletDetails } from '../types';
import {
accountInfoFromHDPath,
getAddress,
getCosmosAccounts,
getHDPath,
getMnemonic,
getNextAccountId,
getPathKey,
resetKeyServers,
updateAccountIndices,
updateGlobalCounter,
} from './utils';
const createWallet = async (): Promise<WalletDetails> => {
try {
const mnemonic = utils.entropyToMnemonic(utils.randomBytes(16));
await setInternetCredentials('mnemonicServer', 'mnemonic', mnemonic);
const hdNode = HDNode.fromMnemonic(mnemonic);
const ethNode = hdNode.derivePath("m/44'/60'/0'/0/0");
const cosmosNode = hdNode.derivePath("m/44'/118'/0'/0/0");
const ethAddress = ethNode.address;
const cosmosAddress = (await getCosmosAccounts(mnemonic, "0'/0/0")).data
.address;
const ethAccountInfo = `${"0'/0/0"},${ethNode.privateKey},${
ethNode.publicKey
},${ethAddress}`;
const cosmosAccountInfo = `${"0'/0/0"},${cosmosNode.privateKey},${
cosmosNode.publicKey
},${cosmosAddress}`;
await Promise.all([
setInternetCredentials(
'eth:keyServer:0',
'eth:pathKey:0',
ethAccountInfo,
),
setInternetCredentials(
'cosmos:keyServer:0',
'cosmos:pathKey:0',
cosmosAccountInfo,
),
setInternetCredentials('eth:accountIndices', 'ethCounter', '0'),
setInternetCredentials('cosmos:accountIndices', 'cosmosCounter', '0'),
setInternetCredentials('eth:globalCounter', 'ethGlobal', '0'),
setInternetCredentials('cosmos:globalCounter', 'cosmosGlobal', '0'),
]);
const ethAccounts = {
counterId: 0,
pubKey: ethNode.publicKey,
address: ethAddress,
hdPath: "m/44'/60'/0'/0/0",
};
const cosmosAccounts = {
counterId: 0,
pubKey: cosmosNode.publicKey,
address: cosmosAddress,
hdPath: "m/44'/118'/0'/0/0",
};
return { mnemonic, ethAccounts, cosmosAccounts };
} catch (error) {
console.error('Error creating HD wallet:', error);
return { mnemonic: '', ethAccounts: undefined, cosmosAccounts: undefined };
}
};
const addAccount = async (network: string): Promise<Account | undefined> => {
try {
const mnemonic = await getMnemonic();
const hdNode = HDNode.fromMnemonic(mnemonic);
const id = await getNextAccountId(network);
const hdPath = getHDPath(network, `0'/0/${id}`);
const node = hdNode.derivePath(hdPath);
const pubKey = node.publicKey;
const address = await getAddress(network, mnemonic, `0'/0/${id}`);
await updateAccountIndices(network, id);
const { counterId } = await updateGlobalCounter(network);
await Promise.all([
setInternetCredentials(
`${network}:keyServer:${counterId}`,
`${network}:pathKey:${counterId}`,
`0'/0/${id},${node.privateKey},${node.publicKey},${address}`,
),
]);
return { counterId, pubKey, address, hdPath };
} catch (error) {
console.error('Error creating account:', error);
}
};
const addAccountFromHDPath = async (
hdPath: string,
): Promise<Account | undefined> => {
try {
const account = await accountInfoFromHDPath(hdPath);
if (!account) {
throw new Error('Error while creating account');
}
const parts = hdPath.split('/');
const path = parts.slice(-3).join('/');
const { privKey, pubKey, address, network } = account;
const counterId = (await updateGlobalCounter(network)).counterId;
await Promise.all([
setInternetCredentials(
`${network}:keyServer:${counterId}`,
`${network}:pathKey:${counterId}`,
`${path},${privKey},${pubKey},${address}`,
),
]);
return { counterId, pubKey, address, hdPath };
} catch (error) {
console.error(error);
}
};
const retrieveAccountsForNetwork = async (
network: string,
count: string,
): Promise<Account[]> => {
const elementsArray = count.split(',');
const loadedAccounts = await Promise.all(
elementsArray.map(async i => {
const pubKey = (await getPathKey(network, Number(i))).pubKey;
const address = (await getPathKey(network, Number(i))).address;
const path = (await getPathKey(network, Number(i))).path;
const hdPath = getHDPath(network, path);
const account: Account = {
counterId: Number(i),
pubKey: pubKey,
address: address,
hdPath: hdPath,
};
return account;
}),
);
return loadedAccounts;
};
const retrieveAccounts = async (): Promise<{
ethLoadedAccounts?: Account[];
cosmosLoadedAccounts?: Account[];
}> => {
const ethServer = await getInternetCredentials('eth:globalCounter');
const ethCounter = ethServer && ethServer.password;
const cosmosServer = await getInternetCredentials('cosmos:globalCounter');
const cosmosCounter = cosmosServer && cosmosServer.password;
const ethLoadedAccounts = ethCounter
? await retrieveAccountsForNetwork('eth', ethCounter)
: undefined;
const cosmosLoadedAccounts = cosmosCounter
? await retrieveAccountsForNetwork('cosmos', cosmosCounter)
: undefined;
return { ethLoadedAccounts, cosmosLoadedAccounts };
};
const retrieveSingleAccount = async (network: string, address: string) => {
let loadedAccounts;
switch (network) {
case 'eth':
const ethServer = await getInternetCredentials('eth:globalCounter');
const ethCounter = ethServer && ethServer.password;
if (ethCounter) {
loadedAccounts = await retrieveAccountsForNetwork(network, ethCounter);
}
break;
case 'cosmos':
const cosmosServer = await getInternetCredentials('cosmos:globalCounter');
const cosmosCounter = cosmosServer && cosmosServer.password;
if (cosmosCounter) {
loadedAccounts = await retrieveAccountsForNetwork(
network,
cosmosCounter,
);
}
break;
default:
break;
}
if (loadedAccounts) {
return loadedAccounts.find(account => account.address === address);
}
return undefined;
};
const resetWallet = async () => {
try {
await Promise.all([
resetInternetCredentials('mnemonicServer'),
resetKeyServers('eth'),
resetKeyServers('cosmos'),
resetInternetCredentials('eth:accountIndices'),
resetInternetCredentials('cosmos:accountIndices'),
resetInternetCredentials('eth:globalCounter'),
resetInternetCredentials('cosmos:globalCounter'),
]);
} catch (error) {
console.error('Error resetting wallet:', error);
throw error;
}
};
export {
createWallet,
addAccount,
addAccountFromHDPath,
retrieveAccounts,
retrieveSingleAccount,
resetWallet,
};

View File

@ -1,220 +0,0 @@
/* 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 { HDNode } from 'ethers/lib/utils';
import {
getInternetCredentials,
resetInternetCredentials,
setInternetCredentials,
} from 'react-native-keychain';
import { AccountData, Secp256k1HdWallet } from '@cosmjs/amino';
import { stringToPath } from '@cosmjs/crypto';
const getMnemonic = async (): Promise<string> => {
const mnemonicStore = await getInternetCredentials('mnemonicServer');
if (!mnemonicStore) {
throw new Error('Mnemonic not found!');
}
const mnemonic = mnemonicStore.password;
return mnemonic;
};
const getHDPath = (network: string, path: string): string => {
return network === 'eth' ? `m/44'/60'/${path}` : `m/44'/118'/${path}`;
};
const getAddress = async (
network: string,
mnemonic: string,
path: string,
): Promise<string> => {
switch (network) {
case 'eth':
return HDNode.fromMnemonic(mnemonic).derivePath(`m/44'/60'/${path}`)
.address;
case 'cosmos':
return (await getCosmosAccounts(mnemonic, `${path}`)).data.address;
default:
throw new Error('Invalid wallet type');
}
};
const getCosmosAccounts = async (
mnemonic: string,
path: string,
): Promise<{ cosmosWallet: Secp256k1HdWallet; data: AccountData }> => {
const cosmosWallet = await Secp256k1HdWallet.fromMnemonic(mnemonic, {
hdPaths: [stringToPath(`m/44'/118'/${path}`)],
});
const accountsData = await cosmosWallet.getAccounts();
const data = accountsData[0];
return { cosmosWallet, data };
};
const accountInfoFromHDPath = async (
hdPath: string,
): Promise<
| { privKey: string; pubKey: string; address: string; network: string }
| undefined
> => {
const mnemonicStore = await getInternetCredentials('mnemonicServer');
if (!mnemonicStore) {
throw new Error('Mnemonic not found!');
}
const mnemonic = mnemonicStore.password;
const hdNode = HDNode.fromMnemonic(mnemonic);
const node = hdNode.derivePath(hdPath);
const privKey = node.privateKey;
const pubKey = node.publicKey;
const parts = hdPath.split('/');
const path = parts.slice(-3).join('/');
const coinType = parts[2];
let network: string;
let address: string;
switch (coinType) {
case "60'":
network = 'eth';
address = node.address;
break;
case "118'":
network = 'cosmos';
address = (await getCosmosAccounts(mnemonic, path)).data.address;
break;
default:
throw new Error('Invalid wallet type');
}
return { privKey, pubKey, address, network };
};
const getPathKey = async (
network: string,
accountId: number,
): Promise<{
path: string;
privKey: string;
pubKey: string;
address: string;
}> => {
const pathKeyStore = await getInternetCredentials(
`${network}:keyServer:${accountId}`,
);
if (!pathKeyStore) {
throw new Error('Error while fetching counter');
}
const pathKeyVal = pathKeyStore.password;
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 getGlobalCounter = async (
network: string,
): Promise<{
accountCounter: string;
counterIds: number[];
counterId: number;
}> => {
const counterStore = await getInternetCredentials(`${network}:globalCounter`);
if (!counterStore) {
throw new Error('Error while fetching counter');
}
let accountCounter = counterStore.password;
const counterIds = accountCounter.split(',').map(Number);
const counterId = counterIds[counterIds.length - 1] + 1;
return { accountCounter, counterIds, counterId };
};
const updateGlobalCounter = async (
network: string,
): Promise<{ accountCounter: string; counterId: number }> => {
const globalCounterData = await getGlobalCounter(network);
const accountCounter = globalCounterData.accountCounter;
const counterId = globalCounterData.counterId;
const updatedAccountCounter = `${accountCounter},${counterId.toString()}`;
await resetInternetCredentials(`${network}:globalCounter`);
await setInternetCredentials(
`${network}:globalCounter`,
`${network}Global`,
updatedAccountCounter,
);
return { accountCounter: updatedAccountCounter, counterId };
};
const getNextAccountId = async (network: string): Promise<number> => {
const idStore = await getInternetCredentials(`${network}:accountIndices`);
if (!idStore) {
throw new Error('Account id not found');
}
const accountIds = idStore.password;
const ids = accountIds.split(',').map(Number);
return ids[ids.length - 1] + 1;
};
const updateAccountIndices = async (
network: string,
id: number,
): Promise<void> => {
const idStore = await getInternetCredentials(`${network}:accountIndices`);
if (!idStore) {
throw new Error('Account id not found');
}
const updatedIndices = `${idStore.password},${id.toString()}`;
await resetInternetCredentials(`${network}:accountIndices`);
await setInternetCredentials(
`${network}:accountIndices`,
`${network}Counter`,
updatedIndices,
);
};
const resetKeyServers = async (prefix: string) => {
const idStore = await getInternetCredentials(`${prefix}:accountIndices`);
if (!idStore) {
throw new Error('Account id not found.');
}
const accountIds = idStore.password;
const ids = accountIds.split(',').map(Number);
const id = ids[ids.length - 1];
for (let i = 0; i <= id; i++) {
await resetInternetCredentials(`${prefix}:keyServer:${i}`);
}
};
export {
accountInfoFromHDPath,
getCosmosAccounts,
getMnemonic,
getPathKey,
getNextAccountId,
updateGlobalCounter,
updateAccountIndices,
getHDPath,
getAddress,
resetKeyServers,
};

1603
yarn.lock

File diff suppressed because it is too large Load Diff