diff --git a/.env.defaults b/.env.defaults index eec4c23ad7..0aad46930e 100644 --- a/.env.defaults +++ b/.env.defaults @@ -9,7 +9,7 @@ HIDE_IMPORT_DERIVATION_PATH=true HIDE_SWAP_REWARDS=true GAS_PRICE_POOLING_FREQUENCY=120 ETHEREUM_NETWORK=mainnet -WEBSITE_ORIGIN=https://tallyho.org +WEBSITE_ORIGIN=https://taho.xyz USE_MAINNET_FORK=false MAINNET_FORK_URL="ws://mainnet-fork.tally.cash:2096/" MAINNET_FORK_CHAIN_ID=1337 @@ -26,7 +26,7 @@ SUPPORT_SWAP_QUOTE_REFRESH=false ENABLE_ACHIEVEMENTS_TAB=true SUPPORT_ACHIEVEMENTS_BANNER=false SWITCH_RUNTIME_FLAGS=false -SUPPORT_NFT_TAB=false +SUPPORT_NFT_TAB=true SUPPORT_NFT_SEND=false UNS_API_KEY="4a77c949-511b-4c16-9862-6edfb6ae6012" REACT_DEVTOOLS_DEFAULT_PORT=8097 diff --git a/.github/ISSUE_TEMPLATE/BUG.yml b/.github/ISSUE_TEMPLATE/BUG.yml index 9f01d121a7..0ac747812f 100644 --- a/.github/ISSUE_TEMPLATE/BUG.yml +++ b/.github/ISSUE_TEMPLATE/BUG.yml @@ -51,6 +51,15 @@ body: label: Version description: What version of the extension are you running? options: + - v0.26.2 + - v0.26.1 + - v0.26.0 + - v0.25.4 + - v0.25.3 + - v0.25.2 + - v0.25.1 + - v0.25.0 + - v0.24.1 - v0.24.0 - v0.23.1 - v0.23.0 diff --git a/.github/ISSUE_TEMPLATE/FEATURE.yml b/.github/ISSUE_TEMPLATE/FEATURE.yml index 9825b5a728..b331267f9d 100644 --- a/.github/ISSUE_TEMPLATE/FEATURE.yml +++ b/.github/ISSUE_TEMPLATE/FEATURE.yml @@ -27,6 +27,6 @@ body: label: What would you like to be able to do? description: >- Please describe what you would like to be able to achieve/what problem - you would like to be able to solve with Tally that you currently can't. + you would like to be able to solve with Taho that you currently can't. validations: required: true diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 17a149ce24..988374967c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -41,6 +41,7 @@ jobs: env: ALCHEMY_KEY: ${{ secrets.DEV_ALCHEMY_API_KEY || 'oV1Rtjh61hGa97X2MTqMY9kEUcpxP-6K' }} BLOCKNATIVE_API_KEY: ${{ secrets.DEV_BLOCKNATIVE_API_KEY || 'f60816ff-da02-463f-87a6-67a09c6d53fa' }} + DAYLIGHT_API_KEY: ${{ secrets.DAYLIGHT_API_KEY }} COMMIT_SHA: ${{ github.sha }} - name: Production build if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') @@ -55,6 +56,7 @@ jobs: ZEROX_API_KEY: ${{ secrets.ZEROX_API_KEY }} COMMIT_SHA: ${{ github.sha }} POAP_API_KEY: ${{ secrets.POAP_API_KEY }} + DAYLIGHT_API_KEY: ${{ secrets.DAYLIGHT_API_KEY }} - name: Upload build asset if: ${{ !startsWith(github.ref, 'refs/tags/') }} uses: actions/upload-artifact@v3 @@ -98,7 +100,7 @@ jobs: - run: yarn install --frozen-lockfile - run: yarn lint e2e-tests: - if: github.ref == 'refs/heads/main' + if: (github.ref == 'refs/heads/main') || contains(github.head_ref, 'e2e') needs: build timeout-minutes: 60 runs-on: ubuntu-latest @@ -125,7 +127,7 @@ jobs: #env: # DEBUG: pw:api* - uses: actions/upload-artifact@v3 - if: always() + if: failure() with: name: debug-output path: | diff --git a/.gitignore b/.gitignore index 409822f139..b1ca03007d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ app/extension-scripts/api/playground/ *.rest .DS_Store .vscode -playwright-report \ No newline at end of file +playwright-report +test-results/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0d6bca1ed5..8d43fc453d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,10 +1,10 @@ -# Tally Ho Contribution Guide +# Taho Contribution Guide 👍🎉 First off, thanks for taking the time to contribute! 🎉👍 Contributions are welcome from anyone on the internet, and even the smallest of fixes are appreciated! -The following is a set of guidelines for contributing to Tally Ho and its +The following is a set of guidelines for contributing to Taho and its packages. These are mostly guidelines, not rules. Use your best judgment, and feel free to propose changes to this document in a pull request. While the team works towards a first release, bigger contributions will be slow-rolled or @@ -13,26 +13,26 @@ to them very soon! More below. ## Deciding What to Work On -Tally Ho is currently being built by a core team in collaboration with a community -on the [Tally Ho Community Discord server](https://chat.tally.cash). **Discord is +Taho is currently being built by a core team in collaboration with a community +on the [Taho Community Discord server](https://chat.taho.xyz). **Discord is the right place to start discussions on new features and bugs.** The community on Discord, led by a few designated folks will help to funnel these into well-organized GitHub issues for features and bugs, as well as organize folks to tackle any issues they’re interested in. For the time being, the core team will be charged with reviewing, critiquing, and ultimately merging new work. -⭐️ Check out our ["good first issues" tag](https://github.com/tallycash/extension/issues?q=is%3Aopen+is%3Aissue+label%3A%22🐣+good+first+issue%22) for existing bugs that are more approachable. +⭐️ Check out our ["good first issues" tag](https://github.com/tahowallet/extension/issues?q=is%3Aopen+is%3Aissue+label%3A%22🐣+good+first+issue%22) for existing bugs that are more approachable. ## Use Feature Flags -When developing a new feature, please put it behind a feature flag. Because this enables you to open PRs in smaller chunks rather than having the feature completely finished, this will help your contributions get merged faster. It'll also help everyone better QA the feature and time its release! Feature flags are set in [`.env.defaults`](https://github.com/tallycash/extension/blob/main/.env.defaults) in tandem with [`features.ts`](https://github.com/tallycash/extension/blob/main/background/features.ts) +When developing a new feature, please put it behind a feature flag. Because this enables you to open PRs in smaller chunks rather than having the feature completely finished, this will help your contributions get merged faster. It'll also help everyone better QA the feature and time its release! Feature flags are set in [`.env.defaults`](https://github.com/tahowallet/extension/blob/main/.env.defaults) in tandem with [`features.ts`](https://github.com/tahowallet/extension/blob/main/background/features.ts) ## Getting Started -1. Fork tallycash/tally-extension +1. Fork tahowallet/tally-extension 2. Clone your fork 3. Follow the [setup - instructions](https://github.com/tallycash/tally-extension#building-and-developing). + instructions](https://github.com/tahowallet/tally-extension#building-and-developing). 4. If you find an issue you would like to work on, post a comment indicating you’d like to pick it up. Otherwise, please file an issue indicating what you are intending to do—there could be a duplicate issue, or someone else @@ -63,14 +63,14 @@ code base. ### Commit Signing -Commits on the Tally Ho repository are all required to be signed. No PR will be +Commits on the Taho repository are all required to be signed. No PR will be merged if it has unsigned commits. See the [GitHub documentation on commit signing](https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification) to get it set up. ### Continuous Integration -Tally Ho uses GitHub Actions for continuous integration. All Actions jobs +Taho uses GitHub Actions for continuous integration. All Actions jobs (including tests, linting) must be green to merge a PR. ### Pre-commit diff --git a/README.md b/README.md index 6592c45e17..2ae55a9ec2 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -# tally-extension +# Taho Extension The community owned & operated wallet. -[Tally Ho](https://blog.tally.cash/a-community-owned-wallet-for-the-new-internet/) +[Taho](https://blog.taho.xyz/a-community-owned-wallet-for-the-new-internet/) is a community owned and operated Web3 wallet, built as a [browser extension](https://browserext.github.io/browserext/). @@ -18,7 +18,7 @@ spirit of community ownership. We can do better. -Tally Ho will be +Taho will be - Fairly launched ⚖️ - Sustainably aligned with users 🤲 @@ -140,7 +140,7 @@ $ yarn start ### Commit signing -Commits on the Tally Ho repository are all required to be signed. +Commits on the Taho repository are all required to be signed. No PR will be merged if it has unsigned commits. See the [GitHub documentation on commit signing](https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification) to get it set up. @@ -346,7 +346,7 @@ Firefox requires to upload source code if minifier is used and to be able to com ## Localization -Tally currently only supports English as the default language. We distill english strings to [\_locales](https://github.com/tallycash/extension/blob/main/ui/_locales/en/messages.json) to prepare for localization. +Taho currently only supports English as the default language. We distill english strings to [\_locales](https://github.com/tallycash/extension/blob/main/ui/_locales/en/messages.json) to prepare for localization. For other languages, we will use language code defined in [Support locales](https://developer.chrome.com/docs/webstore/i18n/#choosing-locales-to-support). We will use [weblate](https://hosted.weblate.org/projects/tallycash/extension/) for crowd translation, and will commit back to the github periodically after these translations are QA'ed. diff --git a/__mocks__/webextension-polyfill.ts b/__mocks__/webextension-polyfill.ts index a6a6da7521..44eca39833 100644 --- a/__mocks__/webextension-polyfill.ts +++ b/__mocks__/webextension-polyfill.ts @@ -27,10 +27,10 @@ module.exports = { ), }, windows: { - writable: true, - value: { - getCurrent: () => {}, - create: () => {}, + getCurrent: () => {}, + create: () => {}, + onRemoved: { + addListener: () => {}, }, }, runtime: { diff --git a/babel.config.js b/babel.config.js index 90c62b319d..f6d90a514f 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,4 +1,4 @@ -// Global config for all babel-affected Tally Ho packages. +// Global config for all babel-affected Taho packages. module.exports = { plugins: ["styled-jsx/babel"], presets: [ diff --git a/background/README.md b/background/README.md index 44a9c852c6..a8af75c894 100644 --- a/background/README.md +++ b/background/README.md @@ -1,7 +1,7 @@ -# Tally Ho Background +# Taho Background -The Tally Ho Background package can effectively be considered the background script for the -Tally Ho extension. It is designed to connect to external data providers including +The Taho Background package can effectively be considered the background script for the +Taho extension. It is designed to connect to external data providers including Ethereum nodes, as well as specific providers like Alchemy and Blocknative that may enable specific functionality beyond the standard node functions. It constructs a data model in the form of a [Redux](https://redux.js.org) store, which is @@ -93,7 +93,7 @@ try to attribute networks (through variable and method naming) in the following ## Public API -The only public API of the Tally Ho Background package is what is exported directly on +The only public API of the Taho Background package is what is exported directly on [`index.ts`](./index.ts). No submodule API is considered public, and all such APIs are subject to arbitrary change without warning. Any API from a child module that is meant for public consumption is re-exported in `index.ts`. diff --git a/background/abilities.ts b/background/abilities.ts index 5abc1f232b..b35f157ba5 100644 --- a/background/abilities.ts +++ b/background/abilities.ts @@ -25,11 +25,11 @@ export const ABILITY_TYPES_ENABLED = [ "airdrop", "vote", "access", + "claim", ] as const // https://docs.daylight.xyz/reference/ability-model#ability-types export const ABILITY_TYPES = [ ...ABILITY_TYPES_ENABLED, - "claim", "product", "event", "article", diff --git a/background/constants/assets.ts b/background/constants/assets.ts index 570f2c31db..8f8b5636ae 100644 --- a/background/constants/assets.ts +++ b/background/constants/assets.ts @@ -1,5 +1,6 @@ import { SmartContractFungibleAsset } from "../assets" import { ETHEREUM } from "./networks" +import { WEBSITE_ORIGIN } from "./website" /** * The primary token for the wallet's DAO. @@ -14,6 +15,6 @@ export const DOGGO: SmartContractFungibleAsset = { homeNetwork: ETHEREUM, metadata: { tokenLists: [], - websiteURL: "https://tallyho.cash", + websiteURL: WEBSITE_ORIGIN, }, } diff --git a/background/constants/errors.ts b/background/constants/errors.ts index 5b588b19f8..43c9e6836a 100644 --- a/background/constants/errors.ts +++ b/background/constants/errors.ts @@ -7,8 +7,8 @@ export const NETWORK_ERRORS = { // 504: // 504: // internal: - UNSUPORTED_NETWORK: "Currently Tally does not support this network", - UNSUPORTED_TRANSPORT: "Currently Tally does not support this transport type", + UNSUPORTED_NETWORK: "Currently Taho does not support this network", + UNSUPORTED_TRANSPORT: "Currently Taho does not support this transport type", CONNECT_NOT_SUPPORTED: "The provided endpoint does not support socket connections", SOCKET_CLOSED: "Connection with node is no longer open", diff --git a/background/constants/website.ts b/background/constants/website.ts index 13a0c0d1a6..53264e2e7c 100644 --- a/background/constants/website.ts +++ b/background/constants/website.ts @@ -1,2 +1,2 @@ /* eslint-disable import/prefer-default-export */ -export const WEBSITE_ORIGIN = process.env.WEBSITE_ORIGIN ?? null +export const WEBSITE_ORIGIN = process.env.WEBSITE_ORIGIN ?? "" diff --git a/background/features.ts b/background/features.ts index 9c68c0208e..bb5c3b55ad 100644 --- a/background/features.ts +++ b/background/features.ts @@ -42,7 +42,7 @@ type BuildTimeFlagType = keyof typeof BuildTimeFlag export type RuntimeFlagType = keyof typeof RuntimeFlag -type FeatureFlagType = RuntimeFlagType | BuildTimeFlagType +export type FeatureFlagType = RuntimeFlagType | BuildTimeFlagType /** * Object with all feature flags. The key is the same as the value. @@ -61,7 +61,10 @@ export const FeatureFlags = Object.keys({ * If value is not exist then is read from environment variables. * The value for the build time flag is read from environment variables. */ -export const isEnabled = (flagName: FeatureFlagType): boolean => { +export const isEnabled = ( + flagName: FeatureFlagType, + checkBrowserStorage: boolean = BuildTimeFlag.SWITCH_RUNTIME_FLAGS +): boolean => { // Guard to narrow flag type const isBuildTimeFlag = (flag: string): flag is BuildTimeFlagType => flag in BuildTimeFlag @@ -70,7 +73,7 @@ export const isEnabled = (flagName: FeatureFlagType): boolean => { return BuildTimeFlag[flagName] } - if (BuildTimeFlag.SWITCH_RUNTIME_FLAGS) { + if (checkBrowserStorage) { const state = localStorage.getItem(flagName) return state !== null ? state === "true" : RuntimeFlag[flagName] } diff --git a/background/lib/alchemy.ts b/background/lib/alchemy.ts index 1986ed1921..dda3775d43 100644 --- a/background/lib/alchemy.ts +++ b/background/lib/alchemy.ts @@ -12,6 +12,7 @@ import { isValidAlchemyAssetTransferResponse, isValidAlchemyTokenBalanceResponse, isValidAlchemyTokenMetadataResponse, + ValidatedType, } from "./validate" import type SerialFallbackProvider from "../services/chain/serial-fallback-provider" import { AddressOnNetwork } from "../accounts" @@ -150,33 +151,59 @@ export async function getAssetTransfers( */ export async function getTokenBalances( provider: SerialFallbackProvider, - { address, network }: AddressOnNetwork + addressOnNetwork: AddressOnNetwork ): Promise { - const json: unknown = await provider.send("alchemy_getTokenBalances", [ - address, - "erc20", - ]) + const fetchAndValidate = async (address: string, pageKey?: string) => { + const json: unknown = await provider.send("alchemy_getTokenBalances", [ + address, + "erc20", + ...(pageKey ? [{ pageKey }] : []), + ]) - if (!isValidAlchemyTokenBalanceResponse(json)) { - logger.warn( - "Alchemy token balance response didn't validate, did the API change?", - json, - isValidAlchemyTokenBalanceResponse.errors - ) - return [] + if (!isValidAlchemyTokenBalanceResponse(json)) { + logger.warn( + "Alchemy token balance response didn't validate, did the API change?", + json, + isValidAlchemyTokenBalanceResponse.errors + ) + return null + } + + return json } + type TokenBalance = ValidatedType< + typeof isValidAlchemyTokenBalanceResponse + >["tokenBalances"] + + const balances: TokenBalance = [] + + type Awaited

