diff --git a/.env.example b/.env.example index 2e44075..1ea38db 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,5 @@ REACT_APP_WALLET_CONNECT_PROJECT_ID= -REACT_APP_DEFAULT_GAS_PRICE=0.025 # Reference: https://github.com/cosmos/cosmos-sdk/issues/16020 REACT_APP_GAS_ADJUSTMENT=2 REACT_APP_LACONICD_RPC_URL=https://laconicd-sapo.laconic.com diff --git a/src/global.d.ts b/src/global.d.ts index dfb6ad7..e3611ef 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -14,10 +14,31 @@ declare global { // Called when accounts are ready for use onAccountsReady?: () => void; + + // Called when transfer is successfully completed + onTransferComplete?: (result: string) => void; + + // Called when transfer fails + onTransferError?: (error: string) => void; + + // Called when transfer is cancelled + onTransferCancelled?: () => void; + + // Called when account is created + onAccountCreated?: (account: string) => void; + + // Called when account creation fails + onAccountError?: (error: string) => void; }; // Handles incoming signature requests from Android receiveSignRequestFromAndroid?: (message: string) => void; + + // Handles incoming transfer requests from Android + receiveTransferRequestFromAndroid?: (to: string, amount: string, namespace: String, chainId: string, memo: string) => void; + + // Handles account creation requests from Android + receiveGetOrCreateAccountFromAndroid?: (chainId: string) => void; } } diff --git a/src/hooks/useGetOrCreateAccounts.ts b/src/hooks/useGetOrCreateAccounts.ts index ce6b950..1f008c3 100644 --- a/src/hooks/useGetOrCreateAccounts.ts +++ b/src/hooks/useGetOrCreateAccounts.ts @@ -58,35 +58,14 @@ const useGetOrCreateAccounts = () => { ); }; - const autoCreateAccounts = async () => { - const defaultChainId = networksData[0]?.chainId; - - if (!defaultChainId) { - console.log('useGetOrCreateAccounts: No default chainId found'); - return; - } - const accounts = await getOrCreateAccountsForChain(defaultChainId); - - // Only notify Android when we actually have accounts - if (accounts.length > 0 && window.Android?.onAccountsReady) { - window.Android.onAccountsReady(); - } else { - console.log('No accounts created or Android bridge not available'); - } - }; - window.addEventListener('message', handleCreateAccounts); - const isAndroidWebView = !!(window.Android); - - if (isAndroidWebView) { - autoCreateAccounts(); - } - return () => { window.removeEventListener('message', handleCreateAccounts); }; }, [networksData, getAccountsData, getOrCreateAccountsForChain]); + + return { getOrCreateAccountsForChain }; }; export default useGetOrCreateAccounts; diff --git a/src/hooks/useWebViewHandler.ts b/src/hooks/useWebViewHandler.ts index 7a0c99c..fdd352c 100644 --- a/src/hooks/useWebViewHandler.ts +++ b/src/hooks/useWebViewHandler.ts @@ -4,17 +4,38 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { useAccounts } from '../context/AccountsContext'; import { useNetworks } from '../context/NetworksContext'; +import useAccountsData from "../hooks/useAccountsData"; + import { StackParamsList } from '../types'; import useGetOrCreateAccounts from './useGetOrCreateAccounts'; +import { retrieveAccountsForNetwork } from '../utils/accounts'; export const useWebViewHandler = () => { // Navigation and context hooks const navigation = useNavigation>(); const { selectedNetwork } = useNetworks(); const { accounts, currentIndex } = useAccounts(); + const { getAccountsData } = useAccountsData(); + const { getOrCreateAccountsForChain } = useGetOrCreateAccounts(); // Initialize accounts - useGetOrCreateAccounts(); + const handleGetOrCreateAccount = useCallback(async (chainId: string) => { + try { + + const accountsData = await getOrCreateAccountsForChain(chainId); + + if (!accountsData || accountsData.length === 0) { + window.Android?.onAccountError?.('Failed to create/retrieve account'); + + return; + } + + window.Android?.onAccountCreated?.(JSON.stringify(accountsData[0])); + } catch (error) { + console.error('Account operation error:', error); + window.Android?.onAccountError?.(`Operation failed: ${error}`); + } + }, [getOrCreateAccountsForChain, getAccountsData]); // Core navigation handler const navigateToSignRequest = useCallback((message: string) => { @@ -27,12 +48,14 @@ export const useWebViewHandler = () => { if (!accounts?.length) { window.Android?.onSignatureError?.('No accounts available'); + return; } const currentAccount = accounts[currentIndex]; if (!currentAccount) { window.Android?.onSignatureError?.('Current account not found'); + return; } @@ -43,6 +66,7 @@ export const useWebViewHandler = () => { if (!match) { window.Android?.onSignatureError?.('Invalid signing path'); + return; } @@ -70,12 +94,67 @@ export const useWebViewHandler = () => { } }, [selectedNetwork, accounts, currentIndex, navigation]); + // Handle incoming transfer requests + const navigateToTransfer = useCallback(async (to: string, amount: string, namespace: String, chainId: string, memo: string) => { + + try { + // TODO: Pass the account info for transferring tokens + // Get first account + const [chainAccount] = await retrieveAccountsForNetwork( + `${namespace}:${chainId}`, + '0' + ); + + if (!chainAccount) { + console.error('Accounts not found'); + if (window.Android?.onTransferError) { + window.Android.onTransferError('Accounts not found'); + } + + return; + } + + const path = `/transfer/${namespace}/${chainId}/${chainAccount.address}/${to}/${amount}`; + + navigation.reset({ + index: 0, + routes: [ + { + name: 'ApproveTransfer', + path: path, + params: { + namespace: namespace, + chainId: `${namespace}:${chainId}`, + transaction: { + from: chainAccount.address, + to: to, + value: amount + }, + accountInfo: chainAccount, + memo: memo + }, + }, + ], + }); + } catch (error) { + if (window.Android?.onTransferError) { + window.Android.onTransferError(`Navigation error: ${error}`); + } + } + }, [navigation]); + useEffect(() => { // Assign the function to the window object window.receiveSignRequestFromAndroid = navigateToSignRequest; + window.receiveTransferRequestFromAndroid = navigateToTransfer; + + window.receiveGetOrCreateAccountFromAndroid = handleGetOrCreateAccount; + return () => { window.receiveSignRequestFromAndroid = undefined; + window.receiveTransferRequestFromAndroid = undefined; + window.receiveGetOrCreateAccountFromAndroid = undefined; }; - }, [navigateToSignRequest]); // Only the function reference as dependency + }, [navigateToSignRequest, navigateToTransfer, handleGetOrCreateAccount]); // Only the function reference as dependency }; diff --git a/src/screens/ApproveTransfer.tsx b/src/screens/ApproveTransfer.tsx index 34fcdff..f32e2a3 100644 --- a/src/screens/ApproveTransfer.tsx +++ b/src/screens/ApproveTransfer.tsx @@ -2,11 +2,11 @@ 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 JSONbig from 'json-bigint'; import { providers, BigNumber } from 'ethers'; import { Deferrable } from 'ethers/lib/utils'; @@ -41,28 +41,29 @@ 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'; +import { Button } from '@mui/material'; +import { LoadingButton } from '@mui/lab'; 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' ->; +type ApproveTransferProps = NativeStackScreenProps -const ApproveTransfer = ({ route }: SignRequestProps) => { +const ApproveTransfer = ({ route }: ApproveTransferProps) => { const { networksData } = useNetworks(); const { web3wallet } = useWalletConnect(); + // Extract data from route params or path 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 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 chainId = requestEvent?.params.chainId || route.params.chainId; + const requestMethod = requestEvent?.params.request.method; + const txMemo = route.params.memo || MEMO; const [account, setAccount] = useState(); const [isLoading, setIsLoading] = useState(true); @@ -205,6 +206,58 @@ const ApproveTransfer = ({ route }: SignRequestProps) => { [navigation, requestedNetwork], ); + //TODO: Handle ETH transactions + const handleIntent = async () => { + if (!account) { + throw new Error('Account is not valid'); + } + + console.log('Sending transaction request:', { + from: account.address, + to: transaction.to, + amount: transaction.value, + denom: requestedNetwork!.nativeDenom, + memo: txMemo, + gas: cosmosGasLimit, + fees: fees + }); + + if (!requestedNetwork) { + throw new Error('Network not found'); + } + + if (!cosmosStargateClient) { + throw new Error('Cosmos stargate client not found'); + } + + const result = await cosmosStargateClient.signAndBroadcast( + account.address, + [sendMsg], + { + amount: [ + { + amount: fees, + denom: requestedNetwork.nativeDenom!, + }, + ], + gas: cosmosGasLimit, + }, + txMemo, + ); + + console.log('Transaction result:', result); + + // Convert BigInt values to strings before sending to Android + const serializedResult = JSONbig.stringify(result); + + // Send the result back to Android and close dialog + if (window.Android?.onTransferComplete) { + window.Android.onTransferComplete(serializedResult); + } else { + alert(`Transaction: ${serializedResult}`); + } + }; + 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 @@ -246,6 +299,7 @@ const ApproveTransfer = ({ route }: SignRequestProps) => { requestedNetwork, ethMaxFee, ]); + useEffect(() => { retrieveData(transaction.from!); }, [retrieveData, transaction]); @@ -267,78 +321,82 @@ const ApproveTransfer = ({ route }: SignRequestProps) => { 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 (requestEvent) { + // Handle WalletConnect request + 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()})`, + 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, + cosmosFee: { + amount: [ + { + amount: fees, + denom: requestedNetwork!.nativeDenom!, + }, + ], + gas: cosmosGasLimit, + }, + sendMsg, + memo: txMemo, + }; + 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('Home'); + } else { + await handleIntent(); } - - 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('Home'); } catch (error) { + if (window.Android?.onTransferError) { + window.Android.onTransferError(`Transaction Failed: ${error}`); + } if (!(error instanceof Error)) { throw error; } @@ -350,14 +408,31 @@ const ApproveTransfer = ({ route }: SignRequestProps) => { }; const rejectRequestHandler = async () => { - const response = rejectWalletConnectRequest(requestEvent); - const { topic } = requestEvent; - await web3wallet!.respondSessionRequest({ - topic, - response, - }); + setIsTxLoading(true); + try { + if (requestEvent) { + const response = rejectWalletConnectRequest(requestEvent); + const { topic } = requestEvent; + await web3wallet!.respondSessionRequest({ + topic, + response, + }); + } - navigation.navigate('Home'); + if (window.Android?.onTransferCancelled) { + window.Android.onTransferCancelled(); + } else { + navigation.navigate('Home'); + } + } catch (error) { + if (!(error instanceof Error)) { + throw error; + } + + setTxError(error.message); + setIsTxErrorDialogOpen(true); + } + setIsTxLoading(false); }; useEffect(() => { @@ -472,7 +547,7 @@ const ApproveTransfer = ({ route }: SignRequestProps) => { const gasEstimation = await cosmosStargateClient.simulate( transaction.from!, [sendMsg], - MEMO, + txMemo, ); setCosmosGasLimit( @@ -490,7 +565,7 @@ const ApproveTransfer = ({ route }: SignRequestProps) => { } }; getCosmosGas(); - }, [cosmosStargateClient, isSufficientFunds, sendMsg, transaction]); + }, [cosmosStargateClient, isSufficientFunds, sendMsg, transaction,txMemo]); useEffect(() => { if (balance && !isSufficientFunds) { @@ -508,16 +583,18 @@ const ApproveTransfer = ({ route }: SignRequestProps) => { ) : ( <> - - {requestIcon && ( - - )} - {requestName} - {requestURL} - + {requestSession && ( + + {requestIcon && ( + + )} + {requestName} + {requestURL} + + )} From @@ -545,6 +622,12 @@ const ApproveTransfer = ({ route }: SignRequestProps) => { transaction.value?.toString(), ).toString()} /> + {namespace === COSMOS && ( + + )} {namespace === EIP155 ? ( <> @@ -638,17 +721,18 @@ const ApproveTransfer = ({ route }: SignRequestProps) => { )} - + diff --git a/src/types.ts b/src/types.ts index f75f10b..e70812b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -21,9 +21,11 @@ export type StackParamsList = { requestSessionData?: SessionTypes.Struct; }; ApproveTransfer: { + chainId?: string; transaction: PopulatedTransaction; - requestEvent: Web3WalletTypes.SessionRequest; - requestSessionData: SessionTypes.Struct; + requestEvent?: Web3WalletTypes.SessionRequest; + requestSessionData?: SessionTypes.Struct; + memo?: string; }; InvalidPath: undefined; WalletConnect: undefined; diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 3f93d29..05a303e 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -41,10 +41,10 @@ export const DEFAULT_NETWORKS: NetworksFormData[] = [ isDefault: true, }, { - chainId: 'theta-testnet-001', - networkName: COSMOS_TESTNET_CHAINS['cosmos:theta-testnet-001'].name, + chainId: 'provider', + networkName: COSMOS_TESTNET_CHAINS['cosmos:provider'].name, namespace: COSMOS, - rpcUrl: COSMOS_TESTNET_CHAINS['cosmos:theta-testnet-001'].rpc, + rpcUrl: COSMOS_TESTNET_CHAINS['cosmos:provider'].rpc, blockExplorerUrl: '', nativeDenom: 'uatom', addressPrefix: 'cosmos', @@ -52,6 +52,22 @@ export const DEFAULT_NETWORKS: NetworksFormData[] = [ gasPrice: '0.025', isDefault: true, }, + + //TODO: Add network from android app + { + chainId: 'nyx', + networkName: 'Nym', + namespace: COSMOS, + rpcUrl: 'https://rpc.nymtech.net', + blockExplorerUrl: 'https://explorer.nymtech.net', + nativeDenom: 'unym', + addressPrefix: 'n', + coinType: '118', + + // Ref: https://nym.com/docs/operators/nodes/validator-setup#apptoml-configuration + gasPrice: '0.025', + isDefault: true, + }, ]; export const CHAINID_DEBOUNCE_DELAY = 250; diff --git a/src/utils/wallet-connect/COSMOSData.ts b/src/utils/wallet-connect/COSMOSData.ts index a1c6baf..2a9c50d 100644 --- a/src/utils/wallet-connect/COSMOSData.ts +++ b/src/utils/wallet-connect/COSMOSData.ts @@ -19,10 +19,10 @@ export const COSMOS_TESTNET_CHAINS: Record< namespace: string; } > = { - 'cosmos:theta-testnet-001': { - chainId: 'theta-testnet-001', + 'cosmos:provider': { + chainId: 'provider', name: 'Cosmos Hub Testnet', - rpc: 'https://rpc-t.cosmos.nodestake.top', + rpc: 'https://rpc-rs.cosmos.nodestake.top', namespace: 'cosmos', }, };