diff --git a/CHANGELOG-Sns_Aggregator.md b/CHANGELOG-Sns_Aggregator.md index 2781b1b0e7e..2d06690deb2 100644 --- a/CHANGELOG-Sns_Aggregator.md +++ b/CHANGELOG-Sns_Aggregator.md @@ -9,10 +9,6 @@ The SNS Aggregator is released through proposals in the Network Nervous System. ## Unreleased ### Added - -- Include SNS nervous system parameters. -- Add common code snippets for developers to the "Documentation" chapter on the landing page. - ### Changed ### Deprecated ### Removed @@ -21,6 +17,10 @@ The SNS Aggregator is released through proposals in the Network Nervous System. - Decoding quota of 10,000 in the `http_request` method. +## [Proposal 129614](https://dashboard.internetcomputer.org/proposal/129614) +### Added +- Include SNS nervous system parameters. +- Add common code snippets for developers to the "Documentation" chapter on the landing page. ## [Proposal 126006](https://nns.ic0.app/proposal/?u=qoctq-giaaa-aaaaa-aaaea-cai&proposal=126006) ### Changed diff --git a/dfx.json b/dfx.json index e33e73372b5..56da79dbdd2 100644 --- a/dfx.json +++ b/dfx.json @@ -386,7 +386,7 @@ "DIDC_VERSION": "2024-05-14", "POCKETIC_VERSION": "3.0.1", "CARGO_SORT_VERSION": "1.0.9", - "SNSDEMO_RELEASE": "release-2024-08-14", + "SNSDEMO_RELEASE": "release-2024-08-21", "IC_COMMIT_FOR_PROPOSALS": "release-2024-08-02_01-30-base", "IC_COMMIT_FOR_SNS_AGGREGATOR": "release-2024-08-02_01-30-base" }, diff --git a/frontend/src/lib/components/accounts/IcrcWalletPage.svelte b/frontend/src/lib/components/accounts/IcrcWalletPage.svelte index f9650b5b435..177140bfe11 100644 --- a/frontend/src/lib/components/accounts/IcrcWalletPage.svelte +++ b/frontend/src/lib/components/accounts/IcrcWalletPage.svelte @@ -20,10 +20,27 @@ hasAccounts, } from "$lib/utils/accounts.utils"; import { replacePlaceholders } from "$lib/utils/i18n.utils"; - import { Island, Spinner } from "@dfinity/gix-components"; + import { + IconBin, + IconDots, + Island, + Popover, + Spinner, + Tag, + } from "@dfinity/gix-components"; import type { Principal } from "@dfinity/principal"; import { TokenAmountV2, isNullish, nonNullish } from "@dfinity/utils"; import type { Writable } from "svelte/store"; + import { accountsPathStore } from "$lib/derived/paths.derived"; + import { startBusy, stopBusy } from "$lib/stores/busy.store"; + import { importedTokensStore } from "$lib/stores/imported-tokens.store"; + import { removeImportedTokens } from "$lib/services/imported-tokens.services"; + import TestIdWrapper from "$lib/components/common/TestIdWrapper.svelte"; + import LinkToDashboardCanister from "$lib/components/common/LinkToDashboardCanister.svelte"; + import { isImportedToken as checkImportedToken } from "$lib/utils/imported-tokens.utils"; + import ImportTokenRemoveConfirmation from "$lib/components/accounts/ImportTokenRemoveConfirmation.svelte"; + import type { Universe } from "$lib/types/universe"; + import { selectableUniversesStore } from "$lib/derived/selectable-universes.derived"; export let testId: string; export let accountIdentifier: string | undefined | null = undefined; @@ -142,58 +159,150 @@ ledgerCanisterId, isSignedIn: $authSignedInStore, }))(); + + const remove = async () => { + if (isNullish(ledgerCanisterId)) return; + + startBusy({ + initiator: "import-token-removing", + labelKey: "import_token.removing", + }); + + const importedTokens = $importedTokensStore.importedTokens ?? []; + const { success } = await removeImportedTokens({ + tokensToRemove: importedTokens.filter( + ({ ledgerCanisterId: id }) => id.toText() === ledgerCanisterId?.toText() + ), + importedTokens, + }); + + stopBusy("import-token-removing"); + + if (success) { + goto($accountsPathStore); + } + }; + + let moreButton: HTMLButtonElement | undefined; + let morePopupVisible = false; + + let isImportedToken = false; + $: isImportedToken = checkImportedToken({ + ledgerCanisterId, + importedTokens: $importedTokensStore.importedTokens, + }); + + let removeImportedTokenConfirmtionVisible = false; + + let universe: Universe | undefined; + $: universe = $selectableUniversesStore.find( + ({ canisterId }) => canisterId === ledgerCanisterId?.toText() + ); - -
-
- {#if loaded && nonNullish(ledgerCanisterId)} - {#if nonNullish($selectedAccountStore.account) && nonNullish(token)} - - {/if} - - - - - - - {#if $$slots["info-card"]} -
- + + +
+
+ {#if loaded && nonNullish(ledgerCanisterId)} + {#if nonNullish($selectedAccountStore.account) && nonNullish(token)} + + {/if} + + + {#if isImportedToken} + {$i18n.import_token.imported_token} + {/if} + + + {#if nonNullish(ledgerCanisterId)} + + {/if} + + + + + + + + {#if $$slots["info-card"]} +
+ +
+ {/if} + + + + +
+
+ {:else} + {/if} +
+
- + +
- -
- -
- {:else} - - {/if} -
-
+ {#if nonNullish(ledgerCanisterId)} + +
+ + {#if isImportedToken && nonNullish(universe)} + + {/if} +
+
+ {/if} - -
+ {#if removeImportedTokenConfirmtionVisible && nonNullish(universe)} + (removeImportedTokenConfirmtionVisible = false)} + on:nnsConfirm={remove} + /> + {/if} + diff --git a/frontend/src/lib/components/accounts/ImportTokenCanisterId.svelte b/frontend/src/lib/components/accounts/ImportTokenCanisterId.svelte index 1c46ce0e7b5..8b88c446e13 100644 --- a/frontend/src/lib/components/accounts/ImportTokenCanisterId.svelte +++ b/frontend/src/lib/components/accounts/ImportTokenCanisterId.svelte @@ -1,23 +1,22 @@ -
+
{label}
{#if nonNullish(canisterId)} {canisterId} - - {#if nonNullish(canisterLinkHref)} - - {/if} + + {:else if nonNullish(canisterIdFallback)} {canisterIdFallback} + import { i18n } from "$lib/stores/i18n"; + import UniversePageSummary from "$lib/components/universe/UniversePageSummary.svelte"; + import type { Universe } from "$lib/types/universe"; + import { nonNullish } from "@dfinity/utils"; + import ConfirmationModal from "$lib/modals/common/ConfirmationModal.svelte"; + import { Tag } from "@dfinity/gix-components"; + + export let universe: Universe | undefined; + + + +
+

{$i18n.import_token.remove_confirmation_description_1}

+
+ {#if nonNullish(universe)}{/if} + {$i18n.import_token.imported_token} +
+

+ {$i18n.import_token.remove_confirmation_description_2} +

+
+
+ + diff --git a/frontend/src/lib/components/accounts/ImportTokenReview.svelte b/frontend/src/lib/components/accounts/ImportTokenReview.svelte new file mode 100644 index 00000000000..d3861a26d66 --- /dev/null +++ b/frontend/src/lib/components/accounts/ImportTokenReview.svelte @@ -0,0 +1,87 @@ + + +
+
+ +
+
{tokenMetaData.name}
+
+ {tokenMetaData.symbol} +
+
+
+ + + + + + + +
+ + + +
+
+ + diff --git a/frontend/src/lib/components/accounts/WalletPageHeader.svelte b/frontend/src/lib/components/accounts/WalletPageHeader.svelte index 57f5e6cb029..4b87e661cea 100644 --- a/frontend/src/lib/components/accounts/WalletPageHeader.svelte +++ b/frontend/src/lib/components/accounts/WalletPageHeader.svelte @@ -12,6 +12,7 @@
+
+ import { IconOpenInNew } from "@dfinity/gix-components"; + import { replacePlaceholders } from "$lib/utils/i18n.utils"; + import { i18n } from "$lib/stores/i18n"; + import type { Principal } from "@dfinity/principal"; + + export let canisterId: Principal; + export let noLabel: boolean = false; + + let href: string; + $: href = replacePlaceholders($i18n.import_token.link_to_dashboard, { + $canisterId: canisterId.toText(), + }); + + + + + {#if !noLabel} + {$i18n.import_token.view_in_dashboard} + {/if} + + + diff --git a/frontend/src/lib/components/tokens/TokensTable/TokenTitleCell.svelte b/frontend/src/lib/components/tokens/TokensTable/TokenTitleCell.svelte index 6d1eccb4719..ddf01e8fdd7 100644 --- a/frontend/src/lib/components/tokens/TokensTable/TokenTitleCell.svelte +++ b/frontend/src/lib/components/tokens/TokensTable/TokenTitleCell.svelte @@ -2,8 +2,19 @@ import type { UserTokenData, UserTokenLoading } from "$lib/types/tokens-page"; import Logo from "../../ui/Logo.svelte"; import { nonNullish } from "@dfinity/utils"; + import { importedTokensStore } from "$lib/stores/imported-tokens.store"; + import { Tag } from "@dfinity/gix-components"; + import { i18n } from "$lib/stores/i18n"; + import { isImportedToken } from "$lib/utils/imported-tokens.utils"; export let rowData: UserTokenData | UserTokenLoading; + + // TODO: test me + let importedToken = false; + $: importedToken = isImportedToken({ + ledgerCanisterId: rowData?.universeId, + importedTokens: $importedTokensStore.importedTokens, + });
@@ -16,6 +27,9 @@ > {/if}
+ {#if importedToken} + {$i18n.import_token.imported_token} + {/if}
diff --git a/frontend/src/lib/constants/tokens.constants.ts b/frontend/src/lib/constants/tokens.constants.ts index 66134a3733d..03dedae7068 100644 --- a/frontend/src/lib/constants/tokens.constants.ts +++ b/frontend/src/lib/constants/tokens.constants.ts @@ -1,3 +1,9 @@ +import { + CKBTC_UNIVERSE_CANISTER_ID, + CKTESTBTC_UNIVERSE_CANISTER_ID, +} from "$lib/constants/ckbtc-canister-ids.constants"; +import { CKETH_UNIVERSE_CANISTER_ID } from "$lib/constants/cketh-canister-ids.constants"; +import { CKUSDC_UNIVERSE_CANISTER_ID } from "$lib/constants/ckusdc-canister-ids.constants"; import { DEFAULT_TRANSACTION_FEE_E8S } from "$lib/constants/icp.constants"; import type { TokensStoreUniverseData } from "$lib/stores/tokens.store"; import { ICPToken } from "@dfinity/utils"; @@ -11,3 +17,12 @@ export const NNS_TOKEN: TokensStoreUniverseData = { token: NNS_TOKEN_DATA, certified: true, }; + +// Tokens that have significance within the Internet Computer ecosystem. +// The fixed order maps to a descending order in the market cap of the underlying native tokens. +export const IMPORTANT_CK_TOKEN_IDS = [ + CKBTC_UNIVERSE_CANISTER_ID, + CKTESTBTC_UNIVERSE_CANISTER_ID, + CKETH_UNIVERSE_CANISTER_ID, + CKUSDC_UNIVERSE_CANISTER_ID, +]; diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index e7f69c3e8f9..4a4ed844acd 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -120,13 +120,13 @@ "adding_permissions": "There was an error adding permissions.", "canister_invalid_transaction": "Sorry, there was a problem with the transaction.", "qrcode_camera_error": "An error occurred while initializing the camera to read the QR code.", - "qrcode_token_incompatible": "The payment token you are attempting to use is not compatible with this transaction." + "qrcode_token_incompatible": "The payment token you are attempting to use is not compatible with this transaction.", + "invalid_ledger_index_pair": "The provided index canister ID does not match the associated ledger canister ID.", + "index_canister_validation": "An error occurred while validating the index canister ID." }, "warning": { "auth_sign_out": "You have been logged out because your session has expired.", - "test_env_welcome": "Welcome to the Testing Environment.", - "test_env_note": "Please note that you are currently using a test version of NNS-dapp that operates on the Internet Computer mainnet. Although it utilizes real data, it is intended solely for testing purposes.", - "test_env_request": "We kindly remind you that the functionality and availability of this testing dapp may change or even disappear at any time. Therefore, it is crucial to refrain from relying on it for any production or critical activities.", + "test_env_note": "Use with caution! This is not stable software, and it is not securely updated by the Network Nervous System. However, it accesses your real wallets and neurons.

Functionality you see here can change at any time, and this dapp may not always be available.", "test_env_confirm": "I understand and want to continue", "test_env_title": "Warning" }, @@ -841,7 +841,10 @@ "load_imported_tokens": "There was an unexpected issue while loading imported tokens.", "add_imported_token": "There was an unexpected issue while adding new imported token.", "remove_imported_token": "There was an unexpected issue while removing the imported token.", - "too_many": "You can't import more than $limit tokens." + "too_many": "You can't import more than $limit tokens.", + "is_duplication": "You have already imported this token, you can find it in the token list.", + "is_sns": "You cannot import SNS tokens, they are added automatically.", + "is_important": "This token is already in the token list." }, "error__sns": { "undefined_project": "The requested project is invalid or throws an error.", @@ -1027,19 +1030,29 @@ "hide_zero_balances": "Hide zero balances", "hide_zero_balances_toggle_label": "Switch between showing and hiding tokens with a balance of zero", "zero_balance_hidden": "Tokens with 0 balances are hidden.", - "show_all": "Show all", - "add_imported_token_success": "New token has been successfully imported!", - "remove_imported_token_success": "The token has been successfully removed!" + "show_all": "Show all" }, "import_token": { "import_token": "Import Token", + "import_button": "Import", + "imported_token": "Imported Token", "description": "To import a new token to your NNS dapp wallet, you will need to find, and paste the ledger canister id of the token. If you want to see your transaction history, you need to import the token’s index canister.", "ledger_label": "Ledger Canister ID", "index_label_optional": "Index Canister ID (Optional)", + "index_label": "Index Canister ID", + "index_fallback_label": "Transaction history won’t be displayed.", "placeholder": "00000-00000-00000-00000-000", "index_canister_description": "Index Canister allows to display a token balance and transaction history. Note: not all tokens have index canisters.", - "review_token_info": "Review token info", "warning": "Warning: Be careful what token you import! Anyone can create a token including one with the same name as existing tokens, such as ckBTC.", + "verifying": "Veryifying token details...", + "importing": "Importing new token...", + "removing": "Removing imported token...", + "review_token_info": "Review token info", + "ledger_canister_loading_error": "Unable to load token details using the provided Ledger Canister ID.", + "add_imported_token_success": "New token has been successfully imported!", + "remove_imported_token_success": "The token has been successfully removed!", + "remove_confirmation_description_1": "Are you sure you want to remove this token from your account?", + "remove_confirmation_description_2": "Tokens you hold in your account will not be lost, and you can add the token back in the future.", "view_in_dashboard": "View in Dashboard", "link_to_dashboard": "https://dashboard.internetcomputer.org/canister/$canisterId" } diff --git a/frontend/src/lib/modals/accounts/ImportTokenModal.svelte b/frontend/src/lib/modals/accounts/ImportTokenModal.svelte index ae38debe4fa..7c94ef626c0 100644 --- a/frontend/src/lib/modals/accounts/ImportTokenModal.svelte +++ b/frontend/src/lib/modals/accounts/ImportTokenModal.svelte @@ -8,8 +8,25 @@ import type { Principal } from "@dfinity/principal"; import ImportTokenForm from "$lib/components/accounts/ImportTokenForm.svelte"; import type { IcrcTokenMetadata } from "$lib/types/icrc"; - import { nonNullish } from "@dfinity/utils"; + import { isNullish, nonNullish } from "@dfinity/utils"; + import ImportTokenReview from "$lib/components/accounts/ImportTokenReview.svelte"; + import { startBusy, stopBusy } from "$lib/stores/busy.store"; + import { toastsError } from "$lib/stores/toasts.store"; + import { importedTokensStore } from "$lib/stores/imported-tokens.store"; + import { addImportedToken } from "$lib/services/imported-tokens.services"; + import { buildWalletUrl } from "$lib/utils/navigation.utils"; + import { goto } from "$app/navigation"; + import { createEventDispatcher } from "svelte"; + import { matchLedgerIndexPair } from "$lib/services/icrc-index.services"; + import { getIcrcTokenMetaData } from "$lib/services/icrc-accounts.services"; + import { isImportedToken } from "$lib/utils/imported-tokens.utils"; + import { snsProjectsCommittedStore } from "$lib/derived/sns/sns-projects.derived"; + import { isSnsLedgerCanisterId } from "$lib/utils/sns.utils"; + import { isImportantCkToken } from "$lib/utils/icrc-tokens.utils"; + let currentStep: WizardStep | undefined = undefined; + const dispatch = createEventDispatcher(); + const STEP_FORM = "Form"; const STEP_REVIEW = "Review"; const steps: WizardSteps = [ @@ -22,16 +39,118 @@ title: $i18n.import_token.review_token_info, }, ]; + let modal: WizardModal; const next = () => { modal?.next(); }; + const back = () => { + modal?.back(); + }; + let ledgerCanisterId: Principal | undefined; let indexCanisterId: Principal | undefined; let tokenMetaData: IcrcTokenMetadata | undefined; + + const getTokenMetaData = async ( + ledgerCanisterId: Principal + ): Promise => { + try { + return await getIcrcTokenMetaData({ ledgerCanisterId }); + } catch (err) { + toastsError({ + labelKey: "import_token.ledger_canister_loading_error", + err, + }); + } + }; + const onUserInput = async () => { - // TODO: load metadata and validation - next(); + if (isNullish(ledgerCanisterId)) return; + + // Ledger canister ID validation + if ( + isImportedToken({ + ledgerCanisterId, + importedTokens: $importedTokensStore?.importedTokens, + }) + ) { + return toastsError({ + labelKey: "error__imported_tokens.is_duplication", + }); + } + if ( + isSnsLedgerCanisterId({ + ledgerCanisterId, + snsProjects: $snsProjectsCommittedStore, + }) + ) { + return toastsError({ + labelKey: "error__imported_tokens.is_sns", + }); + } + if ( + isImportantCkToken({ + ledgerCanisterId, + }) + ) { + return toastsError({ + labelKey: "error__imported_tokens.is_important", + }); + } + + startBusy({ + initiator: "import-token-validation", + labelKey: "import_token.verifying", + }); + + tokenMetaData = await getTokenMetaData(ledgerCanisterId); + // No need to validate index canister if tokenMetaData fails to load or no index canister is provided + const validOrEmptyIndexCanister = + nonNullish(tokenMetaData) && + (nonNullish(indexCanisterId) + ? await matchLedgerIndexPair({ ledgerCanisterId, indexCanisterId }) + : true); + + stopBusy("import-token-validation"); + + if (validOrEmptyIndexCanister) { + next(); + } + }; + + const onUserConfirm = async () => { + if ( + isNullish(ledgerCanisterId) || + isNullish($importedTokensStore.importedTokens) + ) { + return; + } + + startBusy({ + initiator: "import-token-importing", + labelKey: "import_token.importing", + }); + + const { success } = await addImportedToken({ + tokenToAdd: { + ledgerCanisterId, + indexCanisterId, + }, + importedTokens: $importedTokensStore.importedTokens, + }); + + if (success) { + dispatch("nnsClose"); + + goto( + buildWalletUrl({ + universe: ledgerCanisterId.toText(), + }) + ); + } + + stopBusy("import-token-importing"); }; @@ -53,6 +172,12 @@ /> {/if} {#if currentStep?.name === STEP_REVIEW && nonNullish(ledgerCanisterId) && nonNullish(tokenMetaData)} - TBD: Review imported token + {/if} diff --git a/frontend/src/lib/modals/common/ConfirmationModal.svelte b/frontend/src/lib/modals/common/ConfirmationModal.svelte index ff7b525beb9..1645dab9df2 100644 --- a/frontend/src/lib/modals/common/ConfirmationModal.svelte +++ b/frontend/src/lib/modals/common/ConfirmationModal.svelte @@ -4,6 +4,8 @@ import { createEventDispatcher } from "svelte"; export let testId = "confirmation-modal-component"; + export let yesLabel: string | undefined = undefined; + const dispatch = createEventDispatcher(); @@ -23,7 +25,8 @@ data-tid="confirm-yes" disabled={$busy} class="primary" - on:click={() => dispatch("nnsConfirm")}>{$i18n.core.confirm_yes} dispatch("nnsConfirm")} + >{yesLabel ?? $i18n.core.confirm_yes} diff --git a/frontend/src/lib/pages/NnsWallet.svelte b/frontend/src/lib/pages/NnsWallet.svelte index 50d40aad5f6..e337194b6c9 100644 --- a/frontend/src/lib/pages/NnsWallet.svelte +++ b/frontend/src/lib/pages/NnsWallet.svelte @@ -68,7 +68,7 @@ } from "@dfinity/utils"; import { onMount, onDestroy, setContext } from "svelte"; import { writable, type Readable } from "svelte/store"; - import LinkToDashboardCanister from "$lib/components/tokens/LinkToDashboardCanister.svelte"; + import LinkToDashboardCanister from "$lib/components/common/LinkToDashboardCanister.svelte"; import { LEDGER_CANISTER_ID } from "$lib/constants/canister-ids.constants"; import { ENABLE_IMPORT_TOKEN } from "$lib/stores/feature-flags.store"; diff --git a/frontend/src/lib/pages/Tokens.svelte b/frontend/src/lib/pages/Tokens.svelte index 92e3c231ca5..be95df9adb4 100644 --- a/frontend/src/lib/pages/Tokens.svelte +++ b/frontend/src/lib/pages/Tokens.svelte @@ -9,9 +9,10 @@ import { heightTransition } from "$lib/utils/transition.utils"; import { IconPlus, IconSettings } from "@dfinity/gix-components"; import { Popover } from "@dfinity/gix-components"; - import { TokenAmountV2 } from "@dfinity/utils"; + import { nonNullish, TokenAmountV2 } from "@dfinity/utils"; import { ENABLE_IMPORT_TOKEN } from "$lib/stores/feature-flags.store"; import ImportTokenModal from "$lib/modals/accounts/ImportTokenModal.svelte"; + import { importedTokensStore } from "$lib/stores/imported-tokens.store"; export let userTokensData: UserToken[]; @@ -42,6 +43,9 @@ hideZeroBalancesStore.set("show"); }; + let shownImportTokenButton = false; + $: shownImportTokenButton = nonNullish($importedTokensStore.importedTokens); + let showImportTokenModal = false; // TODO(Import token): After removing ENABLE_IMPORT_TOKEN combine divs ->
@@ -78,13 +82,15 @@
{/if} - + {#if shownImportTokenButton} + + {/if}
{:else if shouldHideZeroBalances}
=> { // TODO: load imported tokens after Nns. /** - * If Nns load but Sns load fails it is "fine" to go on because Nns are core features. + * If Nns load but ImportedTokens or Sns load fails it is "fine" to go on because Nns are core features. */ - await Promise.allSettled([Promise.all(initNns), Promise.all(initSns)]); + await Promise.allSettled([ + Promise.all(initNns).then(() => + // When you log in with a new account for the first time, the account is created in the NNS dapp. + // If you request imported tokens before the account is created, an `AccountNotFound` error will be thrown. + // To avoid this, the imported tokens should only be requested after the NNS accounts have been initialized. + Promise.all(get(ENABLE_IMPORT_TOKEN) ? [loadImportedTokens()] : []) + ), + Promise.all(initSns), + ]); // Load the actionable proposals only after the Nns and Sns projects have been loaded. // Because it's a non-critical enhancement, the loading of actionable proposals should not delay the execution of this function. diff --git a/frontend/src/lib/services/icrc-accounts.services.ts b/frontend/src/lib/services/icrc-accounts.services.ts index a03066e5ca1..e67acfc6285 100644 --- a/frontend/src/lib/services/icrc-accounts.services.ts +++ b/frontend/src/lib/services/icrc-accounts.services.ts @@ -40,7 +40,7 @@ export const getIcrcTokenMetaData = async ({ ledgerCanisterId, }: { ledgerCanisterId: Principal; -}): Promise => { +}): Promise => { return queryIcrcToken({ identity: getCurrentIdentity(), canisterId: ledgerCanisterId, diff --git a/frontend/src/lib/services/icrc-index.services.ts b/frontend/src/lib/services/icrc-index.services.ts new file mode 100644 index 00000000000..7ac10df3014 --- /dev/null +++ b/frontend/src/lib/services/icrc-index.services.ts @@ -0,0 +1,57 @@ +import { getLedgerId as getLedgerIdApi } from "$lib/api/icrc-index.api"; +import { getAuthenticatedIdentity } from "$lib/services/auth.services"; +import { toastsError } from "$lib/stores/toasts.store"; +import type { Principal } from "@dfinity/principal"; + +const getLedgerId = async ({ + indexCanisterId, + certified, +}: { + indexCanisterId: Principal; + certified: boolean; +}): Promise => { + const identity = await getAuthenticatedIdentity(); + const ledgerId = await getLedgerIdApi({ + identity, + indexCanisterId, + certified, + }); + return ledgerId; +}; + +/** + * Validates whether the provided index canister ID corresponds to the given ledger canister ID. + * This function uses `ledger_id` icrc1 index canister api to check if the indexCanisterId is correctly associated with + * the provided ledgerCanisterId. + */ +export const matchLedgerIndexPair = async ({ + ledgerCanisterId, + indexCanisterId, +}: { + ledgerCanisterId: Principal; + indexCanisterId: Principal; +}): Promise => { + try { + const ledgerIdFromIndexCaniter = await getLedgerId({ + indexCanisterId, + certified: false, + }); + const match = + ledgerIdFromIndexCaniter.toText() === ledgerCanisterId.toText(); + + if (!match) { + toastsError({ + labelKey: "error.invalid_ledger_index_pair", + }); + } + return match; + } catch (err) { + console.error(err); + toastsError({ + labelKey: "error.index_canister_validation", + err, + }); + } + + return false; +}; diff --git a/frontend/src/lib/services/imported-tokens.services.ts b/frontend/src/lib/services/imported-tokens.services.ts index 62034fc388e..37317ec3a23 100644 --- a/frontend/src/lib/services/imported-tokens.services.ts +++ b/frontend/src/lib/services/imported-tokens.services.ts @@ -7,6 +7,7 @@ import type { ImportedTokens } from "$lib/canisters/nns-dapp/nns-dapp.types"; import { MAX_IMPORTED_TOKENS } from "$lib/constants/imported-tokens.constants"; import { FORCE_CALL_STRATEGY } from "$lib/constants/mockable.constants"; import { getAuthenticatedIdentity } from "$lib/services/auth.services"; +import { icrcCanistersStore } from "$lib/stores/icrc-canisters.store"; import { importedTokensStore } from "$lib/stores/imported-tokens.store"; import { toastsError, toastsSuccess } from "$lib/stores/toasts.store"; import type { ImportedTokenData } from "$lib/types/imported-tokens"; @@ -16,6 +17,7 @@ import { toImportedTokenData, } from "$lib/utils/imported-tokens.utils"; import { isNullish } from "@dfinity/utils"; +import { get } from "svelte/store"; import { queryAndUpdate } from "./utils.services"; /** Load imported tokens from the `nns-dapp` backend and update the `importedTokensStore` store. @@ -25,11 +27,23 @@ export const loadImportedTokens = async () => { return queryAndUpdate({ request: (options) => getImportedTokens(options), strategy: FORCE_CALL_STRATEGY, - onLoad: ({ response: { imported_tokens: importedTokens }, certified }) => + onLoad: ({ response: { imported_tokens: rawImportTokens }, certified }) => { + const importedTokens = rawImportTokens.map(toImportedTokenData); importedTokensStore.set({ - importedTokens: importedTokens.map(toImportedTokenData), + importedTokens, certified, - }), + }); + + const icrcCanistersStoreData = get(icrcCanistersStore); + for (const { ledgerCanisterId, indexCanisterId } of importedTokens) { + if (isNullish(icrcCanistersStoreData[ledgerCanisterId.toText()])) { + icrcCanistersStore.setCanisters({ + ledgerCanisterId, + indexCanisterId, + }); + } + } + }, onError: ({ error: err, certified }) => { console.error(err); @@ -87,7 +101,7 @@ export const addImportedToken = async ({ if (isNullish(err)) { await loadImportedTokens(); toastsSuccess({ - labelKey: "tokens.add_imported_token_success", + labelKey: "import_token.add_imported_token_success", }); return { success: true }; @@ -120,9 +134,12 @@ export const removeImportedTokens = async ({ tokensToRemove: ImportedTokenData[]; importedTokens: ImportedTokenData[]; }): Promise<{ success: boolean }> => { + const canisterIdsToRemove = tokensToRemove.map( + ({ ledgerCanisterId }) => ledgerCanisterId + ); // Compare imported tokens by their ledgerCanisterId because they should be unique. const ledgerIdsToRemove = new Set( - tokensToRemove.map(({ ledgerCanisterId }) => ledgerCanisterId.toText()) + canisterIdsToRemove.map((id) => id.toText()) ); const tokens = importedTokens.filter( ({ ledgerCanisterId }) => !ledgerIdsToRemove.has(ledgerCanisterId.toText()) @@ -130,9 +147,12 @@ export const removeImportedTokens = async ({ const { err } = await saveImportedToken({ tokens }); if (isNullish(err)) { + // TODO: update unit test to check if icrcCanistersStore.removeCanisters is called + icrcCanistersStore.removeCanisters(canisterIdsToRemove); + await loadImportedTokens(); toastsSuccess({ - labelKey: "tokens.remove_imported_token_success", + labelKey: "import_token.remove_imported_token_success", }); return { success: true }; diff --git a/frontend/src/lib/stores/busy.store.ts b/frontend/src/lib/stores/busy.store.ts index 95291a7be3b..986f40dff4a 100644 --- a/frontend/src/lib/stores/busy.store.ts +++ b/frontend/src/lib/stores/busy.store.ts @@ -45,7 +45,10 @@ export type BusyStateInitiatorType = | "dev-add-sns-neuron-maturity" | "dev-add-nns-neuron-maturity" | "update-ckbtc-balance" - | "reload-receive-account"; + | "reload-receive-account" + | "import-token-validation" + | "import-token-importing" + | "import-token-removing"; export interface BusyState { initiator: BusyStateInitiatorType; diff --git a/frontend/src/lib/stores/icrc-canisters.store.ts b/frontend/src/lib/stores/icrc-canisters.store.ts index fd71ca0ff31..c8e21b2317b 100644 --- a/frontend/src/lib/stores/icrc-canisters.store.ts +++ b/frontend/src/lib/stores/icrc-canisters.store.ts @@ -1,5 +1,6 @@ import { browser } from "$app/environment"; import type { UniverseCanisterIdText } from "$lib/types/universe"; +import { removeKeys } from "$lib/utils/utils"; import { Principal } from "@dfinity/principal"; import { nonNullish } from "@dfinity/utils"; import type { Readable } from "svelte/store"; @@ -7,7 +8,7 @@ import { writable } from "svelte/store"; export interface IcrcCanisters { ledgerCanisterId: Principal; - indexCanisterId: Principal; + indexCanisterId: Principal | undefined; } export type IcrcCanistersStoreData = Record< @@ -17,6 +18,7 @@ export type IcrcCanistersStoreData = Record< export interface IcrcCanistersStore extends Readable { setCanisters: (data: IcrcCanisters) => void; + removeCanisters: (ledgerCanisterIds: Principal[]) => void; reset: () => void; } @@ -48,6 +50,17 @@ const initIcrcCanistersStore = (): IcrcCanistersStore => { })); }, + // TODO: unit test me! + // Remove entries by ledger canister id. + removeCanisters(ledgerCanisterIds: Principal[]) { + update((state: IcrcCanistersStoreData) => + removeKeys({ + obj: state, + keysToRemove: ledgerCanisterIds.map((id) => id.toText()), + }) + ); + }, + // Used in tests reset() { set(initialIcrcCanistersStoreData); diff --git a/frontend/src/lib/types/i18n.d.ts b/frontend/src/lib/types/i18n.d.ts index 5ab3ccd890c..48797fb81fb 100644 --- a/frontend/src/lib/types/i18n.d.ts +++ b/frontend/src/lib/types/i18n.d.ts @@ -125,13 +125,13 @@ interface I18nError { canister_invalid_transaction: string; qrcode_camera_error: string; qrcode_token_incompatible: string; + invalid_ledger_index_pair: string; + index_canister_validation: string; } interface I18nWarning { auth_sign_out: string; - test_env_welcome: string; test_env_note: string; - test_env_request: string; test_env_confirm: string; test_env_title: string; } @@ -880,6 +880,9 @@ interface I18nError__imported_tokens { add_imported_token: string; remove_imported_token: string; too_many: string; + is_duplication: string; + is_sns: string; + is_important: string; } interface I18nError__sns { @@ -1086,19 +1089,29 @@ interface I18nTokens { hide_zero_balances_toggle_label: string; zero_balance_hidden: string; show_all: string; - add_imported_token_success: string; - remove_imported_token_success: string; } interface I18nImport_token { import_token: string; + import_button: string; + imported_token: string; description: string; ledger_label: string; index_label_optional: string; + index_label: string; + index_fallback_label: string; placeholder: string; index_canister_description: string; - review_token_info: string; warning: string; + verifying: string; + importing: string; + removing: string; + review_token_info: string; + ledger_canister_loading_error: string; + add_imported_token_success: string; + remove_imported_token_success: string; + remove_confirmation_description_1: string; + remove_confirmation_description_2: string; view_in_dashboard: string; link_to_dashboard: string; } diff --git a/frontend/src/lib/utils/icrc-tokens.utils.ts b/frontend/src/lib/utils/icrc-tokens.utils.ts index f50ed5eab88..c9679929615 100644 --- a/frontend/src/lib/utils/icrc-tokens.utils.ts +++ b/frontend/src/lib/utils/icrc-tokens.utils.ts @@ -1,3 +1,4 @@ +import { IMPORTANT_CK_TOKEN_IDS } from "$lib/constants/tokens.constants"; import type { TokensStore, TokensStoreData } from "$lib/stores/tokens.store"; import type { IcrcTokenMetadata } from "$lib/types/icrc"; import type { CachedSnsDto } from "$lib/types/sns-aggregator"; @@ -6,6 +7,7 @@ import { IcrcMetadataResponseEntries, type IcrcTokenMetadataResponse, } from "@dfinity/ledger-icrc"; +import type { Principal } from "@dfinity/principal"; import { isNullish, nonNullish } from "@dfinity/utils"; /** @@ -86,3 +88,12 @@ export const fillTokensStoreFromAggregatorData = ({ ) ); }; + +export const isImportantCkToken = ({ + ledgerCanisterId, +}: { + ledgerCanisterId: Principal; +}): boolean => + IMPORTANT_CK_TOKEN_IDS.some( + (token) => token.toText() === ledgerCanisterId.toText() + ); diff --git a/frontend/src/lib/utils/imported-tokens.utils.ts b/frontend/src/lib/utils/imported-tokens.utils.ts index 2cd09b72d9a..34ce02e9c58 100644 --- a/frontend/src/lib/utils/imported-tokens.utils.ts +++ b/frontend/src/lib/utils/imported-tokens.utils.ts @@ -1,6 +1,7 @@ import type { ImportedToken } from "$lib/canisters/nns-dapp/nns-dapp.types"; import type { ImportedTokenData } from "$lib/types/imported-tokens"; -import { fromNullable, toNullable } from "@dfinity/utils"; +import type { Principal } from "@dfinity/principal"; +import { fromNullable, nonNullish, toNullable } from "@dfinity/utils"; export const toImportedTokenData = ({ ledger_canister_id, @@ -17,3 +18,16 @@ export const fromImportedTokenData = ({ ledger_canister_id: ledgerCanisterId, index_canister_id: toNullable(indexCanisterId), }); + +export const isImportedToken = ({ + ledgerCanisterId, + importedTokens, +}: { + ledgerCanisterId: Principal | undefined; + importedTokens: ImportedTokenData[] | undefined; +}): boolean => + nonNullish(ledgerCanisterId) && + nonNullish(importedTokens) && + importedTokens.some( + ({ ledgerCanisterId: id }) => id.toText() === ledgerCanisterId.toText() + ); diff --git a/frontend/src/lib/utils/sns.utils.ts b/frontend/src/lib/utils/sns.utils.ts index 3d4404f318c..54d8b5b4fe7 100644 --- a/frontend/src/lib/utils/sns.utils.ts +++ b/frontend/src/lib/utils/sns.utils.ts @@ -1,5 +1,6 @@ import { SECONDS_IN_DAY } from "$lib/constants/constants"; import { MIN_VALID_SNS_GENERIC_NERVOUS_SYSTEM_FUNCTION_ID } from "$lib/constants/sns-proposals.constants"; +import type { SnsFullProject } from "$lib/derived/sns/sns-projects.derived"; import type { SnsTicketsStoreData } from "$lib/stores/sns-tickets.store"; import type { TicketStatus } from "$lib/types/sale"; import type { SnsSwapCommitment } from "$lib/types/sns"; @@ -202,3 +203,18 @@ export const isSnsGenericNervousSystemTypeProposal = ({ action, }: SnsProposalData): boolean => action >= MIN_VALID_SNS_GENERIC_NERVOUS_SYSTEM_FUNCTION_ID; + +/** + * Returns true if the ledgerId is one of the SNS projects. + */ +export const isSnsLedgerCanisterId = ({ + ledgerCanisterId, + snsProjects, +}: { + ledgerCanisterId: Principal; + snsProjects: SnsFullProject[]; +}): boolean => + snsProjects.some( + ({ summary }) => + summary.ledgerCanisterId.toText() === ledgerCanisterId.toText() + ); diff --git a/frontend/src/lib/utils/tokens-table.utils.ts b/frontend/src/lib/utils/tokens-table.utils.ts index ce809e79fec..45e4e2c8f75 100644 --- a/frontend/src/lib/utils/tokens-table.utils.ts +++ b/frontend/src/lib/utils/tokens-table.utils.ts @@ -1,7 +1,5 @@ import { OWN_CANISTER_ID_TEXT } from "$lib/constants/canister-ids.constants"; -import { CKBTC_UNIVERSE_CANISTER_ID } from "$lib/constants/ckbtc-canister-ids.constants"; -import { CKETH_UNIVERSE_CANISTER_ID } from "$lib/constants/cketh-canister-ids.constants"; -import { CKUSDC_UNIVERSE_CANISTER_ID } from "$lib/constants/ckusdc-canister-ids.constants"; +import { IMPORTANT_CK_TOKEN_IDS } from "$lib/constants/tokens.constants"; import type { UserToken } from "$lib/types/tokens-page"; import { createAscendingComparator, @@ -31,11 +29,9 @@ export const compareTokensWithBalanceOrImportedFirst = ({ // These tokens should be placed before others (but after ICP) // because they have significance within the Internet Computer ecosystem and deserve to be highlighted. // Where the fixed order maps to a descending order in the market cap of the underlying native tokens. -const ImportantCkTokenIds = [ - CKBTC_UNIVERSE_CANISTER_ID.toText(), - CKETH_UNIVERSE_CANISTER_ID.toText(), - CKUSDC_UNIVERSE_CANISTER_ID.toText(), -] +const ImportantCkTokenIds = IMPORTANT_CK_TOKEN_IDS.map((token) => + token.toText() +) // To place other tokens (which get an index of -1) at the bottom. .reverse(); export const compareTokensByImportance = createDescendingComparator( diff --git a/frontend/src/tests/lib/components/accounts/ImportTokenCanisterId.spec.ts b/frontend/src/tests/lib/components/accounts/ImportTokenCanisterId.spec.ts index 45ae134fffd..37ea98df4cc 100644 --- a/frontend/src/tests/lib/components/accounts/ImportTokenCanisterId.spec.ts +++ b/frontend/src/tests/lib/components/accounts/ImportTokenCanisterId.spec.ts @@ -1,20 +1,22 @@ import ImportTokenCanisterId from "$lib/components/accounts/ImportTokenCanisterId.svelte"; import { ImportTokenCanisterIdPo } from "$tests/page-objects/ImportTokenCanisterId.page-object"; import { JestPageObjectElement } from "$tests/page-objects/jest.page-object"; +import { Principal } from "@dfinity/principal"; import { render } from "@testing-library/svelte"; describe("ImportTokenCanisterId", () => { const props = { label: "test label", - canisterId: "aaaaa-aa", - canisterLinkHref: "http://test.com", + canisterId: Principal.fromText("aaaaa-aa"), canisterIdFallback: "test fallback", }; const renderComponent = (props) => { const { container } = render(ImportTokenCanisterId, { props, }); - return ImportTokenCanisterIdPo.under(new JestPageObjectElement(container)); + return ImportTokenCanisterIdPo.under({ + element: new JestPageObjectElement(container), + }); }; it("should render label, canister id and buttons", async () => { @@ -25,15 +27,17 @@ describe("ImportTokenCanisterId", () => { expect(await po.getCanisterId().isPresent()).toEqual(true); expect(await po.getCanisterIdText()).toEqual("aaaaa-aa"); expect(await po.getCopyButtonPo().isPresent()).toEqual(true); - expect(await po.getLinkIconPo().isPresent()).toEqual(true); + expect(await po.getLinkToDashboardCanisterPo().isPresent()).toEqual(true); }); it("should render buttons", async () => { const po = renderComponent(props); expect(await po.getCopyButtonPo().isPresent()).toEqual(true); - expect(await po.getLinkIconPo().isPresent()).toEqual(true); - expect(await po.getLinkIconPo().getHref()).toEqual("http://test.com"); + expect(await po.getLinkToDashboardCanisterPo().isPresent()).toEqual(true); + expect(await po.getLinkToDashboardCanisterPo().getHref()).toEqual( + "https://dashboard.internetcomputer.org/canister/aaaaa-aa" + ); }); it("should not render a fallback when canister id provided", async () => { @@ -46,7 +50,6 @@ describe("ImportTokenCanisterId", () => { it("should render fallback when canister id not provided", async () => { const po = renderComponent({ label: "test label", - canisterLinkHref: "http://test.com", canisterIdFallback: "test fallback", }); @@ -61,12 +64,11 @@ describe("ImportTokenCanisterId", () => { it("should render no buttons when canister id not provided", async () => { const po = renderComponent({ label: "test label", - canisterLinkHref: "http://test.com", canisterIdFallback: "test fallback", }); expect(await po.getCopyButtonPo().isPresent()).toEqual(false); - expect(await po.getLinkIconPo().isPresent()).toEqual(false); + expect(await po.getLinkToDashboardCanisterPo().isPresent()).toEqual(false); }); it("Should render neither the canister id nor the fallback when both parameters are not provided", async () => { diff --git a/frontend/src/tests/lib/components/accounts/ImportTokenModal.spec.ts b/frontend/src/tests/lib/components/accounts/ImportTokenModal.spec.ts index 983967a5e93..818a2fcba6b 100644 --- a/frontend/src/tests/lib/components/accounts/ImportTokenModal.spec.ts +++ b/frontend/src/tests/lib/components/accounts/ImportTokenModal.spec.ts @@ -1,9 +1,30 @@ +import * as ledgerApi from "$lib/api/icrc-ledger.api"; import ImportTokenModal from "$lib/modals/accounts/ImportTokenModal.svelte"; +import * as busyServices from "$lib/stores/busy.store"; +import { toastsError } from "$lib/stores/toasts.store"; +import type { IcrcTokenMetadata } from "$lib/types/icrc"; +import { principal } from "$tests/mocks/sns-projects.mock"; import { ImportTokenModalPo } from "$tests/page-objects/ImportTokenModal.page-object"; import { JestPageObjectElement } from "$tests/page-objects/jest.page-object"; import { render } from "$tests/utils/svelte.test-utils"; +import { runResolvedPromises } from "$tests/utils/timers.test-utils"; +import type { SpyInstance } from "vitest"; + +vi.mock("$lib/stores/toasts.store", () => { + return { + toastsError: vi.fn(), + }; +}); describe("ImportTokenModal", () => { + const ledgerCanisterId = principal(0); + const indexCanisterId = principal(1); + const tokenMetaData = { + name: "Tetris", + symbol: "TET", + logo: "https://tetris.tet/logo.png", + } as IcrcTokenMetadata; + const renderComponent = () => { const { container, component } = render(ImportTokenModal); @@ -15,13 +36,136 @@ describe("ImportTokenModal", () => { return { po, formPo: po.getImportTokenFormPo(), + reviewPo: po.getImportTokenReviewPo(), onClose, }; }; + let queryIcrcTokenSpy: SpyInstance; + + beforeEach(() => { + vi.restoreAllMocks(); + (toastsError as undefined as SpyInstance).mockReset(); - it("should display a title", async () => { + queryIcrcTokenSpy = vi + .spyOn(ledgerApi, "queryIcrcToken") + .mockResolvedValue(tokenMetaData); + }); + + it("should display modal title", async () => { const { po } = renderComponent(); expect(await po.getModalTitle()).toEqual("Import Token"); }); + + it("should call queryIcrcToken to get a token meta data", async () => { + const { formPo } = renderComponent(); + + await formPo.getLedgerCanisterInputPo().typeText(ledgerCanisterId.toText()); + + expect(queryIcrcTokenSpy).not.toHaveBeenCalled(); + + await formPo.getSubmitButtonP().click(); + + expect(queryIcrcTokenSpy).toBeCalledTimes(1); + }); + + it("should display a busy screen when fetching meta data", async () => { + const startBusySpy = vi.spyOn(busyServices, "startBusy"); + const { formPo } = renderComponent(); + + await formPo.getLedgerCanisterInputPo().typeText(ledgerCanisterId.toText()); + await formPo.getSubmitButtonP().click(); + + expect(startBusySpy).toHaveBeenCalledTimes(1); + expect(startBusySpy).toHaveBeenCalledWith({ + initiator: "import-token-validation", + labelKey: "import_token.verifying", + }); + }); + + it("should an error toast when failed to load the token meta data, and stay on first step", async () => { + vi.spyOn(ledgerApi, "queryIcrcToken").mockRejectedValue( + new Error("Not a ledger canister") + ); + const { formPo, reviewPo } = renderComponent(); + + expect(toastsError).not.toBeCalled(); + + await formPo.getLedgerCanisterInputPo().typeText(ledgerCanisterId.toText()); + await formPo.getSubmitButtonP().click(); + + // Wait for toast error to be called. + await runResolvedPromises(); + + expect(toastsError).toBeCalledTimes(1); + expect(toastsError).toBeCalledWith({ + labelKey: "import_token.ledger_canister_loading_error", + }); + + // Stays on the first step. + expect(await formPo.isPresent()).toEqual(true); + expect(await reviewPo.isPresent()).toEqual(false); + }); + + it("should display entered canisters info on the review step", async () => { + const { formPo, reviewPo } = renderComponent(); + + await formPo.getLedgerCanisterInputPo().typeText(ledgerCanisterId.toText()); + await formPo.getIndexCanisterInputPo().typeText(indexCanisterId.toText()); + await formPo.getSubmitButtonP().click(); + + // Wait for ModalWizard step animation. + await runResolvedPromises(); + + expect(await reviewPo.getLedgerCanisterIdPo().getCanisterIdText()).toEqual( + ledgerCanisterId.toText() + ); + expect(await reviewPo.getIndexCanisterIdPo().getCanisterIdText()).toEqual( + indexCanisterId.toText() + ); + expect(await reviewPo.getTokenName()).toEqual(tokenMetaData.name); + expect(await reviewPo.getTokenSymbol()).toEqual(tokenMetaData.symbol); + expect(await reviewPo.getLogoSource()).toEqual(tokenMetaData.logo); + }); + + it("should display a fallback text for the index canister when it's not entered", async () => { + const { formPo, reviewPo } = renderComponent(); + + await formPo.getLedgerCanisterInputPo().typeText(ledgerCanisterId.toText()); + await formPo.getSubmitButtonP().click(); + + // Wait for ModalWizard step animation. + await runResolvedPromises(); + + expect( + await reviewPo.getIndexCanisterIdPo().getCanisterId().isPresent() + ).toEqual(false); + expect( + await reviewPo.getIndexCanisterIdPo().getCanisterIdFallback().isPresent() + ).toEqual(true); + expect( + await reviewPo.getIndexCanisterIdPo().getCanisterIdFallbackText() + ).toEqual("Transaction history won’t be displayed."); + }); + + it('should navigate back to form on "Back" button click', async () => { + const { formPo, reviewPo } = renderComponent(); + + expect(await formPo.isPresent()).toEqual(true); + expect(await reviewPo.isPresent()).toEqual(false); + + await formPo.getLedgerCanisterInputPo().typeText(ledgerCanisterId.toText()); + await formPo.getSubmitButtonPo().click(); + + // Wait for ModalWizard step animation. + await runResolvedPromises(); + + expect(await formPo.isPresent()).toEqual(false); + expect(await reviewPo.isPresent()).toEqual(true); + + await reviewPo.getBackButtonPo().click(); + + expect(await formPo.isPresent()).toEqual(true); + expect(await reviewPo.isPresent()).toEqual(false); + }); }); diff --git a/frontend/src/tests/lib/components/accounts/ImportTokenRemoveConfirmation.spec.ts b/frontend/src/tests/lib/components/accounts/ImportTokenRemoveConfirmation.spec.ts new file mode 100644 index 00000000000..f5f7d481d02 --- /dev/null +++ b/frontend/src/tests/lib/components/accounts/ImportTokenRemoveConfirmation.spec.ts @@ -0,0 +1,68 @@ +import ImportTokenRemoveConfirmation from "$lib/components/accounts/ImportTokenRemoveConfirmation.svelte"; +import type { Universe } from "$lib/types/universe"; +import { principal } from "$tests/mocks/sns-projects.mock"; +import { ImportTokenRemoveConfirmationPo } from "$tests/page-objects/ImportTokenRemoveConfirmation.page-object"; +import { JestPageObjectElement } from "$tests/page-objects/jest.page-object"; +import { render } from "$tests/utils/svelte.test-utils"; + +describe("ImportTokenRemoveConfirmation", () => { + const ledgerCanisterId = principal(1); + const tokenLogo = "data:image/svg+xml;base64,PHN2ZyB3..."; + const tokenName = "ckTest"; + const mockUniverse: Universe = { + canisterId: ledgerCanisterId.toText(), + title: tokenName, + logo: tokenLogo, + }; + const renderComponent = () => { + const { container, component } = render(ImportTokenRemoveConfirmation, { + props: { + universe: mockUniverse, + }, + }); + + const nnsConfirm = vi.fn(); + component.$on("nnsConfirm", nnsConfirm); + const nnsClose = vi.fn(); + component.$on("nnsClose", nnsClose); + + return { + po: ImportTokenRemoveConfirmationPo.under( + new JestPageObjectElement(container) + ), + nnsConfirm, + nnsClose, + }; + }; + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("should render a modal title", async () => { + const { po } = renderComponent(); + expect(await po.getModalTitle()).toEqual("Remove Token"); + }); + + it("should render token logo", async () => { + const { po } = renderComponent(); + expect(await po.getUniversePageSummaryPo().getLogoUrl()).toEqual(tokenLogo); + }); + + it("should render token name", async () => { + const { po } = renderComponent(); + expect(await po.getUniversePageSummaryPo().getTitle()).toEqual(tokenName); + }); + + it("should dispatch events", async () => { + const { po, nnsClose, nnsConfirm } = renderComponent(); + + expect(nnsClose).not.toBeCalled(); + await po.clickClose(); + expect(nnsClose).toBeCalledTimes(1); + + expect(nnsConfirm).not.toBeCalled(); + await po.clickConfirm(); + expect(nnsConfirm).toBeCalledTimes(1); + }); +}); diff --git a/frontend/src/tests/lib/components/accounts/ImportTokenReview.spec.ts b/frontend/src/tests/lib/components/accounts/ImportTokenReview.spec.ts new file mode 100644 index 00000000000..021fdeb3cf3 --- /dev/null +++ b/frontend/src/tests/lib/components/accounts/ImportTokenReview.spec.ts @@ -0,0 +1,114 @@ +import ImportTokenReview from "$lib/components/accounts/ImportTokenReview.svelte"; +import type { IcrcTokenMetadata } from "$lib/types/icrc"; +import { principal } from "$tests/mocks/sns-projects.mock"; +import { ImportTokenReviewPo } from "$tests/page-objects/ImportTokenReview.page-object"; +import { JestPageObjectElement } from "$tests/page-objects/jest.page-object"; +import { render } from "$tests/utils/svelte.test-utils"; +import type { Principal } from "@dfinity/principal"; + +describe("ImportTokenReview", () => { + const tokenMetaData = { + name: "Tetris", + symbol: "TET", + logo: "https://tetris.tet/logo.png", + } as IcrcTokenMetadata; + const renderComponent = (props: { + ledgerCanisterId: Principal; + indexCanisterId: Principal | undefined; + tokenMetaData: IcrcTokenMetadata; + }) => { + const { container, component } = render(ImportTokenReview, { + props, + }); + + const onConfirm = vi.fn(); + component.$on("nnsConfirm", onConfirm); + const onBack = vi.fn(); + component.$on("nnsBack", onBack); + + return { + po: ImportTokenReviewPo.under(new JestPageObjectElement(container)), + onConfirm, + onBack, + }; + }; + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("should render token meta information", async () => { + const { po } = renderComponent({ + ledgerCanisterId: principal(0), + indexCanisterId: undefined, + tokenMetaData, + }); + + expect(await po.getTokenName()).toEqual("Tetris"); + expect(await po.getTokenSymbol()).toEqual("TET"); + expect(await po.getLogoSource()).toEqual("https://tetris.tet/logo.png"); + }); + + it("should render ledger canister id", async () => { + const { po } = renderComponent({ + ledgerCanisterId: principal(0), + indexCanisterId: undefined, + tokenMetaData, + }); + + expect(await po.getLedgerCanisterIdPo().getCanisterIdText()).toEqual( + principal(0).toText() + ); + expect(await po.getLedgerCanisterIdPo().getLabelText()).toEqual( + "Ledger Canister ID" + ); + }); + + it("should render index canister id", async () => { + const { po } = renderComponent({ + ledgerCanisterId: principal(0), + indexCanisterId: principal(1), + tokenMetaData, + }); + + expect(await po.getIndexCanisterIdPo().getCanisterIdText()).toEqual( + principal(1).toText() + ); + expect(await po.getIndexCanisterIdPo().getLabelText()).toEqual( + "Index Canister ID" + ); + }); + + it("should render a warning message", async () => { + const { po } = renderComponent({ + ledgerCanisterId: principal(0), + indexCanisterId: undefined, + tokenMetaData, + }); + + expect((await po.getWarningPo().getText()).trim()).toEqual( + "Warning: Be careful what token you import! Anyone can create a token including one with the same name as existing tokens, such as ckBTC." + ); + }); + + it("should dispatch events on buttons click", async () => { + const { po, onBack, onConfirm } = renderComponent({ + ledgerCanisterId: principal(0), + indexCanisterId: undefined, + tokenMetaData, + }); + + expect(onBack).not.toHaveBeenCalled(); + expect(onConfirm).not.toHaveBeenCalled(); + + await po.getConfirmButtonPo().click(); + + expect(onBack).not.toHaveBeenCalled(); + expect(onConfirm).toBeCalledTimes(1); + + await po.getBackButtonPo().click(); + + expect(onBack).toBeCalledTimes(1); + expect(onConfirm).toBeCalledTimes(1); + }); +}); diff --git a/frontend/src/tests/lib/pages/Tokens.spec.ts b/frontend/src/tests/lib/pages/Tokens.spec.ts index b365f5bc911..ac464438ba1 100644 --- a/frontend/src/tests/lib/pages/Tokens.spec.ts +++ b/frontend/src/tests/lib/pages/Tokens.spec.ts @@ -2,6 +2,7 @@ import { NNS_TOKEN_DATA } from "$lib/constants/tokens.constants"; import TokensPage from "$lib/pages/Tokens.svelte"; import { overrideFeatureFlagsStore } from "$lib/stores/feature-flags.store"; import { hideZeroBalancesStore } from "$lib/stores/hide-zero-balances.store"; +import { importedTokensStore } from "$lib/stores/imported-tokens.store"; import type { UserTokenData } from "$lib/types/tokens-page"; import { UnavailableTokenAmount } from "$lib/utils/token.utils"; import { mockSnsToken, principal } from "$tests/mocks/sns-projects.mock"; @@ -194,6 +195,10 @@ describe("Tokens page", () => { describe("when import token feature flag is enabled", () => { beforeEach(() => { overrideFeatureFlagsStore.setFlag("ENABLE_IMPORT_TOKEN", true); + importedTokensStore.set({ + importedTokens: [], + certified: false, + }); }); it("should show import token button", async () => { @@ -201,6 +206,13 @@ describe("Tokens page", () => { expect(await po.getImportTokenButtonPo().isPresent()).toBe(true); }); + it("should not show import token button when they are not loaded", async () => { + // Because of maximum limit validation + importedTokensStore.reset(); + const po = renderPage([positiveBalance, zeroBalance]); + expect(await po.getImportTokenButtonPo().isPresent()).toBe(false); + }); + it("should show import token and show all buttons", async () => { const po = renderPage([positiveBalance, zeroBalance]); diff --git a/frontend/src/tests/lib/services/app.services.spec.ts b/frontend/src/tests/lib/services/app.services.spec.ts index 970e5913594..e41c6d0caaa 100644 --- a/frontend/src/tests/lib/services/app.services.spec.ts +++ b/frontend/src/tests/lib/services/app.services.spec.ts @@ -83,7 +83,16 @@ describe("app-services", () => { await expect(mockLedgerCanister.accountBalance).not.toBeCalled(); const toastData = get(toastsStore); - expect(toastData).toHaveLength(0); + + // The imported tokens error should be shown. + // TODO: check if this is the correct behavior + expect(toastData).toHaveLength(1); + expect(toastData).toEqual([ + expect.objectContaining({ + text: "There was an unexpected issue while loading imported tokens. Cannot read properties of undefined (reading 'imported_tokens')", + level: "error", + }), + ]); }); it("should call loadActionableProposals after Sns data is ready", async () => { diff --git a/frontend/src/tests/lib/services/imported-tokens.services.spec.ts b/frontend/src/tests/lib/services/imported-tokens.services.spec.ts index 44286eee7a0..a00e4448db4 100644 --- a/frontend/src/tests/lib/services/imported-tokens.services.spec.ts +++ b/frontend/src/tests/lib/services/imported-tokens.services.spec.ts @@ -181,7 +181,7 @@ describe("imported-tokens-services", () => { expect(spyToastSuccsess).toBeCalledTimes(1); expect(spyToastSuccsess).toBeCalledWith({ - labelKey: "tokens.add_imported_token_success", + labelKey: "import_token.add_imported_token_success", }); }); @@ -313,7 +313,7 @@ describe("imported-tokens-services", () => { expect(spyToastSuccsess).toBeCalledTimes(1); expect(spyToastSuccsess).toBeCalledWith({ - labelKey: "tokens.remove_imported_token_success", + labelKey: "import_token.remove_imported_token_success", }); }); diff --git a/frontend/src/tests/lib/utils/imported-tokens.utils.spec.ts b/frontend/src/tests/lib/utils/imported-tokens.utils.spec.ts index 94b186f3148..45069fd2f36 100644 --- a/frontend/src/tests/lib/utils/imported-tokens.utils.spec.ts +++ b/frontend/src/tests/lib/utils/imported-tokens.utils.spec.ts @@ -2,6 +2,7 @@ import type { ImportedToken } from "$lib/canisters/nns-dapp/nns-dapp.types"; import type { ImportedTokenData } from "$lib/types/imported-tokens"; import { fromImportedTokenData, + isImportedToken, toImportedTokenData, } from "$lib/utils/imported-tokens.utils"; import { principal } from "$tests/mocks/sns-projects.mock"; @@ -51,4 +52,60 @@ describe("imported tokens utils", () => { ); }); }); + + describe("isImportedToken", () => { + it("should return true when in the list", () => { + expect( + isImportedToken({ + ledgerCanisterId: principal(1), + importedTokens: [ + { + ledgerCanisterId: principal(0), + } as ImportedTokenData, + { + ledgerCanisterId: principal(1), + } as ImportedTokenData, + ], + }) + ).toEqual(true); + }); + + it("should return false when not in the list", () => { + expect( + isImportedToken({ + ledgerCanisterId: principal(1), + importedTokens: [ + { + ledgerCanisterId: principal(0), + } as ImportedTokenData, + ], + }) + ).toEqual(false); + }); + + it("should return false when not enough information", () => { + expect( + isImportedToken({ + ledgerCanisterId: undefined, + importedTokens: [ + { + ledgerCanisterId: principal(0), + } as ImportedTokenData, + ], + }) + ).toEqual(false); + expect( + isImportedToken({ + ledgerCanisterId: principal(0), + importedTokens: undefined, + }) + ).toEqual(false); + expect( + isImportedToken({ + ledgerCanisterId: undefined, + importedTokens: undefined, + }) + ).toEqual(false); + }); + }); }); diff --git a/frontend/src/tests/lib/utils/tokens-table.utils.spec.ts b/frontend/src/tests/lib/utils/tokens-table.utils.spec.ts index 7c909be7935..d7972a17ba0 100644 --- a/frontend/src/tests/lib/utils/tokens-table.utils.spec.ts +++ b/frontend/src/tests/lib/utils/tokens-table.utils.spec.ts @@ -83,6 +83,82 @@ describe("tokens-table.utils", () => { }); }); + describe("compareTokensWithBalanceOrImportedFirst", () => { + const token0 = createTokenWithBalance({ id: 0, amount: 0n }); + const token1 = createTokenWithBalance({ id: 1, amount: 1n }); + const importedTokenWithBalance = createTokenWithBalance({ + id: 2, + amount: 1n, + }); + const importedTokenNoBalance = createTokenWithBalance({ + id: 3, + amount: 0n, + }); + const importedTokenIds = new Set([ + importedTokenWithBalance.universeId.toText(), + importedTokenNoBalance.universeId.toText(), + ]); + + it("should compare by balance", () => { + expect( + compareTokensWithBalanceOrImportedFirst({ + importedTokenIds, + })(token1, token0) + ).toEqual(-1); + expect( + compareTokensWithBalanceOrImportedFirst({ + importedTokenIds, + })(token0, token1) + ).toEqual(1); + expect( + compareTokensWithBalanceOrImportedFirst({ + importedTokenIds, + })(token1, token1) + ).toEqual(0); + expect( + compareTokensWithBalanceOrImportedFirst({ + importedTokenIds, + })(token0, token0) + ).toEqual(0); + }); + + it("should compare by imported", () => { + expect( + compareTokensWithBalanceOrImportedFirst({ + importedTokenIds, + })(importedTokenNoBalance, token0) + ).toEqual(-1); + expect( + compareTokensWithBalanceOrImportedFirst({ + importedTokenIds, + })(token0, importedTokenNoBalance) + ).toEqual(1); + expect( + compareTokensWithBalanceOrImportedFirst({ + importedTokenIds, + })(importedTokenWithBalance, importedTokenNoBalance) + ).toEqual(0); + }); + + it("should compare by balance and imported", () => { + expect( + compareTokensWithBalanceOrImportedFirst({ + importedTokenIds, + })(importedTokenNoBalance, token1) + ).toEqual(0); + expect( + compareTokensWithBalanceOrImportedFirst({ + importedTokenIds, + })(token0, importedTokenNoBalance) + ).toEqual(1); + expect( + compareTokensWithBalanceOrImportedFirst({ + importedTokenIds, + })(importedTokenWithBalance, token0) + ).toEqual(-1); + }); + }); + describe("compareTokensAlphabetically", () => { const annaToken = createUserToken({ title: "Anna" }); const arnyToken = createUserToken({ title: "Arny" }); diff --git a/frontend/src/tests/page-objects/ImportTokenCanisterId.page-object.ts b/frontend/src/tests/page-objects/ImportTokenCanisterId.page-object.ts index 3a12a3b139b..491900444cf 100644 --- a/frontend/src/tests/page-objects/ImportTokenCanisterId.page-object.ts +++ b/frontend/src/tests/page-objects/ImportTokenCanisterId.page-object.ts @@ -1,14 +1,20 @@ import type { ButtonPo } from "$tests/page-objects/Button.page-object"; -import { LinkIconPo } from "$tests/page-objects/LinkIcon.page-object"; +import { LinkToDashboardCanisterPo } from "$tests/page-objects/LinkToDashboardCanister.page-object"; import { BasePageObject } from "$tests/page-objects/base.page-object"; import type { PageObjectElement } from "$tests/types/page-object.types"; export class ImportTokenCanisterIdPo extends BasePageObject { private static readonly TID = "import-token-canister-id-component"; - static under(element: PageObjectElement): ImportTokenCanisterIdPo { + static under({ + element, + testId, + }: { + element: PageObjectElement; + testId?: string; + }): ImportTokenCanisterIdPo { return new ImportTokenCanisterIdPo( - element.byTestId(ImportTokenCanisterIdPo.TID) + element.byTestId(testId ?? ImportTokenCanisterIdPo.TID) ); } @@ -36,8 +42,8 @@ export class ImportTokenCanisterIdPo extends BasePageObject { return this.getButton("copy-component"); } - getLinkIconPo(): LinkIconPo { - return LinkIconPo.under(this.root); + getLinkToDashboardCanisterPo(): LinkToDashboardCanisterPo { + return LinkToDashboardCanisterPo.under(this.root); } getCanisterIdFallbackText(): Promise { diff --git a/frontend/src/tests/page-objects/ImportTokenModal.page-object.ts b/frontend/src/tests/page-objects/ImportTokenModal.page-object.ts index de92c73f45c..75a3b1d34d3 100644 --- a/frontend/src/tests/page-objects/ImportTokenModal.page-object.ts +++ b/frontend/src/tests/page-objects/ImportTokenModal.page-object.ts @@ -1,4 +1,5 @@ import { ImportTokenFormPo } from "$tests/page-objects/ImportTokenForm.page-object"; +import { ImportTokenReviewPo } from "$tests/page-objects/ImportTokenReview.page-object"; import { ModalPo } from "$tests/page-objects/Modal.page-object"; import type { PageObjectElement } from "$tests/types/page-object.types"; @@ -12,4 +13,8 @@ export class ImportTokenModalPo extends ModalPo { getImportTokenFormPo(): ImportTokenFormPo { return ImportTokenFormPo.under(this.root); } + + getImportTokenReviewPo(): ImportTokenReviewPo { + return ImportTokenReviewPo.under(this.root); + } } diff --git a/frontend/src/tests/page-objects/ImportTokenRemoveConfirmation.page-object.ts b/frontend/src/tests/page-objects/ImportTokenRemoveConfirmation.page-object.ts new file mode 100644 index 00000000000..d09765c034c --- /dev/null +++ b/frontend/src/tests/page-objects/ImportTokenRemoveConfirmation.page-object.ts @@ -0,0 +1,25 @@ +import { ModalPo } from "$tests/page-objects/Modal.page-object"; +import { UniversePageSummaryPo } from "$tests/page-objects/UniversePageSummary.page-object"; +import type { PageObjectElement } from "$tests/types/page-object.types"; + +export class ImportTokenRemoveConfirmationPo extends ModalPo { + private static readonly TID = "import-token-remove-confirmation-component"; + + static under(element: PageObjectElement): ImportTokenRemoveConfirmationPo { + return new ImportTokenRemoveConfirmationPo( + element.byTestId(ImportTokenRemoveConfirmationPo.TID) + ); + } + + getUniversePageSummaryPo(): UniversePageSummaryPo { + return UniversePageSummaryPo.under(this.root); + } + + clickClose(): Promise { + return this.getButton("close-button").click(); + } + + clickConfirm(): Promise { + return this.getButton("confirm-button").click(); + } +} diff --git a/frontend/src/tests/page-objects/ImportTokenReview.page-object.ts b/frontend/src/tests/page-objects/ImportTokenReview.page-object.ts new file mode 100644 index 00000000000..4eac10f11a3 --- /dev/null +++ b/frontend/src/tests/page-objects/ImportTokenReview.page-object.ts @@ -0,0 +1,57 @@ +import { ButtonPo } from "$tests/page-objects/Button.page-object"; +import { CalloutWarningPo } from "$tests/page-objects/CalloutWarning.page-object"; +import { ImportTokenCanisterIdPo } from "$tests/page-objects/ImportTokenCanisterId.page-object"; +import { BasePageObject } from "$tests/page-objects/base.page-object"; +import type { PageObjectElement } from "$tests/types/page-object.types"; + +export class ImportTokenReviewPo extends BasePageObject { + private static readonly TID = "import-token-review-component"; + + static under(element: PageObjectElement): ImportTokenReviewPo { + return new ImportTokenReviewPo(element.byTestId(ImportTokenReviewPo.TID)); + } + + getLogoSource(): Promise { + return this.getElement("token-logo").getAttribute("src"); + } + + getTokenName(): Promise { + return this.getText("token-name"); + } + + getTokenSymbol(): Promise { + return this.getText("token-symbol"); + } + + getLedgerCanisterIdPo(): ImportTokenCanisterIdPo { + return ImportTokenCanisterIdPo.under({ + element: this.root, + testId: "ledger-canister-id", + }); + } + + getIndexCanisterIdPo(): ImportTokenCanisterIdPo { + return ImportTokenCanisterIdPo.under({ + element: this.root, + testId: "index-canister-id", + }); + } + + getWarningPo(): CalloutWarningPo { + return CalloutWarningPo.under(this.root); + } + + getBackButtonPo(): ButtonPo { + return ButtonPo.under({ + element: this.root, + testId: "back-button", + }); + } + + getConfirmButtonPo(): ButtonPo { + return ButtonPo.under({ + element: this.root, + testId: "confirm-button", + }); + } +}