Update wallet code for token transfer using android embedded webview (#36)

Part of https://www.notion.so/Integrate-eSIM-buy-flow-into-app-18aa6b22d47280d4a77cf1b27e2ba193

Co-authored-by: AdityaSalunkhe21 <adityasalunkhe2204@gmail.com>
Co-authored-by: pranavjadhav007 <jadhavpranav89@gmail.com>
Reviewed-on: LaconicNetwork/laconic-wallet-web#36
This commit is contained in:
2025-07-01 04:53:35 +00:00
co-authored by AdityaSalunkhe21 pranav
parent d0623be1c3
commit 1e88321490
8 changed files with 320 additions and 140 deletions
-1
View File
@@ -1,6 +1,5 @@
REACT_APP_WALLET_CONNECT_PROJECT_ID= REACT_APP_WALLET_CONNECT_PROJECT_ID=
REACT_APP_DEFAULT_GAS_PRICE=0.025
# Reference: https://github.com/cosmos/cosmos-sdk/issues/16020 # Reference: https://github.com/cosmos/cosmos-sdk/issues/16020
REACT_APP_GAS_ADJUSTMENT=2 REACT_APP_GAS_ADJUSTMENT=2
REACT_APP_LACONICD_RPC_URL=https://laconicd-sapo.laconic.com REACT_APP_LACONICD_RPC_URL=https://laconicd-sapo.laconic.com
+21
View File
@@ -14,10 +14,31 @@ declare global {
// Called when accounts are ready for use // Called when accounts are ready for use
onAccountsReady?: () => void; 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 // Handles incoming signature requests from Android
receiveSignRequestFromAndroid?: (message: string) => void; 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;
} }
} }
+2 -23
View File
@@ -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); window.addEventListener('message', handleCreateAccounts);
const isAndroidWebView = !!(window.Android);
if (isAndroidWebView) {
autoCreateAccounts();
}
return () => { return () => {
window.removeEventListener('message', handleCreateAccounts); window.removeEventListener('message', handleCreateAccounts);
}; };
}, [networksData, getAccountsData, getOrCreateAccountsForChain]); }, [networksData, getAccountsData, getOrCreateAccountsForChain]);
return { getOrCreateAccountsForChain };
}; };
export default useGetOrCreateAccounts; export default useGetOrCreateAccounts;
+81 -2
View File
@@ -4,17 +4,38 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useAccounts } from '../context/AccountsContext'; import { useAccounts } from '../context/AccountsContext';
import { useNetworks } from '../context/NetworksContext'; import { useNetworks } from '../context/NetworksContext';
import useAccountsData from "../hooks/useAccountsData";
import { StackParamsList } from '../types'; import { StackParamsList } from '../types';
import useGetOrCreateAccounts from './useGetOrCreateAccounts'; import useGetOrCreateAccounts from './useGetOrCreateAccounts';
import { retrieveAccountsForNetwork } from '../utils/accounts';
export const useWebViewHandler = () => { export const useWebViewHandler = () => {
// Navigation and context hooks // Navigation and context hooks
const navigation = useNavigation<NativeStackNavigationProp<StackParamsList>>(); const navigation = useNavigation<NativeStackNavigationProp<StackParamsList>>();
const { selectedNetwork } = useNetworks(); const { selectedNetwork } = useNetworks();
const { accounts, currentIndex } = useAccounts(); const { accounts, currentIndex } = useAccounts();
const { getAccountsData } = useAccountsData();
const { getOrCreateAccountsForChain } = useGetOrCreateAccounts();
// Initialize accounts // 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 // Core navigation handler
const navigateToSignRequest = useCallback((message: string) => { const navigateToSignRequest = useCallback((message: string) => {
@@ -27,12 +48,14 @@ export const useWebViewHandler = () => {
if (!accounts?.length) { if (!accounts?.length) {
window.Android?.onSignatureError?.('No accounts available'); window.Android?.onSignatureError?.('No accounts available');
return; return;
} }
const currentAccount = accounts[currentIndex]; const currentAccount = accounts[currentIndex];
if (!currentAccount) { if (!currentAccount) {
window.Android?.onSignatureError?.('Current account not found'); window.Android?.onSignatureError?.('Current account not found');
return; return;
} }
@@ -43,6 +66,7 @@ export const useWebViewHandler = () => {
if (!match) { if (!match) {
window.Android?.onSignatureError?.('Invalid signing path'); window.Android?.onSignatureError?.('Invalid signing path');
return; return;
} }
@@ -70,12 +94,67 @@ export const useWebViewHandler = () => {
} }
}, [selectedNetwork, accounts, currentIndex, navigation]); }, [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(() => { useEffect(() => {
// Assign the function to the window object // Assign the function to the window object
window.receiveSignRequestFromAndroid = navigateToSignRequest; window.receiveSignRequestFromAndroid = navigateToSignRequest;
window.receiveTransferRequestFromAndroid = navigateToTransfer;
window.receiveGetOrCreateAccountFromAndroid = handleGetOrCreateAccount;
return () => { return () => {
window.receiveSignRequestFromAndroid = undefined; 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
}; };
+190 -106
View File
@@ -2,11 +2,11 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Image, ScrollView, View } from 'react-native'; import { Image, ScrollView, View } from 'react-native';
import { import {
ActivityIndicator, ActivityIndicator,
Button,
Text, Text,
Appbar, Appbar,
TextInput, TextInput,
} from 'react-native-paper'; } from 'react-native-paper';
import JSONbig from 'json-bigint';
import { providers, BigNumber } from 'ethers'; import { providers, BigNumber } from 'ethers';
import { Deferrable } from 'ethers/lib/utils'; 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 TxErrorDialog from '../components/TxErrorDialog';
import { EIP155_SIGNING_METHODS } from '../utils/wallet-connect/EIP155Data'; import { EIP155_SIGNING_METHODS } from '../utils/wallet-connect/EIP155Data';
import { COSMOS_METHODS } from '../utils/wallet-connect/COSMOSData'; 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'; export const MEMO = 'Sending signed tx from Laconic Wallet';
// Reference: https://ethereum.org/en/developers/docs/gas/#what-is-gas-limit // Reference: https://ethereum.org/en/developers/docs/gas/#what-is-gas-limit
const ETH_MINIMUM_GAS = 21000; const ETH_MINIMUM_GAS = 21000;
type SignRequestProps = NativeStackScreenProps< type ApproveTransferProps = NativeStackScreenProps<StackParamsList, 'ApproveTransfer'>
StackParamsList,
'ApproveTransfer'
>;
const ApproveTransfer = ({ route }: SignRequestProps) => { const ApproveTransfer = ({ route }: ApproveTransferProps) => {
const { networksData } = useNetworks(); const { networksData } = useNetworks();
const { web3wallet } = useWalletConnect(); const { web3wallet } = useWalletConnect();
// Extract data from route params or path
const requestSession = route.params.requestSessionData; const requestSession = route.params.requestSessionData;
const requestName = requestSession.peer.metadata.name; const requestName = requestSession?.peer.metadata.name;
const requestIcon = requestSession.peer.metadata.icons[0]; const requestIcon = requestSession?.peer.metadata.icons[0];
const requestURL = requestSession.peer.metadata.url; const requestURL = requestSession?.peer.metadata.url;
const transaction = route.params.transaction; const transaction = route.params.transaction;
const requestEvent = route.params.requestEvent; const requestEvent = route.params.requestEvent;
const chainId = requestEvent.params.chainId; const chainId = requestEvent?.params.chainId || route.params.chainId;
const requestMethod = requestEvent.params.request.method; const requestMethod = requestEvent?.params.request.method;
const txMemo = route.params.memo || MEMO;
const [account, setAccount] = useState<Account>(); const [account, setAccount] = useState<Account>();
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -205,6 +206,58 @@ const ApproveTransfer = ({ route }: SignRequestProps) => {
[navigation, requestedNetwork], [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(() => { useEffect(() => {
// Set loading to false when gas values for requested chain are fetched // 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 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, requestedNetwork,
ethMaxFee, ethMaxFee,
]); ]);
useEffect(() => { useEffect(() => {
retrieveData(transaction.from!); retrieveData(transaction.from!);
}, [retrieveData, transaction]); }, [retrieveData, transaction]);
@@ -267,78 +321,82 @@ const ApproveTransfer = ({ route }: SignRequestProps) => {
throw new Error('account not found'); throw new Error('account not found');
} }
if (ethGasLimit && ethGasLimit.lt(ETH_MINIMUM_GAS)) { if (requestEvent) {
throw new Error(`Atleast ${ETH_MINIMUM_GAS} gas limit is required`); // 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)) { if (ethMaxFee && ethMaxPriorityFee && ethMaxFee.lte(ethMaxPriorityFee)) {
throw new Error( throw new Error(
`Max fee per gas (${ethMaxFee.toNumber()}) cannot be lower than or equal to max priority fee per gas (${ethMaxPriorityFee.toNumber()})`, `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) { } catch (error) {
if (window.Android?.onTransferError) {
window.Android.onTransferError(`Transaction Failed: ${error}`);
}
if (!(error instanceof Error)) { if (!(error instanceof Error)) {
throw error; throw error;
} }
@@ -350,14 +408,31 @@ const ApproveTransfer = ({ route }: SignRequestProps) => {
}; };
const rejectRequestHandler = async () => { const rejectRequestHandler = async () => {
const response = rejectWalletConnectRequest(requestEvent); setIsTxLoading(true);
const { topic } = requestEvent; try {
await web3wallet!.respondSessionRequest({ if (requestEvent) {
topic, const response = rejectWalletConnectRequest(requestEvent);
response, 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(() => { useEffect(() => {
@@ -472,7 +547,7 @@ const ApproveTransfer = ({ route }: SignRequestProps) => {
const gasEstimation = await cosmosStargateClient.simulate( const gasEstimation = await cosmosStargateClient.simulate(
transaction.from!, transaction.from!,
[sendMsg], [sendMsg],
MEMO, txMemo,
); );
setCosmosGasLimit( setCosmosGasLimit(
@@ -490,7 +565,7 @@ const ApproveTransfer = ({ route }: SignRequestProps) => {
} }
}; };
getCosmosGas(); getCosmosGas();
}, [cosmosStargateClient, isSufficientFunds, sendMsg, transaction]); }, [cosmosStargateClient, isSufficientFunds, sendMsg, transaction,txMemo]);
useEffect(() => { useEffect(() => {
if (balance && !isSufficientFunds) { if (balance && !isSufficientFunds) {
@@ -508,16 +583,18 @@ const ApproveTransfer = ({ route }: SignRequestProps) => {
) : ( ) : (
<> <>
<ScrollView contentContainerStyle={styles.appContainer}> <ScrollView contentContainerStyle={styles.appContainer}>
<View style={styles.dappDetails}> {requestSession && (
{requestIcon && ( <View style={styles.dappDetails}>
<Image {requestIcon && (
style={styles.dappLogo} <Image
source={requestIcon ? { uri: requestIcon } : undefined} style={styles.dappLogo}
/> source={requestIcon ? { uri: requestIcon } : undefined}
)} />
<Text>{requestName}</Text> )}
<Text variant="bodyMedium">{requestURL}</Text> <Text>{requestName}</Text>
</View> <Text variant="bodyMedium">{requestURL}</Text>
</View>
)}
<View style={styles.dataBoxContainer}> <View style={styles.dataBoxContainer}>
<Text style={styles.dataBoxLabel}>From</Text> <Text style={styles.dataBoxLabel}>From</Text>
<View style={styles.dataBox}> <View style={styles.dataBox}>
@@ -545,6 +622,12 @@ const ApproveTransfer = ({ route }: SignRequestProps) => {
transaction.value?.toString(), transaction.value?.toString(),
).toString()} ).toString()}
/> />
{namespace === COSMOS && (
<DataBox
label="Memo"
data={txMemo}
/>
)}
{namespace === EIP155 ? ( {namespace === EIP155 ? (
<> <>
@@ -638,17 +721,18 @@ const ApproveTransfer = ({ route }: SignRequestProps) => {
)} )}
</ScrollView> </ScrollView>
<View style={styles.buttonContainer}> <View style={styles.buttonContainer}>
<Button <LoadingButton
mode="contained" variant="contained"
onPress={acceptRequestHandler} onClick={acceptRequestHandler}
loading={isTxLoading} loading={isTxLoading}
disabled={!balance || !fees}> disabled={!balance || !fees}
id="approve-transaction-button">
{isTxLoading ? 'Processing' : 'Yes'} {isTxLoading ? 'Processing' : 'Yes'}
</Button> </LoadingButton>
<Button <Button
mode="contained" variant="contained"
onPress={rejectRequestHandler} onClick={rejectRequestHandler}
buttonColor="#B82B0D"> color="error">
No No
</Button> </Button>
</View> </View>
+4 -2
View File
@@ -21,9 +21,11 @@ export type StackParamsList = {
requestSessionData?: SessionTypes.Struct; requestSessionData?: SessionTypes.Struct;
}; };
ApproveTransfer: { ApproveTransfer: {
chainId?: string;
transaction: PopulatedTransaction; transaction: PopulatedTransaction;
requestEvent: Web3WalletTypes.SessionRequest; requestEvent?: Web3WalletTypes.SessionRequest;
requestSessionData: SessionTypes.Struct; requestSessionData?: SessionTypes.Struct;
memo?: string;
}; };
InvalidPath: undefined; InvalidPath: undefined;
WalletConnect: undefined; WalletConnect: undefined;
+19 -3
View File
@@ -41,10 +41,10 @@ export const DEFAULT_NETWORKS: NetworksFormData[] = [
isDefault: true, isDefault: true,
}, },
{ {
chainId: 'theta-testnet-001', chainId: 'provider',
networkName: COSMOS_TESTNET_CHAINS['cosmos:theta-testnet-001'].name, networkName: COSMOS_TESTNET_CHAINS['cosmos:provider'].name,
namespace: COSMOS, namespace: COSMOS,
rpcUrl: COSMOS_TESTNET_CHAINS['cosmos:theta-testnet-001'].rpc, rpcUrl: COSMOS_TESTNET_CHAINS['cosmos:provider'].rpc,
blockExplorerUrl: '', blockExplorerUrl: '',
nativeDenom: 'uatom', nativeDenom: 'uatom',
addressPrefix: 'cosmos', addressPrefix: 'cosmos',
@@ -52,6 +52,22 @@ export const DEFAULT_NETWORKS: NetworksFormData[] = [
gasPrice: '0.025', gasPrice: '0.025',
isDefault: true, 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; export const CHAINID_DEBOUNCE_DELAY = 250;
+3 -3
View File
@@ -19,10 +19,10 @@ export const COSMOS_TESTNET_CHAINS: Record<
namespace: string; namespace: string;
} }
> = { > = {
'cosmos:theta-testnet-001': { 'cosmos:provider': {
chainId: 'theta-testnet-001', chainId: 'provider',
name: 'Cosmos Hub Testnet', name: 'Cosmos Hub Testnet',
rpc: 'https://rpc-t.cosmos.nodestake.top', rpc: 'https://rpc-rs.cosmos.nodestake.top',
namespace: 'cosmos', namespace: 'cosmos',
}, },
}; };