diff --git a/assets/locales/en-US.json b/assets/locales/en-US.json index b106e38..3d3459a 100644 --- a/assets/locales/en-US.json +++ b/assets/locales/en-US.json @@ -31,7 +31,7 @@ "TRY_AGAIN": "Try Again", "COPY_TO_CLIPBOARD_MESSAGE": "Text copied to clipboard", "INVALID_PASSWORD": "Invalid password.", - "INVALID_USERNAME": "Invalid username.", + "INVALID_USERNAME": "Account on selected network does not exist.", "GENERAL_ERROR_MESSAGE": "Couldn't complete request at the moment.", "USERNAME_NOT_AVAILABLE": "Username is not available", "CANNOT_CHECK_USERNAME": "Cannot check username. Network currently unavailable", @@ -103,8 +103,9 @@ "CLEAR_ACTIVITY_DATA_CONFIRMATION": "Do you really want to clear all your wallet data?", "SELECT": "Select", "SELECT_ADDRESS": "Choose address to send to", + "SELECT_ADDRESS_TOKEN": "Choose address to send {tokenName} to", "PROCEED": "Proceed", - "SENDING_TO": "Sednding to", + "SENDING_TO": "Sending to", "ENABLE_WALLET_LOCK": "Enable wallet lock", "MINUTES": "Minutes", "LOCK_INTERVAL": "Lock interval", @@ -113,5 +114,18 @@ "IMPORT_TOKEN": "Import Token", "IMPORT_TOKEN_INSTRUCTIONS": "Enter contract address to import token", "IMPORT_TOKEN_SUCCESS": "Token is successfully imported", - "NOT_ENOUGH_BALANCE": "Your current balance is too low for this transaction" + "NOT_ENOUGH_BALANCE": "Your current balance is too low for this transaction", + "NETWORK_UNAVAILABLE_ERROR": "Network RPC is not reachable.", + "ERROR_DESCRIPTION": "Error description", + "BLOCK_EXPLORER_URL_LABEL": "Block Explorer URL (Optional)", + "TRANSACTION_DETAILS": "Transaction Details", + "RECIPIENT": "Recipient", + "GAS_CONST": "Gas Cost", + "TRANSCTION_HASH": "Transaction hash", + "DATA": "Data", + "TIME": "Time", + "DIALOG_DAPP_ACCOUNT_INFO": "dApp with ID {dappId} asks to access your account information (address and ENS name).\nDo you allow access to your account information?", + "ACCOUNT_INFORMATION_ACCESS": "Account Information Access", + "TOKEN_IMPORT_ERROR": "Couldn't get token data.", + "TRANSACTION_ERROR": "The transaction failed. Click here to see the details." } diff --git a/library/src/constants/api-actions.enum.ts b/library/src/constants/api-actions.enum.ts index c377fa2..873fae3 100644 --- a/library/src/constants/api-actions.enum.ts +++ b/library/src/constants/api-actions.enum.ts @@ -3,5 +3,6 @@ export enum ApiActions { SIGNER_SIGN_MESSAGE = 'signer.signMessage', SEND_TRANSACTION = 'send-transaction', GET_USER_BALANCE = 'get-user-balance', + GET_USER_INFO = 'get-user-info', ECHO = 'echo', } diff --git a/library/src/model/account-info.ts b/library/src/model/account-info.ts new file mode 100644 index 0000000..25533d5 --- /dev/null +++ b/library/src/model/account-info.ts @@ -0,0 +1,4 @@ +export interface AccountInfo { + address: string + ensName?: string +} diff --git a/library/src/wallet.ts b/library/src/wallet.ts index 757dc6e..974cfbf 100644 --- a/library/src/wallet.ts +++ b/library/src/wallet.ts @@ -1,9 +1,19 @@ import { ApiActions } from './constants/api-actions.enum' import { BlossomMessages } from './messages/blossom-messages' +import { AccountInfo } from './model/account-info' export class Wallet { constructor(private messages: BlossomMessages) {} + /** + * Returns user's account information, like account address and + * ENS name (if available). + * @returns AccountInfo object + */ + public getAccountInfo(): Promise { + return this.messages.sendMessage(ApiActions.GET_USER_INFO) + } + /** * Returns account balance of current user in wei * @returns current balance in wei diff --git a/src/constants/background-actions.enum.ts b/src/constants/background-actions.enum.ts index 481e09f..9b617a3 100644 --- a/src/constants/background-actions.enum.ts +++ b/src/constants/background-actions.enum.ts @@ -9,6 +9,7 @@ enum BackgroundAction { GENERATE_WALLET = 'generate-wallet', OPEN_AUTH_PAGE = 'open-auth-page', GET_CURRENT_USER = 'get-current-user', + GET_USER_INFO = 'get-user-info', GET_LOCAL_ACCOUNTS = 'get-local-accounts', GET_BALANCE = 'get-balance', GET_USER_BALANCE = 'get-user-balance', diff --git a/src/constants/dapp-actions.enum.ts b/src/constants/dapp-actions.enum.ts index e3ae616..80c32ab 100644 --- a/src/constants/dapp-actions.enum.ts +++ b/src/constants/dapp-actions.enum.ts @@ -5,6 +5,7 @@ export const DAPP_ACTIONS: string[] = [ BackgroundAction.SIGNER_SIGN_MESSAGE, BackgroundAction.SEND_TRANSACTION, BackgroundAction.GET_USER_BALANCE, + BackgroundAction.GET_USER_INFO, BackgroundAction.ECHO, ] @@ -13,5 +14,6 @@ export const E2E_ACTIONS: string[] = [ BackgroundAction.SIGNER_SIGN_MESSAGE, BackgroundAction.SEND_TRANSACTION, BackgroundAction.GET_USER_BALANCE, + BackgroundAction.GET_USER_INFO, BackgroundAction.ECHO, ] diff --git a/src/constants/networks.ts b/src/constants/networks.ts index 1704612..ed2b94e 100644 --- a/src/constants/networks.ts +++ b/src/constants/networks.ts @@ -15,10 +15,12 @@ export const networks: Network[] = [ ...extractNetworkConfig(Environments.SEPOLIA), label: 'Sepolia', custom: false, + blockExplorerUrl: 'https://sepolia.etherscan.io/tx/', }, { ...extractNetworkConfig(Environments.GOERLI), label: 'Görli', custom: false, + blockExplorerUrl: 'https://goerli.etherscan.io/tx/', }, ] diff --git a/src/constants/whitelisted-dapps.ts b/src/constants/whitelisted-dapps.ts index 9e55a4c..352c80b 100644 --- a/src/constants/whitelisted-dapps.ts +++ b/src/constants/whitelisted-dapps.ts @@ -13,3 +13,7 @@ export const whitelistedDapps: Array<{ url: string; dappId: string }> = [ { url: 'https://fairdrive.dev.fairdatasociety.org/apps/slidezz', dappId: 'slidezz-dev' }, { url: 'https://fairdrive.fairdatasociety.org/apps/slidezz', dappId: 'slidezz' }, ] + +if (process.env.ENVIRONMENT === 'development') { + whitelistedDapps.push({ url: 'http://localhost:3000', dappId: 'fairdrive-local' }) +} diff --git a/src/listeners/message-listeners/account.listener.ts b/src/listeners/message-listeners/account.listener.ts index b04d5c4..aeeeb70 100644 --- a/src/listeners/message-listeners/account.listener.ts +++ b/src/listeners/message-listeners/account.listener.ts @@ -19,7 +19,6 @@ import { TokenCheckRequest, TokenRequest, TokenTransferRequest, - Transaction, } from '../../model/internal-messages.model' import { SessionFdpStorageProvider } from '../../services/fdp-storage/session-fdp-storage.provider' import { Dialog } from '../../services/dialog.service' @@ -42,7 +41,7 @@ const wallet = new WalletService() const fdpStorageProvider = new SessionFdpStorageProvider() function saveTransaction( - transaction: Transaction | InternalTransaction, + transaction: InternalTransaction, transactionContent: providers.TransactionReceipt, accountName: string, networkLabel: string, @@ -60,6 +59,7 @@ function saveTransaction( data: transaction.data, gas: transactionContent.gasUsed.toString(), gasPrice: transactionContent.effectiveGasPrice.toString(), + hash: transactionContent.transactionHash, }, token, }, @@ -253,7 +253,12 @@ export async function getTokenBalance({ token: { address }, rpcUrl }: TokenReque return balance.toString() } -export async function transferTokens({ token, to, value, rpcUrl }: TokenTransferRequest): Promise { +export async function transferTokens({ + token, + to, + value, + rpcUrl, +}: TokenTransferRequest): Promise { const [{ ensUserName, localUserName }, fdp] = await Promise.all([ session.load(), fdpStorageProvider.getService(), @@ -272,6 +277,8 @@ export async function transferTokens({ token, to, value, rpcUrl }: TokenTransfer networks.find(({ rpc }) => rpc === rpcUrl).label, token, ) + + return receipt } const messageHandler = createMessageHandler([ diff --git a/src/listeners/message-listeners/auth.listener.ts b/src/listeners/message-listeners/auth.listener.ts index 2c1abee..7065ce4 100644 --- a/src/listeners/message-listeners/auth.listener.ts +++ b/src/listeners/message-listeners/auth.listener.ts @@ -15,6 +15,7 @@ import { LoginData, RegisterData, RegisterResponse, + UserInfo, UsernameCheckData, UserResponse, } from '../../model/internal-messages.model' @@ -24,9 +25,13 @@ import { SessionService } from '../../services/session.service' import { Storage } from '../../services/storage/storage.service' import { openTab } from '../../utils/tabs' import { createMessageHandler } from './message-handler' +import { getDappId } from './listener.utils' +import { Dialog } from '../../services/dialog.service' +import { errorMessages } from '../../constants/errors' let fdpStorageProvider = new SessionlessFdpStorageProvider() const storage = new Storage() +const dialogs = new Dialog() const session = new SessionService() const account = new AccountService() @@ -182,6 +187,29 @@ export function logout(): Promise { return session.close() } +export async function getUserInfo(data, sender: chrome.runtime.MessageSender): Promise { + const [sessionData, dappId] = await Promise.all([session.load(), getDappId(sender)]) + + const dapp = await storage.getDappBySession(dappId, sessionData) + + if (!dapp.accountInfoAccess) { + const confirmed = await dialogs.ask('DIALOG_DAPP_ACCOUNT_INFO', { dappId }) + + if (!confirmed) { + throw new Error(errorMessages.ACCESS_DENIED) + } + + await storage.updateDappBySession(dappId, { accountInfoAccess: true }, sessionData) + } + + const { ensUserName: ensName, address } = sessionData + + return { + ensName, + address, + } +} + const messageHandler = createMessageHandler([ { action: BackgroundAction.LOGIN, @@ -229,6 +257,10 @@ const messageHandler = createMessageHandler([ action: BackgroundAction.LOGOUT, handler: logout, }, + { + action: BackgroundAction.GET_USER_INFO, + handler: getUserInfo, + }, ]) export default messageHandler diff --git a/src/messaging/content-api.messaging.ts b/src/messaging/content-api.messaging.ts index 3d704d2..faec0cf 100644 --- a/src/messaging/content-api.messaging.ts +++ b/src/messaging/content-api.messaging.ts @@ -1,4 +1,4 @@ -import { BigNumber } from 'ethers' +import { BigNumber, providers } from 'ethers' import BackgroundAction from '../constants/background-actions.enum' import { Address, BigNumberString, DappId } from '../model/general.types' import { @@ -86,8 +86,11 @@ export async function getTokenBalance(token: Token, rpcUrl: string): Promise { - return sendMessage(BackgroundAction.SEND_TRANSACTION_INTERNAL, transaction) +export function sendTransaction(transaction: InternalTransaction): Promise { + return sendMessage( + BackgroundAction.SEND_TRANSACTION_INTERNAL, + transaction, + ) } export async function estimateGasPrice(transaction: InternalTransaction): Promise { @@ -108,8 +111,13 @@ export async function estimateTokenGasPrice(tokenTransferRequest: TokenTransferR return BigNumber.from(price) } -export function transferTokens(tokenTransferRequest: TokenTransferRequest): Promise { - return sendMessage(BackgroundAction.TRANSFER_TOKENS, tokenTransferRequest) +export function transferTokens( + tokenTransferRequest: TokenTransferRequest, +): Promise { + return sendMessage( + BackgroundAction.TRANSFER_TOKENS, + tokenTransferRequest, + ) } export function getWalletTransactions(networkLabel: string): Promise { diff --git a/src/model/internal-messages.model.ts b/src/model/internal-messages.model.ts index 29cdb06..c6572ed 100644 --- a/src/model/internal-messages.model.ts +++ b/src/model/internal-messages.model.ts @@ -120,3 +120,8 @@ export interface TokenTransferRequest extends TokenRequest { to: string value: string } + +export interface UserInfo { + address: Address + ensName?: string +} diff --git a/src/model/storage/dapps.model.ts b/src/model/storage/dapps.model.ts index 0212160..76f4bc8 100644 --- a/src/model/storage/dapps.model.ts +++ b/src/model/storage/dapps.model.ts @@ -16,6 +16,7 @@ export interface PodPermission { export interface Dapp { podPermissions: Record fullStorageAccess: boolean + accountInfoAccess: boolean dappId: DappId } diff --git a/src/model/storage/network.model.ts b/src/model/storage/network.model.ts index 32e4194..ec2fc2d 100644 --- a/src/model/storage/network.model.ts +++ b/src/model/storage/network.model.ts @@ -7,4 +7,5 @@ export interface Network { fdsRegistrar?: Address publicResolver?: Address custom: boolean + blockExplorerUrl?: string } diff --git a/src/model/storage/wallet.model.ts b/src/model/storage/wallet.model.ts index 1bd4cb3..ad1d45a 100644 --- a/src/model/storage/wallet.model.ts +++ b/src/model/storage/wallet.model.ts @@ -1,4 +1,4 @@ -import { Address, BigNumberString, HexStringVariate } from '../general.types' +import { Address, BigNumberString, HexString, HexStringVariate } from '../general.types' export type TransactionDirection = 'sent' | 'received' @@ -13,6 +13,7 @@ export interface Transaction { gas: BigNumberString gasPrice: BigNumberString data?: HexStringVariate + hash: HexString<64> } token?: Token } diff --git a/src/services/storage/storage-factories.ts b/src/services/storage/storage-factories.ts index e066692..b1b5ecc 100644 --- a/src/services/storage/storage-factories.ts +++ b/src/services/storage/storage-factories.ts @@ -41,6 +41,7 @@ export function dappFactory(dappId: DappId): Dapp { return { podPermissions: {}, fullStorageAccess: false, + accountInfoAccess: false, dappId, } } diff --git a/src/ui/common/components/clipboard-button/clipboard-button.component.tsx b/src/ui/common/components/clipboard-button/clipboard-button.component.tsx index 18d567f..60ca448 100644 --- a/src/ui/common/components/clipboard-button/clipboard-button.component.tsx +++ b/src/ui/common/components/clipboard-button/clipboard-button.component.tsx @@ -5,9 +5,10 @@ import ContentCopy from '@mui/icons-material/ContentCopy' export interface ClipboardButtonProps { text: string + size?: 'large' | 'medium' | 'small' } -const ClipboardButton = ({ text }: ClipboardButtonProps) => { +const ClipboardButton = ({ text, size }: ClipboardButtonProps) => { const [open, setOpen] = useState(false) const [closeTimeoutHandle, setCloseTimeoutHandle] = useState(null) @@ -34,7 +35,7 @@ const ClipboardButton = ({ text }: ClipboardButtonProps) => { return ( <> - + } -const ErrorMessage = ({ children }: ErrorMessageProps) => { +const ErrorMessage = ({ children, onClick }: ErrorMessageProps) => { return ( { sx={{ color: (theme) => theme.palette.error.main, marginTop: '20px', + cursor: onClick ? 'pointer' : 'auto', }} + onClick={onClick} > {children} diff --git a/src/ui/common/components/error-modal/error-modal.component.tsx b/src/ui/common/components/error-modal/error-modal.component.tsx new file mode 100644 index 0000000..034eb6e --- /dev/null +++ b/src/ui/common/components/error-modal/error-modal.component.tsx @@ -0,0 +1,47 @@ +import React from 'react' +import intl from 'react-intl-universal' +import { IconButton, Modal, Typography } from '@mui/material' +import { Box } from '@mui/system' +import Close from '@mui/icons-material/Close' + +export interface ErrorModalProps { + open: boolean + onClose: () => void + error: string +} + +const ErrorModal = ({ open, onClose, error }: ErrorModalProps) => { + return ( + + + + {intl.get('ERROR_DESCRIPTION')} + + + + + + {error} + + + + ) +} + +export default ErrorModal diff --git a/src/ui/common/components/wallet/components/send/address-select.component.tsx b/src/ui/common/components/wallet/components/send/address-select.component.tsx index f16daa9..5613ec6 100644 --- a/src/ui/common/components/wallet/components/send/address-select.component.tsx +++ b/src/ui/common/components/wallet/components/send/address-select.component.tsx @@ -7,10 +7,12 @@ import FieldSpinner from '../../../field-spinner/field-spinner.component' import { Address } from '../../../../../../model/general.types' import { addressRegex } from '../../../../utils/ethers' import { useWalletLock } from '../../hooks/wallet-lock.hook' +import { Token } from '../../../../../../model/storage/wallet.model' interface AddressSelectProps { addresses: Address[] disabled: boolean + token?: Token onSubmit: (address: string) => void } @@ -18,7 +20,7 @@ interface FormFields { address: Address } -const AddressSelect = ({ addresses, disabled, onSubmit }: AddressSelectProps) => { +const AddressSelect = ({ addresses, disabled, token, onSubmit }: AddressSelectProps) => { useWalletLock() const { register, @@ -30,7 +32,10 @@ const AddressSelect = ({ addresses, disabled, onSubmit }: AddressSelectProps) => return (
onSubmit(address))}> - {intl.get('SELECT_ADDRESS')}: + {token + ? intl.get('SELECT_ADDRESS_TOKEN', { tokenName: `${token.name} (${token.symbol})` }) + : intl.get('SELECT_ADDRESS')} + : ( diff --git a/src/ui/common/components/wallet/components/send/transaction-completed.tsx b/src/ui/common/components/wallet/components/send/transaction-completed.tsx index c81c816..cec4549 100644 --- a/src/ui/common/components/wallet/components/send/transaction-completed.tsx +++ b/src/ui/common/components/wallet/components/send/transaction-completed.tsx @@ -4,12 +4,27 @@ import CheckCircle from '@mui/icons-material/CheckCircle' import { Button, Typography } from '@mui/material' import { FlexColumnDiv } from '../../../utils/utils' import { useNavigate } from 'react-router-dom' +import { providers } from 'ethers' +import { Token } from '../../../../../../model/storage/wallet.model' +import { BigNumberString } from '../../../../../../model/general.types' +import { constructBlockExplorerUrl, displayAddress } from '../../../../utils/ethers' +import ClipboardButton from '../../../clipboard-button/clipboard-button.component' export interface TransactionCompletedProps { + value: BigNumberString + token?: Token + transaction: providers.TransactionReceipt + blockExplorerUrl?: string onReset: () => void } -const TransactionCompleted = ({ onReset }: TransactionCompletedProps) => { +const TransactionCompleted = ({ + value, + token, + transaction, + blockExplorerUrl, + onReset, +}: TransactionCompletedProps) => { const navigate = useNavigate() return ( @@ -19,6 +34,22 @@ const TransactionCompleted = ({ onReset }: TransactionCompletedProps) => { {intl.get('TRANSACTION_COMPLETE')} + + {value} {token ? `${token.symbol} (${token.name})` : 'ETH'} + + + {blockExplorerUrl ? ( + + {displayAddress(transaction.transactionHash)} + + ) : ( +
{displayAddress(transaction.transactionHash)}
+ )} + +
+ setErrorModalOpen(false)} error={error} /> ) } diff --git a/src/ui/common/components/wallet/components/send/wallet-send.component.tsx b/src/ui/common/components/wallet/components/send/wallet-send.component.tsx index b8f12bd..f352368 100644 --- a/src/ui/common/components/wallet/components/send/wallet-send.component.tsx +++ b/src/ui/common/components/wallet/components/send/wallet-send.component.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import intl from 'react-intl-universal' import WalletImage from '@mui/icons-material/Wallet' import { Address } from '../../../../../../model/general.types' @@ -14,6 +14,8 @@ import TransactionConfirmation from './transaction-confirmation' import TransactionCompleted from './transaction-completed' import { useWalletLock } from '../../hooks/wallet-lock.hook' import { convertFromDecimal } from '../../../../utils/ethers' +import { providers } from 'ethers' +import { useNetworks } from '../../../../hooks/networks.hooks' enum STEPS { ADDRESS, @@ -27,10 +29,11 @@ const WalletSend = () => { const [addresses, setAddresses] = useState([]) const [error, setError] = useState(null) const [loading, setLoading] = useState(false) - const [completed, setCompleted] = useState(false) + const [transaction, setTransaction] = useState(null) const { walletNetwork, selectedToken } = useWallet() const { user, error: userError } = useUser() - useWalletLock() + const { networks } = useNetworks() + const { checkLockError } = useWalletLock() const loadAddresses = async () => { const addresses = await getWalletContacts() @@ -40,6 +43,16 @@ const WalletSend = () => { const getRpcUrl = () => walletNetwork?.rpc || user.network.rpc + const blockExplorerUrl: string = useMemo(() => { + if (!user) { + return '' + } + + const currentNetwork = (networks || []).find(({ rpc }) => rpc === getRpcUrl()) + + return currentNetwork?.blockExplorerUrl + }, [networks, walletNetwork, user]) + const getError = () => userError || error const onSubmit = async () => { @@ -47,24 +60,27 @@ const WalletSend = () => { setLoading(true) setError(null) + let transaction: providers.TransactionReceipt + if (selectedToken) { - await transferTokens({ + transaction = await transferTokens({ token: selectedToken, to: address, value: convertFromDecimal(value, selectedToken.decimals).toString(), rpcUrl: getRpcUrl(), }) } else { - await sendTransaction({ + transaction = await sendTransaction({ to: address, rpcUrl: getRpcUrl(), value: convertFromDecimal(value).toString(), }) } - setCompleted(true) + setTransaction(transaction) } catch (error) { console.error(error) + await checkLockError(error) setError(error) } finally { setLoading(false) @@ -80,7 +96,7 @@ const WalletSend = () => { return STEPS.VALUE } - if (!completed) { + if (!transaction) { return STEPS.CONFIRMATION } @@ -91,7 +107,7 @@ const WalletSend = () => { setValue('') setAddress('') setError('') - setCompleted(false) + setTransaction(null) } useEffect(() => { @@ -105,7 +121,7 @@ const WalletSend = () => {
{step === STEPS.ADDRESS && ( - + )} {step === STEPS.VALUE && ( { onSubmit={onSubmit} /> )} - {step === STEPS.COMPLETED && } + {step === STEPS.COMPLETED && ( + + )} ) } diff --git a/src/ui/common/components/wallet/components/transaction-history/tokens/token-import.component.tsx b/src/ui/common/components/wallet/components/transaction-history/tokens/token-import.component.tsx index 4b34681..0c33f15 100644 --- a/src/ui/common/components/wallet/components/transaction-history/tokens/token-import.component.tsx +++ b/src/ui/common/components/wallet/components/transaction-history/tokens/token-import.component.tsx @@ -2,7 +2,11 @@ import React, { useState } from 'react' import intl from 'react-intl-universal' import { Button, TextField, Typography } from '@mui/material' import WalletImage from '@mui/icons-material/Wallet' -import { checkTokenContract, importToken } from '../../../../../../../messaging/content-api.messaging' +import { + checkTokenContract, + getTokenBalance, + importToken, +} from '../../../../../../../messaging/content-api.messaging' import ErrorMessage from '../../../../error-message/error-message.component' import { useWalletLock } from '../../../hooks/wallet-lock.hook' import { useForm } from 'react-hook-form' @@ -16,23 +20,26 @@ import { Token } from '../../../../../../../model/storage/wallet.model' import TokenInfo from './token-info.component' import { useNavigate } from 'react-router-dom' import Header from '../../../../header/header.component' +import { BigNumber } from 'ethers' +import ErrorModal from '../../../../error-modal/error-modal.component' interface FormFields { address: Address } const TokenImport = () => { - useWalletLock() + const { checkLockError } = useWalletLock() const { register, handleSubmit, - control, formState: { errors }, } = useForm() const [token, setToken] = useState(null) + const [balance, setBalance] = useState(null) const [importDone, setImportDone] = useState(false) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) + const [errorModalOpen, setErrorModalOpen] = useState(false) const { user } = useUser() const { walletNetwork } = useWallet() const navigate = useNavigate() @@ -48,11 +55,16 @@ const TokenImport = () => { setLoading(true) setError(null) setToken(null) - const token = await checkTokenContract(address, getRpcUrl()) + const rpc = getRpcUrl() + const token = await checkTokenContract(address, rpc) + const balance = await getTokenBalance(token, rpc) + + setBalance(balance) setToken(token) } catch (error) { console.error(error) + await checkLockError(error) setError(String(error)) } finally { setLoading(false) @@ -68,6 +80,7 @@ const TokenImport = () => { setImportDone(true) } catch (error) { console.error(error) + await checkLockError(error) setError(String(error)) } finally { setLoading(false) @@ -83,7 +96,7 @@ const TokenImport = () => { {intl.get('IMPORT_TOKEN_SUCCESS')}: - + - - )} + + {token.name === selectedToken?.name ? ( + + ) : ( + {token.symbol} + )} + + + + ))} + + ) } diff --git a/src/ui/common/components/wallet/components/transaction-history/transaction-details.component.tsx b/src/ui/common/components/wallet/components/transaction-history/transaction-details.component.tsx new file mode 100644 index 0000000..44ed956 --- /dev/null +++ b/src/ui/common/components/wallet/components/transaction-history/transaction-details.component.tsx @@ -0,0 +1,140 @@ +import React, { useMemo } from 'react' +import intl from 'react-intl-universal' +import { + IconButton, + Modal, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableRow, + Typography, +} from '@mui/material' +import { Box } from '@mui/system' +import Close from '@mui/icons-material/Close' +import { Transaction } from '../../../../../../model/storage/wallet.model' +import { constructBlockExplorerUrl, displayAddress, displayBalance } from '../../../../utils/ethers' +import { BigNumber, utils } from 'ethers' +import ClipboardButton from '../../../clipboard-button/clipboard-button.component' + +export interface TransactionDetailsProps { + open: boolean + onClose: () => void + transaction: Transaction + blockExplorerUrl?: string +} + +const TransactionDetailsModal = ({ + open, + onClose, + transaction, + blockExplorerUrl, +}: TransactionDetailsProps) => { + const gasCost = useMemo(() => { + try { + return utils + .formatEther( + BigNumber.from(transaction.content.gas).mul(BigNumber.from(transaction.content.gasPrice)), + ) + .toString() + } catch (error) { + return 'Unknown' + } + }, [transaction]) + + if (!open) { + return null + } + + return ( + + + + {intl.get('TRANSACTION_DETAILS')} + + + + + + + + + + {intl.get('AMOUNT')} + + {displayBalance(BigNumber.from(transaction.content.value), transaction.token)} + + + + {intl.get('RECIPIENT')} + + {displayAddress(transaction.content.to)} + + + + + {intl.get('TIME')} + {new Date(transaction.time).toString()} + + + {intl.get('GAS_CONST')} + {gasCost} + + + {intl.get('TRANSCTION_HASH')} + + {blockExplorerUrl ? ( + + {displayAddress(transaction.content.hash as string)} + + ) : ( + displayAddress(transaction.content.hash as string) + )} + + + + + {intl.get('DATA')} + + {transaction.content.data && ( + + )} + + + +
+
+
+
+
+ ) +} + +export default TransactionDetailsModal diff --git a/src/ui/common/components/wallet/components/transaction-history/transaction-history.component.tsx b/src/ui/common/components/wallet/components/transaction-history/transaction-history.component.tsx index 71342f4..b00ee75 100644 --- a/src/ui/common/components/wallet/components/transaction-history/transaction-history.component.tsx +++ b/src/ui/common/components/wallet/components/transaction-history/transaction-history.component.tsx @@ -7,9 +7,10 @@ import ErrorMessage from '../../../error-message/error-message.component' import { Token, Transaction, Transactions } from '../../../../../../model/storage/wallet.model' import TransactionList from './transaction-list.component' import Tokens from './tokens/tokens.component' +import { Network } from '../../../../../../model/storage/network.model' export interface TransactionHistoryProps { - networkLabel: string + network: Network selectedToken: Token | null onTokenSelect: (token: Token | null) => void } @@ -21,7 +22,7 @@ const TabWrapper = styled('div')(() => ({ left: 0, })) -const TransactionHistory = ({ selectedToken, networkLabel, onTokenSelect }: TransactionHistoryProps) => { +const TransactionHistory = ({ selectedToken, network, onTokenSelect }: TransactionHistoryProps) => { const [transactions, setTransactions] = useState(null) const [tab, setTab] = useState(selectedToken ? 1 : 0) const [error, setError] = useState(null) @@ -38,7 +39,7 @@ const TransactionHistory = ({ selectedToken, networkLabel, onTokenSelect }: Tran const loadData = async () => { try { - const transactions = await getWalletTransactions(networkLabel) + const transactions = await getWalletTransactions(network.label) setTransactions(transactions) } catch (error) { @@ -48,7 +49,7 @@ const TransactionHistory = ({ selectedToken, networkLabel, onTokenSelect }: Tran useEffect(() => { loadData() - }, [networkLabel]) + }, [network]) return ( <> @@ -66,13 +67,16 @@ const TransactionHistory = ({ selectedToken, networkLabel, onTokenSelect }: Tran - + diff --git a/src/ui/common/components/wallet/components/transaction-history/transaction-list.component.tsx b/src/ui/common/components/wallet/components/transaction-history/transaction-list.component.tsx index 9d45477..2a60b17 100644 --- a/src/ui/common/components/wallet/components/transaction-history/transaction-list.component.tsx +++ b/src/ui/common/components/wallet/components/transaction-history/transaction-list.component.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useState } from 'react' import { Paper, Table, @@ -9,13 +9,14 @@ import { styled, tableCellClasses, } from '@mui/material' -import Send from '@mui/icons-material/Send' import { Transaction } from '../../../../../../model/storage/wallet.model' import { convertToDecimal, displayAddress } from '../../../../utils/ethers' import { BigNumber } from 'ethers' +import TransactionDetailsModal from './transaction-details.component' export interface TransactionListProps { transactions: Transaction[] + blockExplorerUrl?: string } const StyledTableCell = styled(TableCell)(({ theme }) => ({ @@ -39,30 +40,43 @@ const StyledTableRow = styled(TableRow)(({ theme }) => ({ }, })) -const TransactionList = ({ transactions }: TransactionListProps) => { +const TransactionList = ({ transactions, blockExplorerUrl }: TransactionListProps) => { + const [displayedTransaction, setDisplayedTransaction] = useState(null) + return ( - - - - {transactions.map(({ id, time, content, token }) => ( - - - - - {displayAddress(content.to)} - - {`${convertToDecimal(BigNumber.from(content.value), token?.decimals)} ${ - token ? token.symbol : 'ETH' - }`} - - - {new Date(time).toDateString()} - - - ))} - -
-
+ <> + + + + {transactions.map((transsaction) => ( + setDisplayedTransaction(transsaction)} + sx={{ cursor: 'pointer' }} + > + {displayAddress(transsaction.content.to)} + + {`${convertToDecimal( + BigNumber.from(transsaction.content.value), + transsaction.token?.decimals, + )} ${transsaction.token ? transsaction.token.symbol : 'ETH'}`} + + + {new Date(transsaction.time).toDateString()} + + + ))} + +
+
+ setDisplayedTransaction(null)} + transaction={displayedTransaction} + blockExplorerUrl={blockExplorerUrl} + /> + ) } diff --git a/src/ui/common/components/wallet/components/wallet-overview.component.tsx b/src/ui/common/components/wallet/components/wallet-overview.component.tsx index c81800b..f252f78 100644 --- a/src/ui/common/components/wallet/components/wallet-overview.component.tsx +++ b/src/ui/common/components/wallet/components/wallet-overview.component.tsx @@ -1,14 +1,15 @@ import React, { useEffect, useState } from 'react' import intl from 'react-intl-universal' -import { BigNumber, utils } from 'ethers' +import { BigNumber } from 'ethers' import Send from '@mui/icons-material/Send' +import ArrowBackIosNew from '@mui/icons-material/ArrowBackIosNew' import { FlexColumnDiv, FlexDiv } from '../../utils/utils' import { getAccountBalance, getTokenBalance } from '../../../../../messaging/content-api.messaging' import { UserResponse } from '../../../../../model/internal-messages.model' -import { Button, CircularProgress, Divider, MenuItem, Select, Typography } from '@mui/material' +import { Button, CircularProgress, Divider, IconButton, MenuItem, Select, Typography } from '@mui/material' import { Network } from '../../../../../model/storage/network.model' import { useNetworks } from '../../../hooks/networks.hooks' -import { roundEther } from '../../../utils/ethers' +import { displayAddress, displayBalance } from '../../../utils/ethers' import ClipboardButton from '../../clipboard-button/clipboard-button.component' import { useNavigate } from 'react-router-dom' import WalletRouteCodes from '../routes/wallet-route-codes' @@ -16,19 +17,26 @@ import { useWallet } from '../context/wallet.context' import ErrorMessage from '../../error-message/error-message.component' import TransactionHistory from './transaction-history/transaction-history.component' import { Token } from '../../../../../model/storage/wallet.model' +import ErrorModal from '../../error-modal/error-modal.component' +import { useUser } from '../../../hooks/user.hooks' +import { useWalletLock } from '../hooks/wallet-lock.hook' interface WalletOverviewProps { user: UserResponse + onLock: () => void } -const WalletOverview = ({ user: { address, network } }: WalletOverviewProps) => { +const WalletOverview = ({ user: { address, network }, onLock }: WalletOverviewProps) => { const { walletNetwork, setWalletNetwork, selectedToken, setSelectedToken } = useWallet() const [selectedNetwork, setSelectedNetwork] = useState(walletNetwork || network) const [balance, setBalance] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [errorModalOpen, setErrorModalOpen] = useState(false) const { networks } = useNetworks() + const { user } = useUser() const navigate = useNavigate() + const { checkLockError } = useWalletLock() const loadData = async (network: Network, token: Token) => { try { @@ -40,6 +48,12 @@ const WalletOverview = ({ user: { address, network } }: WalletOverviewProps) => setBalance(balance) } catch (error) { + console.error(error) + const locked = await checkLockError(error) + + if (locked) { + onLock() + } setError(String(error)) } } @@ -89,29 +103,48 @@ const WalletOverview = ({ user: { address, network } }: WalletOverviewProps) => ))} - + - {address} + {user?.ensUserName} - - + + + {displayAddress(address)} + + + + - {error && {error}} + {error && ( + setErrorModalOpen(true)}> + {intl.get('NETWORK_UNAVAILABLE_ERROR')} + + )} {loading ? ( ) : ( <> {balance ? ( - - {selectedToken - ? `${utils.formatUnits(balance, selectedToken.decimals)} ${selectedToken.symbol}` - : `${roundEther(utils.formatEther(balance))} ETH`} - + + {selectedToken && ( + setSelectedToken(null)}> + + + )} + + {displayBalance(balance, selectedToken)} + + ) : ( )} @@ -130,10 +163,11 @@ const WalletOverview = ({ user: { address, network } }: WalletOverviewProps) => )} + setErrorModalOpen(false)} error={error} /> ) } diff --git a/src/ui/common/components/wallet/components/wallet.component.tsx b/src/ui/common/components/wallet/components/wallet.component.tsx index 4481f23..a843a7c 100644 --- a/src/ui/common/components/wallet/components/wallet.component.tsx +++ b/src/ui/common/components/wallet/components/wallet.component.tsx @@ -32,7 +32,11 @@ const Wallet = () => {
{user && locked !== null && - (locked ? setLocked(false)} /> : )} + (locked ? ( + setLocked(false)} /> + ) : ( + setLocked(true)} /> + ))} {error && {error}} ) diff --git a/src/ui/common/components/wallet/hooks/wallet-lock.hook.ts b/src/ui/common/components/wallet/hooks/wallet-lock.hook.ts index e778f03..3a854bf 100644 --- a/src/ui/common/components/wallet/hooks/wallet-lock.hook.ts +++ b/src/ui/common/components/wallet/hooks/wallet-lock.hook.ts @@ -1,19 +1,38 @@ import { useEffect } from 'react' import { refreshWalletLock } from '../../../../../messaging/content-api.messaging' import { useNavigate } from 'react-router-dom' +import { errorMessages } from '../../../../../constants/errors' export function useWalletLock() { const navigate = useNavigate() - const checkLock = async () => { + const checkLock = async (): Promise => { try { await refreshWalletLock() + + return false } catch (error) { setTimeout(() => navigate('..')) + + return true } } + const checkLockError = async (error: unknown): Promise => { + try { + if (error.toString().includes(errorMessages.WALLET_LOCKED)) { + return await checkLock() + } + } catch (error) {} + + return false + } + useEffect(() => { checkLock() }, []) + + return { + checkLockError, + } } diff --git a/src/ui/common/utils/ethers.ts b/src/ui/common/utils/ethers.ts index 0982f8a..edf3730 100644 --- a/src/ui/common/utils/ethers.ts +++ b/src/ui/common/utils/ethers.ts @@ -1,5 +1,6 @@ import { BigNumber, utils } from 'ethers' import { Address } from '../../../model/general.types' +import { Token } from '../../../model/storage/wallet.model' export const valueRegex = /^\d+(\.\d+)?$/g export const addressRegex = /^0x[a-fA-F0-9]{40}$/g @@ -33,3 +34,15 @@ export function convertFromDecimal(amount: string, decimals?: number): BigNumber export function convertToDecimal(amount: BigNumber, decimals?: number): string { return utils.formatUnits(amount, decimals || 'ether') } + +export function constructBlockExplorerUrl(blockExplorerBaseUrl: string, txHash: string): string { + return blockExplorerBaseUrl.endsWith('/') + ? `${blockExplorerBaseUrl}${txHash}` + : `${blockExplorerBaseUrl}/${txHash}` +} + +export function displayBalance(balance: BigNumber, token?: Token): string { + return token + ? `${utils.formatUnits(balance, token.decimals)} ${token.symbol}` + : `${roundEther(utils.formatEther(balance))} ETH` +} diff --git a/src/ui/settings/pages/network/network-form.tsx b/src/ui/settings/pages/network/network-form.tsx index e05a227..6ea06de 100644 --- a/src/ui/settings/pages/network/network-form.tsx +++ b/src/ui/settings/pages/network/network-form.tsx @@ -26,6 +26,7 @@ interface FormFields { ensRegistry: string fdsRegistrar: string publicResolver: string + blockExplorerUrl: string } const NetworkForm = ({ network, disabled, canDelete, onChange, onDelete }: NetworkFormProps) => { @@ -37,6 +38,7 @@ const NetworkForm = ({ network, disabled, canDelete, onChange, onDelete }: Netwo defaultValues: { label: String(network.label), rpc: network.rpc, + blockExplorerUrl: network.blockExplorerUrl, ensRegistry: network.ensRegistry as unknown as string, fdsRegistrar: network.fdsRegistrar as unknown as string, publicResolver: network.publicResolver as unknown as string, @@ -55,6 +57,7 @@ const NetworkForm = ({ network, disabled, canDelete, onChange, onDelete }: Netwo ensRegistry: fields.ensRegistry || undefined, fdsRegistrar: fields.fdsRegistrar || undefined, publicResolver: fields.publicResolver || undefined, + blockExplorerUrl: fields.blockExplorerUrl || undefined, custom: true, } as unknown as Network) } catch (error) { @@ -99,12 +102,22 @@ const NetworkForm = ({ network, disabled, canDelete, onChange, onDelete }: Netwo disabled={disabled} sx={{ marginBottom: FIELD_MARGIN }} /> + setContractsEnabled(!contractsEnabled)} - disabled={disabled} data-testid="show-contract-addresses-checkbox" /> } diff --git a/src/ui/settings/pages/permissions/dapp-permissions/dapp-permissions-form.component.tsx b/src/ui/settings/pages/permissions/dapp-permissions/dapp-permissions-form.component.tsx index 4c7984f..f0f41e4 100644 --- a/src/ui/settings/pages/permissions/dapp-permissions/dapp-permissions-form.component.tsx +++ b/src/ui/settings/pages/permissions/dapp-permissions/dapp-permissions-form.component.tsx @@ -18,6 +18,7 @@ const DappPermissionsForm = ({ dapp, disabled, onUpdate }: DappPermissionsFormPr ...dapp.podPermissions, }) const [fullStorageAccess, setFullStorageAccess] = useState(dapp.fullStorageAccess) + const [accountInfoAccess, setAccountInfoAccess] = useState(dapp.accountInfoAccess) const { dappId } = dapp const onPodPermissionDelete = (podName: string) => { @@ -32,6 +33,7 @@ const DappPermissionsForm = ({ dapp, disabled, onUpdate }: DappPermissionsFormPr onUpdate({ ...dapp, fullStorageAccess, + accountInfoAccess, podPermissions, }) } @@ -44,6 +46,18 @@ const DappPermissionsForm = ({ dapp, disabled, onUpdate }: DappPermissionsFormPr {dappId} + setAccountInfoAccess(!accountInfoAccess)} + /> + } + disabled={disabled} + label={intl.get('ACCOUNT_INFORMATION_ACCESS')} + sx={{ margin: '20px 0 0 0' }} + /> void } -const INTERVAL_OPTIONS = [1, 5, 10, 15, 30, 45, 60] +const INTERVAL_OPTIONS = [5, 10, 15, 30, 45, 60] const WalletLockInput = ({ value, isLocked, onChange }: WalletLockInputProps) => { const minutes = useMemo(() => { diff --git a/test/dapp-library.spec.ts b/test/dapp-library.spec.ts index 3d63450..dbe5ab0 100644 --- a/test/dapp-library.spec.ts +++ b/test/dapp-library.spec.ts @@ -132,6 +132,34 @@ describe('Dapp interaction with Blossom, using the library', () => { expect(`0.${balance.substring(0, 2)}`).toEqual(expectedBalance) } + test("Shouldn't get account info if user didn't allow", async () => { + await click(page, 'get-account-info-btn-1') + + await wait(5000) + + const blossomPage = await getPageByTitle('Blossom') + + await click(blossomPage, 'dialog-cancel-btn') + + expect(await waitForElementText(page, '#account-info-1[complete="true"]')).toEqual( + 'Error: Blossom: Access denied', + ) + }) + + test('Should get account info', async () => { + await click(page, 'get-account-info-btn-2') + + await wait(5000) + + const blossomPage = await getPageByTitle('Blossom') + + await click(blossomPage, 'dialog-confirm-btn') + + const wallet = Wallet.fromMnemonic(mnemonic) + + expect(await waitForElementText(page, '#account-info-2[complete="true"]')).toEqual(wallet.address) + }) + test('Should get initial balance', async () => { await click(page, 'get-balance-btn') diff --git a/test/dapps/wallet/index.html b/test/dapps/wallet/index.html index 9bbfb08..d74adeb 100644 --- a/test/dapps/wallet/index.html +++ b/test/dapps/wallet/index.html @@ -9,6 +9,10 @@ + +

Empty

+ +

Empty

Empty

diff --git a/test/dapps/wallet/index.js b/test/dapps/wallet/index.js index 6885999..769a3c3 100644 --- a/test/dapps/wallet/index.js +++ b/test/dapps/wallet/index.js @@ -6,6 +6,15 @@ function setText(id, text) { element.setAttribute('complete', 'true') } +async function getAccountInfo(elementId) { + try { + const { address } = await blossom.wallet.getAccountInfo() + setText(elementId, address) + } catch (error) { + setText(elementId, error.toString()) + } +} + async function getBalance(elementId) { try { const balance = await blossom.wallet.getUserBalance() @@ -25,6 +34,14 @@ async function sendTransaction(elementId) { } } +function getAccountInfo1() { + return getAccountInfo('account-info-1') +} + +function getAccountInfo2() { + return getAccountInfo('account-info-2') +} + function getInitialBalance() { return getBalance('balance') } diff --git a/test/test-utils/account.ts b/test/test-utils/account.ts index fe31c07..93fc5e4 100644 --- a/test/test-utils/account.ts +++ b/test/test-utils/account.ts @@ -147,7 +147,3 @@ export async function register(username: string, password: string): Promise { - return waitForElementTextByTestId(page, 'address') -} diff --git a/test/wallet.spec.ts b/test/wallet.spec.ts index d74ce31..ea9caa7 100644 --- a/test/wallet.spec.ts +++ b/test/wallet.spec.ts @@ -1,6 +1,6 @@ import { ElementHandle, Page } from 'puppeteer' import { openExtensionOptionsPage } from './test-utils/extension.util' -import { getWalletAddress, login, registerExisting } from './test-utils/account' +import { login, registerExisting } from './test-utils/account' import { click, dataTestId, @@ -14,7 +14,7 @@ import { } from './test-utils/page' import { getRandomString } from './test-utils/extension.util' import deployContracts, { transferToken } from './config/contract-deployment' -import { BigNumber } from 'ethers' +import { BigNumber, Wallet } from 'ethers' import { sendFunds } from './test-utils/ethers' import { PRIVATE_KEY } from './config/constants' @@ -138,7 +138,7 @@ describe('Wallet tokens tests', () => { await deployContracts() await login(username, password) page = await openExtensionOptionsPage(blossomId, 'wallet.html') - walletAddress = await getWalletAddress(page) + walletAddress = Wallet.fromMnemonic(mnemonic).address await sendFunds(PRIVATE_KEY, walletAddress, '0.1') await transferToken( global.__TEST_TOKEN_ADDRESS__,