diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..6b64c5b --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +@cerc-io:registry=https://git.vdb.to/api/packages/cerc-io/npm/ diff --git a/README.md b/README.md index 7b7671c..c81119b 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ - Install Android SDK - - Open Android Studio -> Configure -> SDK Manager -> SDK PLatfrom Tab. + - Open Android Studio -> Configure -> SDK Manager -> SDK Platform Tab. - Check the box next to "Show Package Details" in the bottom right corner. Look for and expand the Android 13 (Tiramisu) entry, then make sure the following items are checked: @@ -94,19 +94,26 @@ WALLET_CONNECT_PROJECT_ID=39bc93c... ``` -5. Set up the Android device +5. Add SDK directory to project + - Inside the [`android`](./android/) directory, create a file `local.properties` and add your Android SDK path + ``` + sdk.dir = /home/USERNAME/Android/Sdk + ``` + Where `USERNAME` is your linux username + +6. 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 +7. Start the application ``` yarn start ``` -7. Press `a` to run the application on android +8. Press `a` to run the application on android ## Flow for the app diff --git a/babel.config.js b/babel.config.js index 1f6548d..f5333ae 100644 --- a/babel.config.js +++ b/babel.config.js @@ -10,6 +10,9 @@ module.exports = { crypto: 'react-native-quick-crypto', stream: 'stream-browserify', buffer: '@craftzdog/react-native-buffer', + http: 'http-browserify', + https: 'https-browserify', + url: 'react-native-url-polyfill', }, }, ], diff --git a/package.json b/package.json index 9295b5b..6c3f6ca 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "prepare": "husky" }, "dependencies": { + "@cerc-io/registry-sdk": "^0.2.2", "@cosmjs/amino": "^0.32.3", "@cosmjs/crypto": "^0.32.3", "@cosmjs/proto-signing": "^0.32.3", @@ -28,9 +29,12 @@ "@walletconnect/react-native-compat": "^2.11.2", "@walletconnect/utils": "^2.12.2", "@walletconnect/web3wallet": "^1.10.2", + "assert": "^2.1.0", "chain-registry": "^1.41.2", "cosmjs-types": "^0.9.0", "ethers": "5.7.2", + "http-browserify": "^1.7.0", + "https-browserify": "^1.0.0", "lodash": "^4.17.21", "patch-package": "^8.0.0", "react": "18.2.0", @@ -45,6 +49,7 @@ "react-native-safe-area-context": "^4.9.0", "react-native-screens": "^3.29.0", "react-native-svg": "^15.1.0", + "react-native-url-polyfill": "^2.0.0", "react-native-vector-icons": "^10.0.3", "react-native-vision-camera": "^3.9.0", "text-encoding-polyfill": "^0.6.7", diff --git a/src/App.tsx b/src/App.tsx index 5edd4a6..914e5ef 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,16 +22,18 @@ import HomeScreen from './screens/HomeScreen'; import SignRequest from './screens/SignRequest'; import AddSession from './screens/AddSession'; import WalletConnect from './screens/WalletConnect'; +import ApproveTransaction from './screens/ApproveTransaction'; import { StackParamsList } from './types'; import { web3wallet } from './utils/wallet-connect/WalletConnectUtils'; import { EIP155_SIGNING_METHODS } from './utils/wallet-connect/EIP155Data'; import { getSignParamsMessage } from './utils/wallet-connect/helpers'; -import ApproveTransaction from './screens/ApproveTransaction'; +import ApproveTransfer from './screens/ApproveTransfer'; import AddNetwork from './screens/AddNetwork'; import EditNetwork from './screens/EditNetwork'; import { COSMOS, EIP155 } from './utils/constants'; import { useNetworks } from './context/NetworksContext'; import { NETWORK_METHODS } from './utils/wallet-connect/common-data'; +import { COSMOS_METHODS } from './utils/wallet-connect/COSMOSData'; const Stack = createNativeStackNavigator(); @@ -114,7 +116,7 @@ const App = (): React.JSX.Element => { break; case EIP155_SIGNING_METHODS.ETH_SEND_TRANSACTION: - navigation.navigate('ApproveTransaction', { + navigation.navigate('ApproveTransfer', { transaction: request.params[0], requestEvent, requestSessionData, @@ -131,7 +133,7 @@ const App = (): React.JSX.Element => { }); break; - case 'cosmos_signDirect': + case COSMOS_METHODS.COSMOS_SIGN_DIRECT: const message = { txbody: TxBody.toJSON( TxBody.decode( @@ -157,7 +159,7 @@ const App = (): React.JSX.Element => { }); break; - case 'cosmos_signAmino': + case COSMOS_METHODS.COSMOS_SIGN_AMINO: navigation.navigate('SignRequest', { namespace: COSMOS, address: request.params.signerAddress, @@ -167,14 +169,24 @@ const App = (): React.JSX.Element => { }); break; - case 'cosmos_sendTokens': - navigation.navigate('ApproveTransaction', { + case COSMOS_METHODS.COSMOS_SEND_TOKENS: + navigation.navigate('ApproveTransfer', { transaction: request.params[0], requestEvent, requestSessionData, }); break; + case COSMOS_METHODS.COSMOS_SEND_TRANSACTION: + const { transactionMessage, signer } = request.params; + navigation.navigate('ApproveTransaction', { + transactionMessage, + signer, + requestEvent, + requestSessionData, + }); + break; + default: throw new Error('Invalid method'); } @@ -268,10 +280,10 @@ const App = (): React.JSX.Element => { /> { title: 'Edit Network', }} /> + { +const Accounts = () => { const navigation = useNavigation>(); - const { accounts, setAccounts, setCurrentIndex } = useAccounts(); + const { accounts, setAccounts, setCurrentIndex, currentIndex } = + useAccounts(); const { networksData, selectedNetwork, setNetworksData, setSelectedNetwork } = useNetworks(); const [expanded, setExpanded] = useState(false); @@ -80,7 +82,7 @@ const Accounts = ({ currentIndex, updateIndex }: AccountsProps) => { setIsAccountCreating(false); if (newAccount) { updateAccounts(newAccount); - updateIndex(newAccount.index); + setCurrentIndex(newAccount.index); } }; @@ -90,7 +92,7 @@ const Accounts = ({ currentIndex, updateIndex }: AccountsProps) => { key={account.index} title={`Account ${account.index + 1}`} onPress={() => { - updateIndex(account.index); + setCurrentIndex(account.index); setExpanded(false); }} /> @@ -120,7 +122,6 @@ const Accounts = ({ currentIndex, updateIndex }: AccountsProps) => { visible={hdDialog} hideDialog={() => setHdDialog(false)} updateAccounts={updateAccounts} - updateIndex={updateIndex} pathCode={pathCode} /> { hideDialog={hideDeleteNetworkDialog} onConfirm={handleRemove} /> + + ); diff --git a/src/components/HDPath.tsx b/src/components/HDPath.tsx index b590d1e..a49ca3c 100644 --- a/src/components/HDPath.tsx +++ b/src/components/HDPath.tsx @@ -5,20 +5,20 @@ import { Button, TextInput } from 'react-native-paper'; import { addAccountFromHDPath } from '../utils/accounts'; import { Account, NetworksDataState, PathState } from '../types'; import styles from '../styles/stylesheet'; +import { useAccounts } from '../context/AccountsContext'; const HDPath = ({ pathCode, updateAccounts, - updateIndex, hideDialog, selectedNetwork, }: { pathCode: string; - updateIndex: (index: number) => void; updateAccounts: (account: Account) => void; hideDialog: () => void; selectedNetwork: NetworksDataState; }) => { + const { setCurrentIndex } = useAccounts(); const [isAccountCreating, setIsAccountCreating] = useState(false); const [path, setPath] = useState({ firstNumber: '', @@ -46,7 +46,7 @@ const HDPath = ({ const newAccount = await addAccountFromHDPath(hdPath, selectedNetwork); if (newAccount) { updateAccounts(newAccount); - updateIndex(newAccount.index); + setCurrentIndex(newAccount.index); hideDialog(); } } catch (error) { diff --git a/src/components/HDPathDialog.tsx b/src/components/HDPathDialog.tsx index 6a8b168..2bd1f54 100644 --- a/src/components/HDPathDialog.tsx +++ b/src/components/HDPathDialog.tsx @@ -8,7 +8,6 @@ import { useNetworks } from '../context/NetworksContext'; const HDPathDialog = ({ visible, hideDialog, - updateIndex, updateAccounts, pathCode, }: HDPathDialogProps) => { @@ -22,7 +21,6 @@ const HDPathDialog = ({ diff --git a/src/components/ShowPKDialog.tsx b/src/components/ShowPKDialog.tsx new file mode 100644 index 0000000..6fdf3b3 --- /dev/null +++ b/src/components/ShowPKDialog.tsx @@ -0,0 +1,97 @@ +import { TouchableOpacity, View } from 'react-native'; +import React, { useState } from 'react'; +import { Button, Text, Portal, Dialog, useTheme } from 'react-native-paper'; + +import styles from '../styles/stylesheet'; +import { getPathKey } from '../utils/misc'; +import { useNetworks } from '../context/NetworksContext'; +import { useAccounts } from '../context/AccountsContext'; + +const ShowPKDialog = () => { + const { currentIndex } = useAccounts(); + const { selectedNetwork } = useNetworks(); + + const [privateKey, setprivateKey] = useState(); + const [showPKDialog, setShowPKDialog] = useState(false); + + const theme = useTheme(); + + const handleShowPrivateKey = async () => { + const pathKey = await getPathKey( + `${selectedNetwork!.namespace}:${selectedNetwork!.chainId}`, + currentIndex, + ); + + setprivateKey(pathKey.privKey); + }; + + const hideShowPKDialog = () => { + setShowPKDialog(false); + setprivateKey(undefined); + }; + + return ( + <> + + { + setShowPKDialog(true); + }}> + + Show Private Key + + + + + + + + {!privateKey ? ( + Show Private Key? + ) : ( + Private Key + )} + + + {privateKey && ( + + + {privateKey} + + + )} + + + + Warning: + + Never disclose this key. Anyone with your private keys can + steal any assets held in your account. + + + + + {!privateKey ? ( + <> + + + + ) : ( + + )} + + + + + + ); +}; + +export default ShowPKDialog; diff --git a/src/screens/ApproveTransaction.tsx b/src/screens/ApproveTransaction.tsx index 3fa411c..532d8a1 100644 --- a/src/screens/ApproveTransaction.tsx +++ b/src/screens/ApproveTransaction.tsx @@ -1,103 +1,65 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, 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 { Button, Text, TextInput } from 'react-native-paper'; +import { SvgUri } from 'react-native-svg'; 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 { useNavigation } from '@react-navigation/native'; import { DirectSecp256k1Wallet } from '@cosmjs/proto-signing'; -import { - calculateFee, - GasPrice, - MsgSendEncodeObject, - SigningStargateClient, -} from '@cosmjs/stargate'; +import { LaconicClient } from '@cerc-io/registry-sdk'; +import { GasPrice, calculateFee } from '@cosmjs/stargate'; +import { formatJsonRpcError } from '@json-rpc-tools/utils'; +import { useNetworks } from '../context/NetworksContext'; import { Account, StackParamsList } from '../types'; -import AccountDetails from '../components/AccountDetails'; import styles from '../styles/stylesheet'; +import { COSMOS, IS_NUMBER_REGEX } from '../utils/constants'; import { retrieveSingleAccount } from '../utils/accounts'; +import { getPathKey } from '../utils/misc'; import { + WalletConnectRequests, approveWalletConnectRequest, rejectWalletConnectRequest, - WalletConnectRequests, } from '../utils/wallet-connect/wallet-connect-requests'; import { web3wallet } from '../utils/wallet-connect/WalletConnectUtils'; -import DataBox from '../components/DataBox'; -import { getPathKey } from '../utils/misc'; -import { useNetworks } from '../context/NetworksContext'; -import { COSMOS, EIP155, IS_NUMBER_REGEX } from '../utils/constants'; +import { MEMO } from './ApproveTransfer'; import TxErrorDialog from '../components/TxErrorDialog'; -import { EIP155_SIGNING_METHODS } from '../utils/wallet-connect/EIP155Data'; -import { COSMOS_METHODS } from '../utils/wallet-connect/COSMOSData'; +import AccountDetails from '../components/AccountDetails'; -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< +type ApproveTransactionProps = NativeStackScreenProps< StackParamsList, 'ApproveTransaction' >; -const ApproveTransaction = ({ route }: SignRequestProps) => { +const ApproveTransaction = ({ route }: ApproveTransactionProps) => { const { networksData } = useNetworks(); const requestSession = route.params.requestSessionData; const requestName = requestSession.peer.metadata.name; const requestIcon = requestSession.peer.metadata.icons[0]; const requestURL = requestSession.peer.metadata.url; - const transaction = route.params.transaction; + const transactionMessage = route.params.transactionMessage; + const signer = route.params.signer; const requestEvent = route.params.requestEvent; const chainId = requestEvent.params.chainId; - const requestMethod = requestEvent.params.request.method; + const requestEventId = requestEvent.id; + const topic = requestEvent.topic; const [account, setAccount] = useState(); - const [isLoading, setIsLoading] = useState(true); - const [balance, setBalance] = useState(''); - const [isTxLoading, setIsTxLoading] = useState(false); const [cosmosStargateClient, setCosmosStargateClient] = - useState(); - const [fees, setFees] = useState(); + useState(); const [cosmosGasLimit, setCosmosGasLimit] = useState(); + const [fees, setFees] = useState(); const [txError, setTxError] = useState(); const [isTxErrorDialogOpen, setIsTxErrorDialogOpen] = useState(false); - const [ethGasPrice, setEthGasPrice] = useState(); - const [ethGasLimit, setEthGasLimit] = useState(); - const [ethMaxFee, setEthMaxFee] = useState(); - const [ethMaxPriorityFee, setEthMaxPriorityFee] = - useState(); + const [isRequestAccepted, setIsRequestAccepted] = useState(false); - 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 navigation = + useNavigation>(); const requestedNetwork = networksData.find( networkData => @@ -105,22 +67,6 @@ const ApproveTransaction = ({ route }: SignRequestProps) => { ); 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; @@ -144,41 +90,21 @@ const ApproveTransaction = ({ route }: SignRequestProps) => { ); try { - const client = await SigningStargateClient.connectWithSigner( + const client = await LaconicClient.connectWithSigner( requestedNetwork?.rpcUrl!, sender, ); - setCosmosStargateClient(client); } catch (error: any) { setTxError(error.message); setIsTxErrorDialogOpen(true); + const response = formatJsonRpcError(requestEventId, error.message); + await web3wallet!.respondSessionRequest({ topic, response }); } }; setClient(); - }, [account, requestedNetwork, chainId, namespace]); - - 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>(); + }, [account, requestedNetwork, chainId, namespace, requestEventId, topic]); const retrieveData = useCallback( async (requestAddress: string) => { @@ -198,245 +124,8 @@ const ApproveTransaction = ({ route }: SignRequestProps) => { ); useEffect(() => { - // Set loading to false when gas values for requested chain are fetched - // If requested chain is EVM compatible, the cosmos gas values will be undefined and vice-versa, hence the condition checks only one of them at the same time - if ( - // If requested chain is EVM compatible, set loading to false when ethMaxFee and ethPriorityFee have been populated - (ethMaxFee !== undefined && ethMaxPriorityFee !== undefined) || - // Or if requested chain is a cosmos chain, set loading to false when cosmosGasLimit has been populated - cosmosGasLimit !== undefined - ) { - setIsLoading(false); - } - }, [ethMaxFee, ethMaxPriorityFee, cosmosGasLimit]); - - useEffect(() => { - if (namespace === EIP155) { - const ethFees = BigNumber.from(ethGasLimit ?? 0) - .mul(BigNumber.from(ethMaxFee ?? ethGasPrice ?? 0)) - .toString(); - setFees(ethFees); - } else { - const gasPrice = GasPrice.fromString( - requestedNetwork?.gasPrice! + requestedNetwork?.nativeDenom, - ); - - if (!cosmosGasLimit) { - return; - } - - const cosmosFees = calculateFee(Number(cosmosGasLimit), gasPrice); - - setFees(cosmosFees.amount[0].amount); - } - }, [ - transaction, - namespace, - ethGasLimit, - ethGasPrice, - cosmosGasLimit, - requestedNetwork, - ethMaxFee, - ]); - useEffect(() => { - retrieveData(transaction.from!); - }, [retrieveData, transaction]); - - const isEIP1559 = useMemo(() => { - if (cosmosGasLimit) { - return; - } - if (ethMaxFee !== null && ethMaxPriorityFee !== null) { - return true; - } - return false; - }, [cosmosGasLimit, ethMaxFee, ethMaxPriorityFee]); - - const acceptRequestHandler = async () => { - setIsTxLoading(true); - try { - if (!account) { - throw new Error('account not found'); - } - - if (ethGasLimit && ethGasLimit.lt(ETH_MINIMUM_GAS)) { - throw new Error(`Atleast ${ETH_MINIMUM_GAS} gas limit is required`); - } - - if (ethMaxFee && ethMaxPriorityFee && ethMaxFee.lte(ethMaxPriorityFee)) { - throw new Error( - `Max fee per gas (${ethMaxFee.toNumber()}) cannot be lower than or equal to max priority fee per gas (${ethMaxPriorityFee.toNumber()})`, - ); - } - - let options: WalletConnectRequests; - - switch (requestMethod) { - case EIP155_SIGNING_METHODS.ETH_SEND_TRANSACTION: - if ( - ethMaxFee === undefined || - ethMaxPriorityFee === undefined || - ethGasPrice === undefined - ) { - throw new Error('Gas values not found'); - } - - options = { - type: 'eth_sendTransaction', - provider: provider!, - ethGasLimit: BigNumber.from(ethGasLimit), - ethGasPrice: ethGasPrice ? ethGasPrice.toHexString() : null, - maxFeePerGas: ethMaxFee, - maxPriorityFeePerGas: ethMaxPriorityFee, - }; - break; - case COSMOS_METHODS.COSMOS_SEND_TOKENS: - if (!cosmosStargateClient) { - throw new Error('Cosmos stargate client not found'); - } - - options = { - type: 'cosmos_sendTokens', - signingStargateClient: cosmosStargateClient, - // StdFee object - cosmosFee: { - // This amount is total fees required for transaction - amount: [ - { - amount: fees!, - denom: requestedNetwork!.nativeDenom!, - }, - ], - gas: cosmosGasLimit!, - }, - sendMsg, - memo: MEMO, - }; - - break; - default: - throw new Error('Invalid method'); - } - - const response = await approveWalletConnectRequest( - requestEvent, - account, - namespace, - requestedNetwork!.chainId, - options, - ); - - const { topic } = requestEvent; - await web3wallet!.respondSessionRequest({ topic, response }); - navigation.navigate('Laconic'); - } catch (error: any) { - setTxError(error.message); - setIsTxErrorDialogOpen(true); - } - setIsTxLoading(false); - }; - - const rejectRequestHandler = async () => { - const response = rejectWalletConnectRequest(requestEvent); - const { topic } = requestEvent; - await web3wallet!.respondSessionRequest({ - topic, - response, - }); - - navigation.navigate('Laconic'); - }; - - useEffect(() => { - const getAccountBalance = async () => { - try { - if (!account) { - return; - } - if (namespace === EIP155) { - if (!provider) { - return; - } - const fetchedBalance = await provider.getBalance(account.address); - setBalance(fetchedBalance ? fetchedBalance.toString() : '0'); - } else { - const cosmosBalance = await cosmosStargateClient?.getBalance( - account.address, - requestedNetwork!.nativeDenom!.toLowerCase(), - ); - - setBalance(cosmosBalance?.amount!); - } - } catch (error: any) { - setTxError(error.message); - setIsTxErrorDialogOpen(true); - } - }; - - getAccountBalance(); - }, [account, provider, namespace, cosmosStargateClient, requestedNetwork]); - - useEffect(() => { - navigation.setOptions({ - // eslint-disable-next-line react/no-unstable-nested-components - header: ({ options, back }) => { - const title = getHeaderTitle(options, 'Approve Transaction'); - - return ( - - {back && ( - { - await rejectRequestHandler(); - navigation.navigate('Laconic'); - }} - /> - )} - - - ); - }, - }); - // 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 = { - 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]); + retrieveData(signer); + }, [retrieveData, signer]); useEffect(() => { const getCosmosGas = async () => { @@ -444,13 +133,9 @@ const ApproveTransaction = ({ route }: SignRequestProps) => { if (!cosmosStargateClient) { return; } - if (!isSufficientFunds) { - return; - } - - const gasEstimation = await cosmosStargateClient.simulate( - transaction.from!, - [sendMsg], + const gasEstimation = await cosmosStargateClient!.simulate( + transactionMessage.value.participant!, + [transactionMessage], MEMO, ); @@ -462,163 +147,134 @@ const ApproveTransaction = ({ route }: SignRequestProps) => { } catch (error: any) { setTxError(error.message); setIsTxErrorDialogOpen(true); + const response = formatJsonRpcError(requestEventId, error.message); + await web3wallet!.respondSessionRequest({ topic, response }); } }; getCosmosGas(); - }, [cosmosStargateClient, isSufficientFunds, sendMsg, transaction]); + }, [cosmosStargateClient, transactionMessage, requestEventId, topic]); useEffect(() => { - if (balance && !isSufficientFunds) { - setTxError('Insufficient funds'); - setIsTxErrorDialogOpen(true); + const gasPrice = GasPrice.fromString( + requestedNetwork?.gasPrice! + requestedNetwork?.nativeDenom, + ); + + if (!cosmosGasLimit) { + return; } - }, [isSufficientFunds, balance]); + + const cosmosFees = calculateFee(Number(cosmosGasLimit), gasPrice); + + setFees(cosmosFees.amount[0].amount); + }, [namespace, cosmosGasLimit, requestedNetwork]); + + const acceptRequestHandler = async () => { + try { + setIsRequestAccepted(true); + if (!account) { + throw new Error('account not found'); + } + + let options: WalletConnectRequests; + + if (!cosmosStargateClient) { + throw new Error('Cosmos stargate client not found'); + } + + options = { + type: 'cosmos_sendTransaction', + LaconicClient: cosmosStargateClient, + // StdFee object + cosmosFee: { + // This amount is total fees required for transaction + amount: [ + { + amount: fees!, + denom: requestedNetwork!.nativeDenom!, + }, + ], + gas: cosmosGasLimit!, + }, + txMsg: transactionMessage, + }; + + const response = await approveWalletConnectRequest( + requestEvent, + account, + namespace, + requestedNetwork!.chainId, + options, + ); + + await web3wallet!.respondSessionRequest({ topic, response }); + setIsRequestAccepted(false); + navigation.navigate('Laconic'); + } catch (error: any) { + setTxError(error.message); + setIsTxErrorDialogOpen(true); + const response = formatJsonRpcError(requestEventId, error.message); + await web3wallet!.respondSessionRequest({ topic, response }); + } + }; + + const rejectRequestHandler = async () => { + const response = rejectWalletConnectRequest(requestEvent); + await web3wallet!.respondSessionRequest({ + topic, + response, + }); + + navigation.navigate('Laconic'); + }; return ( <> - {isLoading ? ( - - - - ) : ( - <> - - - {requestIcon && ( - + + + {requestIcon && ( + <> + {requestIcon.endsWith('.svg') ? ( + + + + ) : ( + )} - {requestName} - {requestURL} - - - From - - - - - + )} + {requestName} + {requestURL} + + + + Message: + + + + {JSON.stringify(transactionMessage, null, 2)} + + + <> + + Gas Limit: + + { + if (IS_NUMBER_REGEX.test(value)) { + setCosmosGasLimit(value); } - /> - {transaction && ( - - - - - {namespace === EIP155 ? ( - <> - {isEIP1559 === false ? ( - <> - - {'Gas Price (wei)'} - - - setEthGasPrice(BigNumber.from(value)) - } - style={styles.transactionFeesInput} - /> - - ) : ( - <> - - Max Fee Per Gas (wei) - - { - if (IS_NUMBER_REGEX.test(value)) { - setEthMaxFee(BigNumber.from(value)); - } - }} - style={styles.transactionFeesInput} - /> - - Max Priority Fee Per Gas (wei) - - { - if (IS_NUMBER_REGEX.test(value)) { - setEthMaxPriorityFee(BigNumber.from(value)); - } - }} - style={styles.transactionFeesInput} - /> - - )} - Gas Limit - { - if (IS_NUMBER_REGEX.test(value)) { - setEthGasLimit(BigNumber.from(value)); - } - }} - style={styles.transactionFeesInput} - /> - - - - ) : ( - <> - {`Fee (${ - requestedNetwork!.nativeDenom - })`} - setFees(value)} - style={styles.transactionFeesInput} - /> - Gas Limit - { - if (IS_NUMBER_REGEX.test(value)) { - setCosmosGasLimit(value); - } - }} - /> - - )} - - )} - + }} + /> - )} + { + hideDialog={async () => { setIsTxErrorDialogOpen(false); - if (!isSufficientFunds || !balance || !fees) { - rejectRequestHandler(); - navigation.navigate('Laconic'); - } + navigation.navigate('Laconic'); }} /> diff --git a/src/screens/ApproveTransfer.tsx b/src/screens/ApproveTransfer.tsx new file mode 100644 index 0000000..f027433 --- /dev/null +++ b/src/screens/ApproveTransfer.tsx @@ -0,0 +1,648 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Image, ScrollView, View } from 'react-native'; +import { + ActivityIndicator, + Button, + Text, + Appbar, + TextInput, +} from 'react-native-paper'; +import { providers, BigNumber } from 'ethers'; +import Config from 'react-native-config'; +import { Deferrable } from 'ethers/lib/utils'; + +import { useNavigation } from '@react-navigation/native'; +import { + NativeStackNavigationProp, + NativeStackScreenProps, +} from '@react-navigation/native-stack'; +import { getHeaderTitle } from '@react-navigation/elements'; +import { DirectSecp256k1Wallet } from '@cosmjs/proto-signing'; +import { + calculateFee, + GasPrice, + MsgSendEncodeObject, + SigningStargateClient, +} from '@cosmjs/stargate'; + +import { Account, StackParamsList } from '../types'; +import AccountDetails from '../components/AccountDetails'; +import styles from '../styles/stylesheet'; +import { retrieveSingleAccount } from '../utils/accounts'; +import { + approveWalletConnectRequest, + rejectWalletConnectRequest, + WalletConnectRequests, +} from '../utils/wallet-connect/wallet-connect-requests'; +import { web3wallet } from '../utils/wallet-connect/WalletConnectUtils'; +import DataBox from '../components/DataBox'; +import { getPathKey } from '../utils/misc'; +import { useNetworks } from '../context/NetworksContext'; +import { COSMOS, EIP155, IS_NUMBER_REGEX } from '../utils/constants'; +import TxErrorDialog from '../components/TxErrorDialog'; +import { EIP155_SIGNING_METHODS } from '../utils/wallet-connect/EIP155Data'; +import { COSMOS_METHODS } from '../utils/wallet-connect/COSMOSData'; + +export const MEMO = 'Sending signed tx from Laconic Wallet'; +// Reference: https://ethereum.org/en/developers/docs/gas/#what-is-gas-limit +const ETH_MINIMUM_GAS = 21000; + +type SignRequestProps = NativeStackScreenProps< + StackParamsList, + 'ApproveTransfer' +>; + +const ApproveTransfer = ({ route }: SignRequestProps) => { + const { networksData } = useNetworks(); + + const requestSession = route.params.requestSessionData; + const requestName = requestSession.peer.metadata.name; + const requestIcon = requestSession.peer.metadata.icons[0]; + const requestURL = requestSession.peer.metadata.url; + const transaction = route.params.transaction; + const requestEvent = route.params.requestEvent; + const chainId = requestEvent.params.chainId; + const requestMethod = requestEvent.params.request.method; + + const [account, setAccount] = useState(); + const [isLoading, setIsLoading] = useState(true); + const [balance, setBalance] = useState(''); + const [isTxLoading, setIsTxLoading] = useState(false); + const [cosmosStargateClient, setCosmosStargateClient] = + useState(); + const [fees, setFees] = useState(); + const [cosmosGasLimit, setCosmosGasLimit] = useState(); + const [txError, setTxError] = useState(); + const [isTxErrorDialogOpen, setIsTxErrorDialogOpen] = useState(false); + const [ethGasPrice, setEthGasPrice] = useState(); + const [ethGasLimit, setEthGasLimit] = useState(); + const [ethMaxFee, setEthMaxFee] = useState(); + const [ethMaxPriorityFee, setEthMaxPriorityFee] = + useState(); + + 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>(); + + const retrieveData = useCallback( + async (requestAddress: string) => { + const requestAccount = await retrieveSingleAccount( + requestedNetwork!.namespace, + requestedNetwork!.chainId, + requestAddress, + ); + if (!requestAccount) { + navigation.navigate('InvalidPath'); + return; + } + + setAccount(requestAccount); + }, + [navigation, requestedNetwork], + ); + + useEffect(() => { + // Set loading to false when gas values for requested chain are fetched + // If requested chain is EVM compatible, the cosmos gas values will be undefined and vice-versa, hence the condition checks only one of them at the same time + if ( + // If requested chain is EVM compatible, set loading to false when ethMaxFee and ethPriorityFee have been populated + (ethMaxFee !== undefined && ethMaxPriorityFee !== undefined) || + // Or if requested chain is a cosmos chain, set loading to false when cosmosGasLimit has been populated + cosmosGasLimit !== undefined + ) { + setIsLoading(false); + } + }, [ethMaxFee, ethMaxPriorityFee, cosmosGasLimit]); + + useEffect(() => { + if (namespace === EIP155) { + const ethFees = BigNumber.from(ethGasLimit ?? 0) + .mul(BigNumber.from(ethMaxFee ?? ethGasPrice ?? 0)) + .toString(); + setFees(ethFees); + } else { + const gasPrice = GasPrice.fromString( + requestedNetwork?.gasPrice! + requestedNetwork?.nativeDenom, + ); + + if (!cosmosGasLimit) { + return; + } + + const cosmosFees = calculateFee(Number(cosmosGasLimit), gasPrice); + + setFees(cosmosFees.amount[0].amount); + } + }, [ + transaction, + namespace, + ethGasLimit, + ethGasPrice, + cosmosGasLimit, + requestedNetwork, + ethMaxFee, + ]); + useEffect(() => { + retrieveData(transaction.from!); + }, [retrieveData, transaction]); + + const isEIP1559 = useMemo(() => { + if (cosmosGasLimit) { + return; + } + if (ethMaxFee !== null && ethMaxPriorityFee !== null) { + return true; + } + return false; + }, [cosmosGasLimit, ethMaxFee, ethMaxPriorityFee]); + + const acceptRequestHandler = async () => { + setIsTxLoading(true); + try { + if (!account) { + throw new Error('account not found'); + } + + if (ethGasLimit && ethGasLimit.lt(ETH_MINIMUM_GAS)) { + throw new Error(`Atleast ${ETH_MINIMUM_GAS} gas limit is required`); + } + + if (ethMaxFee && ethMaxPriorityFee && ethMaxFee.lte(ethMaxPriorityFee)) { + throw new Error( + `Max fee per gas (${ethMaxFee.toNumber()}) cannot be lower than or equal to max priority fee per gas (${ethMaxPriorityFee.toNumber()})`, + ); + } + + let options: WalletConnectRequests; + + switch (requestMethod) { + case EIP155_SIGNING_METHODS.ETH_SEND_TRANSACTION: + if ( + ethMaxFee === undefined || + ethMaxPriorityFee === undefined || + ethGasPrice === undefined + ) { + throw new Error('Gas values not found'); + } + + options = { + type: 'eth_sendTransaction', + provider: provider!, + ethGasLimit: BigNumber.from(ethGasLimit), + ethGasPrice: ethGasPrice ? ethGasPrice.toHexString() : null, + maxFeePerGas: ethMaxFee, + maxPriorityFeePerGas: ethMaxPriorityFee, + }; + break; + case COSMOS_METHODS.COSMOS_SEND_TOKENS: + if (!cosmosStargateClient) { + throw new Error('Cosmos stargate client not found'); + } + + options = { + type: 'cosmos_sendTokens', + signingStargateClient: cosmosStargateClient, + // StdFee object + cosmosFee: { + // This amount is total fees required for transaction + amount: [ + { + amount: fees!, + denom: requestedNetwork!.nativeDenom!, + }, + ], + gas: cosmosGasLimit!, + }, + sendMsg, + memo: MEMO, + }; + + break; + + default: + throw new Error('Invalid method'); + } + + const response = await approveWalletConnectRequest( + requestEvent, + account, + namespace, + requestedNetwork!.chainId, + options, + ); + + const { topic } = requestEvent; + await web3wallet!.respondSessionRequest({ topic, response }); + navigation.navigate('Laconic'); + } catch (error: any) { + setTxError(error.message); + setIsTxErrorDialogOpen(true); + } + setIsTxLoading(false); + }; + + const rejectRequestHandler = async () => { + const response = rejectWalletConnectRequest(requestEvent); + const { topic } = requestEvent; + await web3wallet!.respondSessionRequest({ + topic, + response, + }); + + navigation.navigate('Laconic'); + }; + + useEffect(() => { + const getAccountBalance = async () => { + try { + if (!account) { + return; + } + if (namespace === EIP155) { + if (!provider) { + return; + } + const fetchedBalance = await provider.getBalance(account.address); + setBalance(fetchedBalance ? fetchedBalance.toString() : '0'); + } else { + const cosmosBalance = await cosmosStargateClient?.getBalance( + account.address, + requestedNetwork!.nativeDenom!.toLowerCase(), + ); + + setBalance(cosmosBalance?.amount!); + } + } catch (error: any) { + setTxError(error.message); + setIsTxErrorDialogOpen(true); + } + }; + + getAccountBalance(); + }, [account, provider, namespace, cosmosStargateClient, requestedNetwork]); + + useEffect(() => { + navigation.setOptions({ + // eslint-disable-next-line react/no-unstable-nested-components + header: ({ options, back }) => { + const title = getHeaderTitle(options, 'Approve Transaction'); + + return ( + + {back && ( + { + await rejectRequestHandler(); + navigation.navigate('Laconic'); + }} + /> + )} + + + ); + }, + }); + // 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 = { + 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 ? ( + + + + ) : ( + <> + + + {requestIcon && ( + + )} + {requestName} + {requestURL} + + + From + + + + + + {transaction && ( + + + + + {namespace === EIP155 ? ( + <> + {isEIP1559 === false ? ( + <> + + {'Gas Price (wei)'} + + + setEthGasPrice(BigNumber.from(value)) + } + style={styles.transactionFeesInput} + /> + + ) : ( + <> + + Max Fee Per Gas (wei) + + { + if (IS_NUMBER_REGEX.test(value)) { + setEthMaxFee(BigNumber.from(value)); + } + }} + style={styles.transactionFeesInput} + /> + + Max Priority Fee Per Gas (wei) + + { + if (IS_NUMBER_REGEX.test(value)) { + setEthMaxPriorityFee(BigNumber.from(value)); + } + }} + style={styles.transactionFeesInput} + /> + + )} + Gas Limit + { + if (IS_NUMBER_REGEX.test(value)) { + setEthGasLimit(BigNumber.from(value)); + } + }} + style={styles.transactionFeesInput} + /> + + + + ) : ( + <> + {`Fee (${ + requestedNetwork!.nativeDenom + })`} + setFees(value)} + style={styles.transactionFeesInput} + /> + Gas Limit + { + if (IS_NUMBER_REGEX.test(value)) { + setCosmosGasLimit(value); + } + }} + /> + + )} + + )} + + + + + + + )} + { + setIsTxErrorDialogOpen(false); + if (!isSufficientFunds || !balance || !fees) { + rejectRequestHandler(); + navigation.navigate('Laconic'); + } + }} + /> + + ); +}; + +export default ApproveTransfer; diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 1e09b37..af8dc61 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -29,8 +29,7 @@ const WCLogo = () => { }; const HomeScreen = () => { - const { accounts, setAccounts, currentIndex, setCurrentIndex } = - useAccounts(); + const { accounts, setAccounts, setCurrentIndex } = useAccounts(); const { networksData, selectedNetwork, setSelectedNetwork, setNetworksData } = useNetworks(); @@ -123,10 +122,6 @@ const HomeScreen = () => { setCurrentIndex(0); }; - const updateIndex = (index: number) => { - setCurrentIndex(index); - }; - useEffect(() => { fetchAccounts(); }, [networksData, setAccounts, selectedNetwork, fetchAccounts]); @@ -142,7 +137,7 @@ const HomeScreen = () => { <> - +