zenith-wallet-web/src/screens/ApproveTransfer.tsx
Prathamesh Musale 01a6e63a17 Rename wallet to Zenith wallet (#2)
Part of https://www.notion.so/Implement-stacks-1b5a6b22d472806a82f5dafed6955138

Reviewed-on: LaconicNetwork/zenith-wallet-web#2
Co-authored-by: Prathamesh Musale <prathamesh.musale0@gmail.com>
Co-committed-by: Prathamesh Musale <prathamesh.musale0@gmail.com>
2025-06-19 11:02:11 +00:00

673 lines
20 KiB
TypeScript

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 { 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 { useWalletConnect } from '../context/WalletConnectContext';
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 Zenith 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 { web3wallet } = useWalletConnect();
const requestSession = route.params.requestSessionData;
const requestName = requestSession.peer.metadata.name;
const requestIcon = requestSession.peer.metadata.icons[0];
const requestURL = requestSession.peer.metadata.url;
const transaction = route.params.transaction;
const requestEvent = route.params.requestEvent;
const chainId = requestEvent.params.chainId;
const requestMethod = requestEvent.params.request.method;
const [account, setAccount] = useState<Account>();
const [isLoading, setIsLoading] = useState(true);
const [balance, setBalance] = useState<string>('');
const [isTxLoading, setIsTxLoading] = useState(false);
const [cosmosStargateClient, setCosmosStargateClient] =
useState<SigningStargateClient>();
const [fees, setFees] = useState<string>('');
const [cosmosGasLimit, setCosmosGasLimit] = useState<string>('');
const [txError, setTxError] = useState<string>();
const [isTxErrorDialogOpen, setIsTxErrorDialogOpen] = useState(false);
const [ethGasPrice, setEthGasPrice] = useState<BigNumber | null>();
const [ethGasLimit, setEthGasLimit] = useState<BigNumber>();
const [ethMaxFee, setEthMaxFee] = useState<BigNumber | null>();
const [ethMaxPriorityFee, setEthMaxPriorityFee] =
useState<BigNumber | null>();
const isSufficientFunds = useMemo(() => {
if (!transaction.value) {
return;
}
if (!balance) {
return;
}
const amountBigNum = BigNumber.from(String(transaction.value));
const balanceBigNum = BigNumber.from(balance);
if (amountBigNum.gte(balanceBigNum)) {
return false;
} else {
return true;
}
}, [balance, transaction]);
const requestedNetwork = networksData.find(
networkData =>
`${networkData.namespace}:${networkData.chainId}` === chainId,
);
const namespace = requestedNetwork!.namespace;
const sendMsg: MsgSendEncodeObject = useMemo(() => {
return {
typeUrl: '/cosmos.bank.v1beta1.MsgSend',
value: {
fromAddress: transaction.from,
toAddress: transaction.to,
amount: [
{
amount: String(transaction.value),
denom: requestedNetwork!.nativeDenom!,
},
],
},
};
}, [requestedNetwork, transaction]);
useEffect(() => {
if (namespace !== COSMOS) {
return;
}
const setClient = async () => {
if (!account) {
return;
}
const cosmosPrivKey = (
await getPathKey(
`${requestedNetwork?.namespace}:${requestedNetwork?.chainId}`,
account.index,
)
).privKey;
const sender = await DirectSecp256k1Wallet.fromKey(
Buffer.from(cosmosPrivKey.split('0x')[1], 'hex'),
requestedNetwork?.addressPrefix,
);
try {
const client = await SigningStargateClient.connectWithSigner(
requestedNetwork?.rpcUrl!,
sender,
);
setCosmosStargateClient(client);
} catch (error) {
if (!(error instanceof Error)) {
throw error;
}
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) {
if (!(error instanceof Error)) {
throw error;
}
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
) {
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('Home');
} catch (error) {
if (!(error instanceof Error)) {
throw error;
}
setTxError(error.message);
setIsTxErrorDialogOpen(true);
}
setIsTxLoading(false);
};
const rejectRequestHandler = async () => {
const response = rejectWalletConnectRequest(requestEvent);
const { topic } = requestEvent;
await web3wallet!.respondSessionRequest({
topic,
response,
});
navigation.navigate('Home');
};
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) {
if (!(error instanceof Error)) {
throw error;
}
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('Home');
}}
/>
)}
<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) {
if (!(error instanceof Error)) {
throw error;
}
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(import.meta.env.REACT_APP_GAS_ADJUSTMENT)),
),
);
} catch (error) {
if (!(error instanceof Error)) {
throw error;
}
setTxError(error.message);
setIsTxErrorDialogOpen(true);
}
};
getCosmosGas();
}, [cosmosStargateClient, isSufficientFunds, sendMsg, transaction]);
useEffect(() => {
if (balance && !isSufficientFunds) {
setTxError('Insufficient funds');
setIsTxErrorDialogOpen(true);
}
}, [isSufficientFunds, balance]);
return (
<>
{isLoading ? (
<View style={styles.spinnerContainer}>
<ActivityIndicator size="large" color="#0000ff" />
</View>
) : (
<>
<ScrollView contentContainerStyle={styles.appContainer}>
<View style={styles.dappDetails}>
{requestIcon && (
<Image
style={styles.dappLogo}
source={requestIcon ? { uri: requestIcon } : undefined}
/>
)}
<Text>{requestName}</Text>
<Text variant="bodyMedium">{requestURL}</Text>
</View>
<View style={styles.dataBoxContainer}>
<Text style={styles.dataBoxLabel}>From</Text>
<View style={styles.dataBox}>
<AccountDetails account={account} />
</View>
</View>
<DataBox
label={`Balance (${
namespace === EIP155 ? 'wei' : requestedNetwork!.nativeDenom
})`}
data={
balance === '' || balance === undefined
? 'Loading balance...'
: `${balance}`
}
/>
{transaction && (
<View style={styles.approveTransfer}>
<DataBox label="To" data={transaction.to!} />
<DataBox
label={`Amount (${
namespace === EIP155 ? 'wei' : requestedNetwork!.nativeDenom
})`}
data={BigNumber.from(
transaction.value?.toString(),
).toString()}
/>
{namespace === EIP155 ? (
<>
{isEIP1559 === false ? (
<>
<Text style={styles.dataBoxLabel}>
{'Gas Price (wei)'}
</Text>
<TextInput
mode="outlined"
value={ethGasPrice?.toNumber().toString()}
onChangeText={value =>
setEthGasPrice(BigNumber.from(value))
}
style={styles.transactionFeesInput}
/>
</>
) : (
<>
<Text style={styles.dataBoxLabel}>
Max Fee Per Gas (wei)
</Text>
<TextInput
mode="outlined"
value={ethMaxFee?.toNumber().toString()}
onChangeText={value => {
if (IS_NUMBER_REGEX.test(value)) {
setEthMaxFee(BigNumber.from(value));
}
}}
style={styles.transactionFeesInput}
/>
<Text style={styles.dataBoxLabel}>
Max Priority Fee Per Gas (wei)
</Text>
<TextInput
mode="outlined"
value={ethMaxPriorityFee?.toNumber().toString()}
onChangeText={value => {
if (IS_NUMBER_REGEX.test(value)) {
setEthMaxPriorityFee(BigNumber.from(value));
}
}}
style={styles.transactionFeesInput}
/>
</>
)}
<Text style={styles.dataBoxLabel}>Gas Limit</Text>
<TextInput
mode="outlined"
value={ethGasLimit?.toNumber().toString()}
onChangeText={value => {
if (IS_NUMBER_REGEX.test(value)) {
setEthGasLimit(BigNumber.from(value));
}
}}
style={styles.transactionFeesInput}
/>
<DataBox
label={`${
isEIP1559 === true ? 'Max Fee' : 'Gas Fee'
} (wei)`}
data={fees!}
/>
<DataBox label="Data" data={transaction.data!} />
</>
) : (
<>
<Text style={styles.dataBoxLabel}>{`Fee (${
requestedNetwork!.nativeDenom
})`}</Text>
<TextInput
mode="outlined"
value={fees}
onChangeText={value => setFees(value)}
style={styles.transactionFeesInput}
/>
<Text style={styles.dataBoxLabel}>Gas Limit</Text>
<TextInput
mode="outlined"
value={cosmosGasLimit}
onChangeText={value => {
if (IS_NUMBER_REGEX.test(value)) {
setCosmosGasLimit(value);
}
}}
/>
</>
)}
</View>
)}
</ScrollView>
<View style={styles.buttonContainer}>
<Button
mode="contained"
onPress={acceptRequestHandler}
loading={isTxLoading}
disabled={!balance || !fees}>
{isTxLoading ? 'Processing' : 'Yes'}
</Button>
<Button
mode="contained"
onPress={rejectRequestHandler}
buttonColor="#B82B0D">
No
</Button>
</View>
</>
)}
<TxErrorDialog
error={txError!}
visible={isTxErrorDialogOpen}
hideDialog={() => {
setIsTxErrorDialogOpen(false);
if (!isSufficientFunds || !balance || !fees) {
rejectRequestHandler();
navigation.navigate('Home');
}
}}
/>
</>
);
};
export default ApproveTransfer;