Take gas limit and fees values from user while approving transaction (#109)

* Take gas limit and fees from user

* Update text input ui

* Use gasPrice from networks data

* Use default gas limit from env

* Use default gas price if not found in registry

* Remove appended denom in gas price

* Use gas limit from env

* Show error dialog when transaction fails

* Calculate gas limit and gas price if not received from dapp

* Update example env

* Improve syntax

---------

Co-authored-by: IshaVenikar <ishavenikar7@gmail.com>
This commit is contained in:
shreerang6921 2024-04-23 10:22:30 +05:30 committed by Nabarun Gogoi
parent ad202f46fc
commit b1a0831e78
12 changed files with 290 additions and 74 deletions

View File

@ -1 +1,3 @@
WALLET_CONNECT_PROJECT_ID= WALLET_CONNECT_PROJECT_ID=
DEFAULT_GAS_LIMIT=
DEFAULT_GAS_PRICE=

View File

@ -2,6 +2,8 @@
declare module 'react-native-config' { declare module 'react-native-config' {
export interface NativeConfig { export interface NativeConfig {
WALLET_CONNECT_PROJECT_ID: string; WALLET_CONNECT_PROJECT_ID: string;
DEFAULT_GAS_LIMIT: string;
DEFAULT_GAS_PRICE: string;
} }
export const Config: NativeConfig; export const Config: NativeConfig;

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>Error sending transaction</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

@ -53,6 +53,10 @@ const cosmosNetworkDataSchema = z.object({
coinType: z.string().nonempty({ message: EMPTY_FIELD_ERROR }).regex(/^\d+$/), coinType: z.string().nonempty({ message: EMPTY_FIELD_ERROR }).regex(/^\d+$/),
nativeDenom: z.string().nonempty({ message: EMPTY_FIELD_ERROR }), nativeDenom: z.string().nonempty({ message: EMPTY_FIELD_ERROR }),
addressPrefix: 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 AddNetwork = () => {
@ -113,6 +117,13 @@ const AddNetwork = () => {
setValue('addressPrefix', cosmosChainDetails.bech32_prefix); setValue('addressPrefix', cosmosChainDetails.bech32_prefix);
setValue('coinType', String(cosmosChainDetails.slip44 ?? '118')); setValue('coinType', String(cosmosChainDetails.slip44 ?? '118'));
setValue('nativeDenom', cosmosChainDetails.fees?.fee_tokens[0].denom || ''); 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); }, CHAINID_DEBOUNCE_DELAY);
const submit = useCallback( const submit = useCallback(
@ -359,6 +370,31 @@ const AddNetwork = () => {
</> </>
)} )}
/> />
<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 <Button

View File

@ -1,7 +1,14 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Image, ScrollView, View } from 'react-native'; import { Image, ScrollView, View } from 'react-native';
import { ActivityIndicator, Button, Text, Appbar } from 'react-native-paper'; import {
import { providers, BigNumber } from 'ethers'; ActivityIndicator,
Button,
Text,
Appbar,
TextInput,
} from 'react-native-paper';
import { providers, BigNumber, ethers } from 'ethers';
import Config from 'react-native-config';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import { import {
@ -29,6 +36,7 @@ import DataBox from '../components/DataBox';
import { getPathKey } from '../utils/misc'; import { getPathKey } from '../utils/misc';
import { useNetworks } from '../context/NetworksContext'; import { useNetworks } from '../context/NetworksContext';
import { COSMOS, EIP155 } from '../utils/constants'; import { COSMOS, EIP155 } from '../utils/constants';
import TxErrorDialog from '../components/TxErrorDialog';
type SignRequestProps = NativeStackScreenProps< type SignRequestProps = NativeStackScreenProps<
StackParamsList, StackParamsList,
@ -52,12 +60,20 @@ const ApproveTransaction = ({ route }: SignRequestProps) => {
const [isTxLoading, setIsTxLoading] = useState(false); const [isTxLoading, setIsTxLoading] = useState(false);
const [cosmosStargateClient, setCosmosStargateClient] = const [cosmosStargateClient, setCosmosStargateClient] =
useState<SigningStargateClient>(); useState<SigningStargateClient>();
const [fees, setFees] = useState('');
const [cosmosGasLimit, setCosmosGasLimit] = useState(
Config.DEFAULT_GAS_LIMIT,
);
const [txError, setTxError] = useState<string>('');
const [isTxErrorDialogOpen, setIsTxErrorDialogOpen] = useState(false);
const [ethGasPrice, setEthGasPrice] = useState<string>();
const [ethGasLimit, setEthGasLimit] = useState<string>();
const requestedChain = networksData.find( const requestedNetwork = networksData.find(
networkData => networkData =>
`${networkData.namespace}:${networkData.chainId}` === chainId, `${networkData.namespace}:${networkData.chainId}` === chainId,
); );
const namespace = requestedChain!.namespace; const namespace = requestedNetwork!.namespace;
useEffect(() => { useEffect(() => {
if (namespace !== COSMOS) { if (namespace !== COSMOS) {
@ -71,18 +87,18 @@ const ApproveTransaction = ({ route }: SignRequestProps) => {
const cosmosPrivKey = ( const cosmosPrivKey = (
await getPathKey( await getPathKey(
`${requestedChain?.namespace}:${requestedChain?.chainId}`, `${requestedNetwork?.namespace}:${requestedNetwork?.chainId}`,
account.index, account.index,
) )
).privKey; ).privKey;
const sender = await DirectSecp256k1Wallet.fromKey( const sender = await DirectSecp256k1Wallet.fromKey(
Buffer.from(cosmosPrivKey.split('0x')[1], 'hex'), Buffer.from(cosmosPrivKey.split('0x')[1], 'hex'),
requestedChain?.addressPrefix, requestedNetwork?.addressPrefix,
); );
const client = await SigningStargateClient.connectWithSigner( const client = await SigningStargateClient.connectWithSigner(
requestedChain?.rpcUrl!, requestedNetwork?.rpcUrl!,
sender, sender,
); );
@ -90,17 +106,17 @@ const ApproveTransaction = ({ route }: SignRequestProps) => {
}; };
setClient(); setClient();
}, [account, requestedChain, chainId, namespace]); }, [account, requestedNetwork, chainId, namespace]);
const provider = useMemo(() => { const provider = useMemo(() => {
if (namespace === EIP155) { if (namespace === EIP155) {
if (!requestedChain) { if (!requestedNetwork) {
throw new Error('Requested chain not supported'); throw new Error('Requested chain not supported');
} }
return new providers.JsonRpcProvider(requestedChain.rpcUrl); return new providers.JsonRpcProvider(requestedNetwork.rpcUrl);
} }
}, [requestedChain, namespace]); }, [requestedNetwork, namespace]);
const navigation = const navigation =
useNavigation<NativeStackNavigationProp<StackParamsList>>(); useNavigation<NativeStackNavigationProp<StackParamsList>>();
@ -108,8 +124,8 @@ const ApproveTransaction = ({ route }: SignRequestProps) => {
const retrieveData = useCallback( const retrieveData = useCallback(
async (requestAddress: string) => { async (requestAddress: string) => {
const requestAccount = await retrieveSingleAccount( const requestAccount = await retrieveSingleAccount(
requestedChain!.namespace, requestedNetwork!.namespace,
requestedChain!.chainId, requestedNetwork!.chainId,
requestAddress, requestAddress,
); );
if (!requestAccount) { if (!requestAccount) {
@ -120,21 +136,31 @@ const ApproveTransaction = ({ route }: SignRequestProps) => {
setAccount(requestAccount); setAccount(requestAccount);
setIsLoading(false); setIsLoading(false);
}, },
[navigation, requestedChain], [navigation, requestedNetwork],
); );
const gasFees = useMemo(() => { useEffect(() => {
if (namespace === EIP155) { if (namespace === EIP155) {
return BigNumber.from(transaction.gasLimit) const ethFees = BigNumber.from(transaction.gasLimit ?? ethGasLimit ?? 0)
.mul(BigNumber.from(transaction.gasPrice)) .mul(BigNumber.from(transaction.gasPrice ?? ethGasPrice ?? 0))
.toString(); .toString();
setFees(ethFees);
} else { } else {
const gasPrice = GasPrice.fromString(transaction.gasPrice!.toString()); const gasPrice = GasPrice.fromString(
const cosmosFees = calculateFee(Number(transaction.gasLimit), gasPrice); requestedNetwork?.gasPrice! + requestedNetwork?.nativeDenom,
);
const cosmosFees = calculateFee(Number(cosmosGasLimit), gasPrice);
return cosmosFees.amount[0].amount; setFees(cosmosFees.amount[0].amount);
} }
}, [transaction, namespace]); }, [
transaction,
namespace,
ethGasLimit,
ethGasPrice,
cosmosGasLimit,
requestedNetwork,
]);
useEffect(() => { useEffect(() => {
retrieveData(transaction.from!); retrieveData(transaction.from!);
@ -146,21 +172,41 @@ const ApproveTransaction = ({ route }: SignRequestProps) => {
throw new Error('account not found'); throw new Error('account not found');
} }
const response = await approveWalletConnectRequest( try {
networksData, const response = await approveWalletConnectRequest({
requestEvent, networksData,
account, requestEvent,
namespace!, account,
requestedChain!.chainId, namespace,
'', chainId: requestedNetwork!.chainId,
namespace === EIP155 ? provider : cosmosStargateClient, 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: transaction.gasLimit
? String(transaction.gasLimit)
: ethGasLimit,
ethGasPrice: transaction.gasPrice
? String(transaction.gasPrice)
: ethGasPrice,
});
const { topic } = requestEvent; const { topic } = requestEvent;
await web3wallet!.respondSessionRequest({ topic, response }); await web3wallet!.respondSessionRequest({ topic, response });
navigation.navigate('Laconic');
} catch (error: any) {
setTxError(String(error));
setIsTxErrorDialogOpen(true);
}
setIsTxLoading(false); setIsTxLoading(false);
navigation.navigate('Laconic');
}; };
const rejectRequestHandler = async () => { const rejectRequestHandler = async () => {
@ -187,7 +233,7 @@ const ApproveTransaction = ({ route }: SignRequestProps) => {
} else { } else {
const cosmosBalance = await cosmosStargateClient?.getBalance( const cosmosBalance = await cosmosStargateClient?.getBalance(
account.address, account.address,
requestedChain!.nativeDenom!.toLowerCase(), requestedNetwork!.nativeDenom!.toLowerCase(),
); );
setBalance(cosmosBalance?.amount!); setBalance(cosmosBalance?.amount!);
@ -195,7 +241,7 @@ const ApproveTransaction = ({ route }: SignRequestProps) => {
}; };
getAccountBalance(); getAccountBalance();
}, [account, provider, namespace, cosmosStargateClient, requestedChain]); }, [account, provider, namespace, cosmosStargateClient, requestedNetwork]);
useEffect(() => { useEffect(() => {
navigation.setOptions({ navigation.setOptions({
@ -221,6 +267,37 @@ const ApproveTransaction = ({ route }: SignRequestProps) => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [navigation, route.name]); }, [navigation, route.name]);
useEffect(() => {
const getEthGas = async () => {
if (transaction.gasLimit && transaction.gasPrice) {
return;
}
if (!provider) {
return;
}
const gasPriceVal = await provider.getGasPrice();
const gasPrice = ethers.utils.hexValue(gasPriceVal);
setEthGasPrice(String(gasPrice));
const transactionObject = {
from: transaction.from!,
to: transaction.to!,
data: transaction.data!,
gasPrice,
value: transaction.value!,
};
const gasLimit = await provider.estimateGas(transactionObject);
setEthGasLimit(String(gasLimit));
};
getEthGas();
}, [provider, transaction]);
return ( return (
<> <>
{isLoading ? ( {isLoading ? (
@ -248,7 +325,7 @@ const ApproveTransaction = ({ route }: SignRequestProps) => {
</View> </View>
<DataBox <DataBox
label={`Balance (${ label={`Balance (${
namespace === EIP155 ? 'wei' : requestedChain!.nativeDenom namespace === EIP155 ? 'wei' : requestedNetwork!.nativeDenom
})`} })`}
data={ data={
balance === '' || balance === undefined balance === '' || balance === undefined
@ -261,20 +338,43 @@ const ApproveTransaction = ({ route }: SignRequestProps) => {
<DataBox label="To" data={transaction.to!} /> <DataBox label="To" data={transaction.to!} />
<DataBox <DataBox
label={`Amount (${ label={`Amount (${
namespace === EIP155 ? 'wei' : requestedChain!.nativeDenom namespace === EIP155 ? 'wei' : requestedNetwork!.nativeDenom
})`} })`}
data={BigNumber.from( data={BigNumber.from(
transaction.value?.toString(), transaction.value?.toString(),
).toString()} ).toString()}
/> />
<DataBox
label={`Gas Fees (${ {namespace === EIP155 ? (
namespace === EIP155 ? 'wei' : requestedChain!.nativeDenom <>
})`} <DataBox
data={gasFees!} label={`Gas Fees (${
/> namespace === EIP155
{namespace === EIP155 && ( ? 'wei'
<DataBox label="Data" data={transaction.data!} /> : requestedNetwork!.nativeDenom
})`}
data={fees!}
/>
<DataBox label="Data" data={transaction.data!} />
</>
) : (
<>
<Text style={styles.dataBoxLabel}>{`Fees (${
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 => setCosmosGasLimit(value)}
/>
</>
)} )}
</View> </View>
)} )}
@ -296,6 +396,11 @@ const ApproveTransaction = ({ route }: SignRequestProps) => {
</View> </View>
</> </>
)} )}
<TxErrorDialog
error={txError}
visible={isTxErrorDialogOpen}
hideDialog={() => setIsTxErrorDialogOpen(false)}
/>
</> </>
); );
}; };

View File

@ -16,7 +16,11 @@ import { StackParamsList } from '../types';
import styles from '../styles/stylesheet'; import styles from '../styles/stylesheet';
import { retrieveNetworksData } from '../utils/accounts'; import { retrieveNetworksData } from '../utils/accounts';
import { useNetworks } from '../context/NetworksContext'; import { useNetworks } from '../context/NetworksContext';
import { EMPTY_FIELD_ERROR, INVALID_URL_ERROR } from '../utils/constants'; import {
EIP155,
EMPTY_FIELD_ERROR,
INVALID_URL_ERROR,
} from '../utils/constants';
const networksFormDataSchema = z.object({ const networksFormDataSchema = z.object({
networkName: z.string().nonempty({ message: EMPTY_FIELD_ERROR }), networkName: z.string().nonempty({ message: EMPTY_FIELD_ERROR }),
@ -25,6 +29,10 @@ const networksFormDataSchema = z.object({
.string() .string()
.url({ message: INVALID_URL_ERROR }) .url({ message: INVALID_URL_ERROR })
.or(z.literal('')), .or(z.literal('')),
gasPrice: z
.string()
.nonempty({ message: EMPTY_FIELD_ERROR })
.regex(/^\d+(\.\d+)?$/),
}); });
type EditNetworkProps = NativeStackScreenProps<StackParamsList, 'EditNetwork'>; type EditNetworkProps = NativeStackScreenProps<StackParamsList, 'EditNetwork'>;
@ -129,6 +137,25 @@ const EditNetwork = ({ route }: EditNetworkProps) => {
</> </>
)} )}
/> />
{networkData.namespace !== EIP155 ? (
<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.gasPrice?.message}</HelperText>
</>
)}
/>
) : null}
<Button <Button
mode="contained" mode="contained"
loading={isSubmitting} loading={isSubmitting}

View File

@ -159,14 +159,14 @@ const SignRequest = ({ route }: SignRequestProps) => {
throw new Error('Request event not found'); throw new Error('Request event not found');
} }
const response = await approveWalletConnectRequest( const response = await approveWalletConnectRequest({
networksData, networksData,
requestEvent, requestEvent,
account, account,
namespace, namespace,
chainId, chainId,
message, message,
); });
const { topic } = requestEvent; const { topic } = requestEvent;
await web3wallet!.respondSessionRequest({ topic, response }); await web3wallet!.respondSessionRequest({ topic, response });

View File

@ -271,6 +271,7 @@ const styles = StyleSheet.create({
fontWeight: 'bold', fontWeight: 'bold',
marginVertical: 10, marginVertical: 10,
}, },
transactionFeesInput: { marginBottom: 10 },
}); });
export default styles; export default styles;

View File

@ -57,6 +57,7 @@ export type NetworksFormData = {
nativeDenom?: string; nativeDenom?: string;
addressPrefix?: string; addressPrefix?: string;
coinType?: string; coinType?: string;
gasPrice?: string;
isDefault: boolean; isDefault: boolean;
}; };

View File

@ -135,7 +135,10 @@ const storeNetworkData = async (
const updatedNetworks: NetworksDataState[] = [ const updatedNetworks: NetworksDataState[] = [
...retrievedNetworks, ...retrievedNetworks,
{ ...networkData, networkId: String(networkId) }, {
...networkData,
networkId: String(networkId),
},
]; ];
await setInternetCredentials( await setInternetCredentials(
'networks', 'networks',

View File

@ -23,6 +23,7 @@ export const DEFAULT_NETWORKS = [
nativeDenom: 'uatom', nativeDenom: 'uatom',
addressPrefix: 'cosmos', addressPrefix: 'cosmos',
coinType: '118', coinType: '118',
gasPrice: '0.025',
isDefault: true, isDefault: true,
}, },
]; ];

View File

@ -4,12 +4,7 @@ import { Wallet, providers } from 'ethers';
import { formatJsonRpcError, formatJsonRpcResult } from '@json-rpc-tools/utils'; import { formatJsonRpcError, formatJsonRpcResult } from '@json-rpc-tools/utils';
import { SignClientTypes } from '@walletconnect/types'; import { SignClientTypes } from '@walletconnect/types';
import { getSdkError } from '@walletconnect/utils'; import { getSdkError } from '@walletconnect/utils';
import { import { SigningStargateClient, coins, StdFee } from '@cosmjs/stargate';
SigningStargateClient,
coins,
GasPrice,
calculateFee,
} from '@cosmjs/stargate';
import { EIP155_SIGNING_METHODS } from './EIP155Data'; import { EIP155_SIGNING_METHODS } from './EIP155Data';
import { signDirectMessage, signEthMessage } from '../sign-message'; import { signDirectMessage, signEthMessage } from '../sign-message';
@ -17,15 +12,29 @@ import { Account, NetworksDataState } from '../../types';
import { getMnemonic, getPathKey } from '../misc'; import { getMnemonic, getPathKey } from '../misc';
import { getCosmosAccounts } from '../accounts'; import { getCosmosAccounts } from '../accounts';
export async function approveWalletConnectRequest( export async function approveWalletConnectRequest({
networksData: NetworksDataState[], networksData,
requestEvent: SignClientTypes.EventArguments['session_request'], requestEvent,
account: Account, account,
namespace: string, namespace,
chainId: string, chainId,
message?: string, message,
provider?: providers.JsonRpcProvider | SigningStargateClient, provider,
) { cosmosFee,
ethGasLimit,
ethGasPrice,
}: {
networksData: NetworksDataState[];
requestEvent: SignClientTypes.EventArguments['session_request'];
account: Account;
namespace: string;
chainId: string;
message?: string;
provider?: providers.JsonRpcProvider | SigningStargateClient;
cosmosFee?: StdFee;
ethGasLimit?: string;
ethGasPrice?: string;
}) {
const { params, id } = requestEvent; const { params, id } = requestEvent;
const { request } = params; const { request } = params;
@ -51,13 +60,18 @@ export async function approveWalletConnectRequest(
).privKey; ).privKey;
const wallet = new Wallet(privKey); const wallet = new Wallet(privKey);
const sendTransaction = request.params[0]; const sendTransaction = request.params[0];
const updatedTransaction = {
...sendTransaction,
gasLimit: ethGasLimit,
gasPrice: ethGasPrice,
};
if (!(provider instanceof providers.JsonRpcProvider)) { if (!(provider instanceof providers.JsonRpcProvider)) {
throw new Error('Provider not found'); throw new Error('Provider not found');
} }
const connectedWallet = wallet.connect(provider); const connectedWallet = wallet.connect(provider);
const hash = await connectedWallet.sendTransaction(sendTransaction); const hash = await connectedWallet.sendTransaction(updatedTransaction);
const receipt = typeof hash === 'string' ? hash : hash?.hash; const receipt = typeof hash === 'string' ? hash : hash?.hash;
return formatJsonRpcResult(id, { return formatJsonRpcResult(id, {
signature: receipt, signature: receipt,
@ -118,20 +132,16 @@ export async function approveWalletConnectRequest(
request.params[0].value, request.params[0].value,
requestedChain!.nativeDenom!, requestedChain!.nativeDenom!,
); );
const gasPrice = GasPrice.fromString(
request.params[0].gasPrice.toString(),
);
const cosmosFee = calculateFee(
Number(request.params[0].gasLimit),
gasPrice,
);
const receiverAddress = request.params[0].to; const receiverAddress = request.params[0].to;
if (!(provider instanceof SigningStargateClient)) { if (!(provider instanceof SigningStargateClient)) {
throw new Error('Cosmos stargate client not found'); throw new Error('Cosmos stargate client not found');
} }
if (!cosmosFee) {
throw new Error('Cosmos fee not found');
}
const result = await provider.sendTokens( const result = await provider.sendTokens(
address, address,
receiverAddress, receiverAddress,