diff --git a/cspell.json b/cspell.json index d22fffdca815..979bbbada9a3 100644 --- a/cspell.json +++ b/cspell.json @@ -358,7 +358,8 @@ "walletlink", "WNATIVE", "xdescribe", - "xtest" + "xtest", + "txreceipt" ], "ignoreRegExpList": ["/@servie/"], "overrides": [ diff --git a/packages/mask/shared-ui/locales/en-US.json b/packages/mask/shared-ui/locales/en-US.json index 097bd8385303..30c5c46365ab 100644 --- a/packages/mask/shared-ui/locales/en-US.json +++ b/packages/mask/shared-ui/locales/en-US.json @@ -282,6 +282,7 @@ "plugin_wallet_transaction_confirmed": "Your transaction was confirmed!", "plugin_wallet_transaction_reverted": "Transaction was reverted!", "plugin_wallet_transaction_rejected": "Transaction was rejected!", + "plugin_wallet_transaction_underpriced": "Transaction underpriced.", "plugin_wallet_transaction_server_error": "Transaction was failed due to an internal JSON-RPC server error.", "plugin_wallet_view_on_explorer": "View on Explorer", "plugin_ito_placeholder_when_token_unselected": "Please Select a Token first", diff --git a/packages/mask/src/components/InjectedComponents/ToolboxUnstyled.tsx b/packages/mask/src/components/InjectedComponents/ToolboxUnstyled.tsx index 399897911df2..dd73b57afd5d 100644 --- a/packages/mask/src/components/InjectedComponents/ToolboxUnstyled.tsx +++ b/packages/mask/src/components/InjectedComponents/ToolboxUnstyled.tsx @@ -34,8 +34,10 @@ import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord' import { usePersonaConnectStatus } from '../DataSource/usePersonaConnectStatus' const useStyles = makeStyles()((theme) => ({ - font: { + title: { color: theme.palette.mode === 'dark' ? theme.palette.text.primary : 'rgb(15, 20, 25)', + display: 'flex', + alignItems: 'center', }, paper: { borderRadius: 4, @@ -135,7 +137,7 @@ export function ToolboxHintUnstyled(props: ToolboxHintProps) { justifyContent: 'space-between', alignItems: 'center', }}> - {title} + {title} {shouldDisplayChainIndicator ? ( - + {t('plugin_wallet_pending_transactions', { count: pendingTransactions.length, })} - + ) } diff --git a/packages/mask/src/extension/background-script/EthereumServices/error.ts b/packages/mask/src/extension/background-script/EthereumServices/error.ts index f2d93dfd7f9b..f735f8c7cf3c 100644 --- a/packages/mask/src/extension/background-script/EthereumServices/error.ts +++ b/packages/mask/src/extension/background-script/EthereumServices/error.ts @@ -1,16 +1,51 @@ import { isNil } from 'lodash-unified' import type { JsonRpcResponse } from 'web3-core-helpers' +import { JSON_RPC_ERROR_CODE } from '@masknet/plugin-wallet' +import { i18n } from '../../../../shared-ui/locales_legacy' + +function getInternalError(error: unknown, response?: JsonRpcResponse | null, fallback?: string): Error { + { + const rpcError = error + if (rpcError instanceof Error && rpcError.message) return rpcError + if (rpcError && typeof (rpcError as Error).message === 'string') return new Error((rpcError as Error).message) + if (rpcError && typeof rpcError === 'string') return new Error(rpcError) + } + + { + const responseError = response?.error as unknown + if (responseError instanceof Error) return getError(responseError, null, fallback) + if (responseError && typeof (responseError as Error).message === 'string') + return getError(responseError, null, fallback) + if (responseError && typeof responseError === 'string') return new Error(responseError) + } + if (fallback) return new Error(fallback) + return new Error('Unknown Error.') +} export function hasError(error: unknown, response?: JsonRpcResponse | null) { return !isNil(error) || !isNil(response?.error) } export function getError(error: unknown, response?: JsonRpcResponse | null, fallback?: string): Error { - if (error instanceof Error && error.message) return error - if (typeof error === 'string' && error) return new Error(error) - const responseError = response?.error as unknown - if (responseError instanceof Error) return getError(responseError, null, fallback) - if (typeof response?.error === 'string' && response?.error) return new Error(response.error) - if (fallback) return new Error(fallback) - return new Error('Unknown Error.') + const internalError = getInternalError(error, response, fallback) + const internalErrorMessage = (() => { + const { code, message } = internalError as unknown as { code?: number; message: string } + + if (message.includes(`"code":${JSON_RPC_ERROR_CODE.INTERNAL_ERROR}`)) + return i18n.t('plugin_wallet_transaction_server_error') + if (message.includes('User denied message signature.')) return i18n.t('plugin_wallet_cancel_sign') + if (message.includes('User denied transaction signature.')) return i18n.t('plugin_wallet_transaction_rejected') + if (message.includes('transaction underpriced')) return i18n.t('plugin_wallet_transaction_underpriced') + if ( + typeof code === 'number' && + (code === JSON_RPC_ERROR_CODE.INTERNAL_ERROR || + (code <= JSON_RPC_ERROR_CODE.SERVER_ERROR_RANGE_START && + code >= JSON_RPC_ERROR_CODE.SERVER_ERROR_RANGE_END)) + ) { + return i18n.t('plugin_wallet_transaction_server_error') + } + return internalError.message + })() + + return new Error(internalErrorMessage) } diff --git a/packages/mask/src/extension/background-script/EthereumServices/send.ts b/packages/mask/src/extension/background-script/EthereumServices/send.ts index 4b674eb52274..d1488a3750e9 100644 --- a/packages/mask/src/extension/background-script/EthereumServices/send.ts +++ b/packages/mask/src/extension/background-script/EthereumServices/send.ts @@ -1,4 +1,3 @@ -import { first } from 'lodash-unified' import { EthereumAddress } from 'wallet.ts' import { toHex } from 'web3-utils' import type { HttpProvider } from 'web3-core' @@ -10,11 +9,14 @@ import { EthereumErrorType, EthereumMethodType, EthereumRpcType, - EthereumTransactionConfig, isEIP1559Supported, isSameAddress, ProviderType, SendOverrides, + getPayloadHash, + getPayloadConfig, + getPayloadChainId, + getTransactionHash, isZeroAddress, } from '@masknet/web3-shared-evm' import type { IJsonRpcRequest } from '@walletconnect/types' @@ -80,56 +82,6 @@ function getTo(computedPayload: UnboxPromise(({ dataSource, chainId } const toAddress = getToAddress(transaction.receipt, transaction.computedPayload) return ( ({ content: { @@ -129,20 +128,7 @@ function TransactionDialogUI(props: TransactionDialogUIProps) { <> - { - // it is trick, log(e) print {"code": 4001 ...}, log(e.code) print -1 - state.error.message === 'MetaMask Message Signature: User denied message signature.' - ? t('plugin_wallet_cancel_sign') - : state.error.message.includes('User denied transaction signature.') - ? t('plugin_wallet_transaction_rejected') - : state.error.code === JSON_RPC_ErrorCode.INTERNAL_ERROR || - state.error.message.includes(`"code":${JSON_RPC_ErrorCode.INTERNAL_ERROR}`) || - (state.error.code && - state.error.code <= JSON_RPC_ErrorCode.SERVER_ERROR_RANGE_START && - state.error.code >= JSON_RPC_ErrorCode.SERVER_ERROR_RANGE_END) - ? t('plugin_wallet_transaction_server_error') - : state.error.message - } + {state.error.message} ) : null} diff --git a/packages/mask/src/plugins/Wallet/SNSAdaptor/TransactionSnackbar/index.tsx b/packages/mask/src/plugins/Wallet/SNSAdaptor/TransactionSnackbar/index.tsx index 37832698ff82..f65ad417551e 100644 --- a/packages/mask/src/plugins/Wallet/SNSAdaptor/TransactionSnackbar/index.tsx +++ b/packages/mask/src/plugins/Wallet/SNSAdaptor/TransactionSnackbar/index.tsx @@ -127,7 +127,7 @@ export function TransactionSnackbar() { if (progress.state.type === TransactionStateType.FAILED) { showSingletonSnackbar(t('plugin_wallet_snackbar_swap_token'), { ...config, - ...{ message: getFullMessage('Transaction rejected', hash) }, + ...{ message: getFullMessage('Transaction failed', hash) }, }) return } diff --git a/packages/mask/src/plugins/Wallet/constants.ts b/packages/mask/src/plugins/Wallet/constants.ts index 1730a5fa31e2..8d2236530d36 100644 --- a/packages/mask/src/plugins/Wallet/constants.ts +++ b/packages/mask/src/plugins/Wallet/constants.ts @@ -1,7 +1,7 @@ export { PLUGIN_ID, HD_PATH_WITHOUT_INDEX_ETHEREUM, - JSON_RPC_ErrorCode, + JSON_RPC_ERROR_CODE, UPDATE_CHAIN_STATE_DELAY, } from '@masknet/plugin-wallet' diff --git a/packages/mask/src/plugins/Wallet/services/transaction/database.ts b/packages/mask/src/plugins/Wallet/services/transaction/database.ts index 55be91511440..3678ac111846 100644 --- a/packages/mask/src/plugins/Wallet/services/transaction/database.ts +++ b/packages/mask/src/plugins/Wallet/services/transaction/database.ts @@ -61,15 +61,18 @@ export async function replaceRecentTransaction( address: string, oldHash: string, newHash: string, - payload?: JsonRpcPayload, + newPayload?: JsonRpcPayload, ) { const now = new Date() const recordId = getRecordId(chainId, address) const chunk = await PluginDB.get('recent-transactions', recordId) const transaction = chunk?.transactions.find((x) => x.hash === oldHash) if (!transaction) throw new Error('Failed to find the old transaction.') + if (transaction.hash === newHash) return transaction.hashReplacement = newHash - transaction.payloadReplacement = payload + if (newPayload) { + transaction.payloadReplacement = newPayload + } await PluginDB.add({ type: 'recent-transactions', id: recordId, diff --git a/packages/mask/src/plugins/Wallet/services/transaction/helpers.ts b/packages/mask/src/plugins/Wallet/services/transaction/helpers.ts index 331f36b491cc..be93bd0dafe9 100644 --- a/packages/mask/src/plugins/Wallet/services/transaction/helpers.ts +++ b/packages/mask/src/plugins/Wallet/services/transaction/helpers.ts @@ -72,14 +72,14 @@ export function getPayloadId(payload: JsonRpcPayload) { export function getTransactionId(transaction: Transaction | null) { if (!transaction) return '' const { from, to, input, value } = transaction - return sha3([from, to, input || '0x0', toHex(value) || '0x0'].join('_')) ?? '' + return sha3([from, to, input || '0x0', toHex(value || '0x0') || '0x0'].join('_')) ?? '' } export function getReceiptStatus(receipt: TransactionReceipt | null) { if (!receipt) return TransactionStatusType.NOT_DEPEND const status = receipt.status as unknown as string - if (receipt.status === false || ['0x', '0x0'].includes(status)) return TransactionStatusType.FAILED - if (receipt.status === true || ['0x1'].includes(status)) { + if (receipt.status === false || ['0', '0x', '0x0'].includes(status)) return TransactionStatusType.FAILED + if (receipt.status === true || ['1', '0x1'].includes(status)) { if (isSameAddress(receipt.from, receipt.to)) return TransactionStatusType.CANCELLED return TransactionStatusType.SUCCEED } diff --git a/packages/mask/src/plugins/Wallet/services/transaction/index.ts b/packages/mask/src/plugins/Wallet/services/transaction/index.ts index ff3d61da06c3..b02453de9acb 100644 --- a/packages/mask/src/plugins/Wallet/services/transaction/index.ts +++ b/packages/mask/src/plugins/Wallet/services/transaction/index.ts @@ -12,6 +12,7 @@ export * from './watcher' export interface RecentTransaction { at: Date hash: string + hashReplacement?: string status: TransactionStatusType receipt?: TransactionReceipt | null payload?: JsonRpcPayload @@ -56,14 +57,17 @@ export async function getRecentTransactions(chainId: ChainId, address: string): (await (hashReplacement ? watcher.getReceipt(chainId, hashReplacement) : null)) // if it cannot found receipt, then start the watching progress + // in case the user just refreshed the background page if (!receipt) { - watcher.watchTransaction(chainId, hash) - if (hashReplacement) watcher.watchTransaction(chainId, hashReplacement) + watcher.watchTransaction(chainId, hash, payload) + if (hashReplacement && payloadReplacement) + watcher.watchTransaction(chainId, hashReplacement, payloadReplacement) } return { at, - hash: receipt?.transactionHash ?? hash, + hash, + hashReplacement, status: helpers.getReceiptStatus(receipt), receipt, payload, diff --git a/packages/mask/src/plugins/Wallet/services/transaction/watcher.ts b/packages/mask/src/plugins/Wallet/services/transaction/watcher.ts index 58cde9ad15eb..9fcc17b748d4 100644 --- a/packages/mask/src/plugins/Wallet/services/transaction/watcher.ts +++ b/packages/mask/src/plugins/Wallet/services/transaction/watcher.ts @@ -1,30 +1,80 @@ +import { first } from 'lodash-unified' import type { TransactionReceipt } from 'web3-core' +import type { JsonRpcPayload } from 'web3-core-helpers' import { WalletMessages } from '@masknet/plugin-wallet' -import { ChainId, TransactionStateType } from '@masknet/web3-shared-evm' +import { Explorer } from '@masknet/web3-providers' +import { ChainId, getExplorerConstants, isSameAddress, TransactionStateType } from '@masknet/web3-shared-evm' import * as EthereumService from '../../../../extension/background-script/EthereumService' import * as progress from './progress' import * as helpers from './helpers' import { currentChainIdSettings } from '../../settings' +import { WalletRPC } from '../../messages' -let timer: ReturnType | null = null -const WATCHED_TRANSACTION_CHECK_DELAY = 15 * 1000 // 15s -const WATCHED_TRANSACTION_MAP = new Map< - ChainId, - Map< - string, - { - at: number - receipt: Promise | null - } - > ->() -const WATCHED_TRANSACTIONS_SIZE = 40 - -function getTransactionMap(chainId: ChainId) { - if (!WATCHED_TRANSACTION_MAP.has(chainId)) WATCHED_TRANSACTION_MAP.set(chainId, new Map()) - return WATCHED_TRANSACTION_MAP.get(chainId)! +interface StorageItem { + at: number + limits: number + payload: JsonRpcPayload + receipt: Promise | null } +class Storage { + static MAX_ITEM_SIZE = 40 + + private map = new Map>() + + private getStorage(chainId: ChainId) { + if (!this.map.has(chainId)) this.map.set(chainId, new Map()) + return this.map.get(chainId)! + } + + public hasItem(chainId: ChainId, hash: string) { + return this.getStorage(chainId).has(hash) + } + + public getItem(chainId: ChainId, hash: string) { + return this.getStorage(chainId).get(hash) + } + + public setItem(chainId: ChainId, hash: string, transaction: StorageItem) { + this.getStorage(chainId).set(hash, transaction) + } + + public removeItem(chainId: ChainId, hash: string) { + this.getStorage(chainId).delete(hash) + } + + public getItems(chainId: ChainId) { + const map = this.getStorage(chainId) + return map ? [...map.entries()].sort(([, a], [, z]) => z.at - a.at) : [] + } + + public getWatched(chainId: ChainId) { + return this.getItems(chainId).slice(0, Storage.MAX_ITEM_SIZE) + } + + public getUnwatched(chainId: ChainId) { + return this.getItems(chainId).slice(Storage.MAX_ITEM_SIZE) + } + + public getWatchedAccounts(chainId: ChainId) { + return this.getWatched(chainId) + .map(([_, transaction]) => helpers.getPayloadFrom(transaction.payload)) + .filter(Boolean) as string[] + } + + public getUnwatchedAccounts(chainId: ChainId) { + return this.getUnwatched(chainId) + .map(([_, transaction]) => helpers.getPayloadFrom(transaction.payload)) + .filter(Boolean) as string[] + } +} + +let timer: NodeJS.Timer | null = null +const storage = new Storage() +const CHECK_TIMES = 30 +const CHECK_DELAY = 30 * 1000 // seconds +const CHECK_LATEST_SIZE = 5 + async function getTransactionReceipt(chainId: ChainId, hash: string) { try { const transaction = await EthereumService.getTransactionByHash(hash, { @@ -51,51 +101,131 @@ async function getTransactionReceipt(chainId: ChainId, hash: string) { } } -async function checkReceipt() { - if (timer !== null) { - clearTimeout(timer) - timer = null - } - - const chainId = currentChainIdSettings.value - const map = getTransactionMap(chainId) - const transactions = [...map.entries()].sort(([, a], [, z]) => z.at - a.at) - const watchedTransactions = transactions.slice(0, WATCHED_TRANSACTIONS_SIZE) - const unwatchedTransactions = transactions.slice(WATCHED_TRANSACTIONS_SIZE) - unwatchedTransactions.forEach(([hash]) => unwatchTransaction(chainId, hash)) - - const checkResult = await Promise.allSettled( - watchedTransactions.map(async ([hash, transaction]) => { - const receipt = await map.get(hash)?.receipt - if (receipt) return true - map.set(hash, { - at: transaction.at, +async function checkReceipt(chainId: ChainId) { + await Promise.allSettled( + storage.getWatched(chainId).map(async ([hash, transaction]) => { + const receipt = await storage.getItem(chainId, hash)?.receipt + if (receipt) return + storage.setItem(chainId, hash, { + ...transaction, receipt: getTransactionReceipt(chainId, hash), }) - return false }), ) +} + +async function checkAccount(chainId: ChainId, account: string) { + const { API_KEYS = [], EXPLORER_API = '' } = getExplorerConstants(chainId) + + const watchedTransactions = storage.getWatched(chainId) + const latestTransactions = await Explorer.getLatestTransactions(account, EXPLORER_API, { + offset: CHECK_LATEST_SIZE, + apikey: first(API_KEYS), + }) + + for (const latestTransaction of latestTransactions) { + const [watchedHash, watchedTransaction] = + watchedTransactions.find(([hash, transaction]) => { + // the transaction hash exact matched + if (latestTransaction.hash === hash) return true + + // the transaction signature id exact matched + if (!transaction.payload) return false + if (helpers.getTransactionId(latestTransaction) === helpers.getPayloadId(transaction.payload)) + return true + + // the transaction nonce exact matched + const config = helpers.getPayloadConfig(transaction.payload) + if (!config) return false + return ( + isSameAddress(latestTransaction.from, config.from as string) && + latestTransaction.nonce === config.nonce + ) + }) ?? [] + + if (!watchedHash || !watchedTransaction?.payload || watchedHash === latestTransaction.hash) continue + + // replace the original transaction in DB + await WalletRPC.replaceRecentTransaction( + chainId, + account, + watchedHash, + latestTransaction.hash, + watchedTransaction.payload, + ) + + // update receipt in cache + storage.removeItem(chainId, watchedHash) + storage.setItem(chainId, latestTransaction.hash, { + ...watchedTransaction, + payload: helpers.toPayload(latestTransaction), + receipt: getTransactionReceipt(chainId, latestTransaction.hash), + }) + } +} + +async function check() { + // stop any pending task + stopCheck() + + const chainId = currentChainIdSettings.value + + // unwatch legacy transactions + storage.getUnwatched(chainId).forEach(([hash]) => unwatchTransaction(chainId, hash)) + + // update limits + storage.getWatched(chainId).forEach(([hash, transaction]) => { + storage.setItem(chainId, hash, { + ...transaction, + limits: Math.max(0, transaction.limits - 1), + }) + }) + + try { + await checkReceipt(chainId) + for (const account of storage.getWatchedAccounts(chainId)) await checkAccount(chainId, account) + } catch (error) { + // do nothing + } + + // check if all transaction receipts were found + const allSettled = await Promise.allSettled( + storage.getWatched(chainId).map(([, transaction]) => transaction.receipt), + ) + if (allSettled.every((x) => x.status === 'fulfilled' && x.value)) return + + // kick to the next round + startCheck(true) +} + +function startCheck(force: boolean) { + if (force) stopCheck() + if (timer === null) { + timer = setTimeout(check, CHECK_DELAY) + } +} - if (checkResult.every((x) => x.status === 'fulfilled' && x.value)) return +function stopCheck() { if (timer !== null) clearTimeout(timer) - timer = setTimeout(checkReceipt, WATCHED_TRANSACTION_CHECK_DELAY) + timer = null } export async function getReceipt(chainId: ChainId, hash: string) { - return getTransactionMap(chainId).get(hash)?.receipt ?? null + return storage.getItem(chainId, hash)?.receipt ?? null } -export async function watchTransaction(chainId: ChainId, hash: string) { - const map = getTransactionMap(chainId) - if (!map.has(hash)) { - map.set(hash, { +export async function watchTransaction(chainId: ChainId, hash: string, payload: JsonRpcPayload) { + if (!storage.hasItem(chainId, hash)) { + storage.setItem(chainId, hash, { at: Date.now(), - receipt: getTransactionReceipt(chainId, hash), + payload, + limits: CHECK_TIMES, + receipt: Promise.resolve(null), }) } - if (timer === null) timer = setTimeout(checkReceipt, WATCHED_TRANSACTION_CHECK_DELAY) + startCheck(false) } export function unwatchTransaction(chainId: ChainId, hash: string) { - getTransactionMap(chainId).delete(hash) + storage.removeItem(chainId, hash) } diff --git a/packages/plugins/Wallet/src/constants.ts b/packages/plugins/Wallet/src/constants.ts index f153d83a1ba7..0062e0060e36 100644 --- a/packages/plugins/Wallet/src/constants.ts +++ b/packages/plugins/Wallet/src/constants.ts @@ -13,7 +13,10 @@ export const HD_PATH_WITHOUT_INDEX_ETHEREUM = "m/44'/60'/0'/0" export const MAX_DERIVE_COUNT = 99 // https://www.jsonrpc.org/specification#error_object -export enum JSON_RPC_ErrorCode { +export enum JSON_RPC_ERROR_CODE { + INVALID_REQUEST = -32600, + METHOD_NOT_FOUND = 32601, + INVALID_PARAMS = -32602, INTERNAL_ERROR = -32603, SERVER_ERROR_RANGE_START = -32000, SERVER_ERROR_RANGE_END = -32099, diff --git a/packages/web3-constants/evm/explorer.json b/packages/web3-constants/evm/explorer.json index 26b1e74b38e1..187da84cfb05 100644 --- a/packages/web3-constants/evm/explorer.json +++ b/packages/web3-constants/evm/explorer.json @@ -1,4 +1,20 @@ { + "API_KEYS": { + "Mainnet": ["26GHYKIAZREN4HWJ5NCICTGKC183IGXY9A"], + "Ropsten": [], + "Rinkeby": [], + "Kovan": [], + "Gorli": [], + "BSC": ["5FQ5JHS1BEK4186QCZASE3SD23YD8FIMM8"], + "BSCT": [], + "Matic": ["5HVFKYFPQXWNQ2TFHCAYJJZ3YIYPWQ5HP8"], + "Mumbai": [], + "Arbitrum": ["BE8VU1P9FUKRT15FBHKQMU84829VITWWF2"], + "Arbitrum_Rinkeby": [], + "xDai": [], + "Celo": [], + "Fantom": ["AST4WWPNEYDURUXG2GH32JZMYWEFDP999S"] + }, "EXPLORER_API": { "Mainnet": "https://api.etherscan.io/api", "Ropsten": "", @@ -9,26 +25,10 @@ "BSCT": "", "Matic": "https://api.polygonscan.com/api", "Mumbai": "", - "Arbitrum": "", + "Arbitrum": "https://api.arbiscan.io/api", "Arbitrum_Rinkeby": "", - "xDai": "", - "Celo": "", + "xDai": "https://blockscout.com/xdai/mainnet/api", + "Celo": "https://explorer.celo.org/api", "Fantom": "https://api.ftmscan.com/api" - }, - "EXPLORER_API_KEY": { - "Mainnet": "99IX9MBZKG8FYQUECXDNDPATGVDDH6JCWW", - "Ropsten": "", - "Rinkeby": "", - "Kovan": "", - "Gorli": "", - "BSC": "7IJXTFIFB28ARWKZVA78GS29TT5YG82M5Z", - "BSCT": "", - "Matic": "PEMG6SUGQ71X55MVW2A1YK5J49V8VV6RF9", - "Mumbai": "", - "Arbitrum": "", - "Arbitrum_Rinkeby": "", - "xDai": "", - "Celo": "", - "Fantom": "" } } diff --git a/packages/web3-providers/src/explorer/helpers.ts b/packages/web3-providers/src/explorer/helpers.ts new file mode 100644 index 000000000000..989ffcd1a7f9 --- /dev/null +++ b/packages/web3-providers/src/explorer/helpers.ts @@ -0,0 +1,23 @@ +import type { Transaction } from './types' +import type { ExplorerAPI } from '..' + +export function toTransaction(transaction: Transaction): ExplorerAPI.Transaction & { + status: '0' | '1' + confirmations: number +} { + return { + nonce: Number.parseInt(transaction.nonce, 10), + blockHash: transaction.blockHash, + blockNumber: Number.parseInt(transaction.blockNumber, 10), + from: transaction.from, + to: transaction.to, + gas: Number.parseInt(transaction.gas, 10), + gasPrice: transaction.gasPrice, + hash: transaction.hash, + input: transaction.input, + transactionIndex: Number.parseInt(transaction.transactionIndex, 10), + value: transaction.value, + status: transaction.txreceipt_status, + confirmations: Number.parseInt(transaction.confirmations, 10), + } +} diff --git a/packages/web3-providers/src/explorer/index.ts b/packages/web3-providers/src/explorer/index.ts new file mode 100644 index 000000000000..834d2da5778e --- /dev/null +++ b/packages/web3-providers/src/explorer/index.ts @@ -0,0 +1,32 @@ +import urlcat from 'urlcat' +import type { ExplorerAPI } from '..' +import type { Transaction } from './types' +import { toTransaction } from './helpers' + +export class NativeExplorerAPI implements ExplorerAPI.Provider { + async getLatestTransactions( + account: string, + url: string, + { offset = 10, apikey }: ExplorerAPI.PageInfo = {}, + ): Promise { + const response = await fetch( + urlcat(url, { + module: 'account', + action: 'txlist', + address: account.toLowerCase(), + startBlock: 0, + endblock: 999999999999, + page: 1, + offset, + sort: 'desc', + apikey, + }), + ) + const rawTransactions = (await response.json()) as { + message: string + result?: Transaction[] + status: '0' | '1' + } + return rawTransactions.result?.map(toTransaction) ?? [] + } +} diff --git a/packages/web3-providers/src/explorer/types.ts b/packages/web3-providers/src/explorer/types.ts new file mode 100644 index 000000000000..1e0668b354d9 --- /dev/null +++ b/packages/web3-providers/src/explorer/types.ts @@ -0,0 +1,21 @@ +export interface Transaction { + blockNumber: string + timeStamp: string + hash: string + nonce: string + blockHash: string + transactionIndex: string + from: string + to: string + value: string + gas: string + gasPrice: string + isError: string + errorCode: string + txreceipt_status: '0' | '1' + input: string + contractAddress: string + cumulativeGasUsed: string + gasUsed: string + confirmations: string +} diff --git a/packages/web3-providers/src/index.ts b/packages/web3-providers/src/index.ts index 26f1517af6de..1751fa75f898 100644 --- a/packages/web3-providers/src/index.ts +++ b/packages/web3-providers/src/index.ts @@ -2,6 +2,7 @@ import { CoinGeckoAPI } from './coingecko' import { OpenSeaAPI } from './opensea' import { RaribleAPI } from './rarible' import { NFTScanAPI } from './NFTScan' +import { NativeExplorerAPI } from './explorer' import { RSS3API } from './rss3' export * from './types' @@ -11,6 +12,7 @@ export const OpenSea = new OpenSeaAPI() export const Rarible = new RaribleAPI() export const NFTScan = new NFTScanAPI() export const CoinGecko = new CoinGeckoAPI() +export const Explorer = new NativeExplorerAPI() export const RSS3 = new RSS3API() // Method for provider proxy diff --git a/packages/web3-providers/src/types.ts b/packages/web3-providers/src/types.ts index 6ce0c55d3977..b4978c8a5b25 100644 --- a/packages/web3-providers/src/types.ts +++ b/packages/web3-providers/src/types.ts @@ -1,3 +1,4 @@ +import type { Transaction as Web3Transaction } from 'web3-core' import type RSS3 from 'rss3-next' import type { CurrencyType } from '@masknet/plugin-infra' import type { @@ -8,6 +9,21 @@ import type { NativeTokenDetailed, } from '@masknet/web3-shared-evm' +export namespace ExplorerAPI { + export type Transaction = Web3Transaction & { + status: '0' | '1' + confirmations: number + } + + export interface PageInfo { + offset?: number + apikey?: string + } + + export interface Provider { + getLatestTransactions(account: string, url: string, pageInfo?: PageInfo): Promise + } +} export namespace RSS3BaseAPI { export interface GeneralAsset { platform: string diff --git a/packages/web3-shared/evm/constants/index.ts b/packages/web3-shared/evm/constants/index.ts index c59b2170c154..3e0aae6421eb 100644 --- a/packages/web3-shared/evm/constants/index.ts +++ b/packages/web3-shared/evm/constants/index.ts @@ -14,16 +14,24 @@ import Trader from '@masknet/web3-constants/evm/trader.json' import Trending from '@masknet/web3-constants/evm/trending.json' import MaskBox from '@masknet/web3-constants/evm/mask-box.json' import RPC from '@masknet/web3-constants/evm/rpc.json' +import Explorer from '@masknet/web3-constants/evm/explorer.json' import PoolTogether from '@masknet/web3-constants/evm/pooltogether.json' import TokenList from '@masknet/web3-constants/evm/token-list.json' import TokenAssetBaseURL from '@masknet/web3-constants/evm/token-asset-base-url.json' import GoodGhosting from '@masknet/web3-constants/evm/good-ghosting.json' import SpaceStationGalaxy from '@masknet/web3-constants/evm/space-station-galaxy.json' import OpenseaAPI from '@masknet/web3-constants/evm/opensea-api.json' -import Explorer from '@masknet/web3-constants/evm/explorer.json' import CryptoArtAI from '@masknet/web3-constants/evm/cryptoartai.json' import { hookTransform, transform, transformFromJSON } from './utils' +function getEnvConstants(key: string) { + try { + return process.env[key] ?? '' + } catch { + return '' + } +} + export { ZERO_ADDRESS, FAKE_SIGN_PASSWORD, EthereumNameType } from './specific' export const getAirdropConstants = transform(Airdrop) @@ -68,14 +76,12 @@ export const useTrendingConstants = hookTransform(getTrendingConstants) export const getMaskBoxConstants = transform(MaskBox) export const useMaskBoxConstants = hookTransform(getMaskBoxConstants) -let WEB3_CONSTANTS_RPC = '' -try { - WEB3_CONSTANTS_RPC = process.env.WEB3_CONSTANTS_RPC ?? '' -} catch {} - -export const getRPCConstants = transformFromJSON(WEB3_CONSTANTS_RPC, RPC) +export const getRPCConstants = transformFromJSON(getEnvConstants('WEB3_CONSTANTS_RPC'), RPC) export const useRPCConstants = hookTransform(getRPCConstants) +export const getExplorerConstants = transformFromJSON(getEnvConstants('WEB3_CONSTANTS_EXPLORER'), Explorer) +export const useExplorerConstants = hookTransform(getExplorerConstants) + export const getTokenListConstants = transform(TokenList) export const useTokenListConstants = hookTransform(getTokenListConstants) @@ -90,12 +96,10 @@ export const useGoodGhostingConstants = hookTransform(getGoodGhostingConstants) export const getSpaceStationGalaxyConstants = transform(SpaceStationGalaxy) export const useSpaceStationGalaxyConstants = hookTransform(getSpaceStationGalaxyConstants) + export const getOpenseaAPIConstants = transform(OpenseaAPI) export const useOpenseaAPIConstants = hookTransform(getOpenseaAPIConstants) -export const getExplorerConstants = transform(Explorer) -export const useExplorerConstants = hookTransform(getExplorerConstants) - export const getCryptoArtAIConstants = transform(CryptoArtAI) export const useCryptoArtAIConstants = hookTransform(getCryptoArtAIConstants) diff --git a/packages/web3-shared/evm/utils/index.ts b/packages/web3-shared/evm/utils/index.ts index 96971829d131..223bf5607608 100644 --- a/packages/web3-shared/evm/utils/index.ts +++ b/packages/web3-shared/evm/utils/index.ts @@ -6,4 +6,5 @@ export * from './formatter' export * from './chainDetailed' export * from './transaction' export * from './domain' +export * from './payload' export * from './misc' diff --git a/packages/web3-shared/evm/utils/payload.ts b/packages/web3-shared/evm/utils/payload.ts new file mode 100644 index 000000000000..6266f39ee82c --- /dev/null +++ b/packages/web3-shared/evm/utils/payload.ts @@ -0,0 +1,58 @@ +import { first } from 'lodash-unified' +import type { JsonRpcPayload, JsonRpcResponse } from 'web3-core-helpers' +import { EthereumMethodType, EthereumTransactionConfig } from '../types' + +export function getPayloadChainId(payload: JsonRpcPayload) { + switch (payload.method) { + // here are methods that contracts may emit + case EthereumMethodType.ETH_CALL: + case EthereumMethodType.ETH_ESTIMATE_GAS: + case EthereumMethodType.ETH_SEND_TRANSACTION: + const config = first(payload.params) as { chainId?: string } | undefined + return typeof config?.chainId === 'string' ? Number.parseInt(config.chainId, 16) || undefined : undefined + default: + return + } +} + +export function getPayloadConfig(payload: JsonRpcPayload) { + switch (payload.method) { + case EthereumMethodType.ETH_SEND_TRANSACTION: { + const [config] = payload.params as [EthereumTransactionConfig] + return config + } + case EthereumMethodType.MASK_REPLACE_TRANSACTION: { + const [, config] = payload.params as [string, EthereumTransactionConfig] + return config + } + default: + return + } +} + +export function getPayloadHash(payload: JsonRpcPayload) { + switch (payload.method) { + case EthereumMethodType.ETH_SEND_TRANSACTION: { + return '' + } + case EthereumMethodType.MASK_REPLACE_TRANSACTION: { + const [hash] = payload.params as [string] + return hash + } + default: + return '' + } +} + +export function getPayloadNonce(payload: JsonRpcPayload) { + const config = getPayloadConfig(payload) + return config?.nonce +} + +export function getTransactionHash(response?: JsonRpcResponse) { + if (!response) return '' + const hash = response?.result as string | undefined + if (typeof hash !== 'string') return '' + if (!/^0x([\dA-Fa-f]{64})$/.test(hash)) return '' + return hash +}