diff --git a/apps/wallet/src/App.tsx b/apps/wallet/src/App.tsx index 416115b3..e53dae64 100644 --- a/apps/wallet/src/App.tsx +++ b/apps/wallet/src/App.tsx @@ -21,6 +21,7 @@ const LoginPage = lazy(() => import('./pages/login')); const OidcLoginPage = lazy(() => import('./pages/oidc-login')); const TokenDetailPage = lazy(() => import('./pages/token-detail')); const SendTokenPage = lazy(() => import('./pages/send-token')); +const SendTokenConfirmPage = lazy(() => import('./pages/send-token/confirm-page')); const ReceiveTokenPage = lazy(() => import('./pages/receive-token')); const PasswordPage = lazy(() => import('./pages/password')); const SettingsPage = lazy(() => import('./pages/settings')); @@ -103,6 +104,7 @@ const App: FC = observer(() => { } /> } /> } /> + } /> } /> )} diff --git a/apps/wallet/src/assets/copy.svg b/apps/wallet/src/assets/copy.svg index 93e6cc64..61148ecb 100644 --- a/apps/wallet/src/assets/copy.svg +++ b/apps/wallet/src/assets/copy.svg @@ -1,4 +1,4 @@ - - + + diff --git a/apps/wallet/src/assets/external.svg b/apps/wallet/src/assets/external.svg new file mode 100644 index 00000000..160b7357 --- /dev/null +++ b/apps/wallet/src/assets/external.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/wallet/src/assets/transfer-loading.svg b/apps/wallet/src/assets/transfer-loading.svg new file mode 100644 index 00000000..f01293dc --- /dev/null +++ b/apps/wallet/src/assets/transfer-loading.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/wallet/src/assets/transfer-success.svg b/apps/wallet/src/assets/transfer-success.svg new file mode 100644 index 00000000..bd5517db --- /dev/null +++ b/apps/wallet/src/assets/transfer-success.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/wallet/src/pages/send-token/confirm-page.tsx b/apps/wallet/src/pages/send-token/confirm-page.tsx new file mode 100644 index 00000000..a485970d --- /dev/null +++ b/apps/wallet/src/pages/send-token/confirm-page.tsx @@ -0,0 +1,212 @@ +import { observer } from "mobx-react"; +import { FC, useEffect, useMemo, useState } from "react"; +import hibitIdSession from "../../stores/session"; +import { useNavigate } from "react-router-dom"; +import SvgGo from '../../assets/right-arrow.svg?react'; +import SvgLoading from '../../assets/transfer-loading.svg?react'; +import SvgSuccess from '../../assets/transfer-success.svg?react'; +import SvgExternal from '../../assets/external.svg?react'; +import { useTokenBalanceQuery, useTokenQuery } from "../../apis/react-query/token"; +import BigNumber from "bignumber.js"; +import toaster from "../../components/Toaster"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import CopyButton from "../../components/CopyButton"; +import { sendTokenStore } from "./store"; +import { formatNumber } from "../../utils/formatter"; +import { ChainAssetType } from "../../utils/basicTypes"; +import { getChainTxLink } from "../../utils/link"; + +const SendTokenConfirmPage: FC = observer(() => { + const [errMsg, setErrMsg] = useState('') + const [transferResult, setTransferResult] = useState<{ + state: 'pending' | 'done', + txId: string + }>({ + state: 'pending', + txId: '' + }) + const navigate = useNavigate() + + const { state } = sendTokenStore + const nativeTokenQuery = useTokenQuery(hibitIdSession.chainInfo.nativeAssetSymbol) + const nativeBalanceQuery = useTokenBalanceQuery(nativeTokenQuery.data || undefined) + const feeQuery = useQuery({ + queryKey: ['estimatedFee', state], + queryFn: async () => { + if (!hibitIdSession.walletPool || !state.token) { + return null + } + return await hibitIdSession.walletPool.getEstimatedFee( + state.toAddress, + new BigNumber(state.amount), + state.token, + ) + }, + refetchInterval: 5000, + }) + + const minNativeBalance = useMemo(() => { + if (!feeQuery.data || !state.token) { + return null + } + if (state.token.chainAssetType.equals(ChainAssetType.Native)) { + return new BigNumber(state.amount).plus(feeQuery.data) + } else { + return feeQuery.data + } + }, [state, feeQuery.data]) + + useEffect(() => { + if (!nativeBalanceQuery.data || !minNativeBalance) { + return + } + if (nativeBalanceQuery.data.lt(minNativeBalance)) { + setErrMsg(`Insufficient gas in your wallet (at least ${formatNumber(minNativeBalance)} ${nativeTokenQuery.data?.assetSymbol})`) + } else { + setErrMsg('') + } + }, [nativeBalanceQuery.data, feeQuery.data, nativeTokenQuery.data]) + + const transferMutation = useMutation({ + mutationFn: async ({ address, amount }: { + address: string + amount: string + }) => { + if (!hibitIdSession.walletPool || !state.token) { + throw new Error('Wallet or token not ready') + } + return await hibitIdSession.walletPool.transfer( + address, + new BigNumber(amount), + state.token + ) + } + }) + + const handleSend = async () => { + if (!hibitIdSession.walletPool || !state.token || errMsg) { + return + } + try { + const txId = await transferMutation.mutateAsync({ + address: state.toAddress, + amount: state.amount, + }) + console.debug('[txId]', txId) + setTransferResult({ state: 'done', txId }) + sendTokenStore.reset() + } catch (e) { + console.error(e) + setTransferResult({ state: 'pending', txId: '' }) + toaster.error(e instanceof Error ? e.message : JSON.stringify(e)) + } + } + + if (transferMutation.isPending) { + return ( +
+
+ + Await confirmation +
+
+ ) + } + + if (transferResult.state === 'done') { + const txLink = getChainTxLink(hibitIdSession.chainInfo.chainId, transferResult.txId) + + return ( +
+
+ + Transaction finished + {txLink && ( + + view in explorer + + + )} +
+ +
+ ) + } + + return ( +
+
+ +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ + +
+
+ ) +}) + +export default SendTokenConfirmPage diff --git a/apps/wallet/src/pages/send-token/index.tsx b/apps/wallet/src/pages/send-token/index.tsx index d7bcc445..4780dcb1 100644 --- a/apps/wallet/src/pages/send-token/index.tsx +++ b/apps/wallet/src/pages/send-token/index.tsx @@ -8,18 +8,18 @@ import TokenSelect from "../../components/TokenSelect"; import { RootAssetInfo } from "../../apis/models"; import BigNumber from "bignumber.js"; import { formatNumber } from "../../utils/formatter"; -import toaster from "../../components/Toaster"; -import { useMutation } from "@tanstack/react-query"; import LoaderButton from "../../components/LoaderButton"; import { object, string } from "yup"; import { walletAddressValidate } from "../../utils/validator"; import { yupResolver } from "@hookform/resolvers/yup"; import { Controller, useForm } from "react-hook-form"; import { SYSTEM_MAX_DECIMALS } from "../../utils/formatter/numberFormatter"; +import { sendTokenStore } from "./store"; const SendTokenPage: FC = observer(() => { const { addressOrSymbol } = useParams() const [token, setToken] = useState(null) + const { state, setState } = sendTokenStore const navigate = useNavigate() const tokenQuery = useTokenQuery(addressOrSymbol ?? '') @@ -60,29 +60,13 @@ const SendTokenPage: FC = observer(() => { formState: { errors }, } = useForm({ defaultValues: { - toAddress: '', - amount: '' + toAddress: state.toAddress || '', + amount: state.amount || '', }, resolver: yupResolver(formSchema), mode: 'onChange' }) - const transferMutation = useMutation({ - mutationFn: async ({ address, amount }: { - address: string - amount: string - }) => { - if (!hibitIdSession.walletPool || !token) { - throw new Error('Wallet or token not ready') - } - return await hibitIdSession.walletPool.transfer( - address, - new BigNumber(amount), - token - ) - } - }) - useEffect(() => { if (tokenQuery.data) { setToken(tokenQuery.data) @@ -93,17 +77,12 @@ const SendTokenPage: FC = observer(() => { if (!hibitIdSession.walletPool || !token) { return } - try { - const txId = await transferMutation.mutateAsync({ - address: toAddress, - amount - }) - console.debug('[txId]', txId) - toaster.success('Transfer success') - } catch (e) { - console.error(e) - toaster.error(e instanceof Error ? e.message : JSON.stringify(e)) - } + setState({ + toAddress, + token, + amount + }) + navigate('/send/confirm') }) return ( @@ -200,7 +179,6 @@ const SendTokenPage: FC = observer(() => { Send diff --git a/apps/wallet/src/pages/send-token/store.ts b/apps/wallet/src/pages/send-token/store.ts new file mode 100644 index 00000000..e62c1edc --- /dev/null +++ b/apps/wallet/src/pages/send-token/store.ts @@ -0,0 +1,34 @@ +import { RootAssetInfo } from "../../apis/models"; +import { makeAutoObservable } from "mobx"; + +export interface SendTokenState { + token: RootAssetInfo | null + toAddress: string + amount: string +} + +export class SendTokenStore { + state: SendTokenState = { + token: null, + toAddress: '', + amount: '', + } + + constructor() { + makeAutoObservable(this) + } + + setState = (state: SendTokenState) => { + this.state = { ...state } + } + + reset = () => { + this.state = { + token: null, + toAddress: '', + amount: '', + } + } +} + +export const sendTokenStore = new SendTokenStore() diff --git a/apps/wallet/src/utils/chain/chain-wallets/ethereum/index.ts b/apps/wallet/src/utils/chain/chain-wallets/ethereum/index.ts index 106387d5..ef123817 100644 --- a/apps/wallet/src/utils/chain/chain-wallets/ethereum/index.ts +++ b/apps/wallet/src/utils/chain/chain-wallets/ethereum/index.ts @@ -102,4 +102,42 @@ export class EthereumChainWallet extends ChainWallet { throw new Error(`Ethereum: unsupported chain asset type ${assetInfo.chainAssetType.toString()}`); } + + public override getEstimatedFee = async (toAddress: string, amount: BigNumber, assetInfo: AssetInfo): Promise => { + if (!isAddress(toAddress)) { + throw new Error('Ethereum: invalid wallet address'); + } + if (!assetInfo.chain.equals(Chain.Ethereum)) { + throw new Error('Ethereum: invalid asset chain'); + } + // native + if (assetInfo.chainAssetType.equals(ChainAssetType.Native)) { + const feeData = await this.wallet.provider!.getFeeData() + const price = new BigNumber(feeData.gasPrice!.toString()) + const req = { + to: toAddress, + value: parseEther(amount.toString()) + } + const estimatedGas = await this.wallet.estimateGas(req) + return new BigNumber(estimatedGas.toString()).times(price).shiftedBy(-assetInfo.decimalPlaces.value) + } + // erc20 + if (assetInfo.chainAssetType.equals(ChainAssetType.ERC20)) { + const chainInfo = getChainByChainId(new ChainId(assetInfo.chain, assetInfo.chainNetwork)) + if (!chainInfo) { + throw new Error(`Ethereum: unsupported asset chain ${assetInfo.chain.toString()}_${assetInfo.chainNetwork.toString()}`) + } + const provider = new JsonRpcProvider(chainInfo.rpcUrls[0], chainInfo.chainId.network.value.toNumber()); + const feeData = await provider!.getFeeData() + const price = new BigNumber(feeData.gasPrice!.toString()) + const token = new Contract(assetInfo.contractAddress, erc20Abi, this.wallet.connect(provider)); + const decimals = await token.decimals(); + const estimatedGas = await token + .getFunction('transfer') + .estimateGas(toAddress, parseUnits(amount.toString(), decimals)); + return new BigNumber(parseEther(new BigNumber(estimatedGas.toString()).times(price).toString()).toString()) + } + + throw new Error(`Ethereum: unsupported chain asset type ${assetInfo.chainAssetType.toString()}`); + } } diff --git a/apps/wallet/src/utils/chain/chain-wallets/index.ts b/apps/wallet/src/utils/chain/chain-wallets/index.ts index 074f2984..1332c671 100644 --- a/apps/wallet/src/utils/chain/chain-wallets/index.ts +++ b/apps/wallet/src/utils/chain/chain-wallets/index.ts @@ -51,4 +51,9 @@ export class ChainWalletPool { const chainId = new ChainId(assetInfo.chain, assetInfo.chainNetwork) return await this.get(chainId).transfer(toAddress, amount, assetInfo) } + + getEstimatedFee = async (toAddress: string, amount: BigNumber, assetInfo: AssetInfo): Promise => { + const chainId = new ChainId(assetInfo.chain, assetInfo.chainNetwork) + return await this.get(chainId).getEstimatedFee(toAddress, amount, assetInfo) + } } diff --git a/apps/wallet/src/utils/chain/chain-wallets/ton/index.ts b/apps/wallet/src/utils/chain/chain-wallets/ton/index.ts index 2f4c5d9a..d244d262 100644 --- a/apps/wallet/src/utils/chain/chain-wallets/ton/index.ts +++ b/apps/wallet/src/utils/chain/chain-wallets/ton/index.ts @@ -185,6 +185,51 @@ export class TonChainWallet extends ChainWallet { throw new Error(`Ton: unsupported chain asset type ${assetInfo.chainAssetType.toString()}`); } + public override getEstimatedFee = async (toAddress: string, amount: BigNumber, assetInfo: AssetInfo): Promise => { + if (!assetInfo.chain.equals(Chain.Ton)) { + throw new Error('Ton: invalid asset chain'); + } + await this.readyPromise + const ownerAddress = (await this.getAccount()).address + + // native + if (assetInfo.chainAssetType.equals(ChainAssetType.Native)) { + const body = internal({ + value: toNano(amount.toString()), + to: Address.parse(toAddress), + }).body + const feeData = await this.client?.estimateExternalMessageFee( + Address.parse(ownerAddress), + { + body, + initCode: null, + initData: null, + ignoreSignature: true, + } + ) + if (!feeData) { + throw new Error('Ton: failed to estimate fee') + } + const fee = fromNano( + String( + feeData.source_fees.fwd_fee + + feeData.source_fees.in_fwd_fee + + feeData.source_fees.storage_fee + + feeData.source_fees.gas_fee + ) + ); + return new BigNumber(fee) + } + + // jetton + if (assetInfo.chainAssetType.equals(ChainAssetType.Jetton)) { + const minGas = JETTON_TRANSFER_AMOUNT.plus(JETTON_FORWARD_AMOUNT) + return minGas + } + + throw new Error(`Ton: unsupported chain asset type ${assetInfo.chainAssetType.toString()}`); + } + private initWallet = async (phrase: string) => { const endpoint = await getHttpEndpoint({ network: this.getIsTestNet() ? 'testnet' : 'mainnet', diff --git a/apps/wallet/src/utils/chain/chain-wallets/types.ts b/apps/wallet/src/utils/chain/chain-wallets/types.ts index 70dca8c2..3e45c534 100644 --- a/apps/wallet/src/utils/chain/chain-wallets/types.ts +++ b/apps/wallet/src/utils/chain/chain-wallets/types.ts @@ -21,4 +21,5 @@ export abstract class ChainWallet { public abstract signMessage: (message: string) => Promise public abstract balanceOf: (address: string, assetInfo: AssetInfo) => Promise public abstract transfer: (toAddress: string, amount: BigNumber, assetInfo: AssetInfo) => Promise + public abstract getEstimatedFee: (toAddress: string, amount: BigNumber, assetInfo: AssetInfo) => Promise } diff --git a/apps/wallet/src/utils/formatter/numberFormatter.ts b/apps/wallet/src/utils/formatter/numberFormatter.ts index 466aeea6..8334bb2d 100644 --- a/apps/wallet/src/utils/formatter/numberFormatter.ts +++ b/apps/wallet/src/utils/formatter/numberFormatter.ts @@ -19,10 +19,13 @@ const fmtConfig = { }; export const formatNumber = ( - number: bigint | number | string | BigNumber, + number: bigint | number | string | BigNumber | undefined | null, decimals?: number, decimalsAutoPlacement: boolean = true ) => { + if (typeof number === 'undefined' || number === null) { + return '--' + } const x = new BigNumber(number?.toString()); if (x.isNaN()) { return number?.toString(); diff --git a/apps/wallet/src/utils/link.ts b/apps/wallet/src/utils/link.ts new file mode 100644 index 00000000..72bdb036 --- /dev/null +++ b/apps/wallet/src/utils/link.ts @@ -0,0 +1,29 @@ +import { ChainId } from "./basicTypes"; +import { getChainByChainId } from "./chain"; + +export const getChainTxLink = (chainId: ChainId, txId: string) => { + const chainInfo = getChainByChainId(chainId); + if (chainInfo) { + if (chainInfo.getTxLink) { + return chainInfo.getTxLink(txId); + } + const [url, param] = chainInfo.explorer.split('?') + return `${url}/tx/${txId}${param ? `?${param}` : ''}`; + } + return txId +}; + +export const getChainAddressLink = ( + chainId: ChainId, + address: string +) => { + const chainInfo = getChainByChainId(chainId); + if (chainInfo) { + if (chainInfo.getAddressLink) { + return chainInfo.getAddressLink(address); + } + const [url, param] = chainInfo.explorer.split('?') + return `${url}/address/${address}${param ? `?${param}` : ''}`; + } + return address +};