= P extends Promise ? V : P + + let currentPageKey + let response: Awaited> + + do { + // eslint-disable-next-line no-await-in-loop + response = await fetchAndValidate(addressOnNetwork.address, currentPageKey) + + if (!response) { + break + } + + balances.push(...response.tokenBalances) + + currentPageKey = response.pageKey + } while (currentPageKey) + // TODO log balances with errors, consider returning an error type return ( - json.tokenBalances + balances .filter( ( b - ): b is typeof json["tokenBalances"][0] & { - tokenBalance: Exclude< - typeof json["tokenBalances"][0]["tokenBalance"], - undefined | null - > + ): b is TokenBalance[0] & { + tokenBalance: NonNullable } => (b.error === null || !("error" in b)) && "tokenBalance" in b && @@ -199,7 +226,7 @@ export async function getTokenBalances( return { smartContract: { contractAddress: tokenBalance.contractAddress, - homeNetwork: network, + homeNetwork: addressOnNetwork.network, }, amount: BigInt(balance), } diff --git a/background/lib/daylight.ts b/background/lib/daylight.ts index 4062a449fa..ccb1e008f0 100644 --- a/background/lib/daylight.ts +++ b/background/lib/daylight.ts @@ -85,9 +85,20 @@ export const getDaylightAbilities = async ( // https://docs.daylight.xyz/reference/retrieve-wallets-abilities retries = DEFAULT_RETRIES ): Promise => { + // Learn more at https://docs.daylight.xyz/reference/get_v1-wallets-address-abilities + const requestURL = new URL( + `${DAYLIGHT_BASE_URL}/wallets/${address}/abilities` + ) + // The most interesting abilities will be the first + requestURL.searchParams.set("sort", "magic") + requestURL.searchParams.set("sortDirection", "desc") + // The limit needs to be set. It is set to the highest value. + requestURL.searchParams.set("limit", "1000") + requestURL.searchParams.set("deadline", "all") + try { const response: AbilitiesResponse = await fetchJson({ - url: `${DAYLIGHT_BASE_URL}/wallets/${address}/abilities?deadline=all`, + url: requestURL.toString(), ...(process.env.DAYLIGHT_API_KEY && { headers: { Authorization: `Bearer ${process.env.DAYLIGHT_API_KEY}`, diff --git a/background/lib/erc20.ts b/background/lib/erc20.ts index 80606b598e..566b06dcca 100644 --- a/background/lib/erc20.ts +++ b/background/lib/erc20.ts @@ -126,7 +126,7 @@ export function parseERC20Tx( } /** - * Information bundle from an ostensible ERC20 transfer log using Tally types. + * Information bundle from an ostensible ERC20 transfer log using Taho types. */ export type ERC20TransferLog = { contractAddress: string diff --git a/background/lib/posthog.ts b/background/lib/posthog.ts index 4402141839..b492000eda 100644 --- a/background/lib/posthog.ts +++ b/background/lib/posthog.ts @@ -3,6 +3,12 @@ import { v4 as uuidv4 } from "uuid" import { FeatureFlags, isEnabled } from "../features" import logger from "./logger" +export enum AnalyticsEvent { + NEW_INSTALL = "New install", + UI_SHOWN = "UI shown", + NEW_ACCOUNT_TO_TRACK = "Address added to tracking on network", +} + const POSTHOG_PROJECT_ID = "11112" const PERSON_ENDPOINT = `https://app.posthog.com/api/projects/${POSTHOG_PROJECT_ID}/persons` diff --git a/background/lib/validate/alchemy.ts b/background/lib/validate/alchemy.ts index 760f753986..bbd6b8fd84 100644 --- a/background/lib/validate/alchemy.ts +++ b/background/lib/validate/alchemy.ts @@ -48,6 +48,9 @@ export const alchemyTokenBalanceJTD = { }, }, }, + optionalProperties: { + pageKey: { type: "string" }, + }, additionalProperties: false, } as const diff --git a/background/lib/validate/jtd-validators.js b/background/lib/validate/jtd-validators.js index d5b2804d4a..39e3ab373a 100644 --- a/background/lib/validate/jtd-validators.js +++ b/background/lib/validate/jtd-validators.js @@ -1 +1 @@ -"use strict";exports.isValidMetadata = validate57;var schema29 = {"optionalProperties":{"name":{"type":"string"},"description":{"type":"string"},"image":{"type":"string"},"title":{"type":"string"},"external_url":{"type":"string"}},"additionalProperties":true};function validate57(data, valCxt){"use strict"; ;if(valCxt){var instancePath = valCxt.instancePath;var parentData = valCxt.parentData;var parentDataProperty = valCxt.parentDataProperty;var rootData = valCxt.rootData;}else {var instancePath = "";var parentData = undefined;var parentDataProperty = undefined;var rootData = data;}var vErrors = null;var errors = 0;var valid0 = false;if(data && typeof data == "object" && !Array.isArray(data)){valid0 = true;if(data.name !== undefined){if(!(typeof data.name == "string")){var err0 = {instancePath:instancePath+"/name",schemaPath:"/optionalProperties/name/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}}if(data.description !== undefined){if(!(typeof data.description == "string")){var err1 = {instancePath:instancePath+"/description",schemaPath:"/optionalProperties/description/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}}if(data.image !== undefined){if(!(typeof data.image == "string")){var err2 = {instancePath:instancePath+"/image",schemaPath:"/optionalProperties/image/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}}if(data.title !== undefined){if(!(typeof data.title == "string")){var err3 = {instancePath:instancePath+"/title",schemaPath:"/optionalProperties/title/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}}if(data.external_url !== undefined){if(!(typeof data.external_url == "string")){var err4 = {instancePath:instancePath+"/external_url",schemaPath:"/optionalProperties/external_url/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}}}if(!valid0){var err5 = {instancePath:instancePath,schemaPath:"/optionalProperties",keyword:"optionalProperties",params:{type: "object", nullable: false},message:"must be object"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}validate57.errors = vErrors;return errors === 0;}exports.isValidAlchemyAssetTransferResponse = validate58;var schema30 = {"properties":{"transfers":{"elements":{"properties":{"asset":{"type":"string","nullable":true},"hash":{"type":"string"},"blockNum":{"type":"string"},"category":{"enum":["token","internal","external","erc20","erc1155"]},"from":{"type":"string","nullable":true},"to":{"type":"string","nullable":true},"erc721TokenId":{"type":"string","nullable":true}},"optionalProperties":{"rawContract":{"properties":{"address":{"type":"string","nullable":true},"decimal":{"type":"string","nullable":true},"value":{"type":"string","nullable":true}}}},"additionalProperties":true}}},"additionalProperties":true};function validate58(data, valCxt){"use strict"; ;if(valCxt){var instancePath = valCxt.instancePath;var parentData = valCxt.parentData;var parentDataProperty = valCxt.parentDataProperty;var rootData = valCxt.rootData;}else {var instancePath = "";var parentData = undefined;var parentDataProperty = undefined;var rootData = data;}var vErrors = null;var errors = 0;var valid0 = false;if(data && typeof data == "object" && !Array.isArray(data)){valid0 = true;if(data.transfers !== undefined){var data0 = data.transfers;var valid2 = false;if(!valid2){if(Array.isArray(data0)){var valid4 = true;var len0 = data0.length;for(var i0=0; i0= 0 && data0 <= 4294967295)){var err0 = {instancePath:instancePath+"/decimals",schemaPath:"/properties/decimals/type",keyword:"type",params:{type: "uint32", nullable: false},message:"must be uint32"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}}else {var err1 = {instancePath:instancePath,schemaPath:"/properties/decimals",keyword:"properties",params:{error: "missing", missingProperty: "decimals"},message:"must have property 'decimals'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.name !== undefined){if(!(typeof data.name == "string")){var err2 = {instancePath:instancePath+"/name",schemaPath:"/properties/name/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}}else {var err3 = {instancePath:instancePath,schemaPath:"/properties/name",keyword:"properties",params:{error: "missing", missingProperty: "name"},message:"must have property 'name'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.symbol !== undefined){if(!(typeof data.symbol == "string")){var err4 = {instancePath:instancePath+"/symbol",schemaPath:"/properties/symbol/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}}else {var err5 = {instancePath:instancePath,schemaPath:"/properties/symbol",keyword:"properties",params:{error: "missing", missingProperty: "symbol"},message:"must have property 'symbol'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(data.logo !== undefined){var data3 = data.logo;if(!((data3 === null) || (typeof data3 == "string"))){var err6 = {instancePath:instancePath+"/logo",schemaPath:"/properties/logo/type",keyword:"type",params:{type: "string", nullable: true},message:"must be string or null"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}else {var err7 = {instancePath:instancePath,schemaPath:"/properties/logo",keyword:"properties",params:{error: "missing", missingProperty: "logo"},message:"must have property 'logo'"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}for(var key0 in data){if((((key0 !== "decimals") && (key0 !== "name")) && (key0 !== "symbol")) && (key0 !== "logo")){var err8 = {instancePath:instancePath+"/" + key0.replace(/~/g, "~0").replace(/\//g, "~1"),schemaPath:"",keyword:"properties",params:{error: "additional", additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}}if(!valid0){var err9 = {instancePath:instancePath,schemaPath:"/properties",keyword:"properties",params:{type: "object", nullable: false},message:"must be object"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}validate60.errors = vErrors;return errors === 0;}exports.isValidSwapPriceResponse = validate61;var schema33 = {"properties":{"chainId":{"type":"uint32"},"price":{"type":"string"},"value":{"type":"string"},"gasPrice":{"type":"string"},"gas":{"type":"string"},"estimatedGas":{"type":"string"},"protocolFee":{"type":"string"},"minimumProtocolFee":{"type":"string"},"buyTokenAddress":{"type":"string"},"buyAmount":{"type":"string"},"sellTokenAddress":{"type":"string"},"sellAmount":{"type":"string"},"sources":{"elements":{"properties":{"name":{"type":"string"},"proportion":{"type":"string"}},"additionalProperties":true}},"allowanceTarget":{"type":"string"},"sellTokenToEthRate":{"type":"string"},"buyTokenToEthRate":{"type":"string"}},"additionalProperties":true};function validate61(data, valCxt){"use strict"; ;if(valCxt){var instancePath = valCxt.instancePath;var parentData = valCxt.parentData;var parentDataProperty = valCxt.parentDataProperty;var rootData = valCxt.rootData;}else {var instancePath = "";var parentData = undefined;var parentDataProperty = undefined;var rootData = data;}var vErrors = null;var errors = 0;var valid0 = false;if(data && typeof data == "object" && !Array.isArray(data)){valid0 = true;if(data.chainId !== undefined){var data0 = data.chainId;if(!(typeof data0 == "number" && isFinite(data0) && !(data0 % 1) && data0 >= 0 && data0 <= 4294967295)){var err0 = {instancePath:instancePath+"/chainId",schemaPath:"/properties/chainId/type",keyword:"type",params:{type: "uint32", nullable: false},message:"must be uint32"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}}else {var err1 = {instancePath:instancePath,schemaPath:"/properties/chainId",keyword:"properties",params:{error: "missing", missingProperty: "chainId"},message:"must have property 'chainId'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.price !== undefined){if(!(typeof data.price == "string")){var err2 = {instancePath:instancePath+"/price",schemaPath:"/properties/price/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}}else {var err3 = {instancePath:instancePath,schemaPath:"/properties/price",keyword:"properties",params:{error: "missing", missingProperty: "price"},message:"must have property 'price'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.value !== undefined){if(!(typeof data.value == "string")){var err4 = {instancePath:instancePath+"/value",schemaPath:"/properties/value/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}}else {var err5 = {instancePath:instancePath,schemaPath:"/properties/value",keyword:"properties",params:{error: "missing", missingProperty: "value"},message:"must have property 'value'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(data.gasPrice !== undefined){if(!(typeof data.gasPrice == "string")){var err6 = {instancePath:instancePath+"/gasPrice",schemaPath:"/properties/gasPrice/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}else {var err7 = {instancePath:instancePath,schemaPath:"/properties/gasPrice",keyword:"properties",params:{error: "missing", missingProperty: "gasPrice"},message:"must have property 'gasPrice'"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(data.gas !== undefined){if(!(typeof data.gas == "string")){var err8 = {instancePath:instancePath+"/gas",schemaPath:"/properties/gas/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}else {var err9 = {instancePath:instancePath,schemaPath:"/properties/gas",keyword:"properties",params:{error: "missing", missingProperty: "gas"},message:"must have property 'gas'"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(data.estimatedGas !== undefined){if(!(typeof data.estimatedGas == "string")){var err10 = {instancePath:instancePath+"/estimatedGas",schemaPath:"/properties/estimatedGas/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}else {var err11 = {instancePath:instancePath,schemaPath:"/properties/estimatedGas",keyword:"properties",params:{error: "missing", missingProperty: "estimatedGas"},message:"must have property 'estimatedGas'"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}if(data.protocolFee !== undefined){if(!(typeof data.protocolFee == "string")){var err12 = {instancePath:instancePath+"/protocolFee",schemaPath:"/properties/protocolFee/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}else {var err13 = {instancePath:instancePath,schemaPath:"/properties/protocolFee",keyword:"properties",params:{error: "missing", missingProperty: "protocolFee"},message:"must have property 'protocolFee'"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if(data.minimumProtocolFee !== undefined){if(!(typeof data.minimumProtocolFee == "string")){var err14 = {instancePath:instancePath+"/minimumProtocolFee",schemaPath:"/properties/minimumProtocolFee/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}else {var err15 = {instancePath:instancePath,schemaPath:"/properties/minimumProtocolFee",keyword:"properties",params:{error: "missing", missingProperty: "minimumProtocolFee"},message:"must have property 'minimumProtocolFee'"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}if(data.buyTokenAddress !== undefined){if(!(typeof data.buyTokenAddress == "string")){var err16 = {instancePath:instancePath+"/buyTokenAddress",schemaPath:"/properties/buyTokenAddress/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}else {var err17 = {instancePath:instancePath,schemaPath:"/properties/buyTokenAddress",keyword:"properties",params:{error: "missing", missingProperty: "buyTokenAddress"},message:"must have property 'buyTokenAddress'"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}if(data.buyAmount !== undefined){if(!(typeof data.buyAmount == "string")){var err18 = {instancePath:instancePath+"/buyAmount",schemaPath:"/properties/buyAmount/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}else {var err19 = {instancePath:instancePath,schemaPath:"/properties/buyAmount",keyword:"properties",params:{error: "missing", missingProperty: "buyAmount"},message:"must have property 'buyAmount'"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}if(data.sellTokenAddress !== undefined){if(!(typeof data.sellTokenAddress == "string")){var err20 = {instancePath:instancePath+"/sellTokenAddress",schemaPath:"/properties/sellTokenAddress/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}else {var err21 = {instancePath:instancePath,schemaPath:"/properties/sellTokenAddress",keyword:"properties",params:{error: "missing", missingProperty: "sellTokenAddress"},message:"must have property 'sellTokenAddress'"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}if(data.sellAmount !== undefined){if(!(typeof data.sellAmount == "string")){var err22 = {instancePath:instancePath+"/sellAmount",schemaPath:"/properties/sellAmount/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}}else {var err23 = {instancePath:instancePath,schemaPath:"/properties/sellAmount",keyword:"properties",params:{error: "missing", missingProperty: "sellAmount"},message:"must have property 'sellAmount'"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}if(data.sources !== undefined){var data12 = data.sources;var valid2 = false;if(!valid2){if(Array.isArray(data12)){var valid4 = true;var len0 = data12.length;for(var i0=0; i0= 0 && data0 <= 4294967295)){var err0 = {instancePath:instancePath+"/chainId",schemaPath:"/properties/chainId/type",keyword:"type",params:{type: "uint32", nullable: false},message:"must be uint32"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}}else {var err1 = {instancePath:instancePath,schemaPath:"/properties/chainId",keyword:"properties",params:{error: "missing", missingProperty: "chainId"},message:"must have property 'chainId'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.price !== undefined){if(!(typeof data.price == "string")){var err2 = {instancePath:instancePath+"/price",schemaPath:"/properties/price/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}}else {var err3 = {instancePath:instancePath,schemaPath:"/properties/price",keyword:"properties",params:{error: "missing", missingProperty: "price"},message:"must have property 'price'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.value !== undefined){if(!(typeof data.value == "string")){var err4 = {instancePath:instancePath+"/value",schemaPath:"/properties/value/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}}else {var err5 = {instancePath:instancePath,schemaPath:"/properties/value",keyword:"properties",params:{error: "missing", missingProperty: "value"},message:"must have property 'value'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(data.gasPrice !== undefined){if(!(typeof data.gasPrice == "string")){var err6 = {instancePath:instancePath+"/gasPrice",schemaPath:"/properties/gasPrice/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}else {var err7 = {instancePath:instancePath,schemaPath:"/properties/gasPrice",keyword:"properties",params:{error: "missing", missingProperty: "gasPrice"},message:"must have property 'gasPrice'"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(data.gas !== undefined){if(!(typeof data.gas == "string")){var err8 = {instancePath:instancePath+"/gas",schemaPath:"/properties/gas/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}else {var err9 = {instancePath:instancePath,schemaPath:"/properties/gas",keyword:"properties",params:{error: "missing", missingProperty: "gas"},message:"must have property 'gas'"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(data.estimatedGas !== undefined){if(!(typeof data.estimatedGas == "string")){var err10 = {instancePath:instancePath+"/estimatedGas",schemaPath:"/properties/estimatedGas/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}else {var err11 = {instancePath:instancePath,schemaPath:"/properties/estimatedGas",keyword:"properties",params:{error: "missing", missingProperty: "estimatedGas"},message:"must have property 'estimatedGas'"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}if(data.protocolFee !== undefined){if(!(typeof data.protocolFee == "string")){var err12 = {instancePath:instancePath+"/protocolFee",schemaPath:"/properties/protocolFee/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}else {var err13 = {instancePath:instancePath,schemaPath:"/properties/protocolFee",keyword:"properties",params:{error: "missing", missingProperty: "protocolFee"},message:"must have property 'protocolFee'"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if(data.minimumProtocolFee !== undefined){if(!(typeof data.minimumProtocolFee == "string")){var err14 = {instancePath:instancePath+"/minimumProtocolFee",schemaPath:"/properties/minimumProtocolFee/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}else {var err15 = {instancePath:instancePath,schemaPath:"/properties/minimumProtocolFee",keyword:"properties",params:{error: "missing", missingProperty: "minimumProtocolFee"},message:"must have property 'minimumProtocolFee'"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}if(data.buyTokenAddress !== undefined){if(!(typeof data.buyTokenAddress == "string")){var err16 = {instancePath:instancePath+"/buyTokenAddress",schemaPath:"/properties/buyTokenAddress/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}else {var err17 = {instancePath:instancePath,schemaPath:"/properties/buyTokenAddress",keyword:"properties",params:{error: "missing", missingProperty: "buyTokenAddress"},message:"must have property 'buyTokenAddress'"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}if(data.buyAmount !== undefined){if(!(typeof data.buyAmount == "string")){var err18 = {instancePath:instancePath+"/buyAmount",schemaPath:"/properties/buyAmount/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}else {var err19 = {instancePath:instancePath,schemaPath:"/properties/buyAmount",keyword:"properties",params:{error: "missing", missingProperty: "buyAmount"},message:"must have property 'buyAmount'"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}if(data.sellTokenAddress !== undefined){if(!(typeof data.sellTokenAddress == "string")){var err20 = {instancePath:instancePath+"/sellTokenAddress",schemaPath:"/properties/sellTokenAddress/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}else {var err21 = {instancePath:instancePath,schemaPath:"/properties/sellTokenAddress",keyword:"properties",params:{error: "missing", missingProperty: "sellTokenAddress"},message:"must have property 'sellTokenAddress'"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}if(data.sellAmount !== undefined){if(!(typeof data.sellAmount == "string")){var err22 = {instancePath:instancePath+"/sellAmount",schemaPath:"/properties/sellAmount/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}}else {var err23 = {instancePath:instancePath,schemaPath:"/properties/sellAmount",keyword:"properties",params:{error: "missing", missingProperty: "sellAmount"},message:"must have property 'sellAmount'"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}if(data.sources !== undefined){var data12 = data.sources;var valid2 = false;if(!valid2){if(Array.isArray(data12)){var valid4 = true;var len0 = data12.length;for(var i0=0; i0= 0 && data0 <= 4294967295)){var err0 = {instancePath:instancePath+"/decimals",schemaPath:"/properties/decimals/type",keyword:"type",params:{type: "uint32", nullable: false},message:"must be uint32"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}}else {var err1 = {instancePath:instancePath,schemaPath:"/properties/decimals",keyword:"properties",params:{error: "missing", missingProperty: "decimals"},message:"must have property 'decimals'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.name !== undefined){if(!(typeof data.name == "string")){var err2 = {instancePath:instancePath+"/name",schemaPath:"/properties/name/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}}else {var err3 = {instancePath:instancePath,schemaPath:"/properties/name",keyword:"properties",params:{error: "missing", missingProperty: "name"},message:"must have property 'name'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.symbol !== undefined){if(!(typeof data.symbol == "string")){var err4 = {instancePath:instancePath+"/symbol",schemaPath:"/properties/symbol/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}}else {var err5 = {instancePath:instancePath,schemaPath:"/properties/symbol",keyword:"properties",params:{error: "missing", missingProperty: "symbol"},message:"must have property 'symbol'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(data.logo !== undefined){var data3 = data.logo;if(!((data3 === null) || (typeof data3 == "string"))){var err6 = {instancePath:instancePath+"/logo",schemaPath:"/properties/logo/type",keyword:"type",params:{type: "string", nullable: true},message:"must be string or null"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}else {var err7 = {instancePath:instancePath,schemaPath:"/properties/logo",keyword:"properties",params:{error: "missing", missingProperty: "logo"},message:"must have property 'logo'"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}for(var key0 in data){if((((key0 !== "decimals") && (key0 !== "name")) && (key0 !== "symbol")) && (key0 !== "logo")){var err8 = {instancePath:instancePath+"/" + key0.replace(/~/g, "~0").replace(/\//g, "~1"),schemaPath:"",keyword:"properties",params:{error: "additional", additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}}if(!valid0){var err9 = {instancePath:instancePath,schemaPath:"/properties",keyword:"properties",params:{type: "object", nullable: false},message:"must be object"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}validate60.errors = vErrors;return errors === 0;}exports.isValidSwapPriceResponse = validate61;var schema33 = {"properties":{"chainId":{"type":"uint32"},"price":{"type":"string"},"value":{"type":"string"},"gasPrice":{"type":"string"},"gas":{"type":"string"},"estimatedGas":{"type":"string"},"protocolFee":{"type":"string"},"minimumProtocolFee":{"type":"string"},"buyTokenAddress":{"type":"string"},"buyAmount":{"type":"string"},"sellTokenAddress":{"type":"string"},"sellAmount":{"type":"string"},"sources":{"elements":{"properties":{"name":{"type":"string"},"proportion":{"type":"string"}},"additionalProperties":true}},"allowanceTarget":{"type":"string"},"sellTokenToEthRate":{"type":"string"},"buyTokenToEthRate":{"type":"string"},"estimatedPriceImpact":{"type":"string"}},"additionalProperties":true};function validate61(data, valCxt){"use strict"; ;if(valCxt){var instancePath = valCxt.instancePath;var parentData = valCxt.parentData;var parentDataProperty = valCxt.parentDataProperty;var rootData = valCxt.rootData;}else {var instancePath = "";var parentData = undefined;var parentDataProperty = undefined;var rootData = data;}var vErrors = null;var errors = 0;var valid0 = false;if(data && typeof data == "object" && !Array.isArray(data)){valid0 = true;if(data.chainId !== undefined){var data0 = data.chainId;if(!(typeof data0 == "number" && isFinite(data0) && !(data0 % 1) && data0 >= 0 && data0 <= 4294967295)){var err0 = {instancePath:instancePath+"/chainId",schemaPath:"/properties/chainId/type",keyword:"type",params:{type: "uint32", nullable: false},message:"must be uint32"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}}else {var err1 = {instancePath:instancePath,schemaPath:"/properties/chainId",keyword:"properties",params:{error: "missing", missingProperty: "chainId"},message:"must have property 'chainId'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.price !== undefined){if(!(typeof data.price == "string")){var err2 = {instancePath:instancePath+"/price",schemaPath:"/properties/price/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}}else {var err3 = {instancePath:instancePath,schemaPath:"/properties/price",keyword:"properties",params:{error: "missing", missingProperty: "price"},message:"must have property 'price'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.value !== undefined){if(!(typeof data.value == "string")){var err4 = {instancePath:instancePath+"/value",schemaPath:"/properties/value/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}}else {var err5 = {instancePath:instancePath,schemaPath:"/properties/value",keyword:"properties",params:{error: "missing", missingProperty: "value"},message:"must have property 'value'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(data.gasPrice !== undefined){if(!(typeof data.gasPrice == "string")){var err6 = {instancePath:instancePath+"/gasPrice",schemaPath:"/properties/gasPrice/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}else {var err7 = {instancePath:instancePath,schemaPath:"/properties/gasPrice",keyword:"properties",params:{error: "missing", missingProperty: "gasPrice"},message:"must have property 'gasPrice'"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(data.gas !== undefined){if(!(typeof data.gas == "string")){var err8 = {instancePath:instancePath+"/gas",schemaPath:"/properties/gas/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}else {var err9 = {instancePath:instancePath,schemaPath:"/properties/gas",keyword:"properties",params:{error: "missing", missingProperty: "gas"},message:"must have property 'gas'"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(data.estimatedGas !== undefined){if(!(typeof data.estimatedGas == "string")){var err10 = {instancePath:instancePath+"/estimatedGas",schemaPath:"/properties/estimatedGas/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}else {var err11 = {instancePath:instancePath,schemaPath:"/properties/estimatedGas",keyword:"properties",params:{error: "missing", missingProperty: "estimatedGas"},message:"must have property 'estimatedGas'"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}if(data.protocolFee !== undefined){if(!(typeof data.protocolFee == "string")){var err12 = {instancePath:instancePath+"/protocolFee",schemaPath:"/properties/protocolFee/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}else {var err13 = {instancePath:instancePath,schemaPath:"/properties/protocolFee",keyword:"properties",params:{error: "missing", missingProperty: "protocolFee"},message:"must have property 'protocolFee'"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if(data.minimumProtocolFee !== undefined){if(!(typeof data.minimumProtocolFee == "string")){var err14 = {instancePath:instancePath+"/minimumProtocolFee",schemaPath:"/properties/minimumProtocolFee/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}else {var err15 = {instancePath:instancePath,schemaPath:"/properties/minimumProtocolFee",keyword:"properties",params:{error: "missing", missingProperty: "minimumProtocolFee"},message:"must have property 'minimumProtocolFee'"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}if(data.buyTokenAddress !== undefined){if(!(typeof data.buyTokenAddress == "string")){var err16 = {instancePath:instancePath+"/buyTokenAddress",schemaPath:"/properties/buyTokenAddress/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}else {var err17 = {instancePath:instancePath,schemaPath:"/properties/buyTokenAddress",keyword:"properties",params:{error: "missing", missingProperty: "buyTokenAddress"},message:"must have property 'buyTokenAddress'"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}if(data.buyAmount !== undefined){if(!(typeof data.buyAmount == "string")){var err18 = {instancePath:instancePath+"/buyAmount",schemaPath:"/properties/buyAmount/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}else {var err19 = {instancePath:instancePath,schemaPath:"/properties/buyAmount",keyword:"properties",params:{error: "missing", missingProperty: "buyAmount"},message:"must have property 'buyAmount'"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}if(data.sellTokenAddress !== undefined){if(!(typeof data.sellTokenAddress == "string")){var err20 = {instancePath:instancePath+"/sellTokenAddress",schemaPath:"/properties/sellTokenAddress/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}else {var err21 = {instancePath:instancePath,schemaPath:"/properties/sellTokenAddress",keyword:"properties",params:{error: "missing", missingProperty: "sellTokenAddress"},message:"must have property 'sellTokenAddress'"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}if(data.sellAmount !== undefined){if(!(typeof data.sellAmount == "string")){var err22 = {instancePath:instancePath+"/sellAmount",schemaPath:"/properties/sellAmount/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}}else {var err23 = {instancePath:instancePath,schemaPath:"/properties/sellAmount",keyword:"properties",params:{error: "missing", missingProperty: "sellAmount"},message:"must have property 'sellAmount'"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}if(data.sources !== undefined){var data12 = data.sources;var valid2 = false;if(!valid2){if(Array.isArray(data12)){var valid4 = true;var len0 = data12.length;for(var i0=0; i0= 0 && data0 <= 4294967295)){var err0 = {instancePath:instancePath+"/chainId",schemaPath:"/properties/chainId/type",keyword:"type",params:{type: "uint32", nullable: false},message:"must be uint32"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}}else {var err1 = {instancePath:instancePath,schemaPath:"/properties/chainId",keyword:"properties",params:{error: "missing", missingProperty: "chainId"},message:"must have property 'chainId'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.price !== undefined){if(!(typeof data.price == "string")){var err2 = {instancePath:instancePath+"/price",schemaPath:"/properties/price/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}}else {var err3 = {instancePath:instancePath,schemaPath:"/properties/price",keyword:"properties",params:{error: "missing", missingProperty: "price"},message:"must have property 'price'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.value !== undefined){if(!(typeof data.value == "string")){var err4 = {instancePath:instancePath+"/value",schemaPath:"/properties/value/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}}else {var err5 = {instancePath:instancePath,schemaPath:"/properties/value",keyword:"properties",params:{error: "missing", missingProperty: "value"},message:"must have property 'value'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(data.gasPrice !== undefined){if(!(typeof data.gasPrice == "string")){var err6 = {instancePath:instancePath+"/gasPrice",schemaPath:"/properties/gasPrice/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}else {var err7 = {instancePath:instancePath,schemaPath:"/properties/gasPrice",keyword:"properties",params:{error: "missing", missingProperty: "gasPrice"},message:"must have property 'gasPrice'"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(data.gas !== undefined){if(!(typeof data.gas == "string")){var err8 = {instancePath:instancePath+"/gas",schemaPath:"/properties/gas/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}else {var err9 = {instancePath:instancePath,schemaPath:"/properties/gas",keyword:"properties",params:{error: "missing", missingProperty: "gas"},message:"must have property 'gas'"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(data.estimatedGas !== undefined){if(!(typeof data.estimatedGas == "string")){var err10 = {instancePath:instancePath+"/estimatedGas",schemaPath:"/properties/estimatedGas/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}else {var err11 = {instancePath:instancePath,schemaPath:"/properties/estimatedGas",keyword:"properties",params:{error: "missing", missingProperty: "estimatedGas"},message:"must have property 'estimatedGas'"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}if(data.protocolFee !== undefined){if(!(typeof data.protocolFee == "string")){var err12 = {instancePath:instancePath+"/protocolFee",schemaPath:"/properties/protocolFee/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}else {var err13 = {instancePath:instancePath,schemaPath:"/properties/protocolFee",keyword:"properties",params:{error: "missing", missingProperty: "protocolFee"},message:"must have property 'protocolFee'"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if(data.minimumProtocolFee !== undefined){if(!(typeof data.minimumProtocolFee == "string")){var err14 = {instancePath:instancePath+"/minimumProtocolFee",schemaPath:"/properties/minimumProtocolFee/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}else {var err15 = {instancePath:instancePath,schemaPath:"/properties/minimumProtocolFee",keyword:"properties",params:{error: "missing", missingProperty: "minimumProtocolFee"},message:"must have property 'minimumProtocolFee'"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}if(data.buyTokenAddress !== undefined){if(!(typeof data.buyTokenAddress == "string")){var err16 = {instancePath:instancePath+"/buyTokenAddress",schemaPath:"/properties/buyTokenAddress/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}else {var err17 = {instancePath:instancePath,schemaPath:"/properties/buyTokenAddress",keyword:"properties",params:{error: "missing", missingProperty: "buyTokenAddress"},message:"must have property 'buyTokenAddress'"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}if(data.buyAmount !== undefined){if(!(typeof data.buyAmount == "string")){var err18 = {instancePath:instancePath+"/buyAmount",schemaPath:"/properties/buyAmount/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}else {var err19 = {instancePath:instancePath,schemaPath:"/properties/buyAmount",keyword:"properties",params:{error: "missing", missingProperty: "buyAmount"},message:"must have property 'buyAmount'"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}if(data.sellTokenAddress !== undefined){if(!(typeof data.sellTokenAddress == "string")){var err20 = {instancePath:instancePath+"/sellTokenAddress",schemaPath:"/properties/sellTokenAddress/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}else {var err21 = {instancePath:instancePath,schemaPath:"/properties/sellTokenAddress",keyword:"properties",params:{error: "missing", missingProperty: "sellTokenAddress"},message:"must have property 'sellTokenAddress'"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}if(data.sellAmount !== undefined){if(!(typeof data.sellAmount == "string")){var err22 = {instancePath:instancePath+"/sellAmount",schemaPath:"/properties/sellAmount/type",keyword:"type",params:{type: "string", nullable: false},message:"must be string"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}}else {var err23 = {instancePath:instancePath,schemaPath:"/properties/sellAmount",keyword:"properties",params:{error: "missing", missingProperty: "sellAmount"},message:"must have property 'sellAmount'"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}if(data.sources !== undefined){var data12 = data.sources;var valid2 = false;if(!valid2){if(Array.isArray(data12)){var valid4 = true;var len0 = data12.length;for(var i0=0; i0 { } else { throw new Error(`Unexpected JSON persisted for state: ${state}`) } + } else { + // Should be false if you don't want new users to see the modal + window.localStorage.setItem("modal_meet_taho", "false") } } @@ -501,8 +506,6 @@ export default class Main extends BaseService { protected override async internalStartService(): Promise { await super.internalStartService() - this.indexingService.started().then(async () => this.chainService.started()) - const servicesToBeStarted = [ this.preferenceService.startService(), this.chainService.startService(), @@ -775,6 +778,9 @@ export default class Main extends BaseService { this.store.dispatch( setSnackbarMessage("Transaction signed, broadcasting...") ) + this.store.dispatch( + clearTransactionState(TransactionConstructionStatus.Idle) + ) }) earnSliceEmitter.on("earnDeposit", (message) => { @@ -1313,6 +1319,13 @@ export default class Main extends BaseService { } async connectProviderBridgeService(): Promise { + uiSliceEmitter.on("addCustomNetworkResponse", ([requestId, success]) => { + return this.providerBridgeService.handleAddNetworkRequest( + requestId, + success + ) + }) + this.providerBridgeService.emitter.on( "requestPermission", (permissionRequest: PermissionRequest) => { @@ -1614,6 +1627,10 @@ export default class Main extends BaseService { }) } + getAddNetworkRequestDetails(requestId: string): AddChainRequestData { + return this.providerBridgeService.getNewCustomRPCDetails(requestId) + } + async updateSignerTitle( signer: AccountSignerWithId, title: string @@ -1672,7 +1689,7 @@ export default class Main extends BaseService { const openTime = Date.now() port.onDisconnect.addListener(() => { - this.analyticsService.sendAnalyticsEvent("UI shown", { + this.analyticsService.sendAnalyticsEvent(AnalyticsEvent.UI_SHOWN, { openTime: new Date(openTime).toISOString(), closeTime: new Date().toISOString(), openLength: (Date.now() - openTime) / 1e3, diff --git a/background/networks.ts b/background/networks.ts index 41adfbd24a..7252a9834b 100644 --- a/background/networks.ts +++ b/background/networks.ts @@ -134,7 +134,7 @@ export type LegacyEVMTransaction = EVMTransaction & { * limit used to limit the gas expenditure on a transaction. This is used to * request a signed transaction, and does not include signature fields. * - * Nonce is permitted to be `undefined` as Tally internals can and often do + * Nonce is permitted to be `undefined` as Taho internals can and often do * populate the nonce immediately before a request is signed. * * On networks that roll up to ethereum - the rollup fee is directly proportional @@ -198,7 +198,7 @@ export type EIP1559Transaction = EVMTransaction & { * limit the gas expenditure on a transaction. This is used to request a signed * transaction, and does not include signature fields. * - * Nonce is permitted to be `undefined` as Tally internals can and often do + * Nonce is permitted to be `undefined` as Taho internals can and often do * populate the nonce immediately before a request is signed. */ export type EIP1559TransactionRequest = Pick< @@ -378,6 +378,10 @@ export function toHexChainID(chainID: string | number): string { return `0x${BigInt(chainID).toString(16)}` } +export const sameChainID = (chainID: string, other: string): boolean => { + return toHexChainID(chainID) === toHexChainID(other) +} + // There is probably some clever way to combine the following type guards into one function export const isEIP1559TransactionRequest = ( diff --git a/background/package.json b/background/package.json index ffda078506..126ce9236d 100644 --- a/background/package.json +++ b/background/package.json @@ -1,7 +1,7 @@ { "name": "@tallyho/tally-background", "version": "0.0.1", - "description": "Tally Ho, the community owned and operated Web3 wallet: api implementation.", + "description": "Taho, the community owned and operated Web3 wallet: api implementation.", "main": "index.ts", "repository": "git@github.com:thesis/tally-extension.git", "author": "Matt Luongo ", diff --git a/background/redux-slices/0x-swap.ts b/background/redux-slices/0x-swap.ts index 436fb8dffe..3ad5d223fe 100644 --- a/background/redux-slices/0x-swap.ts +++ b/background/redux-slices/0x-swap.ts @@ -198,7 +198,7 @@ function build0xUrlFromSwapRequest( // Depending on whether the set amount is buy or sell, request the trade. // The /price endpoint is for RFQ-T indicative quotes, while /quote is for - // firm quotes, which the Tally UI calls "final" quotes that the user + // firm quotes, which the Taho UI calls "final" quotes that the user // intends to fill. const tradeField = "buyAmount" in amount ? "buyAmount" : "sellAmount" diff --git a/background/redux-slices/abilities.ts b/background/redux-slices/abilities.ts index 936d8a2130..dc5e488504 100644 --- a/background/redux-slices/abilities.ts +++ b/background/redux-slices/abilities.ts @@ -59,7 +59,17 @@ const abilitiesSlice = createSlice({ if (!immerState.abilities[address]) { immerState.abilities[address] = {} } - immerState.abilities[address][ability.abilityId] = ability + if (immerState.abilities[address][ability.abilityId]) { + const existingAbility = + immerState.abilities[address][ability.abilityId] + immerState.abilities[address][ability.abilityId] = { + ...ability, + completed: existingAbility.completed, + removedFromUi: existingAbility.removedFromUi, + } + } else { + immerState.abilities[address][ability.abilityId] = ability + } }) }, updateAbility: (immerState, { payload }: { payload: Ability }) => { diff --git a/background/redux-slices/accounts.ts b/background/redux-slices/accounts.ts index 7c2664ee54..329451038a 100644 --- a/background/redux-slices/accounts.ts +++ b/background/redux-slices/accounts.ts @@ -11,6 +11,7 @@ import { import { AssetMainCurrencyAmount, AssetDecimalAmount, + isBuiltInNetworkBaseAsset, } from "./utils/asset-utils" import { DomainName, HexString, URI } from "../types" import { normalizeEVMAddress } from "../lib/utils" @@ -292,10 +293,9 @@ const accountSlice = createSlice({ const { address, network, - assetAmount: { - asset: { symbol: updatedAssetSymbol }, - }, + assetAmount: { asset }, } = updatedAccountBalance + const { symbol: updatedAssetSymbol } = asset const normalizedAddress = normalizeEVMAddress(address) const existingAccountData = @@ -309,7 +309,8 @@ const accountSlice = createSlice({ if (existingAccountData !== "loading") { if ( updatedAccountBalance.assetAmount.amount === 0n && - existingAccountData.balances[updatedAssetSymbol] === undefined + existingAccountData.balances[updatedAssetSymbol] === undefined && + !isBuiltInNetworkBaseAsset(asset, network) // add base asset even if balance is 0 ) { return } diff --git a/background/redux-slices/claim.ts b/background/redux-slices/claim.ts index 9e8ab7336f..501cdd0450 100644 --- a/background/redux-slices/claim.ts +++ b/background/redux-slices/claim.ts @@ -325,12 +325,12 @@ export const signTokenDelegationData = createBackgroundAsyncThunk( const delegatee = claim.selectedDelegate?.address if (delegatee) { - const TallyTokenContract = await getContract( + const TahoTokenContract = await getContract( DOGGO_TOKEN_ADDRESS, ERC2612_INTERFACE ) - const nonce: BigNumber = await TallyTokenContract.nonces(address) + const nonce: BigNumber = await TahoTokenContract.nonces(address) const nonceValue = Number(nonce) const timestamp = await getCurrentTimestamp() diff --git a/background/redux-slices/migrations/index.ts b/background/redux-slices/migrations/index.ts index 57f082cf81..140275ff92 100644 --- a/background/redux-slices/migrations/index.ts +++ b/background/redux-slices/migrations/index.ts @@ -21,12 +21,14 @@ import to21 from "./to-21" import to22 from "./to-22" import to23 from "./to-23" import to24 from "./to-24" +import to25 from "./to-25" +import to26 from "./to-26" /** * 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 = 25 +export const REDUX_STATE_VERSION = 26 /** * Common type for all migration functions. @@ -60,6 +62,8 @@ const allMigrations: { [targetVersion: string]: Migration } = { 22: to22, 23: to23, 24: to24, + 25: to25, + 26: to26, } /** diff --git a/background/redux-slices/migrations/to-25.ts b/background/redux-slices/migrations/to-25.ts new file mode 100644 index 0000000000..8ebac9100e --- /dev/null +++ b/background/redux-slices/migrations/to-25.ts @@ -0,0 +1,6 @@ +// Missing migration due to incorrect version update from 23 to 25. +export default ( + prevState: Record +): Record => { + return prevState +} diff --git a/background/redux-slices/migrations/to-26.ts b/background/redux-slices/migrations/to-26.ts new file mode 100644 index 0000000000..c339cf42b0 --- /dev/null +++ b/background/redux-slices/migrations/to-26.ts @@ -0,0 +1,40 @@ +type State = { + abilities: { + filter: { state: string; types: string[]; accounts: string[] } + abilities: { + [address: string]: { + [uuid: string]: unknown + } + } + hideDescription: boolean + } +} + +export default ( + prevState: Record +): Record => { + const typedPrevState = prevState as State + + const { abilities } = typedPrevState + + if (!abilities) { + return prevState + } + + const { filter } = abilities + + const types = filter.types.includes("claim") + ? filter.types + : [...filter.types, "claim"] + + return { + ...prevState, + abilities: { + ...abilities, + filter: { + ...filter, + types, + }, + }, + } +} diff --git a/background/redux-slices/nfts_update.ts b/background/redux-slices/nfts_update.ts index 7fa6dfe490..84fe227e8b 100644 --- a/background/redux-slices/nfts_update.ts +++ b/background/redux-slices/nfts_update.ts @@ -170,40 +170,6 @@ export function parseNFTs(nfts: NFT[]): NFTCached[] { }) } -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)) { - return collection.owners.length === 1 - ? [] - : { - ...collection, - owners: collection.owners.filter((owner) => owner !== address), - } - } - - return collection - }) -} - -export function initializeCollections( - collections: NFTCollection[] -): NFTsSliceState { - const state: NFTsSliceState = { - isReloading: false, - nfts: {}, - filters: { collections: [], accounts: [], type: "desc" }, - } - collections.forEach((collection) => { - updateCollection(state, collection) - updateFilters(state, collection) - }) - return state -} - const NFTsSlice = createSlice({ name: "nftsUpdate", initialState: { @@ -215,11 +181,22 @@ const NFTsSlice = createSlice({ initializeNFTs: ( immerState, { - payload, + payload: collections, }: { payload: NFTCollection[] } - ) => initializeCollections(payload), + ) => { + const state: NFTsSliceState = { + isReloading: false, + nfts: {}, + filters: { collections: [], accounts: [], type: "desc" }, + } + collections.forEach((collection) => { + updateCollection(state, collection) + updateFilters(state, collection) + }) + return state + }, updateNFTsCollections: ( immerState, { payload: collections }: { payload: NFTCollection[] } @@ -273,48 +250,69 @@ const NFTsSlice = createSlice({ ) => { const normalizedAddress = normalizeEVMAddress(address) - removeAccountFromFilters(immerState, normalizedAddress) + immerState.filters.accounts = immerState.filters.accounts.filter( + ({ id }) => id !== address + ) + immerState.filters.collections = immerState.filters.collections.flatMap( + (collection) => { + if (collection.owners?.includes(address)) { + return collection.owners.length === 1 + ? [] + : { + ...collection, + owners: collection.owners.filter( + (owner) => owner !== address + ), + } + } + + return collection + } + ) + Object.keys(immerState.nfts).forEach((chainID) => { delete immerState.nfts[chainID][normalizedAddress] }) }, deleteTransferredNFTs: ( immerState, - { payload: transferredNFTs }: { payload: TransferredNFT[] } + { payload: transfers }: { payload: TransferredNFT[] } ) => { - transferredNFTs.forEach(({ id: nftID, chainID, from: address }) => { - if (!address) return - - const normalizedAddress = normalizeEVMAddress(address) - Object.keys(immerState.nfts[chainID][normalizedAddress] ?? {}).forEach( - (collectionID) => { - const collection = - immerState.nfts[chainID]?.[normalizedAddress]?.[collectionID] - - if (collection) { - const hasTransferredNFT = collection.nfts.some( - (nft) => nft.id === nftID - ) - - if (hasTransferredNFT) { - if (collection.nfts.length === 1) { - immerState.filters.collections = - immerState.filters.collections.filter( - ({ id }) => id !== collectionID - ) - delete immerState.nfts[chainID][normalizedAddress][ - collectionID - ] - } else { - collection.nfts = collection.nfts.filter( - (nft) => nft.id !== nftID + transfers.forEach( + ({ id: nftID, chainID, from: address, collectionID }) => { + if (!address || !collectionID) return + + const normalizedAddress = normalizeEVMAddress(address) + const collection = + immerState.nfts[chainID]?.[normalizedAddress]?.[collectionID] + + if (collection) { + const hasLastNFT = (collection.nftCount ?? 0) <= 1 + const hasCachedTransferredNFT = collection.nfts.some( + (nft) => nft.id === nftID + ) + + // let's update NFT count manually in case of multiple transfers from the same collection + collection.nftCount = (collection.nftCount ?? 1) - 1 + + if (hasCachedTransferredNFT || hasLastNFT) { + if (collection.nfts.length === 1 || hasLastNFT) { + // this is last cached NFT or we know it was the last one owned then remove it from Redux cache + immerState.filters.collections = + immerState.filters.collections.filter( + ({ id }) => id !== collectionID ) - } + delete immerState.nfts[chainID][normalizedAddress][collectionID] + } else { + // there are more NFTs owned in this collection, let's just remove transferred one + collection.nfts = collection.nfts.filter( + (nft) => nft.id !== nftID + ) } } } - ) - }) + } + ) }, cleanCachedNFTs: (immerState) => { Object.keys(immerState.nfts).forEach((chainID) => diff --git a/background/redux-slices/selectors/transactionConstructionSelectors.ts b/background/redux-slices/selectors/transactionConstructionSelectors.ts index 5bdde53933..8b11efb2dd 100644 --- a/background/redux-slices/selectors/transactionConstructionSelectors.ts +++ b/background/redux-slices/selectors/transactionConstructionSelectors.ts @@ -129,3 +129,9 @@ export const selectCurrentlyChosenNetworkFees = createSelector( ], (feeData) => feeData ) + +export const selectHasInsufficientFunds = createSelector( + selectTransactionData, + (transactionDetails) => + !!transactionDetails?.annotation?.warnings?.includes("insufficient-funds") +) diff --git a/background/redux-slices/tests/accounts.unit.test.ts b/background/redux-slices/tests/accounts.unit.test.ts new file mode 100644 index 0000000000..a0e2e56863 --- /dev/null +++ b/background/redux-slices/tests/accounts.unit.test.ts @@ -0,0 +1,184 @@ +import { AccountBalance } from "../../accounts" +import { SmartContractFungibleAsset } from "../../assets" +import { ETH, ETHEREUM } from "../../constants" +import { createSmartContractAsset } from "../../tests/factories" +import reducer, { + AccountData, + AccountState, + updateAccountBalance, +} from "../accounts" + +const ADDRESS_MOCK = "0x208e94d5661a73360d9387d3ca169e5c130090cd" +const ACCOUNT_MOCK = { + address: ADDRESS_MOCK, + network: ETHEREUM, + balances: {}, + ens: {}, + defaultName: "Topa", + defaultAvatar: "", +} +const ASSET_MOCK: SmartContractFungibleAsset = createSmartContractAsset({ + symbol: "XYZ", +}) +const BALANCE_MOCK: AccountBalance = { + address: ADDRESS_MOCK, + assetAmount: { + asset: ETH, + amount: 1n, + }, + network: ETHEREUM, + retrievedAt: 1, + dataSource: "local", +} + +describe("Accounts redux slice", () => { + describe(updateAccountBalance, () => { + let state: AccountState + + beforeEach(() => { + state = { + accountsData: { evm: {} }, + combinedData: { + totalMainCurrencyValue: "", + assets: [], + }, + } + }) + + it("should update positive balance for account that is loading", () => { + const balances = [BALANCE_MOCK] + state.accountsData.evm = { + [ETHEREUM.chainID]: { [ADDRESS_MOCK]: "loading" }, + } + const updated = reducer( + state, + updateAccountBalance({ + balances, + addressOnNetwork: { address: ADDRESS_MOCK, network: ETHEREUM }, + }) + ) + + const updatedAccountData = + updated.accountsData.evm[ETHEREUM.chainID][ADDRESS_MOCK] + + expect(updatedAccountData).not.toEqual("loading") + + const updatedBalance = (updatedAccountData as AccountData)?.balances + expect(updatedBalance?.[ETH.symbol].assetAmount.amount).toBe(1n) + expect(updated.combinedData.totalMainCurrencyValue).toBe("") + }) + + it("should update positive balance for account that is loaded", () => { + const balances = [BALANCE_MOCK] + state.accountsData.evm = { + [ETHEREUM.chainID]: { [ADDRESS_MOCK]: ACCOUNT_MOCK }, + } + const updated = reducer( + state, + updateAccountBalance({ + balances, + addressOnNetwork: { address: ADDRESS_MOCK, network: ETHEREUM }, + }) + ) + const updatedAccountData = + updated.accountsData.evm[ETHEREUM.chainID][ADDRESS_MOCK] + const updatedBalance = (updatedAccountData as AccountData)?.balances + + expect(updatedBalance?.[ETH.symbol].assetAmount.amount).toBe(1n) + expect(updated.combinedData.totalMainCurrencyValue).toBe("") + }) + + it("should updated zero balance for account that is loading", () => { + const balances: AccountBalance[] = [ + { + ...BALANCE_MOCK, + assetAmount: { + asset: ETH, + amount: 0n, + }, + }, + ] + state.accountsData.evm = { + [ETHEREUM.chainID]: { [ADDRESS_MOCK]: "loading" }, + } + const updated = reducer( + state, + updateAccountBalance({ + balances, + addressOnNetwork: { address: ADDRESS_MOCK, network: ETHEREUM }, + }) + ) + + const updatedAccountData = + updated.accountsData.evm[ETHEREUM.chainID][ADDRESS_MOCK] + + expect(updatedAccountData).not.toEqual("loading") + + const updatedBalance = (updatedAccountData as AccountData)?.balances + expect(updatedBalance?.[ETH.symbol].assetAmount.amount).toBe(0n) + }) + + it("should update zero balance for account that is loaded", () => { + const balances: AccountBalance[] = [ + { + ...BALANCE_MOCK, + assetAmount: { + asset: ETH, + amount: 0n, + }, + }, + ] + state.accountsData.evm = { + [ETHEREUM.chainID]: { [ADDRESS_MOCK]: ACCOUNT_MOCK }, + } + const updated = reducer( + state, + updateAccountBalance({ + balances, + addressOnNetwork: { address: ADDRESS_MOCK, network: ETHEREUM }, + }) + ) + const updatedAccountData = + updated.accountsData.evm[ETHEREUM.chainID][ADDRESS_MOCK] + const updatedBalance = (updatedAccountData as AccountData)?.balances + + expect(updatedBalance?.[ETH.symbol].assetAmount.amount).toBe(0n) + }) + + it("should update positive balance multiple times", () => { + state.accountsData.evm = { + [ETHEREUM.chainID]: { [ADDRESS_MOCK]: ACCOUNT_MOCK }, + } + + const initial = reducer( + state, + updateAccountBalance({ + balances: [ + BALANCE_MOCK, + { ...BALANCE_MOCK, assetAmount: { asset: ASSET_MOCK, amount: 5n } }, + ], + addressOnNetwork: { address: ADDRESS_MOCK, network: ETHEREUM }, + }) + ) + const updated = reducer( + initial, + updateAccountBalance({ + balances: [ + { + ...BALANCE_MOCK, + assetAmount: { asset: ASSET_MOCK, amount: 10n }, + }, + ], + addressOnNetwork: { address: ADDRESS_MOCK, network: ETHEREUM }, + }) + ) + + const updatedAccountData = + updated.accountsData.evm[ETHEREUM.chainID][ADDRESS_MOCK] + const updatedBalance = (updatedAccountData as AccountData)?.balances + + expect(updatedBalance?.[ETH.symbol].assetAmount.amount).toBe(1n) + expect(updatedBalance?.[ASSET_MOCK.symbol].assetAmount.amount).toBe(10n) + }) + }) +}) diff --git a/background/redux-slices/tests/nfts_update.integration.test.ts b/background/redux-slices/tests/nfts_update.integration.test.ts new file mode 100644 index 0000000000..989000490f --- /dev/null +++ b/background/redux-slices/tests/nfts_update.integration.test.ts @@ -0,0 +1,380 @@ +import { ETHEREUM } from "../../constants" +import { normalizeEVMAddress } from "../../lib/utils" +import { + OWNER_MOCK, + COLLECTION_MOCK, + NFT_MOCK, + TRANSFER_MOCK, + extractCollection, +} from "./nfts_update.utils" +import reducer, { + deleteTransferredNFTs, + NFTsSliceState, + updateNFTsCollections, + updateNFTs, +} from "../nfts_update" + +describe("NFTs redux slice", () => { + describe("Delete transferred NFTs from state", () => { + let state: NFTsSliceState + + beforeEach(() => { + state = { + isReloading: false, + nfts: {}, + filters: { collections: [], accounts: [], type: "desc" }, + } + }) + + describe("when collection has missing data", () => { + it("should handle transfer even if collection is not saved in state", () => { + const stateAfterTransfers = reducer( + state, + deleteTransferredNFTs([TRANSFER_MOCK]) + ) + expect(stateAfterTransfers.nfts).toMatchObject({}) + }) + + it("should handle transfer if collection has many owned NFTs but none cached", () => { + const stateWithCollections = reducer( + state, + updateNFTsCollections([{ ...COLLECTION_MOCK, nftCount: 10 }]) + ) + const stateAfterTransfers = reducer( + stateWithCollections, + deleteTransferredNFTs([TRANSFER_MOCK]) + ) + + const collection = extractCollection(stateAfterTransfers) + expect(collection).toBeDefined() + expect(collection?.nfts.length).toEqual(0) + expect(collection?.nftCount).toEqual(9) + }) + + it("should handle transfer if collection has single NFT owned but none cached", () => { + const stateWithCollections = reducer( + state, + updateNFTsCollections([{ ...COLLECTION_MOCK, nftCount: 1 }]) + ) + const stateAfterTransfers = reducer( + stateWithCollections, + deleteTransferredNFTs([TRANSFER_MOCK]) + ) + + const collection = extractCollection(stateAfterTransfers) + expect(collection).not.toBeDefined() // should remove collection as the last NFT was transferred + }) + + it("should handle transfer when there is unknown number of NFTs owned", () => { + const stateWithCollections = reducer( + state, + updateNFTsCollections([{ ...COLLECTION_MOCK, nftCount: undefined }]) + ) + + expect(() => + reducer(stateWithCollections, deleteTransferredNFTs([TRANSFER_MOCK])) + ).not.toThrow() + }) + + it("should handle transfer with different owner address format", () => { + const owner = "0xAD23AB2e2ec036a9ec319187e9659fcf8ddd6d38" // not normalized + + const stateWithCollections = reducer( + state, + updateNFTsCollections([{ ...COLLECTION_MOCK, nftCount: 3, owner }]) + ) + const stateAfterTransfers = reducer( + stateWithCollections, + deleteTransferredNFTs([{ ...TRANSFER_MOCK, from: owner }]) + ) + + const collection = extractCollection( + stateAfterTransfers, + COLLECTION_MOCK.id, + ETHEREUM.chainID, + normalizeEVMAddress(owner) + ) + + expect(collection).toBeDefined() + expect(collection?.nftCount).toEqual(2) + }) + + it("should handle transfer even if chainID is unknown", () => { + const stateWithCollections = reducer( + state, + updateNFTsCollections([COLLECTION_MOCK]) + ) + const stateAfterTransfers = reducer( + stateWithCollections, + deleteTransferredNFTs([{ ...TRANSFER_MOCK, chainID: "12345" }]) + ) + + const collection = extractCollection(stateAfterTransfers) + expect(collection).toBeDefined() + }) + }) + + describe("when there was a single transfer", () => { + it("should handle transfer when there are more than 1 NFTs owned and cached", () => { + const secondID = "xyz-2" + + const stateWithCollections = reducer( + state, + updateNFTsCollections([{ ...COLLECTION_MOCK, nftCount: 2 }]) + ) + const stateWithNFTs = reducer( + stateWithCollections, + updateNFTs({ + account: { + address: OWNER_MOCK, + network: ETHEREUM, + }, + collectionID: COLLECTION_MOCK.id, + hasNextPage: false, + nfts: [NFT_MOCK, { ...NFT_MOCK, id: secondID, tokenId: secondID }], + }) + ) + const stateAfterTransfers = reducer( + stateWithNFTs, + deleteTransferredNFTs([TRANSFER_MOCK]) + ) + + const collection = extractCollection(stateAfterTransfers) + + expect(collection).toBeDefined() + expect(collection?.nftCount).toEqual(1) + expect(collection?.nfts.length).toEqual(1) + expect(collection?.nfts[0].id).toBe(secondID) + }) + + it("should handle transfer when it is last NFT owned and cached", () => { + const stateWithCollections = reducer( + state, + updateNFTsCollections([COLLECTION_MOCK]) + ) + const stateWithNFTs = reducer( + stateWithCollections, + updateNFTs({ + account: { + address: OWNER_MOCK, + network: ETHEREUM, + }, + collectionID: COLLECTION_MOCK.id, + hasNextPage: false, + nfts: [NFT_MOCK], + }) + ) + const stateAfterTransfers = reducer( + stateWithNFTs, + deleteTransferredNFTs([TRANSFER_MOCK]) + ) + + const collection = extractCollection(stateAfterTransfers) + + expect(collection).not.toBeDefined() + }) + + it("should handle transfer when transferred NFT is not cached", () => { + const secondID = "xyz-2" + const thirdID = "xyz-3" + + const stateWithCollections = reducer( + state, + updateNFTsCollections([{ ...COLLECTION_MOCK, nftCount: 3 }]) + ) + const stateWithNFTs = reducer( + stateWithCollections, + updateNFTs({ + account: { + address: OWNER_MOCK, + network: ETHEREUM, + }, + collectionID: COLLECTION_MOCK.id, + hasNextPage: false, + nfts: [NFT_MOCK, { ...NFT_MOCK, id: secondID, tokenId: secondID }], + }) + ) + const stateAfterTransfers = reducer( + stateWithNFTs, + deleteTransferredNFTs([{ ...TRANSFER_MOCK, id: thirdID }]) + ) + + const collection = extractCollection(stateAfterTransfers) + + expect(collection).toBeDefined() + expect(collection?.nftCount).toEqual(2) + expect(collection?.nfts.length).toEqual(2) + expect(collection?.nfts.map((nft) => nft.id)).not.toContain(thirdID) + }) + }) + + describe("when there were multiple transfers", () => { + it("should handle transfers from multiple collections", () => { + const secondCollectionID = "abc" + const firstNftID = "abc-1" + const secondNftID = "abc-2" + + const stateWithCollections = reducer( + state, + updateNFTsCollections([ + COLLECTION_MOCK, + { + ...COLLECTION_MOCK, + nftCount: 2, + id: secondCollectionID, + name: "ABC", + }, + ]) + ) + const stateWithNFTs1 = reducer( + stateWithCollections, + updateNFTs({ + account: { + address: OWNER_MOCK, + network: ETHEREUM, + }, + collectionID: COLLECTION_MOCK.id, + hasNextPage: false, + nfts: [NFT_MOCK], + }) + ) + const stateWithNFTs2 = reducer( + stateWithNFTs1, + updateNFTs({ + account: { + address: OWNER_MOCK, + network: ETHEREUM, + }, + collectionID: secondCollectionID, + hasNextPage: false, + nfts: [ + { + ...NFT_MOCK, + id: firstNftID, + tokenId: firstNftID, + collectionID: secondCollectionID, + name: "ABC 1 NFT", + }, + { + ...NFT_MOCK, + id: secondNftID, + tokenId: secondNftID, + collectionID: secondCollectionID, + name: "ABC 2 NFT", + }, + ], + }) + ) + const stateAfterTransfers = reducer( + stateWithNFTs2, + deleteTransferredNFTs([ + TRANSFER_MOCK, + { + ...TRANSFER_MOCK, + id: secondNftID, + collectionID: secondCollectionID, + }, + ]) + ) + + const firstCollection = extractCollection(stateAfterTransfers) + const secondCollection = extractCollection( + stateAfterTransfers, + secondCollectionID + ) + const secondCollectionNFTs = secondCollection?.nfts.map((nft) => nft.id) + + expect(firstCollection).not.toBeDefined() + expect(secondCollection).toBeDefined() + expect(secondCollection?.nftCount).toEqual(1) + expect(secondCollectionNFTs).not.toContain(secondNftID) + expect(secondCollectionNFTs).toContain(firstNftID) + }) + + it("should handle transfers from the same collection", () => { + const secondID = "xyz-2" + const thirdID = "xyz-3" + + const stateWithCollections = reducer( + state, + updateNFTsCollections([{ ...COLLECTION_MOCK, nftCount: 3 }]) + ) + const stateWithNFTs = reducer( + stateWithCollections, + updateNFTs({ + account: { + address: OWNER_MOCK, + network: ETHEREUM, + }, + collectionID: COLLECTION_MOCK.id, + hasNextPage: false, + nfts: [ + NFT_MOCK, + { + ...NFT_MOCK, + id: secondID, + tokenId: secondID, + name: "XYZ 2 NFT", + }, + { ...NFT_MOCK, id: thirdID, tokenId: thirdID, name: "XYZ 3 NFT" }, + ], + }) + ) + const stateAfterTransfers = reducer( + stateWithNFTs, + deleteTransferredNFTs([ + TRANSFER_MOCK, + { ...TRANSFER_MOCK, id: secondID }, + ]) + ) + + const collection = extractCollection(stateAfterTransfers) + const collectionNFTs = collection?.nfts.map((nft) => nft.id) + + expect(collection).toBeDefined() + expect(collection?.nftCount).toEqual(1) + expect(collectionNFTs).toContain(thirdID) + }) + + it("should handle transfers of all NFTs owned from the same collection", () => { + const secondID = "xyz-2" + + const stateWithCollections = reducer( + state, + updateNFTsCollections([{ ...COLLECTION_MOCK, nftCount: 2 }]) + ) + const stateWithNFTs = reducer( + stateWithCollections, + updateNFTs({ + account: { + address: OWNER_MOCK, + network: ETHEREUM, + }, + collectionID: COLLECTION_MOCK.id, + hasNextPage: false, + nfts: [ + NFT_MOCK, + { + ...NFT_MOCK, + id: secondID, + tokenId: secondID, + name: "XYZ 2 NFT", + }, + ], + }) + ) + const stateAfterTransfers = reducer( + stateWithNFTs, + deleteTransferredNFTs([ + TRANSFER_MOCK, + { ...TRANSFER_MOCK, id: secondID }, + ]) + ) + + const collection = extractCollection(stateAfterTransfers) + + expect(collection).not.toBeDefined() + }) + }) + }) +}) diff --git a/background/redux-slices/tests/nfts_update.unit.test.ts b/background/redux-slices/tests/nfts_update.unit.test.ts index 76cb99d4d4..a1a7d837b9 100644 --- a/background/redux-slices/tests/nfts_update.unit.test.ts +++ b/background/redux-slices/tests/nfts_update.unit.test.ts @@ -1,25 +1,11 @@ import { ETHEREUM } from "../../constants" +import { + OWNER_MOCK, + COLLECTION_MOCK, + extractCollection, +} from "./nfts_update.utils" 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 @@ -35,9 +21,7 @@ describe("NFTs redux slice", () => { it("should add a single collection", () => { updateCollection(state, COLLECTION_MOCK) - expect( - state.nfts[ETHEREUM.chainID][OWNER_MOCK][COLLECTION_MOCK.id] - ).toMatchObject({ + expect(extractCollection(state)).toMatchObject({ id: COLLECTION_MOCK.id, name: COLLECTION_MOCK.name, nftCount: 1, @@ -67,11 +51,10 @@ describe("NFTs redux slice", () => { }, }) - const updated = - state.nfts[ETHEREUM.chainID][OWNER_MOCK][COLLECTION_MOCK.id] + const updated = extractCollection(state) - expect(updated.name).toBe("new") - expect(updated.floorPrice?.value).toEqual(2) + expect(updated?.name).toBe("new") + expect(updated?.floorPrice?.value).toEqual(2) }) }) }) diff --git a/background/redux-slices/tests/nfts_update.utils.ts b/background/redux-slices/tests/nfts_update.utils.ts new file mode 100644 index 0000000000..c3bc4bca22 --- /dev/null +++ b/background/redux-slices/tests/nfts_update.utils.ts @@ -0,0 +1,53 @@ +import { ETHEREUM } from "../../constants" +import { NFT, TransferredNFT } from "../../nfts" +import { NFTCollectionCached, NFTsSliceState } from "../nfts_update" + +export const OWNER_MOCK = "0x1234" +export const RECEIVER_MOCK = "0xABCD" +export const COLLECTION_MOCK = { + id: "xyz", + name: "XYZ", + owner: OWNER_MOCK, + network: ETHEREUM, + hasBadges: false, + nftCount: 1, + totalNftCount: 10, + floorPrice: { + value: 1000000000000000000n, + token: { + name: "Ether", + symbol: "ETH", + decimals: 18, + }, + }, +} +export const NFT_MOCK: NFT = { + id: "xyz-1", + tokenId: "xyz-1", + collectionID: COLLECTION_MOCK.id, + name: "XYZ NFT", + description: "🐶", + attributes: [], + rarity: {}, + contract: "0x0123", + owner: OWNER_MOCK, + network: ETHEREUM, + isBadge: false, +} +export const TRANSFER_MOCK: TransferredNFT = { + id: NFT_MOCK.id, + collectionID: COLLECTION_MOCK.id, + chainID: ETHEREUM.chainID, + from: OWNER_MOCK, + to: RECEIVER_MOCK, + isKnownFromAddress: true, + isKnownToAddress: false, +} + +export const extractCollection = ( + state: NFTsSliceState, + collectionID: string = COLLECTION_MOCK.id, + chainID: string = ETHEREUM.chainID, + address: string = OWNER_MOCK +): NFTCollectionCached | undefined => + state.nfts?.[chainID]?.[address]?.[collectionID] diff --git a/background/redux-slices/ui.ts b/background/redux-slices/ui.ts index 9e49c30aad..3806481423 100644 --- a/background/redux-slices/ui.ts +++ b/background/redux-slices/ui.ts @@ -48,6 +48,7 @@ export type Events = { userActivityEncountered: AddressOnNetwork newSelectedNetwork: EVMNetwork updateAnalyticsPreferences: Partial + addCustomNetworkResponse: [string, boolean] } export const emitter = new Emittery() @@ -242,6 +243,20 @@ export const updateSignerTitle = createBackgroundAsyncThunk( } ) +export const getAddNetworkRequestDetails = createBackgroundAsyncThunk( + "ui/getAddNetworkRequestDetails", + async (requestId: string, { extra: { main } }) => { + return main.getAddNetworkRequestDetails(requestId) + } +) + +export const addNetworkUserResponse = createBackgroundAsyncThunk( + "ui/handleAddNetworkConfirmation", + async ([requestId, result]: [string, boolean]) => { + emitter.emit("addCustomNetworkResponse", [requestId, result]) + } +) + export const userActivityEncountered = createBackgroundAsyncThunk( "ui/userActivityEncountered", async (addressNetwork: AddressOnNetwork) => { diff --git a/background/redux-slices/utils.ts b/background/redux-slices/utils.ts index 08ec159aa4..574cf499e1 100644 --- a/background/redux-slices/utils.ts +++ b/background/redux-slices/utils.ts @@ -118,7 +118,7 @@ export function createBackgroundAsyncThunk< // Exit early if this type prefix is already aliased for handling in the // background script. if (allAliases[typePrefix]) { - throw new Error("Attempted to register an alias twice.") + throw new Error(`Attempted to register an alias twice: ${typePrefix}`) } // Use reduxtools' createAsyncThunk to build the infrastructure. diff --git a/background/services/abilities/db.ts b/background/services/abilities/db.ts index 1f9480ce5e..a1b79b22a9 100644 --- a/background/services/abilities/db.ts +++ b/background/services/abilities/db.ts @@ -27,6 +27,17 @@ export class AbilitiesDatabase extends Dexie { await this.abilities.add(ability) return true } + const { id, ...correctAbility } = existingAbility as Ability & { + id: number + } + if (JSON.stringify(correctAbility) !== JSON.stringify(ability)) { + await this.abilities.update(existingAbility, { + ...ability, + completed: existingAbility.completed, + removedFromUi: existingAbility.removedFromUi, + }) + return true + } return false } diff --git a/background/services/analytics/index.ts b/background/services/analytics/index.ts index 4463e856da..618ab75e90 100644 --- a/background/services/analytics/index.ts +++ b/background/services/analytics/index.ts @@ -5,7 +5,12 @@ import { ServiceCreatorFunction, ServiceLifecycleEvents } from "../types" import BaseService from "../base" import { AnalyticsDatabase, getOrCreateDB } from "./db" -import { deletePerson, getPersonId, sendPosthogEvent } from "../../lib/posthog" +import { + AnalyticsEvent, + deletePerson, + getPersonId, + sendPosthogEvent, +} from "../../lib/posthog" import ChainService from "../chain" import PreferenceService from "../preferences" import { FeatureFlags, isEnabled as isFeatureFlagEnabled } from "../../features" @@ -77,10 +82,8 @@ export default class AnalyticsService extends BaseService { ) if (isNew) { - await this.sendAnalyticsEvent("New install") + await this.sendAnalyticsEvent(AnalyticsEvent.NEW_INSTALL) } - - await this.sendAnalyticsEvent("Background start") } } @@ -91,7 +94,7 @@ export default class AnalyticsService extends BaseService { } async sendAnalyticsEvent( - eventName: string, + eventName: AnalyticsEvent, payload?: Record ): Promise { // @TODO: implement event batching @@ -117,7 +120,7 @@ export default class AnalyticsService extends BaseService { private initializeListeners() { // ⚠️ Note: We NEVER send addresses to analytics! this.chainService.emitter.on("newAccountToTrack", () => { - this.sendAnalyticsEvent("Address added to tracking on network", { + this.sendAnalyticsEvent(AnalyticsEvent.NEW_ACCOUNT_TO_TRACK, { description: ` This event is fired when any address on a network is added to the tracked list. diff --git a/background/services/analytics/tests/index.integration.test.ts b/background/services/analytics/tests/index.integration.test.ts index a4b7bc28ea..1e520718ec 100644 --- a/background/services/analytics/tests/index.integration.test.ts +++ b/background/services/analytics/tests/index.integration.test.ts @@ -14,6 +14,8 @@ import { Writeable } from "../../../types" import PreferenceService from "../../preferences" import * as posthog from "../../../lib/posthog" +const { AnalyticsEvent } = posthog + describe("AnalyticsService", () => { let analyticsService: AnalyticsService let preferenceService: PreferenceService @@ -73,13 +75,9 @@ describe("AnalyticsService", () => { await analyticsService.startService() }) - it("should not send any analytics events when both of the feature flags are off", async () => { - await analyticsService.sendAnalyticsEvent("Background start") - expect(fetch).not.toBeCalled() - }) - it("should not send any analytics events when only the support feature flag is on but the default on is not", async () => { - await analyticsService.sendAnalyticsEvent("Background start") + it("should not send any analytics events when both of the feature flags are off", async () => { + await analyticsService.sendAnalyticsEvent(AnalyticsEvent.UI_SHOWN) expect(fetch).not.toBeCalled() }) @@ -130,27 +128,22 @@ describe("AnalyticsService", () => { it("should generate a new uuid and save it to database", async () => { // Called once for generating the new user uuid - // and once for the 'Background start' and once for the `New install' event - expect(uuid.v4).toBeCalledTimes(3) + // and once for the `New install' event + expect(uuid.v4).toBeCalledTimes(2) expect(analyticsService["db"].setAnalyticsUUID).toBeCalledTimes(1) }) - it("should send 'New Install' and 'Background start' events", () => { + it("should send 'New Install' event", () => { // Posthog events are sent through global.fetch method // During initialization we send 2 events - expect(fetch).toBeCalledTimes(2) + expect(fetch).toBeCalledTimes(1) expect(posthog.sendPosthogEvent).toHaveBeenCalledWith( expect.anything(), "New install", undefined ) - expect(posthog.sendPosthogEvent).toHaveBeenCalledWith( - expect.anything(), - "Background start", - undefined - ) }) }) describe("feature is released and enabled (analytics uuid has been created earlier)", () => { @@ -182,7 +175,7 @@ describe("AnalyticsService", () => { expect(analyticsService.sendAnalyticsEvent).not.toHaveBeenCalledWith( expect.anything(), - "New install", + AnalyticsEvent.NEW_INSTALL, undefined ) @@ -190,12 +183,7 @@ describe("AnalyticsService", () => { preferenceService.updateAnalyticsPreferences ).not.toHaveBeenCalled() }) - it("should send 'Background start' event when the service starts", async () => { - expect(analyticsService.sendAnalyticsEvent).toBeCalledTimes(1) - expect(analyticsService.sendAnalyticsEvent).toBeCalledWith( - "Background start" - ) - }) + it("should set the uninstall url when the service starts", async () => { expect(browser.runtime.setUninstallURL).toBeCalledTimes(1) }) @@ -228,7 +216,7 @@ describe("AnalyticsService", () => { expect(fetch).not.toBeCalled() }) it("should not send any event when the 'sendAnalyticsEvent()' method is called", async () => { - await analyticsService.sendAnalyticsEvent("Background start") + await analyticsService.sendAnalyticsEvent(AnalyticsEvent.UI_SHOWN) expect(analyticsService.sendAnalyticsEvent).toBeCalledTimes(1) expect(posthog.sendPosthogEvent).not.toBeCalled() diff --git a/background/services/base.ts b/background/services/base.ts index c197043704..e625a5245a 100644 --- a/background/services/base.ts +++ b/background/services/base.ts @@ -155,7 +155,8 @@ export default abstract class BaseService this.alarmSchedules[alarm.name]?.handler(alarm) } - private serviceState: "unstarted" | "started" | "stopped" = "unstarted" + private serviceState: "unstarted" | "starting" | "started" | "stopped" = + "unstarted" /** * {@inheritdoc Service.started} @@ -171,6 +172,7 @@ export default abstract class BaseService throw new Error("Service is already stopped and cannot be restarted.") case "unstarted": + case "starting": return this.emitter.once("serviceStarted").then(() => this) default: { @@ -202,14 +204,16 @@ export default abstract class BaseService readonly startService = async (): Promise => { switch (this.serviceState) { case "started": + case "starting": return case "stopped": throw new Error("Service is already stopped and cannot be restarted.") case "unstarted": - this.serviceState = "started" + this.serviceState = "starting" await this.internalStartService() + this.serviceState = "started" this.emitter.emit("serviceStarted", undefined) break @@ -238,9 +242,14 @@ export default abstract class BaseService case "stopped": return + case "starting": + await this.started() + await this.stopService() + break + case "started": - this.serviceState = "stopped" await this.internalStopService() + this.serviceState = "stopped" this.emitter.emit("serviceStopped", undefined) break diff --git a/background/services/chain/db.ts b/background/services/chain/db.ts index b8ab184a79..63a39c4c17 100644 --- a/background/services/chain/db.ts +++ b/background/services/chain/db.ts @@ -16,6 +16,8 @@ import { CHAIN_ID_TO_RPC_URLS, DEFAULT_NETWORKS, GOERLI, + isBuiltInNetwork, + NETWORK_BY_CHAIN_ID, POLYGON, } from "../../constants" @@ -166,6 +168,19 @@ export class ChainDatabase extends Dexie { this.version(7).stores({ rpcUrls: "&chainID, rpcUrls", }) + + // Updates saved accounts stored networks for old installs + this.version(8).upgrade((tx) => { + tx.table("accountsToTrack") + .toCollection() + .modify((account: AddressOnNetwork) => { + if (isBuiltInNetwork(account.network)) { + Object.assign(account, { + network: NETWORK_BY_CHAIN_ID[account.network.chainID], + }) + } + }) + }) } async initialize(): Promise { diff --git a/background/services/chain/index.ts b/background/services/chain/index.ts index 07e3cd4e93..da2db160b7 100644 --- a/background/services/chain/index.ts +++ b/background/services/chain/index.ts @@ -19,6 +19,7 @@ import { SignedTransaction, toHexChainID, NetworkBaseAsset, + sameChainID, } from "../../networks" import { AssetTransfer } from "../../assets" import { @@ -63,7 +64,7 @@ import { OPTIMISM_GAS_ORACLE_ADDRESS, } from "./utils/optimismGasPriceOracle" import KeyringService from "../keyring" -import type { ValidatedAddEthereumChainParameter } from "../internal-ethereum-provider" +import type { ValidatedAddEthereumChainParameter } from "../provider-bridge/utils" // The number of blocks to query at a time for historic asset transfers. // Unfortunately there's no "right" answer here that works well across different @@ -424,8 +425,8 @@ export default class ChainService extends BaseService { * Adds a supported network to list of active networks. */ async startTrackingNetworkOrThrow(chainID: string): Promise { - const trackedNetwork = this.trackedNetworks.find( - (ntwrk) => toHexChainID(ntwrk.chainID) === toHexChainID(chainID) + const trackedNetwork = this.trackedNetworks.find((network) => + sameChainID(network.chainID, chainID) ) if (trackedNetwork) { @@ -435,9 +436,10 @@ export default class ChainService extends BaseService { return trackedNetwork } - const networkToTrack = this.supportedNetworks.find( - (ntwrk) => toHexChainID(ntwrk.chainID) === toHexChainID(chainID) + const networkToTrack = this.supportedNetworks.find((ntwrk) => + sameChainID(ntwrk.chainID, chainID) ) + if (!networkToTrack) { throw new Error(`Network with chainID ${chainID} is not supported`) } @@ -1894,11 +1896,13 @@ export default class ChainService extends BaseService { 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/indexing/index.ts b/background/services/indexing/index.ts index c9fd750009..8e6ed8c290 100644 --- a/background/services/indexing/index.ts +++ b/background/services/indexing/index.ts @@ -1,7 +1,3 @@ -import { - AlchemyProvider, - AlchemyWebSocketProvider, -} from "@ethersproject/providers" import logger from "../../lib/logger" import { HexString } from "../../types" import { EVMNetwork, sameNetwork } from "../../networks" @@ -143,7 +139,7 @@ export default class IndexingService extends BaseService { schedule: { periodInMinutes: 1, }, - handler: () => this.handleBalanceAlarm({ onlyActiveAccounts: true }), + handler: () => this.handleBalanceAlarm(), }, forceBalance: { schedule: { @@ -186,13 +182,8 @@ export default class IndexingService extends BaseService { this.emitter.emit("assets", this.cachedAssets[network.chainID]) }) - // Force a balance refresh on service start - tokenListLoad.then(() => - this.handleBalanceAlarm({ - onlyActiveAccounts: false, - fetchTokenLists: false, - }) - ) + // Load balances after token lists load + tokenListLoad.then(() => this.loadAccountBalances()) }) } @@ -249,7 +240,7 @@ export default class IndexingService extends BaseService { * the codebase. Fiat currencies are not included. */ getCachedAssets(network: EVMNetwork): AnyAsset[] { - return this.cachedAssets[network.chainID] + return this.cachedAssets[network.chainID] ?? [] } /** @@ -819,58 +810,48 @@ export default class IndexingService extends BaseService { } } - 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() + private async loadAccountBalances(onlyActiveAccounts = false): Promise { // TODO doesn't support multi-network assets // like USDC or CREATE2-based contracts on L1/L2 - const trackedChainIds = new Set( - trackedNetworks.map((network) => network.chainID) - ) - - const activeAssetsToTrack = assetsToTrack.filter((asset) => - trackedChainIds.has(asset.homeNetwork.chainID) + const accounts = await this.chainService.getAccountsToTrack( + onlyActiveAccounts ) - // wait on balances being written to the db, don't wait on event emission await Promise.allSettled( - ( - await this.chainService.getAccountsToTrack(onlyActiveAccounts) - ).map(async (addressOnNetwork) => { - const provider = await this.chainService.providerForNetworkOrThrow( - addressOnNetwork.network + accounts.map(async (addressOnNetwork) => { + const { network } = addressOnNetwork + + const provider = this.chainService.providerForNetworkOrThrow(network) + + const loadBaseAccountBalance = + this.chainService.getLatestBaseAccountBalance(addressOnNetwork) + + /** + * When the provider supports alchemy we can use alchemy_getTokenBalances + * to query all erc20 token balances without specifying which assets we + * need to check. When it does not, we try checking balances for every asset + * we've seen in the network. + */ + const assetsToCheck = provider.supportsAlchemy + ? [] + : // This doesn't pass assetsToTrack stored in the db as + // it assumes they've already been cached + this.getCachedAssets(network).filter(isSmartContractFungibleAsset) + + const loadTokenBalances = this.retrieveTokenBalances( + addressOnNetwork, + assetsToCheck ) - const isAlchemyProvider = - provider instanceof AlchemyProvider || - provider instanceof AlchemyWebSocketProvider - if (isAlchemyProvider) { - await this.retrieveTokenBalances( - addressOnNetwork, - activeAssetsToTrack - ) - } else { - await this.retrieveTokenBalances( - addressOnNetwork, - this.getCachedAssets(addressOnNetwork.network).filter( - isSmartContractFungibleAsset - ) - ) - } - await this.chainService.getLatestBaseAccountBalance(addressOnNetwork) + return Promise.all([loadBaseAccountBalance, loadTokenBalances]) }) ) } + + private async handleBalanceAlarm(): Promise { + await this.fetchAndCacheTokenLists().then(() => + this.loadAccountBalances(true) + ) + } } diff --git a/background/services/indexing/tests/index.integration.test.ts b/background/services/indexing/tests/index.integration.test.ts index 2095f82225..59d26e9551 100644 --- a/background/services/indexing/tests/index.integration.test.ts +++ b/background/services/indexing/tests/index.integration.test.ts @@ -4,6 +4,7 @@ import * as libPrices from "../../../lib/prices" import IndexingService from ".." import { ETHEREUM, OPTIMISM } from "../../../constants" import { + createAddressOnNetwork, createChainService, createIndexingService, createPreferenceService, @@ -18,7 +19,8 @@ type MethodSpy unknown> = jest.SpyInstance< Parameters > -const getPrivateMethodSpy = unknown>( +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const getPrivateMethodSpy = unknown>( // eslint-disable-next-line @typescript-eslint/no-explicit-any object: any, property: string @@ -37,6 +39,27 @@ beforeEach(() => fetchJsonStub.callsFake(async () => ({}))) afterEach(() => fetchJsonStub.resetBehavior()) +const tokenList = { + name: "Test", + timestamp: "2022-05-12T18:15:59+00:00", + version: { + major: 1, + minor: 169, + patch: 0, + }, + tokens: [ + { + chainId: 1, + address: "0x0000000000000000000000000000000000000000", + name: "Some Token", + decimals: 18, + symbol: "TEST", + logoURI: "/logo.svg", + tags: ["earn"], + }, + ], +} + describe("IndexingService", () => { const sandbox = sinon.createSandbox() let indexingService: IndexingService @@ -93,27 +116,6 @@ describe("IndexingService", () => { }) describe("service start", () => { - const tokenList = { - name: "Test", - timestamp: "2022-05-12T18:15:59+00:00", - version: { - major: 1, - minor: 169, - patch: 0, - }, - tokens: [ - { - chainId: 1, - address: "0x0000000000000000000000000000000000000000", - name: "Some Token", - decimals: 18, - symbol: "TEST", - logoURI: "/logo.svg", - tags: ["earn"], - }, - ], - } - const customAsset = createSmartContractAsset({ symbol: "USDC", }) @@ -301,4 +303,120 @@ describe("IndexingService", () => { ) }) }) + + describe("loading account balances", () => { + it("should query erc20 balances without specifying token addresses when provider supports alchemy", async () => { + const indexingDb = await getIndexingDB() + + const smartContractAsset = createSmartContractAsset() + + await indexingDb.saveTokenList( + "https://gateway.ipfs.io/ipns/tokens.uniswap.org", + tokenList + ) + + await indexingService.addCustomAsset(smartContractAsset) + await indexingDb.addAssetToTrack(smartContractAsset) + + // Skip loading prices at service init + getPrivateMethodSpy( + indexingService, + "handlePriceAlarm" + ).mockResolvedValue(Promise.resolve()) + + await Promise.all([ + chainService.startService(), + indexingService.startService(), + ]) + + const account = createAddressOnNetwork() + + const provider = chainService.providerForNetworkOrThrow(ETHEREUM) + provider.supportsAlchemy = true + + jest + .spyOn(chainService, "getAccountsToTrack") + .mockResolvedValue([account]) + + // We don't care about the return value for these calls + const baseBalanceSpy = jest + .spyOn(chainService, "getLatestBaseAccountBalance") + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockImplementation(() => Promise.resolve({} as any)) + + const tokenBalanceSpy = getPrivateMethodSpy< + IndexingService["retrieveTokenBalances"] + >(indexingService, "retrieveTokenBalances").mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + () => Promise.resolve({}) as any + ) + + // eslint-disable-next-line @typescript-eslint/dot-notation + await indexingService["loadAccountBalances"]() + + expect(baseBalanceSpy).toHaveBeenCalledWith(account) + expect(tokenBalanceSpy).toHaveBeenCalledWith(account, []) + }) + + it("should query erc20 balances specifying token addresses when provider doesn't support alchemy", async () => { + const indexingDb = await getIndexingDB() + + const smartContractAsset = createSmartContractAsset() + + await indexingDb.saveTokenList( + "https://gateway.ipfs.io/ipns/tokens.uniswap.org", + tokenList + ) + + await indexingService.addCustomAsset(smartContractAsset) + await indexingDb.addAssetToTrack(smartContractAsset) + + // Skip loading prices at service init + getPrivateMethodSpy( + indexingService, + "handlePriceAlarm" + ).mockResolvedValue(Promise.resolve()) + + await Promise.all([ + chainService.startService(), + indexingService.startService(), + ]) + + const account = createAddressOnNetwork() + + const provider = chainService.providerForNetworkOrThrow(ETHEREUM) + provider.supportsAlchemy = false + + jest + .spyOn(chainService, "getAccountsToTrack") + .mockResolvedValue([account]) + + // We don't care about the return value for these calls + const baseBalanceSpy = jest + .spyOn(chainService, "getLatestBaseAccountBalance") + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockImplementation(() => Promise.resolve({} as any)) + + const tokenBalanceSpy = getPrivateMethodSpy< + IndexingService["retrieveTokenBalances"] + >(indexingService, "retrieveTokenBalances").mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + () => Promise.resolve({}) as any + ) + + await indexingService.cacheAssetsForNetwork(ETHEREUM) + + // eslint-disable-next-line @typescript-eslint/dot-notation + await indexingService["loadAccountBalances"]() + + expect(baseBalanceSpy).toHaveBeenCalledWith(account) + expect(tokenBalanceSpy).toHaveBeenCalledWith( + account, + expect.arrayContaining([ + expect.objectContaining({ symbol: "TEST" }), + expect.objectContaining({ symbol: smartContractAsset.symbol }), + ]) + ) + }) + }) }) diff --git a/background/services/internal-ethereum-provider/index.ts b/background/services/internal-ethereum-provider/index.ts index ad2a7d2b70..f9d6bbc47d 100644 --- a/background/services/internal-ethereum-provider/index.ts +++ b/background/services/internal-ethereum-provider/index.ts @@ -12,7 +12,12 @@ import logger from "../../lib/logger" import BaseService from "../base" import { ServiceCreatorFunction, ServiceLifecycleEvents } from "../types" import ChainService from "../chain" -import { EVMNetwork, SignedTransaction, toHexChainID } from "../../networks" +import { + EVMNetwork, + sameChainID, + SignedTransaction, + toHexChainID, +} from "../../networks" import { ethersTransactionFromSignedTransaction, transactionRequestFromEthersTransactionRequest, @@ -32,7 +37,8 @@ import { TransactionAnnotation, } from "../enrichment" import { decodeJSON } from "../../lib/utils" -import { FeatureFlags } from "../../features" +import { FeatureFlags, isEnabled } from "../../features" +import type { ValidatedAddEthereumChainParameter } from "../provider-bridge/utils" // A type representing the transaction requests that come in over JSON-RPC // requests like eth_sendTransaction and eth_signTransaction. These are very @@ -74,59 +80,6 @@ export type AddEthereumChainParameter = { rpcUrls?: string[] } -// Lets start with all required and work backwards -export type ValidatedAddEthereumChainParameter = { - chainId: string - blockExplorerUrl: string - chainName: string - iconUrl?: string - nativeCurrency: { - name: string - symbol: string - decimals: number - } - rpcUrls: string[] -} - -const validateAddEthereumChainParameter = ({ - chainId, - chainName, - blockExplorerUrls, - iconUrls, - nativeCurrency, - rpcUrls, -}: AddEthereumChainParameter): ValidatedAddEthereumChainParameter => { - // @TODO Use AJV - if ( - !chainId || - !chainName || - !nativeCurrency || - !blockExplorerUrls || - !blockExplorerUrls.length || - !rpcUrls || - !rpcUrls.length - ) { - throw new Error("Missing Chain Property") - } - - if ( - !nativeCurrency.decimals || - !nativeCurrency.name || - !nativeCurrency.symbol - ) { - throw new Error("Missing Currency Property") - } - - return { - chainId: chainId.startsWith("0x") ? String(parseInt(chainId, 16)) : chainId, - chainName, - nativeCurrency, - blockExplorerUrl: blockExplorerUrls[0], - iconUrl: iconUrls && iconUrls[0], - rpcUrls, - } -} - type DAppRequestEvent = { payload: T resolver: (result: E | PromiseLike) => void @@ -314,20 +267,19 @@ export default class InternalEthereumProviderService extends BaseService // TODO - actually allow adding a new ethereum chain - for now wallet_addEthereumChain // will just switch to a chain if we already support it - but not add a new one case "wallet_addEthereumChain": { - const chainInfo = params[0] as AddEthereumChainParameter + const chainInfo = params[0] as ValidatedAddEthereumChainParameter const { chainId } = chainInfo const supportedNetwork = await this.getTrackedNetworkByChainId(chainId) if (supportedNetwork) { this.switchToSupportedNetwork(origin, supportedNetwork) return null } - if (!FeatureFlags.SUPPORT_CUSTOM_NETWORKS) { + if (!isEnabled(FeatureFlags.SUPPORT_CUSTOM_NETWORKS)) { // Dissallow adding new chains until feature flag is turned on. throw new EIP1193Error(EIP1193_ERROR_CODES.userRejectedRequest) } try { - const validatedParam = validateAddEthereumChainParameter(chainInfo) - await this.chainService.addCustomChain(validatedParam) + await this.chainService.addCustomChain(chainInfo) return null } catch (e) { logger.error(e) @@ -448,9 +400,10 @@ export default class InternalEthereumProviderService extends BaseService chainID: string ): Promise { const trackedNetworks = await this.chainService.getTrackedNetworks() - const trackedNetwork = trackedNetworks.find( - (network) => toHexChainID(network.chainID) === toHexChainID(chainID) + const trackedNetwork = trackedNetworks.find((network) => + sameChainID(network.chainID, chainID) ) + if (trackedNetwork) { return trackedNetwork } diff --git a/background/services/internal-ethereum-provider/tests/index.integration.test.ts b/background/services/internal-ethereum-provider/tests/index.integration.test.ts index 0a941f4d50..d202e5e767 100644 --- a/background/services/internal-ethereum-provider/tests/index.integration.test.ts +++ b/background/services/internal-ethereum-provider/tests/index.integration.test.ts @@ -1,11 +1,13 @@ import sinon from "sinon" import InternalEthereumProviderService from ".." +import * as featureFlags from "../../../features" import { EVMNetwork } from "../../../networks" import { createChainService, createInternalEthereumProviderService, } from "../../../tests/factories" +import { validateAddEthereumChainParameter } from "../../provider-bridge/utils" describe("Internal Ethereum Provider Service", () => { const sandbox = sinon.createSandbox() @@ -24,6 +26,8 @@ describe("Internal Ethereum Provider Service", () => { it("should correctly persist chains sent in via wallet_addEthereumChain", async () => { const chainService = createChainService() + jest.spyOn(featureFlags, "isEnabled").mockImplementation(() => true) + IEPService = await createInternalEthereumProviderService({ chainService }) const startedChainService = await chainService await startedChainService.startService() @@ -31,8 +35,21 @@ describe("Internal Ethereum Provider Service", () => { const METHOD = "wallet_addEthereumChain" const ORIGIN = "https://chainlist.org" - // prettier-ignore - const EIP3085_PARAMS = [ { chainId: "0xfa", chainName: "Fantom Opera", nativeCurrency: { name: "Fantom", symbol: "FTM", decimals: 18, }, rpcUrls: [ "https://fantom-mainnet.gateway.pokt.network/v1/lb/62759259ea1b320039c9e7ac", "https://rpc.ftm.tools", "https://rpc.ankr.com/fantom", "https://rpc.fantom.network", "https://rpc2.fantom.network", "https://rpc3.fantom.network", "https://rpcapi.fantom.network", "https://fantom-mainnet.public.blastapi.io", "https://1rpc.io/ftm", ], blockExplorerUrls: ["https://ftmscan.com"], }, "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", ] + const EIP3085_PARAMS = [ + validateAddEthereumChainParameter({ + chainId: "0xfa", + chainName: "Fantom Opera", + nativeCurrency: { name: "Fantom", symbol: "FTM", decimals: 18 }, + rpcUrls: [ + "https://fantom-mainnet.gateway.pokt.network/v1/lb/62759259ea1b320039c9e7ac", + "https://rpc.ftm.tools", + "https://rpc.ankr.com/fantom", + "https://rpc.fantom.network", + ], + blockExplorerUrls: ["https://ftmscan.com"], + }), + "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", + ] await IEPService.routeSafeRPCRequest(METHOD, EIP3085_PARAMS, ORIGIN) diff --git a/background/services/keyring/encryption.ts b/background/services/keyring/encryption.ts index a729f1cd2b..cc004e979c 100644 --- a/background/services/keyring/encryption.ts +++ b/background/services/keyring/encryption.ts @@ -42,7 +42,7 @@ function requireCryptoGlobal(message?: string) { if (global.crypto === undefined) { throw new Error( `${ - message || "Tally" + message || "Taho" } requires WebCrypto API support — is this being run in a modern browser?` ) } diff --git a/background/services/preferences/db.ts b/background/services/preferences/db.ts index 3f9dac4c1a..8ad0c38203 100644 --- a/background/services/preferences/db.ts +++ b/background/services/preferences/db.ts @@ -7,6 +7,7 @@ import DEFAULT_PREFERENCES from "./defaults" import { AccountSignerSettings } from "../../ui" import { AccountSignerWithId } from "../../signing" import { AnalyticsPreferences } from "./types" +import { NETWORK_BY_CHAIN_ID } from "../../constants" type SignerRecordId = `${AccountSignerWithId["type"]}/${string}` @@ -306,6 +307,18 @@ export class PreferenceDatabase extends Dexie { }) }) + // Updates saved accounts stored networks for old installs + this.version(16).upgrade((tx) => { + return tx + .table("preferences") + .toCollection() + .modify((storedPreferences: Preferences) => { + const { selectedAccount } = storedPreferences + selectedAccount.network = + NETWORK_BY_CHAIN_ID[selectedAccount.network.chainID] + }) + }) + // 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 3870c68da0..49b15d7cab 100644 --- a/background/services/preferences/defaults.ts +++ b/background/services/preferences/defaults.ts @@ -8,7 +8,7 @@ const defaultPreferences: Preferences = { urls: [ storageGatewayURL( "ipfs://bafybeigtlpxobme7utbketsaofgxqalgqzowhx24wlwwrtbzolgygmqorm" - ).href, // the Tally community-curated list + ).href, // the Taho community-curated list "https://gateway.ipfs.io/ipns/tokens.uniswap.org", // the Uniswap default list "https://meta.yearn.finance/api/tokens/list", // the Yearn list "https://messari.io/tokenlist/messari-verified", // Messari-verified projects diff --git a/background/services/provider-bridge/index.ts b/background/services/provider-bridge/index.ts index a22ad245e7..5ca4a6b93d 100644 --- a/background/services/provider-bridge/index.ts +++ b/background/services/provider-bridge/index.ts @@ -12,7 +12,9 @@ import { } from "@tallyho/provider-bridge-shared" import { TransactionRequest as EthersTransactionRequest } from "@ethersproject/abstract-provider" import BaseService from "../base" -import InternalEthereumProviderService from "../internal-ethereum-provider" +import InternalEthereumProviderService, { + AddEthereumChainParameter, +} from "../internal-ethereum-provider" import { getOrCreateDB, ProviderBridgeServiceDatabase } from "./db" import { ServiceCreatorFunction, ServiceLifecycleEvents } from "../types" import PreferenceService from "../preferences" @@ -27,11 +29,14 @@ import { HexString } from "../../types" import { WEBSITE_ORIGIN } from "../../constants/website" import { handleRPCErrorResponse, - parseRPCRequestParams, PermissionMap, + validateAddEthereumChainParameter, + ValidatedAddEthereumChainParameter, + parseRPCRequestParams, } from "./utils" import { toHexChainID } from "../../networks" import { TALLY_INTERNAL_ORIGIN } from "../internal-ethereum-provider/constants" +import { FeatureFlags, isEnabled } from "../../features" type Events = ServiceLifecycleEvents & { requestPermission: PermissionRequest @@ -43,6 +48,11 @@ type Events = ServiceLifecycleEvents & { walletConnectInit: string } +export type AddChainRequestData = ValidatedAddEthereumChainParameter & { + favicon: string + siteTitle: string +} + /** * The ProviderBridgeService is responsible for the communication with the * provider-bridge (content-script). @@ -61,6 +71,16 @@ export default class ProviderBridgeService extends BaseService { [origin: string]: (value: unknown) => void } = {} + #pendingAddNetworkRequests: { + [id: string]: { + resolve: () => void + reject: () => void + data: AddChainRequestData + } + } = {} + + private addNetworkRequestId = 0 + openPorts: Array = [] static create: ServiceCreatorFunction< @@ -214,16 +234,6 @@ export default class ProviderBridgeService extends BaseService { event.request.params, origin ) - } else if ( - event.request.method === "wallet_addEthereumChain" || - event.request.method === "wallet_switchEthereumChain" - ) { - response.result = - await this.internalEthereumProviderService.routeSafeRPCRequest( - event.request.method, - event.request.params, - origin - ) } else if (event.request.method === "eth_requestAccounts") { // if it's external communication AND the dApp does not have permission BUT asks for it // then let's ask the user what he/she thinks @@ -498,6 +508,63 @@ export default class ProviderBridgeService extends BaseService { showExtensionPopup(AllowedQueryParamPage.signTransaction) ) + case "wallet_switchEthereumChain": + return await this.internalEthereumProviderService.routeSafeRPCRequest( + method, + params, + origin + ) + + case "wallet_addEthereumChain": { + if (!isEnabled(FeatureFlags.SUPPORT_CUSTOM_NETWORKS)) { + // Attempt to switch to a chain if its one of the natively supported ones - otherwise fail + return await this.internalEthereumProviderService.routeSafeRPCRequest( + method, + params, + origin + ) + } + + const id = this.addNetworkRequestId.toString() + + this.addNetworkRequestId += 1 + + const window = await showExtensionPopup( + AllowedQueryParamPage.addNewChain, + { requestId: id.toString() } + ) + + browser.windows.onRemoved.addListener((removed) => { + if (removed === window.id) { + this.handleAddNetworkRequest(id, false) + } + }) + + const [rawChainData, address, siteTitle, favicon] = params + const validatedData = validateAddEthereumChainParameter( + rawChainData as AddEthereumChainParameter + ) + + const userConfirmation = new Promise((resolve, reject) => { + this.#pendingAddNetworkRequests[id] = { + resolve, + reject, + data: { + ...validatedData, + favicon: favicon as string, + siteTitle: siteTitle as string, + }, + } + }) + + await userConfirmation + + return await this.internalEthereumProviderService.routeSafeRPCRequest( + method, + [validatedData, address], + origin + ) + } default: { return await this.internalEthereumProviderService.routeSafeRPCRequest( method, @@ -511,4 +578,17 @@ export default class ProviderBridgeService extends BaseService { return handleRPCErrorResponse(error) } } + + getNewCustomRPCDetails(requestId: string): AddChainRequestData { + return this.#pendingAddNetworkRequests[requestId].data + } + + handleAddNetworkRequest(id: string, success: boolean): void { + const request = this.#pendingAddNetworkRequests[id] + if (success) { + request.resolve() + } else { + request.reject() + } + } } diff --git a/background/services/provider-bridge/show-popup.ts b/background/services/provider-bridge/show-popup.ts index ff39705e75..7e44619e1c 100644 --- a/background/services/provider-bridge/show-popup.ts +++ b/background/services/provider-bridge/show-popup.ts @@ -2,18 +2,27 @@ import browser from "webextension-polyfill" import { AllowedQueryParamPageType } from "@tallyho/provider-bridge-shared" export default async function showExtensionPopup( - url: AllowedQueryParamPageType + url: AllowedQueryParamPageType, + additionalOptions: { [key: string]: string } = {} ): Promise { const { left = 0, top, width = 1920 } = await browser.windows.getCurrent() const popupWidth = 384 const popupHeight = 628 - return browser.windows.create({ - url: `${browser.runtime.getURL("popup.html")}?page=${url}`, + + const queryString = new URLSearchParams({ + ...additionalOptions, + page: url, + }).toString() + + const params: browser.Windows.CreateCreateDataType = { + url: `${browser.runtime.getURL("popup.html")}?${queryString}`, type: "popup", left: left + width - popupWidth, top, width: popupWidth, height: popupHeight, focused: true, - }) + } + + return browser.windows.create(params) } diff --git a/background/services/provider-bridge/tests/index.unit.test.ts b/background/services/provider-bridge/tests/index.unit.test.ts index 6ccc0e6864..842fc4b6c6 100644 --- a/background/services/provider-bridge/tests/index.unit.test.ts +++ b/background/services/provider-bridge/tests/index.unit.test.ts @@ -4,8 +4,12 @@ import { } from "@tallyho/provider-bridge-shared" import sinon from "sinon" import browser from "webextension-polyfill" +import * as featureFlags from "../../../features" +import { wait } from "../../../lib/utils" import { createProviderBridgeService } from "../../../tests/factories" +import { AddEthereumChainParameter } from "../../internal-ethereum-provider" import ProviderBridgeService from "../index" +import { validateAddEthereumChainParameter } from "../utils" const WINDOW = { focused: true, @@ -104,5 +108,67 @@ describe("ProviderBridgeService", () => { expect(stub.called).toBe(false) expect(response).toBe(EIP1193_ERROR_CODES.unauthorized) }) + + it("should wait for user confirmation before calling wallet_AddEtherumChain", async () => { + const params = [ + { + chainId: "0xfa", + chainName: "Fantom Opera", + nativeCurrency: { name: "Fantom", symbol: "FTM", decimals: 18 }, + rpcUrls: [ + "https://fantom-mainnet.gateway.pokt.network/v1/lb/62759259ea1b320039c9e7ac", + "https://rpc.ftm.tools", + "https://rpc.ankr.com/fantom", + "https://rpc.fantom.network", + ], + blockExplorerUrls: ["https://ftmscan.com"], + }, + "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", + "some site", + "favicon.png", + ] + + const { enablingPermission } = BASE_DATA + + jest.spyOn(featureFlags, "isEnabled").mockImplementation(() => true) + + const request = providerBridgeService.routeContentScriptRPCRequest( + { + ...enablingPermission, + }, + "wallet_addEthereumChain", + params, + enablingPermission.origin + ) + + // eslint-disable-next-line @typescript-eslint/dot-notation + const IEP = providerBridgeService["internalEthereumProviderService"] + const spy = jest.spyOn(IEP, "routeSafeRPCRequest") + + await wait(0) // wait next tick to setup popup + + const validatedPayload = validateAddEthereumChainParameter( + params[0] as AddEthereumChainParameter + ) + + expect(providerBridgeService.getNewCustomRPCDetails("0")).toEqual({ + ...validatedPayload, + favicon: "favicon.png", + siteTitle: "some site", + }) + + expect(spy).not.toHaveBeenCalled() + providerBridgeService.handleAddNetworkRequest("0", true) + + await wait(0) // wait next tick + + expect(spy).toHaveBeenCalledWith( + "wallet_addEthereumChain", + [validatedPayload, "0xd8da6bf26964af9d7eed9e03e53415d37aa96045"], + BASE_DATA.origin + ) + + await expect(request).resolves.toEqual(null) // resolves without errors + }) }) }) diff --git a/background/services/provider-bridge/utils.ts b/background/services/provider-bridge/utils.ts index 8fb63245bb..6a82ffc866 100644 --- a/background/services/provider-bridge/utils.ts +++ b/background/services/provider-bridge/utils.ts @@ -6,6 +6,7 @@ import { EIP1193ErrorPayload, RPCRequest, } from "@tallyho/provider-bridge-shared" +import { AddEthereumChainParameter } from "../internal-ethereum-provider" import { sameEVMAddress } from "../../lib/utils" import { HexString } from "../../types" @@ -91,6 +92,59 @@ export function handleRPCErrorResponse(error: unknown): unknown { ) } +// Lets start with all required and work backwards +export type ValidatedAddEthereumChainParameter = { + chainId: string + blockExplorerUrl: string + chainName: string + iconUrl?: string + nativeCurrency: { + name: string + symbol: string + decimals: number + } + rpcUrls: string[] +} + +export const validateAddEthereumChainParameter = ({ + chainId, + chainName, + blockExplorerUrls, + iconUrls, + nativeCurrency, + rpcUrls, +}: AddEthereumChainParameter): ValidatedAddEthereumChainParameter => { + // @TODO Use AJV + if ( + !chainId || + !chainName || + !nativeCurrency || + !blockExplorerUrls || + !blockExplorerUrls.length || + !rpcUrls || + !rpcUrls.length + ) { + throw new Error("Missing Chain Property") + } + + if ( + !nativeCurrency.decimals || + !nativeCurrency.name || + !nativeCurrency.symbol + ) { + throw new Error("Missing Currency Property") + } + + return { + chainId: chainId.startsWith("0x") ? String(parseInt(chainId, 16)) : chainId, + chainName, + nativeCurrency, + blockExplorerUrl: blockExplorerUrls[0], + iconUrl: iconUrls && iconUrls[0], + rpcUrls, + } +} + /** * Try to fix request params for dapps that are sending requests with flipped params order. * For now it only affects eth_call and personal_sign as message and address order is sometimes reversed. diff --git a/background/services/telemetry/index.ts b/background/services/telemetry/index.ts index 7d387711a4..612f683185 100644 --- a/background/services/telemetry/index.ts +++ b/background/services/telemetry/index.ts @@ -5,7 +5,7 @@ import logger from "../../lib/logger" import { encodeJSON } from "../../lib/utils" /** - * The TelemetryService is responsible for tracking usage statistics in Tally. + * The TelemetryService is responsible for tracking usage statistics in Taho. * * Currently we are only tracking extension storage information and logging * it to the console. diff --git a/background/services/wallet-connect/sign-client-helper.ts b/background/services/wallet-connect/sign-client-helper.ts index 32a7856a5b..b0eb2e491e 100644 --- a/background/services/wallet-connect/sign-client-helper.ts +++ b/background/services/wallet-connect/sign-client-helper.ts @@ -7,8 +7,8 @@ export default function createSignClient(): Promise { relayUrl: "wss://relay.walletconnect.com", metadata: { // TODO: customize this metadata - name: "Tally Ho Wallet", - description: "WalletConnect for Tally Ho wallet", + name: "Taho Wallet", + description: "WalletConnect for Taho wallet", url: "https://walletconnect.com/", icons: ["https://avatars.githubusercontent.com/u/37784886"], }, diff --git a/background/tests/factories.ts b/background/tests/factories.ts index bea411bc21..562f551c8c 100644 --- a/background/tests/factories.ts +++ b/background/tests/factories.ts @@ -257,7 +257,7 @@ export const createAccountBalance = ( export const createAddressOnNetwork = ( overrides: Partial = {} ): AddressOnNetwork => ({ - address: "0x208e94d5661a73360d9387d3ca169e5c130090cd", + address: createRandom0xHash(), network: ETHEREUM, ...overrides, }) diff --git a/background/third-party-data/blocknative/index.ts b/background/third-party-data/blocknative/index.ts index 3f533d7c76..4825551922 100644 --- a/background/third-party-data/blocknative/index.ts +++ b/background/third-party-data/blocknative/index.ts @@ -19,8 +19,8 @@ export const BlocknativeNetworkIds = { // TODO Ethereum---either top-level or inside the instance. /** - * The Blocknative class wraps access to the Blocknative API for the Tally - * extension backend. It exposes Tally-specific functionality, and manages + * The Blocknative class wraps access to the Blocknative API for the Taho + * extension backend. It exposes Taho-specific functionality, and manages * connection and disconnection from Blocknative based on registered needs and * feedback from the Blocknative system to minimize usage when possible. */ diff --git a/dev-utils/local-chain/package.json b/dev-utils/local-chain/package.json index dc036612c5..9e63238525 100644 --- a/dev-utils/local-chain/package.json +++ b/dev-utils/local-chain/package.json @@ -1,6 +1,6 @@ { "name": "local-chain", - "description": "Local chain setup to support Tally Ho extension development.", + "description": "Local chain setup to support Taho extension development.", "license": "GPL-3.0", "private": true, "repository": "https://github.com/tallycash/extension", diff --git a/e2e-tests/create-wallet.spec.ts b/e2e-tests/create-wallet.spec.ts index 48436c6c71..84b655352a 100644 --- a/e2e-tests/create-wallet.spec.ts +++ b/e2e-tests/create-wallet.spec.ts @@ -1,5 +1,5 @@ -import { createWallet, tallyHoTest } from "./utils" +import { createWallet, test } from "./utils" -tallyHoTest("Create wallet", async ({ page, extensionId }) => { +test("Create wallet", async ({ page, extensionId }) => { await createWallet(page, extensionId) }) diff --git a/e2e-tests/dapp-connect.spec.ts b/e2e-tests/dapp-connect.spec.ts index 173d3eb36d..b562ae6aff 100644 --- a/e2e-tests/dapp-connect.spec.ts +++ b/e2e-tests/dapp-connect.spec.ts @@ -1,6 +1,6 @@ -import { tallyHoTest } from "./utils" +import { test } from "./utils" -tallyHoTest("dapp connect", async ({ page, context, extensionId }) => { +test("dapp connect", async ({ page, context, extensionId }) => { const passwd = "VoXaXa!239" const recoveryPhrase = "tilt ski leave code make fantasy rifle learn wash quiz youth inside promote garlic cat album tell pass between hub brush evolve staff imitate" diff --git a/e2e-tests/nfts.spec.ts b/e2e-tests/nfts.spec.ts new file mode 100644 index 0000000000..2778042183 --- /dev/null +++ b/e2e-tests/nfts.spec.ts @@ -0,0 +1,212 @@ +import { FeatureFlags } from "@tallyho/tally-background/features" +import { wait } from "@tallyho/tally-background/lib/utils" +import { skipIfFeatureFlagged, test, expect } from "./utils" + +skipIfFeatureFlagged(FeatureFlags.SUPPORT_NFT_TAB) + +test.describe("NFTs", () => { + test.use({ viewport: { width: 384, height: 600 } }) + + test("Shows loading state", async ({ + page, + backgroundPage, + walletPageHelper, + }) => { + // Set a delay so we don't miss loading states + await backgroundPage.route(/api\.simplehash\.com/i, async (route) => { + const response = await route.fetch().catch((err) => { + // Waiting for the response doesn't prevent context disposed errors + // consistently + if ( + err instanceof Error && + err.message.includes("Request context disposed") + ) { + // noop + } else { + throw err + } + }) + + if (response) { + await wait(800) + await route.fulfill({ response }) + } + }) + + await walletPageHelper.onboardReadOnlyAddress("bravonaver.eth") + await walletPageHelper.navigateTo("NFTs") + + await expect(page.getByTestId("loading_doggo")).toBeVisible() + + // Wait until load finishes + await expect(page.getByTestId("loading_doggo")).not.toBeVisible() + }) + + test("User can view nft collections, poaps and badges", async ({ + page, + walletPageHelper, + }) => { + await walletPageHelper.onboardReadOnlyAddress("bravonaver.eth") + await walletPageHelper.navigateTo("NFTs") + + await test.step("Check balances", async () => { + await expect( + page.getByTestId("nft_header_currency_total") + ).not.toHaveText(/0.00/) + + await expect(page.getByTestId("nft_header_nft_count")).not.toHaveText("0") + + await expect( + page.getByTestId("nft_header_collection_count") + ).not.toHaveText("0") + + await expect(page.getByTestId("nft_header_badge_count")).not.toHaveText( + "0" + ) + }) + + // Check collections + await test.step("Order by collection count", async () => { + await page.getByRole("button", { name: "Filter collections" }).click() + await page.getByText("Number (in 1 collection)").click() + + await page + .getByTestId("nft_filters_menu") + .getByRole("button", { name: "Close menu" }) + .click() + }) + + const collectionItem = await test.step( + "Check collection expands", + async () => { + const nftCollection = page + .getByTestId("nft_list_item") + .filter({ has: page.getByTestId("nft_list_item_collection") }) + .filter({ hasText: /noox badge/i }) + .first() + + await nftCollection.hover() + await nftCollection.getByTestId("expand").click() + + const collectionItems = nftCollection.getByTestId( + "nft_list_item_single" + ) + + expect((await collectionItems.all()).length).toBeGreaterThan(1) + + return collectionItems.filter({ hasText: /ethereum unique user/i }) + } + ) + + // Check Details + await test.step("Check NFT details", async () => { + await collectionItem.getByTestId("view").click() + + const previewMenu = page.getByTestId("nft_preview_menu") + + await expect( + page.getByText( + /owning this badge indicates that the user has created 1,000\+ transactions on eth/i + ) + ).toBeVisible() + + // Displays traits + expect( + await previewMenu.locator(".preview_property_trait").allInnerTexts() + ).toEqual( + expect.arrayContaining(["category", "project", "required_action"]) + ) + + // ...And their values + await expect( + previewMenu.locator(".preview_property_value") + ).toContainText([/general/i, /ethereum/i, /generate transactions/i]) + + await previewMenu.getByRole("button", { name: "Close menu" }).click() + }) + + // Check Badges + await page.getByRole("tablist").getByRole("tab", { name: "Badges" }).click() + + await test.step("Check Poap Badge", async () => { + const poap = page + .getByTestId("nft_list_item_single") + .filter({ hasText: /paladin community call/i }) + .first() + + await poap.hover() + await poap.getByRole("button", { name: "view" }).click() + + // Check details + const poapPreview = page.getByTestId("nft_preview_menu") + + await expect( + poapPreview.getByRole("heading", { + name: "Paladin Community Call #04", + }) + ).toBeVisible() + + expect( + await poapPreview + .getByRole("link", { name: "POAP" }) + .getAttribute("href") + ).toEqual("https://app.poap.xyz/token/3114612") + + // Description + await expect( + poapPreview.getByText( + "POAP for 4th Paladin and final community call of 2021." + ) + ).toBeVisible() + + // Displays properties + const poapTraits = poapPreview.getByTestId("nft_properties_list") + + await expect(poapTraits.getByText("Event")).toBeVisible() + await expect( + poapTraits.getByTitle("Paladin Community Call #04") + ).toBeVisible() + await expect(poapTraits.getByText("Year")).toBeVisible() + await expect(poapTraits.getByText("2021")).toBeVisible() + + await poapPreview.getByRole("button", { name: "Close menu" }).click() + }) + + // Check a Galxe badge + await test.step("Check a Galxe badge", async () => { + const galxeBadge = page.getByTestId("nft_list_item_single").filter({ + has: page.getByText("Odos.xyz - DEFI Aggregator III"), + }) + + await galxeBadge.scrollIntoViewIfNeeded() + + await galxeBadge.getByTestId("view").click() + + const galxePreview = page.getByTestId("nft_preview_menu") + + await expect( + galxePreview.getByRole("heading", { + name: "Odos.xyz - DEFI Aggregator III", + }) + ).toBeVisible() + + expect( + await galxePreview + .getByRole("link", { name: "Galxe" }) + .getAttribute("href") + ).toEqual( + "https://galxe.com/nft/21102/0x91eEdA83433690056e22fe33F0E2FFc754bA1076" + ) + + // Displays properties + const badgeTraits = galxePreview.getByTestId("nft_properties_list") + + await expect(badgeTraits.getByText("category")).toBeVisible() + await expect( + badgeTraits.getByText("Odos.xyz - DEFI Aggregator III") + ).toBeVisible() + await expect(badgeTraits.getByText("birthday")).toBeVisible() + await expect(badgeTraits.getByText("1670832780")).toBeVisible() + }) + }) +}) diff --git a/e2e-tests/remove-wallet.spec.ts b/e2e-tests/remove-wallet.spec.ts index c1761e557a..2ad9b9d5ee 100644 --- a/e2e-tests/remove-wallet.spec.ts +++ b/e2e-tests/remove-wallet.spec.ts @@ -1,6 +1,6 @@ -import { createWallet, tallyHoTest } from "./utils" +import { createWallet, test } from "./utils" -tallyHoTest("Remove wallet", async ({ page, extensionId }) => { +test("Remove wallet", async ({ page, extensionId }) => { await createWallet(page, extensionId) await page.locator(".profile_button").nth(1).click() await page.locator(".icon_settings").click() diff --git a/e2e-tests/utils.ts b/e2e-tests/utils.ts index 4a30322506..24745e0887 100644 --- a/e2e-tests/utils.ts +++ b/e2e-tests/utils.ts @@ -1,11 +1,55 @@ -import { test as base, BrowserContext, chromium, Page } from "@playwright/test" +/* eslint-disable no-empty-pattern */ +import { test as base, chromium, Page } from "@playwright/test" +import { FeatureFlagType, isEnabled } from "@tallyho/tally-background/features" import path from "path" -export const tallyHoTest = base.extend<{ - context: BrowserContext +// Re-exporting so we don't mix imports +export { expect } from "@playwright/test" + +export class WalletPageHelper { + readonly url: string + + constructor(public readonly page: Page, extensionId: string) { + this.url = `chrome-extension://${extensionId}/popup.html` + } + + async goToStartPage(): Promise { + await this.page.goto(this.url) + } + + async navigateTo(tab: string): Promise { + await this.page + .getByRole("navigation", { name: "Main" }) + .getByRole("link", { name: tab }) + .click() + } + + async onboardReadOnlyAddress(address: string): Promise { + await base.step("Onboard w/ReadOnly address", async () => { + await this.goToStartPage() + await this.page.getByRole("button", { name: "Continue" }).click() + await this.page.getByRole("button", { name: "Continue" }).click() + await this.page + .getByRole("button", { + name: "Read-only address", + }) + .click() + await this.page.getByRole("textbox").fill(address) + await this.page.getByRole("button", { name: "Explore Taho" }).click() + }) + } +} + +type WalletTestFixtures = { extensionId: string -}>({ - /* eslint-disable-next-line no-empty-pattern */ + walletPageHelper: WalletPageHelper + backgroundPage: Page +} + +/** + * Extended instance of playwright's `test` with our fixtures + */ +export const test = base.extend({ context: async ({}, use) => { const pathToExtension = path.resolve(__dirname, "../dist/chrome") const context = await chromium.launchPersistentContext("", { @@ -19,19 +63,27 @@ export const tallyHoTest = base.extend<{ await use(context) await context.close() }, - extensionId: async ({ context }, use) => { + backgroundPage: async ({ context }, use) => { // for manifest v2: let [background] = context.backgroundPages() if (!background) background = await context.waitForEvent("backgroundpage") + await background.waitForResponse(/api\.coingecko\.com/i) + // // for manifest v3: // let [background] = context.serviceWorkers(); // if (!background) // background = await context.waitForEvent("serviceworker"); - - const extensionId = background.url().split("/")[2] + await use(background) + }, + extensionId: async ({ backgroundPage }, use) => { + const extensionId = backgroundPage.url().split("/")[2] await use(extensionId) }, + walletPageHelper: async ({ page, extensionId }, use) => { + const walletOnboarding = new WalletPageHelper(page, extensionId) + await use(walletOnboarding) + }, }) export async function createWallet( @@ -86,3 +138,9 @@ export async function createWallet( await page.locator("text=Verify recovery phrase").click() await page.locator("text=Take me to my wallet").click() } + +export const skipIfFeatureFlagged = (featureFlag: FeatureFlagType): void => + test.skip( + !isEnabled(featureFlag, false), + `Feature Flag: ${featureFlag} has not been turned on for this run` + ) diff --git a/manifest/manifest.json b/manifest/manifest.json index dad783b75b..f05d83003e 100644 --- a/manifest/manifest.json +++ b/manifest/manifest.json @@ -1,6 +1,6 @@ { - "name": "Tally Ho", - "version": "0.24.0", + "name": "Taho", + "version": "0.26.2", "description": "The community owned and operated Web3 wallet.", "homepage_url": "https://tally.cash", "author": "https://tally.cash", @@ -29,7 +29,7 @@ "128": "icon-128.png" }, "browser_action": { - "default_title": "Tally Ho", + "default_title": "Taho", "default_popup": "popup.html" }, "permissions": ["alarms", "storage", "unlimitedStorage", "activeTab"], diff --git a/package.json b/package.json index 90688d4e0d..02dd3f1670 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "@tallyho/tally-extension", "private": true, - "version": "0.24.0", - "description": "Tally Ho, the community owned and operated Web3 wallet.", + "version": "0.26.2", + "description": "Taho, the community owned and operated Web3 wallet.", "main": "index.js", "repository": "git@github.com:thesis/tally-extension.git", "author": "Matt Luongo ", @@ -79,7 +79,7 @@ "@babel/preset-react": "^7.12.13", "@babel/preset-typescript": "^7.15.0", "@babel/register": "^7.14.5", - "@playwright/test": "^1.26.1", + "@playwright/test": "^1.31", "@types/archiver": "^5.1.0", "@types/copy-webpack-plugin": "^8.0.0", "@types/dotenv-webpack": "^7.0.3", diff --git a/playwright.config.ts b/playwright.config.ts index 07bba6757d..45e676a85d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,10 +2,12 @@ import type { PlaywrightTestConfig } from "@playwright/test" import { devices } from "@playwright/test" /** - * Read environment variables from file. - * https://github.com/motdotla/dotenv + * Read environment variables */ -// require('dotenv').config(); +import "dotenv-defaults/config" + +const SECOND = 1e3 +const CI_ENV = typeof process.env.CI === "string" /** * See https://playwright.dev/docs/test-configuration. @@ -13,22 +15,24 @@ import { devices } from "@playwright/test" const config: PlaywrightTestConfig = { testDir: "./e2e-tests", /* Maximum time one test can run for. */ - timeout: 30 * 1000, + timeout: 120 * SECOND, expect: { /** * Maximum time expect() should wait for the condition to be met. * For example in `await expect(locator).toHaveText();` */ - timeout: 5000, + timeout: (CI_ENV ? 30 : 15) * SECOND, }, /* Run tests in files in parallel */ - fullyParallel: true, + fullyParallel: false, /* Fail the build on CI if you accidentally left test.only in the source code. */ - forbidOnly: !!process.env.CI, + forbidOnly: CI_ENV, /* Retry on CI only */ - retries: process.env.CI ? 2 : 0, - /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, + retries: CI_ENV ? 2 : 0, + /** + * Opt out of parallel tests since we interact with real APIs during testing + */ + workers: 1, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: "html", /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ diff --git a/provider-bridge-shared/package.json b/provider-bridge-shared/package.json index aa40dd8d34..aad5e43afc 100644 --- a/provider-bridge-shared/package.json +++ b/provider-bridge-shared/package.json @@ -1,7 +1,7 @@ { "name": "@tallyho/provider-bridge-shared", "version": "0.0.1", - "description": "Tally Ho, the community owned and operated Web3 wallet: provider bridge to connect the in-page provider with the background script.", + "description": "Taho, the community owned and operated Web3 wallet: provider bridge to connect the in-page provider with the background script.", "main": "index.ts", "repository": "git@github.com:thesis/tally-extension.git", "author": "Greg Nagy ", diff --git a/provider-bridge-shared/runtime-typechecks.ts b/provider-bridge-shared/runtime-typechecks.ts index 2ca2909190..e23f070e2f 100644 --- a/provider-bridge-shared/runtime-typechecks.ts +++ b/provider-bridge-shared/runtime-typechecks.ts @@ -63,6 +63,7 @@ export function isPortResponseEvent(arg: unknown): arg is PortResponseEvent { export const AllowedQueryParamPage = { signTransaction: "/sign-transaction", + addNewChain: "/add-evm-chain", dappPermission: "/dapp-permission", signData: "/sign-data", personalSignData: "/personal-sign", diff --git a/provider-bridge/index.ts b/provider-bridge/index.ts index 475b657c03..032475246b 100644 --- a/provider-bridge/index.ts +++ b/provider-bridge/index.ts @@ -20,7 +20,7 @@ export function connectProviderBridge(): void { // if dapp wants to connect let's grab its details if ( event.data.request.method === "eth_requestAccounts" || - event.data.request.method === "eth_accounts" + event.data.request.method === "wallet_addEthereumChain" ) { const faviconElements: NodeListOf = window.document.querySelectorAll("link[rel*='icon']") @@ -83,7 +83,7 @@ export function injectTallyWindowProvider(): void { container.insertBefore(scriptTag, container.children[0]) } catch (e) { throw new Error( - `Tally: oh nos the content-script failed to initilaize the Tally window provider. + `Taho: oh nos the content-script failed to initilaize the Taho window provider. ${e} It's time for a seppuku...🗡` ) diff --git a/provider-bridge/package.json b/provider-bridge/package.json index b3111498c2..76a312692f 100644 --- a/provider-bridge/package.json +++ b/provider-bridge/package.json @@ -1,7 +1,7 @@ { "name": "@tallyho/provider-bridge", "version": "0.0.1", - "description": "Tally Ho, the community owned and operated Web3 wallet: provider bridge to connect the in-page provider with the background script.", + "description": "Taho, the community owned and operated Web3 wallet: provider bridge to connect the in-page provider with the background script.", "main": "index.ts", "repository": "git@github.com:thesis/tally-extension.git", "author": "Greg Nagy ", diff --git a/rfb/rfb-1-keyring-design.adoc b/rfb/rfb-1-keyring-design.adoc index c299d15a54..e164823df2 100644 --- a/rfb/rfb-1-keyring-design.adoc +++ b/rfb/rfb-1-keyring-design.adoc @@ -4,7 +4,7 @@ == Background -The Tally Ho wallet allows users to both view data associated with an account +The Taho wallet allows users to both view data associated with an account of theirs, and sign transactions on behalf of that account using private key material. Users can set up new accounts rooted in fresh private key material, and accounts can derive many addresses via derivation paths as specified in @@ -17,7 +17,7 @@ abstraction that is safe and functional. === Goal -The keyring is the core key management abstraction used by the Tally Ho wallet. +The keyring is the core key management abstraction used by the Taho wallet. It is responsible for managing, securing access to, and protecting a user's private key material, as well as exposing access to the public aspects of that private key material. @@ -53,7 +53,7 @@ derivation, signing, and related wallet work is done entirely by http://ethers.io[Ethers]. Mnemonic management is done by the https://www.npmjs.com/package/bip39[bip39] -package. Tally Ho ``HDKeyring``s currently support any BIP39 mnemonics and +package. Taho ``HDKeyring``s currently support any BIP39 mnemonics and mnemonic generation for any strength supported by the bip39 package. Serialization of keyrings includes version information to allow for future @@ -63,9 +63,9 @@ in order to protect the underlying mnemonic (and therefore private key). ==== Service -===== Tally Ho Services Abstraction +===== Taho Services Abstraction -Tally Ho services are runtime singletons that are charged with managing a +Taho services are runtime singletons that are charged with managing a single slice of functionality for the extension. They manage data storage and interactions with other services, as well as maintaining internal state. Triggering a service’s functionality is currently done by invoking a method on @@ -80,7 +80,7 @@ Once a service is created, it can be started and stopped. Currently services can only walk through their lifecycle once, so once a service is stopped, it can no longer be restarted. -Tally Ho services communicate data outwards in two ways: +Taho services communicate data outwards in two ways: * All services have a set of events they may broadcast. These are expected to be viewable by any external entity, and should only carry public (to the rest diff --git a/rfb/rfb-2-signers-ui.md b/rfb/rfb-2-signers-ui.md index c6f7a8cfa9..88f6ae6ccd 100644 --- a/rfb/rfb-2-signers-ui.md +++ b/rfb/rfb-2-signers-ui.md @@ -2,7 +2,7 @@ ## Background -The Tally Ho wallet has always been meant to support a few things that haven't +The Taho wallet has always been meant to support a few things that haven't yet been implemented. In particular, the wallet is expected to support potentially many different types of out-of-memory signing---from connected hardware wallets like Ledger and Trezor, to air-gapped hardware wallets like @@ -27,7 +27,7 @@ the user is still reviewing the transaction. ### Current Functionality -The initial community edition release of Tally Ho featured a single way to add +The initial community edition release of Taho featured a single way to add an account: via a mnemonic that created an underlying HDKeyring. A keyring is an in-memory cryptographic base key that can derive multiple addresses and sign for them with private key material. The community edition also supported a diff --git a/src/popup.ts b/src/popup.ts index e1241f7859..b826619909 100644 --- a/src/popup.ts +++ b/src/popup.ts @@ -39,6 +39,19 @@ setTimeout(() => { } }, 1000) -window.resizeBy(0, window.outerHeight - window.innerHeight) +const POPUP_WIDTH = 384 +// const POPUP_HEIGHT = 600 + +/** + * Because browser.windows.create(...) takes a height and width that includes + * the native browser window frame, some popups end up slighty smaller than + * the popup triggered by the Tally toolbar icon. To fix this we resize the + * window by the window frame's size. + */ +const missingWidth = POPUP_WIDTH - window.innerWidth +// TODO: Change this to use POPUP_HEIGHT and test signing and other popups +const missingHeight = window.outerHeight - window.innerHeight + +window.resizeBy(missingWidth, missingHeight) attachPopupUIToRootElement() diff --git a/ui/README.md b/ui/README.md index f270401a56..82deb2a440 100644 --- a/ui/README.md +++ b/ui/README.md @@ -1,8 +1,8 @@ -# Tally Ho Extension Frontend 🐕 +# Taho Extension Frontend 🐕 ![Screen + Browser Mock](https://user-images.githubusercontent.com/1918798/125732391-29da0e00-0796-49bb-895d-35de187b141d.png) -Welcome to the frontend portion of the Tally Ho browser extension. This is the +Welcome to the frontend portion of the Taho browser extension. This is the React portion of the codebase which handles UI related states, and communicates with the background script API `@tallyho/tally-background`. The intent is for all communication with outside APIs to strictly happen within diff --git a/ui/_locales/en/messages.json b/ui/_locales/en/messages.json index b9cdf2a5f0..e737efbfbd 100644 --- a/ui/_locales/en/messages.json +++ b/ui/_locales/en/messages.json @@ -21,7 +21,7 @@ "addAddress": "Add address", "readOnly": "Read-only", "import": "Import", - "internal": "Tally Ho", + "internal": "Taho", "ledger": "Ledger", "category": { "readOnly": "Preview", @@ -131,7 +131,7 @@ "multipleLedgersDescriptor": "You can select as many as you want", "connectSelectedLedger": "Connect selected", "doneMessageOne": "Congratulations!", - "doneMessageTwo": "You can open Tally Ho now.", + "doneMessageTwo": "You can open Taho now.", "onboardingSuccessful": "Selected accounts were successfully connected.", "closeTab": "Close tab" }, @@ -206,7 +206,7 @@ "rollUp": "Roll-up", "estimatedGas": "Estimated Gas", "explainerOne": "The estimated gas cost for {{name}} transactions includes an {{name}} fee + an Ethereum roll-up fee (the fee to register transaction on Ethereum chain).", - "explainerTwo": "Tally Ho stays in sync with the current {{name}} and Ethereum network fees to estimate the fee for a given transaction.", + "explainerTwo": "Taho stays in sync with the current {{name}} and Ethereum network fees to estimate the fee for a given transaction.", "explainerThree": "Only in rare cases will the actual fee you pay change by more than 25% from the estimate.", "learnMore": "Learn More" }, @@ -218,7 +218,7 @@ "percentage": "percentage", "estimatedGas": "Estimated Gas", "explainerOne": "The estimated gas price for RSK transactions is calculated by adding 10% to the Minimum Gas Price obtained from the latest mined block.", - "explainerTwo": "Tally Ho stays in sync with the current RSK and Ethereum network fees to estimate the fee for a given transaction.", + "explainerTwo": "Taho stays in sync with the current RSK and Ethereum network fees to estimate the fee for a given transaction.", "explainerThree": "Only in rare cases will the actual fee you pay change by more than 10% from the estimate.", "learnMore": "Learn More" }, @@ -303,14 +303,14 @@ }, "achievements": { "empty": "Nothing to see here", - "startWith": "You can start with the Tally Ho collection!", - "collection": "Tally Ho collection" + "startWith": "You can start with the Taho collection!", + "collection": "Taho collection" }, "onboarding": { "tabbed": { "routeBasedContent": { "newSeed": { - "tip": "If you want an easy way to preview Tally Ho, you can start by adding a view only account", + "tip": "If you want an easy way to preview Taho, you can start by adding a view only account", "action": "Add preview account" }, "ledger": { @@ -321,10 +321,10 @@ "tip": "Some of the code for this was written by Community contributors" }, "viewOnly": { - "tip": "A good way to take a peek at what Tally Ho offers" + "tip": "A good way to take a peek at what Taho offers" }, "importSeed": { - "tip": "Tally Ho offers the possibility of adding multiple recovery phrases" + "tip": "Taho offers the possibility of adding multiple recovery phrases" }, "default": { "fact1": "Fully owned by the community", @@ -358,8 +358,8 @@ }, "viewOnly": { "title": "Read-only address", - "subtitle": "Add an Ethereum address or ENS name to view an existing wallet in Tally Ho!", - "submit": "Preview Tally Ho!", + "subtitle": "Add an Ethereum address or ENS name to view an existing wallet in Taho", + "submit": "Preview Taho", "tip": "You can upgrade a view-only wallet later" } }, @@ -386,14 +386,14 @@ "tip": "If you didn’t write it down, you can start with a new phrase" }, "complete": { - "title": "Welcome to Tally Ho!", - "subtitle": "For faster access we recommend pinning Tally Ho to your browser", + "title": "Welcome to Taho", + "subtitle": "For faster access we recommend pinning Taho to your browser", "animationAlt": "Pin the wallet" } }, "setPassword": { "title": "First, let's secure
your wallet", - "setAsDefault": "Set Tally Ho as default wallet", + "setAsDefault": "Set Taho as default wallet", "submit": "Begin the hunt" }, "supportedChains": "Supported Chains", @@ -480,7 +480,7 @@ "mainMenu": "Settings", "signing": "Signing", "hideSmallAssetBalance": "Hide asset balances under {{sign}}{{amount}}", - "setAsDefault": "Use Tally Ho as your default wallet", + "setAsDefault": "Use Taho as your default wallet", "enableTestNetworks": "Enable test networks", "language": "Language", "bugReport": "Bug report", @@ -552,7 +552,7 @@ "balance": "Balance: {{amount}}" }, "shared": { - "tallyHo": "Tally Ho", + "taho": "Taho", "metaMask": "MetaMask", "injected": "Injected", "cancelBtn": "Cancel", @@ -631,6 +631,16 @@ "confirmButtonLabel": "Confirm" } }, + "addNewChain": { + "subtitle": "Wants to add a main network to Tally Ho!", + "name": "Name", + "chainId": "Chain ID", + "currency": "Currency", + "rpc": "RPC", + "explorer": "BlockExplorer", + "submit": "Add network", + "cancel": "Reject" + }, "swap": { "title": "Swap Assets", "from": "Swap from:", @@ -647,7 +657,7 @@ "rewards": { "header": "Swap rewards for community", "body": "This week, 240,000 DOGGO tokens will be equally shared as Swap Rewards.", - "tooltip": "Tally Ho rewards its users that use swap every week. A council decides weekly prizes and who is eligible.", + "tooltip": "Taho rewards its users that use swap every week. A council decides weekly prizes and who is eligible.", "detailButton": "Details" }, "error": { @@ -666,11 +676,11 @@ }, "switchWallet": { "title": "Want to use another wallet instead?", - "tooltip": "You are seeing this because Tally Ho! is set as default wallet, you can change this option in the main menu.", + "tooltip": "You are seeing this because Taho is set as default wallet, you can change this option in the main menu.", "confirmSwitchWallet": "Yes, switch wallet", - "notDefaultWalletMessage": "Tally Ho not Default", - "disableWalletExplainer": "We disabled Tally Ho as the default wallet for you. You can always re-enable it from Menu ☰ at any time.", - "useTallyHoAsDefaultPrompt": "Use Tally Ho as default wallet", + "notDefaultWalletMessage": "Taho not Default", + "disableWalletExplainer": "We disabled Taho as the default wallet for you. You can always re-enable it from Menu ☰ at any time.", + "useTahoAsDefaultPrompt": "Use Taho as default wallet", "closeButton": "Close window" }, "toggle": { @@ -685,7 +695,7 @@ "protocolList": { "testnetsSectionTitle": "Testnets", "customRPCFooterTitle": "Activate or add networks", - "customRPCFooterDesc": "You can activate any network that is already added to Tally Ho! or you can add any other network.", + "customRPCFooterDesc": "You can activate any network that is already added to Taho or you can add any other network.", "networkSettingsBtn": "Network settings" }, "connectedDappInfo": { @@ -694,17 +704,17 @@ "dappConnections": "Dapp connections", "guideline": { "title": "How to connect to dapps", - "step1": "Set Tally Ho as default", + "step1": "Set Taho 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", + "walletConnectInfo": "You can now connect to any dapp that supports Wallet Connect by selecting Taho from desktop tab. Learn more", "walletConnectHint": "Check the desktop tab in the connection modal" } }, "wallet": { "activities": { - "historicalActivityExplainer": "Tally Ho will populate your historical activity over time; this may take an hour or more for accounts that have been active for a long time. For new accounts, new activity will show up here.", + "historicalActivityExplainer": "Taho will populate your historical activity over time; this may take an hour or more for accounts that have been active for a long time. For new accounts, new activity will show up here.", "endOfList": "You have reached the end of activity list.", "moreHistory": "For more history visit", "tokenApproved": "Token approval", @@ -761,7 +771,7 @@ "snackbar": "You can change this in Settings later", "isDefault": "is now your default wallet", "notDefault": "is not your default wallet", - "tooltip": "Setting Tally Ho as your default wallet means that every time you connect to a dApp, Tally Ho will open instead of MetaMask or other wallets." + "tooltip": "Setting Taho as your default wallet means that every time you connect to a dApp, Taho will open instead of MetaMask or other wallets." }, "analyticsNotification": { "title": "Analytics are enabled", @@ -771,10 +781,10 @@ }, "dAppConnect": { "switchWallet": { - "title": "Tally Ho not Default", - "descFirstPart": "We disabled Tally as default wallet for you. You can always enable it back from Settings", + "title": "Taho not Default", + "descFirstPart": "We disabled Taho as default wallet for you. You can always enable it back from Settings", "descSecondPart": "at any time.", - "toggleTitle": "Use Tally Ho as default wallet" + "toggleTitle": "Use Taho as default wallet" } }, "abilities": { @@ -847,6 +857,13 @@ "desc": "Looks like you encountered an empty bowl, try refreshing the page to see if something appears", "submitBtn": "Refresh page" }, + "globalModal": { + "title": "Hello World. Meet Taho.", + "description1": "In 2021, Tally Ho came into the world on a fox hunt. We set our sights on educating the world about the dangers of centralized wallets, and our name was a playful nod to that.", + "description2": "But now, our sights are set on a bigger goal. It's time to update our identity.", + "description3": "Tally Ho is now Taho.", + "button": "Read more" + }, "devPanel": { "title": "Developer Panel", "featureFlags": { diff --git a/ui/_locales/es/messages.json b/ui/_locales/es/messages.json index 3aaa11a3bc..166ca08862 100644 --- a/ui/_locales/es/messages.json +++ b/ui/_locales/es/messages.json @@ -90,7 +90,7 @@ "joinTitle": "Únete a nuestra comunidad", "language": "Idioma", "mainMenu": "Ajustes", - "setAsDefault": "Usa Tally Ho como su billetera predeterminada" + "setAsDefault": "Usa Taho como su billetera predeterminada" }, "shared": { "cancelBtn": "Cancelar" diff --git a/ui/_locales/pt_BR/messages.json b/ui/_locales/pt_BR/messages.json index d6636342e1..03af15b0e5 100644 --- a/ui/_locales/pt_BR/messages.json +++ b/ui/_locales/pt_BR/messages.json @@ -47,7 +47,7 @@ "joinDesc": "Junte-se ao nosso Discord para nos dar feedback!", "joinTitle": "Junte-se a nossa comunidade", "mainMenu": "Definições", - "setAsDefault": "Usar Tally Ho como sua carteira padrão" + "setAsDefault": "Usar Taho como sua carteira padrão" }, "swap": { "error": { diff --git a/ui/_locales/zh_Hans/messages.json b/ui/_locales/zh_Hans/messages.json index 089a38ed62..70cf8b0329 100644 --- a/ui/_locales/zh_Hans/messages.json +++ b/ui/_locales/zh_Hans/messages.json @@ -34,7 +34,7 @@ "hideSmallAssetBalance": "在 {{sign}}{{amount}} 下隐藏资产余额", "language": "语言", "mainMenu": "设置", - "setAsDefault": "使用 Tally Ho 作为您的默认钱包" + "setAsDefault": "使用 Taho 作为您的默认钱包" }, "swap": { "error": { diff --git a/ui/_locales/zh_Hant/messages.json b/ui/_locales/zh_Hant/messages.json index 2072af16eb..63aa77d05f 100644 --- a/ui/_locales/zh_Hant/messages.json +++ b/ui/_locales/zh_Hant/messages.json @@ -91,7 +91,7 @@ "joinTitle": "加入我們的社群", "language": "語言", "mainMenu": "設定", - "setAsDefault": "將 Tally Ho 設為你的預設錢包" + "setAsDefault": "將 Taho 設為你的預設錢包" }, "shared": { "cancelBtn": "取消" diff --git a/ui/components/Claim/ClaimManifesto.tsx b/ui/components/Claim/ClaimManifesto.tsx index bee6ca4064..2abc1ad63a 100644 --- a/ui/components/Claim/ClaimManifesto.tsx +++ b/ui/components/Claim/ClaimManifesto.tsx @@ -29,7 +29,7 @@ export default function ClaimManifesto({ Open source over restrictive licensing

- All Tally Ho code is—and will remain—100% free and open source. For + All Taho code is—and will remain—100% free and open source. For anyone to review, fork, hack, or remix.
diff --git a/ui/components/GlobalModal/GlobalModal.tsx b/ui/components/GlobalModal/GlobalModal.tsx new file mode 100644 index 0000000000..250d76f682 --- /dev/null +++ b/ui/components/GlobalModal/GlobalModal.tsx @@ -0,0 +1,90 @@ +import React, { ReactElement } from "react" +import { useTranslation } from "react-i18next" +import { getLocalStorageItem, useLocalStorage } from "../../hooks" +import SharedButton from "../Shared/SharedButton" +import SharedModal from "../Shared/SharedModal" + +const IMG = `/images/tahonamechange.gif` +// TODO update +const LINK = "https://blog.taho.xyz/rename-announcement" + +export default function GlobalModal({ id }: { id: string }): ReactElement { + const { t } = useTranslation("translation", { keyPrefix: "globalModal" }) + + const [showModal, setShowModal] = useLocalStorage( + `modal_${id}`, + getLocalStorageItem(`modal_${id}`, "true") + ) + + const handleClick = () => { + setShowModal("false") + window.open(LINK, "_blank")?.focus() + } + + return ( + setShowModal("false")} + width="90%" + minHeight="550px" + bgColor="var(--green-95)" + shadowBgColor="var(--green-120)" + > +
+
+ +
+
+ {t("title")} +
+ {t("description1")} + {t("description2")} + {t("description3")} +
+
+ + {t("button")} + +
+ +
+ ) +} diff --git a/ui/components/Keyring/KeyringSetPassword.tsx b/ui/components/Keyring/KeyringSetPassword.tsx index d7c761248c..7ada91f14a 100644 --- a/ui/components/Keyring/KeyringSetPassword.tsx +++ b/ui/components/Keyring/KeyringSetPassword.tsx @@ -112,8 +112,10 @@ export default function KeyringSetPassword(): ReactElement { width: 100%; } .wordmark { - background: url("./images/wordmark@2x.png"); - background-size: cover; + background: url("./images/wordmark.svg"); + background-size: contain; + background-repeat: no-repeat; + background-position: center; width: 95px; height: 25px; position: absolute; diff --git a/ui/components/NFTS_update/NFTCollection.tsx b/ui/components/NFTS_update/NFTCollection.tsx index 749e4baee6..fe924fd743 100644 --- a/ui/components/NFTS_update/NFTCollection.tsx +++ b/ui/components/NFTS_update/NFTCollection.tsx @@ -112,7 +112,7 @@ export default function NFTCollection(props: { const onItemClick = (nft: NFTCached) => openPreview({ nft, collection }) - if (!nftCount && !isLoading && wasUpdated) return <> + if ((!nftCount || !nfts.length) && !isLoading && wasUpdated) return <> return ( <> @@ -121,6 +121,7 @@ export default function NFTCollection(props: { expanded: isExpanded && !isLoading, invisible: !nftCount, })} + data-testid="nft_list_item" >
  • = { close: { icon: "close", - label: i18n.t("nfts.collectionHover.close"), + label: "nfts.collectionHover.close", background: "var(--green-40)", backgroundHover: "var(--green-20)", size: 12, @@ -24,25 +25,25 @@ const icons: Record< }, expand: { icon: "chevron", - label: i18n.t("nfts.collectionHover.expand"), + label: "nfts.collectionHover.expand", background: "var(--success)", size: 12, style: "margin-bottom: 3px;", }, view: { icon: "eye", - label: i18n.t("nfts.collectionHover.view"), + label: "nfts.collectionHover.view", background: "var(--trophy-gold)", size: 22, style: "", }, } -const getIcon = (isCollection: boolean, isExpanded: boolean) => { +const getIconType = (isCollection: boolean, isExpanded: boolean) => { if (isCollection) { - return isExpanded ? icons.close : icons.expand + return isExpanded ? "close" : "expand" } - return icons.view + return "view" } export default function NFTsHover(props: { @@ -51,15 +52,21 @@ export default function NFTsHover(props: { onClick: () => void }): ReactElement { const { isCollection = false, isExpanded = false, onClick } = props + const { t } = useTranslation() - const { icon, label, background, backgroundHover, size, style } = getIcon( - isCollection, - isExpanded - ) + const iconType = getIconType(isCollection, isExpanded) + + const { icon, label, background, backgroundHover, size, style } = + icons[iconType] return ( - ) } - -SharedButton.defaultProps = { - isDisabled: false, - iconPosition: "right", - linkTo: null, - showLoadingOnClick: false, - isLoading: false, - isFormSubmit: false, -} diff --git a/ui/components/Shared/SharedLoadingDoggo.tsx b/ui/components/Shared/SharedLoadingDoggo.tsx index 4d191e5811..c07c9fd54d 100644 --- a/ui/components/Shared/SharedLoadingDoggo.tsx +++ b/ui/components/Shared/SharedLoadingDoggo.tsx @@ -16,7 +16,7 @@ export default function SharedLoadingDoggo({ animated: animate = true, }: SharedLoadingDoggoProps): ReactElement { return ( -
    +
    void isOpen: boolean minHeight?: string + width?: string closeOnOverlayClick?: boolean + bgColor?: string + shadowBgColor?: string } const modalElement = document.getElementById("tally-root") as HTMLElement @@ -20,6 +23,9 @@ export default function SharedModal({ onClose, isOpen, minHeight, + width, + bgColor, + shadowBgColor, closeOnOverlayClick = true, }: SharedModalProps): ReactElement { const ref = useRef(null) @@ -41,7 +47,7 @@ export default function SharedModal({ />
    -

    {header}

    + {header &&

    {header}

    } {children}
    @@ -67,7 +73,7 @@ export default function SharedModal({ left: 0; width: 100%; height: 100%; - background-color: var(--hunter-green); + background-color: ${shadowBgColor || "var(--hunter-green)"}; opacity: 0.7; } .modal_content { @@ -76,8 +82,8 @@ export default function SharedModal({ align-items: center; z-index: 1; box-sizing: border-box; - width: 312px; - background-color: var(--green-120); + width: ${width || "312px"}; + background-color: ${bgColor || "var(--green-120)"}; padding: 24px; box-shadow: 0px 24px 24px rgba(0, 20, 19, 0.14), 0px 14px 16px rgba(0, 20, 19, 0.24), @@ -91,7 +97,6 @@ export default function SharedModal({ mask-size: cover; width: 11px; height: 11px; - padding: 2.5px; position: absolute; right: 16px; top: 16px; diff --git a/ui/components/Shared/SharedPanelSwitcher.tsx b/ui/components/Shared/SharedPanelSwitcher.tsx index 67e1c78d10..b142fc6acf 100644 --- a/ui/components/Shared/SharedPanelSwitcher.tsx +++ b/ui/components/Shared/SharedPanelSwitcher.tsx @@ -1,24 +1,32 @@ import React, { ReactElement } from "react" -interface Props { +type Props = { setPanelNumber: (x: number) => void panelNumber: number panelNames: string[] + panelId?: string } export default function SharedPanelSwitcher(props: Props): ReactElement { - const { setPanelNumber, panelNumber, panelNames } = props + const { + setPanelNumber, + panelNumber, + panelNames, + panelId = "panel_switcher", + } = props // TODO: make these styles work for more than two panels // .selected::after is the hardcoded culprit. return (