From 6c98987e48f8c3b02738287649480c8a20820dbd Mon Sep 17 00:00:00 2001 From: Jubal Mabaquiao Date: Sun, 15 Oct 2023 23:50:08 -0700 Subject: [PATCH] Improve error handling (#210) * improve error handling * convert provider to signal * error handlers * remove unreachable condition * rename from account to session in contexts * Update app/ts/components/TokenPicker.tsx Co-authored-by: KillariDev <13102010+KillariDev@users.noreply.github.com> * Update app/ts/context/Notification.tsx Co-authored-by: KillariDev <13102010+KillariDev@users.noreply.github.com> * Update app/ts/library/errors.ts Co-authored-by: KillariDev <13102010+KillariDev@users.noreply.github.com> * token amount render --------- Co-authored-by: KillariDev <13102010+KillariDev@users.noreply.github.com> --- app/css/index.css | 69 ++++----- app/ts/components/AccountReconnect.tsx | 32 ++++ app/ts/components/App.tsx | 33 ++-- app/ts/components/ConnectAccount.tsx | 51 ++++-- app/ts/components/ErrorAlert.tsx | 35 ----- app/ts/components/ErrorPage/index.tsx | 18 --- app/ts/components/Icon/Menu.tsx | 5 + app/ts/components/Icon/index.ts | 1 + app/ts/components/Notice.tsx | 13 -- app/ts/components/SetupTransfer.tsx | 12 +- app/ts/components/TokenAdd.tsx | 9 +- app/ts/components/TokenPicker.tsx | 63 +++++--- .../TransactionPage/TransactionDetails.tsx | 31 ++-- app/ts/components/TransactionPage/index.tsx | 70 +++------ app/ts/components/TransferAmountField.tsx | 35 ++--- app/ts/components/TransferButton.tsx | 20 ++- app/ts/components/TransferHistory.tsx | 4 +- app/ts/components/TransferPage/index.tsx | 74 +++------ app/ts/components/TransferResult.tsx | 95 ++++++++++++ app/ts/context/Account.tsx | 56 ------- app/ts/context/Ethereum.tsx | 108 +++++++++++++ app/ts/context/Notification.tsx | 96 ++++++++++++ app/ts/context/TokenManager.tsx | 22 +-- app/ts/context/Wallet.tsx | 146 +++++++++++++----- app/ts/library/constants.ts | 4 +- app/ts/library/errors.ts | 126 +++++++++++++++ app/ts/library/ethereum.ts | 26 +--- app/ts/library/human-ethers-errors.ts | 102 ------------ app/ts/library/utilities.ts | 10 +- app/ts/schema.ts | 7 +- app/ts/store/errors.ts | 42 ----- app/ts/store/network.ts | 29 ---- app/ts/store/provider.ts | 29 ---- app/ts/store/tokens.ts | 70 --------- app/ts/store/transaction.ts | 7 +- twcss/tailwind.config.js | 10 +- 36 files changed, 816 insertions(+), 744 deletions(-) create mode 100644 app/ts/components/AccountReconnect.tsx delete mode 100644 app/ts/components/ErrorAlert.tsx delete mode 100644 app/ts/components/ErrorPage/index.tsx create mode 100644 app/ts/components/Icon/Menu.tsx delete mode 100644 app/ts/components/Notice.tsx create mode 100644 app/ts/components/TransferResult.tsx delete mode 100644 app/ts/context/Account.tsx create mode 100644 app/ts/context/Ethereum.tsx create mode 100644 app/ts/context/Notification.tsx create mode 100644 app/ts/library/errors.ts delete mode 100644 app/ts/library/human-ethers-errors.ts delete mode 100644 app/ts/store/errors.ts delete mode 100644 app/ts/store/network.ts delete mode 100644 app/ts/store/provider.ts delete mode 100644 app/ts/store/tokens.ts diff --git a/app/css/index.css b/app/css/index.css index d5a1642c..002c1a3c 100644 --- a/app/css/index.css +++ b/app/css/index.css @@ -526,16 +526,12 @@ input[type='password']:autofill { inset: 0px; } -.left-4 { - left: 1rem; +.bottom-4 { + bottom: 1rem; } -.left-auto { - left: auto; -} - -.right-0 { - right: 0px; +.left-4 { + left: 1rem; } .right-2 { @@ -562,6 +558,10 @@ input[type='password']:autofill { grid-column: 1 / -1; } +.row-span-2 { + grid-row: span 2 / span 2; +} + .row-start-2 { grid-row-start: 2; } @@ -610,18 +610,6 @@ input[type='password']:autofill { margin-bottom: 2rem; } -.mb-auto { - margin-bottom: auto; -} - -.ml-auto { - margin-left: auto; -} - -.mr-8 { - margin-right: 2rem; -} - .mt-8 { margin-top: 2rem; } @@ -874,6 +862,10 @@ input[type='password']:autofill { grid-template-rows: 1fr min-content; } +.grid-rows-\[min-content\2c min-content\] { + grid-template-rows: min-content min-content; +} + .flex-col { flex-direction: column; } @@ -946,6 +938,10 @@ input[type='password']:autofill { row-gap: 0.25rem; } +.gap-y-2 { + row-gap: 0.5rem; +} + .gap-y-3 { row-gap: 0.75rem; } @@ -1002,6 +998,10 @@ input[type='password']:autofill { border-style: dashed; } +.border-amber-500\/50 { + border-color: rgb(245 158 11 / 0.5); +} + .border-lime-400\/40 { border-color: rgb(163 230 53 / 0.4); } @@ -1015,6 +1015,10 @@ input[type='password']:autofill { border-color: rgb(248 113 113 / 0.2); } +.border-red-400\/50 { + border-color: rgb(248 113 113 / 0.5); +} + .border-transparent { border-color: transparent; } @@ -1044,6 +1048,10 @@ input[type='password']:autofill { border-bottom-color: rgb(255 255 255 / 0.1); } +.bg-amber-500\/10 { + background-color: rgb(245 158 11 / 0.1); +} + .bg-black { --tw-bg-opacity: 1; background-color: rgb(0 0 0 / var(--tw-bg-opacity)); @@ -1075,6 +1083,10 @@ input[type='password']:autofill { background-color: rgb(23 23 23 / var(--tw-bg-opacity)); } +.bg-red-400\/10 { + background-color: rgb(248 113 113 / 0.1); +} + .bg-red-400\/5 { background-color: rgb(248 113 113 / 0.05); } @@ -1108,10 +1120,6 @@ input[type='password']:autofill { background-color: rgb(255 255 255 / 0.5); } -.p-10 { - padding: 2.5rem; -} - .p-2 { padding: 0.5rem; } @@ -1148,11 +1156,6 @@ input[type='password']:autofill { padding-right: 1rem; } -.px-6 { - padding-left: 1.5rem; - padding-right: 1.5rem; -} - .py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; @@ -1178,10 +1181,6 @@ input[type='password']:autofill { padding-bottom: 1rem; } -.pb-4 { - padding-bottom: 1rem; -} - .pl-4 { padding-left: 1rem; } @@ -1194,10 +1193,6 @@ input[type='password']:autofill { padding-top: 1.25rem; } -.pt-6 { - padding-top: 1.5rem; -} - .text-left { text-align: left; } diff --git a/app/ts/components/AccountReconnect.tsx b/app/ts/components/AccountReconnect.tsx new file mode 100644 index 00000000..64d84641 --- /dev/null +++ b/app/ts/components/AccountReconnect.tsx @@ -0,0 +1,32 @@ +import { useSignalEffect } from '@preact/signals' +import { useEffect } from 'preact/hooks' +import { useWallet } from '../context/Wallet.js' +import { useEthereumProvider } from '../context/Ethereum.js' +import { useAsyncState } from '../library/preact-utilities.js' +import { EthereumAddress } from '../schema.js' + +export const AccountReconnect = () => { + const { browserProvider } = useEthereumProvider() + const { account } = useWallet() + const { value: query, waitFor } = useAsyncState() + + const attemptToConnect = () => { + if (!browserProvider.value) return + const provider = browserProvider.value + waitFor(async () => { + const [signer] = await provider.listAccounts() + return EthereumAddress.parse(signer.address) + }) + } + + const listenForQueryChanges = () => { + // do not reset shared state for other instances of this hooks + if (query.value.state === 'inactive') return + account.value = query.value + } + + useSignalEffect(listenForQueryChanges) + useEffect(attemptToConnect, []) + + return <> +} diff --git a/app/ts/components/App.tsx b/app/ts/components/App.tsx index b459ad90..f0eae4b5 100644 --- a/app/ts/components/App.tsx +++ b/app/ts/components/App.tsx @@ -1,29 +1,28 @@ import { Route, Router } from './HashRouter.js' -import { Notices } from './Notice.js' import { SplashScreen } from './SplashScreen.js' import { TransactionPage } from './TransactionPage/index.js' -import { ErrorAlert } from './ErrorAlert.js' import { TransferPage } from './TransferPage/index.js' +import { EthereumProvider } from '../context/Ethereum.js' import { WalletProvider } from '../context/Wallet.js' -import { AccountProvider } from '../context/Account.js' +import { NotificationProvider } from '../context/Notification.js' export function App() { return ( - - - - - - - - - - - - - - + + + + + + + + + + + + + + ) } diff --git a/app/ts/components/ConnectAccount.tsx b/app/ts/components/ConnectAccount.tsx index c840e0e6..cfabc337 100644 --- a/app/ts/components/ConnectAccount.tsx +++ b/app/ts/components/ConnectAccount.tsx @@ -1,25 +1,26 @@ import { useSignalEffect } from '@preact/signals' +import { makeError } from 'ethers' import { useAsyncState } from '../library/preact-utilities.js' import { EthereumAddress } from '../schema.js' +import { useEthereumProvider } from '../context/Ethereum.js' import { useWallet } from '../context/Wallet.js' -import { useAccount } from '../context/Account.js' -import { useNotice } from '../store/notice.js' +import { useNotification } from '../context/Notification.js' import { AsyncText } from './AsyncText.js' import SVGBlockie from './SVGBlockie.js' +import { humanReadableEthersError, isJsonRpcError, isEthersError, humanReadableJsonRpcError } from '../library/errors.js' export const ConnectAccount = () => { - const { browserProvider } = useWallet() - const { account } = useAccount() + const { browserProvider } = useEthereumProvider() + const { account } = useWallet() const { value: query, waitFor } = useAsyncState() - const { notify } = useNotice() + const { notify } = useNotification() const connect = () => { - if (!browserProvider) { - notify({ message: 'No compatible web3 wallet detected.', title: 'Failed to connect' }) - return - } waitFor(async () => { - const signer = await browserProvider.getSigner() + if (!browserProvider.value) { + throw makeError('No compatible web3 wallet detected.', 'UNKNOWN_ERROR', { error: { code: 4900 } }) + } + const signer = await browserProvider.value.getSigner() return EthereumAddress.parse(signer.address) }) } @@ -33,8 +34,28 @@ export const ConnectAccount = () => { useSignalEffect(listenForQueryChanges) switch (account.value.state) { - case 'inactive': case 'rejected': + const accountError = account.value.error + if (isEthersError(accountError)) { + let message = humanReadableEthersError(accountError).message + if (accountError.code === 'UNKNOWN_ERROR' && isJsonRpcError(accountError.error)) { + message = humanReadableJsonRpcError(accountError.error).message + } + notify({ message, title: 'Notice' }) + } + + return ( +
+
+ Get started quickly + by connecting your wallet +
+ +
+ ) + case 'inactive': return (
@@ -61,7 +82,7 @@ export const ConnectAccount = () => { } const AccountAddress = () => { - const { account } = useAccount() + const { account } = useWallet() switch (account.value.state) { case 'inactive': @@ -82,7 +103,7 @@ const NetworkIcon = () => ( ) const AccountAvatar = () => { - const { account } = useAccount() + const { account } = useWallet() switch (account.value.state) { case 'inactive': @@ -100,7 +121,7 @@ const AccountAvatar = () => { } const WalletNetwork = () => { - const { account } = useAccount() + const { account } = useWallet() switch (account.value.state) { case 'inactive': @@ -119,7 +140,7 @@ const WalletNetwork = () => { } const NetworkName = () => { - const { network } = useWallet() + const { network } = useEthereumProvider() switch (network.value.state) { case 'inactive': diff --git a/app/ts/components/ErrorAlert.tsx b/app/ts/components/ErrorAlert.tsx deleted file mode 100644 index 8f590ef7..00000000 --- a/app/ts/components/ErrorAlert.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { useSignalEffect } from '@preact/signals' -import { useSignalRef } from '../library/preact-utilities.js' -import { useErrors } from '../store/errors.js' - -export const ErrorAlert = () => { - const { ref: dialogRef, signal: dialogEl } = useSignalRef(null) - const { latest: latestError, remove } = useErrors() - - useSignalEffect(() => { - if (!dialogEl.value) return - if (!dialogEl.value.open && latestError.value) dialogEl.value.showModal() - if (dialogEl.value.open && !latestError.value) dialogEl.value.close() - }) - - const dismissError = () => { - if (!latestError.value) return - remove(latestError.value.code) - } - - return ( - - {latestError.value && ( - <> - -
Application Error
-
-
{latestError.value.message}
-
- - )} -
- ) -} diff --git a/app/ts/components/ErrorPage/index.tsx b/app/ts/components/ErrorPage/index.tsx deleted file mode 100644 index 14a02599..00000000 --- a/app/ts/components/ErrorPage/index.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { useErrors } from '../../store/errors.js' - -export const ErrorPage = () => { - const { add } = useErrors() - - const addErrors = () => { - add('UNKNOWN') - add('WALLET_MISSING', 'A web3 wallet is not detected') - } - - return ( -
- -
- ) -} diff --git a/app/ts/components/Icon/Menu.tsx b/app/ts/components/Icon/Menu.tsx new file mode 100644 index 00000000..f5e8c69c --- /dev/null +++ b/app/ts/components/Icon/Menu.tsx @@ -0,0 +1,5 @@ +export const Menu = () => ( + + + +) diff --git a/app/ts/components/Icon/index.ts b/app/ts/components/Icon/index.ts index 44139919..b4894cde 100644 --- a/app/ts/components/Icon/index.ts +++ b/app/ts/components/Icon/index.ts @@ -5,6 +5,7 @@ export * from './Check.js' export * from './Copy.js' export * from './Ethereum.js' export * from './Info.js' +export * from './Menu.js' export * from './Refresh.js' export * from './Spinner.js' export * from './Xmark.js' diff --git a/app/ts/components/Notice.tsx b/app/ts/components/Notice.tsx deleted file mode 100644 index bd920931..00000000 --- a/app/ts/components/Notice.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import Modal from './Modal.js' -import { useNotice } from '../store/notice.js' - -export const Notices = () => { - const { notices } = useNotice() - if (notices.value.length < 1) return <> - - return ( - (notices.value = notices.value.filter(n => n.id !== notices.value[0]?.id))} hasCloseButton> - {notices.value[0]?.message} - - ) -} diff --git a/app/ts/components/SetupTransfer.tsx b/app/ts/components/SetupTransfer.tsx index c433ef9d..95d4d6fd 100644 --- a/app/ts/components/SetupTransfer.tsx +++ b/app/ts/components/SetupTransfer.tsx @@ -9,10 +9,11 @@ import { TransferAmountField } from './TransferAmountField.js' import { TransferRecorder } from './TransferRecorder.js' import { TransferButton } from './TransferButton.js' import { TransferTokenSelectField } from './TransferTokenField.js' -import { useWallet } from '../context/Wallet.js' +import { useEthereumProvider } from '../context/Ethereum.js' import { useNotice } from '../store/notice.js' import { TokenPicker } from './TokenPicker.js' import { TokenAdd } from './TokenAdd.js' +import { TransferResult } from './TransferResult.js' export function SetupTransfer() { return ( @@ -23,6 +24,7 @@ export function SetupTransfer() {
+ @@ -33,7 +35,7 @@ export function SetupTransfer() { } const TransferForm = ({ children }: { children: ComponentChildren }) => { - const { browserProvider, network } = useWallet() + const { browserProvider, network } = useEthereumProvider() const { input, transaction, safeParse } = useTransfer() const { value: transactionQuery, waitFor } = useAsyncState() const { notify } = useNotice() @@ -41,16 +43,18 @@ const TransferForm = ({ children }: { children: ComponentChildren }) => { const sendTransferRequest = (e: Event) => { e.preventDefault() - if (!browserProvider) { + if (!browserProvider.value) { notify({ message: 'No compatible web3 wallet detected.', title: 'Failed to connect' }) return } if (!safeParse.value.success) return + const transferInput = safeParse.value.value + const provider = browserProvider.value waitFor(async () => { - const signer = await browserProvider.getSigner() + const signer = await provider.getSigner() // Ether transfer if (transferInput.token === undefined) { diff --git a/app/ts/components/TokenAdd.tsx b/app/ts/components/TokenAdd.tsx index 9789ce6f..65232d4f 100644 --- a/app/ts/components/TokenAdd.tsx +++ b/app/ts/components/TokenAdd.tsx @@ -4,7 +4,7 @@ import { Result } from 'funtypes' import { ComponentChildren } from 'preact' import { useTokenManager } from '../context/TokenManager.js' import { useTransfer } from '../context/Transfer.js' -import { useWallet } from '../context/Wallet.js' +import { useEthereumProvider } from '../context/Ethereum.js' import { ERC20ABI } from '../library/ERC20ABI.js' import { useAsyncState, useSignalRef } from '../library/preact-utilities.js' import { ERC20Token, EthereumAddress, serialize } from '../schema.js' @@ -124,7 +124,7 @@ const QueryAddressField = ({ result }: { result: Signal const QueryResult = ({ result }: { result: Signal | undefined> }) => { const { notify } = useNotice() const { value: query, waitFor, reset } = useAsyncState() - const { browserProvider, network } = useWallet() + const { browserProvider, network } = useEthereumProvider() const getTokenMetadata = () => { if (!result.value?.success) { @@ -132,7 +132,7 @@ const QueryResult = ({ result }: { result: Signal | unde return } - if (!browserProvider) { + if (!browserProvider.value) { notify({ message: 'No compatible web3 wallet detected.', title: 'Failed to connect' }) return } @@ -144,9 +144,10 @@ const QueryResult = ({ result }: { result: Signal | unde const tokenAddress = result.value.value const activeChainId = network.value.value.chainId + const provider = browserProvider.value waitFor(async () => { - const contract = new Contract(tokenAddress, ERC20ABI, browserProvider) + const contract = new Contract(tokenAddress, ERC20ABI, provider) const namePromise = contract.name() const symbolPromise = contract.symbol() const decimalsPromise = contract.decimals() diff --git a/app/ts/components/TokenPicker.tsx b/app/ts/components/TokenPicker.tsx index b6e3667d..900c9ffa 100644 --- a/app/ts/components/TokenPicker.tsx +++ b/app/ts/components/TokenPicker.tsx @@ -1,16 +1,16 @@ import { batch, Signal, useComputed, useSignal, useSignalEffect } from '@preact/signals' -import { Contract, formatUnits } from 'ethers' -import { useEffect, useRef } from 'preact/hooks' -import { useAccount } from '../context/Account.js' +import { Contract, formatEther, formatUnits } from 'ethers' +import { useRef } from 'preact/hooks' import { useTokenManager } from '../context/TokenManager.js' import { useTransfer } from '../context/Transfer.js' -import { useWallet } from '../context/Wallet.js' +import { useEthereumProvider } from '../context/Ethereum.js' import { ERC20ABI } from '../library/ERC20ABI.js' import { useAsyncState, useSignalRef } from '../library/preact-utilities.js' import { removeNonStringsAndTrim, stringIncludes } from '../library/utilities.js' -import { ERC20Token } from '../schema.js' +import { ERC20Token, HexString } from '../schema.js' import { AsyncText } from './AsyncText.js' import * as Icon from './Icon/index.js' +import { useWallet } from '../context/Wallet.js' export const TokenPicker = () => { const { ref, signal: dialogRef } = useSignalRef(null) @@ -60,7 +60,7 @@ const AssetCardList = () => { const { cache } = useTokenManager() const { input } = useTransfer() const { query } = useTokenManager() - const { network } = useWallet() + const { network } = useEthereumProvider() const activeChainId = useComputed(() => (network.value.state === 'resolved' ? network.value.value.chainId : 1n)) @@ -92,7 +92,7 @@ const AssetCardList = () => { {tokensList.value.map(token => ( ))} - + ) } @@ -137,7 +137,7 @@ const AssetCard = ({ token }: { token?: ERC20Token }) => {
{token?.symbol || 'ETH'}
{token?.name || 'Ether'}
- +
@@ -146,24 +146,29 @@ const AssetCard = ({ token }: { token?: ERC20Token }) => { ) } -const TokenBalance = ({ token }: { token?: ERC20Token }) => { - const { browserProvider} = useWallet() - const { account } = useAccount() +const AssetBalance = ({ token }: { token?: ERC20Token }) => { + const { browserProvider, blockNumber } = useEthereumProvider() + const { account } = useWallet() const { value: query, waitFor } = useAsyncState() - if (!browserProvider || !token) return <> - - const getTokenBalance = async () => { - if (account.value.state !== 'resolved') return - console.log('getTokenBalance', account.value.state) - const accountAddress = account.value.value - const contract = new Contract(token.address, ERC20ABI, browserProvider) - waitFor(async () => await contract.balanceOf(accountAddress)) + if (account.value.state !== 'resolved') return <> + if (!browserProvider) return <> + + const getAssetBalance = async (address: HexString) => { + if (!browserProvider.value) return + const provider = browserProvider.value + if (!token) { + waitFor(async () => await provider.getBalance(address)) + } else { + const contract = new Contract(token.address, ERC20ABI, provider) + waitFor(async () => await contract.balanceOf(address)) + } } - useEffect(() => { - getTokenBalance() - }, [token]) + useSignalEffect(() => { + if (!blockNumber.value || account.value.state !== 'resolved') return + getAssetBalance(account.value.value) + }) switch (query.value.state) { case 'inactive': @@ -173,10 +178,15 @@ const TokenBalance = ({ token }: { token?: ERC20Token }) => { case 'rejected': return
error
case 'resolved': + if (!token) return <>{formatEther(query.value.value)} + const displayValue = formatUnits(query.value.value, token.decimals) - return <>{displayValue} {token.symbol} + return ( + <> + {displayValue} {token.symbol} + + ) } - } const RemoveAssetDialog = ({ token }: { token: ERC20Token }) => { @@ -220,12 +230,15 @@ const RemoveAssetDialog = ({ token }: { token: ERC20Token }) => { ) } -const AddTokenCard = () => { +const AddTokenOrConnectCard = () => { + const { account } = useWallet() const { stage } = useTokenManager() const openAddTokenDialog = () => { stage.value = 'add' } + if (account.value.state !== 'resolved') return <> + return (
+
+ ) +} diff --git a/app/ts/context/TokenManager.tsx b/app/ts/context/TokenManager.tsx index 0db9bf8a..98992ef5 100644 --- a/app/ts/context/TokenManager.tsx +++ b/app/ts/context/TokenManager.tsx @@ -1,13 +1,9 @@ import { Signal, useSignal } from '@preact/signals' import { ComponentChildren, createContext } from 'preact' import { useContext } from 'preact/hooks' -import { Contract } from 'ethers' import { createCacheParser, TokensCache, TokensCacheSchema } from '../schema.js' -import { DEFAULT_TOKENS, MANAGED_TOKENS_CACHE_KEY } from '../library/constants.js' +import { DEFAULT_TOKENS, KNOWN_TOKENS_CACHE_KEY } from '../library/constants.js' import { persistSignalEffect } from '../library/persistent-signal.js' -import { useAsyncState } from '../library/preact-utilities.js' -import { ERC20ABI } from '../library/ERC20ABI.js' -import { useWallet } from './Wallet.js' export type TokenManagerContext = { cache: Signal @@ -22,7 +18,7 @@ export const TokenManagerProvider = ({ children }: { children: ComponentChildren const stage = useSignal(undefined) const cache = useSignal({ data: DEFAULT_TOKENS, version: '1.0.0' }) - persistSignalEffect(MANAGED_TOKENS_CACHE_KEY, cache, createCacheParser(TokensCacheSchema)) + persistSignalEffect(KNOWN_TOKENS_CACHE_KEY, cache, createCacheParser(TokensCacheSchema)) return {children} } @@ -32,17 +28,3 @@ export function useTokenManager() { if (context === undefined) throw new Error('useTokenManager can only be used within children of TokenManagerProvider') return context } - -export function useTokenBalance() { - const { browserProvider } = useWallet() - const { value: tokenBalance, waitFor } = useAsyncState() - - const getTokenBalance = (accountAddress: string, tokenAddress: string) => { - waitFor(async () => { - const contract = new Contract(tokenAddress, ERC20ABI, browserProvider) - return await contract.balanceOf(accountAddress) - }) - } - - return { tokenBalance, getTokenBalance } -} diff --git a/app/ts/context/Wallet.tsx b/app/ts/context/Wallet.tsx index 6fc115f0..64af227a 100644 --- a/app/ts/context/Wallet.tsx +++ b/app/ts/context/Wallet.tsx @@ -1,53 +1,129 @@ -import { Signal, useSignal, useSignalEffect } from '@preact/signals' -import { BrowserProvider, Network } from 'ethers' +import { Signal, useComputed, useSignal, useSignalEffect } from '@preact/signals' +import { Contract, makeError } from 'ethers' import { ComponentChildren, createContext } from 'preact' -import { useContext, useEffect } from 'preact/hooks' -import { assertsEthereumObservable, assertsWithEthereum } from '../library/ethereum.js' -import { AsyncProperty } from '../library/preact-utilities.js' -import { BigIntHex, HexString } from '../schema.js' - -type WalletContext = { - browserProvider: BrowserProvider | undefined - network: Signal> +import { useContext } from 'preact/hooks' +import { DEFAULT_TOKENS, SETTINGS_CACHE_KEY } from '../library/constants.js' +import { ERC20ABI } from '../library/ERC20ABI.js' +import { isEthereumProvider, withEip1193Provider } from '../library/ethereum.js' +import { persistSignalEffect } from '../library/persistent-signal.js' +import { AsyncProperty, useAsyncState } from '../library/preact-utilities.js' +import { SettingsCacheSchema, createCacheParser, SettingsCache, EthereumAddress, HexString, AccountSettings, ERC20Token } from '../schema.js' +import { useEthereumProvider } from './Ethereum.js' + +export type WalletContext = { + settings: Signal + account: Signal> } export const WalletContext = createContext(undefined) export const WalletProvider = ({ children }: { children: ComponentChildren }) => { - const provider = useSignal(undefined) - const network = useSignal>({ state: 'inactive' }) + const settings = useSignal({ data: [], version: '1.0.0' }) + const account = useSignal>({ state: 'inactive' }) + + persistSignalEffect(SETTINGS_CACHE_KEY, settings, createCacheParser(SettingsCacheSchema)) + + return ( + + + + {children} + + ) +} + +export function useWallet() { + const context = useContext(WalletContext) + if (!context) throw new Error('useWallet can only be used within children of WalletProvider') - const updateBrowserProvider = async (chainIdHex?: HexString) => { - assertsWithEthereum(window) - const chainId = chainIdHex ? BigIntHex.parse(chainIdHex) : undefined - provider.value = new BrowserProvider(window.ethereum, chainId) - const newNetwork = await provider.value.getNetwork() - network.value = { state: 'resolved', value: newNetwork } + const { browserProvider } = useEthereumProvider() + const { value: query, waitFor } = useAsyncState() + + const connect = () => { + waitFor(async () => { + if (!browserProvider.value) { + throw makeError('No compatible web3 wallet detected.', 'UNKNOWN_ERROR', { error: { code: 4900 } }) + } + const signer = await browserProvider.value.getSigner() + return EthereumAddress.parse(signer.address) + }) } - const listenToWalletsChainChange = () => { - assertsWithEthereum(window) - assertsEthereumObservable(window.ethereum) - window.ethereum.on('chainChanged', updateBrowserProvider) + const listenForQueryChanges = () => { + // do not reset shared state for other instances of this hooks + if (query.value.state === 'inactive') return + context.account.value = query.value } - const listenToBrowserProviderChange = () => { - if (provider.value === undefined) return - listenToWalletsChainChange() + useSignalEffect(listenForQueryChanges) + return { ...context, connect } +} + +const AccountUpdater = () => { + const { account } = useWallet() + const addAccountChangeListener = () => { + if (!withEip1193Provider(window)) return + if (!isEthereumProvider(window.ethereum)) return + window.ethereum.on('accountsChanged', updateAsyncAccount) } + const updateAsyncAccount = ([newAddress]: (string | undefined)[]) => { + account.value = newAddress ? { state: 'resolved', value: EthereumAddress.parse(newAddress) } : { state: 'inactive' } + } + const listenToAccountChange = () => { + if (account.value.state !== 'resolved') return + addAccountChangeListener() + } + + useSignalEffect(listenToAccountChange) - const context = { - browserProvider: provider.value, - network, + return <> +} + +const SettingsUpdater = () => { + const { account, settings } = useWallet() + const initializeSettings = (accountAddress: HexString) => { + const accountSettingsExist = settings.value.data.some(data => data.address === accountAddress) + if (accountSettingsExist) return + const accountSettings: AccountSettings = { + address: accountAddress, + holdings: DEFAULT_TOKENS.map(token => token.address) + } + settings.value = Object.assign({}, settings.peek(), { data: settings.peek().data.concat([accountSettings]) }) } - useSignalEffect(listenToBrowserProviderChange) - useEffect(() => void updateBrowserProvider(), []) + useSignalEffect(() => { + if (account.value.state !== 'resolved') return + initializeSettings(account.value.value) + }) - return {children} + return <> } -export function useWallet() { - const context = useContext(WalletContext) - if (context === undefined) throw new Error('useWallet can only be used within children of WalletProvider') - return context +export function useBalance() { + const { browserProvider, blockNumber } = useEthereumProvider() + const { account } = useWallet() + const { value: balance, waitFor } = useAsyncState() + const token = useSignal(undefined) + + const resolvedAccount = useComputed(() => account.value.state === 'resolved' ? account.value.value : undefined) + + const queryAssetBalance = (accountAddress: EthereumAddress, token?: ERC20Token) => { + if (!browserProvider.value || !blockNumber.value) return + const provider = browserProvider.value + if (!token) { + waitFor(async () => await provider.getBalance(accountAddress, blockNumber.value)) + } else { + const contract = new Contract(token.address, ERC20ABI, provider) + waitFor(async () => await contract.balanceOf(accountAddress)) + } + } + + const fetchLatestBalance = () => { + if (!resolvedAccount.value) return + queryAssetBalance(resolvedAccount.value, token.value) + } + + useSignalEffect(fetchLatestBalance) + + return { balance, token } } + diff --git a/app/ts/library/constants.ts b/app/ts/library/constants.ts index dd50f009..b34a55b4 100644 --- a/app/ts/library/constants.ts +++ b/app/ts/library/constants.ts @@ -1,5 +1,6 @@ import { ERC20Token } from '../schema' +export const LAYOUT_SCROLL_OPTIONS:ScrollIntoViewOptions = { inline: 'start', behavior: 'smooth' } export const DEFAULT_TOKENS: ERC20Token[] = [ { chainId: 1n, @@ -51,8 +52,7 @@ export const DEFAULT_TOKENS: ERC20Token[] = [ decimals: 6n, }, ] - export const STORAGE_KEY_RECENTS = 'txns' -export const MANAGED_TOKENS_CACHE_KEY = 'managed_tokens' +export const KNOWN_TOKENS_CACHE_KEY = 'tokens' export const SETTINGS_CACHE_KEY = 'settings' export const RECENT_TRANSFERS_CACHE_KEY = 'transfers' diff --git a/app/ts/library/errors.ts b/app/ts/library/errors.ts new file mode 100644 index 00000000..39085b37 --- /dev/null +++ b/app/ts/library/errors.ts @@ -0,0 +1,126 @@ +import { ActionRejectedError, NetworkError, TimeoutError, CancelledError, NumericFaultError, MissingArgumentError, CallExceptionError, TransactionReplacedError, ReplacementUnderpricedError, UnconfiguredNameError, UnexpectedArgumentError, InvalidArgumentError, BufferOverrunError, BadDataError, ServerError, NotImplementedError, UnknownError, UnsupportedOperationError, InsufficientFundsError, NonceExpiredError, OffchainFaultError } from 'ethers' +import { assertUnreachable } from './utilities.js' + +type EthersError = (UnknownError & { code: 'UNKNOWN_ERROR' }) + | (NotImplementedError & { code: 'NOT_IMPLEMENTED' }) + | (UnsupportedOperationError & { code: 'UNSUPPORTED_OPERATION' }) + | (NetworkError & { code: 'NETWORK_ERROR' }) + | (ServerError & { code: 'SERVER_ERROR' }) + | (TimeoutError & { code: 'TIMEOUT' }) + | (BadDataError & { code: 'BAD_DATA' }) + | (CancelledError & { code: 'CANCELLED' }) + | (BufferOverrunError & { code: 'BUFFER_OVERRUN' }) + | (NumericFaultError & { code: 'NUMERIC_FAULT' }) + | (InvalidArgumentError & { code: 'INVALID_ARGUMENT' }) + | (MissingArgumentError & { code: 'MISSING_ARGUMENT' }) + | (UnexpectedArgumentError & { code: 'UNEXPECTED_ARGUMENT' }) + | (CallExceptionError & { code: 'CALL_EXCEPTION' }) + | (InsufficientFundsError & { code: 'INSUFFICIENT_FUNDS' }) + | (NonceExpiredError & { code: 'NONCE_EXPIRED' }) + | (OffchainFaultError & { code: 'OFFCHAIN_FAULT' }) + | (ReplacementUnderpricedError & { code: 'REPLACEMENT_UNDERPRICED' }) + | (TransactionReplacedError & { code: 'TRANSACTION_REPLACED' }) + | (UnconfiguredNameError & { code: 'UNCONFIGURED_NAME' }) + | (ActionRejectedError & { code: 'ACTION_REJECTED' }) + +const ethersErrorCodes = ['UNKNOWN_ERROR', 'NOT_IMPLEMENTED', 'UNSUPPORTED_OPERATION', 'NETWORK_ERROR', 'SERVER_ERROR', 'TIMEOUT', 'BAD_DATA', 'CANCELLED', 'BUFFER_OVERRUN', 'NUMERIC_FAULT', 'INVALID_ARGUMENT', 'MISSING_ARGUMENT', 'UNEXPECTED_ARGUMENT', 'CALL_EXCEPTION', 'INSUFFICIENT_FUNDS', 'NONCE_EXPIRED', 'OFFCHAIN_FAULT', 'REPLACEMENT_UNDERPRICED', 'TRANSACTION_REPLACED', 'UNCONFIGURED_NAME', 'ACTION_REJECTED'] as const +type EthersErrorCode = typeof ethersErrorCodes[number] + +export function isEthersError(error: unknown): error is EthersError { + if (typeof error !== 'object') return false + if (error === null) return false + if (!('code' in error)) return false + if (typeof error.code !== 'string') return false + if (!ethersErrorCodes.includes(error.code as EthersErrorCode)) return false + return true +} + +export type HumanReadableError = { message: string, warning: boolean } + +export function humanReadableEthersError(error: EthersError): HumanReadableError { + switch (error.code) { + // Generic Errors + case 'UNKNOWN_ERROR': + console.error('Found UNKNOWN_ERROR error: ', error) + return { warning: true, message: `Unknown Error: ${typeof error === 'string' ? error : JSON.stringify(error)}` } + case 'NOT_IMPLEMENTED': + console.error('Found NOT_IMPLEMENTED Error: ', error) + return { warning: true, message: `Error with EthersJS: "${error.info ? JSON.stringify(error.info) : ''}". This is a bug and you should report it.` } + case 'UNSUPPORTED_OPERATION': + return { warning: true, message: `Attempted to execute an unsupported operation: "${error.info ? JSON.stringify(error.info) : ''}". This is a bug and you should report it.` } + case 'SERVER_ERROR': + return { warning: true, message: `Could not communicate with server. ${error.info ? JSON.stringify(error.info) : ''}` } + case 'TIMEOUT': + return { warning: true, message: `Timeout during action "${error.operation}". ${error.reason}` } + case 'BAD_DATA': + return { warning: true, message: `EthersJS tried failed to understand this value: ${JSON.stringify(error.value)}. This is likely a bug and you should report it.` } + case 'CANCELLED': + return { warning: false, message: `Request was canceled by app. ${error.info ? JSON.stringify(error.info) : ''}` } + // Operational Errors + case 'BUFFER_OVERRUN': + return { warning: true, message: `Buffer overrun. This likely a bug and you should report it. ${error.info ? JSON.stringify(error.info) : ''}` } + case 'NUMERIC_FAULT': + return { warning: true, message: `Failed to ${error.operation}. ${error.fault} with value ${error.value}` } + // Argument Errors + case 'INVALID_ARGUMENT': + return { warning: true, message: `EthersJS received an invalid argument, "${error.argument}" with value ${JSON.stringify(error.value)}. This is a bug and you should report it. ${error.stack ?? ''}` } + case 'MISSING_ARGUMENT': + return { warning: true, message: `EthersJS expected ${error.count} arguments and received ${error.expectedCount}. This is a bug and you should report it. ${error.stack ?? ''}` } + case 'UNEXPECTED_ARGUMENT': + return { warning: true, message: `EthersJS received too many arguments. This is a bug and you should report it. ${error.stack ?? ''}` } + // Blockchain Errors + case 'CALL_EXCEPTION': + if (error.receipt) { + return { warning: true, message: `Transaction was included in block #${error.receipt.blockNumber} but reverted${error.reason ? ` with error: ${error.reason}` : ''}` } + } else { + return { warning: true, message: error.reason ? `Transaction will fail. Call exeception during ${error.action}: ${error.reason}` : `The transaction will revert. ${error.reason ?? ''}` } + } + case 'INSUFFICIENT_FUNDS': + return { warning: true, message: `Account ${error.transaction.from} does not have enough funds for this transaction.` } + case 'NONCE_EXPIRED': + return { warning: true, message: `The transaction from ${error.transaction.from} got replaced by a transaction with the same nonce.` } + case 'REPLACEMENT_UNDERPRICED': + return { warning: true, message: `The replacement transaction is underpriced.` } + case 'TRANSACTION_REPLACED': + return { warning: true, message: `The transaction got replaced by a transaction with the same nonce` } + case 'UNCONFIGURED_NAME': + return { warning: true, message: `Could not find ${error.value}. This ENS address may not be registered.` } + case 'OFFCHAIN_FAULT': + return { warning: true, message: `Offchain CCIP Error: ${error.reason}` } + // User Interaction Errors + case 'ACTION_REJECTED': + return { warning: false, message: 'User rejected the request' } + default: + console.error('Found unknown error: ', error) + return { warning: true, message: `Unknown Error: ${typeof error === 'string' ? error : JSON.stringify(error)}` } + } +} + +/* + * https://eips.ethereum.org/EIPS/eip-1474#error-codes + * */ +const jsonRpcErrorCodes = [-32002, 4100, 4900] as const +type JsonRpcErrorCode = typeof jsonRpcErrorCodes[number] +type JsonRpcError = Error & { code: JsonRpcErrorCode } + +export function isJsonRpcError(error: unknown): error is JsonRpcError { + if (typeof error !== 'object') return false + if (error === null) return false + if (!('code' in error)) return false + if (typeof error.code !== 'number') return false + if (!jsonRpcErrorCodes.includes(error.code as JsonRpcErrorCode)) return false + return true +} + +export function humanReadableJsonRpcError(error: JsonRpcError): HumanReadableError { + switch(error.code) { + case -32002: + return { warning: false, message: 'A pending request to connect is awaiting response. Check your wallet for more information.' } + case 4100: + return { warning: false, message: 'The requested method and/or account has not been authorized by the user.' } + case 4900: + return { warning: false, message: 'No compatible web3 wallet was detected.' } + default: + assertUnreachable(error.code) + } +} diff --git a/app/ts/library/ethereum.ts b/app/ts/library/ethereum.ts index 123953b4..d22d394f 100644 --- a/app/ts/library/ethereum.ts +++ b/app/ts/library/ethereum.ts @@ -1,29 +1,19 @@ -import { TransactionResponse, Interface, id, TransactionReceipt, Log, Eip1193Provider, formatEther } from 'ethers' +import { TransactionResponse, Interface, id, TransactionReceipt, Log, Eip1193Provider, formatEther, EventEmitterable } from 'ethers' import { ERC20ABI } from './ERC20ABI.js' -import { ApplicationError } from '../store/errors.js' -export interface WithEthereum { +export interface WithEip1193Provider { ethereum: Eip1193Provider } -export function withEthereum(global: unknown): global is WithEthereum { - return global !== null && typeof global === 'object' && 'ethereum' in global && global.ethereum !== null && typeof global.ethereum === 'object' && 'on' in global.ethereum && typeof global.ethereum.on === 'function' -} - -export type ObservableEthereum = { - on(eventName: string | symbol, listener: (...args: any[]) => void): void -} - -export function isEthereumObservable(ethereum: unknown): ethereum is ObservableEthereum { - return ethereum instanceof Object && 'on' in ethereum && typeof ethereum.on === 'function' -} +export type EthereumProviderEvents = 'chainChanged' | 'accountsChanged' +export type EthereumProvider = EventEmitterable -export function assertsEthereumObservable(ethereum: unknown): asserts ethereum is ObservableEthereum { - if (!isEthereumObservable(ethereum)) throw new Error('Ethereum object is not observable') +export function withEip1193Provider(global: unknown): global is WithEip1193Provider { + return global !== null && typeof global === 'object' && 'ethereum' in global && global.ethereum !== null && typeof global.ethereum === 'object' && 'request' in global.ethereum && typeof global.ethereum.request === 'function' } -export function assertsWithEthereum(global: unknown): asserts global is WithEthereum { - if (!withEthereum(global)) throw new ApplicationError('WALLET_MISSING') +export function isEthereumProvider(ethereum: unknown): ethereum is EthereumProvider { + return ethereum !== null && typeof ethereum === 'object' && 'on' in ethereum && typeof ethereum.on === 'function' && 'removeListener' in ethereum && typeof ethereum.removeListener === 'function' } export const calculateGasFee = (effectiveGasPrice: bigint, gasUsed: bigint) => { diff --git a/app/ts/library/human-ethers-errors.ts b/app/ts/library/human-ethers-errors.ts deleted file mode 100644 index cf1e0c24..00000000 --- a/app/ts/library/human-ethers-errors.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { ActionRejectedError, NetworkError, TimeoutError, CancelledError, NumericFaultError, MissingArgumentError, CallExceptionError, TransactionReplacedError, ReplacementUnderpricedError, UnconfiguredNameError, UnexpectedArgumentError, InvalidArgumentError, BufferOverrunError, BadDataError, ServerError, NotImplementedError, UnknownError, UnsupportedOperationError, InsufficientFundsError, NonceExpiredError, OffchainFaultError } from 'ethers' - -type EthersError = (UnknownError & { code: 'UNKNOWN_ERROR' }) - | (NotImplementedError & { code: 'NOT_IMPLEMENTED' }) - | (UnsupportedOperationError & { code: 'UNSUPPORTED_OPERATION' }) - | (NetworkError & { code: 'NETWORK_ERROR' }) - | (ServerError & { code: 'SERVER_ERROR' }) - | (TimeoutError & { code: 'TIMEOUT' }) - | (BadDataError & { code: 'BAD_DATA' }) - | (CancelledError & { code: 'CANCELLED' }) - | (BufferOverrunError & { code: 'BUFFER_OVERRUN' }) - | (NumericFaultError & { code: 'NUMERIC_FAULT' }) - | (InvalidArgumentError & { code: 'INVALID_ARGUMENT' }) - | (MissingArgumentError & { code: 'MISSING_ARGUMENT' }) - | (UnexpectedArgumentError & { code: 'UNEXPECTED_ARGUMENT' }) - | (CallExceptionError & { code: 'CALL_EXCEPTION' }) - | (InsufficientFundsError & { code: 'INSUFFICIENT_FUNDS' }) - | (NonceExpiredError & { code: 'NONCE_EXPIRED' }) - | (OffchainFaultError & { code: 'OFFCHAIN_FAULT' }) - | (ReplacementUnderpricedError & { code: 'REPLACEMENT_UNDERPRICED' }) - | (TransactionReplacedError & { code: 'TRANSACTION_REPLACED' }) - | (UnconfiguredNameError & { code: 'UNCONFIGURED_NAME' }) - | (ActionRejectedError & { code: 'ACTION_REJECTED' }) -const ETHERS_ERROR_CODES = [ 'UNKNOWN_ERROR', 'NOT_IMPLEMENTED', 'UNSUPPORTED_OPERATION', 'NETWORK_ERROR', 'SERVER_ERROR', 'TIMEOUT', 'BAD_DATA', 'CANCELLED', 'BUFFER_OVERRUN', 'NUMERIC_FAULT', 'INVALID_ARGUMENT', 'MISSING_ARGUMENT', 'UNEXPECTED_ARGUMENT', 'CALL_EXCEPTION', 'INSUFFICIENT_FUNDS', 'NONCE_EXPIRED', 'OFFCHAIN_FAULT', 'REPLACEMENT_UNDERPRICED', 'TRANSACTION_REPLACED', 'UNCONFIGURED_NAME', 'ACTION_REJECTED' ] as const -type ETHERS_ERROR_CODES = typeof ETHERS_ERROR_CODES[number] - -export function isEthersError(error: unknown): error is EthersError { - if (typeof error !== 'object') return false - if (error === null) return false - if (!('code' in error)) return false - const code = error.code - if (typeof code !== 'string') return false - if (!ETHERS_ERROR_CODES.includes(code as ETHERS_ERROR_CODES)) return false - return true -} - -export type HumanReadableEthersError = { message: string, warning: boolean } - -export function humanReadableEthersError(error: unknown): HumanReadableEthersError { - if (isEthersError(error)) { - switch (error.code) { - // Generic Errors - case 'UNKNOWN_ERROR': - console.error('Found UNKNOWN_ERROR error: ', error) - return { warning: true, message: `Unknown Error: ${typeof error === 'string' ? error : JSON.stringify(error)}` } - case 'NOT_IMPLEMENTED': - console.error('Found NOT_IMPLEMENTED Error: ', error) - return { warning: true, message: `Error with EthersJS: "${error.info ? JSON.stringify(error.info) : ''}". This is a bug and you should report it.`} - case 'UNSUPPORTED_OPERATION': - return { warning: true, message: `Attempted to execute an unsupported operation: "${error.info ? JSON.stringify(error.info) : ''}". This is a bug and you should report it.` } - case 'SERVER_ERROR': - return { warning: true, message: `Could not communicate with server. ${error.info ? JSON.stringify(error.info) : ''}` } - case 'TIMEOUT': - return { warning: true, message: `Timeout during action "${error.operation}". ${error.reason}` } - case 'BAD_DATA': - return { warning: true, message: `EthersJS tried failed to understand this value: ${JSON.stringify(error.value)}. This is likely a bug and you should report it.` } - case 'CANCELLED': - return { warning: false, message: `Request was canceled by app. ${error.info ? JSON.stringify(error.info) : ''}` } - // Operational Errors - case 'BUFFER_OVERRUN': - return { warning: true, message: `Buffer overrun. This likely a bug and you should report it. ${error.info ? JSON.stringify(error.info): ''}` } - case 'NUMERIC_FAULT': - return { warning: true, message: `Failed to ${error.operation}. ${error.fault} with value ${error.value}` } - // Argument Errors - case 'INVALID_ARGUMENT': - return { warning: true, message: `EthersJS received an invalid argument, "${error.argument}" with value ${JSON.stringify(error.value)}. This is a bug and you should report it. ${error.stack ?? ''}` } - case 'MISSING_ARGUMENT': - return { warning: true, message: `EthersJS expected ${error.count} arguments and received ${error.expectedCount}. This is a bug and you should report it. ${error.stack ?? ''}` } - case 'UNEXPECTED_ARGUMENT': - return { warning: true, message: `EthersJS received too many arguments. This is a bug and you should report it. ${error.stack ?? ''}` } - // Blockchain Errors - case 'CALL_EXCEPTION': - if (error.receipt) { - return { warning: true, message: `Transaction was included in block #${error.receipt.blockNumber} but reverted${error.reason ? ` with error: ${error.reason}`: ''}` } - } else { - return { warning: true, message: error.reason ? `Transaction will fail. Call exeception during ${error.action}: ${error.reason}`: `The transaction will revert. ${error.reason ?? ''}` } - } - case 'INSUFFICIENT_FUNDS': - return { warning: true, message: `Account ${error.transaction.from} does not have enough funds for this transaction.` } - case 'NONCE_EXPIRED': - return { warning: true, message: `The transaction from ${error.transaction.from} got replaced by a transaction with the same nonce.` } - case 'REPLACEMENT_UNDERPRICED': - return { warning: true, message: `The replacement transaction is underpriced.` } - case 'TRANSACTION_REPLACED': - return { warning: true, message: `The transaction got replaced by a transaction with the same nonce` } - case 'UNCONFIGURED_NAME': - return { warning: true, message: `Could not find ${error.value}. This ENS address may not be registered.` } - case 'OFFCHAIN_FAULT': - return { warning: true, message: `Offchain CCIP Error: ${error.reason}` } - // User Interaction Errors - case 'ACTION_REJECTED': - return { warning: false, message: 'User rejected the request' } - default: - console.error('Found unknown error: ', error) - return { warning: true, message: `Unknown Error: ${typeof error === 'string' ? error : JSON.stringify(error)}` } - } - } else { - // string, code, extra?, fallback - console.error('Found unknown error: ', error) - return { warning: true, message: `Unknown Error: ${typeof error === 'string' ? error : JSON.stringify(error)}` } - } -} diff --git a/app/ts/library/utilities.ts b/app/ts/library/utilities.ts index 40297f74..1ec2419b 100644 --- a/app/ts/library/utilities.ts +++ b/app/ts/library/utilities.ts @@ -46,9 +46,7 @@ export function JSONParse(jsonString: string) { } /** - * * Checks if a search string can be found within the source string - * */ export function stringIncludes(source: string, search: string, caseSensitive?: boolean) { if (caseSensitive) return source.includes(search) @@ -58,3 +56,11 @@ export function stringIncludes(source: string, search: string, caseSensitive?: b export function preventFocus(e: JSX.TargetedEvent) { e.currentTarget.blur() } + +/** + * Match string equality + */ +export function areEqualStrings(a: string, b: string, caseSensitive?: true) { + if (caseSensitive) return a === b + return a.toLowerCase() === b.toLowerCase() +} diff --git a/app/ts/schema.ts b/app/ts/schema.ts index dd4e61cf..8a7799e2 100644 --- a/app/ts/schema.ts +++ b/app/ts/schema.ts @@ -98,11 +98,16 @@ export const TokensCacheSchema = funtypes.Union( export type TokensCache = funtypes.Static +export const Holdings = funtypes.Array(EthereumAddress) +export type Holdings = funtypes.Static + const AccountSettings = funtypes.Object({ address: EthereumAddress, - tokens: funtypes.Array(EthereumAddress), + holdings: Holdings }) +export type AccountSettings = funtypes.Static + export const SettingsCacheSchema = funtypes.Union( funtypes.Object({ data: funtypes.Array(AccountSettings), diff --git a/app/ts/store/errors.ts b/app/ts/store/errors.ts deleted file mode 100644 index 1c97f653..00000000 --- a/app/ts/store/errors.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { signal, useComputed } from '@preact/signals' - -export const errors = signal([]) - -const scheduleDismiss = () => { - setInterval(() => { - errors.value = errors.peek().slice(1) - }, 1000) -} - -export function useErrors() { - const latest = useComputed(() => errors.value.at(0)) - - const add = (code: keyof typeof ErrorsDictionary, message?: string) => { - const error = new ApplicationError(code, message) - errors.value = [error, ...errors.value] - scheduleDismiss() - } - - const remove = (code: keyof typeof ErrorsDictionary) => { - errors.value = errors.value.filter(error => error.code !== code) - } - - return { errors, remove, add, latest } -} - -export class ApplicationError extends Error { - code: keyof typeof ErrorsDictionary - - constructor(code: keyof typeof ErrorsDictionary, customMessage?: string) { - super(customMessage) - this.name = 'ApplicationError' - this.message = customMessage || ErrorsDictionary[code] - this.code = code - } -} - -// TODO: create a map of possible errors -export const ErrorsDictionary = { - UNKNOWN: 'An unknown error has occurred.', - WALLET_MISSING: 'No web 3 compatible wallet detected.', -} diff --git a/app/ts/store/network.ts b/app/ts/store/network.ts deleted file mode 100644 index 9c461b60..00000000 --- a/app/ts/store/network.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { signal, useSignalEffect } from '@preact/signals' -import { AsyncProperty, useAsyncState } from '../library/preact-utilities.js' -import { useProviders } from './provider.js' -import { Network } from 'ethers' - -const network = signal>({ state: 'inactive' }) - -export function useNetwork() { - const providers = useProviders() - const { value: query, waitFor } = useAsyncState() - - const getNetwork = () => { - waitFor(async () => await providers.browserProvider.getNetwork()) - } - - const listenForQueryChanges = () => { - if (query.value.state !== 'resolved') return - network.value = query.value - } - - const listenForProviderChanges = () => { - getNetwork() - } - - useSignalEffect(listenForProviderChanges) - useSignalEffect(listenForQueryChanges) - - return { network } -} diff --git a/app/ts/store/provider.ts b/app/ts/store/provider.ts deleted file mode 100644 index 4b63f59d..00000000 --- a/app/ts/store/provider.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { effect, signal } from '@preact/signals' -import { assertsEthereumObservable, assertsWithEthereum } from '../library/ethereum.js' -import { BrowserProvider } from 'ethers' - -const provider = signal(undefined) - -export function useProviders() { - return { - get browserProvider() { - assertsWithEthereum(window) - return new BrowserProvider(window.ethereum) - }, - } -} - -const handleChainChange = async () => { - removeChainChangeListener() - - // reinitialize provider - assertsWithEthereum(window) - provider.value = new BrowserProvider(window.ethereum) -} - -const removeChainChangeListener = effect(() => { - if (provider.value === undefined) return - assertsWithEthereum(window) - assertsEthereumObservable(window.ethereum) - window.ethereum.on('chainChanged', handleChainChange) -}) diff --git a/app/ts/store/tokens.ts b/app/ts/store/tokens.ts deleted file mode 100644 index 612960d5..00000000 --- a/app/ts/store/tokens.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { signal, useSignal, useSignalEffect } from '@preact/signals' -import * as funtypes from 'funtypes' -import { useAsyncState } from '../library/preact-utilities.js' -import { useProviders } from './provider.js' -import { useNetwork } from './network.js' -import { Contract, isAddress } from 'ethers' -import { ERC20ABI } from '../library/ERC20ABI.js' -import { DEFAULT_TOKENS, MANAGED_TOKENS_CACHE_KEY } from '../library/constants.js' -import { persistSignalEffect } from '../library/persistent-signal.js' -import { createCacheParser, EthereumAddress, ERC20Token } from '../schema.js' - -export function useTokenQuery() { - const { value: query, waitFor, reset } = useAsyncState() - const providers = useProviders() - const { network } = useNetwork() - const tokenAddress = useSignal('') - - const validateChangedAddress = () => { - reset() - - // check address validity - if (!isAddress(tokenAddress.value)) return - - waitFor(async () => { - if (network.value.state !== 'resolved') { - throw new Error('Disconnected') - } - - const chainId = network.value.value.chainId - - try { - const contract = new Contract(tokenAddress.value, ERC20ABI, providers.browserProvider) - const name = await contract.name() - const symbol = await contract.symbol() - const decimals = await contract.decimals() - const address = EthereumAddress.parse(tokenAddress.value) - return { chainId, name, symbol, decimals, address } as const - } catch (unknownError) { - throw new Error('Contract call failed') - } - }) - } - - useSignalEffect(validateChangedAddress) - - return { query, tokenAddress } -} - -export function useTokenBalance() { - const providers = useProviders() - const { value: tokenBalance, waitFor } = useAsyncState() - - const getTokenBalance = (accountAddress: string, tokenAddress: string) => { - waitFor(async () => { - const contract = new Contract(tokenAddress, ERC20ABI, providers.browserProvider) - return await contract.balanceOf(accountAddress) - }) - } - - return { tokenBalance, getTokenBalance } -} - -const ManagedTokensSchema = funtypes.Array(ERC20Token) -const managedTokens = signal(DEFAULT_TOKENS) -const managedTokensCacheKey = signal(MANAGED_TOKENS_CACHE_KEY) - -export function useManagedTokens() { - persistSignalEffect(managedTokensCacheKey.value, managedTokens, createCacheParser(ManagedTokensSchema)) - return { tokens: managedTokens, cacheKey: managedTokensCacheKey } -} diff --git a/app/ts/store/transaction.ts b/app/ts/store/transaction.ts index d595ac36..a4d6932e 100644 --- a/app/ts/store/transaction.ts +++ b/app/ts/store/transaction.ts @@ -2,16 +2,17 @@ import { useSignalEffect } from '@preact/signals' import { useEffect } from 'preact/hooks' import { useAsyncState } from '../library/preact-utilities.js' import { TransactionReceipt, TransactionResponse } from 'ethers' -import { useProviders } from './provider.js' +import { useEthereumProvider } from '../context/Ethereum.js' export function useTransaction(transactionHash: string) { - const providers = useProviders() + const { browserProvider } = useEthereumProvider() const { value: transactionResponse, waitFor: waitForResponse, reset: resetResponse } = useAsyncState() const { value: transactionReceipt, waitFor: waitForReceipt, reset: resetReceipt } = useAsyncState() const getTransactionResponse = (transactionHash: string) => { + if (!browserProvider.value) return + const provider = browserProvider.value waitForResponse(async () => { - const provider = providers.browserProvider const result = await provider.getTransaction(transactionHash) // TransactionResult can actually be null? if (result === null) throw new Error('Transaction was not found on chain!') diff --git a/twcss/tailwind.config.js b/twcss/tailwind.config.js index 5a17cbf3..86fb6507 100644 --- a/twcss/tailwind.config.js +++ b/twcss/tailwind.config.js @@ -4,14 +4,14 @@ module.exports = { content: ['../app/ts/**/*.(ts|tsx)'], theme: {}, plugins: [ - plugin(function({ addVariant }) { + plugin(function ({ addVariant }) { addVariant('enabled', '&:not(:disabled)') addVariant('modified', '&:not([data-pristine])') - addVariant('group-modified',':merge(.group):not([data-pristine]) &') - addVariant('focus|hover', ['&:focus', '&:hover']) - }) + addVariant('group-modified', ':merge(.group):not([data-pristine]) &') + addVariant('focus|hover', ['&:focus', '&:hover']) + }), ], experimental: { optimizeUniversalDefaults: true, - } + }, }