diff --git a/.env.defaults b/.env.defaults index 25fda33f5c..6ff1ceaade 100644 --- a/.env.defaults +++ b/.env.defaults @@ -16,14 +16,11 @@ MAINNET_FORK_CHAIN_ID=1337 FILE_DIRECTORY_IPFS_HASH="QmYwYkRdYMBCtMqbimgaXjf7UL4RUb5zBQu4TDE67oZbq5" PART_GLOSSARY_IPFS_HASH="bafybeibytsozn7qsvqgecogv5urg5en34r7v3zxo326vacumi56ckah5b4" RESOLVE_RNS_NAMES=true -SUPPORT_RSK=true USE_UPDATED_SIGNING_UI=true SUPPORT_MULTIPLE_LANGUAGES=false SUPPORT_TABBED_ONBOARDING=false SUPPORT_KEYRING_LOCKING=true 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 diff --git a/.github/ISSUE_TEMPLATE/BUG.yml b/.github/ISSUE_TEMPLATE/BUG.yml index 21ac20af9e..dc6afe5b9a 100644 --- a/.github/ISSUE_TEMPLATE/BUG.yml +++ b/.github/ISSUE_TEMPLATE/BUG.yml @@ -51,6 +51,14 @@ body: label: Version description: What version of the extension are you running? options: + - v0.21.2 + - v0.21.1 + - v0.21.0 + - v0.20.0 + - v0.19.3 + - v0.19.2 + - v0.19.1 + - v0.19.0 - v0.18.9 - v0.18.8 - v0.18.7 diff --git a/__mocks__/webextension-polyfill.ts b/__mocks__/webextension-polyfill.ts index 479ed2ee9c..a6a6da7521 100644 --- a/__mocks__/webextension-polyfill.ts +++ b/__mocks__/webextension-polyfill.ts @@ -26,6 +26,13 @@ module.exports = { Promise.resolve(undefined as unknown as Tabs.Tab) ), }, + windows: { + writable: true, + value: { + getCurrent: () => {}, + create: () => {}, + }, + }, runtime: { ...browserMock.runtime, setUninstallURL: jest.fn(), diff --git a/background/abilities.ts b/background/abilities.ts new file mode 100644 index 0000000000..5abc1f232b --- /dev/null +++ b/background/abilities.ts @@ -0,0 +1,69 @@ +import { NormalizedEVMAddress } from "./types" + +type HoldERC20 = { + type: "hold" + address: string +} + +type OwnNFT = { + type: "own" + nftAddress: string +} + +type AllowList = { + type: "allowList" +} + +type Unknown = { + type: "unknown" +} + +export type AbilityRequirement = HoldERC20 | OwnNFT | AllowList | Unknown + +export const ABILITY_TYPES_ENABLED = [ + "mint", + "airdrop", + "vote", + "access", +] as const +// https://docs.daylight.xyz/reference/ability-model#ability-types +export const ABILITY_TYPES = [ + ...ABILITY_TYPES_ENABLED, + "claim", + "product", + "event", + "article", + "result", + "misc", +] as const + +export type AbilityType = typeof ABILITY_TYPES[number] + +export type Ability = { + type: AbilityType + title: string + description: string | null + abilityId: string + slug: string + linkUrl: string + imageUrl?: string + openAt?: string + closeAt?: string + completed: boolean + removedFromUi: boolean + address: NormalizedEVMAddress + requirement: AbilityRequirement +} + +export const ABILITY_TYPE_COLOR = { + mint: "#20c580", + airdrop: "#FF1E6F", + vote: "#E3C10B", + result: "#E3C10B", + access: "#02C0EA", + product: "#D824DC", + event: "#FF8A1E", + article: "#B2B2B2", + misc: "#CBCBCB", + claim: "#F4D530", +} diff --git a/background/constants/base-assets.ts b/background/constants/base-assets.ts index dd38dbe833..1bcd11fd95 100644 --- a/background/constants/base-assets.ts +++ b/background/constants/base-assets.ts @@ -5,26 +5,46 @@ const ETH: NetworkBaseAsset = { name: "Ether", symbol: "ETH", decimals: 18, + metadata: { + coinGeckoID: "ethereum", + tokenLists: [], + }, } const ARBITRUM_ONE_ETH: NetworkBaseAsset = { ...ETH, chainID: "42161", + metadata: { + coinGeckoID: "arbitrum-one", + tokenLists: [], + }, } const ARBITRUM_NOVA_ETH: NetworkBaseAsset = { ...ETH, chainID: "42170", + metadata: { + coinGeckoID: "arbitrum-nova", + tokenLists: [], + }, } const OPTIMISTIC_ETH: NetworkBaseAsset = { ...ETH, chainID: "10", + metadata: { + coinGeckoID: "optimistic-ethereum", + tokenLists: [], + }, } const GOERLI_ETH: NetworkBaseAsset = { ...ETH, chainID: "5", + metadata: { + coinGeckoID: "ethereum", + tokenLists: [], + }, } const RBTC: NetworkBaseAsset = { @@ -32,6 +52,10 @@ const RBTC: NetworkBaseAsset = { name: "RSK Token", symbol: "RBTC", decimals: 18, + metadata: { + coinGeckoID: "rootstock", + tokenLists: [], + }, } const MATIC: NetworkBaseAsset = { @@ -39,6 +63,10 @@ const MATIC: NetworkBaseAsset = { name: "Matic Token", symbol: "MATIC", decimals: 18, + metadata: { + coinGeckoID: "polygon-pos", + tokenLists: [], + }, } const AVAX: NetworkBaseAsset = { @@ -46,6 +74,10 @@ const AVAX: NetworkBaseAsset = { name: "Avalanche", symbol: "AVAX", decimals: 18, + metadata: { + coinGeckoID: "avalanche", + tokenLists: [], + }, } const BNB: NetworkBaseAsset = { @@ -53,6 +85,10 @@ const BNB: NetworkBaseAsset = { name: "Binance Coin", symbol: "BNB", decimals: 18, + metadata: { + coinGeckoID: "binance-smart-chain", + tokenLists: [], + }, } export const BASE_ASSETS_BY_CUSTOM_NAME = { diff --git a/background/constants/networks.ts b/background/constants/networks.ts index 0454f4b70d..8f972de01a 100644 --- a/background/constants/networks.ts +++ b/background/constants/networks.ts @@ -24,6 +24,7 @@ export const ROOTSTOCK: EVMNetwork = { name: "Rootstock", baseAsset: RBTC, chainID: "30", + derivationPath: "m/44'/137'/0'/0", family: "EVM", coingeckoPlatformID: "rootstock", } @@ -90,11 +91,9 @@ export const DEFAULT_NETWORKS = [ OPTIMISM, GOERLI, ARBITRUM_ONE, - ...(isEnabled(FeatureFlags.SUPPORT_RSK) ? [ROOTSTOCK] : []), - ...(isEnabled(FeatureFlags.SUPPORT_AVALANCHE) ? [AVALANCHE] : []), - ...(isEnabled(FeatureFlags.SUPPORT_BINANCE_SMART_CHAIN) - ? [BINANCE_SMART_CHAIN] - : []), + ROOTSTOCK, + AVALANCHE, + BINANCE_SMART_CHAIN, ...(isEnabled(FeatureFlags.SUPPORT_ARBITRUM_NOVA) ? [ARBITRUM_NOVA] : []), ] @@ -192,6 +191,33 @@ export const CHAIN_ID_TO_RPC_URLS: { ], } +// Taken from https://api.coingecko.com/api/v3/asset_platforms +export const CHAIN_ID_TO_COINGECKO_PLATFORM_ID: { + [chainId: string]: string +} = { + "250": "fantom", + "122": "fuse", + "361": "theta", + "199": "bittorent", + "106": "velas", + "128": "huobi-token", + "96": "bitkub-chain", + "333999": "polis-chain", + "321": "kucoin-community-chain", + "1285": "moonriver", + "25": "cronos", + "10000": "smartbch", + "1313161554": "aurora", + "88": "tomochain", + "1088": "metis-andromeda", + "2001": "milkomeda-cardano", + "9001": "evmos", + "288": "boba", + "42220": "celo", + "1284": "moonbeam", + "66": "okex-chain", +} + /** * Method list, to describe which rpc method calls on which networks should * prefer alchemy provider over the generic ones. diff --git a/background/features.ts b/background/features.ts index c5d826dd57..9c68c0208e 100644 --- a/background/features.ts +++ b/background/features.ts @@ -26,10 +26,6 @@ export const RuntimeFlag = { SUPPORT_FORGOT_PASSWORD: process.env.SUPPORT_FORGOT_PASSWORD === "true", ENABLE_ACHIEVEMENTS_TAB: process.env.ENABLE_ACHIEVEMENTS_TAB === "true", HIDE_TOKEN_FEATURES: process.env.HIDE_TOKEN_FEATURES === "true", - SUPPORT_RSK: process.env.SUPPORT_RSK === "true", - SUPPORT_AVALANCHE: process.env.SUPPORT_AVALANCHE === "true", - SUPPORT_BINANCE_SMART_CHAIN: - process.env.SUPPORT_BINANCE_SMART_CHAIN === "true", SUPPORT_ARBITRUM_NOVA: process.env.SUPPORT_ARBITRUM_NOVA === "true", SUPPORT_ACHIEVEMENTS_BANNER: process.env.SUPPORT_ACHIEVEMENTS_BANNER === "true", diff --git a/background/services/abilities/daylight.ts b/background/lib/daylight.ts similarity index 54% rename from background/services/abilities/daylight.ts rename to background/lib/daylight.ts index ce32b6b0e4..63e4009e6c 100644 --- a/background/services/abilities/daylight.ts +++ b/background/lib/daylight.ts @@ -1,19 +1,8 @@ import { fetchJson } from "@ethersproject/web" +import { AbilityType } from "../abilities" +import logger from "./logger" -const DAYLIGHT_BASE_URL = "https://api.daylight.xyz/v1/wallets" - -// https://docs.daylight.xyz/reference/ability-model#ability-types -type DaylightAbilityType = - | "vote" - | "claim" - | "airdrop" - | "mint" - | "access" - | "product" - | "event" - | "article" - | "result" - | "misc" +const DAYLIGHT_BASE_URL = "https://api.daylight.xyz/v1" type Community = { chain: string @@ -63,7 +52,7 @@ type DaylightAbilityAction = { } export type DaylightAbility = { - type: DaylightAbilityType + type: AbilityType title: string description: string | null imageUrl: string | null @@ -85,16 +74,56 @@ type AbilitiesResponse = { status: string } -// eslint-disable-next-line import/prefer-default-export +type SpamReportResponse = { + success: boolean +} + export const getDaylightAbilities = async ( address: string ): Promise => { - const response: AbilitiesResponse = await fetchJson( - // Abilities whose deadline has not yet passed - we will probably - // want to turn this on once the feature is ready to go live - // `${DAYLIGHT_BASE_URL}/${address}/abilities?deadline=set&type=mint&type=airdrop&type=access` - `${DAYLIGHT_BASE_URL}/${address}/abilities?type=mint&type=airdrop&type=access` - ) - - return response.abilities + try { + const response: AbilitiesResponse = await fetchJson( + `${DAYLIGHT_BASE_URL}/wallets/${address}/abilities?deadline=all` + ) + + return response.abilities + } catch (err) { + logger.error("Error getting abilities", err) + } + + return [] +} + +/** + * Report ability as spam. + * + * Learn more at https://docs.daylight.xyz/reference/create-spam-report + * + * @param address the address that reports the ability + * @param abilitySlug the slug of the ability being reported + * @param reason the reason why ability is reported + */ +export const createSpamReport = async ( + address: string, + abilitySlug: string, + reason: string +): Promise => { + try { + const options = JSON.stringify({ + submitter: address, + abilitySlug, + reason, + }) + + const response: SpamReportResponse = await fetchJson( + `${DAYLIGHT_BASE_URL}/spam-report`, + options + ) + + return response.success + } catch (err) { + logger.error("Error reporting spam", err) + } + + return false } diff --git a/background/lib/poap_update.ts b/background/lib/poap_update.ts index 558a0503c0..e9bffd76c5 100644 --- a/background/lib/poap_update.ts +++ b/background/lib/poap_update.ts @@ -3,7 +3,7 @@ import logger from "./logger" import { ETHEREUM } from "../constants" import { NFT, NFTCollection, NFTsWithPagesResponse } from "../nfts" -export const POAP_CONTRACT = "poap_contract" +export const POAP_CONTRACT = "0x22C1f6050E56d2876009903609a2cC3fEf83B415" // POAP contract address https://etherscan.io/address/0x22C1f6050E56d2876009903609a2cC3fEf83B415 export const POAP_COLLECTION_ID = "POAP" type PoapNFTModel = { @@ -39,6 +39,7 @@ function poapNFTModelToNFT(original: PoapNFTModel, owner: string): NFT { country, city, year, + supply, }, } = original return { @@ -58,7 +59,9 @@ function poapNFTModelToNFT(original: PoapNFTModel, owner: string): NFT { contract: POAP_CONTRACT, // contract address doesn't make sense for POAPs owner, network: ETHEREUM, + supply, isBadge: true, + rarity: {}, // no rarity rankings for POAPs } } diff --git a/background/lib/prices.ts b/background/lib/prices.ts index 8a7bc04662..bd8255a3f7 100644 --- a/background/lib/prices.ts +++ b/background/lib/prices.ts @@ -17,18 +17,23 @@ import { USD } from "../constants" const COINGECKO_API_ROOT = "https://api.coingecko.com/api/v3" +// @TODO Test Me export async function getPrices( - assets: (AnyAsset & Required)[], + assets: AnyAsset[], vsCurrencies: FiatCurrency[] ): Promise { - const coinIds = assets - .reduce((ids, asset) => { - if (ids.some((id) => id === asset.metadata.coinGeckoID)) { - return ids - } - return [...ids, asset.metadata.coinGeckoID] - }, []) - .join(",") + const queryableAssets = assets.filter( + (asset): asset is AnyAsset & Required => + "metadata" in asset && !!asset.metadata && "coinGeckoID" in asset.metadata + ) + + if (queryableAssets.length === 0) { + return [] + } + + const coinIds = [ + ...new Set([...queryableAssets.map((asset) => asset.metadata.coinGeckoID)]), + ].join(",") const currencySymbols = vsCurrencies .map((c) => c.symbol.toLowerCase()) @@ -52,7 +57,7 @@ export async function getPrices( } const resolutionTime = Date.now() - return assets.flatMap((asset) => { + return queryableAssets.flatMap((asset) => { const simpleCoinPrices = json[asset.metadata.coinGeckoID] return vsCurrencies @@ -98,6 +103,10 @@ export async function getTokenPrices( ): Promise<{ [contractAddress: string]: UnitPricePoint }> { + if (tokenAddresses.length < 1) { + return {} + } + const fiatSymbol = fiatCurrency.symbol const prices: { diff --git a/background/lib/simple-hash_update.ts b/background/lib/simple-hash_update.ts index c8305c6639..56a348e449 100644 --- a/background/lib/simple-hash_update.ts +++ b/background/lib/simple-hash_update.ts @@ -39,6 +39,11 @@ type SimpleHashNFTModel = { }[] } owners: { owner_address: string; last_acquired_date: string }[] + rarity?: { + rank: number | null + score: number | null + unique_attributes: number | null + } extra_metadata: { attributes?: [{ trait_type?: string | null; value?: string | null }] } @@ -111,11 +116,17 @@ function isGalxeAchievement(url: string | null | undefined) { return !!url && (url.includes("galaxy.eco") || url.includes("galxe.com")) } +function isKnownAddress(address: string, allAddresses: string[]): boolean { + return allAddresses.some((current) => sameEVMAddress(current, address)) +} + function getChainIDsNames(chainIDs: string[]) { return chainIDs - .map( + .flatMap( (chainID) => - CHAIN_ID_TO_NAME[parseInt(chainID, 10) as keyof typeof CHAIN_ID_TO_NAME] + CHAIN_ID_TO_NAME[ + parseInt(chainID, 10) as keyof typeof CHAIN_ID_TO_NAME + ] ?? [] ) .join(",") } @@ -164,6 +175,7 @@ function simpleHashNFTModelToNFT( external_url: nftURL = "", collection: { collection_id: collectionID }, extra_metadata: metadata, + rarity, } = original const thumbnailURL = @@ -206,6 +218,11 @@ function simpleHashNFTModelToNFT( collectionID, contract: contractAddress, owner, + rarity: { + rank: rarity?.rank ?? undefined, + score: rarity?.score ?? undefined, + uniqueAttributes: rarity?.unique_attributes ?? undefined, + }, network: NETWORK_BY_CHAIN_ID[chainID], isBadge: isGalxeAchievement(nftURL), } @@ -329,23 +346,25 @@ export async function getSimpleHashNFTsTransfers( const { transfers, next } = result - 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(), - 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, - } - : [] - ) + const transferDetails: TransferredNFT[] = transfers.flatMap((transfer) => { + const { nft_id: id, from_address: from, to_address: to } = transfer + if (id && (from || to)) { + const isKnownFromAddress = !!from && isKnownAddress(from, addresses) + const isKnownToAddress = !!to && isKnownAddress(to, addresses) + + return { + id, + chainID: SIMPLE_HASH_CHAIN_TO_ID[transfer.chain].toString(), + from, + to, + isKnownFromAddress, + isKnownToAddress, + collectionID: transfer.nft_details?.collection?.collection_id ?? null, + } + } + + return [] + }) if (next) { const nextPageTransferDetails = await getSimpleHashNFTsTransfers( diff --git a/background/main.ts b/background/main.ts index d2d9afa18c..5c00d7acc5 100644 --- a/background/main.ts +++ b/background/main.ts @@ -43,6 +43,7 @@ import { Eligible } from "./services/doggo/types" import rootReducer from "./redux-slices" import { + AccountType, deleteAccount, loadAccount, updateAccountBalance, @@ -63,7 +64,7 @@ import { updateKeyrings, setKeyringToVerify, } from "./redux-slices/keyrings" -import { blockSeen } from "./redux-slices/networks" +import { blockSeen, setEVMNetworks } from "./redux-slices/networks" import { initializationLoadingTimeHitLimit, emitter as uiSliceEmitter, @@ -157,7 +158,14 @@ import { deleteTransferredNFTs, } from "./redux-slices/nfts_update" import AbilitiesService from "./services/abilities" -import { addAbilities } from "./redux-slices/abilities" +import { + addAbilities, + updateAbility, + addAccount as addAccountFilter, + deleteAccount as deleteAccountFilter, + deleteAbilitiesForAccount, + initAbilities, +} from "./redux-slices/abilities" // This sanitizer runs on store and action data before serializing for remote // redux devtools. The goal is to end up with an object that is directly @@ -265,7 +273,6 @@ export default class Main extends BaseService { const preferenceService = PreferenceService.create() const keyringService = KeyringService.create() const chainService = ChainService.create(preferenceService, keyringService) - const abilitiesService = AbilitiesService.create(chainService) const indexingService = IndexingService.create( preferenceService, chainService @@ -301,11 +308,17 @@ export default class Main extends BaseService { const nftsService = NFTsService.create(chainService) + const abilitiesService = AbilitiesService.create( + chainService, + ledgerService + ) + const walletConnectService = isEnabled(FeatureFlags.SUPPORT_WALLET_CONNECT) ? WalletConnectService.create( providerBridgeService, internalEthereumProviderService, - preferenceService + preferenceService, + chainService ) : getNoopService() @@ -593,7 +606,7 @@ export default class Main extends BaseService { ): Promise { this.store.dispatch(deleteAccount(address)) - if (signer.type !== "read-only" && lastAddressInAccount) { + if (signer.type !== AccountType.ReadOnly && lastAddressInAccount) { await this.preferenceService.deleteAccountSignerSettings(signer) } @@ -609,6 +622,13 @@ export default class Main extends BaseService { this.store.dispatch(deleteNFTsForAddress(address)) await this.nftsService.removeNFTsForAddress(address) } + // remove abilities + if ( + isEnabled(FeatureFlags.SUPPORT_ABILITIES) && + signer.type !== AccountType.ReadOnly + ) { + await this.abilitiesService.deleteAbilitiesForAccount(address) + } // remove dApp premissions this.store.dispatch(revokePermissionsForAddress(address)) await this.providerBridgeService.revokePermissionsForAddress(address) @@ -722,6 +742,16 @@ export default class Main extends BaseService { await this.enrichActivitiesForSelectedAccount() } ) + + // Set up initial state. + const existingAccounts = await this.chainService.getAccountsToTrack() + existingAccounts.forEach(async (addressNetwork) => { + // Mark as loading and wire things up. + this.store.dispatch(loadAccount(addressNetwork)) + + // Force a refresh of the account balance to populate the store. + this.chainService.getLatestBaseAccountBalance(addressNetwork) + }) }) // Wire up chain service to account slice. @@ -733,6 +763,10 @@ export default class Main extends BaseService { } ) + this.chainService.emitter.on("supportedNetworks", (supportedNetworks) => { + this.store.dispatch(setEVMNetworks(supportedNetworks)) + }) + this.chainService.emitter.on("block", (block) => { this.store.dispatch(blockSeen(block)) }) @@ -858,16 +892,6 @@ export default class Main extends BaseService { } ) - // Set up initial state. - const existingAccounts = await this.chainService.getAccountsToTrack() - existingAccounts.forEach((addressNetwork) => { - // Mark as loading and wire things up. - this.store.dispatch(loadAccount(addressNetwork)) - - // Force a refresh of the account balance to populate the store. - this.chainService.getLatestBaseAccountBalance(addressNetwork) - }) - this.chainService.emitter.on( "blockPrices", async ({ blockPrices, network }) => { @@ -1087,13 +1111,14 @@ export default class Main extends BaseService { }) }) - keyringSliceEmitter.on("generateNewKeyring", async () => { + keyringSliceEmitter.on("generateNewKeyring", async (path) => { // TODO move unlocking to a reasonable place in the initialization flow const generated: { id: string mnemonic: string[] } = await this.keyringService.generateNewKeyring( - KeyringTypes.mnemonicBIP39S256 + KeyringTypes.mnemonicBIP39S256, + path ) this.store.dispatch(setKeyringToVerify(generated)) @@ -1282,6 +1307,7 @@ export default class Main extends BaseService { [{ chainId: network.chainID }], TALLY_INTERNAL_ORIGIN ) + this.chainService.pollBlockPricesForNetwork(network.chainID) this.store.dispatch(clearCustomGas()) }) } @@ -1502,6 +1528,9 @@ export default class Main extends BaseService { nftsSliceEmitter.on("fetchNFTs", ({ collectionID, account }) => this.nftsService.fetchNFTsFromCollection(collectionID, account) ) + nftsSliceEmitter.on("refetchNFTs", ({ collectionID, account }) => + this.nftsService.refreshNFTsFromCollection(collectionID, account) + ) nftsSliceEmitter.on("fetchMoreNFTs", ({ collectionID, account }) => this.nftsService.fetchNFTsFromNextPage(collectionID, account) ) @@ -1516,9 +1545,26 @@ export default class Main extends BaseService { } connectAbilitiesService(): void { - this.abilitiesService.emitter.on("newAbilities", async (newAbilities) => { + this.abilitiesService.emitter.on("initAbilities", (address) => { + this.store.dispatch(initAbilities(address)) + }) + this.abilitiesService.emitter.on("newAbilities", (newAbilities) => { this.store.dispatch(addAbilities(newAbilities)) }) + this.abilitiesService.emitter.on("deleteAbilities", (address) => { + this.store.dispatch(deleteAbilitiesForAccount(address)) + }) + this.abilitiesService.emitter.on("updatedAbility", (ability) => { + this.store.dispatch(updateAbility(ability)) + }) + this.abilitiesService.emitter.on("newAccount", (address) => { + if (isEnabled(FeatureFlags.SUPPORT_ABILITIES)) { + this.store.dispatch(addAccountFilter(address)) + } + }) + this.abilitiesService.emitter.on("deleteAccount", (address) => { + this.store.dispatch(deleteAccountFilter(address)) + }) } async getActivityDetails(txHash: string): Promise { @@ -1583,6 +1629,10 @@ export default class Main extends BaseService { } } + async pollForAbilities(address: NormalizedEVMAddress): Promise { + return this.abilitiesService.pollForAbilities(address) + } + async markAbilityAsCompleted( address: NormalizedEVMAddress, abilityId: string @@ -1597,6 +1647,20 @@ export default class Main extends BaseService { return this.abilitiesService.markAbilityAsRemoved(address, abilityId) } + async reportAndRemoveAbility( + address: NormalizedEVMAddress, + abilitySlug: string, + abilityId: string, + reason: string + ): Promise { + this.abilitiesService.reportAndRemoveAbility( + address, + abilitySlug, + abilityId, + reason + ) + } + private connectPopupMonitor() { runtime.onConnect.addListener((port) => { if (port.name !== popupMonitorPortName) return diff --git a/background/networks.ts b/background/networks.ts index 1f800abe40..41adfbd24a 100644 --- a/background/networks.ts +++ b/background/networks.ts @@ -35,6 +35,7 @@ export type Network = { family: NetworkFamily chainID?: string coingeckoPlatformID?: string + derivationPath?: string } /** diff --git a/background/nfts.ts b/background/nfts.ts index a9989e9a02..93475928d1 100644 --- a/background/nfts.ts +++ b/background/nfts.ts @@ -51,10 +51,16 @@ export type NFT = { previewURL?: string transferDate?: string attributes: { trait: string; value: string }[] + rarity: { + rank?: number + score?: number + uniqueAttributes?: number + } contract: string owner: string network: EVMNetwork - isBadge: boolean + supply?: number // only for POAPs + isBadge: boolean // POAPs, Galxe NFTs and OATs } export type NFTCollection = { @@ -81,6 +87,7 @@ export type TransferredNFT = { chainID: string from: string | null to: string | null - type: "sell" | "buy" + isKnownFromAddress: boolean + isKnownToAddress: boolean collectionID: string | null } diff --git a/background/package.json b/background/package.json index 16f4512e89..ffda078506 100644 --- a/background/package.json +++ b/background/package.json @@ -44,8 +44,8 @@ "@tallyho/window-provider": "0.0.1", "@types/w3c-web-usb": "^1.0.5", "@uniswap/token-lists": "^1.0.0-beta.30", + "@walletconnect/client": "^1.8.0", "@walletconnect/sign-client": "^2.1.4", - "@walletconnect/types": "^2.1.4", "@walletconnect/utils": "^2.1.4", "ajv": "^8.6.2", "ajv-formats": "^2.1.0", @@ -68,6 +68,8 @@ "@types/sinon": "^10.0.12", "@types/uuid": "^8.3.4", "@types/webextension-polyfill": "^0.8.0", + "@walletconnect/legacy-types": "^2.0.0-rc.0", + "@walletconnect/types": "^2.1.4", "crypto-browserify": "^3.12.0", "fake-indexeddb": "^4.0.0", "jest-webextension-mock": "^3.7.22", diff --git a/background/redux-slices/abilities.ts b/background/redux-slices/abilities.ts index c21e4172c9..175c5732e1 100644 --- a/background/redux-slices/abilities.ts +++ b/background/redux-slices/abilities.ts @@ -1,11 +1,36 @@ import { createSlice } from "@reduxjs/toolkit" -import { Ability } from "../services/abilities" +import { Ability, ABILITY_TYPES_ENABLED } from "../abilities" import { HexString, NormalizedEVMAddress } from "../types" -import { setSnackbarMessage } from "./ui" +import { KeyringsState } from "./keyrings" +import { LedgerState } from "./ledger" import { createBackgroundAsyncThunk } from "./utils" +const isLedgerAccount = ( + ledger: LedgerState, + address: NormalizedEVMAddress +): boolean => + Object.values(ledger.devices) + .flatMap((device) => + Object.values(device.accounts).flatMap((account) => account.address ?? "") + ) + .includes(address) + +const isImportOrInternalAccount = ( + keyrings: KeyringsState, + address: NormalizedEVMAddress +): boolean => + keyrings.keyrings.flatMap(({ addresses }) => addresses).includes(address) + +export type State = "open" | "completed" | "expired" | "deleted" | "all" + +export type Filter = { + state: State + types: string[] + accounts: string[] +} + type AbilitiesState = { - filter: "all" | "completed" | "incomplete" + filter: Filter abilities: { [address: HexString]: { [uuid: string]: Ability @@ -15,7 +40,11 @@ type AbilitiesState = { } const initialState: AbilitiesState = { - filter: "incomplete", + filter: { + state: "open", + types: [...ABILITY_TYPES_ENABLED], + accounts: [], + }, abilities: {}, hideDescription: false, } @@ -33,37 +62,59 @@ const abilitiesSlice = createSlice({ immerState.abilities[address][ability.abilityId] = ability }) }, - deleteAbility: ( - immerState, - { payload }: { payload: { address: HexString; abilityId: string } } - ) => { - delete immerState.abilities[payload.address]?.[payload.abilityId] + updateAbility: (immerState, { payload }: { payload: Ability }) => { + immerState.abilities[payload.address][payload.abilityId] = payload }, - markAbilityAsCompleted: ( + deleteAbilitiesForAccount: ( immerState, - { payload }: { payload: { address: HexString; abilityId: string } } + { payload: address }: { payload: HexString } ) => { - immerState.abilities[payload.address][payload.abilityId].completed = true + delete immerState.abilities[address] }, - markAbilityAsRemoved: ( + deleteAbility: ( immerState, { payload }: { payload: { address: HexString; abilityId: string } } ) => { - immerState.abilities[payload.address][payload.abilityId].removedFromUi = - true + delete immerState.abilities[payload.address]?.[payload.abilityId] }, toggleHideDescription: (immerState, { payload }: { payload: boolean }) => { immerState.hideDescription = payload }, + updateState: (immerState, { payload: state }: { payload: State }) => { + immerState.filter.state = state + }, + addType: (immerState, { payload: type }: { payload: string }) => { + immerState.filter.types.push(type) + }, + deleteType: (immerState, { payload: type }: { payload: string }) => { + immerState.filter.types = immerState.filter.types.filter( + (value) => value !== type + ) + }, + addAccount: (immerState, { payload: account }: { payload: string }) => { + if (!immerState.filter.accounts.includes(account)) { + immerState.filter.accounts.push(account) + } + }, + deleteAccount: (immerState, { payload: account }: { payload: string }) => { + immerState.filter.accounts = immerState.filter.accounts.filter( + (value) => value !== account + ) + }, }, }) export const { addAbilities, + updateAbility, + deleteAbilitiesForAccount, deleteAbility, - markAbilityAsCompleted, - markAbilityAsRemoved, toggleHideDescription, + updateState, + addType, + deleteType, + addAccount, + deleteAccount, } = abilitiesSlice.actions export const completeAbility = createBackgroundAsyncThunk( @@ -73,11 +124,9 @@ export const completeAbility = createBackgroundAsyncThunk( address, abilityId, }: { address: NormalizedEVMAddress; abilityId: string }, - { dispatch, extra: { main } } + { extra: { main } } ) => { await main.markAbilityAsCompleted(address, abilityId) - dispatch(markAbilityAsCompleted({ address, abilityId })) - dispatch(setSnackbarMessage("Marked as completed")) } ) @@ -88,11 +137,49 @@ export const removeAbility = createBackgroundAsyncThunk( address, abilityId, }: { address: NormalizedEVMAddress; abilityId: string }, - { dispatch, extra: { main } } + { extra: { main } } ) => { await main.markAbilityAsRemoved(address, abilityId) - dispatch(markAbilityAsRemoved({ address, abilityId })) - dispatch(setSnackbarMessage("Ability deleted")) + } +) + +export const reportAndRemoveAbility = createBackgroundAsyncThunk( + "abilities/reportAndRemoveAbility", + async ( + { + address, + abilitySlug, + abilityId, + reason, + }: { + address: NormalizedEVMAddress + abilitySlug: string + abilityId: string + reason: string + }, + { extra: { main } } + ) => { + await main.reportAndRemoveAbility(address, abilitySlug, abilityId, reason) + } +) + +export const initAbilities = createBackgroundAsyncThunk( + "abilities/initAbilities", + async ( + address: NormalizedEVMAddress, + { dispatch, getState, extra: { main } } + ) => { + const { ledger, keyrings } = getState() as { + ledger: LedgerState + keyrings: KeyringsState + } + if ( + isImportOrInternalAccount(keyrings, address) || + isLedgerAccount(ledger, address) + ) { + await main.pollForAbilities(address) + dispatch(addAccount(address)) + } } ) diff --git a/background/redux-slices/accounts.ts b/background/redux-slices/accounts.ts index e3a7935844..7c2664ee54 100644 --- a/background/redux-slices/accounts.ts +++ b/background/redux-slices/accounts.ts @@ -29,7 +29,7 @@ export const enum AccountType { Internal = "internal", } -const availableDefaultNames = [ +export const DEFAULT_ACCOUNT_NAMES = [ "Phoenix", "Matilda", "Sirius", @@ -40,7 +40,9 @@ const availableDefaultNames = [ "Foz", ] -type AccountData = { +const availableDefaultNames = [...DEFAULT_ACCOUNT_NAMES] + +export type AccountData = { address: HexString network: Network balances: { diff --git a/background/redux-slices/assets.ts b/background/redux-slices/assets.ts index 98e1928858..9e6c5f6e81 100644 --- a/background/redux-slices/assets.ts +++ b/background/redux-slices/assets.ts @@ -13,7 +13,10 @@ import { AddressOnNetwork } from "../accounts" import { findClosestAssetIndex } from "../lib/asset-similarity" import { normalizeEVMAddress } from "../lib/utils" import { createBackgroundAsyncThunk } from "./utils" -import { isBuiltInNetworkBaseAsset } from "./utils/asset-utils" +import { + isBuiltInNetworkBaseAsset, + sameBuiltInNetworkBaseAsset, +} from "./utils/asset-utils" import { getProvider } from "./utils/contract-utils" import { sameNetwork } from "../networks" import { ERC20_INTERFACE } from "../lib/erc20" @@ -55,33 +58,31 @@ const assetsSlice = createSlice({ mappedAssets[asset.symbol].push(asset) }) // merge in new assets - newAssets.forEach((asset) => { - if (mappedAssets[asset.symbol] === undefined) { - mappedAssets[asset.symbol] = [{ ...asset, recentPrices: {} }] + newAssets.forEach((newAsset) => { + if (mappedAssets[newAsset.symbol] === undefined) { + mappedAssets[newAsset.symbol] = [{ ...newAsset, recentPrices: {} }] } else { - const duplicates = mappedAssets[asset.symbol].filter( - (a) => - ("homeNetwork" in asset && - "contractAddress" in asset && - "homeNetwork" in a && - "contractAddress" in a && - a.homeNetwork.name === asset.homeNetwork.name && - normalizeEVMAddress(a.contractAddress) === - normalizeEVMAddress(asset.contractAddress)) || + const duplicates = mappedAssets[newAsset.symbol].filter( + (existingAsset) => + ("homeNetwork" in newAsset && + "contractAddress" in newAsset && + "homeNetwork" in existingAsset && + "contractAddress" in existingAsset && + existingAsset.homeNetwork.name === newAsset.homeNetwork.name && + normalizeEVMAddress(existingAsset.contractAddress) === + normalizeEVMAddress(newAsset.contractAddress)) || // Only match base assets by name - since there may be // many assets that share a name and symbol across L2's - (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) + BUILT_IN_NETWORK_BASE_ASSETS.some( + (baseAsset) => + sameBuiltInNetworkBaseAsset(baseAsset, newAsset) && + sameBuiltInNetworkBaseAsset(baseAsset, existingAsset) + ) ) // if there aren't duplicates, add the asset if (duplicates.length === 0) { - mappedAssets[asset.symbol].push({ - ...asset, + mappedAssets[newAsset.symbol].push({ + ...newAsset, recentPrices: {}, }) } @@ -223,6 +224,14 @@ export const selectAssetPricePoint = createSelector( asset.homeNetwork.chainID === assetToFind.homeNetwork.chainID && hasRecentPriceData(asset) ) + + /* Don't do anything else if this is an untrusted asset and there's no exact match */ + if ( + (assetToFind.metadata?.tokenLists.length ?? 0) < 1 && + !isBuiltInNetworkBaseAsset(assetToFind, assetToFind.homeNetwork) + ) { + return undefined + } } /* Otherwise, find a best-effort match by looking for assets with the same symbol */ diff --git a/background/redux-slices/keyrings.ts b/background/redux-slices/keyrings.ts index 61e8ca8108..798af7febf 100644 --- a/background/redux-slices/keyrings.ts +++ b/background/redux-slices/keyrings.ts @@ -10,7 +10,7 @@ type KeyringToVerify = { mnemonic: string[] } | null -type KeyringsState = { +export type KeyringsState = { keyrings: Keyring[] keyringMetadata: { [keyringId: string]: KeyringMetadata @@ -32,7 +32,7 @@ export type Events = { createPassword: string unlockKeyrings: string lockKeyrings: never - generateNewKeyring: never + generateNewKeyring: string | undefined deriveAddress: string importKeyring: ImportKeyring } @@ -134,8 +134,8 @@ export default keyringsSlice.reducer // Async thunk to bubble the generateNewKeyring action from store to emitter. export const generateNewKeyring = createBackgroundAsyncThunk( "keyrings/generateNewKeyring", - async () => { - await emitter.emit("generateNewKeyring") + async (path?: string) => { + await emitter.emit("generateNewKeyring", path) } ) diff --git a/background/redux-slices/migrations/index.ts b/background/redux-slices/migrations/index.ts index d39e50e139..57f082cf81 100644 --- a/background/redux-slices/migrations/index.ts +++ b/background/redux-slices/migrations/index.ts @@ -20,12 +20,13 @@ import to20 from "./to-20" import to21 from "./to-21" import to22 from "./to-22" import to23 from "./to-23" +import to24 from "./to-24" /** * The version of persisted Redux state the extension is expecting. Any previous * state without this version, or with a lower version, ought to be migrated. */ -export const REDUX_STATE_VERSION = 23 +export const REDUX_STATE_VERSION = 25 /** * Common type for all migration functions. @@ -58,6 +59,7 @@ const allMigrations: { [targetVersion: string]: Migration } = { 21: to21, 22: to22, 23: to23, + 24: to24, } /** diff --git a/background/redux-slices/migrations/to-24.ts b/background/redux-slices/migrations/to-24.ts new file mode 100644 index 0000000000..efe0ad8b99 --- /dev/null +++ b/background/redux-slices/migrations/to-24.ts @@ -0,0 +1,25 @@ +type OldState = { + networks: { + evm: unknown + } +} + +type NewState = { + networks: { + blockInfo: unknown + evmNetworks: Record + } +} + +export default ( + prevState: Record +): Record => { + const { networks, ...newState } = prevState as OldState + + ;(newState as NewState).networks = { + blockInfo: networks.evm, + evmNetworks: {}, + } + + return newState as NewState +} diff --git a/background/redux-slices/networks.ts b/background/redux-slices/networks.ts index d0b4b3e3e7..13ae402172 100644 --- a/background/redux-slices/networks.ts +++ b/background/redux-slices/networks.ts @@ -1,5 +1,5 @@ import { createSlice } from "@reduxjs/toolkit" -import { EIP1559Block, AnyEVMBlock } from "../networks" +import { EIP1559Block, AnyEVMBlock, EVMNetwork } from "../networks" type NetworkState = { blockHeight: number | null @@ -7,13 +7,17 @@ type NetworkState = { } export type NetworksState = { - evm: { + evmNetworks: { + [chainID: string]: EVMNetwork + } + blockInfo: { [chainID: string]: NetworkState } } export const initialState: NetworksState = { - evm: { + evmNetworks: {}, + blockInfo: { "1": { blockHeight: null, baseFeePerGas: null, @@ -31,23 +35,29 @@ const networksSlice = createSlice({ ) => { const block = blockPayload as EIP1559Block - if (!(block.network.chainID in immerState.evm)) { - immerState.evm[block.network.chainID] = { + if (!(block.network.chainID in immerState.blockInfo)) { + immerState.blockInfo[block.network.chainID] = { blockHeight: block.blockHeight, baseFeePerGas: block?.baseFeePerGas ?? null, } } else if ( block.blockHeight > - (immerState.evm[block.network.chainID].blockHeight || 0) + (immerState.blockInfo[block.network.chainID].blockHeight || 0) ) { - immerState.evm[block.network.chainID].blockHeight = block.blockHeight - immerState.evm[block.network.chainID].baseFeePerGas = + immerState.blockInfo[block.network.chainID].blockHeight = + block.blockHeight + immerState.blockInfo[block.network.chainID].baseFeePerGas = block?.baseFeePerGas ?? null } }, + setEVMNetworks: (immerState, { payload }: { payload: EVMNetwork[] }) => { + payload.forEach((network) => { + immerState.evmNetworks[network.chainID] = network + }) + }, }, }) -export const { blockSeen } = networksSlice.actions +export const { blockSeen, setEVMNetworks } = networksSlice.actions export default networksSlice.reducer diff --git a/background/redux-slices/nfts_update.ts b/background/redux-slices/nfts_update.ts index 1c1981eb5f..7fa6dfe490 100644 --- a/background/redux-slices/nfts_update.ts +++ b/background/redux-slices/nfts_update.ts @@ -6,14 +6,20 @@ import { normalizeEVMAddress } from "../lib/utils" import { NFT, NFTCollection, TransferredNFT } from "../nfts" import { createBackgroundAsyncThunk } from "./utils" +export type NFTCached = { + chainID: string + rarityRank: number | null +} & Omit + export type NFTCollectionCached = { floorPrice?: { value: number tokenSymbol: string } - nfts: NFT[] + nfts: NFTCached[] hasNextPage: boolean -} & Omit + chainID: string +} & Omit export type NFTsState = { [chainID: string]: { @@ -25,7 +31,7 @@ export type NFTsState = { export type NFTWithCollection = { collection: NFTCollectionCached - nft: NFT + nft: NFTCached } export type Filter = { @@ -53,13 +59,14 @@ export type NFTsSliceState = { export type Events = { fetchNFTs: { collectionID: string; account: AddressOnNetwork } + refetchNFTs: { collectionID: string; account: AddressOnNetwork } fetchMoreNFTs: { collectionID: string; account: AddressOnNetwork } refetchCollections: never } export const emitter = new Emittery() -function updateCollection( +export function updateCollection( acc: NFTsSliceState, collection: NFTCollection ): void { @@ -87,7 +94,7 @@ function updateCollection( totalNftCount, nfts: savedCollection.nfts ?? [], hasBadges: savedCollection.hasBadges || hasBadges, // once we know it has badges it should stay like that - network, + chainID, owner: ownerAddress, thumbnailURL, hasNextPage: false, @@ -101,7 +108,7 @@ function updateCollection( } } -function updateFilter( +export function updateFilter( acc: NFTsSliceState, collection: NFTCollection, type: "accounts" | "collections" @@ -140,7 +147,10 @@ function updateFilter( } } -function updateFilters(acc: NFTsSliceState, collection: NFTCollection): void { +export function updateFilters( + acc: NFTsSliceState, + collection: NFTCollection +): void { const { nftCount } = collection if ((nftCount ?? 0) > 0) { updateFilter(acc, collection, "collections") @@ -148,7 +158,22 @@ function updateFilters(acc: NFTsSliceState, collection: NFTCollection): void { updateFilter(acc, collection, "accounts") } -function removeAccountFromFilters(acc: NFTsSliceState, address: string): void { +export function parseNFTs(nfts: NFT[]): NFTCached[] { + return nfts.map((nft) => { + const { network, rarity, ...cached } = nft + + return { + ...cached, + chainID: network.chainID, + rarityRank: rarity.rank ?? null, + } + }) +} + +export 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)) { @@ -164,7 +189,9 @@ function removeAccountFromFilters(acc: NFTsSliceState, address: string): void { }) } -function initializeCollections(collections: NFTCollection[]): NFTsSliceState { +export function initializeCollections( + collections: NFTCollection[] +): NFTsSliceState { const state: NFTsSliceState = { isReloading: false, nfts: {}, @@ -227,7 +254,7 @@ const NFTsSlice = createSlice({ collectionID ] - collectionToUpdate.nfts = nfts + collectionToUpdate.nfts = parseNFTs(nfts) collectionToUpdate.hasNextPage = hasNextPage }, updateIsReloading: ( @@ -352,6 +379,13 @@ export const fetchNFTsFromCollection = createBackgroundAsyncThunk( } ) +export const refetchNFTsFromCollection = createBackgroundAsyncThunk( + "nfts/refetchNFTsFromCollection", + async (payload: { collectionID: string; account: AddressOnNetwork }) => { + await emitter.emit("refetchNFTs", payload) + } +) + export const fetchMoreNFTsFromCollection = createBackgroundAsyncThunk( "nfts/fetchMoreNFTsFromCollection", async (payload: { collectionID: string; account: AddressOnNetwork }) => { diff --git a/background/redux-slices/selectors/abilities.ts b/background/redux-slices/selectors/abilities.ts deleted file mode 100644 index bc52ac79f9..0000000000 --- a/background/redux-slices/selectors/abilities.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { createSelector } from "@reduxjs/toolkit" -import { RootState } from ".." -import { Ability } from "../../services/abilities" - -const selectAbilities = (state: RootState) => state.abilities.abilities - -const selectAbilityFilter = (state: RootState) => state.abilities.filter - -export const selectFilteredAbilities = createSelector( - selectAbilityFilter, - selectAbilities, - (filter, abilities) => { - const activeAbilities: Ability[] = [] - Object.values(abilities).forEach((addressAbilities) => { - activeAbilities.push( - ...Object.values(addressAbilities).filter((ability) => { - if (ability.removedFromUi === true) { - return false - } - if (filter === "incomplete") { - return ability.completed === false - } - if (filter === "completed") { - return ability.completed === true - } - return true - }) - ) - }) - return activeAbilities - } -) - -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/abilitiesSelectors.ts b/background/redux-slices/selectors/abilitiesSelectors.ts new file mode 100644 index 0000000000..144d31cdcd --- /dev/null +++ b/background/redux-slices/selectors/abilitiesSelectors.ts @@ -0,0 +1,63 @@ +import { createSelector } from "@reduxjs/toolkit" +import { RootState } from ".." +import { Ability } from "../../abilities" +import { filterAbility } from "../utils/abilities-utils" + +const selectAbilities = createSelector( + (state: RootState) => state.abilities, + (abilitiesSlice) => abilitiesSlice.abilities +) + +export const selectDescriptionHidden = createSelector( + (state: RootState) => state.abilities.hideDescription, + (hideDescription) => hideDescription +) + +/* Filtering selectors */ +const selectAbilityFilter = createSelector( + (state: RootState) => state.abilities, + (abilitiesSlice) => abilitiesSlice.filter +) + +export const selectAbilityFilterState = createSelector( + (state: RootState) => state.abilities, + (abilitiesSlice) => abilitiesSlice.filter.state +) + +export const selectAbilityFilterTypes = createSelector( + (state: RootState) => state.abilities, + (abilitiesSlice) => abilitiesSlice.filter.types +) + +export const selectAbilityFilterAccounts = createSelector( + (state: RootState) => state.abilities, + (abilitiesSlice) => abilitiesSlice.filter.accounts +) + +/* Items selectors */ +export const selectFilteredAbilities = createSelector( + selectAbilityFilter, + selectAbilities, + (filter, abilities) => { + const activeAbilities: Ability[] = [] + Object.values(abilities).forEach((addressAbilities) => { + activeAbilities.push( + ...Object.values(addressAbilities).filter((ability) => + filterAbility(ability, filter) + ) + ) + }) + return activeAbilities + } +) + +/* Counting selectors */ +export const selectOpenAbilityCount = createSelector( + selectAbilityFilter, + selectAbilities, + (filter, abilities) => + Object.values(abilities) + .flatMap((address) => Object.values(address)) + .filter((ability) => filterAbility(ability, { ...filter, state: "open" })) + .length +) diff --git a/background/redux-slices/selectors/accountsSelectors.ts b/background/redux-slices/selectors/accountsSelectors.ts index beb246f04a..316c6e81cd 100644 --- a/background/redux-slices/selectors/accountsSelectors.ts +++ b/background/redux-slices/selectors/accountsSelectors.ts @@ -1,7 +1,11 @@ import { createSelector } from "@reduxjs/toolkit" import { selectHideDust } from "../ui" import { RootState } from ".." -import { AccountType, CompleteAssetAmount } from "../accounts" +import { + AccountType, + DEFAULT_ACCOUNT_NAMES, + CompleteAssetAmount, +} from "../accounts" import { AssetsState, selectAssetPricePoint } from "../assets" import { enrichAssetAmountWithDecimalValues, @@ -84,10 +88,6 @@ const computeCombinedAssetAmountsData = ( combinedAssetAmounts: CompleteAssetAmount[] totalMainCurrencyAmount: number | undefined } => { - // Keep a tally of the total user value; undefined if no main currency data - // is available. - let totalMainCurrencyAmount: number | undefined - // Derive account "assets"/assetAmount which include USD values using // data from the assets slice const combinedAssetAmounts = assetAmounts @@ -117,11 +117,6 @@ const computeCombinedAssetAmountsData = ( ) ) - if (typeof fullyEnrichedAssetAmount.mainCurrencyAmount !== "undefined") { - totalMainCurrencyAmount ??= 0 // initialize if needed - totalMainCurrencyAmount += fullyEnrichedAssetAmount.mainCurrencyAmount - } - return fullyEnrichedAssetAmount }) .filter((assetAmount) => { @@ -141,10 +136,12 @@ const computeCombinedAssetAmountsData = ( ? true : assetAmount.mainCurrencyAmount > userValueDustThreshold const isPresent = assetAmount.decimalAmount > 0 + const isTrusted = !!(assetAmount.asset?.metadata?.tokenLists.length ?? 0) - // Hide dust and missing amounts. + // Hide dust, untrusted assets and missing amounts. return ( - isForciblyDisplayed || (hideDust ? isNotDust && isPresent : isPresent) + isForciblyDisplayed || + (hideDust ? isTrusted && isNotDust && isPresent : isPresent) ) }) .sort((asset1, asset2) => { @@ -200,6 +197,16 @@ const computeCombinedAssetAmountsData = ( return asset1.mainCurrencyAmount === undefined ? 1 : -1 }) + // Keep a tally of the total user value; undefined if no main currency data + // is available. + let totalMainCurrencyAmount: number | undefined + combinedAssetAmounts.forEach((assetAmount) => { + if (typeof assetAmount.mainCurrencyAmount !== "undefined") { + totalMainCurrencyAmount ??= 0 // initialize if needed + totalMainCurrencyAmount += assetAmount.mainCurrencyAmount + } + }) + return { combinedAssetAmounts, totalMainCurrencyAmount } } @@ -288,6 +295,7 @@ export type AccountTotal = AddressOnNetwork & { // FIXME Add `categoryFor(accountSigner): string` utility function to // FIXME generalize beyond keyrings. keyringId: string | null + path: string | null accountSigner: AccountSigner name?: string avatarURL?: string @@ -373,6 +381,7 @@ function getNetworkAccountTotalsByCategory( const accountSigner = accountSignersByAddress[address] const keyringId = keyringsByAddresses[address]?.id + const path = keyringsByAddresses[address]?.path const accountType = getAccountType( address, @@ -387,6 +396,7 @@ function getNetworkAccountTotalsByCategory( shortenedAddress, accountType, keyringId, + path, accountSigner, } } @@ -397,6 +407,7 @@ function getNetworkAccountTotalsByCategory( shortenedAddress, accountType, keyringId, + path, accountSigner, name: accountData.ens.name ?? accountData.defaultName, avatarURL: accountData.ens.avatarURL ?? accountData.defaultAvatar, @@ -513,6 +524,17 @@ export const getAccountTotal = ( accountAddressOnNetwork ) +export const getAccountNameOnChain = ( + state: RootState, + accountAddressOnNetwork: AddressOnNetwork +): string | undefined => { + const account = getAccountTotal(state, accountAddressOnNetwork) + + return account?.name && !DEFAULT_ACCOUNT_NAMES.includes(account.name) + ? account.name + : undefined +} + export const selectCurrentAccountTotal = createSelector( selectCurrentNetworkAccountTotalsByCategory, selectCurrentAccount, diff --git a/background/redux-slices/selectors/index.ts b/background/redux-slices/selectors/index.ts index 44251cc5c0..0185299c17 100644 --- a/background/redux-slices/selectors/index.ts +++ b/background/redux-slices/selectors/index.ts @@ -6,4 +6,4 @@ export * from "./signingSelectors" export * from "./dappSelectors" export * from "./uiSelectors" export * from "./nftsSelectors_update" -export * from "./abilities" +export * from "./abilitiesSelectors" diff --git a/background/redux-slices/selectors/networks.ts b/background/redux-slices/selectors/networks.ts new file mode 100644 index 0000000000..1bbd37ca62 --- /dev/null +++ b/background/redux-slices/selectors/networks.ts @@ -0,0 +1,21 @@ +import { createSelector } from "@reduxjs/toolkit" +import { RootState } from ".." +import { TEST_NETWORK_BY_CHAIN_ID } from "../../constants" +import { EVMNetwork } from "../../networks" + +// Adds chainID to each NFT for convenience in frontend +// eslint-disable-next-line import/prefer-default-export +export const selectEVMNetworks = createSelector( + (state: RootState) => state.networks.evmNetworks, + (evmNetworks): EVMNetwork[] => { + return Object.values(evmNetworks) + } +) + +export const selectProductionEVMNetworks = createSelector( + selectEVMNetworks, + (evmNetworks) => + evmNetworks.filter( + (network) => !TEST_NETWORK_BY_CHAIN_ID.has(network.chainID) + ) +) diff --git a/background/redux-slices/selectors/transactionConstructionSelectors.ts b/background/redux-slices/selectors/transactionConstructionSelectors.ts index 97d1a33dd3..5bdde53933 100644 --- a/background/redux-slices/selectors/transactionConstructionSelectors.ts +++ b/background/redux-slices/selectors/transactionConstructionSelectors.ts @@ -49,7 +49,8 @@ export const selectDefaultNetworkFeeSettings = createSelector( )?.gasPrice : selectedFeesPerGas?.price ?? 0n, baseFeePerGas: - networks.evm[currentNetwork.chainID]?.baseFeePerGas ?? undefined, + networks.blockInfo[currentNetwork.chainID]?.baseFeePerGas ?? + undefined, }, } } diff --git a/background/redux-slices/tests/assets-utils.unit.test.ts b/background/redux-slices/tests/assets-utils.unit.test.ts new file mode 100644 index 0000000000..f6f323b51f --- /dev/null +++ b/background/redux-slices/tests/assets-utils.unit.test.ts @@ -0,0 +1,71 @@ +import { MATIC, OPTIMISTIC_ETH, AVAX, ETH, BNB } from "../../constants" +import { + enrichAssetAmountWithMainCurrencyValues, + formatCurrencyAmount, + getBuiltInNetworkBaseAsset, + sameBuiltInNetworkBaseAsset, +} from "../utils/asset-utils" +import { NetworkBaseAsset } from "../../networks" +import { createAssetAmount, createPricePoint } from "../../tests/factories" + +describe(sameBuiltInNetworkBaseAsset, () => { + test("should handle built in network base assets", () => { + expect(sameBuiltInNetworkBaseAsset(MATIC, MATIC)).toBe(true) + + expect(sameBuiltInNetworkBaseAsset(OPTIMISTIC_ETH, OPTIMISTIC_ETH)).toBe( + true + ) + }) + + test("should handle other network base assets", () => { + const baseAsset: NetworkBaseAsset = { + chainID: "111", + name: "Tally", + symbol: "TULLY", + decimals: 18, + } + + expect(sameBuiltInNetworkBaseAsset(MATIC, baseAsset)).toBe(false) + expect( + sameBuiltInNetworkBaseAsset(AVAX, { + chainID: "43114", + name: "Avalanche", + symbol: "AVAX", + }) + ).toBe(true) + }) +}) + +describe(getBuiltInNetworkBaseAsset, () => { + test("should return base asset data for builtin networks", () => { + expect(getBuiltInNetworkBaseAsset("ETH", "1")).toBe(ETH) + expect(getBuiltInNetworkBaseAsset("BNB", "56")).toBe(BNB) + }) +}) + +describe(formatCurrencyAmount, () => { + test("should return the localized currency amount without the symbol", () => { + expect(formatCurrencyAmount("USD", 100, 2)).toBe("100.00") + }) +}) + +describe(enrichAssetAmountWithMainCurrencyValues, () => { + test("should add localized price and currency data to an asset amount ", () => { + const assetAmount = createAssetAmount() + const pricePoint = createPricePoint(assetAmount.asset, 1637.7) + + const result = enrichAssetAmountWithMainCurrencyValues( + assetAmount, + pricePoint, + 2 + ) + + expect(result).toMatchObject({ + ...assetAmount, + localizedMainCurrencyAmount: "1,637.70", + localizedUnitPrice: "1,637.70", + mainCurrencyAmount: 1637.7, + unitPrice: 1637.7, + }) + }) +}) diff --git a/background/redux-slices/assets.unit.test.ts b/background/redux-slices/tests/assets.unit.test.ts similarity index 77% rename from background/redux-slices/assets.unit.test.ts rename to background/redux-slices/tests/assets.unit.test.ts index 4e7ab4eaec..f231636319 100644 --- a/background/redux-slices/assets.unit.test.ts +++ b/background/redux-slices/tests/assets.unit.test.ts @@ -1,7 +1,14 @@ -import { PricePoint, SmartContractFungibleAsset } from "../assets" -import { POLYGON } from "../constants" -import { createPricePoint, createSmartContractAsset } from "../tests/factories" -import { AssetsState, selectAssetPricePoint, SingleAssetState } from "./assets" +import { + PricePoint, + SmartContractFungibleAsset, + unitPricePointForPricePoint, +} from "../../assets" +import { ETH, POLYGON } from "../../constants" +import { + createPricePoint, + createSmartContractAsset, +} from "../../tests/factories" +import { AssetsState, selectAssetPricePoint, SingleAssetState } from "../assets" const asset: SmartContractFungibleAsset = createSmartContractAsset() @@ -83,3 +90,18 @@ describe("Assets selectors", () => { }) }) }) + +describe(unitPricePointForPricePoint, () => { + // An asset amount of the second asset using the pair's price point data + test("should return the unit price of an asset using a price point", () => { + const result = unitPricePointForPricePoint(createPricePoint(ETH, 1500)) + + expect(result).toMatchObject({ + unitPrice: { + asset: { name: "United States Dollar", symbol: "USD", decimals: 10 }, + amount: 15000000000000n, + }, + time: expect.any(Number), + }) + }) +}) diff --git a/background/redux-slices/tests/nfts_update.unit.test.ts b/background/redux-slices/tests/nfts_update.unit.test.ts new file mode 100644 index 0000000000..76cb99d4d4 --- /dev/null +++ b/background/redux-slices/tests/nfts_update.unit.test.ts @@ -0,0 +1,77 @@ +import { ETHEREUM } from "../../constants" +import { NFTsSliceState, updateCollection } from "../nfts_update" + +const OWNER_MOCK = "0x1234" +const COLLECTION_MOCK = { + id: "1", + name: "test", + owner: OWNER_MOCK, + network: ETHEREUM, + hasBadges: false, + nftCount: 1, + totalNftCount: 10, + floorPrice: { + value: 1000000000000000000n, + token: { + name: "Ether", + symbol: "ETH", + decimals: 18, + }, + }, +} + +describe("NFTs redux slice", () => { + describe("updateCollection util", () => { + let state: NFTsSliceState + + beforeEach(() => { + state = { + isReloading: false, + nfts: {}, + filters: { collections: [], accounts: [], type: "desc" }, + } + }) + + it("should add a single collection", () => { + updateCollection(state, COLLECTION_MOCK) + + expect( + state.nfts[ETHEREUM.chainID][OWNER_MOCK][COLLECTION_MOCK.id] + ).toMatchObject({ + id: COLLECTION_MOCK.id, + name: COLLECTION_MOCK.name, + nftCount: 1, + totalNftCount: 10, + nfts: [], + hasBadges: false, + chainID: ETHEREUM.chainID, + owner: OWNER_MOCK, + thumbnailURL: undefined, + hasNextPage: false, + floorPrice: { value: 1, tokenSymbol: "ETH" }, + }) + }) + + it("should update collection", () => { + updateCollection(state, COLLECTION_MOCK) + updateCollection(state, { + ...COLLECTION_MOCK, + name: "new", + floorPrice: { + value: 2000000000000000000n, + token: { + name: "Ether", + symbol: "ETH", + decimals: 18, + }, + }, + }) + + const updated = + state.nfts[ETHEREUM.chainID][OWNER_MOCK][COLLECTION_MOCK.id] + + expect(updated.name).toBe("new") + expect(updated.floorPrice?.value).toEqual(2) + }) + }) +}) diff --git a/background/redux-slices/tests/transaction-construction.unit.test.ts b/background/redux-slices/tests/transaction-construction.unit.test.ts new file mode 100644 index 0000000000..8f987ebfd9 --- /dev/null +++ b/background/redux-slices/tests/transaction-construction.unit.test.ts @@ -0,0 +1,21 @@ +import reducer, { + clearCustomGas, + initialState, + NetworkFeeTypeChosen, +} from "../transaction-construction" + +describe("Transaction Construction Redux Slice", () => { + describe("Actions", () => { + describe("clearCustomGas", () => { + it("Should reset selected fee type to Regular", () => { + const mockState = { + ...initialState, + feeTypeSeected: NetworkFeeTypeChosen.Custom, + } + + const newState = reducer(mockState, clearCustomGas()) + expect(newState.feeTypeSelected).toBe(NetworkFeeTypeChosen.Regular) + }) + }) + }) +}) diff --git a/background/redux-slices/transaction-construction.ts b/background/redux-slices/transaction-construction.ts index 7b4dda8458..413206d4eb 100644 --- a/background/redux-slices/transaction-construction.ts +++ b/background/redux-slices/transaction-construction.ts @@ -328,6 +328,7 @@ const transactionSlice = createSlice({ }, clearCustomGas: (immerState) => { immerState.customFeesPerGas = defaultCustomGas + immerState.feeTypeSelected = NetworkFeeTypeChosen.Regular }, setCustomGasLimit: ( immerState, diff --git a/background/redux-slices/ui.ts b/background/redux-slices/ui.ts index bfacba8b32..7ad13fc6c6 100644 --- a/background/redux-slices/ui.ts +++ b/background/redux-slices/ui.ts @@ -88,6 +88,7 @@ const uiSlice = createSlice({ settings: { ...state.settings, collectAnalytics, + showAnalyticsNotification: false, }, }), setShowAnalyticsNotification: ( diff --git a/background/redux-slices/utils/abilities-utils.ts b/background/redux-slices/utils/abilities-utils.ts new file mode 100644 index 0000000000..aad900c533 --- /dev/null +++ b/background/redux-slices/utils/abilities-utils.ts @@ -0,0 +1,47 @@ +import { Ability, AbilityType } from "../../abilities" +import { Filter, State } from "../abilities" + +const isDeleted = (ability: Ability): boolean => ability.removedFromUi + +const isExpired = (ability: Ability): boolean => + ability.closeAt ? new Date(ability.closeAt) < new Date() : false + +export const filterByState = (ability: Ability, state: State): boolean => { + switch (state) { + case "open": + return ( + !isDeleted(ability) && + !isExpired(ability) && + ability.completed === false + ) + case "completed": + return ( + !isDeleted(ability) && !isExpired(ability) && ability.completed === true + ) + case "expired": + return !isDeleted(ability) && isExpired(ability) + case "deleted": + return isDeleted(ability) + default: + return true + } +} + +export const filterByType = (type: AbilityType, types: string[]): boolean => { + return types.includes(type) +} + +export const filterByAddress = ( + address: string, + accounts: string[] +): boolean => { + return accounts.includes(address) +} + +export const filterAbility = (ability: Ability, filter: Filter): boolean => { + return ( + filterByAddress(ability.address, filter.accounts) && + filterByState(ability, filter.state) && + filterByType(ability.type, filter.types) + ) +} diff --git a/background/redux-slices/utils/asset-utils.ts b/background/redux-slices/utils/asset-utils.ts index b9727efb70..b6eec89893 100644 --- a/background/redux-slices/utils/asset-utils.ts +++ b/background/redux-slices/utils/asset-utils.ts @@ -9,6 +9,7 @@ import { UnitPricePoint, AnyAsset, CoinGeckoAsset, + isSmartContractFungibleAsset, } from "../../assets" import { BUILT_IN_NETWORK_BASE_ASSETS, @@ -42,19 +43,31 @@ export type AssetDecimalAmount = { localizedDecimalAmount: string } -function hasCoinType(asset: AnyAsset): asset is NetworkBaseAsset { - return "coinType" in asset +function hasChainID(asset: AnyAsset): asset is NetworkBaseAsset { + return "chainID" in asset } function isOptimismBaseAsset(asset: AnyAsset) { + const hasMatchingChainID = + (isSmartContractFungibleAsset(asset) && + asset.homeNetwork.chainID === OPTIMISM.chainID) || + (hasChainID(asset) && asset.chainID === OPTIMISM.chainID) + return ( + hasMatchingChainID && "contractAddress" in asset && asset.contractAddress === OPTIMISM.baseAsset.contractAddress ) } function isPolygonBaseAsset(asset: AnyAsset) { + const hasMatchingChainID = + (isSmartContractFungibleAsset(asset) && + asset.homeNetwork.chainID === POLYGON.chainID) || + (hasChainID(asset) && asset.chainID === POLYGON.chainID) + return ( + hasMatchingChainID && "contractAddress" in asset && asset.contractAddress === POLYGON.baseAsset.contractAddress ) @@ -83,9 +96,9 @@ export function isBuiltInNetworkBaseAsset( } return ( - hasCoinType(asset) && + hasChainID(asset) && asset.symbol === network.baseAsset.symbol && - asset.coinType === network.baseAsset.coinType && + asset.chainID === network.baseAsset.chainID && asset.name === network.baseAsset.name ) } @@ -102,6 +115,37 @@ export function getBuiltInNetworkBaseAsset( ) } +/** + * @param asset1 any asset + * @param asset2 any asset + * @returns true if both assets are the same network base assets + */ +export function sameBuiltInNetworkBaseAsset( + asset1: AnyAsset, + asset2: AnyAsset +): boolean { + // for base assets with possible homeNetwork field + if (isOptimismBaseAsset(asset1) && isOptimismBaseAsset(asset2)) return true + + if (isPolygonBaseAsset(asset1) && isPolygonBaseAsset(asset2)) return true + + // for other base assets + if ( + "homeNetwork" in asset1 || + "homeNetwork" in asset2 || + !hasChainID(asset1) || + !hasChainID(asset2) + ) { + return false + } + + return ( + asset1.symbol === asset2.symbol && + asset1.chainID === asset2.chainID && + asset1.name === asset2.name + ) +} + /** * 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 index f9903c5434..016da280a0 100644 --- a/background/redux-slices/utils/nfts-utils.ts +++ b/background/redux-slices/utils/nfts-utils.ts @@ -1,9 +1,9 @@ import { BUILT_IN_NETWORK_BASE_ASSETS } from "../../constants" -import { NFT } from "../../nfts" import { AssetsState, selectAssetPricePoint } from "../assets" import { Filter, FiltersState, + NFTCached, NFTCollectionCached, SortType, } from "../nfts_update" @@ -76,7 +76,10 @@ const sortByDate = ( return transferDate1 > transferDate2 ? 1 : -1 } -const sortNFTsByDate = (type: "new" | "old", nfts: NFT[]): NFT[] => { +const sortNFTsByDate = ( + type: "new" | "old", + nfts: NFTCached[] +): NFTCached[] => { const sorted = nfts.sort( (nft1, nft2) => new Date(nft2.transferDate ?? "").getTime() - diff --git a/background/redux-slices/utils/tests/abilities-utils.unit.test.ts b/background/redux-slices/utils/tests/abilities-utils.unit.test.ts new file mode 100644 index 0000000000..a3e35655a4 --- /dev/null +++ b/background/redux-slices/utils/tests/abilities-utils.unit.test.ts @@ -0,0 +1,120 @@ +import { Ability } from "../../../abilities" +import { NormalizedEVMAddress } from "../../../types" +import { + filterByAddress, + filterByState, + filterByType, +} from "../abilities-utils" + +const ADDRESS = "0x208e94d5661a73360d9387d3ca169e5c130090cd" + +const ABILITY_DEFAULT: Ability = { + type: "mint", + title: "Test Ability", + description: null, + abilityId: "", + slug: "", + linkUrl: "", + completed: false, + removedFromUi: false, + address: ADDRESS as NormalizedEVMAddress, + requirement: { + type: "hold", + address: "", + }, +} + +describe("Abilities utils", () => { + describe("filterByState", () => { + test("should return true if an ability is open", () => { + const ability = ABILITY_DEFAULT + expect(filterByState(ability, "open")).toBeTruthy() + }) + test("should return false if an ability is open and deleted", () => { + const ability = { + ...ABILITY_DEFAULT, + removedFromUi: true, + } + expect(filterByState(ability, "open")).toBeFalsy() + }) + test("should return false if an ability is open and expired", () => { + const ability = { + ...ABILITY_DEFAULT, + closeAt: "Thu Mar 31 2022", + } + expect(filterByState(ability, "open")).toBeFalsy() + }) + test("should return true if an ability is completed", () => { + const ability = { + ...ABILITY_DEFAULT, + completed: true, + } + expect(filterByState(ability, "completed")).toBeTruthy() + }) + test("should return false if an ability is completed and deleted", () => { + const ability = { + ...ABILITY_DEFAULT, + completed: true, + removedFromUi: true, + } + expect(filterByState(ability, "completed")).toBeFalsy() + }) + test("should return false if an ability is completed and expired", () => { + const ability = { + ...ABILITY_DEFAULT, + completed: true, + closeAt: "Thu Mar 31 2022", + } + expect(filterByState(ability, "completed")).toBeFalsy() + }) + test("should return false if an ability is not deleted", () => { + const ability = ABILITY_DEFAULT + expect(filterByState(ability, "deleted")).toBeFalsy() + }) + test("should return true if an ability is deleted", () => { + const ability = { + ...ABILITY_DEFAULT, + removedFromUi: true, + } + expect(filterByState(ability, "deleted")).toBeTruthy() + }) + test("should return true if an ability is expired", () => { + const ability = { + ...ABILITY_DEFAULT, + closeAt: "Thu Mar 31 2022", + } + expect(filterByState(ability, "expired")).toBeTruthy() + }) + test("should return false if an ability is not expired", () => { + const ability = { + ...ABILITY_DEFAULT, + closeAt: Date.now().toString(), + } + expect(filterByState(ability, "expired")).toBeFalsy() + }) + test("should return false if an ability is expired and deleted", () => { + const ability = { + ...ABILITY_DEFAULT, + removedFromUi: true, + closeAt: "Thu Mar 31 2022", + } + expect(filterByState(ability, "expired")).toBeFalsy() + }) + }) + describe("filterByType", () => { + test("should return true if it is a mint type", () => { + expect(filterByType("mint", ["mint"])).toBeTruthy() + }) + test("should return false if it is an airdrop type", () => { + expect(filterByType("airdrop", [])).toBeFalsy() + }) + }) + describe("filterByAddress", () => { + test("should return true if an address for an ability is enabled", () => { + expect(filterByAddress(ADDRESS, [ADDRESS])).toBeTruthy() + }) + test("should return false if an address for an ability is disabled", () => { + expect(filterByAddress(ADDRESS, [])).toBeFalsy() + }) + }) +}) diff --git a/background/redux-slices/utils/nfts-utils.unit.test.ts b/background/redux-slices/utils/tests/nfts-utils.unit.test.ts similarity index 96% rename from background/redux-slices/utils/nfts-utils.unit.test.ts rename to background/redux-slices/utils/tests/nfts-utils.unit.test.ts index 6c039e3e2a..f3d57a76fe 100644 --- a/background/redux-slices/utils/nfts-utils.unit.test.ts +++ b/background/redux-slices/utils/tests/nfts-utils.unit.test.ts @@ -1,17 +1,17 @@ -import { AVAX, BNB, ETH, ETHEREUM, USD } from "../../constants" -import { AssetsState } from "../assets" +import { AVAX, BNB, ETH, ETHEREUM, USD } from "../../../constants" +import { AssetsState } from "../../assets" import { enrichCollectionWithUSDFloorPrice, getTotalFloorPrice, sortByPrice, -} from "./nfts-utils" -import { createPricePoint } from "../../tests/factories" +} from "../nfts-utils" +import { createPricePoint } from "../../../tests/factories" const COLLECTION_MOCK = { id: "", name: "", owner: "", - network: ETHEREUM, // doesn't matter for now + chainID: ETHEREUM.chainID, // doesn't matter for now hasBadges: false, nfts: [], // doesn't matter for now hasNextPage: false, diff --git a/background/services/abilities/db.ts b/background/services/abilities/db.ts index 0c786a642e..1f9480ce5e 100644 --- a/background/services/abilities/db.ts +++ b/background/services/abilities/db.ts @@ -1,7 +1,7 @@ import Dexie from "dexie" +import { Ability } from "../../abilities" import { FeatureFlags, isEnabled } from "../../features" -import type { Ability } from "." -import { NormalizedEVMAddress } from "../../types" +import { HexString, NormalizedEVMAddress } from "../../types" export class AbilitiesDatabase extends Dexie { private abilities!: Dexie.Table @@ -48,29 +48,39 @@ export class AbilitiesDatabase extends Dexie { async markAsCompleted( address: NormalizedEVMAddress, abilityId: string - ): Promise { + ): Promise { const ability = await this.getAbility(address, abilityId) - if (!ability) { - throw new Error("Ability does not exist") + if (ability) { + const updatedAbility = { + ...ability, + completed: true, + } + this.abilities.put(updatedAbility) + return updatedAbility } - this.abilities.put({ - ...ability, - completed: true, - }) + return undefined } async markAsRemoved( address: NormalizedEVMAddress, abilityId: string - ): Promise { + ): Promise { const ability = await this.getAbility(address, abilityId) - if (!ability) { - throw new Error("Ability does not exist") + if (ability) { + const updatedAbility = { + ...ability, + removedFromUi: true, + } + this.abilities.put(updatedAbility) + return updatedAbility } - this.abilities.put({ - ...ability, - removedFromUi: true, - }) + return undefined + } + + async deleteAbilitiesForAccount(address: HexString): Promise { + return this.abilities + .filter((ability) => ability.address === address) + .delete() } } diff --git a/background/services/abilities/index.ts b/background/services/abilities/index.ts index e8b2f271e3..91307309d0 100644 --- a/background/services/abilities/index.ts +++ b/background/services/abilities/index.ts @@ -2,49 +2,17 @@ import { ServiceCreatorFunction, ServiceLifecycleEvents } from "../types" import BaseService from "../base" import { HexString, NormalizedEVMAddress } from "../../types" import { + createSpamReport, DaylightAbility, DaylightAbilityRequirement, getDaylightAbilities, -} from "./daylight" +} from "../../lib/daylight" import { AbilitiesDatabase, getOrCreateDB } from "./db" import ChainService from "../chain" -import { FeatureFlags } from "../../features" +import { FeatureFlags, isEnabled } from "../../features" import { normalizeEVMAddress } from "../../lib/utils" - -export type AbilityType = "mint" | "airdrop" | "access" - -type AbilityRequirement = HoldERC20 | OwnNFT | AllowList | Unknown - -type HoldERC20 = { - type: "hold" - address: string -} - -type OwnNFT = { - type: "own" - nftAddress: string -} - -type AllowList = { - type: "allowList" -} - -type Unknown = { - type: "unknown" -} - -export type Ability = { - type: AbilityType - title: string - description: string | null - abilityId: string - linkUrl: string - imageUrl?: string - completed: boolean - removedFromUi: boolean - address: NormalizedEVMAddress - requirement: AbilityRequirement -} +import { Ability, AbilityRequirement } from "../../abilities" +import LedgerService from "../ledger" const normalizeDaylightRequirements = ( requirement: DaylightAbilityRequirement @@ -81,28 +49,24 @@ const normalizeDaylightAbilities = ( const normalizedAbilities: Ability[] = [] daylightAbilities.forEach((daylightAbility) => { - // Lets start with just mints - if ( - daylightAbility.type === "mint" || - daylightAbility.type === "airdrop" || - daylightAbility.type === "access" - ) { - normalizedAbilities.push({ - type: daylightAbility.type, - title: daylightAbility.title, - description: daylightAbility.description, - abilityId: daylightAbility.uid, - linkUrl: daylightAbility.action.linkUrl, - imageUrl: daylightAbility.imageUrl || undefined, - completed: false, - removedFromUi: false, - address: normalizeEVMAddress(address), - requirement: normalizeDaylightRequirements( - // Just take the 1st requirement for now - daylightAbility.requirements[0] - ), - }) - } + normalizedAbilities.push({ + type: daylightAbility.type, + title: daylightAbility.title, + description: daylightAbility.description, + abilityId: daylightAbility.uid, + slug: daylightAbility.slug, + linkUrl: daylightAbility.action.linkUrl, + imageUrl: daylightAbility.imageUrl || undefined, + openAt: daylightAbility.openAt || undefined, + closeAt: daylightAbility.closeAt || undefined, + completed: false, + removedFromUi: false, + address: normalizeEVMAddress(address), + requirement: normalizeDaylightRequirements( + // Just take the 1st requirement for now + daylightAbility.requirements[0] + ), + }) }) return normalizedAbilities @@ -110,11 +74,17 @@ const normalizeDaylightAbilities = ( interface Events extends ServiceLifecycleEvents { newAbilities: Ability[] + updatedAbility: Ability + newAccount: string + deleteAccount: string + initAbilities: NormalizedEVMAddress + deleteAbilities: string } export default class AbilitiesService extends BaseService { constructor( private db: AbilitiesDatabase, - private chainService: ChainService + private chainService: ChainService, + private ledgerService: LedgerService ) { super({ abilitiesAlarm: { @@ -132,20 +102,34 @@ export default class AbilitiesService extends BaseService { static create: ServiceCreatorFunction< ServiceLifecycleEvents, AbilitiesService, - [Promise] - > = async (chainService) => { - return new this(await getOrCreateDB(), await chainService) + [Promise, Promise] + > = async (chainService, ledgerService) => { + return new this( + await getOrCreateDB(), + await chainService, + await ledgerService + ) } protected override async internalStartService(): Promise { await super.internalStartService() - this.chainService.emitter.on("newAccountToTrack", (addressOnNetwork) => { - this.pollForAbilities(addressOnNetwork.address) - }) + this.chainService.emitter.on( + "newAccountToTrack", + async ({ addressOnNetwork, source }) => { + const { address } = addressOnNetwork + const ledgerAccount = await this.ledgerService.getAccountByAddress( + address + ) + if (source ?? ledgerAccount) { + this.pollForAbilities(address) + this.emitter.emit("newAccount", address) + } + } + ) } async pollForAbilities(address: HexString): Promise { - if (!FeatureFlags.SUPPORT_ABILITIES) { + if (!isEnabled(FeatureFlags.SUPPORT_ABILITIES)) { return } @@ -175,14 +159,22 @@ export default class AbilitiesService extends BaseService { address: NormalizedEVMAddress, abilityId: string ): Promise { - return this.db.markAsCompleted(address, abilityId) + const ability = await this.db.markAsCompleted(address, abilityId) + + if (ability) { + this.emitter.emit("updatedAbility", ability) + } } async markAbilityAsRemoved( address: NormalizedEVMAddress, abilityId: string ): Promise { - return this.db.markAsRemoved(address, abilityId) + const ability = await this.db.markAsRemoved(address, abilityId) + + if (ability) { + this.emitter.emit("updatedAbility", ability) + } } async abilitiesAlarm(): Promise { @@ -192,8 +184,26 @@ export default class AbilitiesService extends BaseService { // 1-by-1 decreases likelihood of hitting rate limit // eslint-disable-next-line no-restricted-syntax for (const address of addresses) { - // eslint-disable-next-line no-await-in-loop - await this.pollForAbilities(address) + this.emitter.emit("initAbilities", address as NormalizedEVMAddress) + } + } + + async reportAndRemoveAbility( + address: NormalizedEVMAddress, + abilitySlug: string, + abilityId: string, + reason: string + ): Promise { + await createSpamReport(address, abilitySlug, reason) + this.markAbilityAsRemoved(address, abilityId) + } + + async deleteAbilitiesForAccount(address: HexString): Promise { + const deletedRecords = await this.db.deleteAbilitiesForAccount(address) + + if (deletedRecords > 0) { + this.emitter.emit("deleteAbilities", address) } + this.emitter.emit("deleteAccount", address) } } diff --git a/background/services/chain/db.ts b/background/services/chain/db.ts index 17d5cf74e1..b8ab184a79 100644 --- a/background/services/chain/db.ts +++ b/background/services/chain/db.ts @@ -12,6 +12,7 @@ import { import { FungibleAsset } from "../../assets" import { BASE_ASSETS, + CHAIN_ID_TO_COINGECKO_PLATFORM_ID, CHAIN_ID_TO_RPC_URLS, DEFAULT_NETWORKS, GOERLI, @@ -77,7 +78,7 @@ export class ChainDatabase extends Dexie { private networks!: Dexie.Table - private baseAssets!: Dexie.Table + private baseAssets!: Dexie.Table private rpcUrls!: Dexie.Table<{ chainID: string; rpcUrls: string[] }, string> @@ -218,6 +219,7 @@ export class ChainDatabase extends Dexie { }): Promise { await this.networks.put({ name: chainName, + coingeckoPlatformID: CHAIN_ID_TO_COINGECKO_PLATFORM_ID[chainID], chainID, family: "EVM", baseAsset: { @@ -251,6 +253,14 @@ export class ChainDatabase extends Dexie { }) } + async getBaseAssetForNetwork(chainID: string): Promise { + const baseAsset = await this.baseAssets.get(chainID) + if (!baseAsset) { + throw new Error(`No Base Asset Found For Network ${chainID}`) + } + return baseAsset + } + async getAllBaseAssets(): Promise { return this.baseAssets.toArray() } diff --git a/background/services/chain/index.ts b/background/services/chain/index.ts index 83b9cc1a6d..07e3cd4e93 100644 --- a/background/services/chain/index.ts +++ b/background/services/chain/index.ts @@ -18,6 +18,7 @@ import { TransactionRequestWithNonce, SignedTransaction, toHexChainID, + NetworkBaseAsset, } from "../../networks" import { AssetTransfer } from "../../assets" import { @@ -106,7 +107,11 @@ interface Events extends ServiceLifecycleEvents { transactions: Transaction[] account: AddressOnNetwork } - newAccountToTrack: AddressOnNetwork + newAccountToTrack: { + addressOnNetwork: AddressOnNetwork + source: "import" | "internal" | null + } + supportedNetworks: EVMNetwork[] accountsWithBalances: { /** * Retrieved balance for the network's base asset @@ -348,9 +353,9 @@ export default class ChainService extends BaseService { async initializeNetworks(): Promise { const rpcUrls = await this.db.getAllRpcUrls() - if (!this.supportedNetworks.length) { - this.supportedNetworks = await this.db.getAllEVMNetworks() - } + + await this.updateSupportedNetworks() + this.lastUserActivityOnNetwork = Object.fromEntries( this.supportedNetworks.map((network) => [network.chainID, 0]) @@ -361,7 +366,7 @@ export default class ChainService extends BaseService { this.supportedNetworks.map((network) => [ network.chainID, makeSerialFallbackProvider( - network, + network.chainID, rpcUrls.find((v) => v.chainID === network.chainID)?.rpcUrls || [] ), ]) @@ -870,7 +875,7 @@ export default class ChainService extends BaseService { network, assetAmount: { // Data stored in chain db for network base asset might be stale - asset: NETWORK_BY_CHAIN_ID[network.chainID].baseAsset, + asset: await this.db.getBaseAssetForNetwork(network.chainID), amount: balance.toBigInt(), }, dataSource: "alchemy", // TODO do this properly (eg provider isn't Alchemy) @@ -894,12 +899,18 @@ export default class ChainService extends BaseService { } async addAccountToTrack(addressNetwork: AddressOnNetwork): Promise { + const source = await this.keyringService.getKeyringSourceForAddress( + addressNetwork.address + ) const isAccountOnNetworkAlreadyTracked = await this.db.getTrackedAccountOnNetwork(addressNetwork) if (!isAccountOnNetworkAlreadyTracked) { // Skip save, emit and savedTransaction emission on resubmission await this.db.addAccountToTrack(addressNetwork) - this.emitter.emit("newAccountToTrack", addressNetwork) + this.emitter.emit("newAccountToTrack", { + addressOnNetwork: addressNetwork, + source, + }) } this.emitSavedTransactions(addressNetwork) this.subscribeToAccountTransactions(addressNetwork).catch((e) => { @@ -914,11 +925,7 @@ export default class ChainService extends BaseService { e ) }) - if ( - (await this.keyringService.getKeyringSourceForAddress( - addressNetwork.address - )) !== "internal" - ) { + if (source !== "internal") { this.loadHistoricAssetTransfers(addressNetwork).catch((e) => { logger.error( "chainService/addAccountToTrack: Error loading historic asset transfers", @@ -1865,6 +1872,10 @@ export default class ChainService extends BaseService { } } + async getNetworkBaseAssets(): Promise { + return this.db.getAllBaseAssets() + } + // Used to add non-default chains via wallet_addEthereumChain async addCustomChain( chainInfo: ValidatedAddEthereumChainParameter @@ -1877,6 +1888,18 @@ export default class ChainService extends BaseService { assetName: chainInfo.nativeCurrency.name, rpcUrls: chainInfo.rpcUrls, }) - this.supportedNetworks = await this.db.getAllEVMNetworks() + await this.updateSupportedNetworks() + + this.providers.evm[chainInfo.chainId] = makeSerialFallbackProvider( + chainInfo.chainId, + chainInfo.rpcUrls + ) + await this.startTrackingNetworkOrThrow(chainInfo.chainId) + } + + async updateSupportedNetworks(): Promise { + const supportedNetworks = await this.db.getAllEVMNetworks() + this.supportedNetworks = supportedNetworks + this.emitter.emit("supportedNetworks", supportedNetworks) } } diff --git a/background/services/chain/serial-fallback-provider.ts b/background/services/chain/serial-fallback-provider.ts index cc0651d8e4..05a07ee437 100644 --- a/background/services/chain/serial-fallback-provider.ts +++ b/background/services/chain/serial-fallback-provider.ts @@ -14,7 +14,7 @@ import { RPC_METHOD_PROVIDER_ROUTING, } from "../../constants" import logger from "../../lib/logger" -import { AnyEVMTransaction, EVMNetwork } from "../../networks" +import { AnyEVMTransaction } from "../../networks" import { AddressOnNetwork } from "../../accounts" import { transactionFromEthersTransaction } from "./utils" import { @@ -224,7 +224,7 @@ export default class SerialFallbackProvider extends JsonRpcProvider { constructor( // Internal network type useful for helper calls, but not exposed to avoid // clashing with Ethers's own `network` stuff. - private evmNetwork: EVMNetwork, + private chainID: string, providerCreators: Array<{ type: "alchemy" | "generic" creator: () => WebSocketProvider | JsonRpcProvider @@ -258,7 +258,7 @@ export default class SerialFallbackProvider extends JsonRpcProvider { this.cleanupStaleCacheEntries() }, CACHE_CLEANUP_INTERVAL) - this.cachedChainId = utils.hexlify(Number(evmNetwork.chainID)) + this.cachedChainId = utils.hexlify(Number(chainID)) this.providerCreators = [firstProviderCreator, ...remainingProviderCreators] } @@ -568,11 +568,11 @@ export default class SerialFallbackProvider extends JsonRpcProvider { { address, network }: AddressOnNetwork, handler: (pendingTransaction: AnyEVMTransaction) => void ): Promise { - if (this.evmNetwork.chainID !== network.chainID) { + if (this.chainID !== network.chainID) { logger.error( `Tried to subscribe to pending transactions for chain id ` + `${network.chainID} but provider was on ` + - `${this.evmNetwork.chainID}` + `${this.chainID}` ) return } @@ -930,26 +930,21 @@ export default class SerialFallbackProvider extends JsonRpcProvider { } export function makeSerialFallbackProvider( - network: EVMNetwork, + chainID: string, rpcUrls: string[] ): SerialFallbackProvider { - const alchemyProviderCreators = ALCHEMY_SUPPORTED_CHAIN_IDS.has( - network.chainID - ) + const alchemyProviderCreators = ALCHEMY_SUPPORTED_CHAIN_IDS.has(chainID) ? [ { type: "alchemy" as const, creator: () => - new AlchemyProvider( - getNetwork(Number(network.chainID)), - ALCHEMY_KEY - ), + new AlchemyProvider(getNetwork(Number(chainID)), ALCHEMY_KEY), }, { type: "alchemy" as const, creator: () => new AlchemyWebSocketProvider( - getNetwork(Number(network.chainID)), + getNetwork(Number(chainID)), ALCHEMY_KEY ), }, @@ -961,7 +956,7 @@ export function makeSerialFallbackProvider( creator: () => new JsonRpcProvider(rpcUrl), })) - return new SerialFallbackProvider(network, [ + return new SerialFallbackProvider(chainID, [ // Prefer alchemy as the primary provider when available ...genericProviders, ...alchemyProviderCreators, diff --git a/background/services/chain/tests/index.integration.test.ts b/background/services/chain/tests/index.integration.test.ts index 3289c0feea..a67fe3e7f0 100644 --- a/background/services/chain/tests/index.integration.test.ts +++ b/background/services/chain/tests/index.integration.test.ts @@ -10,6 +10,7 @@ import { createAnyEVMTransaction, createChainService, createLegacyTransactionRequest, + MockSerialFallbackProvider, } from "../../../tests/factories" import { ChainDatabase } from "../db" import SerialFallbackProvider from "../serial-fallback-provider" @@ -193,6 +194,38 @@ describe("ChainService", () => { ).toBeTruthy() }) + describe("updateSupportedNetworks", () => { + it("Should properly update supported networks", async () => { + chainService.supportedNetworks = [] + await chainService.updateSupportedNetworks() + expect(chainService.supportedNetworks.length).toBe(8) + }) + }) + + describe("addCustomChain", () => { + // prettier-ignore + const FANTOM_CHAIN_PARAMS = { chainId: "250", blockExplorerUrl: "https://ftmscan.com", 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"], } + it("should update supported networks after adding a chain", async () => { + expect(chainService.supportedNetworks.length).toBe(8) + await chainService.addCustomChain(FANTOM_CHAIN_PARAMS) + expect(chainService.supportedNetworks.length).toBe(9) + }) + + it("should create a provider for the new chain", async () => { + expect(chainService.providers.evm["250"]).toBe(undefined) + await chainService.addCustomChain(FANTOM_CHAIN_PARAMS) + expect(chainService.providers.evm["250"]).toBeInstanceOf( + MockSerialFallbackProvider + ) + }) + + it("should start tracking the new chain", async () => { + expect((await chainService.getTrackedNetworks()).length).toBe(1) + await chainService.addCustomChain(FANTOM_CHAIN_PARAMS) + expect((await chainService.getTrackedNetworks()).length).toBe(2) + }) + }) + describe("populateEVMTransactionNonce", () => { // The number of transactions address has ever sent const TRANSACTION_COUNT = 100 diff --git a/background/services/chain/tests/serial-fallback-provider.integration.test.ts b/background/services/chain/tests/serial-fallback-provider.integration.test.ts index 832e0ad253..f940e33f87 100644 --- a/background/services/chain/tests/serial-fallback-provider.integration.test.ts +++ b/background/services/chain/tests/serial-fallback-provider.integration.test.ts @@ -20,7 +20,7 @@ describe("Serial Fallback Provider", () => { alchemySendStub = sandbox .stub(mockAlchemyProvider, "send") .callsFake(async () => "success") - fallbackProvider = new SerialFallbackProvider(ETHEREUM, [ + fallbackProvider = new SerialFallbackProvider(ETHEREUM.chainID, [ { type: "generic", creator: () => mockGenericProvider, diff --git a/background/services/doggo/index.ts b/background/services/doggo/index.ts index 72dc7662aa..8414a77772 100644 --- a/background/services/doggo/index.ts +++ b/background/services/doggo/index.ts @@ -77,9 +77,12 @@ export default class DoggoService extends BaseService { // Track referrals for all added accounts and any new ones that are added // after load. - this.chainService.emitter.on("newAccountToTrack", (addressOnNetwork) => { - this.trackReferrals(addressOnNetwork) - }) + this.chainService.emitter.on( + "newAccountToTrack", + ({ addressOnNetwork }) => { + this.trackReferrals(addressOnNetwork) + } + ) ;(await this.chainService.getAccountsToTrack()).forEach( (addressOnNetwork) => { this.trackReferrals(addressOnNetwork) diff --git a/background/services/enrichment/tests/transactions.integration.test.ts b/background/services/enrichment/tests/transactions.integration.test.ts index 26d93dc246..cd757f498e 100644 --- a/background/services/enrichment/tests/transactions.integration.test.ts +++ b/background/services/enrichment/tests/transactions.integration.test.ts @@ -38,6 +38,8 @@ describe("Enrichment Service Transactions", () => { nameServicePromise, ]) + await chainService.startService() + await chainService.addAccountToTrack({ address: "0x9eef87f4c08d8934cb2a3309df4dec5635338115", network: ETHEREUM, diff --git a/background/services/indexing/db.ts b/background/services/indexing/db.ts index 479934194f..82f2466f24 100644 --- a/background/services/indexing/db.ts +++ b/background/services/indexing/db.ts @@ -2,7 +2,7 @@ import Dexie, { DexieOptions } from "dexie" import { TokenList } from "@uniswap/token-lists" import { AccountBalance } from "../../accounts" -import { Network } from "../../networks" +import { EVMNetwork } from "../../networks" import { AnyAsset, FungibleAsset, @@ -222,6 +222,14 @@ export class IndexingDatabase extends Dexie { .toCollection() .modify(normalizeAssetAddress) }) + + // Fix incorrect index on "chainId" + this.version(5).stores({ + customAssets: + "&[contractAddress+homeNetwork.name],contractAddress,symbol,homeNetwork.chainID,homeNetwork.name", + assetsToTrack: + "&[contractAddress+homeNetwork.name],symbol,contractAddress,homeNetwork.family,homeNetwork.chainID,homeNetwork.name", + }) } async savePriceMeasurement( @@ -241,7 +249,7 @@ export class IndexingDatabase extends Dexie { async getLatestAccountBalance( address: string, - network: Network, + network: EVMNetwork, asset: FungibleAsset ): Promise { // TODO this needs to be tightened up, both for performance and specificity @@ -279,17 +287,17 @@ export class IndexingDatabase extends Dexie { ) } - async getCustomAssetsByNetwork( - network: Network + async getCustomAssetsByNetworks( + networks: EVMNetwork[] ): Promise { return this.customAssets - .where("homeNetwork.name") - .equals(network.name) + .where("homeNetwork.chainID") + .anyOf(networks.map((network) => network.chainID)) .toArray() } async getCustomAssetByAddressAndNetwork( - network: Network, + network: EVMNetwork, contractAddress: string ): Promise { return this.customAssets @@ -299,7 +307,7 @@ export class IndexingDatabase extends Dexie { } async getTrackedAssetByAddressAndNetwork( - network: Network, + network: EVMNetwork, contractAddress: string ): Promise { return this.assetsToTrack diff --git a/background/services/indexing/index.ts b/background/services/indexing/index.ts index ef8c1148ad..c9fd750009 100644 --- a/background/services/indexing/index.ts +++ b/background/services/indexing/index.ts @@ -143,7 +143,7 @@ export default class IndexingService extends BaseService { schedule: { periodInMinutes: 1, }, - handler: () => this.handleBalanceAlarm(true), + handler: () => this.handleBalanceAlarm({ onlyActiveAccounts: true }), }, forceBalance: { schedule: { @@ -163,7 +163,6 @@ export default class IndexingService extends BaseService { periodInMinutes: 10, }, handler: () => this.handlePriceAlarm(), - runAtStart: true, }, }) } @@ -177,6 +176,8 @@ export default class IndexingService extends BaseService { const tokenListLoad = this.fetchAndCacheTokenLists() this.chainService.emitter.once("serviceStarted").then(async () => { + this.handlePriceAlarm() + const trackedNetworks = await this.chainService.getTrackedNetworks() // Push any assets we have cached in the db for all active networks @@ -186,7 +187,12 @@ export default class IndexingService extends BaseService { }) // Force a balance refresh on service start - tokenListLoad.then(() => this.handleBalanceAlarm()) + tokenListLoad.then(() => + this.handleBalanceAlarm({ + onlyActiveAccounts: false, + fetchTokenLists: false, + }) + ) }) } @@ -251,7 +257,7 @@ export default class IndexingService extends BaseService { * lists. */ async cacheAssetsForNetwork(network: EVMNetwork): Promise { - const customAssets = await this.db.getCustomAssetsByNetwork(network) + const customAssets = await this.db.getCustomAssetsByNetworks([network]) const tokenListPrefs = await this.preferenceService.getTokenListPreferences() const tokenLists = await this.db.getLatestTokenLists(tokenListPrefs.urls) @@ -432,7 +438,7 @@ export default class IndexingService extends BaseService { this.chainService.emitter.on( "newAccountToTrack", - async (addressOnNetwork) => { + async ({ addressOnNetwork }) => { // whenever a new account is added, get token balances from Alchemy's // default list and add any non-zero tokens to the tracking list const balances = await this.retrieveTokenBalances(addressOnNetwork) @@ -629,22 +635,15 @@ export default class IndexingService extends BaseService { } } - private async handlePriceAlarm(): Promise { - if (Date.now() < this.lastPriceAlarmTime + 5 * SECOND) { - // If this is quickly called multiple times (for example when - // using a network for the first time with a wallet loaded - // with many accounts) only fetch prices once. - return - } - this.lastPriceAlarmTime = Date.now() - // TODO refactor for multiple price sources + /** + * Loads prices for base network assets + */ + private async getBaseAssetsPrices() { try { // TODO include user-preferred currencies // get the prices of ETH and BTC vs major currencies - const basicPrices = await getPrices( - BUILT_IN_NETWORK_BASE_ASSETS, - FIAT_CURRENCIES - ) + const baseAssets = await this.chainService.getNetworkBaseAssets() + const basicPrices = await getPrices(baseAssets, FIAT_CURRENCIES) // kick off db writes and event emission, don't wait for the promises to // settle @@ -669,32 +668,54 @@ export default class IndexingService extends BaseService { FIAT_CURRENCIES ) } + } + /** + * Loads prices for all tracked assets except untrusted/custom network assets + */ + private async getTrackedAssetsPrices() { // get the prices of all assets to track and save them const assetsToTrack = await this.db.getAssetsToTrack() const trackedNetworks = await this.chainService.getTrackedNetworks() - // Filter all assets based on supported networks - const activeAssetsToTrack = assetsToTrack.filter( - (asset) => - asset.symbol === "ETH" || - trackedNetworks - .map((n) => n.chainID) - .includes(asset.homeNetwork.chainID) + const getAssetId = (asset: SmartContractFungibleAsset) => + `${asset.homeNetwork.chainID}:${asset.contractAddress}` + + const customAssets = await this.db.getCustomAssetsByNetworks( + trackedNetworks ) + const customAssetsById = new Set(customAssets.map(getAssetId)) + + // Filter all assets based on supported networks + const activeAssetsToTrack = assetsToTrack.filter((asset) => { + // Skip custom assets + if (customAssetsById.has(getAssetId(asset))) { + return false + } + + return trackedNetworks.some( + (network) => network.chainID === asset.homeNetwork.chainID + ) + }) + try { // TODO only uses USD const allActiveAssetsByAddress = getAssetsByAddress(activeAssetsToTrack) - const activeAssetsByNetwork = trackedNetworks.map((network) => ({ - activeAssetsByAddress: getActiveAssetsByAddressForNetwork( + const activeAssetsByNetwork = trackedNetworks + .map((network) => ({ + activeAssetsByAddress: getActiveAssetsByAddressForNetwork( + network, + activeAssetsToTrack + ), network, - activeAssetsToTrack - ), - network, - })) + })) + .filter( + ({ activeAssetsByAddress }) => + Object.keys(activeAssetsByAddress).length > 0 + ) const measuredAt = Date.now() @@ -739,6 +760,20 @@ export default class IndexingService extends BaseService { } } + private async handlePriceAlarm(): Promise { + if (Date.now() < this.lastPriceAlarmTime + 5 * SECOND) { + // If this is quickly called multiple times (for example when + // using a network for the first time with a wallet loaded + // with many accounts) only fetch prices once. + return + } + + this.lastPriceAlarmTime = Date.now() + // TODO refactor for multiple price sources + await this.getBaseAssetsPrices() + await this.getTrackedAssetsPrices() + } + private async fetchAndCacheTokenLists(): Promise { const tokenListPrefs = await this.preferenceService.getTokenListPreferences() @@ -784,9 +819,17 @@ export default class IndexingService extends BaseService { } } - private async handleBalanceAlarm(onlyActiveAccounts = false): Promise { - // no need to block here, as the first fetch blocks the entire service init - this.fetchAndCacheTokenLists() + private async handleBalanceAlarm({ + onlyActiveAccounts = false, + fetchTokenLists = true, + }: { + onlyActiveAccounts?: boolean + fetchTokenLists?: boolean + } = {}): Promise { + if (fetchTokenLists) { + // no need to block here, as the first fetch blocks the entire service init + this.fetchAndCacheTokenLists() + } const assetsToTrack = await this.db.getAssetsToTrack() const trackedNetworks = await this.chainService.getTrackedNetworks() diff --git a/background/services/indexing/tests/index.integration.test.ts b/background/services/indexing/tests/index.integration.test.ts index d90a40d0ed..2095f82225 100644 --- a/background/services/indexing/tests/index.integration.test.ts +++ b/background/services/indexing/tests/index.integration.test.ts @@ -1,17 +1,31 @@ import { fetchJson } from "@ethersproject/web" import sinon, { SinonStub } from "sinon" +import * as libPrices from "../../../lib/prices" import IndexingService from ".." -import { SmartContractFungibleAsset } from "../../../assets" import { ETHEREUM, OPTIMISM } from "../../../constants" import { createChainService, createIndexingService, createPreferenceService, + createSmartContractAsset, } from "../../../tests/factories" import ChainService from "../../chain" import PreferenceService from "../../preferences" import { getOrCreateDb as getIndexingDB } from "../db" +type MethodSpy unknown> = jest.SpyInstance< + ReturnType, + Parameters +> + +const getPrivateMethodSpy = unknown>( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + object: any, + property: string +) => { + return jest.spyOn(object, property) as MethodSpy +} + const fetchJsonStub: SinonStub< Parameters, ReturnType @@ -30,6 +44,18 @@ describe("IndexingService", () => { let preferenceService: PreferenceService beforeEach(async () => { + fetchJsonStub + .withArgs( + "https://api.coingecko.com/api/v3/simple/price?ids=ethereum,matic-network,rootstock,avalanche-2,binancecoin&include_last_updated_at=true&vs_currencies=usd" + ) + .resolves({ + "matic-network": { usd: 1.088, last_updated_at: 1675123143 }, + ethereum: { usd: 1569.14, last_updated_at: 1675123142 }, + "avalanche-2": { usd: 19.76, last_updated_at: 1675123166 }, + binancecoin: { usd: 307.31, last_updated_at: 1675123138 }, + rootstock: { usd: 22837, last_updated_at: 1675123110 }, + }) + preferenceService = await createPreferenceService() sandbox.stub(preferenceService, "getTokenListPreferences").resolves({ @@ -42,6 +68,9 @@ describe("IndexingService", () => { }) sandbox.stub(chainService, "supportedNetworks").value([ETHEREUM, OPTIMISM]) + sandbox + .stub(chainService, "getTrackedNetworks") + .resolves([ETHEREUM, OPTIMISM]) indexedDB = new IDBFactory() @@ -85,22 +114,9 @@ describe("IndexingService", () => { ], } - const customAsset: SmartContractFungibleAsset = { - metadata: { - tokenLists: [ - { - url: "https://bridge.arbitrum.io/token-list-42161.json", - name: "Arb Whitelist Era", - logoURL: "ipfs://QmTvWJ4kmzq9koK74WJQ594ov8Es1HHurHZmMmhU8VY68y", - }, - ], - }, - name: "USD Coin", + const customAsset = createSmartContractAsset({ symbol: "USDC", - decimals: 6, - homeNetwork: ETHEREUM, - contractAddress: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", - } + }) it("should initialize cache with base assets, custom assets and tokenlists stored in the db", async () => { const cacheSpy = jest.spyOn(indexingService, "cacheAssetsForNetwork") @@ -149,16 +165,17 @@ describe("IndexingService", () => { indexingService .getCachedAssets(ETHEREUM) .map((assets) => assets.symbol) - ).toEqual(["ETH", "USDC", "TEST"]) + ).toEqual(["ETH", customAsset.symbol, "TEST"]) }) delay.resolve(undefined) }) it("should update cache once token lists load", async () => { - sandbox - .stub(chainService, "supportedNetworks") - .value([ETHEREUM, OPTIMISM]) + const spy = getPrivateMethodSpy< + IndexingService["fetchAndCacheTokenLists"] + >(indexingService, "fetchAndCacheTokenLists") + const cacheSpy = jest.spyOn(indexingService, "cacheAssetsForNetwork") const delay = sinon.promise() @@ -186,9 +203,16 @@ describe("IndexingService", () => { delay.resolve(undefined) + await spy.mock.results[0].value + await indexingService.emitter.once("assets").then(() => { - /* Caches assets for every supported network + 1 active network */ - expect(cacheSpy).toHaveBeenCalledTimes(5) + /** + * Caches assets for every tracked network at service start and + * for every supported network after tokenlist load + */ + expect(cacheSpy).toHaveBeenCalledTimes( + chainService.supportedNetworks.length + 2 + ) expect( indexingService.getCachedAssets(ETHEREUM).map((asset) => asset.symbol) @@ -227,5 +251,54 @@ describe("IndexingService", () => { indexingService.getCachedAssets(ETHEREUM).map((assets) => assets.symbol) ).toEqual(["ETH", customAsset.symbol, "TEST"]) }) + + it("should not retrieve token prices for custom assets", async () => { + const indexingDb = await getIndexingDB() + + const smartContractAsset = createSmartContractAsset() + + await indexingDb.addCustomAsset(customAsset) + await indexingDb.saveTokenList( + "https://gateway.ipfs.io/ipns/tokens.uniswap.org", + tokenList + ) + + await indexingDb.addAssetToTrack(customAsset) + await indexingDb.addAssetToTrack(smartContractAsset) + + const getTokenPricesSpy = jest.spyOn(libPrices, "getTokenPrices") + + fetchJsonStub + .withArgs( + `https://api.coingecko.com/api/v3/simple/token_price/ethereum?vs_currencies=USD&include_last_updated_at=true&contract_addresses=${smartContractAsset.contractAddress}` + ) + .resolves({ + [smartContractAsset.contractAddress]: { + usd: 0.511675, + last_updated_at: 1675140863, + }, + }) + + const spy = getPrivateMethodSpy( + indexingService, + "handlePriceAlarm" + ) + + await Promise.all([ + chainService.startService(), + indexingService.startService(), + ]) + + await indexingService.emitter.once("assets") + + expect(spy).toHaveBeenCalled() + + await spy.mock.results[0].value + expect(getTokenPricesSpy).toHaveBeenCalledWith( + [smartContractAsset.contractAddress], + { name: "United States Dollar", symbol: "USD", decimals: 10 }, + ETHEREUM + ) + }) }) }) diff --git a/background/services/internal-ethereum-provider/index.ts b/background/services/internal-ethereum-provider/index.ts index bd349d716e..b551734d1e 100644 --- a/background/services/internal-ethereum-provider/index.ts +++ b/background/services/internal-ethereum-provider/index.ts @@ -318,7 +318,7 @@ export default class InternalEthereumProviderService extends BaseService const { chainId } = chainInfo const supportedNetwork = await this.getTrackedNetworkByChainId(chainId) if (supportedNetwork) { - this.switchToSupportedNetwork(supportedNetwork) + this.switchToSupportedNetwork(origin, supportedNetwork) return null } if (!FeatureFlags.SUPPORT_CUSTOM_NETWORKS) { @@ -340,7 +340,7 @@ export default class InternalEthereumProviderService extends BaseService newChainId ) if (supportedNetwork) { - this.switchToSupportedNetwork(supportedNetwork) + this.switchToSupportedNetwork(origin, supportedNetwork) return null } @@ -475,7 +475,10 @@ export default class InternalEthereumProviderService extends BaseService }) } - private async switchToSupportedNetwork(supportedNetwork: EVMNetwork) { + private async switchToSupportedNetwork( + origin: string, + supportedNetwork: EVMNetwork + ) { const { address } = await this.preferenceService.getSelectedAccount() await this.chainService.markAccountActivity({ address, diff --git a/background/services/keyring/index.ts b/background/services/keyring/index.ts index 9287d50134..793d6292e2 100644 --- a/background/services/keyring/index.ts +++ b/background/services/keyring/index.ts @@ -28,6 +28,7 @@ export const MAX_OUTSIDE_IDLE_TIME = 60 * MINUTE export type Keyring = { type: KeyringTypes id: string | null + path: string | null addresses: string[] } @@ -301,7 +302,8 @@ export default class KeyringService extends BaseService { * accessed at generation time through this return value. */ async generateNewKeyring( - type: KeyringTypes + type: KeyringTypes, + path?: string ): Promise<{ id: string; mnemonic: string[] }> { this.requireUnlocked() @@ -311,7 +313,13 @@ export default class KeyringService extends BaseService { ) } - const newKeyring = new HDKeyring({ strength: 256 }) + const options: { strength: number; path?: string } = { strength: 256 } + + if (path) { + options.path = path + } + + const newKeyring = new HDKeyring(options) const { mnemonic } = newKeyring.serializeSync() @@ -382,6 +390,7 @@ export default class KeyringService extends BaseService { .filter((address) => this.#hiddenAccounts[address] !== true), ], id: kr.id, + path: kr.path, })) } diff --git a/background/services/ledger/index.ts b/background/services/ledger/index.ts index 4509ed797f..54c33f0d60 100644 --- a/background/services/ledger/index.ts +++ b/background/services/ledger/index.ts @@ -382,6 +382,11 @@ export default class LedgerService extends BaseService { await this.db.removeAccount(address) } + async getAccountByAddress(address: HexString): Promise { + const ledgerAccount = await this.db.getAccountByAddress(address) + return ledgerAccount + } + async signTransaction( transactionRequest: TransactionRequestWithNonce, { deviceID, path: derivationPath }: LedgerAccountSigner diff --git a/background/services/name/index.ts b/background/services/name/index.ts index fc33cb7af9..20f726278b 100644 --- a/background/services/name/index.ts +++ b/background/services/name/index.ts @@ -135,13 +135,20 @@ export default class NameService extends BaseService { } ) - chainService.emitter.on("newAccountToTrack", async (addressOnNetwork) => { - try { - await this.lookUpName(addressOnNetwork) - } catch (error) { - logger.error("Error fetching name for address", addressOnNetwork, error) + chainService.emitter.on( + "newAccountToTrack", + async ({ addressOnNetwork }) => { + try { + await this.lookUpName(addressOnNetwork) + } catch (error) { + logger.error( + "Error fetching name for address", + addressOnNetwork, + error + ) + } } - }) + ) this.emitter.on("resolvedName", async ({ from: { addressOnNetwork } }) => { try { const avatar = await this.lookUpAvatar(addressOnNetwork) diff --git a/background/services/nfts/index.ts b/background/services/nfts/index.ts index e06ba2d8c0..4a6c0ff849 100644 --- a/background/services/nfts/index.ts +++ b/background/services/nfts/index.ts @@ -85,7 +85,7 @@ export default class NFTsService extends BaseService { this.chainService.emitter.on( "newAccountToTrack", - async (addressOnNetwork) => { + async ({ addressOnNetwork }) => { this.emitter.emit("isReloadingNFTs", true) await this.initializeCollections([addressOnNetwork]) this.emitter.emit("isReloadingNFTs", false) @@ -111,7 +111,7 @@ export default class NFTsService extends BaseService { const transfers = await this.fetchTransferredNFTs(accountsToFetch) - if (transfers.sold.length || transfers.bought.length) { + if (transfers.length) { await this.fetchCollections(accountsToFetch) // refetch only if there are some transfers } await this.fetchPOAPs(accountsToFetch) @@ -132,6 +132,14 @@ export default class NFTsService extends BaseService { ) } + async refreshNFTsFromCollection( + collectionID: string, + account: AddressOnNetwork + ): Promise { + this.setFreshCollection(collectionID, account.address, false) + await this.fetchNFTsFromCollection(collectionID, account) + } + async fetchNFTsFromCollection( collectionID: string, account: AddressOnNetwork @@ -236,7 +244,8 @@ export default class NFTsService extends BaseService { this.emitter.emit("updateCollections", [updatedCollection]) } - this.setFreshCollection(collectionID, account.address, true) + // if NFTs were fetched then mark as fresh + this.setFreshCollection(collectionID, account.address, !!updatedNFTs.length) const hasNextPage = !!Object.keys(nextPageURLs).length @@ -280,45 +289,38 @@ export default class NFTsService extends BaseService { async fetchTransferredNFTs( accounts: AddressOnNetwork[] - ): Promise<{ sold: TransferredNFT[]; bought: TransferredNFT[] }> { + ): Promise { const transfers = await getNFTsTransfers( accounts, this.#transfersLookupTimestamp ) // indexing transfers can take some time, let's add some margin to the timestamp - this.#transfersLookupTimestamp = getUNIXTimestamp(Date.now() - 5 * MINUTE) - - 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[] + this.#transfersLookupTimestamp = getUNIXTimestamp(Date.now() - 2 * MINUTE) + + // mark collections with transferred NFTs to be refetched + transfers.forEach((transfer) => { + const { collectionID, to, from, isKnownFromAddress, isKnownToAddress } = + transfer + if (collectionID && to && isKnownToAddress) { + this.setFreshCollection(collectionID, to, false) } - ) + if (collectionID && from && isKnownFromAddress) { + this.setFreshCollection(collectionID, from, false) + } + }) - 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) - } - }) - } + const knownFromAddress = transfers.filter( + (transfer) => transfer.isKnownFromAddress + ) - if (sold.length) { - await this.db.removeNFTsByIDs(sold.map((transferred) => transferred.id)) - this.emitter.emit("removeTransferredNFTs", sold) + if (knownFromAddress.length) { + await this.db.removeNFTsByIDs( + knownFromAddress.map((transferred) => transferred.id) + ) + this.emitter.emit("removeTransferredNFTs", knownFromAddress) } - return { sold, bought } + return transfers } } diff --git a/background/services/preferences/db.ts b/background/services/preferences/db.ts index c65b1127bb..3f9dac4c1a 100644 --- a/background/services/preferences/db.ts +++ b/background/services/preferences/db.ts @@ -268,6 +268,44 @@ export class PreferenceDatabase extends Dexie { }) }) + this.version(14).upgrade((tx) => { + return tx + .table("preferences") + .toCollection() + .modify((storedPreferences: Preferences) => { + const urls = storedPreferences.tokenLists.urls.filter( + (url) => + url !== + "https://raw.githubusercontent.com/traderjoe-xyz/joe-tokenlists/main/src/joe.tokenlist-v2.json" + ) + + urls.push( + "https://raw.githubusercontent.com/traderjoe-xyz/joe-tokenlists/main/avalanche.tokenlist.json" + ) + + Object.assign(storedPreferences.tokenLists, { urls }) + }) + }) + + this.version(15).upgrade((tx) => { + return tx + .table("preferences") + .toCollection() + .modify((storedPreferences: Preferences) => { + const urls = storedPreferences.tokenLists.urls.filter( + (url) => + url !== + "https://raw.githubusercontent.com/traderjoe-xyz/joe-tokenlists/main/avalanche.tokenlist.json" + ) + + urls.push( + "https://raw.githubusercontent.com/traderjoe-xyz/joe-tokenlists/1722d8c47a728a64c8dca8ac160b32cf39c5e671/mc.tokenlist.json" + ) + + Object.assign(storedPreferences.tokenLists, { urls }) + }) + }) + // This is the old version for populate // https://dexie.org/docs/Dexie/Dexie.on.populate-(old-version) // The this does not behave according the new docs, but works diff --git a/background/services/preferences/defaults.ts b/background/services/preferences/defaults.ts index 8b8d03ba2a..3870c68da0 100644 --- a/background/services/preferences/defaults.ts +++ b/background/services/preferences/defaults.ts @@ -18,7 +18,7 @@ const defaultPreferences: Preferences = { "https://api-polygon-tokens.polygon.technology/tokenlists/default.tokenlist.json", // Polygon Default Tokens "https://static.optimism.io/optimism.tokenlist.json", // Optimism Default Tokens "https://bridge.arbitrum.io/token-list-42161.json", // Arbitrum Default tokens - "https://raw.githubusercontent.com/traderjoe-xyz/joe-tokenlists/main/src/joe.tokenlist-v2.json", // Trader Joe tokens + "https://raw.githubusercontent.com/traderjoe-xyz/joe-tokenlists/1722d8c47a728a64c8dca8ac160b32cf39c5e671/mc.tokenlist.json", // Trader Joe tokens "https://tokens.pancakeswap.finance/pancakeswap-default.json", // PancakeSwap Default List ], }, diff --git a/background/services/provider-bridge/index.ts b/background/services/provider-bridge/index.ts index a360f6012b..fe8a4dc821 100644 --- a/background/services/provider-bridge/index.ts +++ b/background/services/provider-bridge/index.ts @@ -25,7 +25,7 @@ import { import showExtensionPopup from "./show-popup" import { HexString } from "../../types" import { WEBSITE_ORIGIN } from "../../constants/website" -import { PermissionMap } from "./utils" +import { handleRPCErrorResponse, PermissionMap } from "./utils" import { toHexChainID } from "../../networks" import { TALLY_INTERNAL_ORIGIN } from "../internal-ethereum-provider/constants" @@ -33,6 +33,10 @@ type Events = ServiceLifecycleEvents & { requestPermission: PermissionRequest initializeAllowedPages: PermissionMap setClaimReferrer: string + /** + * Contains the Wallet Connect URI required to pair/connect + */ + walletConnectInit: string } /** @@ -147,15 +151,39 @@ export default class ProviderBridgeService extends BaseService { defaultWallet: await this.preferenceService.getDefaultWallet(), chainId: toHexChainID(network.chainID), } - } else if (event.request.method === "tally_setClaimReferrer") { - const referrer = event.request.params[0] - if (origin !== WEBSITE_ORIGIN || typeof referrer !== "string") { - logger.warn(`invalid 'setClaimReferrer' request`) - return + } else if (event.request.method.startsWith("tally_")) { + switch (event.request.method) { + case "tally_setClaimReferrer": + if (origin !== WEBSITE_ORIGIN) { + logger.warn( + `invalid WEBSITE_ORIGIN ${WEBSITE_ORIGIN} when using a custom 'tally_...' method` + ) + return + } + + if (typeof event.request.params[0] !== "string") { + logger.warn(`invalid 'tally_setClaimReferrer' request`) + return + } + + this.emitter.emit("setClaimReferrer", String(event.request.params[0])) + break + case "tally_walletConnectInit": { + const [wcUri] = event.request.params + if (typeof wcUri === "string") { + await this.emitter.emit("walletConnectInit", wcUri) + } else { + logger.warn(`invalid 'tally_walletConnectInit' request`) + } + + break + } + default: + logger.debug( + `Unknown method ${event.request.method} in 'ProviderBridgeService'` + ) } - this.emitter.emit("setClaimReferrer", String(referrer)) - response.result = null } else if ( event.request.method === "eth_chainId" || @@ -248,6 +276,27 @@ export default class ProviderBridgeService extends BaseService { EIP1193_ERROR_CODES.userRejectedRequest ).toJSON() } + } else if (event.request.method === "eth_accounts") { + const dAppChainID = Number( + (await this.internalEthereumProviderService.routeSafeRPCRequest( + "eth_chainId", + [], + origin + )) as string + ).toString() + + const permission = await this.checkPermission(origin, dAppChainID) + + response.result = [] + + if (permission) { + response.result = await this.routeContentScriptRPCRequest( + permission, + "eth_accounts", + event.request.params, + origin + ) + } } else { // sorry dear dApp, there is no love for you here response.result = new EIP1193Error( @@ -452,8 +501,7 @@ export default class ProviderBridgeService extends BaseService { } } } catch (error) { - logger.log("error processing request", error) - return new EIP1193Error(EIP1193_ERROR_CODES.userRejectedRequest).toJSON() + return handleRPCErrorResponse(error) } } } diff --git a/background/services/provider-bridge/tests/index.unit.test.ts b/background/services/provider-bridge/tests/index.unit.test.ts new file mode 100644 index 0000000000..6ccc0e6864 --- /dev/null +++ b/background/services/provider-bridge/tests/index.unit.test.ts @@ -0,0 +1,108 @@ +import { + EIP1193_ERROR_CODES, + PermissionRequest, +} from "@tallyho/provider-bridge-shared" +import sinon from "sinon" +import browser from "webextension-polyfill" +import { createProviderBridgeService } from "../../../tests/factories" +import ProviderBridgeService from "../index" + +const WINDOW = { + focused: true, + incognito: false, + alwaysOnTop: true, +} + +const CHAIN_ID = "1" +const ADDRESS = "0x0000000000000000000000000000000000000000" + +const BASE_DATA = { + enablingPermission: { + key: `https://app.test_${"0x0000000000000000000000000000000000000000"}_${CHAIN_ID}`, + origin: "https://app.test", + faviconUrl: "https://app.test/favicon.png", + title: "Test", + state: "allow", + accountAddress: ADDRESS, + chainID: CHAIN_ID, + } as PermissionRequest, + origin: "https://app.test", +} + +const PARAMS = { + eth_accounts: ["Test", "https://app.test/favicon.png"], + eth_sendTransaction: [ + { + from: ADDRESS, + data: Date.now().toString(), + gasPrice: "0xf4240", + to: "0x1111111111111111111111111111111111111111", + }, + ], +} +describe("ProviderBridgeService", () => { + let providerBridgeService: ProviderBridgeService + const sandbox = sinon.createSandbox() + + beforeEach(async () => { + browser.windows.getCurrent = jest.fn(() => Promise.resolve(WINDOW)) + browser.windows.create = jest.fn(() => Promise.resolve(WINDOW)) + providerBridgeService = await createProviderBridgeService() + await providerBridgeService.startService() + sandbox.restore() + }) + + afterEach(async () => { + await providerBridgeService.stopService() + jest.clearAllMocks() + }) + + describe("routeContentScriptRPCRequest", () => { + it("eth_accounts should return the account address owned by the client", async () => { + const { enablingPermission, origin } = BASE_DATA + const method = "eth_accounts" + const params = PARAMS[method] + + const response = await providerBridgeService.routeContentScriptRPCRequest( + enablingPermission, + method, + params, + origin + ) + expect(response).toEqual([enablingPermission.accountAddress]) + }) + + it("eth_sendTransaction should call routeSafeRequest when a user has permission to sign", async () => { + const { enablingPermission, origin } = BASE_DATA + const method = "eth_sendTransaction" + const params = PARAMS[method] + const stub = sandbox.stub(providerBridgeService, "routeSafeRequest") + + await providerBridgeService.routeContentScriptRPCRequest( + enablingPermission, + method, + params, + origin + ) + + expect(stub.called).toBe(true) + }) + + it("eth_sendTransaction should not call routeSafeRequest when a user has not permission to sign", async () => { + const { enablingPermission, origin } = BASE_DATA + const method = "eth_sendTransaction" + const params = PARAMS[method] + const stub = sandbox.stub(providerBridgeService, "routeSafeRequest") + + const response = await providerBridgeService.routeContentScriptRPCRequest( + { ...enablingPermission, state: "deny" }, + method, + params, + origin + ) + + expect(stub.called).toBe(false) + expect(response).toBe(EIP1193_ERROR_CODES.unauthorized) + }) + }) +}) diff --git a/background/services/provider-bridge/tests/utils.unit.test.ts b/background/services/provider-bridge/tests/utils.unit.test.ts new file mode 100644 index 0000000000..4ee05bc63a --- /dev/null +++ b/background/services/provider-bridge/tests/utils.unit.test.ts @@ -0,0 +1,60 @@ +import { + EIP1193Error, + EIP1193_ERROR_CODES, +} from "@tallyho/provider-bridge-shared" +import { handleRPCErrorResponse } from "../utils" + +describe("Utils", () => { + describe("handleRPCErrorResponse", () => { + it("should return a provider Rpc error", () => { + const response = handleRPCErrorResponse( + new EIP1193Error(EIP1193_ERROR_CODES.disconnected) + ) + + expect(response).toBe(EIP1193_ERROR_CODES.disconnected) + }) + + it("should return a custom error when a message is in the body", () => { + const error = { + body: JSON.stringify({ + error: { + message: "Custom error", + }, + }), + } + const response = handleRPCErrorResponse(error) + + expect(response).toStrictEqual({ code: 4001, message: "Custom error" }) + }) + + it("should return a custom error when a message is nested in the error object", () => { + const error = { + error: { + body: JSON.stringify({ + error: { + message: "Custom error", + }, + }), + }, + } + const response = handleRPCErrorResponse(error) + + expect(response).toStrictEqual({ code: 4001, message: "Custom error" }) + }) + + it("should return a default message when is not possible to handle the error", () => { + const error = { + error: { + body: { + error: { + message: "Custom error", + }, + }, + }, + } + const response = handleRPCErrorResponse(error) + + expect(response).toBe(EIP1193_ERROR_CODES.userRejectedRequest) + }) + }) +}) diff --git a/background/services/provider-bridge/utils.ts b/background/services/provider-bridge/utils.ts index c88db9fb41..d76e3720ec 100644 --- a/background/services/provider-bridge/utils.ts +++ b/background/services/provider-bridge/utils.ts @@ -1,4 +1,11 @@ -import { PermissionRequest } from "@tallyho/provider-bridge-shared" +import { + PermissionRequest, + EIP1193Error, + EIP1193_ERROR_CODES, + isEIP1193Error, + EIP1193ErrorPayload, +} from "@tallyho/provider-bridge-shared" +import logger from "../../lib/logger" export type PermissionMap = { evm: { @@ -23,3 +30,63 @@ export const keyPermissionsByChainIdAddressOrigin = ( }) return map } + +export function parsedRPCErrorResponse(error: { body: string }): + | { + code: number + message: string + } + | undefined { + try { + const parsedError = JSON.parse(error.body).error + return { + /** + * The code should be the same as for user rejected requests because otherwise it will not be displayed. + */ + code: 4001, + message: + "message" in parsedError && parsedError.message + ? parsedError.message[0].toUpperCase() + parsedError.message.slice(1) + : EIP1193_ERROR_CODES.userRejectedRequest.message, + } + } catch (err) { + return undefined + } +} + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +export function handleRPCErrorResponse(error: unknown) { + let response + logger.log("error processing request", error) + if (typeof error === "object" && error !== null) { + /** + * Get error per the RPC method’s specification + */ + if ("eip1193Error" in error) { + const { eip1193Error } = error as { + eip1193Error: EIP1193ErrorPayload + } + if (isEIP1193Error(eip1193Error)) { + response = eip1193Error + } + /** + * In the case of a non-matching error message, the error is returned without being nested in an object. + * This is due to the error handling implementation. + * Check the code for more details https://github.com/ethers-io/ethers.js/blob/master/packages/providers/src.ts/json-rpc-provider.ts#L96:L130 + */ + } else if ("body" in error) { + response = parsedRPCErrorResponse(error as { body: string }) + } else if ("error" in error) { + response = parsedRPCErrorResponse( + (error as { error: { body: string } }).error + ) + } + } + /** + * If no specific error is obtained return a user rejected request error + */ + return ( + response ?? + new EIP1193Error(EIP1193_ERROR_CODES.userRejectedRequest).toJSON() + ) +} diff --git a/background/services/wallet-connect/eip155-request-utils.ts b/background/services/wallet-connect/eip155-request-utils.ts new file mode 100644 index 0000000000..fab7aac667 --- /dev/null +++ b/background/services/wallet-connect/eip155-request-utils.ts @@ -0,0 +1,84 @@ +import { SignClientTypes } from "@walletconnect/types" +import { + EIP1193Error, + EIP1193_ERROR_CODES, +} from "@tallyho/provider-bridge-shared" +import { formatErrorMessage } from "./error" +import { + TranslatedRequestParams, + JsonRpcError, + JsonRpcResult, + ErrorResponse, +} from "./types" + +function formatJsonRpcResult( + id: number, + result: T +): JsonRpcResult { + return { + id, + jsonrpc: "2.0", + result, + } +} + +function formatJsonRpcError( + id: number, + error?: string | ErrorResponse +): JsonRpcError { + return { + id, + jsonrpc: "2.0", + error: formatErrorMessage(error), + } +} + +export function approveEIP155Request( + request: TranslatedRequestParams, + signedMessage: string +): JsonRpcResult { + const { id, method } = request + + switch (method) { + case "eth_sign": + case "personal_sign": + case "eth_signTransaction": + case "eth_sendTransaction": + return formatJsonRpcResult(id, signedMessage) + + default: + throw new Error("UNKNOWN_JSONRPC_METHOD") + } +} + +export function rejectEIP155Request( + request: TranslatedRequestParams +): JsonRpcError { + const { id } = request + + return formatJsonRpcError(id, "JSONRPC_REQUEST_METHOD_REJECTED") +} + +export function processRequestParams( + event: SignClientTypes.EventArguments["session_request"] +): TranslatedRequestParams { + // TODO: figure out if this method is needed + const { id, params: eventParams, topic } = event + // TODO: handle chain id + const { request } = eventParams + + switch (request.method) { + case "eth_sign": + case "personal_sign": + case "eth_sendTransaction": + case "eth_signTransaction": + return { + id, + topic, + method: request.method, + params: request.params, + } + default: + throw new EIP1193Error(EIP1193_ERROR_CODES.unsupportedMethod) + } +} diff --git a/background/services/wallet-connect/error.ts b/background/services/wallet-connect/error.ts new file mode 100644 index 0000000000..f82e6fc6e9 --- /dev/null +++ b/background/services/wallet-connect/error.ts @@ -0,0 +1,70 @@ +/* eslint-disable import/prefer-default-export */ +// source reference: https://github.com/pedrouid/json-rpc-tools/blob/master/packages/utils/src/error.ts + +import { ErrorResponse } from "./types" + +const PARSE_ERROR = "PARSE_ERROR" +const INVALID_REQUEST = "INVALID_REQUEST" +const METHOD_NOT_FOUND = "METHOD_NOT_FOUND" +const INVALID_PARAMS = "INVALID_PARAMS" +const INTERNAL_ERROR = "INTERNAL_ERROR" +const SERVER_ERROR = "SERVER_ERROR" + +const RESERVED_ERROR_CODES = [-32700, -32600, -32601, -32602, -32603] +const SERVER_ERROR_CODE_RANGE = [-32000, -32099] + +const STANDARD_ERROR_MAP: Record = { + [PARSE_ERROR]: { code: -32700, message: "Parse error" }, + [INVALID_REQUEST]: { code: -32600, message: "Invalid Request" }, + [METHOD_NOT_FOUND]: { code: -32601, message: "Method not found" }, + [INVALID_PARAMS]: { code: -32602, message: "Invalid params" }, + [INTERNAL_ERROR]: { code: -32603, message: "Internal error" }, + [SERVER_ERROR]: { code: -32000, message: "Server error" }, +} + +function isServerErrorCode(code: number): boolean { + return ( + code <= SERVER_ERROR_CODE_RANGE[0] && code >= SERVER_ERROR_CODE_RANGE[1] + ) +} + +function isReservedErrorCode(code: number): boolean { + return RESERVED_ERROR_CODES.includes(code) +} + +function getError(type: string): ErrorResponse { + if (!Object.keys(STANDARD_ERROR_MAP).includes(type)) { + return STANDARD_ERROR_MAP[INTERNAL_ERROR] + } + return STANDARD_ERROR_MAP[type] +} + +function getErrorByCode(code: number): ErrorResponse { + const match = Object.values(STANDARD_ERROR_MAP).find((e) => e.code === code) + if (!match) { + return STANDARD_ERROR_MAP[INTERNAL_ERROR] + } + return match +} + +export function formatErrorMessage( + error?: string | ErrorResponse +): ErrorResponse { + let translatedError = error + if (typeof translatedError === "undefined") { + return getError(INTERNAL_ERROR) + } + if (typeof translatedError === "string") { + translatedError = { + ...getError(SERVER_ERROR), + message: translatedError, + } + } + if (isReservedErrorCode(translatedError.code)) { + translatedError = getErrorByCode(translatedError.code) + } + if (!isServerErrorCode(translatedError.code)) { + throw new Error("Error code is not in server code range") + } + return translatedError +} diff --git a/background/services/wallet-connect/index.ts b/background/services/wallet-connect/index.ts index e7ca3adfa4..2687ff8692 100644 --- a/background/services/wallet-connect/index.ts +++ b/background/services/wallet-connect/index.ts @@ -1,6 +1,7 @@ -import SignClient from "@walletconnect/sign-client" -import { parseUri } from "@walletconnect/utils" +import { parseUri, getSdkError } from "@walletconnect/utils" import { SignClientTypes, SessionTypes } from "@walletconnect/types" +import SignClient from "@walletconnect/sign-client" +import { isEIP1193Error } from "@tallyho/provider-bridge-shared" import { ServiceCreatorFunction, ServiceLifecycleEvents } from "../types" @@ -8,6 +9,26 @@ import BaseService from "../base" import PreferenceService from "../preferences" import ProviderBridgeService from "../provider-bridge" import InternalEthereumProviderService from "../internal-ethereum-provider" +import { + approveEIP155Request, + processRequestParams, + rejectEIP155Request, +} from "./eip155-request-utils" + +import createSignClient from "./sign-client-helper" +import { + acknowledgeLegacyProposal, + createLegacySignClient, + LegacyEventData, + LegacyProposal, + postLegacyApprovalResponse, + postLegacyRejectionResponse, + processLegacyRequestParams, + rejectLegacyProposal, +} from "./legacy-sign-client-helper" +import { getMetaPort } from "./utils" +import { TranslatedRequestParams } from "./types" +import ChainService from "../chain" interface Events extends ServiceLifecycleEvents { placeHolderEventForTypingPurposes: string @@ -23,7 +44,16 @@ interface Events extends ServiceLifecycleEvents { * and sanitize the communication properly before it can reach the rest of the codebase. */ export default class WalletConnectService extends BaseService { - signClient: SignClient | undefined + #signClientv2: SignClient | undefined + + private get signClientv2(): SignClient { + if (!this.#signClientv2) { + throw new Error("WalletConnect: SignClient v2 has not initialized") + } + return this.#signClientv2 + } + + senderUrl = "" /* * Create a new WalletConnectService. The service isn't initialized until @@ -35,24 +65,28 @@ export default class WalletConnectService extends BaseService { [ Promise, Promise, - Promise + Promise, + Promise ] > = async ( providerBridgeService, internalEthereumProviderService, - preferenceService + preferenceService, + chainService ) => { return new this( await providerBridgeService, await internalEthereumProviderService, - await preferenceService + await preferenceService, + await chainService ) } private constructor( private providerBridgeService: ProviderBridgeService, private internalEthereumProviderService: InternalEthereumProviderService, - private preferenceService: PreferenceService + private preferenceService: PreferenceService, + private chainService: ChainService ) { super() } @@ -60,7 +94,12 @@ export default class WalletConnectService extends BaseService { protected override async internalStartService(): Promise { await super.internalStartService() - await this.initializeWalletConnect() + this.#signClientv2 = await createSignClient() + this.defineEventHandlers() + + this.providerBridgeService.emitter.on("walletConnectInit", async (wcUri) => + this.performConnection(wcUri) + ) } protected override async internalStopService(): Promise { @@ -70,89 +109,244 @@ export default class WalletConnectService extends BaseService { await super.internalStopService() } - // eslint-disable-next-line class-methods-use-this - private async initializeWalletConnect() { - this.signClient = await WalletConnectService.createSignClient() - this.defineEventHandlers() + private defineEventHandlers(): void { + this.signClientv2.on("session_proposal", (proposal) => + this.sessionProposalListener(false, proposal) + ) - // TODO: remove this, inject uri - // simulate connection attempt - const wcUri = "wc:710f..." - await this.performConnection(wcUri) + this.signClientv2.on("session_request", (event) => + this.sessionRequestListener(false, event) + ) } - private static createSignClient(): Promise { - return SignClient.init({ - logger: "debug", // TODO: set from .env - projectId: "9ab2e13df08600b06ac588e1292d6512", // TODO: set from .env - relayUrl: "wss://relay.walletconnect.com", - metadata: { - // TODO: customize this metadata - name: "Tally Ho Wallet", - description: "WalletConnect for Tally Ho wallet", - url: "https://walletconnect.com/", - icons: ["https://avatars.githubusercontent.com/u/37784886"], - }, + async performConnection(uri: string): Promise { + try { + const { version } = parseUri(uri) + + switch (true) { + // Route the provided URI to the v1 SignClient if URI version indicates it. + case version === 1: + WalletConnectService.tempFeatureLog("legacy pairing", parseUri(uri)) + + createLegacySignClient( + uri, + (payload) => this.sessionProposalListener(true, undefined, payload), + (payload) => this.sessionRequestListener(true, undefined, payload) + ) + break + + case version === 2: + await this.signClientv2.pair({ uri }) + WalletConnectService.tempFeatureLog("pairing request sent") + break + + default: + // TODO: decide how to handle this + WalletConnectService.tempFeatureLog( + "unhandled uri version: ", + version + ) + break + } + } catch (err: unknown) { + WalletConnectService.tempFeatureLog( + "TODO: Error while establishing session", + err + ) + } + } + + private async acknowledgeProposal( + proposal: SignClientTypes.EventArguments["session_proposal"], + selectedAccounts: [string] + ) { + // TODO: in case of a new connection, this callback should perform request processing AFTER wallet selection/confirmation dialog + const { id, params } = proposal + const { requiredNamespaces, relays } = params + + WalletConnectService.tempFeatureLog("proposal", proposal) + WalletConnectService.tempFeatureLog( + "requiredNamespaces", + requiredNamespaces + ) + + const ethNamespaceKey = "eip155" + const ethNamespace = requiredNamespaces[ethNamespaceKey] + if (!ethNamespace) { + await this.rejectProposal(id) + return + } + + const namespaces: SessionTypes.Namespaces = {} + const accounts: string[] = [] + ethNamespace.chains.forEach((chain) => { + selectedAccounts.map((acc) => accounts.push(`${chain}:${acc}`)) }) + namespaces[ethNamespaceKey] = { + accounts, + methods: requiredNamespaces[ethNamespaceKey].methods, + events: requiredNamespaces[ethNamespaceKey].events, + } + + if (relays.length > 0) { + const { acknowledged } = await this.signClientv2.approve({ + id, + relayProtocol: relays[0].protocol, + namespaces, + }) + + await acknowledged() + WalletConnectService.tempFeatureLog("connection acknowledged", namespaces) + } else { + // TODO: how to handle this case? + await this.rejectProposal(id) + } } - private defineEventHandlers(): void { - const address = "0xcd6b1f2080bde01d56023c9b50cd91ff09fefd73" // TODO: remove this, replace with real address - - this.signClient?.on( - "session_proposal", - async (proposal: SignClientTypes.EventArguments["session_proposal"]) => { - // TODO: in case of a new connection, this callback should perform request processing AFTER wallet selection/confirmation dialog - const { id, params } = proposal - const { requiredNamespaces, relays } = params - - // TODO: expand this section to be able to match requiredNamespaces to actual wallet - const key = "eip155" - const accounts = [`eip155:1:${address}`] - const namespaces: SessionTypes.Namespaces = {} - namespaces[key] = { - accounts, - methods: requiredNamespaces[key].methods, - events: requiredNamespaces[key].events, + private async rejectProposal(id: number) { + await this.signClientv2.reject({ + id, + reason: getSdkError("USER_REJECTED_METHODS"), + }) + } + + private async postApprovalResponse( + event: TranslatedRequestParams, + payload: string + ) { + const { topic } = event + const response = approveEIP155Request(event, payload) + await this.signClientv2.respond({ + topic, + response, + }) + } + + private async postRejectionResponse(event: TranslatedRequestParams) { + const { topic } = event + const response = rejectEIP155Request(event) + await this.signClientv2.respond({ + topic, + response, + }) + } + + async sessionRequestListener( + isLegacy: boolean, // TODO: this along with @legacyEvent should be removed when we fully migrate to v2, @event should become non optional + event?: SignClientTypes.EventArguments["session_request"], + legacyEvent?: LegacyEventData + ): Promise { + WalletConnectService.tempFeatureLog("in sessionRequestListener", event) + + let request: TranslatedRequestParams | undefined + + if (isLegacy && legacyEvent) { + request = processLegacyRequestParams(legacyEvent) + } else if (event) { + request = processRequestParams(event) + } + + if (!request) { + return + } + + const port = getMetaPort( + "sessionRequestListenerPort", + this.senderUrl, + async (message) => { + WalletConnectService.tempFeatureLog( + "sessionRequestListenerPort message:", + message + ) + + if (!request) { + return } - if (this.signClient !== undefined && relays.length > 0) { - const { acknowledged } = await this.signClient.approve({ - id, - relayProtocol: relays[0].protocol, - namespaces, - }) - await acknowledged() + if (isEIP1193Error(message.result)) { + if (isLegacy) { + postLegacyRejectionResponse(request) + } else { + await this.postRejectionResponse(request) + } + } else if (isLegacy) { + postLegacyApprovalResponse(request, message.result) } else { - // TODO: how to handle this case? + await this.postApprovalResponse(request, message.result) } } ) - this.signClient?.on( - "session_request", - // eslint-disable-next-line @typescript-eslint/no-unused-vars - (event: SignClientTypes.EventArguments["session_request"]) => {} - ) + await this.providerBridgeService.onMessageListener(port, { + id: "1400", + request, + }) } - async performConnection(uri: string): Promise { - if (this.signClient === undefined) { - return + async sessionProposalListener( + isLegacy: boolean, + proposal?: SignClientTypes.EventArguments["session_proposal"], + legacyProposal?: LegacyProposal + ): Promise { + WalletConnectService.tempFeatureLog( + "in sessionProposalListener", + proposal ?? legacyProposal + ) + + let favicon = "" + let dAppName = "" + + if (isLegacy && legacyProposal) { + const { params } = legacyProposal + if (Array.isArray(params) && params.length > 0) { + this.senderUrl = params[0].peerMeta?.url || "" + favicon = params[0].peerMeta.icons?.[0] ?? "" + dAppName = params[0].peerMeta.name ?? "" + } + } else if (proposal) { + const { params } = proposal + this.senderUrl = params.proposer.metadata.url // we can also extract information such as icons and description + favicon = params.proposer.metadata.icons?.[0] ?? "" + dAppName = params.proposer.metadata.name ?? "" } - try { - const { version } = parseUri(uri) + if (!this.senderUrl) { + return + } - // Route the provided URI to the v1 SignClient if URI version indicates it, else use v2. - if (version === 1) { - // createLegacySignClient({ uri }) - } else if (version === 2) { - await this.signClient.pair({ uri }) - } else { - // TODO: decide how to handle this + const port = getMetaPort( + "sessionProposalListenerPort", + this.senderUrl, + async (message) => { + if (Array.isArray(message.result) && message.result.length > 0) { + if (isLegacy && legacyProposal) { + acknowledgeLegacyProposal(legacyProposal, message.result) + } else if (proposal) { + await this.acknowledgeProposal(proposal, message.result) + } + WalletConnectService.tempFeatureLog("pairing request acknowledged") + } else if (isEIP1193Error(message.result)) { + if (isLegacy) { + rejectLegacyProposal() + } else if (proposal) { + await this.rejectProposal(proposal?.id) + } + } } - // eslint-disable-next-line no-empty - } catch (err: unknown) {} + ) + + await this.providerBridgeService.onMessageListener(port, { + id: "1300", + request: { + method: "eth_requestAccounts", + params: [dAppName, favicon], + }, + }) + } + + /* eslint-disable */ + private static tempFeatureLog(message?: any, ...optionalParams: any[]): void { + console.log(`[WalletConnect Demo] - ${message || ""}`, ...optionalParams) } + /* eslint-enable */ } diff --git a/background/services/wallet-connect/legacy-sign-client-helper.ts b/background/services/wallet-connect/legacy-sign-client-helper.ts new file mode 100644 index 0000000000..48da552a8b --- /dev/null +++ b/background/services/wallet-connect/legacy-sign-client-helper.ts @@ -0,0 +1,157 @@ +import { IClientMeta, IWalletConnectSession } from "@walletconnect/legacy-types" +import LegacySignClient from "@walletconnect/client" + +import { getSdkError } from "@walletconnect/utils" +import { TranslatedRequestParams } from "./types" +import { + approveEIP155Request, + rejectEIP155Request, +} from "./eip155-request-utils" + +export type LegacyProposal = { + id: number + params: [{ chainId: number; peerId: string; peerMeta: IClientMeta }] +} + +export type LegacyEventData = { + id: number + topic: string + method: string + params: unknown[] +} + +type SessionProposalListener = (payload: LegacyProposal) => void +type SessionRequestListener = (payload: LegacyEventData) => void + +let legacySignClient: LegacySignClient | undefined + +/* eslint-disable */ +function tempFeatureLog(message?: any, ...optionalParams: any[]): void { + console.log(`[WalletConnect Demo V1] - ${message || ""}`, optionalParams) +} +/* eslint-enable */ + +function deleteCachedLegacySession(): void { + if (typeof window === "undefined") return + window.localStorage.removeItem("walletconnect") +} + +function getCachedLegacySession(): IWalletConnectSession | null { + if (typeof window === "undefined") return null + + const local = window.localStorage + ? window.localStorage.getItem("walletconnect") + : null + + let session = null + if (local) { + session = JSON.parse(local) + } + return session +} + +export function createLegacySignClient( + uri?: string, + sessionProposalListener?: SessionProposalListener, + sessionRequestListener?: SessionRequestListener +): void { + // If URI is passed always create a new session, + // otherwise fall back to cached session if client isn't already instantiated. + if (uri) { + deleteCachedLegacySession() + legacySignClient = new LegacySignClient({ uri }) + } else if (!legacySignClient && getCachedLegacySession()) { + const session = getCachedLegacySession() + if (session != null) { + legacySignClient = new LegacySignClient({ session }) + } + } else { + return + } + + legacySignClient?.on("session_request", (error, payload) => { + if (error) { + throw new Error(`legacySignClient > session_request failed: ${error}`) + } + tempFeatureLog("LegacySessionProposalModal", { legacyProposal: payload }) + sessionProposalListener?.(payload) + }) + + legacySignClient?.on("connect", () => { + tempFeatureLog("legacySignClient > connect") + }) + + legacySignClient?.on("error", (error) => { + throw new Error(`legacySignClient > on error: ${error}`) + }) + + legacySignClient?.on("call_request", (error, payload) => { + if (error) { + throw new Error(`legacySignClient > call_request failed: ${error}`) + } + // onCallRequest(payload) + sessionRequestListener?.(payload) + }) + + legacySignClient?.on("disconnect", async () => { + deleteCachedLegacySession() + }) +} + +export function acknowledgeLegacyProposal( + proposal: LegacyProposal, + accounts: string[] +): void { + const { params } = proposal + const [{ chainId }] = params + + legacySignClient?.approveSession({ + accounts, + chainId: chainId ?? 1, + }) +} + +export function rejectLegacyProposal(): void { + legacySignClient?.rejectSession(getSdkError("USER_REJECTED_METHODS")) +} + +export function processLegacyRequestParams( + payload: LegacyEventData +): TranslatedRequestParams | undefined { + // TODO: figure out if this method is needed + const { method } = payload + // TODO: handle chain id + + switch (method) { + case "eth_signTypedData": + case "personal_sign": + case "eth_sendTransaction": + case "eth_signTransaction": + return payload + default: + return undefined + } +} + +export async function postLegacyApprovalResponse( + event: TranslatedRequestParams, + payload: string +): Promise { + const { id } = event + const { result } = approveEIP155Request(event, payload) + legacySignClient?.approveRequest({ + id, + result, + }) +} + +export async function postLegacyRejectionResponse( + event: TranslatedRequestParams +): Promise { + const { id } = event + const { error } = rejectEIP155Request(event) + legacySignClient?.rejectRequest({ + id, + error, + }) +} diff --git a/background/services/wallet-connect/sign-client-helper.ts b/background/services/wallet-connect/sign-client-helper.ts new file mode 100644 index 0000000000..32a7856a5b --- /dev/null +++ b/background/services/wallet-connect/sign-client-helper.ts @@ -0,0 +1,16 @@ +import SignClient from "@walletconnect/sign-client" + +export default function createSignClient(): Promise { + return SignClient.init({ + logger: "debug", // TODO: set from .env + projectId: "9ab2e13df08600b06ac588e1292d6512", // TODO: set from .env + relayUrl: "wss://relay.walletconnect.com", + metadata: { + // TODO: customize this metadata + name: "Tally Ho Wallet", + description: "WalletConnect for Tally Ho wallet", + url: "https://walletconnect.com/", + icons: ["https://avatars.githubusercontent.com/u/37784886"], + }, + }) +} diff --git a/background/services/wallet-connect/types.ts b/background/services/wallet-connect/types.ts new file mode 100644 index 0000000000..9beccf5e2b --- /dev/null +++ b/background/services/wallet-connect/types.ts @@ -0,0 +1,25 @@ +import { RPCRequest } from "@tallyho/provider-bridge-shared" + +export interface TranslatedRequestParams { + id: number + topic: string + method: string + params: RPCRequest["params"] +} + +export interface ErrorResponse { + code: number + message: string + data?: string +} +export interface JsonRpcError { + id: number + jsonrpc: string + error: ErrorResponse +} + +export interface JsonRpcResult { + id: number + jsonrpc: string + result: T +} diff --git a/background/services/wallet-connect/utils.ts b/background/services/wallet-connect/utils.ts new file mode 100644 index 0000000000..c5a394c8b7 --- /dev/null +++ b/background/services/wallet-connect/utils.ts @@ -0,0 +1,17 @@ +import browser from "webextension-polyfill" + +/* eslint-disable import/prefer-default-export */ +export const getMetaPort = ( + name: string, + senderUrl: string, + postMessage: (message: any) => void +): Required => { + const port: browser.Runtime.Port = browser.runtime.connect({ + name, + }) + port.sender = { + url: senderUrl, + } + port.postMessage = postMessage + return port as unknown as Required +} diff --git a/background/tests/factories.ts b/background/tests/factories.ts index cbe3d67663..5f51250583 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, + AnyAssetAmount, flipPricePoint, isFungibleAsset, PricePoint, @@ -41,6 +42,7 @@ import { LedgerService, NameService, PreferenceService, + ProviderBridgeService, SigningService, } from "../services" import { @@ -111,6 +113,16 @@ type CreateSigningServiceOverrides = { chainService?: Promise } +type CreateProviderBridgeServiceOverrides = { + internalEthereumProviderService?: Promise + preferenceService?: Promise +} + +type CreateInternalEthereumProviderServiceOverrides = { + chainService?: Promise + preferenceService?: Promise +} + export async function createAnalyticsService(overrides?: { chainService?: Promise preferenceService?: Promise @@ -134,10 +146,7 @@ export const createSigningService = async ( } export const createInternalEthereumProviderService = async ( - overrides: { - chainService?: Promise - preferenceService?: Promise - } = {} + overrides: CreateInternalEthereumProviderServiceOverrides = {} ): Promise => { return InternalEthereumProviderService.create( overrides.chainService ?? createChainService(), @@ -145,6 +154,18 @@ export const createInternalEthereumProviderService = async ( ) } +export const createProviderBridgeService = async ( + overrides: CreateProviderBridgeServiceOverrides = {} +): Promise => { + const preferenceService = + overrides?.preferenceService ?? createPreferenceService() + return ProviderBridgeService.create( + overrides.internalEthereumProviderService ?? + createInternalEthereumProviderService({ preferenceService }), + preferenceService + ) +} + // Copied from a legacy Optimism transaction generated with our test wallet. export const createLegacyTransactionRequest = ( overrides: Partial = {} @@ -328,30 +349,30 @@ export const makeEthersFeeData = (overrides?: Partial): FeeData => { } } -export const makeSerialFallbackProvider = - (): Partial => { - class MockSerialFallbackProvider { - async getBlock() { - return makeEthersBlock() - } +export class MockSerialFallbackProvider { + async getBlock(): Promise { + return makeEthersBlock() + } - async getBlockNumber() { - return 1 - } + async getBlockNumber(): Promise { + return 1 + } - async getBalance() { - return BigNumber.from(100) - } + async getBalance(): Promise { + return BigNumber.from(100) + } - async getFeeData() { - return makeEthersFeeData() - } + async getFeeData(): Promise { + return makeEthersFeeData() + } - async getCode() { - return "false" - } - } + async getCode(): Promise { + return "false" + } +} +export const makeSerialFallbackProvider = + (): Partial => { return new MockSerialFallbackProvider() } @@ -368,7 +389,7 @@ const getRandomStr = (length: number) => { export const createSmartContractAsset = ( overrides: Partial = {} ): SmartContractFungibleAsset => { - const symbol = getRandomStr(3) + const symbol = overrides.symbol ?? getRandomStr(3) const asset = { metadata: { logoURL: @@ -418,6 +439,16 @@ export const createNetworkBaseAsset = ( } } +export const createAssetAmount = ( + asset: AnyAsset = ETH, + amount = 1 +): AnyAssetAmount => { + return { + asset, + amount: BigInt(Math.trunc(1e10 * amount)) * 10n ** 8n, + } +} + /** * @param asset Any type of asset * @param price Price, e.g. 1.5 => 1.5$ diff --git a/build-utils/inject-window-provider.ts b/build-utils/inject-window-provider.ts index dc761d6d84..b7f20165f1 100644 --- a/build-utils/inject-window-provider.ts +++ b/build-utils/inject-window-provider.ts @@ -19,6 +19,16 @@ export default class InjectWindowProvider { let windowProviderSource = assets[WINDOW_PROVIDER_FILENAME].source().toString() + // Insert assets for injected UI + const fontAsB64 = assets["fonts/segment-medium.woff2"] + .source() + .toString("base64") + + windowProviderSource = windowProviderSource.replace( + `@@@SEGMENT_MEDIUM_BASE64@@@`, + fontAsB64 + ) + // need to encode so it can be used as a string // in non optimised builds the source is a multi line string > `` needs to be used // but ${ needs to be escaped separatly otherwise it breaks the `` diff --git a/e2e-tests/dapp-connect.spec.ts b/e2e-tests/dapp-connect.spec.ts index 6098d83495..173d3eb36d 100644 --- a/e2e-tests/dapp-connect.spec.ts +++ b/e2e-tests/dapp-connect.spec.ts @@ -20,13 +20,16 @@ tallyHoTest("dapp connect", async ({ page, context, extensionId }) => { await page.locator("button", { hasText: "Import account" }).click() const dappPage = await context.newPage() - await dappPage.goto("https://cowswap.app/") - await dappPage.locator("text=Connect").click() + await dappPage.goto("https://swap.cow.fi/") + await dappPage + .locator("#swap-button") + .getByRole("button", { name: "Connect Wallet" }) + .click() // Get page after a specific action (e.g. clicking a link) const [popupPage] = await Promise.all([ context.waitForEvent("page"), - await dappPage.locator("text=Metamask").click(), // Opens a new tab + await dappPage.locator("text=Injected").click(), // Opens a new tab ]) await popupPage.waitForLoadState() @@ -38,6 +41,6 @@ tallyHoTest("dapp connect", async ({ page, context, extensionId }) => { // The timeouts are here only to pause and show that we are connected/disconnected and can be removed await page.waitForTimeout(2000) - await page.locator('xpath=//li[contains(., "CowSwap")]//button').click() + await page.locator('xpath=//li[contains(., "CoW Swap")]//button').click() await page.waitForTimeout(2000) }) diff --git a/env.d.ts b/env.d.ts index 67c7e82ef8..8188315307 100644 --- a/env.d.ts +++ b/env.d.ts @@ -38,6 +38,7 @@ type WalletProvider = { type TallyProvider = WalletProvider & { isTally: true + send: (method: string, params: unknown[]) => Promise } type WindowEthereum = WalletProvider & { diff --git a/manifest/manifest.json b/manifest/manifest.json index 089d6de4d1..a9d839c0bf 100644 --- a/manifest/manifest.json +++ b/manifest/manifest.json @@ -1,6 +1,6 @@ { "name": "Tally Ho", - "version": "0.18.9", + "version": "0.21.2", "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 955ced22a2..93c3079ff2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@tallyho/tally-extension", "private": true, - "version": "0.18.9", + "version": "0.21.2", "description": "Tally Ho, the community owned and operated Web3 wallet.", "main": "index.js", "repository": "git@github.com:thesis/tally-extension.git", diff --git a/provider-bridge/index.ts b/provider-bridge/index.ts index 8997d10714..475b657c03 100644 --- a/provider-bridge/index.ts +++ b/provider-bridge/index.ts @@ -18,7 +18,10 @@ export function connectProviderBridge(): void { event.data.target === PROVIDER_BRIDGE_TARGET ) { // if dapp wants to connect let's grab its details - if (event.data.request.method === "eth_requestAccounts") { + if ( + event.data.request.method === "eth_requestAccounts" || + event.data.request.method === "eth_accounts" + ) { const faviconElements: NodeListOf = window.document.querySelectorAll("link[rel*='icon']") const largestFavicon = [...faviconElements].sort((el) => diff --git a/ui/_locales/en/messages.json b/ui/_locales/en/messages.json index 9d4e2d63aa..938024dc6e 100644 --- a/ui/_locales/en/messages.json +++ b/ui/_locales/en/messages.json @@ -261,7 +261,10 @@ "description": "Description", "itemsCount": "Items in collection", "creator": "Creator", - "properties": "Properties" + "properties": "Properties", + "refresh": "Refresh metadata", + "rank": "Rank", + "rarityRank": "Rarity rank" }, "units": { "nft_one": "NFT", @@ -466,8 +469,8 @@ "beta": "Mainnet (beta)", "testnet": "Test Network", "l2": "L2 scaling solution", - "compatibleChain": "Ethereum-compatible blockchain (beta)", - "avalanche": "Mainnet C-Chain (beta)", + "compatibleChain": "Ethereum-compatible blockchain", + "avalanche": "Mainnet C-Chain", "connected": "Connected" }, "readOnly": "Read-only", @@ -535,7 +538,7 @@ "title": "Custom networks", "ariaLabel": "Open custom networks page", "chainList": { - "description": "Fastest and safest way of adding a custom network is by visiting , connecting your wallet and adding the chains you wish to see.", + "description": "Fastest and safest way of adding a custom network is by visiting , connecting your wallet and adding the chains you wish to see.", "addBtn": "Add chains with Chainlist" }, "customRPC": { @@ -690,7 +693,9 @@ "step1": "Set Tally Ho as default", "step2": "Click “connect to wallet” in a dapp", "step3": "Select one similar to these" - } + }, + "walletConnectInfo": "You can now connect to any dapp that supports Wallet Connect by selecting Tally Ho from desktop tab. Learn more", + "walletConnectHint": "Check the desktop tab in the connection modal" } }, "wallet": { @@ -719,7 +724,7 @@ "assetImported": "Asset imported from transaction history", "name": "Name", "contract": "Contract address", - "close": "Close" + "close": "Close" }, "banner": { "bannerTitle": "New Odyssey week!", @@ -778,8 +783,59 @@ "banner": { "description": "Daylight tells you when you qualify for an airdrop, mint or unlock.", "seeAbilities": "See your abilities", - "new": "New", + "open": "Open", "none": "None" + }, + "timeStarting": "Starting in 1 month", + "timeCloses": "Closes in 1 month", + "viewWebsiteBtn": "Visit website", + "markBtn": "Mark as Completed", + "snackbar": "Marked as completed", + "deleteBtn": "Delete", + "deleteSlideUpMenu": { + "title": "Delete ability?", + "desc": "If you delete an ability you won't be able to see it anymore", + "spamPrompt": "Is this ability spam?", + "selectSpamReason": "Select reason for reporting", + "spamReason": { + "spam": "Spam", + "inaccurateInfo": "Inaccurate information", + "copyright": "Copyright violation", + "scam": "Scam", + "duplicate": "Duplicate" + }, + "reportSpamBtn": "Yes, report spam", + "submitBtn": "Yes, delete", + "submitSpamBtn": "Yes, report & delete", + "snackbar": "Ability deleted" + }, + "filter": { + "title": "Filter Abilities", + "tooltip": "Filter", + "abilityState": { + "open": "Open", + "completed": "Completed", + "expired": "Expired", + "deleted": "Deleted", + "all": "All" + }, + "abilityTypeDesc": { + "vote": "A proposal that someone can vote on based on a token they hold.", + "claim": "On-chain reward that someone can claim that is not a mint or airdrop.", + "airdrop": "A ERC-20 token airdrop, usually not promised in advance.", + "mint": "A ERC-721 or ERC-11555 token mint.", + "access": "A general off-chain token-gated experience, often a group chat.", + "product": "An off-chain product, often a discounted or free item available on an ecommerce store.", + "event": "An event, often at a conference or a token-gated online experience.", + "article": "A blog post or article about a specific token.", + "result": "The result of a vote proposal.", + "misc": "An ability that does not fall into any of these types." + }, + "abilityStateTitle": "Show", + "abilitiesTypesTitle": "Notify me about abilities type", + "accountsTitle": "Show/hide accounts", + "accountsReadOnlyInfo": "Read-only accounts can’t see abilities. If you can, we recommend you import the address you want to see abilities for.", + "noAccounts": "No accounts" } }, "globalError": { @@ -804,12 +860,10 @@ "ENABLE_ACHIEVEMENTS_TAB": "Show an Achievements tab on the overview page", "HIDE_TOKEN_FEATURES": "Hide token features", "SUPPORT_ACHIEVEMENTS_BANNER": "Enable achievements banner", - "SUPPORT_AVALANCHE": "Enable Avalanche network", "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" } @@ -823,4 +877,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 a2d9486e1b..29cd1ecad6 100644 --- a/ui/components/AccountsNotificationPanel/AccountsNotificationPanelAccounts.tsx +++ b/ui/components/AccountsNotificationPanel/AccountsNotificationPanelAccounts.tsx @@ -11,6 +11,7 @@ import { updateSignerTitle, } from "@tallyho/tally-background/redux-slices/ui" import { deriveAddress } from "@tallyho/tally-background/redux-slices/keyrings" +import { ROOTSTOCK } from "@tallyho/tally-background/constants" import { AccountTotal, selectCurrentNetworkAccountTotalsByCategory, @@ -77,12 +78,14 @@ function WalletTypeHeader({ accountType, onClickAddAddress, walletNumber, + path, accountSigner, }: { accountType: AccountType onClickAddAddress?: () => void accountSigner: AccountSigner walletNumber?: number + path?: string | null }) { const { t } = useTranslation() const { title, icon } = walletTypeDetails[accountType] @@ -103,10 +106,13 @@ function WalletTypeHeader({ const sectionTitle = useMemo(() => { if (accountType === AccountType.ReadOnly) return title - if (sectionCustomName) return sectionCustomName + let networkName = "" // Only for Rootstock + if (path === ROOTSTOCK.derivationPath) networkName = `(${ROOTSTOCK.name})` - return `${title} ${walletNumber}` - }, [accountType, title, sectionCustomName, walletNumber]) + if (sectionCustomName) return `${sectionCustomName} ${networkName}` + + return `${title} ${walletNumber} ${networkName}` + }, [accountType, title, sectionCustomName, walletNumber, path]) const history = useHistory() const areKeyringsUnlocked = useAreKeyringsUnlocked(false) @@ -339,6 +345,7 @@ export default function AccountsNotificationPanelAccounts({ string + getNFTLink: (nft: NFTCached) => string } export const MARKET_LINK: Record = { @@ -33,13 +33,10 @@ export const MARKET_LINK: Record = { color: "#409FFF", hoverColor: "#A8D4FF", icon: "opensea.png", - getNFTLink: (nft: NFT): string => + getNFTLink: (nft: NFTCached): string => `https://opensea.io/assets/${ CHAIN_ID_TO_OPENSEA_CHAIN[ - parseInt( - nft.network.chainID, - 10 - ) as keyof typeof CHAIN_ID_TO_OPENSEA_CHAIN + parseInt(nft.chainID, 10) as keyof typeof CHAIN_ID_TO_OPENSEA_CHAIN ] }/${nft.contract}/${nft.tokenId}`, }, @@ -49,7 +46,7 @@ export const MARKET_LINK: Record = { color: "#2DE370", hoverColor: "#B3F5CB", icon: "looksrare.png", - getNFTLink: (nft: NFT): string => + getNFTLink: (nft: NFTCached): string => `https://looksrare.org/collections/${nft.contract}/${nft.tokenId}`, }, galxe: { @@ -58,7 +55,7 @@ export const MARKET_LINK: Record = { color: "#D6EAE9", hoverColor: "#ffffff", icon: "galxe.svg", - getNFTLink: (nft: NFT): string => + getNFTLink: (nft: NFTCached): string => `https://galxe.com/nft/${nft.tokenId}/${nft.contract}`, }, poap: { @@ -68,19 +65,19 @@ export const MARKET_LINK: Record = { hoverColor: "#E8E5FF", icon: "poap.png", hoverIcon: "poap_white.png", - getNFTLink: (nft: NFT): string => + getNFTLink: (nft: NFTCached): string => `https://app.poap.xyz/token/${nft.tokenId}`, }, } -export function getRelevantMarketsList(nft: NFT): MarketDetails[] { +export function getRelevantMarketsList(nft: NFTCached): MarketDetails[] { if (nft.contract === POAP_CONTRACT) { return [MARKET_LINK.poap] } if (nft.isBadge) { return [MARKET_LINK.galxe] } - if (nft.network.chainID !== ETHEREUM.chainID) { + if (nft.chainID !== ETHEREUM.chainID) { return [MARKET_LINK.opensea] } return [MARKET_LINK.opensea, MARKET_LINK.looksrare] diff --git a/ui/components/NFTS_update/Filters/FilterList.tsx b/ui/components/NFTS_update/Filters/FilterList.tsx index 32d5880786..396a37beaa 100644 --- a/ui/components/NFTS_update/Filters/FilterList.tsx +++ b/ui/components/NFTS_update/Filters/FilterList.tsx @@ -1,7 +1,7 @@ import { Filter } from "@tallyho/tally-background/redux-slices/nfts_update" import React from "react" import SharedSkeletonLoader from "../../Shared/SharedSkeletonLoader" -import FilterListItem from "./FilterListItem" +import SharedToggleItem from "../../Shared/SharedToggleItem" const HEIGHT = 40 @@ -30,7 +30,7 @@ const FilterList = React.forwardRef( isLoaded={isLoaded} height={HEIGHT} > - void }): ReactElement { const { collection, openPreview, isExpanded, setExpandedID } = props - const { id, owner, network, nfts, nftCount, hasNextPage } = collection + const { id, owner, chainID, nfts, nftCount, hasNextPage } = collection const dispatch = useBackgroundDispatch() + const network = NETWORK_BY_CHAIN_ID[chainID] const [isLoading, setIsLoading] = useState(false) // initial update of collection const [isUpdating, setIsUpdating] = useState(false) // update on already loaded collection @@ -108,7 +110,7 @@ export default function NFTCollection(props: { const toggleCollection = () => isExpanded ? setExpandedID(null, null) : setExpandedID(id, owner) - const onItemClick = (nft: NFT) => openPreview({ nft, collection }) + const onItemClick = (nft: NFTCached) => openPreview({ nft, collection }) return ( <> diff --git a/ui/components/NFTS_update/NFTItem.tsx b/ui/components/NFTS_update/NFTItem.tsx index f2e1363c86..17be571d50 100644 --- a/ui/components/NFTS_update/NFTItem.tsx +++ b/ui/components/NFTS_update/NFTItem.tsx @@ -1,19 +1,24 @@ import React, { ReactElement, useState } from "react" -import { NFT } from "@tallyho/tally-background/nfts" -import { NFTCollectionCached } from "@tallyho/tally-background/redux-slices/nfts_update" +import { + NFTCached, + NFTCollectionCached, +} from "@tallyho/tally-background/redux-slices/nfts_update" +import { NETWORK_BY_CHAIN_ID } from "@tallyho/tally-background/constants" import NFTImage from "./NFTImage" import NFTHover from "./NFTHover" import SharedNetworkIcon from "../Shared/SharedNetworkIcon" -export default function NFTItem(props: { +export default function NFTItem< + T extends NFTCached | NFTCollectionCached +>(props: { item: T isCollection?: boolean isExpanded?: boolean onClick: (value: T) => void }): ReactElement { const { onClick, isCollection = false, isExpanded = false, item } = props - const { name = "No title", network, thumbnailURL } = item - + const { name = "No title", chainID, thumbnailURL } = item + const network = NETWORK_BY_CHAIN_ID[chainID] const floorPrice = "floorPrice" in item && item.floorPrice?.value !== undefined && diff --git a/ui/components/NFTS_update/NFTPreview.tsx b/ui/components/NFTS_update/NFTPreview.tsx index cc8d5f566e..3508e26491 100644 --- a/ui/components/NFTS_update/NFTPreview.tsx +++ b/ui/components/NFTS_update/NFTPreview.tsx @@ -1,14 +1,22 @@ +import { NETWORK_BY_CHAIN_ID } from "@tallyho/tally-background/constants" import { FeatureFlags, isEnabled } from "@tallyho/tally-background/features" import { - isProbablyEVMAddress, - truncateAddress, -} from "@tallyho/tally-background/lib/utils" -import { NFTWithCollection } from "@tallyho/tally-background/redux-slices/nfts_update" -import React, { ReactElement, useMemo } from "react" + refetchNFTsFromCollection, + NFTWithCollection, +} from "@tallyho/tally-background/redux-slices/nfts_update" +import { getAccountNameOnChain } from "@tallyho/tally-background/redux-slices/selectors" +import React, { ReactElement, useCallback, useMemo } from "react" import { useTranslation } from "react-i18next" -import { useIntersectionObserver } from "../../hooks" +import { + useBackgroundDispatch, + useBackgroundSelector, + useIntersectionObserver, +} from "../../hooks" +import { trimWithEllipsis } from "../../utils/textUtils" +import SharedAddress from "../Shared/SharedAddress" import SharedButton from "../Shared/SharedButton" import SharedNetworkIcon from "../Shared/SharedNetworkIcon" +import SharedTooltip from "../Shared/SharedTooltip" import ExploreMarketLink, { getRelevantMarketsList } from "./ExploreMarketLink" import NFTImage from "./NFTImage" @@ -19,13 +27,11 @@ const removeMarkdownLinks = (description: string) => { return description.replace(LINK_REGEX, "$1") } -const trimDescription = (description: string) => - description && description.length > MAX_DESCRIPTION_LENGTH - ? `${description.slice(0, MAX_DESCRIPTION_LENGTH)}...` - : description - const parseDescription = (description = "") => { - return trimDescription(removeMarkdownLinks(description)) + return trimWithEllipsis( + removeMarkdownLinks(description), + MAX_DESCRIPTION_LENGTH + ) } export default function NFTPreview(props: NFTWithCollection): ReactElement { @@ -35,18 +41,27 @@ export default function NFTPreview(props: NFTWithCollection): ReactElement { previewURL, contract, name, - network, + chainID, owner, description, attributes, + supply, isBadge, + rarityRank, } = nft - const { totalNftCount } = collection + const { totalNftCount, id: collectionID } = collection + const network = NETWORK_BY_CHAIN_ID[chainID] const floorPrice = "floorPrice" in collection && collection.floorPrice?.value !== undefined && collection.floorPrice + const dispatch = useBackgroundDispatch() + + const ownerName = useBackgroundSelector((state) => + getAccountNameOnChain(state, { address: owner, network }) + ) + // Chrome seems to have problems when elements with backdrop style are rendered initially // out of the viewport - browser is not rendering them at all. This is a workaround // to force them to rerender. @@ -66,6 +81,20 @@ export default function NFTPreview(props: NFTWithCollection): ReactElement { keyPrefix: "nfts", }) + const refetchNFTs = useCallback( + () => + dispatch( + refetchNFTsFromCollection({ + collectionID, + account: { address: owner, network }, + }) + ), + [collectionID, owner, network, dispatch] + ) + + const localizedTotalCount = (totalNftCount ?? supply)?.toLocaleString() + const localizedRarityRank = rarityRank?.toLocaleString() + return ( <>
@@ -88,10 +117,10 @@ export default function NFTPreview(props: NFTWithCollection): ReactElement { {t("preview.owner")} - {truncateAddress(owner)} +
-
+
{t("preview.floorPrice")} @@ -105,16 +134,41 @@ export default function NFTPreview(props: NFTWithCollection): ReactElement {
-

{name || t("noTitle")}

- {isEnabled(FeatureFlags.SUPPORT_NFT_SEND) && ( - - {t("preview.send")} - +
+

{name || t("noTitle")}

+ {isEnabled(FeatureFlags.SUPPORT_NFT_SEND) && ( + + {t("preview.send")} + + )} +
+ + {rarityRank !== null && ( +
+ ( + + {t("preview.rank")}: + + {" "} + {localizedRarityRank} + + + )} + > +
+ {t("preview.rarityRank")}: {localizedRarityRank} /{" "} + {localizedTotalCount} +
+
+
)}
@@ -154,21 +208,33 @@ export default function NFTPreview(props: NFTWithCollection): ReactElement {
{t("preview.itemsCount")}
-

{totalNftCount ?? "-"}

+

{localizedTotalCount ?? "-"}

{t("preview.creator")}
-

- {isProbablyEVMAddress(contract) ? truncateAddress(contract) : "-"} -

+ {contract?.length ? ( + + ) : ( + "-" + )}
- {!!attributes.length && ( -
-
- {t("preview.properties")} -
+
+
+ {t("preview.properties")} +
+ + {t("preview.refresh")} + + {!!attributes.length && (
{attributes.map( ({ trait, value }) => @@ -188,8 +254,8 @@ export default function NFTPreview(props: NFTWithCollection): ReactElement { ) )}
-
- )} + )} +
) diff --git a/ui/components/NetworkFees/FeeSettingsText.tsx b/ui/components/NetworkFees/FeeSettingsText.tsx index 4880071c05..ef2e4425c7 100644 --- a/ui/components/NetworkFees/FeeSettingsText.tsx +++ b/ui/components/NetworkFees/FeeSettingsText.tsx @@ -135,7 +135,7 @@ export default function FeeSettingsText({ networkSettings = customNetworkSetting ?? networkSettings const baseFeePerGas = useBackgroundSelector((state) => { - return state.networks.evm[currentNetwork.chainID].baseFeePerGas + return state.networks.blockInfo[currentNetwork.chainID].baseFeePerGas }) ?? networkSettings.values?.baseFeePerGas ?? 0n diff --git a/ui/components/NetworkFees/NetworkSettingsSelectArbitrum.tsx b/ui/components/NetworkFees/NetworkSettingsSelectArbitrum.tsx index ae704f4e06..5757b4fdf2 100644 --- a/ui/components/NetworkFees/NetworkSettingsSelectArbitrum.tsx +++ b/ui/components/NetworkFees/NetworkSettingsSelectArbitrum.tsx @@ -12,7 +12,7 @@ import { PricePoint } from "@tallyho/tally-background/assets" import { enrichAssetAmountWithMainCurrencyValues } from "@tallyho/tally-background/redux-slices/utils/asset-utils" import { SharedTypedInput } from "../Shared/SharedInput" import { useBackgroundSelector } from "../../hooks" -import capitalize from "../../utils/capitalize" +import { capitalize } from "../../utils/textUtils" import SharedButton from "../Shared/SharedButton" interface NetworkSettingsSelectProps { diff --git a/ui/components/NetworkFees/NetworkSettingsSelectOptionButtons.tsx b/ui/components/NetworkFees/NetworkSettingsSelectOptionButtons.tsx index 39fef82c50..689506bf38 100644 --- a/ui/components/NetworkFees/NetworkSettingsSelectOptionButtons.tsx +++ b/ui/components/NetworkFees/NetworkSettingsSelectOptionButtons.tsx @@ -151,7 +151,7 @@ export function NetworkSettingsSelectOptionButtonCustom({ const [warningMessage, setWarningMessage] = useState("") const selectedNetwork = useBackgroundSelector(selectCurrentNetwork) const baseGasFee = useBackgroundSelector( - (state) => state.networks.evm[selectedNetwork.chainID].baseFeePerGas + (state) => state.networks.blockInfo[selectedNetwork.chainID].baseFeePerGas ) return ( diff --git a/ui/components/Overview/AbilitiesHeader.tsx b/ui/components/Overview/AbilitiesHeader.tsx index 7e95430f79..6610094e18 100644 --- a/ui/components/Overview/AbilitiesHeader.tsx +++ b/ui/components/Overview/AbilitiesHeader.tsx @@ -1,7 +1,7 @@ import { toggleHideDescription } from "@tallyho/tally-background/redux-slices/abilities" import { - selectAbilityCount, - selectHideDescription, + selectDescriptionHidden, + selectOpenAbilityCount, } from "@tallyho/tally-background/redux-slices/selectors" import classNames from "classnames" import React, { ReactElement } from "react" @@ -15,13 +15,15 @@ export default function AbilitiesHeader(): ReactElement { const { t } = useTranslation("translation", { keyPrefix: "abilities", }) - const newAbilities = useSelector(selectAbilityCount) - const hideDescription = useSelector(selectHideDescription) + const openAbilities = useSelector(selectOpenAbilityCount) + const hideDescription = useSelector(selectDescriptionHidden) const dispatch = useBackgroundDispatch() const history = useHistory() const abilityCount = - newAbilities > 0 ? `${newAbilities} ${t("banner.new")}` : t("banner.none") + openAbilities > 0 + ? `${openAbilities} ${t("banner.open")}` + : t("banner.none") const handleClick = () => { if (!hideDescription) { @@ -30,11 +32,22 @@ export default function AbilitiesHeader(): ReactElement { history.push("abilities") } + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleClick() + } + } + return (
@@ -47,19 +60,7 @@ export default function AbilitiesHeader(): ReactElement { {t("header")}
-
handleClick()} - onKeyDown={(e) => { - if (e.key === "Enter") { - handleClick() - } - }} - > - {abilityCount} -
+
{abilityCount}
{!hideDescription && (
@@ -109,7 +110,9 @@ export default function AbilitiesHeader(): ReactElement { box-shadow: 0px 16px 16px rgba(7, 17, 17, 0.3), 0px 6px 8px rgba(7, 17, 17, 0.24), 0px 2px 4px rgba(7, 17, 17, 0.34); } - + .abilities_header.pointer { + cursor: pointer; + } .abilities_info { display: flex; flex-direction: row; @@ -134,14 +137,13 @@ export default function AbilitiesHeader(): ReactElement { background: var(--hunter-green); border-radius: 17px; padding: 0px 8px; - cursor: pointer; height: 24px; font-weight: 500; font-size: 14px; line-height: 16px; letter-spacing: 0.03em; - color: var(--${newAbilities > 0 ? "success" : "green-40"}); + color: var(--${openAbilities > 0 ? "success" : "green-40"}); } .desc { diff --git a/ui/components/Overview/NetworksChart.tsx b/ui/components/Overview/NetworksChart.tsx index 0d8e6d73bf..a32258568b 100644 --- a/ui/components/Overview/NetworksChart.tsx +++ b/ui/components/Overview/NetworksChart.tsx @@ -6,6 +6,7 @@ import { NETWORK_BY_CHAIN_ID, POLYGON, BINANCE_SMART_CHAIN, + ROOTSTOCK, } from "@tallyho/tally-background/constants" import { FeatureFlags, isEnabled } from "@tallyho/tally-background/features" import { AccountTotalList } from "@tallyho/tally-background/redux-slices/selectors" @@ -20,6 +21,7 @@ const NETWORKS_CHART_COLORS = { [OPTIMISM.chainID]: "#CD041C", [AVALANCHE.chainID]: "#E84142", [BINANCE_SMART_CHAIN.chainID]: "#F3BA2F", + [ROOTSTOCK.chainID]: "#F18C30", } const getNetworksPercents = ( diff --git a/ui/components/Send/NFTCollectionAccordion.tsx b/ui/components/Send/NFTCollectionAccordion.tsx index 598fc07e5f..9fec8be0a9 100644 --- a/ui/components/Send/NFTCollectionAccordion.tsx +++ b/ui/components/Send/NFTCollectionAccordion.tsx @@ -1,9 +1,9 @@ import React, { useEffect, useState } from "react" import { NETWORK_BY_CHAIN_ID } from "@tallyho/tally-background/constants" -import { NFT } from "@tallyho/tally-background/nfts" import { NFTCollectionCached, fetchNFTsFromCollection, + NFTCached, } from "@tallyho/tally-background/redux-slices/nfts_update" import { useBackgroundDispatch } from "../../hooks" import { scanWebsite } from "../../utils/constants" @@ -18,7 +18,7 @@ export default function NFTCollectionAccordion({ onSelectNFT, }: { collection: NFTCollectionCached - onSelectNFT: (nft: NFT) => void + onSelectNFT: (nft: NFTCached) => void }): JSX.Element { const dispatch = useBackgroundDispatch() const [isExpanded, setIsExpanded] = useState(false) @@ -33,7 +33,7 @@ export default function NFTCollectionAccordion({ collectionID: collection.id, account: { address: collection.owner, - network: NETWORK_BY_CHAIN_ID[collection.network.chainID], + network: NETWORK_BY_CHAIN_ID[collection.chainID], }, }) ).then(() => setIsLoading(false)) @@ -43,7 +43,7 @@ export default function NFTCollectionAccordion({ isExpanded, collection.id, collection.owner, - collection.network.chainID, + collection.chainID, ]) return ( @@ -103,7 +103,7 @@ export default function NFTCollectionAccordion({ const { contract } = collection.nfts[0] ?? {} const url = `${ - scanWebsite[collection.network.chainID].url + scanWebsite[collection.chainID].url }/token/${contract}` window.open(url, "_blank")?.focus() diff --git a/ui/components/Shared/SharedAccordion.tsx b/ui/components/Shared/SharedAccordion.tsx index 6798b6e2b9..e7e462356f 100644 --- a/ui/components/Shared/SharedAccordion.tsx +++ b/ui/components/Shared/SharedAccordion.tsx @@ -116,14 +116,15 @@ export default function SharedAccordion({ .accordion_content { max-height: 0; overflow: hidden; - transition: max-height ${DELAY}ms ease-out, opacity 130ms ease-in; + transition: max-height ${DELAY}ms ease-out, + opacity var(--content-fade-in-duration, 130ms) ease-in; opacity: 0; padding: 0 8px; } .accordion_content.visible { max-height: ${height + 10}px; transition: max-height ${withTransition ? DELAY : 0}ms ease-in, - opacity 130ms ease-in; + opacity var(--content-fade-in-duration, 130ms) ease-in; opacity: 1; overflow: ${isVisible ? "visible" : "hidden"}; } diff --git a/ui/components/Shared/SharedAddress.tsx b/ui/components/Shared/SharedAddress.tsx index 179239cfd8..747b1d1ff3 100644 --- a/ui/components/Shared/SharedAddress.tsx +++ b/ui/components/Shared/SharedAddress.tsx @@ -74,6 +74,7 @@ export default function SharedAddress({ diff --git a/ui/components/Shared/types.ts b/ui/components/Shared/types.ts index d151287565..f7a5438185 100644 --- a/ui/components/Shared/types.ts +++ b/ui/components/Shared/types.ts @@ -57,6 +57,7 @@ interface PropsWithSmallIcon { | "notif-wrong" | "notification" | "receive" + | "refresh" | "send" | "settings" | "swap" diff --git a/ui/components/SignData/SignTypedDataInfo.tsx b/ui/components/SignData/SignTypedDataInfo.tsx index c7a709e15f..32a71047ba 100644 --- a/ui/components/SignData/SignTypedDataInfo.tsx +++ b/ui/components/SignData/SignTypedDataInfo.tsx @@ -5,7 +5,7 @@ import { } from "@tallyho/tally-background/lib/utils" import { EnrichedSignTypedDataRequest } from "@tallyho/tally-background/services/enrichment" import { useTranslation } from "react-i18next" -import capitalize from "../../utils/capitalize" +import { capitalize } from "../../utils/textUtils" type SignTypedDataInfoProps = { typedDataRequest: EnrichedSignTypedDataRequest diff --git a/ui/components/Signing/SignatureDetails/DataSignatureDetails/TypedDataSignatureDetails.tsx b/ui/components/Signing/SignatureDetails/DataSignatureDetails/TypedDataSignatureDetails.tsx index 18be27f2e6..83bbcb6dfa 100644 --- a/ui/components/Signing/SignatureDetails/DataSignatureDetails/TypedDataSignatureDetails.tsx +++ b/ui/components/Signing/SignatureDetails/DataSignatureDetails/TypedDataSignatureDetails.tsx @@ -2,7 +2,7 @@ import { isProbablyEVMAddress } from "@tallyho/tally-background/lib/utils" import { EnrichedSignTypedDataRequest } from "@tallyho/tally-background/services/enrichment" import React, { ReactElement } from "react" import { useTranslation } from "react-i18next" -import capitalize from "../../../../utils/capitalize" +import { capitalize } from "../../../../utils/textUtils" import SharedAddress from "../../../Shared/SharedAddress" import SharedIcon from "../../../Shared/SharedIcon" import DataSignatureDetails from "." diff --git a/ui/components/Signing/Signer/SignerBaseFrame.tsx b/ui/components/Signing/Signer/SignerBaseFrame.tsx index 45294ba8c0..47e0fb98a6 100644 --- a/ui/components/Signing/Signer/SignerBaseFrame.tsx +++ b/ui/components/Signing/Signer/SignerBaseFrame.tsx @@ -1,5 +1,7 @@ import React, { ReactElement, useState } from "react" import { useTranslation } from "react-i18next" +import { selectTransactionData } from "@tallyho/tally-background/redux-slices/selectors/transactionConstructionSelectors" +import { useBackgroundSelector } from "../../../hooks" import SharedButton from "../../Shared/SharedButton" type SignerBaseFrameProps = { @@ -17,6 +19,10 @@ export default function SignerBaseFrame({ }: SignerBaseFrameProps): ReactElement { const { t } = useTranslation("translation", { keyPrefix: "signTransaction" }) + const transactionDetails = useBackgroundSelector(selectTransactionData) + const hasInsufficientFunds = + transactionDetails?.annotation?.warnings?.includes("insufficient-funds") + const [isOnDelayToSign /* , setIsOnDelayToSign */] = useState(false) return ( @@ -32,7 +38,7 @@ export default function SignerBaseFrame({ size="large" onClick={onConfirm} showLoadingOnClick - isDisabled={isOnDelayToSign} + isDisabled={hasInsufficientFunds || isOnDelayToSign} > {signingActionLabel} diff --git a/ui/components/Swap/SwapTransactionSettingsChooser.tsx b/ui/components/Swap/SwapTransactionSettingsChooser.tsx index b99e833043..c5a6a1dea8 100644 --- a/ui/components/Swap/SwapTransactionSettingsChooser.tsx +++ b/ui/components/Swap/SwapTransactionSettingsChooser.tsx @@ -88,6 +88,7 @@ export default function SwapTransactionSettingsChooser({ { setIsSlideUpMenuOpen(false) }} diff --git a/ui/components/TabBar/TabBar.tsx b/ui/components/TabBar/TabBar.tsx index 6b90b55caa..6c540d95e9 100644 --- a/ui/components/TabBar/TabBar.tsx +++ b/ui/components/TabBar/TabBar.tsx @@ -1,10 +1,14 @@ -import React, { ReactElement } from "react" +import React, { ReactElement, useCallback } from "react" import { matchPath, useHistory, useLocation } from "react-router-dom" -import { selectCurrentNetwork } from "@tallyho/tally-background/redux-slices/selectors" +import { + selectCurrentNetwork, + selectOpenAbilityCount, +} from "@tallyho/tally-background/redux-slices/selectors" import { NETWORKS_SUPPORTING_SWAPS } from "@tallyho/tally-background/constants/networks" import { EVMNetwork } from "@tallyho/tally-background/networks" import { useTranslation } from "react-i18next" +import { FeatureFlags, isEnabled } from "@tallyho/tally-background/features" import TabBarIconButton from "./TabBarIconButton" import tabs, { defaultTab, TabInfo } from "../../utils/tabs" import { useBackgroundSelector } from "../../hooks" @@ -21,6 +25,10 @@ const isTabSupportedByNetwork = (tab: TabInfo, network: EVMNetwork) => { export default function TabBar(): ReactElement { const location = useLocation() const selectedNetwork = useBackgroundSelector(selectCurrentNetwork) + const abilityCount = useBackgroundSelector( + isEnabled(FeatureFlags.SUPPORT_ABILITIES) ? selectOpenAbilityCount : () => 0 + ) + const history = useHistory() const { t } = useTranslation() @@ -33,6 +41,18 @@ export default function TabBar(): ReactElement { matchPath(location.pathname, { path, exact: false }) ) ?? defaultTab + const hasNotifications = useCallback( + (path: string): boolean => { + switch (path) { + case "/portfolio": + return abilityCount > 0 + default: + return false + } + }, + [abilityCount] + ) + return (