diff --git a/.env.defaults b/.env.defaults index e049d65bad..25fda33f5c 100644 --- a/.env.defaults +++ b/.env.defaults @@ -25,6 +25,7 @@ SUPPORT_FORGOT_PASSWORD=false SUPPORT_AVALANCHE=true SUPPORT_BINANCE_SMART_CHAIN=true SUPPORT_ARBITRUM_NOVA=false +SUPPORT_SWAP_QUOTE_REFRESH=false ENABLE_ACHIEVEMENTS_TAB=true SUPPORT_ACHIEVEMENTS_BANNER=false SWITCH_RUNTIME_FLAGS=false diff --git a/.github/ISSUE_TEMPLATE/BUG.yml b/.github/ISSUE_TEMPLATE/BUG.yml index b81cb236f4..21ac20af9e 100644 --- a/.github/ISSUE_TEMPLATE/BUG.yml +++ b/.github/ISSUE_TEMPLATE/BUG.yml @@ -51,6 +51,8 @@ body: label: Version description: What version of the extension are you running? options: + - v0.18.9 + - v0.18.8 - v0.18.7 - v0.18.6 - v0.18.5 diff --git a/background/accounts.ts b/background/accounts.ts index b12bbb2411..631465661a 100644 --- a/background/accounts.ts +++ b/background/accounts.ts @@ -4,7 +4,7 @@ import { HexString } from "./types" /** * An account balance at a particular time and block height, on a particular - * network. Flexible enough to represent base assets like ETH and BTC as well + * network. Flexible enough to represent base assets like ETH as well * application-layer tokens like ERC-20s. */ export type AccountBalance = { diff --git a/background/assets.ts b/background/assets.ts index 6c7c9471d6..5b85be2c0b 100644 --- a/background/assets.ts +++ b/background/assets.ts @@ -56,7 +56,7 @@ export type Asset = { * asset id in CoinGecko's records. */ export type CoinGeckoAsset = Asset & { - metadata: Asset["metadata"] & { + metadata?: Asset["metadata"] & { coinGeckoID: string } } @@ -120,6 +120,7 @@ export type AnyAsset = | FiatCurrency | FungibleAsset | SmartContractFungibleAsset + | NetworkBaseAsset /** * An asset that can be swapped with our current providers @@ -211,6 +212,20 @@ export function isFungibleAssetAmount( ): assetAmount is FungibleAssetAmount { return isFungibleAsset(assetAmount.asset) } +/** + * Flips `pair` and `amounts` values in the PricePoint object. + * + * @param pricePoint + * @returns pricePoint with flipped pair and amounts + */ +export function flipPricePoint(pricePoint: PricePoint): PricePoint { + const { pair, amounts, time } = pricePoint + return { + pair: [pair[1], pair[0]], + amounts: [amounts[1], amounts[0]], + time, + } +} /** * Converts the given source asset amount, fungible or non-fungible, to a target diff --git a/background/constants/base-assets.ts b/background/constants/base-assets.ts new file mode 100644 index 0000000000..dd38dbe833 --- /dev/null +++ b/background/constants/base-assets.ts @@ -0,0 +1,70 @@ +import { NetworkBaseAsset } from "../networks" + +const ETH: NetworkBaseAsset = { + chainID: "1", + name: "Ether", + symbol: "ETH", + decimals: 18, +} + +const ARBITRUM_ONE_ETH: NetworkBaseAsset = { + ...ETH, + chainID: "42161", +} + +const ARBITRUM_NOVA_ETH: NetworkBaseAsset = { + ...ETH, + chainID: "42170", +} + +const OPTIMISTIC_ETH: NetworkBaseAsset = { + ...ETH, + chainID: "10", +} + +const GOERLI_ETH: NetworkBaseAsset = { + ...ETH, + chainID: "5", +} + +const RBTC: NetworkBaseAsset = { + chainID: "30", + name: "RSK Token", + symbol: "RBTC", + decimals: 18, +} + +const MATIC: NetworkBaseAsset = { + chainID: "137", + name: "Matic Token", + symbol: "MATIC", + decimals: 18, +} + +const AVAX: NetworkBaseAsset = { + chainID: "43114", + name: "Avalanche", + symbol: "AVAX", + decimals: 18, +} + +const BNB: NetworkBaseAsset = { + chainID: "56", + name: "Binance Coin", + symbol: "BNB", + decimals: 18, +} + +export const BASE_ASSETS_BY_CUSTOM_NAME = { + ETH, + MATIC, + RBTC, + AVAX, + BNB, + ARBITRUM_ONE_ETH, + ARBITRUM_NOVA_ETH, + OPTIMISTIC_ETH, + GOERLI_ETH, +} + +export const BASE_ASSETS = Object.values(BASE_ASSETS_BY_CUSTOM_NAME) diff --git a/background/constants/coin-types.ts b/background/constants/coin-types.ts index 2352b5c554..0dab19b232 100644 --- a/background/constants/coin-types.ts +++ b/background/constants/coin-types.ts @@ -5,8 +5,6 @@ * Limited extension-specific list of coin types by asset symbol. */ export const coinTypesByAssetSymbol = { - BTC: 0, - "Testnet BTC": 1, ETH: 60, RBTC: 137, MATIC: 966, diff --git a/background/constants/currencies.ts b/background/constants/currencies.ts index 9d7ea3e6bc..2212272fde 100644 --- a/background/constants/currencies.ts +++ b/background/constants/currencies.ts @@ -1,5 +1,6 @@ -import { FiatCurrency } from "../assets" +import { CoinGeckoAsset, FiatCurrency } from "../assets" import { NetworkBaseAsset } from "../networks" +import { BASE_ASSETS_BY_CUSTOM_NAME } from "./base-assets" import { coinTypesByAssetSymbol } from "./coin-types" export const USD: FiatCurrency = { @@ -13,10 +14,7 @@ export const FIAT_CURRENCIES_SYMBOL = FIAT_CURRENCIES.map( (currency) => currency.symbol ) -export const ETH: NetworkBaseAsset = { - name: "Ether", - symbol: "ETH", - decimals: 18, +export const ETH_DATA = { coinType: coinTypesByAssetSymbol.ETH, metadata: { coinGeckoID: "ethereum", @@ -25,10 +23,13 @@ export const ETH: NetworkBaseAsset = { }, } -export const RBTC: NetworkBaseAsset = { - name: "RSK Token", - symbol: "RBTC", - decimals: 18, +export const ETH: NetworkBaseAsset & Required = { + ...BASE_ASSETS_BY_CUSTOM_NAME.ETH, + ...ETH_DATA, +} + +export const RBTC: NetworkBaseAsset & Required = { + ...BASE_ASSETS_BY_CUSTOM_NAME.RBTC, coinType: coinTypesByAssetSymbol.RBTC, metadata: { coinGeckoID: "rootstock", @@ -37,23 +38,29 @@ export const RBTC: NetworkBaseAsset = { }, } -export const OPTIMISTIC_ETH: NetworkBaseAsset = { - name: "Ether", - symbol: "ETH", - decimals: 18, - coinType: coinTypesByAssetSymbol.ETH, +export const OPTIMISTIC_ETH: NetworkBaseAsset & Required = { + ...BASE_ASSETS_BY_CUSTOM_NAME.OPTIMISTIC_ETH, + ...ETH_DATA, contractAddress: "0xdeaddeaddeaddeaddeaddeaddeaddeaddead0000", - metadata: { - coinGeckoID: "ethereum", - tokenLists: [], - websiteURL: "https://ethereum.org", - }, } -export const MATIC: NetworkBaseAsset = { - name: "Matic Token", - symbol: "MATIC", - decimals: 18, +export const ARBITRUM_ONE_ETH: NetworkBaseAsset & Required = { + ...BASE_ASSETS_BY_CUSTOM_NAME.ARBITRUM_ONE_ETH, + ...ETH_DATA, +} + +export const ARBITRUM_NOVA_ETH: NetworkBaseAsset & Required = { + ...BASE_ASSETS_BY_CUSTOM_NAME.ARBITRUM_NOVA_ETH, + ...ETH_DATA, +} + +export const GOERLI_ETH: NetworkBaseAsset & Required = { + ...BASE_ASSETS_BY_CUSTOM_NAME.GOERLI_ETH, + ...ETH_DATA, +} + +export const MATIC: NetworkBaseAsset & Required = { + ...BASE_ASSETS_BY_CUSTOM_NAME.MATIC, coinType: coinTypesByAssetSymbol.MATIC, contractAddress: "0x0000000000000000000000000000000000001010", metadata: { @@ -63,10 +70,8 @@ export const MATIC: NetworkBaseAsset = { }, } -export const AVAX: NetworkBaseAsset = { - name: "Avalanche", - symbol: "AVAX", - decimals: 18, +export const AVAX: NetworkBaseAsset & Required = { + ...BASE_ASSETS_BY_CUSTOM_NAME.AVAX, coinType: coinTypesByAssetSymbol.AVAX, metadata: { coinGeckoID: "avalanche-2", @@ -75,10 +80,8 @@ export const AVAX: NetworkBaseAsset = { }, } -export const BNB: NetworkBaseAsset = { - name: "Binance Coin", - symbol: "BNB", - decimals: 18, +export const BNB: NetworkBaseAsset & Required = { + ...BASE_ASSETS_BY_CUSTOM_NAME.BNB, coinType: coinTypesByAssetSymbol.BNB, metadata: { coinGeckoID: "binancecoin", @@ -87,26 +90,14 @@ export const BNB: NetworkBaseAsset = { }, } -export const BTC: NetworkBaseAsset = { - name: "Bitcoin", - symbol: "BTC", - decimals: 8, - coinType: coinTypesByAssetSymbol.BTC, - metadata: { - coinGeckoID: "bitcoin", - tokenLists: [], - websiteURL: "https://bitcoin.org", - }, -} - -export const BASE_ASSETS = [ETH, BTC, MATIC, RBTC, OPTIMISTIC_ETH, AVAX, BNB] - -export const BASE_ASSETS_BY_SYMBOL = BASE_ASSETS.reduce<{ - [assetSymbol: string]: NetworkBaseAsset -}>((acc, asset) => { - const newAcc = { - ...acc, - } - newAcc[asset.symbol] = asset - return newAcc -}, {}) +export const BUILT_IN_NETWORK_BASE_ASSETS = [ + ETH, + MATIC, + RBTC, + OPTIMISTIC_ETH, + ARBITRUM_ONE_ETH, + ARBITRUM_NOVA_ETH, + GOERLI_ETH, + AVAX, + BNB, +] diff --git a/background/constants/index.ts b/background/constants/index.ts index f637893b78..bdeb7d878e 100644 --- a/background/constants/index.ts +++ b/background/constants/index.ts @@ -36,3 +36,4 @@ export enum EarnStages { export * from "./assets" export * from "./currencies" export * from "./networks" +export * from "./base-assets" diff --git a/background/constants/networks.ts b/background/constants/networks.ts index fa7fd46a06..0454f4b70d 100644 --- a/background/constants/networks.ts +++ b/background/constants/networks.ts @@ -1,6 +1,16 @@ import { FeatureFlags, isEnabled } from "../features" -import { EVMNetwork, Network } from "../networks" -import { AVAX, BNB, BTC, ETH, MATIC, OPTIMISTIC_ETH, RBTC } from "./currencies" +import { EVMNetwork } from "../networks" +import { + ARBITRUM_NOVA_ETH, + ARBITRUM_ONE_ETH, + AVAX, + BNB, + ETH, + GOERLI_ETH, + MATIC, + OPTIMISTIC_ETH, + RBTC, +} from "./currencies" export const ETHEREUM: EVMNetwork = { name: "Ethereum", @@ -28,7 +38,7 @@ export const POLYGON: EVMNetwork = { export const ARBITRUM_ONE: EVMNetwork = { name: "Arbitrum", - baseAsset: ETH, + baseAsset: ARBITRUM_ONE_ETH, chainID: "42161", family: "EVM", coingeckoPlatformID: "arbitrum-one", @@ -52,7 +62,7 @@ export const BINANCE_SMART_CHAIN: EVMNetwork = { export const ARBITRUM_NOVA: EVMNetwork = { name: "Arbitrum Nova", - baseAsset: ETH, + baseAsset: ARBITRUM_NOVA_ETH, chainID: "42170", family: "EVM", coingeckoPlatformID: "arbitrum-nova", @@ -68,19 +78,12 @@ export const OPTIMISM: EVMNetwork = { export const GOERLI: EVMNetwork = { name: "Goerli", - baseAsset: ETH, + baseAsset: GOERLI_ETH, chainID: "5", family: "EVM", coingeckoPlatformID: "ethereum", } -export const BITCOIN: Network = { - name: "Bitcoin", - baseAsset: BTC, - family: "BTC", - coingeckoPlatformID: "bitcoin", -} - export const DEFAULT_NETWORKS = [ ETHEREUM, POLYGON, diff --git a/background/features.ts b/background/features.ts index 3c80c6e367..c5d826dd57 100644 --- a/background/features.ts +++ b/background/features.ts @@ -36,6 +36,7 @@ export const RuntimeFlag = { SUPPORT_NFT_TAB: process.env.SUPPORT_NFT_TAB === "true", SUPPORT_NFT_SEND: process.env.SUPPORT_NFT_SEND === "true", SUPPORT_WALLET_CONNECT: process.env.SUPPORT_WALLET_CONNECT === "true", + SUPPORT_SWAP_QUOTE_REFRESH: process.env.SUPPORT_SWAP_QUOTE_REFRESH === "true", SUPPORT_ABILITIES: process.env.SUPPORT_ABILITIES === "true", SUPPORT_CUSTOM_NETWORKS: process.env.SUPPORT_CUSTOM_NETWORKS === "true", SUPPORT_CUSTOM_RPCS: process.env.SUPPORT_CUSTOM_RPCS === "true", diff --git a/background/lib/alchemy.ts b/background/lib/alchemy.ts index 4e2d7496b7..761f58d290 100644 --- a/background/lib/alchemy.ts +++ b/background/lib/alchemy.ts @@ -44,7 +44,7 @@ export async function getAssetTransfers( fromBlock: number, toBlock?: number, order: "asc" | "desc" = "desc", - maxCount = 1000 + maxCount = 25 ): Promise { const { address: account, network } = addressOnNetwork diff --git a/background/lib/asset-similarity.ts b/background/lib/asset-similarity.ts index 67b87ecd89..d236c23a5a 100644 --- a/background/lib/asset-similarity.ts +++ b/background/lib/asset-similarity.ts @@ -97,6 +97,8 @@ export function findClosestAssetIndex( export function mergeAssets(asset1: AnyAsset, asset2: AnyAsset): AnyAsset { return { ...asset1, + ...("coinType" in asset1 ? { coinType: asset1.coinType } : {}), + ...("coinType" in asset2 ? { coinType: asset2.coinType } : {}), metadata: { ...asset1.metadata, ...asset2.metadata, diff --git a/background/lib/logger.ts b/background/lib/logger.ts index 7ea6de3963..8d70bb9283 100644 --- a/background/lib/logger.ts +++ b/background/lib/logger.ts @@ -288,8 +288,16 @@ export function serializeLogs(): string { return splitLogs }) + const HOUR = 1000 * 60 * 60 return ( logEntries + // Only grab logs from the past hour + .filter((logLine) => { + return ( + new Date(logLine.substring(1, iso8601Length)) > + new Date(Date.now() - HOUR) + ) + }) // Sort by date. .sort((a, b) => { return a diff --git a/background/lib/nfts_update.ts b/background/lib/nfts_update.ts index 7daa74bc8b..dc29c4c8aa 100644 --- a/background/lib/nfts_update.ts +++ b/background/lib/nfts_update.ts @@ -117,7 +117,7 @@ export function getNFTCollections( ) } -export async function getTransferredNFTs( +export async function getNFTsTransfers( accounts: AddressOnNetwork[], timestamp: UNIXTime ): Promise { @@ -134,11 +134,11 @@ export async function getTransferredNFTs( { addresses: new Set(), chains: new Set() } ) - const removedNFTs = await getSimpleHashNFTsTransfers( + const transfers = await getSimpleHashNFTsTransfers( [...addresses], [...chains], timestamp ) - return removedNFTs + return transfers } diff --git a/background/lib/poap_update.ts b/background/lib/poap_update.ts index dcd7d73d19..558a0503c0 100644 --- a/background/lib/poap_update.ts +++ b/background/lib/poap_update.ts @@ -107,6 +107,6 @@ export async function getPoapCollections( hasBadges: true, network: ETHEREUM, floorPrice: undefined, // POAPs don't have floor prices - thumbnailURL: "https://poap.xyz/POAP.f74a7300.svg", + thumbnailURL: "images/poap_logo.svg", } } diff --git a/background/lib/prices.ts b/background/lib/prices.ts index 880c8d3b24..8a7bc04662 100644 --- a/background/lib/prices.ts +++ b/background/lib/prices.ts @@ -18,10 +18,17 @@ import { USD } from "../constants" const COINGECKO_API_ROOT = "https://api.coingecko.com/api/v3" export async function getPrices( - assets: (AnyAsset & CoinGeckoAsset)[], + assets: (AnyAsset & Required)[], vsCurrencies: FiatCurrency[] ): Promise { - const coinIds = assets.map((a) => a.metadata.coinGeckoID).join(",") + const coinIds = assets + .reduce((ids, asset) => { + if (ids.some((id) => id === asset.metadata.coinGeckoID)) { + return ids + } + return [...ids, asset.metadata.coinGeckoID] + }, []) + .join(",") const currencySymbols = vsCurrencies .map((c) => c.symbol.toLowerCase()) diff --git a/background/lib/simple-hash_update.ts b/background/lib/simple-hash_update.ts index c3c0f9a6d6..c8305c6639 100644 --- a/background/lib/simple-hash_update.ts +++ b/background/lib/simple-hash_update.ts @@ -68,6 +68,11 @@ type SimpleHashTransferModel = { chain: SupportedChain from_address: string | null to_address: string | null + nft_details?: { + collection?: { + collection_id: string + } + } } type SimpleHashNFTsByWalletAPIResponse = { @@ -311,6 +316,7 @@ export async function getSimpleHashNFTsTransfers( requestURL.searchParams.set("chains", getChainIDsNames(chainIDs)) requestURL.searchParams.set("wallet_addresses", addresses.join(",")) requestURL.searchParams.set("from_timestamp", fromTimestamp.toString()) + requestURL.searchParams.set("include_nft_details", "1") } try { @@ -323,32 +329,36 @@ export async function getSimpleHashNFTsTransfers( const { transfers, next } = result - const removedNFTs: TransferredNFT[] = transfers.flatMap((transfer) => - transfer.nft_id && - transfer.from_address && - addresses.some((address) => - sameEVMAddress(address, transfer.from_address) - ) + const transferDetails: TransferredNFT[] = transfers.flatMap((transfer) => + transfer.nft_id && (transfer.from_address || transfer.to_address) ? { id: transfer.nft_id, chainID: SIMPLE_HASH_CHAIN_TO_ID[transfer.chain].toString(), - address: transfer.from_address, + from: transfer.from_address, + to: transfer.to_address, + type: addresses.some((address) => + sameEVMAddress(address, transfer.from_address) + ) + ? "sell" + : "buy", + collectionID: + transfer.nft_details?.collection?.collection_id ?? null, } : [] ) if (next) { - const nextPageRemovedNFTs = await getSimpleHashNFTsTransfers( + const nextPageTransferDetails = await getSimpleHashNFTsTransfers( addresses, chainIDs, fromTimestamp, next ) - return [...removedNFTs, ...nextPageRemovedNFTs] + return [...transferDetails, ...nextPageTransferDetails] } - return removedNFTs + return transferDetails } catch (err) { logger.error("Error retrieving NFTs ", err) } diff --git a/background/lib/tests/asset-similarity.unit.test.ts b/background/lib/tests/asset-similarity.unit.test.ts new file mode 100644 index 0000000000..ba5fc8d3d7 --- /dev/null +++ b/background/lib/tests/asset-similarity.unit.test.ts @@ -0,0 +1,18 @@ +import { + createNetworkBaseAsset, + createSmartContractAsset, +} from "../../tests/factories" +import { mergeAssets } from "../asset-similarity" + +describe("Asset Similarity", () => { + describe("mergeAssets", () => { + it("Should preserve coinType when merging assets", () => { + const mergedAsset = mergeAssets( + createSmartContractAsset(), + createNetworkBaseAsset() + ) + + expect("coinType" in mergedAsset).toBe(true) + }) + }) +}) diff --git a/background/lib/token-lists.ts b/background/lib/token-lists.ts index 267f8a187d..b19dc97730 100644 --- a/background/lib/token-lists.ts +++ b/background/lib/token-lists.ts @@ -148,10 +148,9 @@ export function mergeAssets( metadata: { ...matchingAsset.metadata, ...asset.metadata, - tokenLists: - matchingAsset.metadata?.tokenLists?.concat( - asset.metadata?.tokenLists ?? [] - ) ?? [], + tokenLists: (matchingAsset.metadata?.tokenLists || [])?.concat( + asset.metadata?.tokenLists ?? [] + ), }, } } else { diff --git a/background/networks.ts b/background/networks.ts index 49536e33ec..1f800abe40 100644 --- a/background/networks.ts +++ b/background/networks.ts @@ -14,14 +14,15 @@ import type { * Each supported network family is generally incompatible with others from a * transaction, consensus, and/or wire format perspective. */ -export type NetworkFamily = "EVM" | "BTC" +export type NetworkFamily = "EVM" // Should be structurally compatible with FungibleAsset or much code will // likely explode. export type NetworkBaseAsset = FungibleAsset & CoinGeckoAsset & { contractAddress?: string - coinType: Slip44CoinType + coinType?: Slip44CoinType + chainID: string } /** @@ -33,7 +34,7 @@ export type Network = { baseAsset: NetworkBaseAsset & CoinGeckoAsset family: NetworkFamily chainID?: string - coingeckoPlatformID: string + coingeckoPlatformID?: string } /** diff --git a/background/nfts.ts b/background/nfts.ts index c5d500fe62..a9989e9a02 100644 --- a/background/nfts.ts +++ b/background/nfts.ts @@ -79,5 +79,8 @@ export type NFTCollection = { export type TransferredNFT = { id: string chainID: string - address: string + from: string | null + to: string | null + type: "sell" | "buy" + collectionID: string | null } diff --git a/background/package.json b/background/package.json index cb6fefb5c5..16f4512e89 100644 --- a/background/package.json +++ b/background/package.json @@ -56,7 +56,6 @@ "emittery": "^0.9.2", "ethers": "^5.5.1", "lodash": "^4.17.21", - "node-fetch": "^2.6.1", "sinon": "^14.0.0", "siwe": "^1.1.0", "util": "^0.12.4", diff --git a/background/redux-slices/abilities.ts b/background/redux-slices/abilities.ts index c791b9bd05..c21e4172c9 100644 --- a/background/redux-slices/abilities.ts +++ b/background/redux-slices/abilities.ts @@ -11,11 +11,13 @@ type AbilitiesState = { [uuid: string]: Ability } } + hideDescription: boolean } const initialState: AbilitiesState = { filter: "incomplete", abilities: {}, + hideDescription: false, } const abilitiesSlice = createSlice({ @@ -50,6 +52,9 @@ const abilitiesSlice = createSlice({ immerState.abilities[payload.address][payload.abilityId].removedFromUi = true }, + toggleHideDescription: (immerState, { payload }: { payload: boolean }) => { + immerState.hideDescription = payload + }, }, }) @@ -58,6 +63,7 @@ export const { deleteAbility, markAbilityAsCompleted, markAbilityAsRemoved, + toggleHideDescription, } = abilitiesSlice.actions export const completeAbility = createBackgroundAsyncThunk( diff --git a/background/redux-slices/assets.ts b/background/redux-slices/assets.ts index 6a7fa587c5..98e1928858 100644 --- a/background/redux-slices/assets.ts +++ b/background/redux-slices/assets.ts @@ -3,6 +3,7 @@ import { ethers } from "ethers" import { AnyAsset, AnyAssetAmount, + flipPricePoint, isFungibleAsset, isSmartContractFungibleAsset, PricePoint, @@ -12,12 +13,15 @@ import { AddressOnNetwork } from "../accounts" import { findClosestAssetIndex } from "../lib/asset-similarity" import { normalizeEVMAddress } from "../lib/utils" import { createBackgroundAsyncThunk } from "./utils" -import { isNetworkBaseAsset } from "./utils/asset-utils" +import { isBuiltInNetworkBaseAsset } from "./utils/asset-utils" import { getProvider } from "./utils/contract-utils" import { sameNetwork } from "../networks" import { ERC20_INTERFACE } from "../lib/erc20" import logger from "../lib/logger" -import { BASE_ASSETS_BY_SYMBOL, FIAT_CURRENCIES_SYMBOL } from "../constants" +import { + BUILT_IN_NETWORK_BASE_ASSETS, + FIAT_CURRENCIES_SYMBOL, +} from "../constants" import { convertFixedPoint } from "../lib/fixed-point" export type AssetWithRecentPrices = T & { @@ -66,8 +70,12 @@ const assetsSlice = createSlice({ normalizeEVMAddress(asset.contractAddress)) || // Only match base assets by name - since there may be // many assets that share a name and symbol across L2's - (BASE_ASSETS_BY_SYMBOL[a.symbol] && - BASE_ASSETS_BY_SYMBOL[asset.symbol] && + (BUILT_IN_NETWORK_BASE_ASSETS.some( + (baseAsset) => baseAsset.symbol === a.symbol + ) && + BUILT_IN_NETWORK_BASE_ASSETS.some( + (baseAsset) => baseAsset.symbol === asset.symbol + ) && a.name === asset.name) ) // if there aren't duplicates, add the asset @@ -144,7 +152,7 @@ export const transferAsset = createBackgroundAsyncThunk( const provider = getProvider() const signer = provider.getSigner() - if (isNetworkBaseAsset(assetAmount.asset, fromNetwork)) { + if (isBuiltInNetworkBaseAsset(assetAmount.asset, fromNetwork)) { logger.debug( `Sending ${assetAmount.amount} ${assetAmount.asset.symbol} from ` + `${fromAddress} to ${toAddress} as a base asset transfer.` @@ -230,12 +238,7 @@ export const selectAssetPricePoint = createSelector( // Flip it if the price point looks like USD-ETH if (pricePoint.pair[0].symbol !== assetToFind.symbol) { - const { pair, amounts, time } = pricePoint - pricePoint = { - pair: [pair[1], pair[0]], - amounts: [amounts[1], amounts[0]], - time, - } + pricePoint = flipPricePoint(pricePoint) } const assetDecimals = isFungibleAsset(assetToFind) diff --git a/background/redux-slices/assets.test.ts b/background/redux-slices/assets.unit.test.ts similarity index 100% rename from background/redux-slices/assets.test.ts rename to background/redux-slices/assets.unit.test.ts diff --git a/background/redux-slices/nfts_update.ts b/background/redux-slices/nfts_update.ts index a8f3ac90f6..1c1981eb5f 100644 --- a/background/redux-slices/nfts_update.ts +++ b/background/redux-slices/nfts_update.ts @@ -148,6 +148,22 @@ function updateFilters(acc: NFTsSliceState, collection: NFTCollection): void { updateFilter(acc, collection, "accounts") } +function removeAccountFromFilters(acc: NFTsSliceState, address: string): void { + acc.filters.accounts = acc.filters.accounts.filter(({ id }) => id !== address) + acc.filters.collections = acc.filters.collections.flatMap((collection) => { + if (collection.owners?.includes(address)) { + return collection.owners.length === 1 + ? [] + : { + ...collection, + owners: collection.owners.filter((owner) => owner !== address), + } + } + + return collection + }) +} + function initializeCollections(collections: NFTCollection[]): NFTsSliceState { const state: NFTsSliceState = { isReloading: false, @@ -230,6 +246,7 @@ const NFTsSlice = createSlice({ ) => { const normalizedAddress = normalizeEVMAddress(address) + removeAccountFromFilters(immerState, normalizedAddress) Object.keys(immerState.nfts).forEach((chainID) => { delete immerState.nfts[chainID][normalizedAddress] }) @@ -238,7 +255,9 @@ const NFTsSlice = createSlice({ immerState, { payload: transferredNFTs }: { payload: TransferredNFT[] } ) => { - transferredNFTs.forEach(({ id: nftID, chainID, address }) => { + transferredNFTs.forEach(({ id: nftID, chainID, from: address }) => { + if (!address) return + const normalizedAddress = normalizeEVMAddress(address) Object.keys(immerState.nfts[chainID][normalizedAddress] ?? {}).forEach( (collectionID) => { @@ -252,6 +271,10 @@ const NFTsSlice = createSlice({ if (hasTransferredNFT) { if (collection.nfts.length === 1) { + immerState.filters.collections = + immerState.filters.collections.filter( + ({ id }) => id !== collectionID + ) delete immerState.nfts[chainID][normalizedAddress][ collectionID ] diff --git a/background/redux-slices/selectors/abilities.ts b/background/redux-slices/selectors/abilities.ts index 50fe826647..bc52ac79f9 100644 --- a/background/redux-slices/selectors/abilities.ts +++ b/background/redux-slices/selectors/abilities.ts @@ -35,3 +35,8 @@ export const selectAbilityCount = createSelector( selectFilteredAbilities, (abilities) => abilities.length ) + +export const selectHideDescription = createSelector( + (state: RootState) => state.abilities.hideDescription, + (hideDescription) => hideDescription +) diff --git a/background/redux-slices/selectors/accountsSelectors.ts b/background/redux-slices/selectors/accountsSelectors.ts index e342ee584e..beb246f04a 100644 --- a/background/redux-slices/selectors/accountsSelectors.ts +++ b/background/redux-slices/selectors/accountsSelectors.ts @@ -7,8 +7,9 @@ import { enrichAssetAmountWithDecimalValues, enrichAssetAmountWithMainCurrencyValues, formatCurrencyAmount, + getBuiltInNetworkBaseAsset, heuristicDesiredDecimalsForUnitPrice, - isNetworkBaseAsset, + isBuiltInNetworkBaseAsset, } from "../utils/asset-utils" import { AnyAsset, @@ -33,11 +34,7 @@ import { } from "./keyringsSelectors" import { AccountBalance, AddressOnNetwork } from "../../accounts" import { EVMNetwork, NetworkBaseAsset, sameNetwork } from "../../networks" -import { - BASE_ASSETS_BY_SYMBOL, - NETWORK_BY_CHAIN_ID, - TEST_NETWORK_BY_CHAIN_ID, -} from "../../constants" +import { NETWORK_BY_CHAIN_ID, TEST_NETWORK_BY_CHAIN_ID } from "../../constants" import { DOGGO } from "../../constants/assets" import { FeatureFlags, isEnabled } from "../../features" import { @@ -74,7 +71,7 @@ const shouldForciblyDisplayAsset = ( !isEnabled(FeatureFlags.HIDE_TOKEN_FEATURES) && assetAmount.asset.symbol === DOGGO.symbol - return isDoggo || isNetworkBaseAsset(baseAsset, network) + return isDoggo || isBuiltInNetworkBaseAsset(baseAsset, network) } const computeCombinedAssetAmountsData = ( @@ -128,7 +125,10 @@ const computeCombinedAssetAmountsData = ( return fullyEnrichedAssetAmount }) .filter((assetAmount) => { - const baseAsset = BASE_ASSETS_BY_SYMBOL[assetAmount.asset.symbol] + const baseAsset = getBuiltInNetworkBaseAsset( + assetAmount.asset.symbol, + currentNetwork.chainID + ) const isForciblyDisplayed = shouldForciblyDisplayAsset( assetAmount, @@ -167,9 +167,14 @@ const computeCombinedAssetAmountsData = ( if (leftIsNetworkBaseAsset !== rightIsNetworkBaseAsset) { return leftIsNetworkBaseAsset ? -1 : 1 } - - const leftIsBaseAsset = asset1.asset.symbol in BASE_ASSETS_BY_SYMBOL - const rightIsBaseAsset = asset2.asset.symbol in BASE_ASSETS_BY_SYMBOL + const leftIsBaseAsset = !!getBuiltInNetworkBaseAsset( + asset1.asset.symbol, + networkBaseAsset.chainID + ) + const rightIsBaseAsset = !!getBuiltInNetworkBaseAsset( + asset2.asset.symbol, + networkBaseAsset.chainID + ) // Always sort base assets above non-base assets. if (leftIsBaseAsset !== rightIsBaseAsset) { diff --git a/background/redux-slices/selectors/nftsSelectors_update.ts b/background/redux-slices/selectors/nftsSelectors_update.ts index 7075c68cc2..3fd2f3f24a 100644 --- a/background/redux-slices/selectors/nftsSelectors_update.ts +++ b/background/redux-slices/selectors/nftsSelectors_update.ts @@ -7,10 +7,10 @@ import { getAdditionalDataForFilter, getFilteredCollections, getNFTsCount, - getTotalFloorPriceInETH, -} from "../utils/nfts_update" -import { selectAccountTotals } from "./accountsSelectors" -import { selectCurrentAccount } from "./uiSelectors" + getTotalFloorPrice, +} from "../utils/nfts-utils" +import { getAssetsState, selectAccountTotals } from "./accountsSelectors" +import { selectCurrentAccount, selectMainCurrencySymbol } from "./uiSelectors" const selectNFTs = createSelector( (state: RootState) => state.nftsUpdate, @@ -60,12 +60,16 @@ export const selectEnrichedNFTFilters = createSelector( return [...acc] }, []) - const collections = filters.collections.filter(({ owners }) => { - const enablingAccount = (owners ?? []).find((owner) => - accounts.find((account) => account.id === owner && account.isEnabled) + const collections = filters.collections + .filter(({ owners }) => { + const enablingAccount = (owners ?? []).find((owner) => + accounts.find((account) => account.id === owner && account.isEnabled) + ) + return !!enablingAccount + }) + .sort((collection1, collection2) => + collection1.name.localeCompare(collection2.name) ) - return !!enablingAccount - }) return { ...filters, collections, accounts } } ) @@ -92,13 +96,19 @@ const selectAllNFTBadgesCollections = createSelector( export const selectFilteredNFTCollections = createSelector( selectAllNFTCollections, selectNFTFilters, - (collections, filters) => getFilteredCollections(collections, filters) + getAssetsState, + selectMainCurrencySymbol, + (collections, filters, assets, mainCurrencySymbol) => + getFilteredCollections(collections, filters, assets, mainCurrencySymbol) ) export const selectFilteredNFTBadgesCollections = createSelector( selectAllNFTBadgesCollections, selectNFTFilters, - (collections, filters) => getFilteredCollections(collections, filters) + getAssetsState, + selectMainCurrencySymbol, + (collections, filters, assets, mainCurrencySymbol) => + getFilteredCollections(collections, filters, assets, mainCurrencySymbol) ) /* Counting selectors */ @@ -117,11 +127,6 @@ export const selectAllNFTBadgesCount = createSelector( (collections) => getNFTsCount(collections) ) -export const selectAllNFTCollectionsCount = createSelector( - selectAllNFTCollections, - (collections) => collections.length -) - export const selectFilteredNFTsCount = createSelector( selectFilteredNFTCollections, (collections) => getNFTsCount(collections) @@ -138,12 +143,7 @@ export const selectFilteredNFTCollectionsCount = createSelector( ) /* Total Floor Price selectors */ -export const selectTotalFloorPriceInETH = createSelector( - selectAllCollections, - (collections) => getTotalFloorPriceInETH(collections) -) - -export const selectFilteredTotalFloorPriceInETH = createSelector( +export const selectFilteredTotalFloorPrice = createSelector( selectFilteredNFTCollections, - (collections) => getTotalFloorPriceInETH(collections) + (collections) => getTotalFloorPrice(collections) ) diff --git a/background/redux-slices/utils/asset-utils.ts b/background/redux-slices/utils/asset-utils.ts index 09e153dba2..b9727efb70 100644 --- a/background/redux-slices/utils/asset-utils.ts +++ b/background/redux-slices/utils/asset-utils.ts @@ -8,8 +8,13 @@ import { FungibleAsset, UnitPricePoint, AnyAsset, + CoinGeckoAsset, } from "../../assets" -import { OPTIMISM } from "../../constants" +import { + BUILT_IN_NETWORK_BASE_ASSETS, + OPTIMISM, + POLYGON, +} from "../../constants" import { fromFixedPointNumber } from "../../lib/fixed-point" import { AnyNetwork, NetworkBaseAsset } from "../../networks" import { hardcodedMainCurrencySign } from "./constants" @@ -37,7 +42,7 @@ export type AssetDecimalAmount = { localizedDecimalAmount: string } -function isBaseAsset(asset: AnyAsset): asset is NetworkBaseAsset { +function hasCoinType(asset: AnyAsset): asset is NetworkBaseAsset { return "coinType" in asset } @@ -48,6 +53,13 @@ function isOptimismBaseAsset(asset: AnyAsset) { ) } +function isPolygonBaseAsset(asset: AnyAsset) { + return ( + "contractAddress" in asset && + asset.contractAddress === POLYGON.baseAsset.contractAddress + ) +} + /** * Given an asset and a network, determines whether the given asset is the base * asset for the given network. Used to special-case transactions that should @@ -58,7 +70,7 @@ function isOptimismBaseAsset(asset: AnyAsset) { * * @return True if the passed asset is the base asset for the passed network. */ -export function isNetworkBaseAsset( +export function isBuiltInNetworkBaseAsset( asset: AnyAsset, network: AnyNetwork ): asset is NetworkBaseAsset { @@ -66,14 +78,30 @@ export function isNetworkBaseAsset( return true } + if (network.chainID === POLYGON.chainID && isPolygonBaseAsset(asset)) { + return true + } + return ( - isBaseAsset(asset) && + hasCoinType(asset) && asset.symbol === network.baseAsset.symbol && asset.coinType === network.baseAsset.coinType && asset.name === network.baseAsset.name ) } +/** + * Return network base asset for chain by asset symbol. + */ +export function getBuiltInNetworkBaseAsset( + symbol: string, + chainID: string +): (NetworkBaseAsset & Required) | undefined { + return BUILT_IN_NETWORK_BASE_ASSETS.find( + (asset) => asset.symbol === symbol && asset.chainID === chainID + ) +} + /** * Given an asset symbol, price as a JavaScript number, and a number of desired * decimals during formatting, format the price in a localized way as a diff --git a/background/redux-slices/utils/nfts-utils.ts b/background/redux-slices/utils/nfts-utils.ts new file mode 100644 index 0000000000..f9903c5434 --- /dev/null +++ b/background/redux-slices/utils/nfts-utils.ts @@ -0,0 +1,232 @@ +import { BUILT_IN_NETWORK_BASE_ASSETS } from "../../constants" +import { NFT } from "../../nfts" +import { AssetsState, selectAssetPricePoint } from "../assets" +import { + Filter, + FiltersState, + NFTCollectionCached, + SortType, +} from "../nfts_update" +import { enrichAssetAmountWithMainCurrencyValues } from "./asset-utils" + +const ETH_SYMBOLS = ["ETH", "WETH"] + +export type AccountData = { + address: string + name: string + avatarURL: string +} + +type NFTCollectionEnriched = NFTCollectionCached & { + floorPrice?: { + value: number + valueUSD?: number + tokenSymbol: string + } +} + +const isEnabledFilter = (id: string, filters: Filter[]): boolean => { + return !!filters.find((filter) => id === filter.id && filter.isEnabled) +} + +const isETHPrice = (collection: NFTCollectionCached): boolean => { + return !!ETH_SYMBOLS.includes(collection?.floorPrice?.tokenSymbol ?? "") +} + +export const getAdditionalDataForFilter = ( + id: string, + accounts: AccountData[] +): { name?: string; thumbnailURL?: string } => { + const a = accounts.find(({ address }) => address === id) + return a ? { name: a.name, thumbnailURL: a.avatarURL } : {} +} + +/* Items are sorted by price in USD. All other elements are added at the end. */ +export const sortByPrice = ( + type: "asc" | "desc", + collection1: NFTCollectionEnriched, + collection2: NFTCollectionEnriched +): number => { + if (collection1.floorPrice?.valueUSD === undefined) return 1 + if (collection2.floorPrice?.valueUSD === undefined) return -1 + + if (type === "asc") { + return collection1.floorPrice.valueUSD - collection2.floorPrice.valueUSD + } + return collection2.floorPrice.valueUSD - collection1.floorPrice.valueUSD +} + +const sortByDate = ( + type: "new" | "old", + collection1: NFTCollectionCached, + collection2: NFTCollectionCached +): number => { + // NFTs are already sorted with current sort type + const transferDate1 = new Date( + collection1.nfts[0]?.transferDate ?? "" + ).getTime() + const transferDate2 = new Date( + collection2.nfts[0]?.transferDate ?? "" + ).getTime() + + if (type === "new") { + return transferDate1 > transferDate2 ? -1 : 1 + } + + return transferDate1 > transferDate2 ? 1 : -1 +} + +const sortNFTsByDate = (type: "new" | "old", nfts: NFT[]): NFT[] => { + const sorted = nfts.sort( + (nft1, nft2) => + new Date(nft2.transferDate ?? "").getTime() - + new Date(nft1.transferDate ?? "").getTime() + ) + + return type === "new" ? sorted : sorted.reverse() +} + +const sortByNFTCount = ( + collection1: NFTCollectionCached, + collection2: NFTCollectionCached +): number => + (Number(collection2?.nftCount) || 0) - (Number(collection1?.nftCount) || 0) + +export const sortCollections = ( + collection1: NFTCollectionCached, + collection2: NFTCollectionCached, + type: SortType +): number => { + switch (type) { + case "asc": + return sortByPrice("asc", collection1, collection2) + case "desc": + return sortByPrice("desc", collection1, collection2) + case "new": + return sortByDate("new", collection1, collection2) + case "old": + return sortByDate("old", collection1, collection2) + case "number": + return sortByNFTCount(collection1, collection2) + default: + return 0 + } +} + +const sortNFTs = ( + collection: NFTCollectionCached, + type: SortType +): NFTCollectionCached => { + switch (type) { + case "new": + return { + ...collection, + nfts: sortNFTsByDate("new", collection.nfts), + } + case "old": + return { + ...collection, + nfts: sortNFTsByDate("old", collection.nfts), + } + default: + return collection + } +} + +type TotalFloorPriceMap = { [symbol: string]: number } + +export const getTotalFloorPrice = ( + collections: NFTCollectionCached[] +): TotalFloorPriceMap => + collections.reduce( + (acc, collection) => { + if (!collection.floorPrice) return acc + + const sum = collection.floorPrice.value * (collection.nftCount ?? 0) + + if (isETHPrice(collection)) { + acc.ETH += sum + } else { + acc[collection.floorPrice.tokenSymbol] ??= 0 + acc[collection.floorPrice.tokenSymbol] += sum + } + + return acc + }, + { ETH: 0 } as TotalFloorPriceMap + ) + +export const getNFTsCount = (collections: NFTCollectionCached[]): number => + collections.reduce((sum, collection) => sum + (collection.nftCount ?? 0), 0) + +export function enrichCollectionWithUSDFloorPrice( + collection: NFTCollectionCached, + assets: AssetsState, + mainCurrencySymbol: string +): NFTCollectionEnriched { + if (!collection.floorPrice) return collection + + const { tokenSymbol, value } = collection.floorPrice + const symbol = isETHPrice(collection) ? "ETH" : tokenSymbol + + const baseAsset = BUILT_IN_NETWORK_BASE_ASSETS.find( + (asset) => symbol === asset.symbol + ) + + if (!baseAsset) return collection + + const pricePoint = selectAssetPricePoint( + assets, + baseAsset, + mainCurrencySymbol + ) + + const valueUSD = + enrichAssetAmountWithMainCurrencyValues( + { + asset: baseAsset, + amount: BigInt(Math.round(value * 10 ** baseAsset.decimals)), + }, + pricePoint, + 2 + ).mainCurrencyAmount ?? 0 + + return { + ...collection, + floorPrice: { + value, + valueUSD, + tokenSymbol, + }, + } +} + +export const getFilteredCollections = ( + collections: NFTCollectionCached[], + filters: FiltersState, + assets: AssetsState, + mainCurrencySymbol: string +): NFTCollectionCached[] => { + const applyPriceSort = filters.type === "asc" || filters.type === "desc" + + return collections + .filter( + (collection) => + isEnabledFilter(collection.id, filters.collections) && + isEnabledFilter(collection.owner, filters.accounts) + ) + .map((collection) => { + const collectionWithSortedNFTs = sortNFTs(collection, filters.type) + + return applyPriceSort + ? enrichCollectionWithUSDFloorPrice( + collectionWithSortedNFTs, + assets, + mainCurrencySymbol + ) + : collectionWithSortedNFTs + }) + .sort((collection1, collection2) => + sortCollections(collection1, collection2, filters.type) + ) +} diff --git a/background/redux-slices/utils/nfts-utils.unit.test.ts b/background/redux-slices/utils/nfts-utils.unit.test.ts new file mode 100644 index 0000000000..6c039e3e2a --- /dev/null +++ b/background/redux-slices/utils/nfts-utils.unit.test.ts @@ -0,0 +1,299 @@ +import { AVAX, BNB, ETH, ETHEREUM, USD } from "../../constants" +import { AssetsState } from "../assets" +import { + enrichCollectionWithUSDFloorPrice, + getTotalFloorPrice, + sortByPrice, +} from "./nfts-utils" +import { createPricePoint } from "../../tests/factories" + +const COLLECTION_MOCK = { + id: "", + name: "", + owner: "", + network: ETHEREUM, // doesn't matter for now + hasBadges: false, + nfts: [], // doesn't matter for now + hasNextPage: false, +} + +const assetsState: AssetsState = [ + { + ...ETH, + recentPrices: { + USD: createPricePoint(ETH, 2000), + }, + }, + { + ...AVAX, + recentPrices: { + USD: createPricePoint(AVAX, 15), + }, + }, + { + ...BNB, + recentPrices: { + USD: createPricePoint(BNB, 50), + }, + }, +] + +describe("NFTs utils", () => { + describe("getTotalFloorPrice", () => { + test("should sum ETH and WETH floor prices", () => { + const collections = [ + { + ...COLLECTION_MOCK, + nftCount: 1, + floorPrice: { value: 0.001, tokenSymbol: "ETH" }, + }, + { + ...COLLECTION_MOCK, + nftCount: 1, + floorPrice: { value: 0.002, tokenSymbol: "WETH" }, + }, + ] + + expect(getTotalFloorPrice(collections)).toMatchObject({ ETH: 0.003 }) + }) + test("should sum floor prices for multiple currencies", () => { + const collections = [ + { + ...COLLECTION_MOCK, + nftCount: 1, + floorPrice: { value: 0.002, tokenSymbol: "BNB" }, + }, + { + ...COLLECTION_MOCK, + nftCount: 1, + floorPrice: { value: 0.003, tokenSymbol: "ETH" }, + }, + { + ...COLLECTION_MOCK, + nftCount: 1, + floorPrice: { value: 0.001, tokenSymbol: "BNB" }, + }, + { + ...COLLECTION_MOCK, + nftCount: 1, + floorPrice: { value: 0.002, tokenSymbol: "AVAX" }, + }, + { + ...COLLECTION_MOCK, + nftCount: 1, + floorPrice: { value: 0.001, tokenSymbol: "AVAX" }, + }, + ] + + expect(getTotalFloorPrice(collections)).toMatchObject({ + ETH: 0.003, + AVAX: 0.003, + BNB: 0.003, + }) + }) + test("should sum floor prices for collections with multiple NFTs owned", () => { + const collections = [ + { + ...COLLECTION_MOCK, + nftCount: 10, + floorPrice: { value: 0.001, tokenSymbol: "ETH" }, + }, + { + ...COLLECTION_MOCK, + nftCount: 5, + floorPrice: { value: 0.002, tokenSymbol: "WETH" }, + }, + ] + + expect(getTotalFloorPrice(collections)).toMatchObject({ ETH: 0.02 }) + }) + test("should sum correctly when some collection has 0 NFTs", () => { + const collections = [ + { + ...COLLECTION_MOCK, + floorPrice: { value: 0.001, tokenSymbol: "ETH" }, + }, + { + ...COLLECTION_MOCK, + nftCount: 0, + floorPrice: { value: 0.002, tokenSymbol: "WETH" }, + }, + { + ...COLLECTION_MOCK, + nftCount: 1, + floorPrice: { value: 0.001, tokenSymbol: "WETH" }, + }, + ] + + expect(getTotalFloorPrice(collections)).toMatchObject({ ETH: 0.001 }) + }) + test("should sum correctly if some collections have no floor prices", () => { + const collections = [ + COLLECTION_MOCK, + { + ...COLLECTION_MOCK, + nftCount: 1, + floorPrice: { value: 0.001, tokenSymbol: "WETH" }, + }, + COLLECTION_MOCK, + ] + + expect(getTotalFloorPrice(collections)).toMatchObject({ ETH: 0.001 }) + }) + }) + + describe("enrichCollectionWithUSDFloorPrice", () => { + test("should add USD price if floor price is in ETH", () => { + const collection = { + ...COLLECTION_MOCK, + floorPrice: { + value: 1, + tokenSymbol: "ETH", + }, + } + + expect( + enrichCollectionWithUSDFloorPrice(collection, assetsState, USD.symbol) + .floorPrice + ).toMatchObject({ + value: 1, + valueUSD: 2000, + tokenSymbol: "ETH", + }) + }) + test("should add USD price if floor price is in WETH", () => { + const collection = { + ...COLLECTION_MOCK, + floorPrice: { + value: 0.5, + tokenSymbol: "WETH", + }, + } + + expect( + enrichCollectionWithUSDFloorPrice(collection, assetsState, USD.symbol) + .floorPrice + ).toMatchObject({ + value: 0.5, + valueUSD: 1000, + tokenSymbol: "WETH", + }) + }) + test("should add USD price if floor price is in AVAX", () => { + const collection = { + ...COLLECTION_MOCK, + floorPrice: { + value: 2, + tokenSymbol: "AVAX", + }, + } + + expect( + enrichCollectionWithUSDFloorPrice(collection, assetsState, USD.symbol) + .floorPrice + ).toMatchObject({ + value: 2, + valueUSD: 30, + tokenSymbol: "AVAX", + }) + }) + test("should add USD price if floor price is in BNB", () => { + const collection = { + ...COLLECTION_MOCK, + floorPrice: { + value: 0.5, + tokenSymbol: "BNB", + }, + } + + expect( + enrichCollectionWithUSDFloorPrice(collection, assetsState, USD.symbol) + .floorPrice + ).toMatchObject({ + value: 0.5, + valueUSD: 25, + tokenSymbol: "BNB", + }) + }) + test("shouldn't add USD price if base asset is not found in assets list", () => { + const collection = { + ...COLLECTION_MOCK, + floorPrice: { + value: 0.5, + tokenSymbol: "MATIC", + }, + } + + expect( + enrichCollectionWithUSDFloorPrice(collection, assetsState, USD.symbol) + .floorPrice + ).toMatchObject({ + value: 0.5, + tokenSymbol: "MATIC", + }) + }) + test("shouldn't add USD price if there is not floor price", () => { + const collection = COLLECTION_MOCK + expect( + enrichCollectionWithUSDFloorPrice(collection, assetsState, USD.symbol) + .floorPrice + ).toBeUndefined() + }) + test("shouldn't add floor price if price is not using base assets", () => { + const collection = { + ...COLLECTION_MOCK, + floorPrice: { + value: 0.5, + tokenSymbol: "XYZ", + }, + } + + expect( + enrichCollectionWithUSDFloorPrice(collection, assetsState, USD.symbol) + .floorPrice + ).toMatchObject({ + value: 0.5, + tokenSymbol: "XYZ", + }) + }) + }) + + describe("sortByPrice", () => { + const collections = [ + { + ...COLLECTION_MOCK, + id: "cheap", + floorPrice: { value: 1, valueUSD: 1, tokenSymbol: "USDT" }, + }, + { + ...COLLECTION_MOCK, + id: "expensive", + floorPrice: { value: 100, valueUSD: 100, tokenSymbol: "USDT" }, + }, + { + ...COLLECTION_MOCK, + id: "zero", + floorPrice: { value: 0, valueUSD: 0, tokenSymbol: "USDT" }, + }, + { + ...COLLECTION_MOCK, + id: "undefined", + }, + ] + + test("should sort collection by ascending floor price", () => { + expect( + collections + .sort((a, b) => sortByPrice("asc", a, b)) + .map((collection) => collection.id) + ).toMatchObject(["zero", "cheap", "expensive", "undefined"]) + }) + test("should sort collection by descending floor price", () => { + expect( + collections + .sort((a, b) => sortByPrice("desc", a, b)) + .map((collection) => collection.id) + ).toMatchObject(["expensive", "cheap", "zero", "undefined"]) + }) + }) +}) diff --git a/background/redux-slices/utils/nfts_update.ts b/background/redux-slices/utils/nfts_update.ts deleted file mode 100644 index 2f778d7cd0..0000000000 --- a/background/redux-slices/utils/nfts_update.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { - Filter, - FiltersState, - NFTCollectionCached, - SortType, -} from "../nfts_update" - -const ETH_SYMBOLS = ["ETH", "WETH"] - -export type AccountData = { - address: string - name: string - avatarURL: string -} - -const isEnabledFilter = (id: string, filters: Filter[]): boolean => { - return !!filters.find((filter) => id === filter.id && filter.isEnabled) -} - -const isETHPrice = (collection: NFTCollectionCached): boolean => { - return !!ETH_SYMBOLS.includes(collection?.floorPrice?.tokenSymbol ?? "") -} - -export const getAdditionalDataForFilter = ( - id: string, - accounts: AccountData[] -): { name?: string; thumbnailURL?: string } => { - const a = accounts.find(({ address }) => address === id) - return a ? { name: a.name, thumbnailURL: a.avatarURL } : {} -} - -/* Items are sorted by price in ETH. All other elements are added at the end. */ -const sortByPrice = ( - type: "asc" | "desc", - collection1: NFTCollectionCached, - collection2: NFTCollectionCached -): number => { - if (collection1.floorPrice && collection2.floorPrice) { - if (isETHPrice(collection1) && isETHPrice(collection2)) { - if (type === "asc") { - return collection1.floorPrice.value - collection2.floorPrice.value - } - return collection2.floorPrice.value - collection1.floorPrice.value - } - } - if (collection1.floorPrice === undefined) return 1 - if (collection2.floorPrice === undefined) return -1 - - return 1 -} - -const sortByDate = ( - type: "new" | "old", - collection1: NFTCollectionCached, - collection2: NFTCollectionCached -): number => { - const dates1 = collection1.nfts.map(({ transferDate }) => - new Date(transferDate || "").getTime() - ) - const dates2 = collection2.nfts.map(({ transferDate }) => - new Date(transferDate || "").getTime() - ) - - const transferDate1 = new Date( - type === "new" ? Math.max(...dates1) : Math.min(...dates1) - ) - const transferDate2 = new Date( - type === "new" ? Math.max(...dates2) : Math.min(...dates2) - ) - - if (type === "new") { - return transferDate1 > transferDate2 ? -1 : 1 - } - - return transferDate1 > transferDate2 ? 1 : -1 -} - -const sortByNFTCount = ( - collection1: NFTCollectionCached, - collection2: NFTCollectionCached -): number => - (Number(collection2?.nftCount) || 0) - (Number(collection1?.nftCount) || 0) - -export const sortNFTS = ( - collection1: NFTCollectionCached, - collection2: NFTCollectionCached, - type: SortType -): number => { - switch (type) { - case "asc": - return sortByPrice("asc", collection1, collection2) - case "desc": - return sortByPrice("desc", collection1, collection2) - case "new": - return sortByDate("new", collection1, collection2) - case "old": - return sortByDate("old", collection1, collection2) - case "number": - return sortByNFTCount(collection1, collection2) - default: - return 0 - } -} - -export const getTotalFloorPriceInETH = ( - collections: NFTCollectionCached[] -): number => - collections.reduce((sum, collection) => { - if (collection.floorPrice && isETHPrice(collection)) { - return sum + collection.floorPrice.value * (collection.nftCount ?? 0) - } - - return sum - }, 0) - -export const getNFTsCount = (collections: NFTCollectionCached[]): number => - collections.reduce((sum, collection) => sum + (collection.nftCount ?? 0), 0) - -export const getFilteredCollections = ( - collections: NFTCollectionCached[], - filters: FiltersState -): NFTCollectionCached[] => - collections - .filter( - (collection) => - isEnabledFilter(collection.id, filters.collections) && - isEnabledFilter(collection.owner, filters.accounts) - ) - .sort((collection1, collection2) => - sortNFTS(collection1, collection2, filters.type) - ) diff --git a/background/services/chain/db.ts b/background/services/chain/db.ts index 1535e63ccb..17d5cf74e1 100644 --- a/background/services/chain/db.ts +++ b/background/services/chain/db.ts @@ -7,9 +7,16 @@ import { AnyEVMTransaction, EVMNetwork, Network, + NetworkBaseAsset, } from "../../networks" import { FungibleAsset } from "../../assets" -import { DEFAULT_NETWORKS, GOERLI, POLYGON } from "../../constants" +import { + BASE_ASSETS, + CHAIN_ID_TO_RPC_URLS, + DEFAULT_NETWORKS, + GOERLI, + POLYGON, +} from "../../constants" export type Transaction = AnyEVMTransaction & { dataSource: "alchemy" | "local" @@ -70,6 +77,10 @@ export class ChainDatabase extends Dexie { private networks!: Dexie.Table + private baseAssets!: Dexie.Table + + private rpcUrls!: Dexie.Table<{ chainID: string; rpcUrls: string[] }, string> + constructor(options?: DexieOptions) { super("tally/chain", options) this.version(1).stores({ @@ -146,6 +157,20 @@ export class ChainDatabase extends Dexie { this.version(5).stores({ networks: "&chainID,name,family", }) + + this.version(6).stores({ + baseAssets: "&chainID,symbol,name", + }) + + this.version(7).stores({ + rpcUrls: "&chainID, rpcUrls", + }) + } + + async initialize(): Promise { + await this.initializeBaseAssets() + await this.initializeRPCs() + await this.initializeEVMNetworks() } async getLatestBlock(network: Network): Promise { @@ -176,10 +201,74 @@ export class ChainDatabase extends Dexie { ) } + async addEVMNetwork({ + chainName, + chainID, + decimals, + symbol, + assetName, + rpcUrls, + }: { + chainName: string + chainID: string + decimals: number + symbol: string + assetName: string + rpcUrls: string[] + }): Promise { + await this.networks.put({ + name: chainName, + chainID, + family: "EVM", + baseAsset: { + decimals, + symbol, + name: assetName, + chainID, + }, + }) + // A bit awkward that we are adding the base asset to the network as well + // as to its own separate table - but lets forge on for now. + await this.addBaseAsset(assetName, symbol, chainID, decimals) + await this.addRpcUrls(chainID, rpcUrls) + } + async getAllEVMNetworks(): Promise { return this.networks.where("family").equals("EVM").toArray() } + private async addBaseAsset( + name: string, + symbol: string, + chainID: string, + decimals: number + ) { + await this.baseAssets.put({ + decimals, + name, + symbol, + chainID, + }) + } + + async getAllBaseAssets(): Promise { + return this.baseAssets.toArray() + } + + async initializeRPCs(): Promise { + await Promise.all( + Object.entries(CHAIN_ID_TO_RPC_URLS).map(async ([chainId, rpcUrls]) => { + if (rpcUrls) { + await this.addRpcUrls(chainId, rpcUrls) + } + }) + ) + } + + async initializeBaseAssets(): Promise { + await this.updateBaseAssets(BASE_ASSETS) + } + async initializeEVMNetworks(): Promise { const existingNetworks = await this.getAllEVMNetworks() await Promise.all( @@ -195,6 +284,31 @@ export class ChainDatabase extends Dexie { ) } + async getRpcUrlsByChainId(chainId: string): Promise { + const rpcUrls = await this.rpcUrls.where({ chainId }).first() + if (rpcUrls) { + return rpcUrls.rpcUrls + } + throw new Error(`No RPC Found for ${chainId}`) + } + + private async addRpcUrls(chainID: string, rpcUrls: string[]): Promise { + const existingRpcUrlsForChain = await this.rpcUrls.get(chainID) + if (existingRpcUrlsForChain) { + existingRpcUrlsForChain.rpcUrls.push(...rpcUrls) + existingRpcUrlsForChain.rpcUrls = [ + ...new Set(existingRpcUrlsForChain.rpcUrls), + ] + await this.rpcUrls.put(existingRpcUrlsForChain) + } else { + await this.rpcUrls.put({ chainID, rpcUrls }) + } + } + + async getAllRpcUrls(): Promise<{ chainID: string; rpcUrls: string[] }[]> { + return this.rpcUrls.toArray() + } + async getAllSavedTransactionHashes(): Promise { return this.chainTransactions.orderBy("hash").keys() } @@ -343,6 +457,10 @@ export class ChainDatabase extends Dexie { await this.balances.add(accountBalance) } + async updateBaseAssets(baseAssets: NetworkBaseAsset[]): Promise { + await this.baseAssets.bulkPut(baseAssets) + } + async getAccountsToTrack(): Promise { return this.accountsToTrack.toArray() } diff --git a/background/services/chain/index.ts b/background/services/chain/index.ts index 567ae4916c..83b9cc1a6d 100644 --- a/background/services/chain/index.ts +++ b/background/services/chain/index.ts @@ -62,6 +62,7 @@ import { OPTIMISM_GAS_ORACLE_ADDRESS, } from "./utils/optimismGasPriceOracle" import KeyringService from "../keyring" +import type { ValidatedAddEthereumChainParameter } from "../internal-ethereum-provider" // The number of blocks to query at a time for historic asset transfers. // Unfortunately there's no "right" answer here that works well across different @@ -90,6 +91,12 @@ const GAS_POLLS_PER_PERIOD = 4 // 4 times per minute const GAS_POLLING_PERIOD = 1 // 1 minute +// Maximum number of transactions with priority. +// Transactions that will be retrieved before others for one account. +// Transactions with priority for individual accounts will keep the order of loading +// from adding accounts. +const TRANSACTIONS_WITH_PRIORITY_MAX_COUNT = 25 + interface Events extends ServiceLifecycleEvents { initializeActivities: { transactions: Transaction[] @@ -126,6 +133,15 @@ export type QueuedTxToRetrieve = { hash: HexString firstSeen: UNIXTime } +/** + * The queue object contains transaction and priority. + * The priority value is a number. The value of the highest priority has not been set. + * The lowest possible priority is 0. + */ +export type PriorityQueuedTxToRetrieve = { + transaction: QueuedTxToRetrieve + priority: number +} /** * ChainService is responsible for basic network monitoring and interaction. @@ -180,11 +196,11 @@ export default class ChainService extends BaseService { } = {} /** - * FIFO queues of transaction hashes per network that should be retrieved and + * Modified FIFO queues with priority of transaction hashes per network that should be retrieved and * cached, alongside information about when that hash request was first seen - * for expiration purposes. + * for expiration purposes. In the absence of priorities, it acts as a regular FIFO queue. */ - private transactionsToRetrieve: QueuedTxToRetrieve[] + private transactionsToRetrieve: PriorityQueuedTxToRetrieve[] /** * Internal timer for the transactionsToRetrieve FIFO queue. @@ -284,9 +300,11 @@ export default class ChainService extends BaseService { override async internalStartService(): Promise { await super.internalStartService() + await this.db.initialize() await this.initializeNetworks() const accounts = await this.getAccountsToTrack() const trackedNetworks = await this.getTrackedNetworks() + const transactions = await this.db.getAllTransactions() this.emitter.emit("initializeActivities", { transactions, accounts }) @@ -329,7 +347,7 @@ export default class ChainService extends BaseService { } async initializeNetworks(): Promise { - await this.db.initializeEVMNetworks() + const rpcUrls = await this.db.getAllRpcUrls() if (!this.supportedNetworks.length) { this.supportedNetworks = await this.db.getAllEVMNetworks() } @@ -342,7 +360,10 @@ export default class ChainService extends BaseService { evm: Object.fromEntries( this.supportedNetworks.map((network) => [ network.chainID, - makeSerialFallbackProvider(network), + makeSerialFallbackProvider( + network, + rpcUrls.find((v) => v.chainID === network.chainID)?.rpcUrls || [] + ), ]) ), } @@ -794,7 +815,7 @@ export default class ChainService extends BaseService { // now-released-and-therefore-never-broadcast nonce). this.evmChainLastSeenNoncesByNormalizedAddress[chainID][ normalizedAddress - ] = lastSeenNonce - 1 + ] = nonce - 1 } } } @@ -989,17 +1010,33 @@ export default class ChainService extends BaseService { * @param firstSeen The timestamp at which the queued transaction was first * seen; used to treat transactions as dropped after a certain amount * of time. + * @param priority The priority of the transaction in the queue to be retrieved */ queueTransactionHashToRetrieve( network: EVMNetwork, txHash: HexString, - firstSeen: UNIXTime + firstSeen: UNIXTime, + priority = 0 ): void { + const newElement: PriorityQueuedTxToRetrieve = { + transaction: { hash: txHash, network, firstSeen }, + priority, + } const seen = this.isTransactionHashQueued(network, txHash) - if (!seen) { // @TODO Interleave initial transaction retrieval by network - this.transactionsToRetrieve.push({ hash: txHash, network, firstSeen }) + const existingTransactionIndex = this.transactionsToRetrieve.findIndex( + ({ priority: txPriority }) => newElement.priority > txPriority + ) + if (existingTransactionIndex >= 0) { + this.transactionsToRetrieve.splice( + existingTransactionIndex, + 0, + newElement + ) + } else { + this.transactionsToRetrieve.push(newElement) + } } } @@ -1011,8 +1048,9 @@ export default class ChainService extends BaseService { */ isTransactionHashQueued(txNetwork: EVMNetwork, txHash: HexString): boolean { return this.transactionsToRetrieve.some( - ({ hash, network }) => - hash === txHash && txNetwork.chainID === network.chainID + ({ transaction }) => + transaction.hash === txHash && + txNetwork.chainID === transaction.network.chainID ) } @@ -1029,7 +1067,7 @@ export default class ChainService extends BaseService { // Let's clean up the tx queue if the hash is present. // The pending tx hash should be on chain as soon as it's broadcasted. this.transactionsToRetrieve = this.transactionsToRetrieve.filter( - (queuedTx) => queuedTx.hash !== txHash + ({ transaction }) => transaction.hash !== txHash ) } } @@ -1422,12 +1460,13 @@ export default class ChainService extends BaseService { await this.db.getAllSavedTransactionHashes() ) /// send all new tx hashes into a queue to retrieve + cache - assetTransfers.forEach((a) => { + assetTransfers.forEach((a, idx) => { if (!savedTransactionHashes.has(a.txHash)) { this.queueTransactionHashToRetrieve( addressOnNetwork.network, a.txHash, - firstSeen + firstSeen, + idx <= TRANSACTIONS_WITH_PRIORITY_MAX_COUNT ? 0 : 1 ) } }) @@ -1501,12 +1540,12 @@ export default class ChainService extends BaseService { } // TODO: balance getting txs between networks - const txToRetrieve = this.transactionsToRetrieve[0] + const { transaction } = this.transactionsToRetrieve[0] this.removeTransactionHashFromQueue( - txToRetrieve.network, - txToRetrieve.hash + transaction.network, + transaction.hash ) - this.retrieveTransaction(txToRetrieve) + this.retrieveTransaction(transaction) }, 2 * SECOND) } } @@ -1818,9 +1857,26 @@ export default class ChainService extends BaseService { ): Promise { const provider = this.providerForNetworkOrThrow(network) const receipt = await provider.getTransactionReceipt(transaction.hash) - await this.saveTransaction( - enrichTransactionWithReceipt(transaction, receipt), - "alchemy" - ) + if (receipt) { + await this.saveTransaction( + enrichTransactionWithReceipt(transaction, receipt), + "alchemy" + ) + } + } + + // Used to add non-default chains via wallet_addEthereumChain + async addCustomChain( + chainInfo: ValidatedAddEthereumChainParameter + ): Promise { + await this.db.addEVMNetwork({ + chainName: chainInfo.chainName, + chainID: chainInfo.chainId, + decimals: chainInfo.nativeCurrency.decimals, + symbol: chainInfo.nativeCurrency.symbol, + assetName: chainInfo.nativeCurrency.name, + rpcUrls: chainInfo.rpcUrls, + }) + this.supportedNetworks = await this.db.getAllEVMNetworks() } } diff --git a/background/services/chain/serial-fallback-provider.ts b/background/services/chain/serial-fallback-provider.ts index a099b4a232..cc0651d8e4 100644 --- a/background/services/chain/serial-fallback-provider.ts +++ b/background/services/chain/serial-fallback-provider.ts @@ -10,7 +10,6 @@ import { utils } from "ethers" import { getNetwork } from "@ethersproject/networks" import { SECOND, - CHAIN_ID_TO_RPC_URLS, ALCHEMY_SUPPORTED_CHAIN_IDS, RPC_METHOD_PROVIDER_ROUTING, } from "../../constants" @@ -931,7 +930,8 @@ export default class SerialFallbackProvider extends JsonRpcProvider { } export function makeSerialFallbackProvider( - network: EVMNetwork + network: EVMNetwork, + rpcUrls: string[] ): SerialFallbackProvider { const alchemyProviderCreators = ALCHEMY_SUPPORTED_CHAIN_IDS.has( network.chainID @@ -956,12 +956,10 @@ export function makeSerialFallbackProvider( ] : [] - const genericProviders = (CHAIN_ID_TO_RPC_URLS[network.chainID] || []).map( - (rpcUrl) => ({ - type: "generic" as const, - creator: () => new JsonRpcProvider(rpcUrl), - }) - ) + const genericProviders = rpcUrls.map((rpcUrl) => ({ + type: "generic" as const, + creator: () => new JsonRpcProvider(rpcUrl), + })) return new SerialFallbackProvider(network, [ // Prefer alchemy as the primary provider when available diff --git a/background/services/chain/tests/index.integration.test.ts b/background/services/chain/tests/index.integration.test.ts index 4bb311ca6c..3289c0feea 100644 --- a/background/services/chain/tests/index.integration.test.ts +++ b/background/services/chain/tests/index.integration.test.ts @@ -11,9 +11,11 @@ import { createChainService, createLegacyTransactionRequest, } from "../../../tests/factories" +import { ChainDatabase } from "../db" import SerialFallbackProvider from "../serial-fallback-provider" type ChainServiceExternalized = Omit & { + db: ChainDatabase handlePendingTransaction: (transaction: AnyEVMTransaction) => void populateEVMTransactionNonce: ( transactionRequest: TransactionRequest @@ -46,6 +48,39 @@ describe("ChainService", () => { ) ).toHaveLength(1) }) + + it("should initialize persisted data in the correct order", async () => { + const chainServiceInstance = + (await createChainService()) as unknown as ChainServiceExternalized + + const initialize = sandbox.spy(chainServiceInstance.db, "initialize") + + const initializeBaseAssets = sandbox.spy( + chainServiceInstance.db, + "initializeBaseAssets" + ) + const initializeRPCs = sandbox.spy( + chainServiceInstance.db, + "initializeRPCs" + ) + const initializeEVMNetworks = sandbox.spy( + chainServiceInstance.db, + "initializeEVMNetworks" + ) + + const initializeNetworks = sandbox.spy( + chainServiceInstance, + "initializeNetworks" + ) + + await chainServiceInstance.internalStartService() + + expect(initialize.calledBefore(initializeNetworks)).toBe(true) + expect(initializeBaseAssets.calledBefore(initializeRPCs)).toBe(true) + expect(initializeRPCs.calledBefore(initializeEVMNetworks)).toBe(true) + expect(initializeEVMNetworks.calledBefore(initializeNetworks)).toBe(true) + expect(initializeNetworks.called).toBe(true) + }) }) it("handlePendingTransactions on chains without mempool should subscribe to transaction confirmations, and persist the transaction to indexedDB", async () => { @@ -157,4 +192,226 @@ describe("ChainService", () => { ) ).toBeTruthy() }) + + describe("populateEVMTransactionNonce", () => { + // The number of transactions address has ever sent + const TRANSACTION_COUNT = 100 + // Nonce for chain. This should be set to the number of transactions ever sent from this address + const CHAIN_NONCE = TRANSACTION_COUNT + + beforeEach(() => { + chainService.providerForNetworkOrThrow = jest.fn( + () => + ({ + getTransactionCount: async () => TRANSACTION_COUNT, + } as unknown as SerialFallbackProvider) + ) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it("should not overwrite the nonce set on tx request for chains with a mempool", async () => { + const chainServiceExternalized = + chainService as unknown as ChainServiceExternalized + const transactionRequest = createLegacyTransactionRequest({ + network: ETHEREUM, + chainID: ETHEREUM.chainID, + nonce: CHAIN_NONCE, + }) + + const transactionWithNonce = + await chainServiceExternalized.populateEVMTransactionNonce( + transactionRequest + ) + + expect(transactionWithNonce.nonce).toBe(CHAIN_NONCE) + }) + + it("should not overwrite the nonce set on tx request for chains without a mempool", async () => { + const chainServiceExternalized = + chainService as unknown as ChainServiceExternalized + const transactionRequest = createLegacyTransactionRequest({ + network: OPTIMISM, + chainID: OPTIMISM.chainID, + nonce: CHAIN_NONCE, + }) + + const transactionWithNonce = + await chainServiceExternalized.populateEVMTransactionNonce( + transactionRequest + ) + + expect(transactionWithNonce.nonce).toBe(CHAIN_NONCE) + }) + + it("should not store the nonce for chains without a mempool when a tx request is set", async () => { + const chainServiceExternalized = + chainService as unknown as ChainServiceExternalized + const transactionRequest = createLegacyTransactionRequest({ + network: OPTIMISM, + chainID: OPTIMISM.chainID, + nonce: CHAIN_NONCE, + }) + + await chainServiceExternalized.populateEVMTransactionNonce( + transactionRequest + ) + + expect( + chainServiceExternalized.evmChainLastSeenNoncesByNormalizedAddress[ + transactionRequest.chainID + ] + ).toBe(undefined) + }) + + it("should set the nonce for tx request for chains with a mempool", async () => { + const chainServiceExternalized = + chainService as unknown as ChainServiceExternalized + const transactionRequest = createLegacyTransactionRequest({ + network: ETHEREUM, + chainID: ETHEREUM.chainID, + nonce: undefined, + }) + + const transactionWithNonce = + await chainServiceExternalized.populateEVMTransactionNonce( + transactionRequest + ) + + expect(transactionWithNonce.nonce).toBe(CHAIN_NONCE) + }) + + it("should set the nonce for tx request for chains without a mempool", async () => { + const chainServiceExternalized = + chainService as unknown as ChainServiceExternalized + const transactionRequest = createLegacyTransactionRequest({ + network: OPTIMISM, + chainID: OPTIMISM.chainID, + nonce: undefined, + }) + + const transactionWithNonce = + await chainServiceExternalized.populateEVMTransactionNonce( + transactionRequest + ) + + expect(transactionWithNonce.nonce).toBe(CHAIN_NONCE) + }) + + it("should store the nonce for chains with a mempool when a tx request is set", async () => { + const chainServiceExternalized = + chainService as unknown as ChainServiceExternalized + const transactionRequest = createLegacyTransactionRequest({ + network: ETHEREUM, + chainID: ETHEREUM.chainID, + nonce: undefined, + }) + + await chainServiceExternalized.populateEVMTransactionNonce( + transactionRequest + ) + + expect( + chainServiceExternalized.evmChainLastSeenNoncesByNormalizedAddress[ + transactionRequest.chainID + ][transactionRequest.from] + ).toBe(CHAIN_NONCE) + }) + + it("should not store the nonce for chains without a mempool when a tx request is set", async () => { + const chainServiceExternalized = + chainService as unknown as ChainServiceExternalized + const transactionRequest = createLegacyTransactionRequest({ + network: OPTIMISM, + chainID: OPTIMISM.chainID, + nonce: undefined, + }) + + await chainServiceExternalized.populateEVMTransactionNonce( + transactionRequest + ) + + expect( + chainServiceExternalized.evmChainLastSeenNoncesByNormalizedAddress[ + transactionRequest.chainID + ] + ).toBe(undefined) + }) + }) + + describe("releaseEVMTransactionNonce", () => { + it("should release all intervening nonces if the nonce for transaction is below the latest allocated nonce", async () => { + /** + * Two transactions have been sent: one approving (nonce=11) the other for the swapping (nonce=12). + * In case transaction for nonce 11 will has too small gas we should release all intervening nonces. + * Nonce for the chain is then 10. Last seen nonce should also be set to this value. + */ + // Actual Swap transaction + const LAST_SEEN_NONCE = 12 + // Approval transaction + const NONCE = 11 + // Nonce for chain + const CHAIN_NONCE = 10 + + const chainServiceExternalized = + chainService as unknown as ChainServiceExternalized + const transactionRequest = createLegacyTransactionRequest({ + network: ETHEREUM, + chainID: ETHEREUM.chainID, + nonce: NONCE, + }) as TransactionRequestWithNonce + const { chainID, from } = transactionRequest + + chainServiceExternalized.evmChainLastSeenNoncesByNormalizedAddress[ + chainID + ] ??= {} + chainServiceExternalized.evmChainLastSeenNoncesByNormalizedAddress[ + chainID + ][from] = LAST_SEEN_NONCE + + await chainServiceExternalized.releaseEVMTransactionNonce( + transactionRequest + ) + + expect( + chainServiceExternalized.evmChainLastSeenNoncesByNormalizedAddress[ + chainID + ][from] + ).toBe(CHAIN_NONCE) + }) + + it("should release all intervening nonces if the nonce for a transaction is equal to the value of the latest allocated nonce", async () => { + const LAST_SEEN_NONCE = 11 + const NONCE = LAST_SEEN_NONCE + const CHAIN_NONCE = 10 + + const chainServiceExternalized = + chainService as unknown as ChainServiceExternalized + const transactionRequest = createLegacyTransactionRequest({ + network: ETHEREUM, + chainID: ETHEREUM.chainID, + nonce: NONCE, + }) as TransactionRequestWithNonce + const { chainID, from } = transactionRequest + + chainServiceExternalized.evmChainLastSeenNoncesByNormalizedAddress[ + chainID + ] ??= {} + chainServiceExternalized.evmChainLastSeenNoncesByNormalizedAddress[ + chainID + ][from] = LAST_SEEN_NONCE + + await chainServiceExternalized.releaseEVMTransactionNonce( + transactionRequest + ) + + expect( + chainServiceExternalized.evmChainLastSeenNoncesByNormalizedAddress[ + chainID + ][from] + ).toBe(CHAIN_NONCE) + }) + }) }) diff --git a/background/services/chain/tests/index.unit.test.ts b/background/services/chain/tests/index.unit.test.ts index 966b54aa7b..45a196dee5 100644 --- a/background/services/chain/tests/index.unit.test.ts +++ b/background/services/chain/tests/index.unit.test.ts @@ -1,10 +1,14 @@ import sinon from "sinon" -import ChainService, { QueuedTxToRetrieve } from ".." +import ChainService, { + PriorityQueuedTxToRetrieve, + QueuedTxToRetrieve, +} from ".." import { ETHEREUM, MINUTE, OPTIMISM, POLYGON, SECOND } from "../../../constants" import { EVMNetwork } from "../../../networks" import * as gas from "../../../lib/gas" import { createAddressOnNetwork, + createArrayWith0xHash, createBlockPrices, createChainService, createTransactionsToRetrieve, @@ -31,9 +35,10 @@ type ChainServiceExternalized = Omit & { incomingOnly: boolean ) => Promise retrieveTransaction: (queuedTx: QueuedTxToRetrieve) => Promise - transactionsToRetrieve: QueuedTxToRetrieve[] + transactionsToRetrieve: PriorityQueuedTxToRetrieve[] handleQueuedTransactionAlarm: () => Promise transactionToRetrieveGranularTimer: NodeJS.Timer | undefined + queueTransactionHashToRetrieve: void } describe("Chain Service", () => { @@ -320,5 +325,75 @@ describe("Chain Service", () => { ).toBe(undefined) }) }) + + describe("queueTransactionHashToRetrieve", () => { + const NUMBER_OF_TX = 100 + const PRIORITY_MAX_COUNT = 25 + let chainServiceExternalized: ChainServiceExternalized + let hashesForFirstAccount: string[] + let hashesForSecondAccount: string[] + let transactionsToRetrieve: PriorityQueuedTxToRetrieve[] + + beforeEach(() => { + chainServiceExternalized = + chainService as unknown as ChainServiceExternalized + + hashesForFirstAccount = createArrayWith0xHash(NUMBER_OF_TX) + hashesForSecondAccount = createArrayWith0xHash(NUMBER_OF_TX) + + const allHashesToAdd = [hashesForFirstAccount, hashesForSecondAccount] + + allHashesToAdd.forEach((hashes) => + hashes.forEach((txHash, idx) => + chainServiceExternalized.queueTransactionHashToRetrieve( + ETHEREUM, + txHash, + Date.now(), + idx < PRIORITY_MAX_COUNT ? 1 : 0 + ) + ) + ) + + transactionsToRetrieve = chainServiceExternalized.transactionsToRetrieve + }) + + it("should add transactions to the queue", async () => { + expect(transactionsToRetrieve.length).toBe(NUMBER_OF_TX * 2) + }) + + it(`the first ${PRIORITY_MAX_COUNT} transactions have a higher priority and should come from the first account`, async () => { + Array(PRIORITY_MAX_COUNT).forEach((idx) => { + expect(transactionsToRetrieve[idx].transaction.hash).toBe( + hashesForFirstAccount[idx] + ) + }) + }) + + it(`another ${PRIORITY_MAX_COUNT} transactions have a higher priority and should come from the second account`, async () => { + Array(PRIORITY_MAX_COUNT).forEach((idx) => { + expect( + transactionsToRetrieve[idx + PRIORITY_MAX_COUNT].transaction.hash + ).toBe(hashesForSecondAccount[idx]) + }) + }) + + it("after items with higher priority in the queue should be the next transactions for the first account", async () => { + Array(NUMBER_OF_TX - PRIORITY_MAX_COUNT).forEach((idx) => { + expect( + transactionsToRetrieve[idx + PRIORITY_MAX_COUNT * 2].transaction + .hash + ).toBe(hashesForFirstAccount[idx + PRIORITY_MAX_COUNT]) + }) + }) + + it("transactions with lower priority for the second account should be after high-priority items and all items of the first account", async () => { + Array(NUMBER_OF_TX - PRIORITY_MAX_COUNT).forEach((idx) => { + expect( + transactionsToRetrieve[idx + PRIORITY_MAX_COUNT + NUMBER_OF_TX] + .transaction.hash + ).toBe(hashesForSecondAccount[idx + PRIORITY_MAX_COUNT]) + }) + }) + }) }) }) diff --git a/background/services/enrichment/index.ts b/background/services/enrichment/index.ts index ec9f03fb7a..05372c0336 100644 --- a/background/services/enrichment/index.ts +++ b/background/services/enrichment/index.ts @@ -133,7 +133,8 @@ export default class EnrichmentService extends BaseService { (asset): asset is SmartContractFungibleAsset => { if ( typedData.domain.verifyingContract && - "contractAddress" in asset + "contractAddress" in asset && + asset.contractAddress ) { return ( normalizeHexAddress(asset.contractAddress) === diff --git a/background/services/enrichment/tests/transactions.integration.test.ts b/background/services/enrichment/tests/transactions.integration.test.ts new file mode 100644 index 0000000000..26d93dc246 --- /dev/null +++ b/background/services/enrichment/tests/transactions.integration.test.ts @@ -0,0 +1,80 @@ +import sinon from "sinon" +import { ETHEREUM } from "../../../constants" + +import { + createChainService, + createIndexingService, + createNameService, + createAnyEVMBlock, + makeSerialFallbackProvider, +} from "../../../tests/factories" +import SerialFallbackProvider from "../../chain/serial-fallback-provider" +import { annotationsFromLogs } from "../transactions" + +// These logs reference transaction https://etherscan.io/tx/0x0ba306853f8be38d54327675f14694d582a14759b851f2126dd900bef0aff840 +// prettier-ignore +const TEST_ERC20_LOGS = [ { contractAddress: "0x853d955aCEf822Db058eb8505911ED77F175b99e", data: "0x00000000000000000000000000000000000000000000057d723eb063126abeaf", topics: [ "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", "0x0000000000000000000000009eef87f4c08d8934cb2a3309df4dec5635338115", "0x00000000000000000000000022f9dcf4647084d6c31b2765f6910cd85c178c18", ], }, { contractAddress: "0x853d955aCEf822Db058eb8505911ED77F175b99e", data: "0xffffffffffffffffffffffffffffffffffffffffffffcf2f0540996d1fc52a54", topics: [ "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925", "0x0000000000000000000000009eef87f4c08d8934cb2a3309df4dec5635338115", "0x000000000000000000000000def1c0ded9bec7f1a1670819833240f027b25eff", ], }, { contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", data: "0x000000000000000000000000000000000000000000000000000000060869fd65", topics: [ "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", "0x0000000000000000000000009a834b70c07c81a9fcd6f22e842bf002fbffbe4d", "0x00000000000000000000000022f9dcf4647084d6c31b2765f6910cd85c178c18", ], }, { contractAddress: "0x853d955aCEf822Db058eb8505911ED77F175b99e", data: "0x00000000000000000000000000000000000000000000057d723eb063126abeaf", topics: [ "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", "0x00000000000000000000000022f9dcf4647084d6c31b2765f6910cd85c178c18", "0x0000000000000000000000009a834b70c07c81a9fcd6f22e842bf002fbffbe4d", ], }, { contractAddress: "0x853d955aCEf822Db058eb8505911ED77F175b99e", data: "0xffffffffffffffffffffffffffffffffffffffffffe0bf27eab0a412146f26d0", topics: [ "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925", "0x00000000000000000000000022f9dcf4647084d6c31b2765f6910cd85c178c18", "0x000000000000000000000000e592427a0aece92de3edee1f18e0157c05861564", ], }, { contractAddress: "0x9A834b70C07C81a9fcD6F22E842BF002fBfFbe4D", data: "0x00000000000000000000000000000000000000000000057d723eb063126abeaffffffffffffffffffffffffffffffffffffffffffffffffffffffff9f796029b0000000000000000000000000000000000000000000010c5f0437647d68615730000000000000000000000000000000000000000000002091686803a9aa2714ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffbc897", topics: [ "0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67", "0x000000000000000000000000e592427a0aece92de3edee1f18e0157c05861564", "0x00000000000000000000000022f9dcf4647084d6c31b2765f6910cd85c178c18", ], }, { contractAddress: "0x22F9dCF4647084d6C31b2765F6910cd85C178C18", data: "0x00000000000000000000000000000012556e6973776170563300000000000000000000000000000000000000853d955acef822db058eb8505911ed77f175b99e000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000057d723eb063126abeaf000000000000000000000000000000000000000000000000000000060869fd65", topics: [ "0xe59e71a14fe90157eedc866c4f8c767d3943d6b6b2e8cd64dddcc92ab4c55af8", ], }, { contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", data: "0x00000000000000000000000000000000000000000000000000000000079b57dc", topics: [ "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", "0x00000000000000000000000022f9dcf4647084d6c31b2765f6910cd85c178c18", "0x00000000000000000000000099b36fdbc582d113af36a21eba06bfeab7b9be12", ], }, { contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", data: "0x0000000000000000000000000000000000000000000000000000000600cea589", topics: [ "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", "0x00000000000000000000000022f9dcf4647084d6c31b2765f6910cd85c178c18", "0x0000000000000000000000009eef87f4c08d8934cb2a3309df4dec5635338115", ], }, { contractAddress: "0xDef1C0ded9bec7F1a1670819833240f027b25EfF", data: "0x000000000000000000000000853d955acef822db058eb8505911ed77f175b99e000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000057d723eb063126abeaf0000000000000000000000000000000000000000000000000000000600cea589", topics: [ "0x0f6672f78a59ba8e5e5b5d38df3ebc67f3c792e2c9259b8d97d7f00dd78ba1b3", "0x0000000000000000000000009eef87f4c08d8934cb2a3309df4dec5635338115", ], }, ] + +describe("Enrichment Service Transactions", () => { + const sandbox = sinon.createSandbox() + + beforeEach(async () => { + sandbox.restore() + }) + + describe("annotationsFromLogs", () => { + it("Should only create subannotations from logs with relevant addresses in them", async () => { + const chainServicePromise = createChainService() + const indexingServicePromise = createIndexingService({ + chainService: chainServicePromise, + }) + const nameServicePromise = createNameService({ + chainService: chainServicePromise, + }) + + const [chainService, indexingService, nameService] = await Promise.all([ + chainServicePromise, + indexingServicePromise, + nameServicePromise, + ]) + + await chainService.addAccountToTrack({ + address: "0x9eef87f4c08d8934cb2a3309df4dec5635338115", + network: ETHEREUM, + }) + + await indexingService.addCustomAsset({ + contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + symbol: "USDC", + name: "USDC Coin", + decimals: 6, + homeNetwork: ETHEREUM, + }) + + await indexingService.addCustomAsset({ + contractAddress: "0x853d955aCEf822Db058eb8505911ED77F175b99e", + symbol: "FRAX", + name: "FRAX Token", + decimals: 18, + homeNetwork: ETHEREUM, + }) + + sandbox + .stub(chainService, "providerForNetworkOrThrow") + .returns(makeSerialFallbackProvider() as SerialFallbackProvider) + + const subannotations = await annotationsFromLogs( + chainService, + indexingService, + nameService, + TEST_ERC20_LOGS, + ETHEREUM, + 2, + Date.now(), + createAnyEVMBlock() + ) + + expect(subannotations.length).toBe(2) + }) + }) +}) diff --git a/background/services/enrichment/transactions.ts b/background/services/enrichment/transactions.ts index 9581d01b84..cbc2fdf691 100644 --- a/background/services/enrichment/transactions.ts +++ b/background/services/enrichment/transactions.ts @@ -8,6 +8,7 @@ import { import { SmartContractFungibleAsset, isSmartContractFungibleAsset, + AnyAsset, } from "../../assets" import { enrichAssetAmountWithDecimalValues } from "../../redux-slices/utils/asset-utils" @@ -20,6 +21,7 @@ import { TransactionAnnotation, PartialTransactionRequestWithFrom, EnrichedEVMTransactionRequest, + EnrichedAddressOnNetwork, } from "./types" import { getDistinctRecipentAddressesFromERC20Logs, @@ -28,64 +30,30 @@ import { import { enrichAddressOnNetwork } from "./addresses" import { OPTIMISM } from "../../constants" import { parseLogsForWrappedDepositsAndWithdrawals } from "../../lib/wrappedAsset" -import { parseERC20Tx, parseLogsForERC20Transfers } from "../../lib/erc20" +import { + ERC20TransferLog, + parseERC20Tx, + parseLogsForERC20Transfers, +} from "../../lib/erc20" import { isDefined, isFulfilledPromise } from "../../lib/utils/type-guards" import { unsignedTransactionFromEVMTransaction } from "../chain/utils" -async function annotationsFromLogs( +async function buildSubannotations( chainService: ChainService, - indexingService: IndexingService, nameService: NameService, - logs: EVMLog[], + relevantTransferLogs: ERC20TransferLog[], + assets: AnyAsset[], + addressEnrichmentsByAddress: { + [k: string]: EnrichedAddressOnNetwork + }, network: EVMNetwork, desiredDecimals: number, resolvedTime: number, block: AnyEVMBlock | undefined -): Promise { - const assets = indexingService.getCachedAssets(network) - - const accountAddresses = (await chainService.getAccountsToTrack()).map( - (account) => account.address - ) - - const tokenTransferLogs = [ - ...parseLogsForERC20Transfers(logs), - ...parseLogsForWrappedDepositsAndWithdrawals(logs), - ] - - const relevantTransferLogs = getERC20LogsForAddresses( - tokenTransferLogs, - accountAddresses - ) - const relevantAddresses = - getDistinctRecipentAddressesFromERC20Logs(relevantTransferLogs).map( - normalizeEVMAddress - ) - - // Look up transfer log names, then flatten to an address -> name map. - const addressEnrichmentsByAddress = Object.fromEntries( - ( - await Promise.allSettled( - relevantAddresses.map( - async (address) => - [ - address, - await enrichAddressOnNetwork(chainService, nameService, { - address, - network, - }), - ] as const - ) - ) - ) - .filter(isFulfilledPromise) - .map(({ value }) => value) - .filter(([, annotation]) => isDefined(annotation)) - ) - +) { const subannotations = ( await Promise.allSettled( - tokenTransferLogs.map( + relevantTransferLogs.map( async ({ contractAddress, amount, @@ -144,6 +112,73 @@ async function annotationsFromLogs( return subannotations } +export async function annotationsFromLogs( + chainService: ChainService, + indexingService: IndexingService, + nameService: NameService, + logs: EVMLog[], + network: EVMNetwork, + desiredDecimals: number, + resolvedTime: number, + block: AnyEVMBlock | undefined +): Promise { + const assets = indexingService.getCachedAssets(network) + + const accountAddresses = (await chainService.getAccountsToTrack()).map( + (account) => account.address + ) + + const tokenTransferLogs = [ + ...parseLogsForERC20Transfers(logs), + ...parseLogsForWrappedDepositsAndWithdrawals(logs), + ] + + const relevantTransferLogs = getERC20LogsForAddresses( + tokenTransferLogs, + accountAddresses + ) + + const relevantAddresses = + getDistinctRecipentAddressesFromERC20Logs(relevantTransferLogs).map( + normalizeEVMAddress + ) + + // Look up transfer log names, then flatten to an address -> name map. + const addressEnrichmentsByAddress = Object.fromEntries( + ( + await Promise.allSettled( + relevantAddresses.map( + async (address) => + [ + address, + await enrichAddressOnNetwork(chainService, nameService, { + address, + network, + }), + ] as const + ) + ) + ) + .filter(isFulfilledPromise) + .map(({ value }) => value) + .filter(([, annotation]) => isDefined(annotation)) + ) + + const subannotations = await buildSubannotations( + chainService, + nameService, + relevantTransferLogs, + assets, + addressEnrichmentsByAddress, + network, + desiredDecimals, + resolvedTime, + block + ) + + return subannotations +} + /** * Resolve an annotation for a partial transaction request, or a pending * or mined transaction. diff --git a/background/services/indexing/db.ts b/background/services/indexing/db.ts index 56f6e31236..479934194f 100644 --- a/background/services/indexing/db.ts +++ b/background/services/indexing/db.ts @@ -64,7 +64,11 @@ export type CachedTokenList = TokenListCitation & { */ function assetID(asset: AnyAsset): string { const id = `${asset.symbol}` - if (!("contractAddress" in asset) || "coinType" in asset) { + if ( + !("contractAddress" in asset) || + !("homeNetwork" in asset) || + "coinType" in asset + ) { return id } const network = asset.homeNetwork diff --git a/background/services/indexing/index.ts b/background/services/indexing/index.ts index 1000c95498..ef8c1148ad 100644 --- a/background/services/indexing/index.ts +++ b/background/services/indexing/index.ts @@ -15,7 +15,7 @@ import { SmartContractFungibleAsset, } from "../../assets" import { - BASE_ASSETS, + BUILT_IN_NETWORK_BASE_ASSETS, FIAT_CURRENCIES, HOUR, MINUTE, @@ -412,6 +412,24 @@ export default class IndexingService extends BaseService { } private async connectChainServiceEvents(): Promise { + // listen for assetTransfers, and if we find them, track those tokens + // TODO update for NFTs + this.chainService.emitter.on( + "assetTransfers", + async ({ addressNetwork, assetTransfers }) => { + assetTransfers.forEach((transfer) => { + const fungibleAsset = transfer.assetAmount + .asset as SmartContractFungibleAsset + if (fungibleAsset.contractAddress && fungibleAsset.decimals) { + this.addTokenToTrackByContract( + addressNetwork, + fungibleAsset.contractAddress + ) + } + }) + } + ) + this.chainService.emitter.on( "newAccountToTrack", async (addressOnNetwork) => { @@ -623,7 +641,10 @@ export default class IndexingService extends BaseService { try { // TODO include user-preferred currencies // get the prices of ETH and BTC vs major currencies - const basicPrices = await getPrices(BASE_ASSETS, FIAT_CURRENCIES) + const basicPrices = await getPrices( + BUILT_IN_NETWORK_BASE_ASSETS, + FIAT_CURRENCIES + ) // kick off db writes and event emission, don't wait for the promises to // settle @@ -644,7 +665,7 @@ export default class IndexingService extends BaseService { } catch (e) { logger.error( "Error getting base asset prices", - BASE_ASSETS, + BUILT_IN_NETWORK_BASE_ASSETS, FIAT_CURRENCIES ) } diff --git a/background/services/internal-ethereum-provider/index.ts b/background/services/internal-ethereum-provider/index.ts index f8d8802e87..bd349d716e 100644 --- a/background/services/internal-ethereum-provider/index.ts +++ b/background/services/internal-ethereum-provider/index.ts @@ -32,6 +32,7 @@ import { TransactionAnnotation, } from "../enrichment" import { decodeJSON } from "../../lib/utils" +import { FeatureFlags } from "../../features" // A type representing the transaction requests that come in over JSON-RPC // requests like eth_sendTransaction and eth_signTransaction. These are very @@ -59,6 +60,73 @@ export type SwitchEthereumChainParameter = { chainId: string } +// https://eips.ethereum.org/EIPS/eip-3085 +export type AddEthereumChainParameter = { + chainId: string + blockExplorerUrls?: string[] + chainName?: string + iconUrls?: string[] + nativeCurrency?: { + name: string + symbol: string + decimals: number + } + rpcUrls?: string[] +} + +// Lets start with all required and work backwards +export type ValidatedAddEthereumChainParameter = { + chainId: string + blockExplorerUrl: string + chainName: string + iconUrl?: string + nativeCurrency: { + name: string + symbol: string + decimals: number + } + rpcUrls: string[] +} + +const validateAddEthereumChainParameter = ({ + chainId, + chainName, + blockExplorerUrls, + iconUrls, + nativeCurrency, + rpcUrls, +}: AddEthereumChainParameter): ValidatedAddEthereumChainParameter => { + // @TODO Use AJV + if ( + !chainId || + !chainName || + !nativeCurrency || + !blockExplorerUrls || + !blockExplorerUrls.length || + !rpcUrls || + !rpcUrls.length + ) { + throw new Error("Missing Chain Property") + } + + if ( + !nativeCurrency.decimals || + !nativeCurrency.name || + !nativeCurrency.symbol + ) { + throw new Error("Missing Currency Property") + } + + return { + chainId: chainId.startsWith("0x") ? String(parseInt(chainId, 16)) : chainId, + chainName, + nativeCurrency, + blockExplorerUrl: blockExplorerUrls[0], + iconUrl: iconUrls && iconUrls[0], + rpcUrls, + } +} + type DAppRequestEvent = { payload: T resolver: (result: E | PromiseLike) => void @@ -245,21 +313,37 @@ export default class InternalEthereumProviderService extends BaseService ) // TODO - actually allow adding a new ethereum chain - for now wallet_addEthereumChain // will just switch to a chain if we already support it - but not add a new one - case "wallet_addEthereumChain": + case "wallet_addEthereumChain": { + const chainInfo = params[0] as AddEthereumChainParameter + const { chainId } = chainInfo + const supportedNetwork = await this.getTrackedNetworkByChainId(chainId) + if (supportedNetwork) { + this.switchToSupportedNetwork(supportedNetwork) + return null + } + if (!FeatureFlags.SUPPORT_CUSTOM_NETWORKS) { + // Dissallow adding new chains until feature flag is turned on. + throw new EIP1193Error(EIP1193_ERROR_CODES.userRejectedRequest) + } + try { + const validatedParam = validateAddEthereumChainParameter(chainInfo) + await this.chainService.addCustomChain(validatedParam) + return null + } catch (e) { + logger.error(e) + throw new EIP1193Error(EIP1193_ERROR_CODES.userRejectedRequest) + } + } case "wallet_switchEthereumChain": { const newChainId = (params[0] as SwitchEthereumChainParameter).chainId const supportedNetwork = await this.getTrackedNetworkByChainId( newChainId ) if (supportedNetwork) { - const { address } = await this.preferenceService.getSelectedAccount() - await this.chainService.markAccountActivity({ - address, - network: supportedNetwork, - }) - await this.db.setCurrentChainIdForOrigin(origin, supportedNetwork) + this.switchToSupportedNetwork(supportedNetwork) return null } + throw new EIP1193Error(EIP1193_ERROR_CODES.chainDisconnected) } case "metamask_getProviderState": // --- important MM only methods --- @@ -391,6 +475,15 @@ export default class InternalEthereumProviderService extends BaseService }) } + private async switchToSupportedNetwork(supportedNetwork: EVMNetwork) { + const { address } = await this.preferenceService.getSelectedAccount() + await this.chainService.markAccountActivity({ + address, + network: supportedNetwork, + }) + await this.db.setCurrentChainIdForOrigin(origin, supportedNetwork) + } + private async signData( { input, diff --git a/background/services/internal-ethereum-provider/tests/index.integration.test.ts b/background/services/internal-ethereum-provider/tests/index.integration.test.ts new file mode 100644 index 0000000000..0a941f4d50 --- /dev/null +++ b/background/services/internal-ethereum-provider/tests/index.integration.test.ts @@ -0,0 +1,45 @@ +import sinon from "sinon" +import InternalEthereumProviderService from ".." +import { EVMNetwork } from "../../../networks" + +import { + createChainService, + createInternalEthereumProviderService, +} from "../../../tests/factories" + +describe("Internal Ethereum Provider Service", () => { + const sandbox = sinon.createSandbox() + let IEPService: InternalEthereumProviderService + + beforeEach(async () => { + sandbox.restore() + IEPService = await createInternalEthereumProviderService() + await IEPService.startService() + }) + + afterEach(async () => { + await IEPService.stopService() + }) + + it("should correctly persist chains sent in via wallet_addEthereumChain", async () => { + const chainService = createChainService() + + IEPService = await createInternalEthereumProviderService({ chainService }) + const startedChainService = await chainService + await startedChainService.startService() + await IEPService.startService() + const METHOD = "wallet_addEthereumChain" + const ORIGIN = "https://chainlist.org" + + // prettier-ignore + const EIP3085_PARAMS = [ { chainId: "0xfa", chainName: "Fantom Opera", nativeCurrency: { name: "Fantom", symbol: "FTM", decimals: 18, }, rpcUrls: [ "https://fantom-mainnet.gateway.pokt.network/v1/lb/62759259ea1b320039c9e7ac", "https://rpc.ftm.tools", "https://rpc.ankr.com/fantom", "https://rpc.fantom.network", "https://rpc2.fantom.network", "https://rpc3.fantom.network", "https://rpcapi.fantom.network", "https://fantom-mainnet.public.blastapi.io", "https://1rpc.io/ftm", ], blockExplorerUrls: ["https://ftmscan.com"], }, "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", ] + + await IEPService.routeSafeRPCRequest(METHOD, EIP3085_PARAMS, ORIGIN) + + expect( + startedChainService.supportedNetworks.find( + (network: EVMNetwork) => network.name === "Fantom Opera" + ) + ).toBeTruthy() + }) +}) diff --git a/background/services/nfts/index.ts b/background/services/nfts/index.ts index 95ee110ad0..e06ba2d8c0 100644 --- a/background/services/nfts/index.ts +++ b/background/services/nfts/index.ts @@ -3,7 +3,7 @@ import { FeatureFlags, isEnabled } from "../../features" import { getNFTCollections, getNFTs, - getTransferredNFTs, + getNFTsTransfers, } from "../../lib/nfts_update" import { getSimpleHashNFTs } from "../../lib/simple-hash_update" import { POAP_COLLECTION_ID } from "../../lib/poap_update" @@ -13,7 +13,7 @@ import ChainService from "../chain" import { ServiceCreatorFunction, ServiceLifecycleEvents } from "../types" import { getOrCreateDB, NFTsDatabase } from "./db" -import { getUNIXTimestamp } from "../../lib/utils" +import { getUNIXTimestamp, normalizeEVMAddress } from "../../lib/utils" import { MINUTE } from "../../constants" interface Events extends ServiceLifecycleEvents { @@ -30,12 +30,17 @@ interface Events extends ServiceLifecycleEvents { } type NextPageURLsMap = { [collectionID: string]: { [address: string]: string } } +type FreshCollectionsMap = { + [collectionID: string]: { [address: string]: boolean } +} export default class NFTsService extends BaseService { #nextPageUrls: NextPageURLsMap = {} #transfersLookupTimestamp: number + #freshCollections: FreshCollectionsMap = {} + static create: ServiceCreatorFunction< Events, NFTsService, @@ -70,7 +75,8 @@ export default class NFTsService extends BaseService { private async connectChainServiceEvents(): Promise { this.chainService.emitter.once("serviceStarted").then(async () => { this.emitter.emit("isReloadingNFTs", true) - await this.refreshCollections() + await this.initializeCollections() + this.#transfersLookupTimestamp = getUNIXTimestamp(Date.now() - 5 * MINUTE) const collections = await this.db.getAllCollections() this.emitter.emit("initializeNFTs", collections) @@ -81,26 +87,34 @@ export default class NFTsService extends BaseService { "newAccountToTrack", async (addressOnNetwork) => { this.emitter.emit("isReloadingNFTs", true) - await this.refreshCollections([addressOnNetwork]) + await this.initializeCollections([addressOnNetwork]) this.emitter.emit("isReloadingNFTs", false) } ) } + async initializeCollections(accounts?: AddressOnNetwork[]): Promise { + const accountsToFetch = + accounts ?? (await this.chainService.getAccountsToTrack()) + + if (accountsToFetch.length) { + await this.fetchCollections(accountsToFetch) + await this.fetchPOAPs(accountsToFetch) + } + } + async refreshCollections(accounts?: AddressOnNetwork[]): Promise { const accountsToFetch = accounts ?? (await this.chainService.getAccountsToTrack()) if (!accountsToFetch.length) return - await this.removeTransferredNFTs(accountsToFetch) - await this.fetchCollections(accountsToFetch) - // prefetch POAPs to avoid loading empty POAPs collections from UI - await Promise.allSettled( - accountsToFetch.map((account) => - this.fetchNFTsFromCollection(POAP_COLLECTION_ID, account) - ) - ) + const transfers = await this.fetchTransferredNFTs(accountsToFetch) + + if (transfers.sold.length || transfers.bought.length) { + await this.fetchCollections(accountsToFetch) // refetch only if there are some transfers + } + await this.fetchPOAPs(accountsToFetch) } async fetchCollections(accounts: AddressOnNetwork[]): Promise { @@ -122,14 +136,42 @@ export default class NFTsService extends BaseService { collectionID: string, account: AddressOnNetwork ): Promise { + if ( + this.#freshCollections[collectionID]?.[ + normalizeEVMAddress(account.address) + ] + ) { + await this.fetchNFTsFromDatabase(collectionID, account) + } else { + await Promise.allSettled( + getNFTs([account], [collectionID]).map(async (request) => { + const { nfts, nextPageURLs } = await request + await this.updateSavedNFTs(collectionID, account, nfts, nextPageURLs) + }) + ) + } + } + + async fetchPOAPs(accounts: AddressOnNetwork[]): Promise { await Promise.allSettled( - getNFTs([account], [collectionID]).map(async (request) => { - const { nfts, nextPageURLs } = await request - await this.updateSavedNFTs(collectionID, account, nfts, nextPageURLs) - }) + accounts.map((account) => + this.fetchNFTsFromCollection(POAP_COLLECTION_ID, account) + ) ) } + async fetchNFTsFromDatabase( + collectionID: string, + account: AddressOnNetwork + ): Promise { + await this.emitter.emit("updateNFTs", { + collectionID, + account, + nfts: await this.db.getCollectionNFTsForAccount(collectionID, account), + hasNextPage: !!this.#nextPageUrls[collectionID]?.[account.address], + }) + } + async fetchNFTsFromNextPage( collectionID: string, account: AddressOnNetwork @@ -194,6 +236,8 @@ export default class NFTsService extends BaseService { this.emitter.emit("updateCollections", [updatedCollection]) } + this.setFreshCollection(collectionID, account.address, true) + const hasNextPage = !!Object.keys(nextPageURLs).length await this.emitter.emit("updateNFTs", { @@ -213,24 +257,68 @@ export default class NFTsService extends BaseService { ) } + setFreshCollection( + collectionID: string, + address: string, + isFresh: boolean + ): void { + // POAPs won't appear in transfers so we don't know if they are stale + if (collectionID === POAP_COLLECTION_ID) return + + this.#freshCollections[collectionID] ??= {} + this.#freshCollections[collectionID][normalizeEVMAddress(address)] = isFresh + } + async removeNFTsForAddress(address: string): Promise { + Object.keys(this.#freshCollections).forEach((collectionID) => { + if (this.#freshCollections[collectionID][normalizeEVMAddress(address)]) { + this.setFreshCollection(collectionID, address, false) + } + }) await this.db.removeNFTsForAddress(address) } - async removeTransferredNFTs(accounts: AddressOnNetwork[]): Promise { - const removedNFTs = await getTransferredNFTs( + async fetchTransferredNFTs( + accounts: AddressOnNetwork[] + ): Promise<{ sold: TransferredNFT[]; bought: TransferredNFT[] }> { + const transfers = await getNFTsTransfers( accounts, this.#transfersLookupTimestamp ) - if (removedNFTs.length) { - // indexing transfers can take some time, let's add some margin to the timestamp - this.#transfersLookupTimestamp = getUNIXTimestamp(Date.now() - 5 * MINUTE) + // indexing transfers can take some time, let's add some margin to the timestamp + this.#transfersLookupTimestamp = getUNIXTimestamp(Date.now() - 5 * MINUTE) - await this.db.removeNFTsByIDs( - removedNFTs.map((transferred) => transferred.id) - ) - this.emitter.emit("removeTransferredNFTs", removedNFTs) + const { sold, bought } = transfers.reduce( + (acc, transfer) => { + if (transfer.type === "buy") { + acc.bought.push(transfer) + } else { + acc.sold.push(transfer) + } + return acc + }, + { sold: [], bought: [] } as { + sold: TransferredNFT[] + bought: TransferredNFT[] + } + ) + + if (bought.length) { + // mark collections with new NFTs to be refetched + bought.forEach((transfer) => { + const { collectionID, to } = transfer + if (collectionID && to) { + this.setFreshCollection(collectionID, to, false) + } + }) } + + if (sold.length) { + await this.db.removeNFTsByIDs(sold.map((transferred) => transferred.id)) + this.emitter.emit("removeTransferredNFTs", sold) + } + + return { sold, bought } } } diff --git a/background/tests/factories.ts b/background/tests/factories.ts index 3b99008681..cbe3d67663 100644 --- a/background/tests/factories.ts +++ b/background/tests/factories.ts @@ -11,6 +11,7 @@ import { keccak256 } from "ethers/lib/utils" import { AccountBalance, AddressOnNetwork } from "../accounts" import { AnyAsset, + flipPricePoint, isFungibleAsset, PricePoint, SmartContractFungibleAsset, @@ -29,18 +30,23 @@ import { LegacyEVMTransactionRequest, AnyEVMBlock, BlockPrices, + NetworkBaseAsset, } from "../networks" import { AnalyticsService, ChainService, IndexingService, + InternalEthereumProviderService, KeyringService, LedgerService, NameService, PreferenceService, SigningService, } from "../services" -import { QueuedTxToRetrieve } from "../services/chain" +import { + PriorityQueuedTxToRetrieve, + QueuedTxToRetrieve, +} from "../services/chain" import SerialFallbackProvider from "../services/chain/serial-fallback-provider" const createRandom0xHash = () => @@ -127,6 +133,18 @@ export const createSigningService = async ( ) } +export const createInternalEthereumProviderService = async ( + overrides: { + chainService?: Promise + preferenceService?: Promise + } = {} +): Promise => { + return InternalEthereumProviderService.create( + overrides.chainService ?? createChainService(), + overrides.preferenceService ?? createPreferenceService() + ) +} + // Copied from a legacy Optimism transaction generated with our test wallet. export const createLegacyTransactionRequest = ( overrides: Partial = {} @@ -250,12 +268,15 @@ export const createQueuedTransaction = ( export const createTransactionsToRetrieve = ( numberOfTx = 100 -): QueuedTxToRetrieve[] => { +): PriorityQueuedTxToRetrieve[] => { const NETWORKS = [ETHEREUM, POLYGON, ARBITRUM_ONE, AVALANCHE, OPTIMISM] - return [...Array(numberOfTx).keys()].map((_, ind) => - createQueuedTransaction({ network: NETWORKS[ind % NETWORKS.length] }) - ) + return [...Array(numberOfTx).keys()].map((_, ind) => ({ + transaction: createQueuedTransaction({ + network: NETWORKS[ind % NETWORKS.length], + }), + priority: 0, + })) } export const createTransactionResponse = ( @@ -325,24 +346,28 @@ export const makeSerialFallbackProvider = async getFeeData() { return makeEthersFeeData() } + + async getCode() { + return "false" + } } return new MockSerialFallbackProvider() } -export const createSmartContractAsset = ( - overrides: Partial = {} -): SmartContractFungibleAsset => { - const getRandomStr = (length: number) => { - let result = "" - - while (result.length < length) { - result += Math.random().toString(36).slice(2) - } +const getRandomStr = (length: number) => { + let result = "" - return result.slice(0, length) + while (result.length < length) { + result += Math.random().toString(36).slice(2) } + return result.slice(0, length) +} + +export const createSmartContractAsset = ( + overrides: Partial = {} +): SmartContractFungibleAsset => { const symbol = getRandomStr(3) const asset = { metadata: { @@ -369,6 +394,30 @@ export const createSmartContractAsset = ( } } +export const createNetworkBaseAsset = ( + overrides: Partial = {} +): NetworkBaseAsset => { + const symbol = getRandomStr(3) + const asset: NetworkBaseAsset = { + metadata: { + coinGeckoID: "ethereum", + logoURL: "http://example.com/foo.png", + tokenLists: [], + }, + name: `${symbol} Network`, + symbol, + decimals: 18, + coinType: 60, + chainID: "1", + contractAddress: createRandom0xHash(), + } + + return { + ...asset, + ...overrides, + } +} + /** * @param asset Any type of asset * @param price Price, e.g. 1.5 => 1.5$ @@ -383,15 +432,12 @@ export const createPricePoint = ( const pricePoint: PricePoint = { pair: [asset, USD], - amounts: [10n ** BigInt(decimals), BigInt(Math.trunc(1e11 * price))], + amounts: [10n ** BigInt(decimals), BigInt(Math.trunc(1e10 * price))], time: Math.trunc(Date.now() / 1e3), } - if (flip) { - const { pair, amounts } = pricePoint - pricePoint.pair = [pair[1], pair[0]] - pricePoint.amounts = [amounts[1], amounts[0]] - } - - return pricePoint + return flip ? flipPricePoint(pricePoint) : pricePoint } + +export const createArrayWith0xHash = (length: number): string[] => + Array.from({ length }).map(() => createRandom0xHash()) diff --git a/background/tests/prices.test.ts b/background/tests/prices.test.ts index d4a1d879d7..dd8e4a7b60 100644 --- a/background/tests/prices.test.ts +++ b/background/tests/prices.test.ts @@ -2,8 +2,7 @@ import * as ethers from "@ethersproject/web" // << THIS IS THE IMPORTANT TRICK import logger from "../lib/logger" -import { BTC, ETH, FIAT_CURRENCIES, USD } from "../constants" -import { CoinGeckoAsset } from "../assets" +import { ETH, FIAT_CURRENCIES, USD } from "../constants" import { getPrices } from "../lib/prices" import { isValidCoinGeckoPriceResponse } from "../lib/validate" @@ -152,7 +151,7 @@ describe("lib/prices.ts", () => { amounts: [639090000000000n, 100000000n], pair: [ { decimals: 10, name: "United States Dollar", symbol: "USD" }, - BTC, + ETH, ], time: dateNow, }, @@ -168,9 +167,9 @@ describe("lib/prices.ts", () => { jest.spyOn(ethers, "fetchJson").mockResolvedValue(fetchJsonResponse) - await expect( - getPrices([BTC, ETH] as CoinGeckoAsset[], FIAT_CURRENCIES) - ).resolves.toEqual(getPricesResponse) + await expect(getPrices([ETH], FIAT_CURRENCIES)).resolves.toEqual( + getPricesResponse + ) expect(ethers.fetchJson).toHaveBeenCalledTimes(1) }) it("should filter out invalid pairs if the data DOESN'T exist", async () => { @@ -210,9 +209,9 @@ describe("lib/prices.ts", () => { ] jest.spyOn(ethers, "fetchJson").mockResolvedValue(fetchJsonResponse) - await expect( - getPrices([ETH, FAKE_COIN] as CoinGeckoAsset[], currencies) - ).resolves.toEqual(getPricesResponse) + await expect(getPrices([ETH, FAKE_COIN], currencies)).resolves.toEqual( + getPricesResponse + ) expect(ethers.fetchJson).toHaveBeenCalledTimes(1) }) it("should return [] if the api response does not fit the schema", async () => { @@ -220,9 +219,7 @@ describe("lib/prices.ts", () => { jest.spyOn(ethers, "fetchJson").mockResolvedValue(response) - await expect( - getPrices([ETH] as CoinGeckoAsset[], FIAT_CURRENCIES) - ).resolves.toEqual([]) + await expect(getPrices([ETH], FIAT_CURRENCIES)).resolves.toEqual([]) expect(ethers.fetchJson).toHaveBeenCalledTimes(1) }) }) diff --git a/manifest/manifest.json b/manifest/manifest.json index f7399d75b1..089d6de4d1 100644 --- a/manifest/manifest.json +++ b/manifest/manifest.json @@ -1,6 +1,6 @@ { "name": "Tally Ho", - "version": "0.18.7", + "version": "0.18.9", "description": "The community owned and operated Web3 wallet.", "homepage_url": "https://tally.cash", "author": "https://tally.cash", diff --git a/package.json b/package.json index fb64902c4d..955ced22a2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@tallyho/tally-extension", "private": true, - "version": "0.18.7", + "version": "0.18.9", "description": "Tally Ho, the community owned and operated Web3 wallet.", "main": "index.js", "repository": "git@github.com:thesis/tally-extension.git", diff --git a/ui/_locales/en/messages.json b/ui/_locales/en/messages.json index b6b60afe25..9d4e2d63aa 100644 --- a/ui/_locales/en/messages.json +++ b/ui/_locales/en/messages.json @@ -132,7 +132,7 @@ "connectSelectedLedger": "Connect selected", "doneMessageOne": "Congratulations!", "doneMessageTwo": "You can open Tally Ho now.", - "onboardingSuccessful": "Selected accounts were succesfully connected.", + "onboardingSuccessful": "Selected accounts were successfully connected.", "closeTab": "Close tab" }, "connectionStatus": { @@ -272,7 +272,7 @@ "collection_other": "Collections" }, "header": { - "title": "Estimated total NFTs value", + "title": "Total NFTs floor price", "addAccountCTA": "Add account", "emptyTitle": "No NFTs here", "emptyDesc": "Add more accounts to see your NFTs, you can also just add read-only accounts" @@ -283,9 +283,10 @@ }, "filters": { "title": "Filter collections", + "warning": "Changing filters here will affect your Portfolio page as well.", "sortType": { - "priceDesc": "Price: Descending", - "priceAsc": "Price: Ascending", + "priceDesc": "Floor price: Descending", + "priceAsc": "Floor price: Ascending", "newestAdded": "Newest added", "oldestAdded": "Oldest added", "numberInOneCollection": "Number (in 1 collection)" @@ -316,7 +317,7 @@ "tip": "Some of the code for this was written by Community contributors" }, "viewOnly": { - "tip": "A good way to take a peak at what Tally Ho offers" + "tip": "A good way to take a peek at what Tally Ho offers" }, "importSeed": { "tip": "Tally Ho offers the possibility of adding multiple recovery phrases" @@ -399,7 +400,7 @@ }, "seedVerification": { "seedIsInWrongOrder": "Wrong Order", - "seedMismatchExplainer": "We are sorry, the recovery phrase you entered did not match.You can try to re-order them, but make sure you have them written down.", + "seedMismatchExplainer": "We are sorry, the recovery phrase you entered did not match. You can try to re-order them, but make sure you have them written down.", "createNewWallet": "If you prefer you can <1><0>create a new wallet.<0>", "retryVerification": "Try again", "verifySeedPrompt": "Verify recovery phrase", @@ -436,6 +437,7 @@ "assets": "Assets", "networks": "Networks", "nfts": "{{nfts}} in {{collections}} & {{badges}}", + "nftsTooltip": "NFTs here are shown based on your filters in the NFT page.", "units": { "nft": "1 NFT", "nft_other": "{{count}} NFTs", @@ -446,6 +448,7 @@ }, "tableHeader": { "asset": "Asset", + "assets": "Assets", "price": "Price", "balance": "Balance" } @@ -636,10 +639,11 @@ "swapRewardsTeaser": "Swap rewards coming soon", "continueSwap": "Continue Swap", "exchangeRoute": "Exchange route", + "loadingQuote": "Fetching price", "rewards": { "header": "Swap rewards for community", "body": "This week, 240,000 DOGGO tokens will be equally shared as Swap Rewards.", - "tooltip": "Tally Ho rewards it's users that use swap every week. A council decides weekly prizes and who is eligible.", + "tooltip": "Tally Ho rewards its users that use swap every week. A council decides weekly prizes and who is eligible.", "detailButton": "Details" }, "error": { @@ -708,6 +712,15 @@ "transactionFrom": "From:", "loadingActivities": "Digging deeper..." }, + "trustedAssets": { + "notTrusted": "Asset isn't trusted", + "notVerified": "Asset has not been verified yet!", + "trustExplainer": "Only transact with assets you trust.", + "assetImported": "Asset imported from transaction history", + "name": "Name", + "contract": "Contract address", + "close": "Close" + }, "banner": { "bannerTitle": "New Odyssey week!", "emptyBannerContent": "Check out Arbitrum Odyssey campaign", @@ -739,7 +752,7 @@ "snackbar": "You can change this in Settings later", "isDefault": "is now your default wallet", "notDefault": "is not your default wallet", - "tooltip": "Setting Tally Ho as your default wallet means that everytime you connect to a dApp, Tally Ho will open instead of MetaMask or other wallets." + "tooltip": "Setting Tally Ho as your default wallet means that every time you connect to a dApp, Tally Ho will open instead of MetaMask or other wallets." }, "analyticsNotification": { "title": "Analytics are on", @@ -755,6 +768,20 @@ "toggleTitle": "Use Tally Ho as default wallet" } }, + "abilities": { + "header": "Portfolio abilities", + "emptyState": { + "title": "No abilities here", + "desc": "Add more accounts to see your abilities or change filters", + "addBtn": "Add account" + }, + "banner": { + "description": "Daylight tells you when you qualify for an airdrop, mint or unlock.", + "seeAbilities": "See your abilities", + "new": "New", + "none": "None" + } + }, "globalError": { "title": "Ups, nothing to see here", "desc": "Looks like you encountered an empty bowl, try refreshing the page to see if something appears", @@ -781,6 +808,7 @@ "SUPPORT_NFT_TAB": "Enable to open NFTs page from tab", "SUPPORT_NFT_SEND": "Enable sending NFTs", "SUPPORT_ARBITRUM_NOVA": "Enable Arbitrum Nova network", + "SUPPORT_SWAP_QUOTE_REFRESH": "Enable automatic swap quote updates", "SUPPORT_BINANCE_SMART_CHAIN": "Enable Binance Smart Chain network", "SUPPORT_CUSTOM_NETWORKS": "Show custom network page on settings panel", "SUPPORT_CUSTOM_RPCS": "Enable adding custom RPCs" @@ -795,4 +823,4 @@ "earn": "Earn", "settings": "Settings" } -} \ No newline at end of file +} diff --git a/ui/components/AccountsNotificationPanel/AccountsNotificationPanelAccounts.tsx b/ui/components/AccountsNotificationPanel/AccountsNotificationPanelAccounts.tsx index 199c004b48..a2d9486e1b 100644 --- a/ui/components/AccountsNotificationPanel/AccountsNotificationPanelAccounts.tsx +++ b/ui/components/AccountsNotificationPanel/AccountsNotificationPanelAccounts.tsx @@ -467,6 +467,7 @@ export default function AccountsNotificationPanelAccounts({ .switcher_wrap { height: 432px; overflow-y: scroll; + border-top: 1px solid var(--green-120); } .category_wrap { display: flex; diff --git a/ui/components/AccountsNotificationPanel/EditSectionForm.tsx b/ui/components/AccountsNotificationPanel/EditSectionForm.tsx index 61728eb7bf..3086e88bb8 100644 --- a/ui/components/AccountsNotificationPanel/EditSectionForm.tsx +++ b/ui/components/AccountsNotificationPanel/EditSectionForm.tsx @@ -78,6 +78,7 @@ function EditSectionForm({ label="" placeholder={t("typeNewName")} errorMessage={error} + autoFocus onChange={(value) => { if (!touched) { setTouched(true) diff --git a/ui/components/NFTS_update/Filters/NFTsFilters.tsx b/ui/components/NFTS_update/Filters/NFTsFilters.tsx index e07c8a8eef..fca9dd50b7 100644 --- a/ui/components/NFTS_update/Filters/NFTsFilters.tsx +++ b/ui/components/NFTS_update/Filters/NFTsFilters.tsx @@ -109,6 +109,7 @@ export default function NFTsFilters(): ReactElement { return (
+ {t("warning")}
{t("sortTypeTitle")} {RADIO_BTNS.map(({ value, label }) => ( @@ -153,13 +154,16 @@ export default function NFTsFilters(): ReactElement { gap: 16px; height: 456px; overflow-y: scroll; - padding: 8px 24px; + padding: 0 24px 8px; } .filter_title { display: inline-block; margin-bottom: 4px; width: 100%; } + .filter_warning { + color: var(--green-20); + } .spinner { width: 100%; display: flex; diff --git a/ui/components/NFTS_update/NFTCollection.tsx b/ui/components/NFTS_update/NFTCollection.tsx index 363872dbb1..22e5038fe7 100644 --- a/ui/components/NFTS_update/NFTCollection.tsx +++ b/ui/components/NFTS_update/NFTCollection.tsx @@ -13,13 +13,14 @@ import SharedSkeletonLoader from "../Shared/SharedSkeletonLoader" export default function NFTCollection(props: { collection: NFTCollectionCached + isExpanded: boolean + setExpandedID: (id: string | null, owner: string | null) => void openPreview: (current: NFTWithCollection) => void }): ReactElement { - const { collection, openPreview } = props + const { collection, openPreview, isExpanded, setExpandedID } = props const { id, owner, network, nfts, nftCount, hasNextPage } = collection const dispatch = useBackgroundDispatch() - const [isExpanded, setIsExpanded] = useState(false) const [isLoading, setIsLoading] = useState(false) // initial update of collection const [isUpdating, setIsUpdating] = useState(false) // update on already loaded collection const [wasUpdated, setWasUpdated] = useState(false) // to fetch NFTs data only once during the component lifespan @@ -104,62 +105,76 @@ export default function NFTCollection(props: { } }, [fetchCollection, isExpanded, wasUpdated]) - const toggleCollection = () => setIsExpanded((val) => !val) + const toggleCollection = () => + isExpanded ? setExpandedID(null, null) : setExpandedID(id, owner) const onItemClick = (nft: NFT) => openPreview({ nft, collection }) return ( <> -
  • - - {nfts.length === 1 ? ( - onItemClick(nfts[0])} - /> - ) : ( - - )} - {isExpanded && ( - <> - {nfts.map((nft) => ( - - ))} - + {nfts.length === 1 ? ( + onItemClick(nfts[0])} /> -
    - - )} - -
  • + ) : ( + + )} + {isExpanded && ( + <> + {nfts.map((nft) => ( + + ))} + +
    + + )} + + +
    + ) } diff --git a/ui/components/NFTS_update/NFTHover.tsx b/ui/components/NFTS_update/NFTHover.tsx index 86f936f37c..d3d49cb2ce 100644 --- a/ui/components/NFTS_update/NFTHover.tsx +++ b/ui/components/NFTS_update/NFTHover.tsx @@ -9,6 +9,7 @@ const icons: Record< icon: string label: I18nKey background: string + backgroundHover?: string size: number style: string } @@ -17,6 +18,7 @@ const icons: Record< icon: "close", label: i18n.t("nfts.collectionHover.close"), background: "var(--green-40)", + backgroundHover: "var(--green-20)", size: 12, style: "", }, @@ -50,7 +52,7 @@ export default function NFTsHover(props: { }): ReactElement { const { isCollection = false, isExpanded = false, onClick } = props - const { icon, label, background, size, style } = getIcon( + const { icon, label, background, backgroundHover, size, style } = getIcon( isCollection, isExpanded ) @@ -99,6 +101,10 @@ export default function NFTsHover(props: { width: 32px; height: 32px; border-radius: 100%; + transition: background 200ms ease-in-out; + } + .nft_hover:hover .nft_hover_icon { + background: ${backgroundHover ?? background}; } `} diff --git a/ui/components/NFTS_update/NFTList.tsx b/ui/components/NFTS_update/NFTList.tsx index 7bbbab72d0..7a0b745cef 100644 --- a/ui/components/NFTS_update/NFTList.tsx +++ b/ui/components/NFTS_update/NFTList.tsx @@ -3,7 +3,7 @@ import { NFTWithCollection, } from "@tallyho/tally-background/redux-slices/nfts_update" import { selectIsReloadingNFTs } from "@tallyho/tally-background/redux-slices/selectors" -import React, { ReactElement, useState } from "react" +import React, { ReactElement, useCallback, useState } from "react" import { useBackgroundSelector } from "../../hooks" import SharedLoadingDoggo from "../Shared/SharedLoadingDoggo" import SharedSlideUpMenu from "../Shared/SharedSlideUpMenu" @@ -19,6 +19,14 @@ export default function NFTList(props: { const [isPreviewOpen, setIsPreviewOpen] = useState(false) const [currentNFTPreview, setCurrentNFTPreview] = useState(null) + const [currentExpandedID, setCurrentExpandedID] = useState( + null + ) + const setExpandedID = useCallback( + (id: string | null, owner: string | null) => + setCurrentExpandedID(`${id}_${owner}`), // TODO: owner can be removed after we will merge collections owned by multiple accounts + [] + ) const isReloading = useBackgroundSelector(selectIsReloadingNFTs) @@ -51,7 +59,7 @@ export default function NFTList(props: { collection.nfts.map((nft) => ( openPreview({ nft, collection })} /> )) @@ -60,6 +68,10 @@ export default function NFTList(props: { key={`${collection.id}_${collection.owner}`} openPreview={openPreview} collection={collection} + setExpandedID={setExpandedID} + isExpanded={ + `${collection.id}_${collection.owner}` === currentExpandedID + } /> ) )} diff --git a/ui/components/NFTS_update/NFTPreview.tsx b/ui/components/NFTS_update/NFTPreview.tsx index aa0c7574cc..cc8d5f566e 100644 --- a/ui/components/NFTS_update/NFTPreview.tsx +++ b/ui/components/NFTS_update/NFTPreview.tsx @@ -75,6 +75,7 @@ export default function NFTPreview(props: NFTWithCollection): ReactElement { highResolutionSrc={previewURL} alt={name} width={384} + height={isBadge ? 384 : undefined} isBadge={isBadge} customStyles="border-radius: 0 0 8px 8px;" /> @@ -254,6 +255,7 @@ export default function NFTPreview(props: NFTWithCollection): ReactElement { font-weight: 500; font-size: 16px; line-height: 24px; + overflow-wrap: break-word; color: var(--green-20); margin: 0; } diff --git a/ui/components/NFTS_update/NFTsHeader.tsx b/ui/components/NFTS_update/NFTsHeader.tsx index 672ff88146..862b4c2c5e 100644 --- a/ui/components/NFTS_update/NFTsHeader.tsx +++ b/ui/components/NFTS_update/NFTsHeader.tsx @@ -33,7 +33,7 @@ export default function NFTsHeader(): ReactElement { const mainCurrencySign = useBackgroundSelector(selectMainCurrencySign) const { totalFloorPriceInETH, totalFloorPriceInUSD } = - useTotalNFTsFloorPrice(false) + useTotalNFTsFloorPrice() const handleToggleClick = useCallback(() => { setOpenFiltersMenu((currentlyOpen) => !currentlyOpen) @@ -81,13 +81,13 @@ export default function NFTsHeader(): ReactElement {
    • - {collectionCount} - {t("units.collection", { count: collectionCount ?? 0 })} + {nftCount} + {t("units.nft", { count: nftCount ?? 0 })}
    • - {nftCount} - {t("units.nft", { count: nftCount ?? 0 })} + {collectionCount} + {t("units.collection", { count: collectionCount ?? 0 })}
    • diff --git a/ui/components/Overview/AbilitiesHeader.tsx b/ui/components/Overview/AbilitiesHeader.tsx index 37fbf0f5b7..7e95430f79 100644 --- a/ui/components/Overview/AbilitiesHeader.tsx +++ b/ui/components/Overview/AbilitiesHeader.tsx @@ -1,92 +1,166 @@ -import { selectAbilityCount } from "@tallyho/tally-background/redux-slices/selectors" +import { toggleHideDescription } from "@tallyho/tally-background/redux-slices/abilities" +import { + selectAbilityCount, + selectHideDescription, +} from "@tallyho/tally-background/redux-slices/selectors" +import classNames from "classnames" import React, { ReactElement } from "react" +import { useTranslation } from "react-i18next" import { useSelector } from "react-redux" import { useHistory } from "react-router-dom" +import { useBackgroundDispatch } from "../../hooks" +import SharedButton from "../Shared/SharedButton" export default function AbilitiesHeader(): ReactElement { + const { t } = useTranslation("translation", { + keyPrefix: "abilities", + }) const newAbilities = useSelector(selectAbilityCount) + const hideDescription = useSelector(selectHideDescription) + const dispatch = useBackgroundDispatch() const history = useHistory() - const abilityCount = newAbilities > 0 ? `${newAbilities} New` : "None" + const abilityCount = + newAbilities > 0 ? `${newAbilities} ${t("banner.new")}` : t("banner.none") + + const handleClick = () => { + if (!hideDescription) { + dispatch(toggleHideDescription(true)) + } + history.push("abilities") + } return ( - <> -
      -
      -
      -
      -
      Daylight abilities
      -
      +
      +
      +
      +
      { - history.push("abilities") - }} - onKeyDown={(e) => { - if (e.key === "Enter") { - history.push("abilities") - } - }} - role="button" - tabIndex={0} - className="ability_count" + className={classNames({ + header: !hideDescription, + })} > - {abilityCount} + {t("header")}
      +
      handleClick()} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleClick() + } + }} + > + {abilityCount} +
      + {!hideDescription && ( +
      +
      {t("banner.description")}
      + handleClick()} + > + {t("banner.seeAbilities")} + +
      + )} - +
      ) } diff --git a/ui/components/Overview/AccountList.tsx b/ui/components/Overview/AccountList.tsx index 771ed5660f..800ecca9f2 100644 --- a/ui/components/Overview/AccountList.tsx +++ b/ui/components/Overview/AccountList.tsx @@ -63,7 +63,7 @@ export default function AccountList({ >
      - {t("overview.accounts")}({accountsCount}) + {t("overview.accounts")} ({accountsCount}) {isCollapsible && (
      ) diff --git a/ui/components/Shared/SharedSquareButton.tsx b/ui/components/Shared/SharedSquareButton.tsx index c086640da5..27166eccec 100644 --- a/ui/components/Shared/SharedSquareButton.tsx +++ b/ui/components/Shared/SharedSquareButton.tsx @@ -3,7 +3,7 @@ import React, { ReactElement } from "react" const SIZE = 32 const DEFAULT_COLORS: ColorDetails = { color: "var(--green-40)", - hoverColor: "var(--trophy-gold)", + hoverColor: "var(--gold-80)", } type ColorDetails = { @@ -70,9 +70,9 @@ export default function SharedSquareButton(props: Props): ReactElement { mask-repeat: no-repeat; mask-position: center; mask-size: cover; - mask-size: 60%; - width: ${size}px; - height: ${size}px; + width: ${size / 2}px; + height: ${size / 2}px; + margin: ${size / 4}px; background-color: var(--hunter-green); } `} diff --git a/ui/components/Snackbar/Snackbar.tsx b/ui/components/Snackbar/Snackbar.tsx index 45f45e11c7..601b1d0afe 100644 --- a/ui/components/Snackbar/Snackbar.tsx +++ b/ui/components/Snackbar/Snackbar.tsx @@ -52,10 +52,8 @@ export default function Snackbar(): ReactElement { }, [clearSnackbarTimeout, dispatch]) return ( -
      -
      - {displayMessage} -
      +
      +
      {displayMessage}
      + + ) +} + +type AssetWarningSlideUpProps = { + asset: AnyAsset | null + close: () => void +} + +export default function AssetWarningSlideUp( + props: AssetWarningSlideUpProps +): ReactElement { + const { t } = useTranslation("translation", { + keyPrefix: "wallet.trustedAssets", + }) + const { asset, close } = props + return ( + + + + + {t("notVerified")} +
      + {t("trustExplainer")} +
      +
        +
      • + {t("name")} +
        + {`${asset?.name} (${asset?.symbol})`} +
        +
      • +
      • + {t("contract")} +
        + +
        +
      • +
      + + {t("close")} + +
      + ) +} diff --git a/ui/components/Wallet/WalletAccountBalanceControl.tsx b/ui/components/Wallet/WalletAccountBalanceControl.tsx index 5f9eb9d39d..381cb97e08 100644 --- a/ui/components/Wallet/WalletAccountBalanceControl.tsx +++ b/ui/components/Wallet/WalletAccountBalanceControl.tsx @@ -48,7 +48,7 @@ function ActionButtons(props: ActionButtonsProps): ReactElement { onClick={() => history.push("/swap")} iconColor={{ color: "var(--trophy-gold)", - hoverColor: "var(--trophy-gold)", + hoverColor: "var(--gold-80)", }} > {t("swap")} diff --git a/ui/components/Wallet/WalletAssetList.tsx b/ui/components/Wallet/WalletAssetList.tsx index c15d4335bf..00dd10d27b 100644 --- a/ui/components/Wallet/WalletAssetList.tsx +++ b/ui/components/Wallet/WalletAssetList.tsx @@ -1,43 +1,59 @@ // @ts-check -// -import React, { ReactElement } from "react" + +import React, { ReactElement, useState } from "react" import { CompleteAssetAmount } from "@tallyho/tally-background/redux-slices/accounts" import { useTranslation } from "react-i18next" import WalletAssetListItem from "./WalletAssetListItem" +import AssetWarningSlideUp from "./AssetWarningSlideUp" -interface Props { +type WalletAssetListProps = { assetAmounts: CompleteAssetAmount[] initializationLoadingTimeExpired: boolean } -export default function WalletAssetList(props: Props): ReactElement { +export default function WalletAssetList( + props: WalletAssetListProps +): ReactElement { const { t } = useTranslation("translation", { keyPrefix: "wallet.activities", }) const { assetAmounts, initializationLoadingTimeExpired } = props + + const [warnedAsset, setWarnedAsset] = useState< + CompleteAssetAmount["asset"] | null + >(null) + if (!assetAmounts) return <> + return ( -
        - {assetAmounts.map((assetAmount) => ( - - ))} - {!initializationLoadingTimeExpired && ( -
      • {t("loadingActivities")}
      • - )} - -
      + <> + setWarnedAsset(null)} + /> +
        + {assetAmounts.map((assetAmount) => ( + setWarnedAsset(asset)} + /> + ))} + {!initializationLoadingTimeExpired && ( +
      • {t("loadingActivities")}
      • + )} + +
      + ) } diff --git a/ui/components/Wallet/WalletAssetListItem.tsx b/ui/components/Wallet/WalletAssetListItem.tsx index b020e851d0..c78549e395 100644 --- a/ui/components/Wallet/WalletAssetListItem.tsx +++ b/ui/components/Wallet/WalletAssetListItem.tsx @@ -7,10 +7,15 @@ import CommonAssetListItem from "./AssetListItem/CommonAssetListItem" interface Props { assetAmount: CompleteAssetAmount initializationLoadingTimeExpired: boolean + onUntrustedAssetWarningClick?: (asset: CompleteAssetAmount["asset"]) => void } export default function WalletAssetListItem(props: Props): ReactElement { - const { assetAmount, initializationLoadingTimeExpired } = props + const { + assetAmount, + initializationLoadingTimeExpired, + onUntrustedAssetWarningClick, + } = props const isDoggoAsset = assetAmount.asset.symbol === "DOGGO" @@ -22,33 +27,51 @@ export default function WalletAssetListItem(props: Props): ReactElement { )}
    • diff --git a/ui/hooks/nft-hooks.ts b/ui/hooks/nft-hooks.ts index 57f127a1b7..0571e260d7 100644 --- a/ui/hooks/nft-hooks.ts +++ b/ui/hooks/nft-hooks.ts @@ -1,56 +1,92 @@ /* eslint-disable import/prefer-default-export */ import { getAssetsState, - selectFilteredTotalFloorPriceInETH, + selectFilteredTotalFloorPrice, selectMainCurrencySymbol, - selectTotalFloorPriceInETH, } from "@tallyho/tally-background/redux-slices/selectors" import { enrichAssetAmountWithMainCurrencyValues, formatCurrencyAmount, } from "@tallyho/tally-background/redux-slices/utils/asset-utils" -import { ETH } from "@tallyho/tally-background/constants" +import { + BUILT_IN_NETWORK_BASE_ASSETS, + ETH, + USD, +} from "@tallyho/tally-background/constants" import { selectAssetPricePoint } from "@tallyho/tally-background/redux-slices/assets" import { cleanCachedNFTs, refetchCollections, } from "@tallyho/tally-background/redux-slices/nfts_update" import { useEffect } from "react" +import { + assetAmountToDesiredDecimals, + convertAssetAmountViaPricePoint, + flipPricePoint, +} from "@tallyho/tally-background/assets" import { useBackgroundDispatch, useBackgroundSelector } from "./redux-hooks" -export const useTotalNFTsFloorPrice = ( - useTotalFloorPrice = true -): { +export const useTotalNFTsFloorPrice = (): { totalFloorPriceInETH: string totalFloorPriceInUSD: string } => { const assets = useBackgroundSelector(getAssetsState) const mainCurrencySymbol = useBackgroundSelector(selectMainCurrencySymbol) - const totalFloorPriceInETH = useBackgroundSelector( - useTotalFloorPrice - ? selectTotalFloorPriceInETH - : selectFilteredTotalFloorPriceInETH + const totalFloorPrice = useBackgroundSelector(selectFilteredTotalFloorPrice) + const ETHPricePoint = selectAssetPricePoint(assets, ETH, mainCurrencySymbol) + + const mainCurrencyTotalPrice = Object.entries(totalFloorPrice).reduce( + (acc, [symbol, price]) => { + const baseAsset = BUILT_IN_NETWORK_BASE_ASSETS.find( + (asset) => asset.symbol === symbol + ) + + if (!baseAsset) return acc + + const pricePoint = selectAssetPricePoint( + assets, + baseAsset, + mainCurrencySymbol + ) + + const enrichedPrice = enrichAssetAmountWithMainCurrencyValues( + { + asset: baseAsset, + amount: BigInt(Math.round(price * 10 ** baseAsset.decimals)), + }, + pricePoint, + 2 + ) + + return acc + (enrichedPrice.mainCurrencyAmount ?? 0) + }, + 0 ) - const totalFloorPriceInETHFormatted = formatCurrencyAmount( + + const totalFloorPriceInUSD = formatCurrencyAmount( mainCurrencySymbol, - totalFloorPriceInETH, - 4 + mainCurrencyTotalPrice, + 2 ) - const ETHPricePoint = selectAssetPricePoint(assets, ETH, mainCurrencySymbol) - const totalFloorPriceLocalized = - enrichAssetAmountWithMainCurrencyValues( + const floorPriceConvertedToETH = + ETHPricePoint && + convertAssetAmountViaPricePoint( { - asset: ETH, - amount: BigInt(Math.round(totalFloorPriceInETH * 10 ** ETH.decimals)), + asset: USD, + amount: BigInt(Math.round(mainCurrencyTotalPrice * 10 ** USD.decimals)), }, - ETHPricePoint, - 2 - ).localizedMainCurrencyAmount ?? "-" + flipPricePoint(ETHPricePoint) + ) + + const totalFloorPriceInETH = + (floorPriceConvertedToETH && + assetAmountToDesiredDecimals(floorPriceConvertedToETH, 4)) ?? + 0 return { - totalFloorPriceInETH: totalFloorPriceInETHFormatted, - totalFloorPriceInUSD: totalFloorPriceLocalized, + totalFloorPriceInETH: totalFloorPriceInETH?.toLocaleString(), + totalFloorPriceInUSD, } } diff --git a/ui/hooks/react-hooks.ts b/ui/hooks/react-hooks.ts index 111ec9e74d..f4c679a7e9 100644 --- a/ui/hooks/react-hooks.ts +++ b/ui/hooks/react-hooks.ts @@ -16,6 +16,25 @@ export function useIsMounted(): React.MutableRefObject { return mountedRef } +/** + * Proper implementation of `setInterval` with cleanup on component unmount + */ +export function useInterval unknown>( + callback: F, + delay: number +): void { + const callbackRef = useRef(callback) + callbackRef.current = callback + + useEffect(() => { + const timerId = setInterval(() => callbackRef.current(), delay) + + return () => { + clearInterval(timerId) + } + }, [delay]) +} + /** * Returns an always updated ref to value */ diff --git a/ui/pages/Abilities.tsx b/ui/pages/Abilities.tsx index 8284e57b10..b345c88294 100644 --- a/ui/pages/Abilities.tsx +++ b/ui/pages/Abilities.tsx @@ -1,44 +1,118 @@ import { selectFilteredAbilities } from "@tallyho/tally-background/redux-slices/selectors" import React, { ReactElement } from "react" -import { useSelector } from "react-redux" +import { useTranslation } from "react-i18next" +import SharedButton from "../components/Shared/SharedButton" import AbilityCard from "./Abilities/AbilityCard" +import { useBackgroundSelector } from "../hooks" export default function Abilities(): ReactElement { - const abilities = useSelector(selectFilteredAbilities) + const { t } = useTranslation("translation", { + keyPrefix: "abilities", + }) + const abilities = useBackgroundSelector(selectFilteredAbilities) return ( - <> -
      -
      -
      -

      Daylight Abilities!

      +
      +
      +
      +
      +

      {t("header")}

      - {abilities.map((ability) => ( - - ))} -
      + {abilities.length > 0 ? ( + abilities.map((ability) => ( + + )) + ) : ( +
      +
      +
      {t("emptyState.title")}
      +
      {t("emptyState.desc")}
      + + {t("emptyState.addBtn")} + +
      + )} +
      - +
      ) } diff --git a/ui/pages/Overview.tsx b/ui/pages/Overview.tsx index c544d37e60..1ee8738d0f 100644 --- a/ui/pages/Overview.tsx +++ b/ui/pages/Overview.tsx @@ -51,7 +51,7 @@ function Overview(): ReactElement { balance={balance} initializationTimeExpired={initializationLoadingTimeExpired} /> - {FeatureFlags.SUPPORT_ABILITIES ? : null} + {isEnabled(FeatureFlags.SUPPORT_ABILITIES) ? : null} - {FeatureFlags.SUPPORT_ABILITIES ? : null} + {isEnabled(FeatureFlags.SUPPORT_ABILITIES) ? : null} { + if (!isEnabled(FeatureFlags.SUPPORT_SWAP_QUOTE_REFRESH)) return + + const isRecentQuote = + quote && + // Time passed since last quote + Date.now() - quote.timestamp <= 3 * SECOND + + const skipRefresh = + loadingQuote || (isRecentQuote && quoteAppliesToCurrentAssets) + + if ( + !skipRefresh && + !amountInputHasFocus && + sellAsset && + buyAsset && + (sellAmount || buyAmount) + ) { + const type = sellAmount ? "getBuyAmount" : "getSellAmount" + const amount = sellAmount || buyAmount + + requestQuoteUpdate({ + type, + amount, + sellAsset, + buyAsset, + }) + } + }, REFRESH_QUOTE_INTERVAL) + useOnMount(() => { // Request a quote on mount if (sellAsset && buyAsset && sellAmount) { @@ -445,6 +482,8 @@ export default function Swap(): ReactElement { selectedAsset={sellAsset} isDisabled={loadingSellAmount} onAssetSelect={updateSellAsset} + onFocus={() => setAmountInputHasFocus(true)} + onBlur={() => setAmountInputHasFocus(false)} mainCurrencySign={mainCurrencySign} onAmountChange={(newAmount, error) => { setSellAmount(newAmount) @@ -483,6 +522,8 @@ export default function Swap(): ReactElement { assetsAndAmounts={buyAssets.map((asset) => ({ asset }))} selectedAsset={buyAsset} isDisabled={loadingBuyAmount} + onFocus={() => setAmountInputHasFocus(true)} + onBlur={() => setAmountInputHasFocus(false)} showMaxButton={false} mainCurrencySign={mainCurrencySign} onAssetSelect={updateBuyAsset} @@ -503,13 +544,14 @@ export default function Swap(): ReactElement { }} label={t("swap.to")} /> - {loadingQuote && sellAsset && buyAsset && ( - - )} +
      + {loadingQuote && sellAsset && buyAsset && ( + + )} +
    {!isEnabled(FeatureFlags.HIDE_SWAP_REWARDS) ? ( @@ -574,6 +616,12 @@ export default function Swap(): ReactElement { font-weight: 500; line-height: 32px; } + + .loading_wrapper { + min-height: 73.5px; + margin: 16px 0 32px; + } + .footer { display: flex; justify-content: center; diff --git a/ui/public/images/assets/daylight.png b/ui/public/images/assets/daylight.png deleted file mode 100644 index 420b9ec448..0000000000 Binary files a/ui/public/images/assets/daylight.png and /dev/null differ diff --git a/ui/public/images/daylight.svg b/ui/public/images/daylight.svg deleted file mode 100644 index ef754ac779..0000000000 --- a/ui/public/images/daylight.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/ui/public/images/no_preview.svg b/ui/public/images/no_preview.svg index 081c70a9dc..f477cef78d 100644 --- a/ui/public/images/no_preview.svg +++ b/ui/public/images/no_preview.svg @@ -1,12 +1,20 @@ - - - - - - - - - - + + + + + + + + + + + + + + + + + + diff --git a/ui/public/images/poap_logo.svg b/ui/public/images/poap_logo.svg new file mode 100644 index 0000000000..424e99dfc7 --- /dev/null +++ b/ui/public/images/poap_logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/public/images/tail.svg b/ui/public/images/tail.svg new file mode 100644 index 0000000000..aafe4f197f --- /dev/null +++ b/ui/public/images/tail.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/window-provider/index.ts b/window-provider/index.ts index 5dc5ecbf52..d3c3dc1d41 100644 --- a/window-provider/index.ts +++ b/window-provider/index.ts @@ -214,7 +214,7 @@ export default class TallyWindowProvider extends EventEmitter { reject(result) } - // let's emmit connected on the first successful response from background + // let's emit connected on the first successful response from background if (!this.connected) { this.connected = true this.emit("connect", { chainId: this.chainId }) diff --git a/yarn.lock b/yarn.lock index bf881af529..ad5c8b6fbf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10406,11 +10406,6 @@ node-addon-api@^4.2.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-4.3.0.tgz#52a1a0b475193e0928e98e0426a0d1254782b77f" integrity sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ== -node-fetch@^2.6.1: - version "2.6.1" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" - integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== - node-fetch@^2.6.7: version "2.6.7" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad"