diff --git a/README.md b/README.md index 996979bc30..9bc1c38305 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,9 @@ $ yarn start # start a continuous webpack build that will auto-update with chang Once the build is running, you can install the extension in your browser of choice: - [Firefox instructions](https://extensionworkshop.com/documentation/develop/temporary-installation-in-firefox/) -- [Chrome, Brave, and Opera instructions](https://developer.chrome.com/docs/extensions/mv3/getstarted/#manifest) +- [Chrome, Brave, Edge, and Opera instructions](https://developer.chrome.com/docs/extensions/mv3/getstarted/#manifest) - Note that these instructions are for Chrome, but substituting - `brave://extensions` or `opera://extensions` for `chrome://extensions` + `brave://extensions` or `edge://extensions` or `opera://extensions` for `chrome://extensions` depending on browser should get you to the same buttons. Extension bundles for each browser are in `dist/`. @@ -291,11 +291,13 @@ src/ # extension source files dist/ # output directory for builds brave/ # browser-specific - firefox/ # build - chrome/ # directories - brave.zip # browser-specific - firefox.zip # production - chrome.zip # bundles + chrome/ # build + edge/ # directories + firefox/ + brave.zip # browser-specific + chrome.zip # production + edge.zip # bundles + firefox.zip build-utils/ # build-related helpers, used in webpack.config.js *.js diff --git a/background/lib/asset-similarity.ts b/background/lib/asset-similarity.ts new file mode 100644 index 0000000000..4bcedfd5f8 --- /dev/null +++ b/background/lib/asset-similarity.ts @@ -0,0 +1,115 @@ +import { AnyAsset, isSmartContractFungibleAsset } from "../assets" +import { normalizeEVMAddress } from "./utils" + +/** + * Use heuristics to score two assets based on their metadata similarity. The + * higher the score, the more likely the asset metadata refers to the same + * asset. + * + * @param a - the first asset + * @param b - the second asset + * @return an integer score >= 0 + */ +export function scoreAssetSimilarity(a: AnyAsset, b: AnyAsset): number { + let score = 0 + if (a.symbol === b.symbol) { + score += 1 + } + if (a.name === b.name) { + score += 1 + } + if ("decimals" in a && "decimals" in b) { + if (a.decimals === b.decimals) { + score += 1 + } else { + score -= 1 + } + } else if ("decimals" in a || "decimals" in b) { + score -= 1 + } + if ("homeNetwork" in a && "homeNetwork" in b) { + const sameNetwork = + a.homeNetwork.name === b.homeNetwork.name && + a.homeNetwork.chainID === b.homeNetwork.chainID + if (sameNetwork) { + score += 1 + } else { + score -= 1 + } + } else if ("homeNetwork" in a || "homeNetwork" in b) { + score -= 1 + } + return score +} + +/** + * Returns a prioritized list of similarity keys, which are strings that can be + * used to rapidly correlate assets. All similarity keys should be further + * checked using {@link assetsSufficientlySimilar}, as a similarity key match + * is designed to narrow the field rather than guarantee asset sameness. + */ +export function prioritizedAssetSimilarityKeys(asset: AnyAsset): string[] { + let similarityKeys: string[] = [] + + if (isSmartContractFungibleAsset(asset)) { + const normalizedContractAddressAndNetwork = `${normalizeEVMAddress( + asset.contractAddress + )}-${asset.homeNetwork.chainID}` + + similarityKeys = [...similarityKeys, normalizedContractAddressAndNetwork] + } + + return [...similarityKeys, asset.symbol] +} + +/** + * Score a set of assets by similarity to a search asset, returning the most + * similiar asset to the search asset as long as it is above a base similiarity + * score, or null. + * + * @see scoreAssetSimilarity The way asset similarity is computed. + * + * @param assetToFind The asset we're trying to find. + * @param assets The array of assets in which to search for `assetToFind`. + * @param minimumSimilarityScore The minimum similarity score to consider as a + * match. + */ +export function findClosestAssetIndex( + assetToFind: AnyAsset, + assets: AnyAsset[], + minimumSimilarityScore = 2 +): number | undefined { + const [bestScore, index] = assets.reduce( + ([runningScore, runningScoreIndex], asset, i) => { + const score = scoreAssetSimilarity(assetToFind, asset) + if (score > runningScore) { + return [score, i] + } + return [runningScore, runningScoreIndex] + }, + [0, -1] + ) + + if (bestScore >= minimumSimilarityScore && index >= 0) { + return index + } + + return undefined +} + +/** + * Merges the information about two assets. Mostly focused on merging metadata. + */ +export function mergeAssets(asset1: AnyAsset, asset2: AnyAsset): AnyAsset { + return { + ...asset1, + metadata: { + ...asset1.metadata, + ...asset2.metadata, + tokenLists: + asset1.metadata?.tokenLists?.concat( + asset2.metadata?.tokenLists ?? [] + ) ?? [], + }, + } +} diff --git a/background/lib/erc20.ts b/background/lib/erc20.ts index 7f2b555905..862d38519e 100644 --- a/background/lib/erc20.ts +++ b/background/lib/erc20.ts @@ -1,12 +1,12 @@ import { AlchemyProvider, BaseProvider } from "@ethersproject/providers" -import { BigNumber, ethers, logger } from "ethers" +import { BigNumber, ethers } from "ethers" import { EventFragment, Fragment, FunctionFragment, TransactionDescription, } from "ethers/lib/utils" -import { getTokenBalances, getTokenMetadata } from "./alchemy" +import { getTokenBalances } from "./alchemy" import { AccountBalance, AddressOnNetwork } from "../accounts" import { SmartContractFungibleAsset } from "../assets" import { EVMLog } from "../networks" diff --git a/background/lib/tokenList.ts b/background/lib/token-lists.ts similarity index 54% rename from background/lib/tokenList.ts rename to background/lib/token-lists.ts index 6008b94c11..a20ec14180 100644 --- a/background/lib/tokenList.ts +++ b/background/lib/token-lists.ts @@ -1,14 +1,16 @@ import { TokenList } from "@uniswap/token-lists" -import { normalizeEVMAddress } from "./utils" import { FungibleAsset, - isSmartContractFungibleAsset, SmartContractFungibleAsset, TokenListAndReference, } from "../assets" import { isValidUniswapTokenListResponse } from "./validate" import { EVMNetwork } from "../networks" +import { + findClosestAssetIndex, + prioritizedAssetSimilarityKeys, +} from "./asset-similarity" export async function fetchAndValidateTokenList( url: string @@ -66,89 +68,67 @@ function tokenListToFungibleAssetsForNetwork( /** * Merges the given asset lists into a single deduplicated array. + * + * Note that currently, two smart contract assets that are the same but don't + * share a contract address (e.g., a token A that points to a contract address + * and a token A that points to a proxy A's contract address) will not be + * considered the same for merging purposes. */ export function mergeAssets( ...assetLists: T[][] ): T[] { function tokenReducer( - seenAssetsBy: { - contractAddressAndNetwork: { - [contractAddressAndNetwork: string]: SmartContractFungibleAsset - } - symbol: { [symbol: string]: T } + seenAssetsBySimilarityKey: { + [similarityKey: string]: T[] }, asset: T ) { - const updatedAssetsBy = { - contractAddressAndNetwork: { ...seenAssetsBy.contractAddressAndNetwork }, - symbol: { ...seenAssetsBy.symbol }, - } + const updatedSeenAssetsBySimilarityKey = { ...seenAssetsBySimilarityKey } - if (isSmartContractFungibleAsset(asset)) { - const normalizedContractAddressAndNetwork = - `${normalizeEVMAddress(asset.contractAddress)}-${ - asset.homeNetwork.chainID - }` ?? asset.homeNetwork.name - const existingAsset = - updatedAssetsBy.contractAddressAndNetwork[ - normalizedContractAddressAndNetwork - ] - - if (typeof existingAsset !== "undefined") { - updatedAssetsBy.contractAddressAndNetwork[ - normalizedContractAddressAndNetwork - ] = { - ...existingAsset, - metadata: { - ...existingAsset.metadata, - ...asset.metadata, - tokenLists: - existingAsset.metadata?.tokenLists?.concat( - asset.metadata?.tokenLists ?? [] - ) ?? [], - }, - } - } else { - updatedAssetsBy.contractAddressAndNetwork[ - normalizedContractAddressAndNetwork - ] = asset - } - } else if (asset.symbol in updatedAssetsBy.symbol) { - const original = updatedAssetsBy.symbol[asset.symbol] - updatedAssetsBy.symbol[asset.symbol] = { - ...original, + const similarityKeys = prioritizedAssetSimilarityKeys(asset) + + // For now, only use the highest-priority similarity key with no fallback. + const referenceKey = similarityKeys[0] + // Initialize if needed. + updatedSeenAssetsBySimilarityKey[referenceKey] ??= [] + + // For each key, determine where a close asset match exists. + const matchingAssetIndex = findClosestAssetIndex( + asset, + updatedSeenAssetsBySimilarityKey[referenceKey] + ) + + if (typeof matchingAssetIndex !== "undefined") { + // Merge the matching asset with this new one. + const matchingAsset = + updatedSeenAssetsBySimilarityKey[referenceKey][matchingAssetIndex] + + updatedSeenAssetsBySimilarityKey[referenceKey][matchingAssetIndex] = { + ...matchingAsset, metadata: { - ...original.metadata, + ...matchingAsset.metadata, ...asset.metadata, tokenLists: - original.metadata?.tokenLists?.concat( + matchingAsset.metadata?.tokenLists?.concat( asset.metadata?.tokenLists ?? [] ) ?? [], }, } } else { - updatedAssetsBy.symbol[asset.symbol] = asset + updatedSeenAssetsBySimilarityKey[referenceKey].push(asset) } - return updatedAssetsBy + return updatedSeenAssetsBySimilarityKey } - const mergedAssetsBy = assetLists.flat().reduce(tokenReducer, { - contractAddressAndNetwork: {}, - symbol: {}, - }) - const mergedAssets = Object.values(mergedAssetsBy.symbol).concat( - // Because the inputs to the function conform to T[], if T is not a subtype - // of SmartContractFungibleAsset, this will be an empty array. As such, we - // can safely do this cast. - Object.values(mergedAssetsBy.contractAddressAndNetwork) as unknown as T[] - ) + const mergedAssetsBy = assetLists.flat().reduce(tokenReducer, {}) + const mergedAssets = Object.values(mergedAssetsBy).flat() - return mergedAssets.sort((a, b) => - (a.metadata?.tokenLists?.length || 0) > - (b.metadata?.tokenLists?.length || 0) - ? 1 - : -1 + // Sort the merged assets by the number of token lists they appear in. + return mergedAssets.sort( + (a, b) => + (a.metadata?.tokenLists?.length || 0) - + (b.metadata?.tokenLists?.length || 0) ) } diff --git a/background/main.ts b/background/main.ts index c3ff3844a5..d8973c8d13 100644 --- a/background/main.ts +++ b/background/main.ts @@ -29,7 +29,6 @@ import { AddressOnNetwork, NameOnNetwork } from "./accounts" import rootReducer from "./redux-slices" import { loadAccount, - blockSeen, updateAccountBalance, updateENSName, updateENSAvatar, @@ -43,6 +42,7 @@ import { updateKeyrings, setKeyringToVerify, } from "./redux-slices/keyrings" +import { blockSeen } from "./redux-slices/networks" import { initializationLoadingTimeHitLimit, emitter as uiSliceEmitter, @@ -101,7 +101,7 @@ const devToolsSanitizer = (input: unknown) => { // The version of persisted Redux state the extension is expecting. Any previous // state without this version, or with a lower version, ought to be migrated. -const REDUX_STATE_VERSION = 3 +const REDUX_STATE_VERSION = 4 type Migration = (prevState: Record) => Record @@ -141,6 +141,54 @@ const REDUX_MIGRATIONS: { [version: number]: Migration } = { return newState }, + 4: (prevState: Record) => { + // Migrate the ETH-only block data in store.accounts.blocks[blockHeight] to + // a new networks slice. Block data is now network-specific, keyed by EVM + // chainID in store.networks.networkData[chainId].blocks + type OldState = { + account?: { + blocks?: { [blockHeight: number]: unknown } + } + } + type NetworkState = { + evm: { + [chainID: string]: { + blockHeight: number | null + blocks: { + [blockHeight: number]: unknown + } + } + } + } + + const oldState = prevState as OldState + + const networks: NetworkState = { + evm: { + "1": { + blocks: { ...oldState.account?.blocks }, + blockHeight: + Math.max( + ...Object.keys(oldState.account?.blocks ?? {}).map((s) => + parseInt(s, 10) + ) + ) || null, + }, + }, + } + + const { blocks, ...oldStateAccountWithoutBlocks } = oldState.account ?? { + blocks: undefined, + } + + return { + ...prevState, + // Drop blocks from account slice. + account: oldStateAccountWithoutBlocks, + // Add new networks slice data. + networks, + } + }, } // Migrate a previous version of the Redux state to that expected by the current @@ -549,7 +597,7 @@ export default class Main extends BaseService { async getAccountEthBalanceUncached(address: string): Promise { const amountBigNumber = - await this.chainService.pollingProviders.ethereum.getBalance(address) + await this.chainService.providers.ethereum.getBalance(address) return amountBigNumber.toBigInt() } @@ -616,6 +664,11 @@ export default class Main extends BaseService { "requestSignature", async ({ transaction, method }) => { if (HIDE_IMPORT_LEDGER) { + const network = this.chainService.resolveNetwork(transaction) + if (typeof network === "undefined") { + throw new Error(`Unknown chain ID ${transaction.chainID}.`) + } + const transactionWithNonce = await this.chainService.populateEVMTransactionNonce(transaction) @@ -638,7 +691,6 @@ export default class Main extends BaseService { } else { try { const signedTx = await this.signingService.signTransaction( - this.chainService.ethereumNetwork, transaction, method ) diff --git a/background/redux-slices/accounts.ts b/background/redux-slices/accounts.ts index 3a906624e1..5f0cfa9797 100644 --- a/background/redux-slices/accounts.ts +++ b/background/redux-slices/accounts.ts @@ -1,7 +1,7 @@ import { createSlice } from "@reduxjs/toolkit" import { createBackgroundAsyncThunk } from "./utils" import { AccountBalance, AddressOnNetwork, NameOnNetwork } from "../accounts" -import { AnyEVMBlock, Network } from "../networks" +import { Network } from "../networks" import { AnyAsset, AnyAssetAmount, SmartContractFungibleAsset } from "../assets" import { AssetMainCurrencyAmount, @@ -51,9 +51,6 @@ export type AccountState = { // TODO Adapt to use AccountNetwork, probably via a Map and custom serialization/deserialization. accountsData: { [address: string]: AccountData | "loading" } combinedData: CombinedAccountData - // TODO the blockHeight key should be changed to something - // compatible with the idea of multiple networks. - blocks: { [blockHeight: number]: AnyEVMBlock } } export type CombinedAccountData = { @@ -83,7 +80,6 @@ export const initialState = { totalMainCurrencyValue: "", assets: [], }, - blocks: {}, } as AccountState function newAccountData( @@ -140,9 +136,6 @@ const accountSlice = createSlice({ name: "account", initialState, reducers: { - blockSeen: (immerState, { payload: block }: { payload: AnyEVMBlock }) => { - immerState.blocks[block.blockHeight] = block - }, loadAccount: (state, { payload: accountToLoad }: { payload: string }) => { return state.accountsData[accountToLoad] ? state // If the account data already exists, the account is already loaded. @@ -256,7 +249,6 @@ export const { updateAccountBalance, updateENSName, updateENSAvatar, - blockSeen, } = accountSlice.actions export default accountSlice.reducer diff --git a/background/redux-slices/assets.ts b/background/redux-slices/assets.ts index 455be41adc..0225def0fe 100644 --- a/background/redux-slices/assets.ts +++ b/background/redux-slices/assets.ts @@ -1,5 +1,6 @@ import { createSelector, createSlice } from "@reduxjs/toolkit" import { AnyAsset, PricePoint } from "../assets" +import { findClosestAssetIndex } from "../lib/asset-similarity" import { normalizeEVMAddress } from "../lib/utils" type SingleAssetState = AnyAsset & { @@ -13,77 +14,6 @@ export type AssetsState = SingleAssetState[] export const initialState = [] as AssetsState -/* - * Use heuristics to score two assets based on their metadata similarity. The - * higher the score, the more likely the asset metadata refers to the same - * asset. - * - * @param a - the first asset - * @param b - the second asset - * @returns an integer score >= 0 - */ -function scoreAssetSimilarity(a: AnyAsset, b: AnyAsset): number { - let score = 0 - if (a.symbol === b.symbol) { - score += 1 - } - if (a.name === b.name) { - score += 1 - } - if ("decimals" in a && "decimals" in b) { - if (a.decimals === b.decimals) { - score += 1 - } else { - score -= 1 - } - } else if ("decimals" in a || "decimals" in b) { - score -= 1 - } - if ("homeNetwork" in a && "homeNetwork" in b) { - if ( - a.homeNetwork.name === b.homeNetwork.name && - a.homeNetwork.chainID === b.homeNetwork.chainID - ) { - score += 1 - } else { - score -= 1 - } - } else if ("homeNetwork" in a || "homeNetwork" in b) { - score -= 1 - } - return score -} - -/* - * Score all assets by similarity, returning the most similiar asset as long as - * it is above a base similiarity score, or null. - * - * @param baseAsset - the asset we're trying to find - * @param assets - an array of assets to sort - */ -function findClosestAsset( - baseAsset: AnyAsset, - assets: AnyAsset[], - minScore = 2 -): number | null { - const [bestScore, index] = assets.reduce( - ([runningScore, runningScoreIndex], asset, i) => { - const score = scoreAssetSimilarity(baseAsset, asset) - if (score > runningScore) { - return [score, i] - } - return [runningScore, runningScoreIndex] - }, - [0, -1] as [number, number] - ) - - if (bestScore >= minScore && index >= 0) { - return index - } - - return null -} - function prunePrices(prices: PricePoint[]): PricePoint[] { // TODO filter prices to daily in the past week, weekly in the past month, monthly in the past year const pricesToSort = prices.map<[number, PricePoint]>((pp) => [pp.time, pp]) @@ -113,18 +43,18 @@ function recentPricesFromArray( baseAsset: AnyAsset, prices: PricePoint[] ): SingleAssetState["recentPrices"] { - const pricesToSort = prices.map((pp) => [pp.time, pp]) + const pricesToSort = prices.map((pp) => [pp.time, pp] as const) pricesToSort.sort() return pricesToSort - .map((r) => r[1] as PricePoint) + .map((r) => r[1]) .reduce((agg: SingleAssetState["recentPrices"], pp: PricePoint) => { - const pricedAssetIndex = findClosestAsset(baseAsset, pp.pair) - if (pricedAssetIndex !== null) { - const pricedAsset = pp.pair[+(pricedAssetIndex === 0)] + const baseAssetIndex = findClosestAssetIndex(baseAsset, pp.pair) + if (baseAssetIndex !== null) { + const priceAsset = pp.pair[baseAssetIndex === 0 ? 1 : 0] const newAgg = { ...agg, } - newAgg[pricedAsset.symbol] = pp + newAgg[priceAsset.symbol] = pp return newAgg } return agg @@ -186,14 +116,10 @@ const assetsSlice = createSlice({ ) => { pricePoint.pair.forEach((pricedAsset) => { // find the asset metadata - const index = findClosestAsset(pricedAsset, [ - ...immerState, - ] as AnyAsset[]) - if (index !== null) { + const index = findClosestAssetIndex(pricedAsset, immerState) + if (typeof index !== "undefined") { // append to longer-running prices - const prices = prunePrices( - [...immerState[index].prices].concat([pricePoint]) - ) + const prices = prunePrices([...immerState[index].prices, pricePoint]) immerState[index].prices = prices // update recent prices for easy checks by symbol immerState[index].recentPrices = recentPricesFromArray( diff --git a/background/redux-slices/index.ts b/background/redux-slices/index.ts index c96941ddc1..d2699acf1f 100644 --- a/background/redux-slices/index.ts +++ b/background/redux-slices/index.ts @@ -6,6 +6,7 @@ import accountsReducer from "./accounts" import assetsReducer from "./assets" import activitiesReducer from "./activities" import keyringsReducer from "./keyrings" +import networksReducer from "./networks" import swapReducer from "./0x-swap" import transactionConstructionReducer from "./transaction-construction" import uiReducer from "./ui" @@ -19,6 +20,7 @@ const mainReducer = combineReducers({ assets: assetsReducer, activities: activitiesReducer, keyrings: keyringsReducer, + networks: networksReducer, swap: swapReducer, transactionConstruction: transactionConstructionReducer, ui: uiReducer, diff --git a/background/redux-slices/networks.ts b/background/redux-slices/networks.ts new file mode 100644 index 0000000000..b7700ee9b7 --- /dev/null +++ b/background/redux-slices/networks.ts @@ -0,0 +1,48 @@ +import { createSlice } from "@reduxjs/toolkit" + +import { AnyEVMBlock } from "../networks" + +type NetworkState = { + blocks: { [blockHeight: number]: AnyEVMBlock } + blockHeight: number | null +} + +export type NetworksState = { + evm: { + [chainID: string]: NetworkState + } +} + +export const initialState: NetworksState = { + evm: { + "1": { + blockHeight: null, + blocks: {}, + }, + }, +} + +const networksSlice = createSlice({ + name: "networks", + initialState, + reducers: { + blockSeen: (immerState, { payload: block }: { payload: AnyEVMBlock }) => { + if (!(block.network.chainID in immerState.evm)) { + immerState.evm[block.network.chainID] = { + blocks: {}, + blockHeight: block.blockHeight, + } + } else if ( + block.blockHeight > + (immerState.evm[block.network.chainID].blockHeight || 0) + ) { + immerState.evm[block.network.chainID].blockHeight = block.blockHeight + } + immerState.evm[block.network.chainID].blocks[block.blockHeight] = block + }, + }, +}) + +export const { blockSeen } = networksSlice.actions + +export default networksSlice.reducer diff --git a/background/redux-slices/selectors/activitiesSelectors.ts b/background/redux-slices/selectors/activitiesSelectors.ts index a8e4977b53..9c3bce2c42 100644 --- a/background/redux-slices/selectors/activitiesSelectors.ts +++ b/background/redux-slices/selectors/activitiesSelectors.ts @@ -5,14 +5,13 @@ import { RootState } from ".." export const selectCurrentAccountActivitiesWithTimestamps = createSelector( (state: RootState) => { - const currentAccountAddress = selectCurrentAccount(state).address + const currentAccount = selectCurrentAccount(state) + const { address, network } = currentAccount return { currentAccountActivities: - typeof currentAccountAddress !== "undefined" - ? state.activities[currentAccountAddress] - : undefined, - blocks: state.account.blocks, + typeof address !== "undefined" ? state.activities[address] : undefined, + blocks: state.networks.evm[network.chainID]?.blocks ?? {}, } }, ({ currentAccountActivities, blocks }) => { diff --git a/background/redux-slices/selectors/uiSelectors.ts b/background/redux-slices/selectors/uiSelectors.ts index d0e4f1bfe4..6f1b6faa50 100644 --- a/background/redux-slices/selectors/uiSelectors.ts +++ b/background/redux-slices/selectors/uiSelectors.ts @@ -8,7 +8,10 @@ const hardcodedMainCurrencySymbol = "USD" export const selectShowingActivityDetail = createSelector( (state: RootState) => state.activities, (state: RootState) => state.ui.showingActivityDetailID, - (state: RootState) => state.account.blocks, + (state: RootState) => { + const { network } = state.ui.selectedAccount + return state.networks.evm[network.chainID].blocks + }, (activities, showingActivityDetailID, blocks) => { return showingActivityDetailID === null ? null diff --git a/background/redux-slices/utils/contract-utils.ts b/background/redux-slices/utils/contract-utils.ts index aba3c1500a..1a9040f556 100644 --- a/background/redux-slices/utils/contract-utils.ts +++ b/background/redux-slices/utils/contract-utils.ts @@ -1,6 +1,4 @@ -import browser from "webextension-polyfill" -import { Provider, Web3Provider } from "@ethersproject/providers" -import { INTERNAL_PORT_NAME } from "@tallyho/provider-bridge-shared" +import { Web3Provider } from "@ethersproject/providers" import TallyWindowProvider from "@tallyho/window-provider" import { Contract, ethers, ContractInterface } from "ethers" import Emittery from "emittery" diff --git a/background/services/chain/index.ts b/background/services/chain/index.ts index fadae70bea..709b12f4f5 100644 --- a/background/services/chain/index.ts +++ b/background/services/chain/index.ts @@ -1,6 +1,7 @@ import { AlchemyProvider, AlchemyWebSocketProvider, + BaseProvider, TransactionReceipt, } from "@ethersproject/providers" import { getNetwork } from "@ethersproject/networks" @@ -48,6 +49,7 @@ import type { EnrichedEVMTransactionSignatureRequest, } from "../enrichment" import { HOUR } from "../../constants" +import SerialFallbackProvider from "./serial-fallback-provider" // We can't use destructuring because webpack has to replace all instances of // `process.env` variables in the bundled output @@ -114,18 +116,16 @@ interface Events extends ServiceLifecycleEvents { * case a service needs to interact with a network directly. */ export default class ChainService extends BaseService { - pollingProviders: { [networkName: string]: AlchemyProvider } - - websocketProviders: { [networkName: string]: AlchemyWebSocketProvider } + providers: { [networkName: string]: SerialFallbackProvider } subscribedAccounts: { account: string - provider: AlchemyWebSocketProvider + provider: SerialFallbackProvider }[] subscribedNetworks: { network: EVMNetwork - provider: AlchemyWebSocketProvider + provider: SerialFallbackProvider }[] /** @@ -195,18 +195,21 @@ export default class ChainService extends BaseService { this.ethereumNetwork = getEthereumNetwork() // TODO set up for each relevant network - this.pollingProviders = { - ethereum: new AlchemyProvider( - getNetwork(Number(this.ethereumNetwork.chainID)), - ALCHEMY_KEY - ), - } - this.websocketProviders = { - ethereum: new AlchemyWebSocketProvider( - getNetwork(Number(this.ethereumNetwork.chainID)), - ALCHEMY_KEY + this.providers = { + ethereum: new SerialFallbackProvider( + () => + new AlchemyWebSocketProvider( + getNetwork(Number(this.ethereumNetwork.chainID)), + ALCHEMY_KEY + ), + () => + new AlchemyProvider( + getNetwork(Number(this.ethereumNetwork.chainID)), + ALCHEMY_KEY + ) ), } + this.subscribedAccounts = [] this.subscribedNetworks = [] this.transactionsToRetrieve = { ethereum: [] } @@ -216,7 +219,7 @@ export default class ChainService extends BaseService { await super.internalStartService() const accounts = await this.getAccountsToTrack() - const ethProvider = this.pollingProviders.ethereum + const ethProvider = this.providers.ethereum const network = this.ethereumNetwork // FIXME Should we await or drop Promise.all on the below two? @@ -350,7 +353,7 @@ export default class ChainService extends BaseService { const normalizedAddress = normalizeEVMAddress(transactionRequest.from) const chainNonce = - (await this.pollingProviders.ethereum.getTransactionCount( + (await this.providers.ethereum.getTransactionCount( transactionRequest.from, "latest" )) - 1 @@ -391,6 +394,15 @@ export default class ChainService extends BaseService { } } + resolveNetwork( + transactionRequest: EIP1559TransactionRequest + ): EVMNetwork | undefined { + if (transactionRequest.chainID === this.ethereumNetwork.chainID) { + return this.ethereumNetwork + } + return undefined + } + /** * Releases the specified nonce for the given network and address. This * updates internal service state to allow that nonce to be reused. In cases @@ -446,7 +458,7 @@ export default class ChainService extends BaseService { this.checkNetwork(addressNetwork.network) // TODO look up provider network properly - const balance = await this.pollingProviders.ethereum.getBalance( + const balance = await this.providers.ethereum.getBalance( addressNetwork.address ) const accountBalance: AccountBalance = { @@ -478,7 +490,7 @@ export default class ChainService extends BaseService { return cachedBlock.blockHeight } // TODO make proper use of the network - return this.pollingProviders.ethereum.getBlockNumber() + return this.providers.ethereum.getBlockNumber() } /** @@ -501,7 +513,7 @@ export default class ChainService extends BaseService { } // Looking for new block - const resultBlock = await this.pollingProviders.ethereum.getBlock(blockHash) + const resultBlock = await this.providers.ethereum.getBlock(blockHash) const block = blockFromEthersBlock(network, resultBlock) @@ -529,9 +541,7 @@ export default class ChainService extends BaseService { return cachedTx } // TODO make proper use of the network - const gethResult = await this.pollingProviders.ethereum.getTransaction( - txHash - ) + const gethResult = await this.providers.ethereum.getTransaction(txHash) const newTransaction = transactionFromEthersTransaction( gethResult, ETH, @@ -582,7 +592,7 @@ export default class ChainService extends BaseService { network: EVMNetwork, transactionRequest: EIP1559TransactionRequest ): Promise { - const estimate = await this.pollingProviders.ethereum.estimateGas( + const estimate = await this.providers.ethereum.estimateGas( ethersTransactionRequestFromEIP1559TransactionRequest(transactionRequest) ) // Add 10% more gas as a safety net @@ -606,23 +616,21 @@ export default class ChainService extends BaseService { ) try { await Promise.all([ - this.pollingProviders.ethereum - .sendTransaction(serialized) - .catch((error) => { - logger.debug( - "Broadcast error caught, saving failed status and releasing nonce...", - transaction, - error - ) - // Failure to broadcast needs to be registered. - this.saveTransaction( - { ...transaction, status: 0, error: error.toString() }, - "alchemy" - ) - this.releaseEVMTransactionNonce(transaction) - - return Promise.reject(error) - }), + this.providers.ethereum.sendTransaction(serialized).catch((error) => { + logger.debug( + "Broadcast error caught, saving failed status and releasing nonce...", + transaction, + error + ) + // Failure to broadcast needs to be registered. + this.saveTransaction( + { ...transaction, status: 0, error: error.toString() }, + "alchemy" + ) + this.releaseEVMTransactionNonce(transaction) + + return Promise.reject(error) + }), this.subscribeToTransactionConfirmation( transaction.network, transaction @@ -650,7 +658,7 @@ export default class ChainService extends BaseService { } async send(method: string, params: unknown[]): Promise { - return this.websocketProviders.ethereum.send(method, params) + return this.providers.ethereum.send(method, params) } /* ***************** @@ -780,7 +788,7 @@ export default class ChainService extends BaseService { // TODO only works on Ethereum today const assetTransfers = await getAssetTransfers( - this.pollingProviders.ethereum, + this.providers.ethereum as unknown as AlchemyWebSocketProvider, addressOnNetwork, Number(startBlock), Number(endBlock) @@ -832,7 +840,7 @@ export default class ChainService extends BaseService { toHandle.forEach(async ({ hash, firstSeen }) => { try { // TODO make this multi network - const result = await this.pollingProviders.ethereum.getTransaction(hash) + const result = await this.providers.ethereum.getTransaction(hash) const transaction = transactionFromEthersTransaction( result, @@ -974,9 +982,9 @@ export default class ChainService extends BaseService { */ private async subscribeToNewHeads(network: EVMNetwork): Promise { // TODO look up provider network properly - const provider = this.websocketProviders.ethereum + const provider = this.providers.ethereum // eslint-disable-next-line no-underscore-dangle - await provider._subscribe( + await provider.subscribe( "newHeadsSubscriptionID", ["newHeads"], async (result: unknown) => { @@ -1009,9 +1017,8 @@ export default class ChainService extends BaseService { this.checkNetwork(network) // TODO look up provider network properly - const provider = this.websocketProviders.ethereum - // eslint-disable-next-line no-underscore-dangle - await provider._subscribe( + const provider = this.providers.ethereum + await provider.subscribe( "filteredNewFullPendingTransactionsSubscriptionID", ["alchemy_filteredNewFullPendingTransactions", { address }], async (result: unknown) => { @@ -1073,7 +1080,7 @@ export default class ChainService extends BaseService { this.checkNetwork(network) // TODO make proper use of the network - this.websocketProviders.ethereum.once( + this.providers.ethereum.once( transaction.hash, (confirmedReceipt: TransactionReceipt) => { this.saveTransaction( @@ -1097,7 +1104,7 @@ export default class ChainService extends BaseService { this.checkNetwork(network) // TODO make proper use of the network - const receipt = await this.pollingProviders.ethereum.getTransactionReceipt( + const receipt = await this.providers.ethereum.getTransactionReceipt( transaction.hash ) await this.saveTransaction( diff --git a/background/services/chain/serial-fallback-provider.ts b/background/services/chain/serial-fallback-provider.ts new file mode 100644 index 0000000000..12f9d673f4 --- /dev/null +++ b/background/services/chain/serial-fallback-provider.ts @@ -0,0 +1,466 @@ +import { Network } from "@ethersproject/networks" +import { + EventType, + JsonRpcProvider, + Listener, + WebSocketProvider, +} from "@ethersproject/providers" +import { MINUTE } from "../../constants" +import logger from "../../lib/logger" + +// Back off by this amount as a base, exponentiated by attempts and jittered. +const BASE_BACKOFF_MS = 150 +// Reset backoffs after 5 minutes. +const COOLDOWN_PERIOD = 5 * MINUTE +// Retry 3 times before falling back to the next provider. +const MAX_RETRIES = 3 + +/** + * Wait the given number of ms, then run the provided function. Returns a + * promise that will resolve after the delay has elapsed and the passed + * function has executed, with the result of the passed function. + */ +function waitAnd>( + waitMs: number, + fn: () => E +): Promise { + return new Promise((resolve) => { + // TODO setTimeout rather than browser.alarms here could mean this would + // hang when transitioning to a transient background page? Can we do this + // with browser.alarms? + setTimeout(() => { + resolve(fn()) + }, waitMs) + }) +} + +/** + * Given the number of the backoff being executed, returns a jittered number of + * ms to back off before making the next attempt. + */ +function backedOffMs(backoffCount: number): number { + const backoffSlotStart = BASE_BACKOFF_MS ** backoffCount + const backoffSlotEnd = BASE_BACKOFF_MS ** (backoffCount + 1) + + return backoffSlotStart + Math.random() * (backoffSlotEnd - backoffSlotStart) +} + +/** + * Returns true if the given provider is using a WebSocket AND the WebSocket is + * either closing or already closed. Ethers does not provide direct access to + * this information, nor does it attempt to reconnect in these cases. + */ +function isClosedOrClosingWebSocketProvider( + provider: JsonRpcProvider +): boolean { + if (provider instanceof WebSocketProvider) { + // Digging into the innards of Ethers here because there's no + // other way to get access to the WebSocket connection situation. + // eslint-disable-next-line no-underscore-dangle + const webSocket = provider._websocket as WebSocket + + return ( + webSocket.readyState === WebSocket.CLOSING || + webSocket.readyState === WebSocket.CLOSED + ) + } + + return false +} + +/** + * The SerialFallbackProvider is an Ethers JsonRpcProvider that can fall back + * through a series of providers in case previous ones fail. + * + * In case of server errors, this provider attempts a number of exponential + * backoffs and retries before falling back to the next provider in the list. + * WebSocketProviders in the list are checked for WebSocket connections, and + * attempt reconnects if the underlying WebSocket disconnects. + * + * Additionally, subscriptions are tracked and, if the current provider is a + * WebSocket provider, they are restored on reconnect. + */ +export default class SerialFallbackProvider extends JsonRpcProvider { + // Functions that will create and initialize a new provider, in priority + // order. + private providerCreators: [ + () => WebSocketProvider, + ...(() => JsonRpcProvider)[] + ] + + // The currently-used provider, produced by the provider-creator at + // currentProviderIndex. + private currentProvider: JsonRpcProvider + + // The index of the provider creator that created the current provider. Used + // for reconnects when relevant. + private currentProviderIndex = 0 + + // Information on the current backoff state. This is used to ensure retries + // and reconnects back off exponentially. + private currentBackoff = { + providerIndex: 0, + backoffMs: BASE_BACKOFF_MS, + backoffCount: 0, + lastBackoffTime: 0, + } + + // Information on WebSocket-style subscriptions. Tracked here so as to + // restore them in case of WebSocket disconnects. + private subscriptions: { + tag: string + param: unknown[] + processFunc: (result: unknown) => void + }[] = [] + + // Information on event subscriptions, which can be restored on non-WebSocket + // subscriptions and WebSocket subscriptions both. + private eventSubscriptions: { + eventName: EventType + listener: Listener | (Listener & { wrappedListener: Listener }) + once: boolean + }[] = [] + + constructor( + firstProviderCreator: () => WebSocketProvider, + ...remainingProviderCreators: (() => JsonRpcProvider)[] + ) { + const firstProvider = firstProviderCreator() + + super(firstProvider.connection, firstProvider.network) + + this.currentProvider = firstProvider + this.providerCreators = [firstProviderCreator, ...remainingProviderCreators] + } + + /** + * Override the core `perform` method to handle disconnects and other errors + * that should trigger retries. Ethers already does internal retrying, but + * this retry methodology eventually falls back on another provider, handles + * WebSocket disconnects, and restores subscriptions where + * possible/necessary. + */ + async send(method: string, params: unknown): Promise { + try { + if (isClosedOrClosingWebSocketProvider(this.currentProvider)) { + // Detect disconnected WebSocket and immediately throw. + throw new Error("WebSocket is already in CLOSING") + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return await this.currentProvider.send(method, params as any) + } catch (error) { + // Awful, but what can ya do. + const stringifiedError = String(error) + + if ( + stringifiedError.match(/WebSocket is already in CLOSING|bad response/) + ) { + const backoff = this.backoffFor(this.currentProviderIndex) + if (typeof backoff === "undefined") { + logger.debug( + "Attempting to connect new provider after error", + error, + "." + ) + this.disconnectCurrentProvider() + + this.currentProviderIndex += 1 + if (this.currentProviderIndex < this.providerCreators.length) { + // Try again with the next provider. + await this.reconnectProvider() + + return await this.send(method, params) + } + + // If we've looped around, set us up for the next call, but fail the + // current one since we've gone through every available provider. Note + // that this may happen over time, but we still fail the request that + // hits the end of the list. + this.currentProviderIndex = 0 + + // Reconnect, but don't wait for the connection to go through. + this.reconnectProvider() + + throw error + } else { + logger.debug( + "Backing off for", + backoff, + "and retrying: ", + method, + params + ) + + return await waitAnd(backoff, async () => { + if (isClosedOrClosingWebSocketProvider(this.currentProvider)) { + await this.reconnectProvider() + } + + return this.send(method, params) + }) + } + } + + logger.debug( + "Skipping fallback for unidentified error", + error, + "for provider", + this.currentProvider + ) + + throw error + } + } + + private async disconnectCurrentProvider() { + logger.debug( + "Disconnecting current provider; websocket: ", + this.currentProvider instanceof WebSocketProvider, + "." + ) + if (this.currentProvider instanceof WebSocketProvider) { + this.currentProvider.destroy() + } else { + // For non-WebSocket providers, kill all subscriptions so the listeners + // won't fire; the next provider will pick them up. We could lose events + // in between, but if we're considering the current provider dead, let's + // assume we would lose them anyway. + this.eventSubscriptions.forEach(({ eventName }) => + this.removeAllListeners(eventName) + ) + } + } + + async subscribe( + tag: string, + param: Array, + processFunc: (result: unknown) => void + ): Promise { + const subscription = { tag, param, processFunc } + this.subscriptions.push(subscription) + + if (this.currentProvider instanceof WebSocketProvider) { + // eslint-disable-next-line no-underscore-dangle + await this.currentProvider._subscribe(tag, param, processFunc) + } else { + logger.warn( + "Current provider is not a WebSocket provider; subscription " + + "will not work until a WebSocket provider connects." + ) + } + } + + async detectNetwork(): Promise { + return this.currentProvider.detectNetwork() + } + + // Overriding internal functionality here to support event listener + // restoration on reconnect. + // eslint-disable-next-line no-underscore-dangle + on(eventName: EventType, listener: Listener): this { + this.eventSubscriptions.push({ + eventName, + listener, + once: false, + }) + + this.currentProvider.on(eventName, listener) + + return this + } + + once(eventName: EventType, listener: Listener): this { + const adjustedListener = this.listenerWithCleanup(eventName, listener) + + this.eventSubscriptions.push({ + eventName, + listener: adjustedListener, + once: true, + }) + + this.currentProvider.once(eventName, listener) + + return this + } + + /** + * Removes one or all listeners for a given event. + * + * Ensures these will not be restored during a reconnect. + */ + off(eventName: EventType, listenerToRemove?: Listener): this { + this.eventSubscriptions = this.eventSubscriptions.filter( + ({ eventName: savedEventName, listener: savedListener }) => { + if (savedEventName === eventName) { + // No explicit listener to remove = remove all listeners. + if ( + typeof listenerToRemove === "undefined" || + listenerToRemove === null + ) { + return true + } + + // If the listener is wrapped, use that to check against the + // specified listener to remove. + if ("wrappedListener" in savedListener) { + return savedListener.wrappedListener === listenerToRemove + } + + // Otherwise, directly compare. + return savedListener === listenerToRemove + } + + return false + } + ) + + this.currentProvider.off(eventName, listenerToRemove) + + return this + } + + /** + * Wraps an Ethers listener function meant to only be invoked once with + * cleanup to ensure it won't be resubscribed in case of a provider switch. + */ + private listenerWithCleanup( + eventName: EventType, + listenerToWrap: Listener + ): Listener & { wrappedListener: Listener } { + const wrappedListener = ( + ...params: Parameters + ): ReturnType => { + try { + listenerToWrap(...params) + } finally { + this.eventSubscriptions = this.eventSubscriptions.filter( + ({ eventName: storedEventName, listener, once }) => + eventName !== storedEventName || + listener !== wrappedListener || + once !== true + ) + } + } + + wrappedListener.wrappedListener = listenerToWrap + + return wrappedListener + } + + /** + * Reconnects the currently-selected provider. If the current provider index + * has been somehow set out of range, resets it to 0. + */ + private async reconnectProvider() { + if (this.currentProviderIndex >= this.providerCreators.length) { + this.currentProviderIndex = 0 + } + + logger.debug( + "Reconnecting provider at index", + this.currentProviderIndex, + "..." + ) + + this.currentProvider = this.providerCreators[this.currentProviderIndex]() + this.resubscribe() + + // TODO After a longer backoff, attempt to reset the current provider to 0. + } + + private async resubscribe() { + logger.debug("Resubscribing subscriptions...") + + if (this.currentProvider instanceof WebSocketProvider) { + const provider = this.currentProvider as WebSocketProvider + + // Chain promises to serially resubscribe. + // + // TODO If anything fails along the way, it should yield the same kind of + // TODO backoff as a regular `perform`. + await this.subscriptions.reduce( + (previousPromise, { tag, param, processFunc }) => + previousPromise.then(() => + waitAnd(backedOffMs(0), () => + // Direct subscriptions are internal, but we want to be able to + // restore them. + // eslint-disable-next-line no-underscore-dangle + provider._subscribe(tag, param, processFunc) + ) + ), + Promise.resolve() + ) + } else if (this.subscriptions.length > 0) { + logger.warn( + `Cannot resubscribe ${this.subscriptions.length} subscription(s) ` + + `as the current provider is not a WebSocket provider; waiting ` + + `until a WebSocket provider connects to restore subscriptions ` + + `properly.` + ) + } + + this.eventSubscriptions.forEach(({ eventName, listener, once }) => { + if (once) { + this.currentProvider.once(eventName, listener) + } else { + this.currentProvider.on(eventName, listener) + } + }) + + logger.debug("Subscriptions resubscribed...") + } + + /** + * Computes the backoff time for the given provider index. If the provider + * index is new, starts with the base backoff; if the provider index is + * unchanged, computes a jittered exponential backoff. If the current + * provider has already exceeded its maximum retries, returns undefined to + * signal the provider should be considered dead for the time being. + * + * Backoffs respect a cooldown time after which they reset down to the base + * backoff time. + */ + private backoffFor(providerIndex: number): number | undefined { + const { + providerIndex: existingProviderIndex, + backoffCount, + lastBackoffTime, + } = this.currentBackoff + + if (backoffCount > MAX_RETRIES) { + return undefined + } + + if (existingProviderIndex !== providerIndex) { + this.currentBackoff = { + providerIndex, + backoffMs: BASE_BACKOFF_MS, + backoffCount: 0, + lastBackoffTime: 0, + } + } else if (Date.now() - lastBackoffTime > COOLDOWN_PERIOD) { + this.currentBackoff = { + providerIndex, + backoffMs: BASE_BACKOFF_MS, + backoffCount: 0, + lastBackoffTime: 0, + } + } else { + // The next backoff slot starts at the current minimum backoff and + // extends until the start of the next backoff. This specific backoff is + // randomized within that slot. + const newBackoffCount = backoffCount + 1 + const backoffMs = backedOffMs(newBackoffCount) + + this.currentBackoff = { + providerIndex, + backoffMs, + backoffCount: newBackoffCount, + lastBackoffTime: Date.now(), + } + } + + return this.currentBackoff.backoffMs + } +} diff --git a/background/services/enrichment/index.ts b/background/services/enrichment/index.ts index ea91a15287..4efbb8e30d 100644 --- a/background/services/enrichment/index.ts +++ b/background/services/enrichment/index.ts @@ -9,7 +9,6 @@ import { } from "../../networks" import { enrichAssetAmountWithDecimalValues } from "../../redux-slices/utils/asset-utils" -import { ETH } from "../../constants" import { parseERC20Tx, parseLogsForERC20Transfers } from "../../lib/erc20" import { sameEVMAddress } from "../../lib/utils" diff --git a/background/services/indexing/index.ts b/background/services/indexing/index.ts index 2165c3561f..d9fd5c0e9d 100644 --- a/background/services/indexing/index.ts +++ b/background/services/indexing/index.ts @@ -1,3 +1,4 @@ +import { AlchemyProvider } from "@ethersproject/providers" import logger from "../../lib/logger" import { HexString } from "../../types" @@ -19,7 +20,7 @@ import { fetchAndValidateTokenList, mergeAssets, networkAssetsFromLists, -} from "../../lib/tokenList" +} from "../../lib/token-lists" import PreferenceService from "../preferences" import ChainService from "../chain" import { ServiceCreatorFunction, ServiceLifecycleEvents } from "../types" @@ -337,7 +338,7 @@ export default class IndexingService extends BaseService { contractAddresses?: HexString[] ): ReturnType { const balances = await getTokenBalances( - this.chainService.pollingProviders.ethereum, + this.chainService.providers.ethereum as unknown as AlchemyProvider, addressNetwork.address, contractAddresses || undefined ) @@ -414,7 +415,8 @@ export default class IndexingService extends BaseService { ) if (!customAsset) { // TODO hardcoded to Ethereum - const provider = this.chainService.pollingProviders.ethereum + const provider = this.chainService.providers + .ethereum as unknown as AlchemyProvider // pull metadata from Alchemy customAsset = (await getTokenMetadata(provider, { @@ -586,7 +588,7 @@ export default class IndexingService extends BaseService { ).map(async (addressOnNetwork) => { // TODO hardcoded to Ethereum const balances = await getAssetBalances( - this.chainService.pollingProviders.ethereum, + this.chainService.providers.ethereum as unknown as AlchemyProvider, activeAssetsToTrack, addressOnNetwork ) diff --git a/background/services/name/index.ts b/background/services/name/index.ts index 9c53a88fe4..fc69f3ed64 100644 --- a/background/services/name/index.ts +++ b/background/services/name/index.ts @@ -163,7 +163,7 @@ export default class NameService extends BaseService { // TODO ENS lookups should work on Ethereum mainnet and a few testnets as well. // This is going to be strange, though, as we'll be looking up ENS names for // non-Ethereum networks (eg eventually Bitcoin). - const provider = this.chainService.pollingProviders.ethereum + const provider = this.chainService.providers.ethereum // TODO cache name resolution and TTL const address = await provider.resolveName(name) if (!address || !address.match(/^0x[a-zA-Z0-9]*$/)) { @@ -198,7 +198,7 @@ export default class NameService extends BaseService { } } - const provider = this.chainService.pollingProviders.ethereum + const provider = this.chainService.providers.ethereum // TODO cache name resolution and TTL const name = await provider.lookupAddress(address) // TODO proper domain name validation ala RFC2181 @@ -248,7 +248,7 @@ export default class NameService extends BaseService { return undefined } // TODO handle if it doesn't exist - const provider = this.chainService.pollingProviders.ethereum + const provider = this.chainService.providers.ethereum const resolver = await provider.getResolver(name) if (!sameEVMAddress(await resolver?.getAddress(), address)) { return undefined diff --git a/background/services/signing/index.ts b/background/services/signing/index.ts index 3ba1f3aa2a..5ab92bf290 100644 --- a/background/services/signing/index.ts +++ b/background/services/signing/index.ts @@ -111,10 +111,14 @@ export default class SigningService extends BaseService { } async signTransaction( - network: EVMNetwork, transactionRequest: EIP1559TransactionRequest, signingMethod: SigningMethod ): Promise { + const network = this.chainService.resolveNetwork(transactionRequest) + if (typeof network === "undefined") { + throw new Error(`Unknown chain ID ${transactionRequest.chainID}.`) + } + const transactionWithNonce = await this.chainService.populateEVMTransactionNonce(transactionRequest) @@ -151,9 +155,9 @@ export default class SigningService extends BaseService { reason: "genericError", }) - throw err - } finally { this.chainService.releaseEVMTransactionNonce(transactionWithNonce) + + throw err } } diff --git a/background/tests/prices.test.ts b/background/tests/prices.test.ts index d85c01e6e8..fc6fc2db38 100644 --- a/background/tests/prices.test.ts +++ b/background/tests/prices.test.ts @@ -1,6 +1,5 @@ // It's necessary to have an object w/ the function on it so we can use spyOn import * as ethers from "@ethersproject/web" // << THIS IS THE IMPORTANT TRICK -import { JSONSchemaType, ValidateFunction } from "ajv" import logger from "../lib/logger" import { BTC, ETH, FIAT_CURRENCIES, USD } from "../constants" diff --git a/package.json b/package.json index 6e4ac05a3c..7c3b34e590 100644 --- a/package.json +++ b/package.json @@ -82,10 +82,10 @@ "eslint-config-airbnb": "^18.2.1", "eslint-config-airbnb-base": "^14.2.1", "eslint-config-airbnb-typescript": "^14.0.0", - "eslint-config-prettier": "^8.3.0", - "eslint-plugin-import": "^2.23.4", - "eslint-plugin-jsx-a11y": "^6.4.1", - "eslint-plugin-prettier": "^3.4.0", + "eslint-config-prettier": "^8.4.0", + "eslint-plugin-import": "^2.25.4", + "eslint-plugin-jsx-a11y": "^6.5.1", + "eslint-plugin-prettier": "^4.0.0", "eslint-plugin-react": "^7.24.0", "eslint-plugin-react-hooks": "^4.2.0", "fork-ts-checker-webpack-plugin": "^6.3.2", @@ -94,7 +94,7 @@ "npm": "^7.5.6", "npm-run-all": "^4.1.5", "patch-package": "^6.4.7", - "prettier": "^2.3.2", + "prettier": "^2.5.1", "process": "^0.11.10", "size-plugin": "^2.0.2", "stream-browserify": "^3.0.0", diff --git a/ui/README.md b/ui/README.md index b5f6e05f35..8699ba246a 100644 --- a/ui/README.md +++ b/ui/README.md @@ -50,7 +50,4 @@ by the folder they're in. /public # Static assets like fonts and images - -/slices -# Reducers and actions consolidated as files here. Interacting with the project's background.js api and using its data to populate frontend's state happens here ``` diff --git a/ui/components/Keyring/KeyringSetPassword.tsx b/ui/components/Keyring/KeyringSetPassword.tsx index 103825ed0f..8c81d5b6ce 100644 --- a/ui/components/Keyring/KeyringSetPassword.tsx +++ b/ui/components/Keyring/KeyringSetPassword.tsx @@ -5,6 +5,7 @@ import { useBackgroundDispatch, useAreKeyringsUnlocked } from "../../hooks" import SharedButton from "../Shared/SharedButton" import SharedInput from "../Shared/SharedInput" import titleStyle from "../Onboarding/titleStyle" +import SharedBackButton from "../Shared/SharedBackButton" export default function KeyringSetPassword(): ReactElement { const [password, setPassword] = useState("") @@ -51,8 +52,11 @@ export default function KeyringSetPassword(): ReactElement { } return ( -
-
+
+
+ +
+

First, let's secure your wallet

You will NOT be able to change this password for now. @@ -96,6 +100,20 @@ export default function KeyringSetPassword(): ReactElement {