diff --git a/.github/actions/prepare/action.yml b/.github/actions/prepare/action.yml index 82d0698e18..1ecb5442c4 100644 --- a/.github/actions/prepare/action.yml +++ b/.github/actions/prepare/action.yml @@ -9,7 +9,7 @@ runs: - name: Setup node uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 registry-url: 'https://registry.npmjs.org' cache: 'npm' diff --git a/.github/workflows/deploy-to-environment.yml b/.github/workflows/deploy-to-environment.yml index 8f73beca3c..585f6ba240 100644 --- a/.github/workflows/deploy-to-environment.yml +++ b/.github/workflows/deploy-to-environment.yml @@ -13,6 +13,11 @@ on: options: - staging - beta + - test_fe_1 + - test_fe_2 + - test_fe_3 + - test_fe_4 + - test_be_1 canister: required: true type: choice @@ -41,17 +46,6 @@ jobs: runs-on: ubuntu-24.04 steps: - - name: Fail if branch is not main - if: ${{ github.ref != 'refs/heads/main' }} - run: | - echo "This workflow can only be manually triggered with workflow_dispatch on the main branch" - exit 1 - - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 2 - - name: Determine Deployment Network run: | if [ "${{ github.event_name }}" == "push" ]; then @@ -62,25 +56,51 @@ jobs: echo "CANISTER=${{ github.event.inputs.canister }}" >> $GITHUB_ENV fi + - name: Check release policy + run: | + if [[ "$NETWORK" == "staging" ]] && [[ "${{ github.ref }}" != "refs/heads/main" ]] ; then + echo "Only the main branch may be deployed to staging." + exit 1 + fi + if [[ "$NETWORK" = test_fe_* ]] && [[ "$CANISTER" != "frontend" ]] ; then + echo "Only a frontend may be deployed to test_fe_* networks" + exit 1 + fi + if [[ "$NETWORK" = test_be_* ]] && [[ "$CANISTER" != "backend" ]] ; then + echo "Only a backend may be deployed to test_be_* networks" + exit 1 + fi + + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 2 + - name: Set Environment Variables Based on Network run: | - if [ "$NETWORK" == "staging" ]; then + if [[ "$NETWORK" == "staging" ]] || [[ "$NETWORK" = test_fe_* ]]; then echo "VITE_ETHERSCAN_API_KEY=${{ secrets.VITE_ETHERSCAN_API_KEY_STAGING }}" >> $GITHUB_ENV echo "VITE_INFURA_API_KEY=${{ secrets.VITE_INFURA_API_KEY_STAGING }}" >> $GITHUB_ENV echo "VITE_ALCHEMY_API_KEY=${{ secrets.VITE_ALCHEMY_API_KEY_STAGING }}" >> $GITHUB_ENV echo "VITE_WALLET_CONNECT_PROJECT_ID=${{ secrets.VITE_WALLET_CONNECT_PROJECT_ID_STAGING }}" >> $GITHUB_ENV - echo "VITE_OISY_URL=${{ secrets.VITE_OISY_URL_STAGING }}" >> $GITHUB_ENV echo "VITE_AIRDROP=${{ secrets.VITE_AIRDROP_STAGING }}" >> $GITHUB_ENV echo "VITE_AIRDROP_COMPLETED=${{ secrets.VITE_AIRDROP_COMPLETED_STAGING }}" >> $GITHUB_ENV echo "VITE_COINGECKO_API_KEY=${{ secrets.VITE_COINGECKO_API_KEY_STAGING }}" >> $GITHUB_ENV echo "VITE_JUNO_SATELLITE_ID=${{ secrets.VITE_JUNO_SATELLITE_ID_STAGING }}" >> $GITHUB_ENV echo "VITE_JUNO_ORBITER_ID=${{ secrets.VITE_JUNO_ORBITER_ID_STAGING }}" >> $GITHUB_ENV echo "VITE_POUH_ENABLED=${{ secrets.VITE_POUH_ENABLED_STAGING }}" >> $GITHUB_ENV - echo "VITE_AUTH_ALTERNATIVE_ORIGINS=${{ secrets.VITE_AUTH_ALTERNATIVE_ORIGINS_STAGING }}" >> $GITHUB_ENV echo "VITE_AUTH_DERIVATION_ORIGIN=${{ secrets.VITE_AUTH_DERIVATION_ORIGIN_STAGING }}" >> $GITHUB_ENV echo "VITE_BTC_TO_CKBTC_EXCHANGE_ENABLED=${{ secrets.VITE_BTC_TO_CKBTC_EXCHANGE_ENABLED_STAGING }}" >> $GITHUB_ENV echo "VITE_ONRAMPER_API_KEY_DEV=${{ secrets.VITE_ONRAMPER_API_KEY_DEV_STAGING }}" >> $GITHUB_ENV echo "VITE_ONRAMPER_API_KEY_PROD=${{ secrets.VITE_ONRAMPER_API_KEY_PROD_STAGING }}" >> $GITHUB_ENV + if [[ "$NETWORK" == "staging" ]]; then + echo "VITE_AUTH_ALTERNATIVE_ORIGINS=${{ secrets.VITE_AUTH_ALTERNATIVE_ORIGINS_STAGING }}" >> $GITHUB_ENV + echo "VITE_OISY_URL=${{ secrets.VITE_OISY_URL_STAGING }}" >> $GITHUB_ENV + else + SUBDOMAIN="fe${NETWORK#test_fe_}" # E.g. test_fe_1 -> fe1 + echo "VITE_AUTH_ALTERNATIVE_ORIGINS=${{ secrets.VITE_AUTH_ALTERNATIVE_ORIGINS_STAGING }}" | sed "s/staging/$SUBDOMAIN/g" >> $GITHUB_ENV + echo "VITE_OISY_URL=${{ secrets.VITE_OISY_URL_STAGING }}" | sed "s/staging/$SUBDOMAIN/g" >> $GITHUB_ENV + fi { echo 'DFX_DEPLOY_KEY<=0.10.0" } @@ -9788,20 +9794,26 @@ } }, "node_modules/postcss-nested": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz", - "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "dependencies": { - "postcss-selector-parser": "^6.0.11" + "postcss-selector-parser": "^6.1.1" }, "engines": { "node": ">=12.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, "peerDependencies": { "postcss": "^8.2.14" } @@ -9930,9 +9942,9 @@ } }, "node_modules/prettier-plugin-svelte": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.2.7.tgz", - "integrity": "sha512-/Dswx/ea0lV34If1eDcG3nulQ63YNr5KPDfMsjbdtpSWOxKKJ7nAc2qlVuYwEvCr4raIuredNoR7K4JCkmTGaQ==", + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.2.8.tgz", + "integrity": "sha512-PAHmmU5cGZdnhW4mWhmvxuG2PVbbHIxUuPOdUKvfE+d4Qt2d29iU5VWrPdsaW5YqVEE0nqhlvN4eoKmVMpIF3Q==", "dev": true, "peerDependencies": { "prettier": "^3.0.0", @@ -10427,13 +10439,13 @@ "dev": true }, "node_modules/sass": { - "version": "1.80.6", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.80.6.tgz", - "integrity": "sha512-ccZgdHNiBF1NHBsWvacvT5rju3y1d/Eu+8Ex6c21nHp2lZGLBEtuwc415QfiI1PJa1TpCo3iXwwSRjRpn2Ckjg==", + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.81.0.tgz", + "integrity": "sha512-Q4fOxRfhmv3sqCLoGfvrC9pRV8btc0UtqL9mN6Yrv6Qi9ScL55CVH1vlPP863ISLEEMNLLuu9P+enCeGHlnzhA==", "dev": true, "dependencies": { "chokidar": "^4.0.0", - "immutable": "^4.0.0", + "immutable": "^5.0.2", "source-map-js": ">=0.6.2 <2.0.0" }, "bin": { @@ -10949,14 +10961,14 @@ "license": "MIT" }, "node_modules/sucrase": { - "version": "3.34.0", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.34.0.tgz", - "integrity": "sha512-70/LQEZ07TEcxiU2dz51FKaE6hCTWC6vr7FOk3Gr0U60C3shtAN+H+BFr9XlYe5xqf3RA8nrc+VIwzCfnxuXJw==", + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", "dev": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", - "glob": "7.1.6", + "glob": "^10.3.10", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", @@ -10967,7 +10979,16 @@ "sucrase-node": "bin/sucrase-node" }, "engines": { - "node": ">=8" + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" } }, "node_modules/sucrase/node_modules/commander": { @@ -10980,20 +11001,35 @@ } }, "node_modules/sucrase/node_modules/glob": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", - "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", "dev": true, "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" }, "engines": { - "node": "*" + "node": ">=16 || 14 >=14.17" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -11057,9 +11093,9 @@ } }, "node_modules/svelte-check": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.0.6.tgz", - "integrity": "sha512-2XwmQNJaKbenJbvu5at+DuRpvF4v73Zu7f0/WkMl1O7WDm/IfF+E13t8D0nnRiRcMsNYm9ufHyLwfeMEnebpdg==", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.0.9.tgz", + "integrity": "sha512-SVNCz2L+9ZELGli7G0n3B3QE5kdf0u27RtKr2ZivWQhcWIXatZxwM4VrQ6AiA2k9zKp2mk5AxkEhdjbpjv7rEw==", "dev": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", @@ -11216,33 +11252,33 @@ } }, "node_modules/tailwindcss": { - "version": "3.4.14", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.14.tgz", - "integrity": "sha512-IcSvOcTRcUtQQ7ILQL5quRDg7Xs93PdJEk1ZLbhhvJc7uj/OAhYOnruEiwnGgBvUtaUAJ8/mhSw1o8L2jCiENA==", + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.15.tgz", + "integrity": "sha512-r4MeXnfBmSOuKUWmXe6h2CcyfzJCEk4F0pptO5jlnYSIViUkVmsawj80N5h2lO3gwcmSb4n3PuN+e+GC1Guylw==", "dev": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", - "chokidar": "^3.5.3", + "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", - "fast-glob": "^3.3.0", + "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", - "jiti": "^1.21.0", + "jiti": "^1.21.6", "lilconfig": "^2.1.0", - "micromatch": "^4.0.5", + "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", - "picocolors": "^1.0.0", - "postcss": "^8.4.23", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.1", - "postcss-nested": "^6.0.1", - "postcss-selector-parser": "^6.0.11", - "resolve": "^1.22.2", - "sucrase": "^3.32.0" + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", diff --git a/package.json b/package.json index 00fe698831..214a161020 100644 --- a/package.json +++ b/package.json @@ -96,12 +96,12 @@ "postcss": "^8.4.48", "prettier": "^3.3.3", "prettier-plugin-organize-imports": "^4.1.0", - "prettier-plugin-svelte": "^3.2.7", + "prettier-plugin-svelte": "^3.2.8", "prettier-plugin-tailwindcss": "^0.6.8", - "sass": "^1.80.6", + "sass": "^1.81.0", "svelte": "^4.2.19", - "svelte-check": "^4.0.6", - "tailwindcss": "^3.4.14", + "svelte-check": "^4.0.9", + "tailwindcss": "^3.4.15", "tslib": "^2.8.1", "typescript": "^5.4.5", "vite": "^5.4.10", @@ -115,6 +115,6 @@ "elliptic": "^6.5.7" }, "engines": { - "node": "^20" + "node": "^22" } } diff --git a/scripts/i18n.mjs b/scripts/i18n.mjs index 946115d57f..a7506bb781 100644 --- a/scripts/i18n.mjs +++ b/scripts/i18n.mjs @@ -11,7 +11,7 @@ const PATH_TO_OUTPUT = join(PATH_FROM_ROOT, 'lib', 'types', 'i18n.d.ts'); * Generates TypeScript interfaces from the English translation file. */ const generateTypes = async () => { - const { default: en } = await import(PATH_TO_EN_JSON, { assert: { type: 'json' } }); + const { default: en } = await import(PATH_TO_EN_JSON, { with: { type: 'json' } }); const mapValues = (values) => Object.entries(values).reduce( diff --git a/src/backend/src/signer.rs b/src/backend/src/signer.rs index 8a1c848d80..204be8e755 100644 --- a/src/backend/src/signer.rs +++ b/src/backend/src/signer.rs @@ -13,7 +13,7 @@ use ic_cdk::api::{ }, }; use ic_cycles_ledger_client::{ - Account, ApproveArgs, ApproveError, DepositArgs, DepositResult, Service as CyclesLedgerService, + Account, ApproveArgs, ApproveError, CyclesLedgerService, DepositArgs, DepositResult, }; use ic_ledger_types::Subaccount; use serde_bytes::ByteBuf; diff --git a/src/cycles_ledger_client/.gitignore b/src/cycles_ledger/client/.gitignore similarity index 100% rename from src/cycles_ledger_client/.gitignore rename to src/cycles_ledger/client/.gitignore diff --git a/src/cycles_ledger_client/Cargo.toml b/src/cycles_ledger/client/Cargo.toml similarity index 100% rename from src/cycles_ledger_client/Cargo.toml rename to src/cycles_ledger/client/Cargo.toml diff --git a/src/cycles_ledger_client/src/lib.rs b/src/cycles_ledger/client/src/lib.rs similarity index 99% rename from src/cycles_ledger_client/src/lib.rs rename to src/cycles_ledger/client/src/lib.rs index a6d0e0b67d..ccaa2ce459 100644 --- a/src/cycles_ledger_client/src/lib.rs +++ b/src/cycles_ledger/client/src/lib.rs @@ -420,8 +420,8 @@ pub enum WithdrawFromError { }, } -pub struct Service(pub Principal); -impl Service { +pub struct CyclesLedgerService(pub Principal); +impl CyclesLedgerService { pub async fn create_canister( &self, arg0: &CreateCanisterArgs, diff --git a/src/frontend/src/btc/components/convert/BtcConvertFeeTotal.svelte b/src/frontend/src/btc/components/convert/BtcConvertFeeTotal.svelte new file mode 100644 index 0000000000..c96d9ca446 --- /dev/null +++ b/src/frontend/src/btc/components/convert/BtcConvertFeeTotal.svelte @@ -0,0 +1,26 @@ + + + diff --git a/src/frontend/src/btc/components/convert/BtcConvertFees.svelte b/src/frontend/src/btc/components/convert/BtcConvertFees.svelte new file mode 100644 index 0000000000..33a61c3446 --- /dev/null +++ b/src/frontend/src/btc/components/convert/BtcConvertFees.svelte @@ -0,0 +1,55 @@ + + + + {$i18n.fee.text.convert_fee} + + + + {$i18n.fee.text.convert_inter_network_fee} + + + + {$i18n.fee.text.convert_btc_network_fee} + diff --git a/src/frontend/src/btc/components/convert/BtcConvertForm.svelte b/src/frontend/src/btc/components/convert/BtcConvertForm.svelte new file mode 100644 index 0000000000..289d726a45 --- /dev/null +++ b/src/frontend/src/btc/components/convert/BtcConvertForm.svelte @@ -0,0 +1,82 @@ + + + + + {#if insufficientFundsForFee} +
+ {$i18n.convert.assertion.insufficient_funds_for_fee} +
+ {:else if nonNullish($hasPendingTransactionsStore)} +
+ +
+ {/if} +
+ + + + +
+ + +
+ + +
diff --git a/src/frontend/src/btc/components/convert/BtcConvertReview.svelte b/src/frontend/src/btc/components/convert/BtcConvertReview.svelte new file mode 100644 index 0000000000..b26c7ff0b2 --- /dev/null +++ b/src/frontend/src/btc/components/convert/BtcConvertReview.svelte @@ -0,0 +1,37 @@ + + + +
+ + +
+ +
+ + +
+
+ +
+ {$i18n.convert.text.conversion_may_take} +
+ + +
+ + diff --git a/src/frontend/src/btc/components/fee/UtxosFeeContext.svelte b/src/frontend/src/btc/components/fee/UtxosFeeContext.svelte index cd24033781..c11020c633 100644 --- a/src/frontend/src/btc/components/fee/UtxosFeeContext.svelte +++ b/src/frontend/src/btc/components/fee/UtxosFeeContext.svelte @@ -6,9 +6,10 @@ import { authIdentity } from '$lib/derived/auth.derived'; import { nullishSignOut } from '$lib/services/auth.services'; import type { NetworkId } from '$lib/types/network'; + import type { OptionAmount } from '$lib/types/send'; import { mapNetworkIdToBitcoinNetwork } from '$lib/utils/network.utils'; - export let amount: number | undefined = undefined; + export let amount: OptionAmount = undefined; export let networkId: NetworkId | undefined = undefined; const { store } = getContext(UTXOS_FEE_CONTEXT_KEY); diff --git a/src/frontend/src/btc/components/send/BtcSendAmount.svelte b/src/frontend/src/btc/components/send/BtcSendAmount.svelte index 5f596c8166..53d58ed780 100644 --- a/src/frontend/src/btc/components/send/BtcSendAmount.svelte +++ b/src/frontend/src/btc/components/send/BtcSendAmount.svelte @@ -7,9 +7,10 @@ import { tokenDecimals } from '$lib/derived/token.derived'; import { i18n } from '$lib/stores/i18n.store'; import { SEND_CONTEXT_KEY, type SendContext } from '$lib/stores/send.store'; + import type { OptionAmount } from '$lib/types/send'; import { invalidAmount } from '$lib/utils/input.utils'; - export let amount: number | undefined = undefined; + export let amount: OptionAmount = undefined; export let amountError: BtcAmountAssertionError | undefined; const { sendBalance } = getContext(SEND_CONTEXT_KEY); diff --git a/src/frontend/src/btc/components/send/BtcSendForm.svelte b/src/frontend/src/btc/components/send/BtcSendForm.svelte index c230fe7ac1..d7822e2ce5 100644 --- a/src/frontend/src/btc/components/send/BtcSendForm.svelte +++ b/src/frontend/src/btc/components/send/BtcSendForm.svelte @@ -10,10 +10,11 @@ import { balance } from '$lib/derived/balances.derived'; import { token } from '$lib/stores/token.store'; import type { NetworkId } from '$lib/types/network'; + import type { OptionAmount } from '$lib/types/send'; import { isNullishOrEmpty } from '$lib/utils/input.utils'; export let networkId: NetworkId | undefined = undefined; - export let amount: number | undefined = undefined; + export let amount: OptionAmount = undefined; export let destination = ''; export let source: string; diff --git a/src/frontend/src/btc/components/send/BtcSendReview.svelte b/src/frontend/src/btc/components/send/BtcSendReview.svelte index 4acb079d85..a11f9a8796 100644 --- a/src/frontend/src/btc/components/send/BtcSendReview.svelte +++ b/src/frontend/src/btc/components/send/BtcSendReview.svelte @@ -11,11 +11,12 @@ import type { UtxosFee } from '$btc/types/btc-send'; import SendReview from '$lib/components/send/SendReview.svelte'; import type { NetworkId } from '$lib/types/network'; + import type { OptionAmount } from '$lib/types/send'; import { invalidAmount } from '$lib/utils/input.utils'; import { isInvalidDestinationBtc } from '$lib/utils/send.utils'; export let destination = ''; - export let amount: number | undefined = undefined; + export let amount: OptionAmount = undefined; export let networkId: NetworkId | undefined = undefined; export let source: string; export let utxosFee: UtxosFee | undefined = undefined; diff --git a/src/frontend/src/btc/components/send/BtcSendTokenWizard.svelte b/src/frontend/src/btc/components/send/BtcSendTokenWizard.svelte index ee0a7c8fb4..3725861f9a 100644 --- a/src/frontend/src/btc/components/send/BtcSendTokenWizard.svelte +++ b/src/frontend/src/btc/components/send/BtcSendTokenWizard.svelte @@ -23,6 +23,7 @@ import { SEND_CONTEXT_KEY, type SendContext } from '$lib/stores/send.store'; import { toastsError } from '$lib/stores/toasts.store'; import type { NetworkId } from '$lib/types/network'; + import type { OptionAmount } from '$lib/types/send'; import { invalidAmount, isNullishOrEmpty } from '$lib/utils/input.utils'; import { isNetworkIdBTCRegtest, @@ -33,7 +34,7 @@ export let currentStep: WizardStep | undefined; export let destination = ''; - export let amount: number | undefined = undefined; + export let amount: OptionAmount = undefined; export let sendProgressStep: string; export let formCancelAction: 'back' | 'close' = 'close'; diff --git a/src/frontend/src/btc/components/send/BtcUtxosFee.svelte b/src/frontend/src/btc/components/send/BtcUtxosFee.svelte index 0f4a4d8d87..6934eb0f44 100644 --- a/src/frontend/src/btc/components/send/BtcUtxosFee.svelte +++ b/src/frontend/src/btc/components/send/BtcUtxosFee.svelte @@ -12,11 +12,12 @@ import { SEND_CONTEXT_KEY, type SendContext } from '$lib/stores/send.store'; import { toastsError } from '$lib/stores/toasts.store'; import type { NetworkId } from '$lib/types/network'; + import type { OptionAmount } from '$lib/types/send'; import { formatToken } from '$lib/utils/format.utils'; import { mapNetworkIdToBitcoinNetwork } from '$lib/utils/network.utils'; export let utxosFee: UtxosFee | undefined = undefined; - export let amount: number | undefined = undefined; + export let amount: OptionAmount = undefined; export let networkId: NetworkId | undefined = undefined; const { sendTokenDecimals } = getContext(SEND_CONTEXT_KEY); diff --git a/src/frontend/src/btc/components/transactions/BtcTransaction.svelte b/src/frontend/src/btc/components/transactions/BtcTransaction.svelte index 25d5cc62f2..b54a284259 100644 --- a/src/frontend/src/btc/components/transactions/BtcTransaction.svelte +++ b/src/frontend/src/btc/components/transactions/BtcTransaction.svelte @@ -6,10 +6,11 @@ import Transaction from '$lib/components/transactions/Transaction.svelte'; import { i18n } from '$lib/stores/i18n.store'; import { modalStore } from '$lib/stores/modal.store'; - import type { OptionToken } from '$lib/types/token'; + import type { Token } from '$lib/types/token'; export let transaction: BtcTransactionUi; - export let token: OptionToken = undefined; + export let token: Token; + export let iconType: 'token' | 'transaction' = 'transaction'; let value: bigint | undefined; let timestamp: bigint | undefined; @@ -29,6 +30,7 @@ timestamp={Number(timestamp)} {status} {token} + {iconType} > {label} diff --git a/src/frontend/src/btc/components/transactions/BtcTransactions.svelte b/src/frontend/src/btc/components/transactions/BtcTransactions.svelte index d7740db040..b9d1aae046 100644 --- a/src/frontend/src/btc/components/transactions/BtcTransactions.svelte +++ b/src/frontend/src/btc/components/transactions/BtcTransactions.svelte @@ -11,9 +11,11 @@ import type { BtcTransactionUi } from '$btc/types/btc'; import TransactionsPlaceholder from '$lib/components/transactions/TransactionsPlaceholder.svelte'; import TransactionsSkeletons from '$lib/components/transactions/TransactionsSkeletons.svelte'; + import { DEFAULT_BITCOIN_TOKEN } from '$lib/constants/tokens.constants'; import { SLIDE_DURATION } from '$lib/constants/transition.constants'; import { modalBtcTransaction } from '$lib/derived/modal.derived'; import { modalStore } from '$lib/stores/modal.store'; + import { token } from '$lib/stores/token.store'; let selectedTransaction: BtcTransactionUi | undefined; $: selectedTransaction = $modalBtcTransaction @@ -26,7 +28,7 @@ {#each $sortedBtcTransactions as transaction (transaction.data.id)}
- +
{/each} diff --git a/src/frontend/src/btc/constants/btc.constants.ts b/src/frontend/src/btc/constants/btc.constants.ts index deea0afd00..747193fe66 100644 --- a/src/frontend/src/btc/constants/btc.constants.ts +++ b/src/frontend/src/btc/constants/btc.constants.ts @@ -9,3 +9,5 @@ export const BTC_BALANCE_MIN_CONFIRMATIONS = 1; // 6 and more confirmations - transaction status "confirmed" export const UNCONFIRMED_BTC_TRANSACTION_MIN_CONFIRMATIONS = 1; export const CONFIRMED_BTC_TRANSACTION_MIN_CONFIRMATIONS = 6; + +export const BTC_CONVERT_FEE = 0n; diff --git a/src/frontend/src/btc/services/btc-send.services.ts b/src/frontend/src/btc/services/btc-send.services.ts index a44cd1c479..23af8dcac7 100644 --- a/src/frontend/src/btc/services/btc-send.services.ts +++ b/src/frontend/src/btc/services/btc-send.services.ts @@ -4,6 +4,7 @@ import type { SendBtcResponse } from '$declarations/signer/signer.did'; import { addPendingBtcTransaction, selectUserUtxosFee } from '$lib/api/backend.api'; import { sendBtc as sendBtcApi } from '$lib/api/signer.api'; import type { BtcAddress } from '$lib/types/address'; +import type { Amount } from '$lib/types/send'; import { mapToSignerBitcoinNetwork } from '$lib/utils/network.utils'; import { waitAndTriggerWallet } from '$lib/utils/wallet.utils'; import type { Identity } from '@dfinity/agent'; @@ -15,7 +16,7 @@ const DEFAULT_MIN_CONFIRMATIONS = 6; interface BtcSendServiceParams { identity: Identity; network: BitcoinNetwork; - amount: number; + amount: Amount; } export type SendBtcParams = BtcSendServiceParams & { diff --git a/src/frontend/src/btc/utils/btc-send.utils.ts b/src/frontend/src/btc/utils/btc-send.utils.ts index e5f8ecae3a..b16bd3b33a 100644 --- a/src/frontend/src/btc/utils/btc-send.utils.ts +++ b/src/frontend/src/btc/utils/btc-send.utils.ts @@ -1,4 +1,5 @@ import { BTC_DECIMALS } from '$env/tokens.btc.env'; +import type { Amount } from '$lib/types/send'; -export const convertNumberToSatoshis = ({ amount }: { amount: number }): bigint => - BigInt(amount * 10 ** BTC_DECIMALS); +export const convertNumberToSatoshis = ({ amount }: { amount: Amount }): bigint => + BigInt(Number(amount) * 10 ** BTC_DECIMALS); diff --git a/src/frontend/src/env/networks.icrc.env.ts b/src/frontend/src/env/networks.icrc.env.ts index b5237043d7..c99bf77422 100644 --- a/src/frontend/src/env/networks.icrc.env.ts +++ b/src/frontend/src/env/networks.icrc.env.ts @@ -18,10 +18,12 @@ import { XAUT_TOKEN } from '$env/tokens-erc20/tokens.xaut.env'; import { BTC_MAINNET_TOKEN, BTC_TESTNET_TOKEN } from '$env/tokens.btc.env'; import { ckErc20Production, ckErc20Staging } from '$env/tokens.ckerc20.env'; import { ETHEREUM_TOKEN, SEPOLIA_TOKEN } from '$env/tokens.env'; +import { additionalIcrcTokensProduction } from '$env/tokens.icrc.env'; import type { EnvCkErc20Tokens } from '$env/types/env-token-ckerc20'; import type { EnvTokenSymbol } from '$env/types/env-token-common'; import type { LedgerCanisterIdText } from '$icp/types/canister'; import type { IcCkInterface, IcInterface } from '$icp/types/ic-token'; +import { mapIcrcData } from '$icp/utils/map-icrc-data'; import { BETA, LOCAL, PROD, STAGING } from '$lib/constants/app.constants'; import type { CanisterIdText, OptionCanisterIdText } from '$lib/types/canister'; import type { NetworkEnvironment } from '$lib/types/network'; @@ -385,6 +387,15 @@ const CKXAUT_IC_DATA: IcCkInterface | undefined = nonNullish(CKERC20_PRODUCTION_ } : undefined; +const ADDITIONAL_ICRC_PRODUCTION_DATA = mapIcrcData(additionalIcrcTokensProduction); + +const BURN_IC_DATA: IcInterface | undefined = nonNullish(ADDITIONAL_ICRC_PRODUCTION_DATA?.BURN) + ? { + ...ADDITIONAL_ICRC_PRODUCTION_DATA.BURN, + position: 12 + } + : undefined; + export const CKERC20_LEDGER_CANISTER_TESTNET_IDS: CanisterIdText[] = [ ...(nonNullish(LOCAL_CKUSDC_LEDGER_CANISTER_ID) ? [LOCAL_CKUSDC_LEDGER_CANISTER_ID] : []), ...(nonNullish(CKUSDC_STAGING_DATA?.ledgerCanisterId) @@ -428,8 +439,11 @@ export const PUBLIC_ICRC_TOKENS: IcInterface[] = [ ...(nonNullish(CKUSDC_IC_DATA) ? [CKUSDC_IC_DATA] : []) ]; +const ADDITIONAL_ICRC_TOKENS: IcInterface[] = [...(nonNullish(BURN_IC_DATA) ? [BURN_IC_DATA] : [])]; + export const ICRC_TOKENS: IcInterface[] = [ ...PUBLIC_ICRC_TOKENS, + ...ADDITIONAL_ICRC_TOKENS, ...(nonNullish(CKBTC_LOCAL_DATA) ? [CKBTC_LOCAL_DATA] : []), ...(nonNullish(CKBTC_STAGING_DATA) ? [CKBTC_STAGING_DATA] : []), ...(nonNullish(CKETH_LOCAL_DATA) ? [CKETH_LOCAL_DATA] : []), diff --git a/src/frontend/src/env/rest/etherscan.env.ts b/src/frontend/src/env/rest/etherscan.env.ts new file mode 100644 index 0000000000..eb7f197764 --- /dev/null +++ b/src/frontend/src/env/rest/etherscan.env.ts @@ -0,0 +1 @@ +export const ETHERSCAN_API_KEY = import.meta.env.VITE_ETHERSCAN_API_KEY; diff --git a/src/frontend/src/env/rest/infura.env.ts b/src/frontend/src/env/rest/infura.env.ts new file mode 100644 index 0000000000..4a9cd111a0 --- /dev/null +++ b/src/frontend/src/env/rest/infura.env.ts @@ -0,0 +1 @@ +export const INFURA_API_KEY = import.meta.env.VITE_INFURA_API_KEY; diff --git a/src/frontend/src/env/tokens.icrc.json b/src/frontend/src/env/tokens.icrc.json index edc2e3f83a..b3d75b2f58 100644 --- a/src/frontend/src/env/tokens.icrc.json +++ b/src/frontend/src/env/tokens.icrc.json @@ -1,4 +1,9 @@ { - "production": {}, + "production": { + "BURN": { + "indexCanisterId": "nrant-tyaaa-aaaag-atsjq-cai", + "ledgerCanisterId": "egjwt-lqaaa-aaaak-qi2aa-cai" + } + }, "staging": {} } diff --git a/src/frontend/src/env/tokens.sns.json b/src/frontend/src/env/tokens.sns.json index 06d4bbfa0e..560cf17bf6 100644 --- a/src/frontend/src/env/tokens.sns.json +++ b/src/frontend/src/env/tokens.sns.json @@ -138,7 +138,7 @@ "fee": { "__bigint__": "100000" }, - "alternativeName": "Seers", + "alternativeName": "Seers AI", "url": "https://seers.social" }, "indexCanisterVersion": "up-to-date" diff --git a/src/frontend/src/env/types/env-icrc-additional-token.ts b/src/frontend/src/env/types/env-icrc-additional-token.ts index d6e9eb5ba0..c53a137d9d 100644 --- a/src/frontend/src/env/types/env-icrc-additional-token.ts +++ b/src/frontend/src/env/types/env-icrc-additional-token.ts @@ -1,4 +1,4 @@ -import { EnvTokensAdditionalIcrcSchema } from '$env/schema/env-additional-icrc-token.schema'; +import { EnvAdditionalIcrcTokensSchema } from '$env/schema/env-additional-icrc-token.schema'; import { z } from 'zod'; -export type EnvAdditionalIcrcTokens = z.infer; +export type EnvAdditionalIcrcTokens = z.infer; diff --git a/src/frontend/src/eth/components/fee/FeeContext.svelte b/src/frontend/src/eth/components/fee/FeeContext.svelte index 9b9729a42f..911ace3cdf 100644 --- a/src/frontend/src/eth/components/fee/FeeContext.svelte +++ b/src/frontend/src/eth/components/fee/FeeContext.svelte @@ -26,13 +26,14 @@ import { SEND_CONTEXT_KEY, type SendContext } from '$lib/stores/send.store'; import { toastsError, toastsHide } from '$lib/stores/toasts.store'; import type { Network } from '$lib/types/network'; + import type { OptionAmount } from '$lib/types/send'; import type { Token } from '$lib/types/token'; import { isNetworkICP } from '$lib/utils/network.utils'; import { parseToken } from '$lib/utils/parse.utils'; export let observe: boolean; export let destination = ''; - export let amount: string | number | undefined = undefined; + export let amount: OptionAmount = undefined; export let sourceNetwork: EthereumNetwork; export let targetNetwork: Network | undefined = undefined; export let nativeEthereumToken: Token; diff --git a/src/frontend/src/eth/components/loaders/LoaderEthTransactions.svelte b/src/frontend/src/eth/components/loaders/LoaderEthTransactions.svelte index 1d829b8e76..afa33e4e95 100644 --- a/src/frontend/src/eth/components/loaders/LoaderEthTransactions.svelte +++ b/src/frontend/src/eth/components/loaders/LoaderEthTransactions.svelte @@ -1,6 +1,6 @@ + + + +
+
+ +
+
+ + diff --git a/src/frontend/src/lib/components/convert/ConvertAmountDestination.svelte b/src/frontend/src/lib/components/convert/ConvertAmountDestination.svelte new file mode 100644 index 0000000000..c75923f9ba --- /dev/null +++ b/src/frontend/src/lib/components/convert/ConvertAmountDestination.svelte @@ -0,0 +1,68 @@ + + + + + + + + {formatUSD({ value: receiveAmountUSD })} + + + + {$i18n.convert.text.available_balance}: + {formatToken({ + value: $destinationTokenBalance ?? ZERO, + unitName: $destinationToken.decimals + })} + {$destinationToken.symbol} + + diff --git a/src/frontend/src/lib/components/convert/ConvertAmountDisplay.svelte b/src/frontend/src/lib/components/convert/ConvertAmountDisplay.svelte index f73cdf82b3..641483a7ee 100644 --- a/src/frontend/src/lib/components/convert/ConvertAmountDisplay.svelte +++ b/src/frontend/src/lib/components/convert/ConvertAmountDisplay.svelte @@ -4,8 +4,9 @@ import ConvertAmountExchange from '$lib/components/convert/ConvertAmountExchange.svelte'; import ConvertValue from '$lib/components/convert/ConvertValue.svelte'; import SkeletonText from '$lib/components/ui/SkeletonText.svelte'; + import type { OptionAmount } from '$lib/types/send'; - export let amount: number | undefined = undefined; + export let amount: OptionAmount = undefined; export let symbol: string; export let exchangeRate: number | undefined = undefined; export let zeroAmountLabel: string | undefined = undefined; diff --git a/src/frontend/src/lib/components/convert/ConvertAmountExchange.svelte b/src/frontend/src/lib/components/convert/ConvertAmountExchange.svelte index 485d49b19f..b5d4bbd428 100644 --- a/src/frontend/src/lib/components/convert/ConvertAmountExchange.svelte +++ b/src/frontend/src/lib/components/convert/ConvertAmountExchange.svelte @@ -2,16 +2,17 @@ import { nonNullish } from '@dfinity/utils'; import { fade } from 'svelte/transition'; import SkeletonText from '$lib/components/ui/SkeletonText.svelte'; + import type { OptionAmount } from '$lib/types/send'; import { formatUSD } from '$lib/utils/format.utils'; - export let amount: number | undefined = undefined; + export let amount: OptionAmount = undefined; export let exchangeRate: number | undefined = undefined; let usdValue: string | undefined; $: usdValue = nonNullish(amount) && nonNullish(exchangeRate) ? formatUSD({ - value: amount * exchangeRate + value: Number(amount) * exchangeRate }) : undefined; diff --git a/src/frontend/src/lib/components/convert/ConvertAmountSource.svelte b/src/frontend/src/lib/components/convert/ConvertAmountSource.svelte new file mode 100644 index 0000000000..1134e90dc6 --- /dev/null +++ b/src/frontend/src/lib/components/convert/ConvertAmountSource.svelte @@ -0,0 +1,96 @@ + + + + + + +
+ {#if insufficientFunds} +
+ {$i18n.convert.assertion.insufficient_funds} +
+ {:else} +
+ {formatUSD({ value: convertAmountUSD })} +
+ {/if} +
+ + +
diff --git a/src/frontend/src/lib/components/convert/ConvertForm.svelte b/src/frontend/src/lib/components/convert/ConvertForm.svelte new file mode 100644 index 0000000000..c7c484acc8 --- /dev/null +++ b/src/frontend/src/lib/components/convert/ConvertForm.svelte @@ -0,0 +1,42 @@ + + + + + +
+ + + +
+ + + + + + +
diff --git a/src/frontend/src/lib/components/convert/ConvertInputAmount.svelte b/src/frontend/src/lib/components/convert/ConvertInputAmount.svelte index 032e7569c0..269c81c74d 100644 --- a/src/frontend/src/lib/components/convert/ConvertInputAmount.svelte +++ b/src/frontend/src/lib/components/convert/ConvertInputAmount.svelte @@ -6,12 +6,13 @@ import InputCurrency from '$lib/components/ui/InputCurrency.svelte'; import { i18n } from '$lib/stores/i18n.store'; import type { ConvertAmountErrorType } from '$lib/types/convert'; + import type { OptionAmount } from '$lib/types/send'; import type { Token } from '$lib/types/token'; import { invalidAmount } from '$lib/utils/input.utils'; import { parseToken } from '$lib/utils/parse.utils'; export let token: Token; - export let amount: number | undefined = undefined; + export let amount: OptionAmount = undefined; export let name = 'convert-amount'; export let disabled: boolean | undefined = undefined; export let customValidate: (userAmount: BigNumber) => ConvertAmountErrorType = () => undefined; diff --git a/src/frontend/src/lib/components/convert/ConvertInputsContainer.svelte b/src/frontend/src/lib/components/convert/ConvertInputsContainer.svelte index fbd7c44945..3dd5c5d167 100644 --- a/src/frontend/src/lib/components/convert/ConvertInputsContainer.svelte +++ b/src/frontend/src/lib/components/convert/ConvertInputsContainer.svelte @@ -1,6 +1,6 @@
diff --git a/src/frontend/src/lib/components/convert/ConvertNetwork.svelte b/src/frontend/src/lib/components/convert/ConvertNetwork.svelte index a03b5352a1..dee3786660 100644 --- a/src/frontend/src/lib/components/convert/ConvertNetwork.svelte +++ b/src/frontend/src/lib/components/convert/ConvertNetwork.svelte @@ -1,9 +1,7 @@ @@ -12,10 +10,7 @@
- {token.network.name} + + {token.network.name}
diff --git a/src/frontend/src/lib/components/convert/ConvertReview.svelte b/src/frontend/src/lib/components/convert/ConvertReview.svelte new file mode 100644 index 0000000000..f809cdca0c --- /dev/null +++ b/src/frontend/src/lib/components/convert/ConvertReview.svelte @@ -0,0 +1,38 @@ + + + + + +
+ + + + + + + +
+ + + + + + +
diff --git a/src/frontend/src/lib/components/convert/ConvertReviewAmount.svelte b/src/frontend/src/lib/components/convert/ConvertReviewAmount.svelte index e692d27b6d..63baee812b 100644 --- a/src/frontend/src/lib/components/convert/ConvertReviewAmount.svelte +++ b/src/frontend/src/lib/components/convert/ConvertReviewAmount.svelte @@ -4,8 +4,9 @@ import ConvertAmountDisplay from '$lib/components/convert/ConvertAmountDisplay.svelte'; import { CONVERT_CONTEXT_KEY, type ConvertContext } from '$lib/stores/convert.store'; import { i18n } from '$lib/stores/i18n.store.js'; + import type { OptionAmount } from '$lib/types/send'; - export let sendAmount: number | undefined = undefined; + export let sendAmount: OptionAmount = undefined; export let receiveAmount: number | undefined = undefined; const { sourceToken, sourceTokenExchangeRate, destinationToken, destinationTokenExchangeRate } = diff --git a/src/frontend/src/lib/components/convert/ConvertReviewTokens.svelte b/src/frontend/src/lib/components/convert/ConvertReviewTokens.svelte index 160e26ce89..774e2e96f3 100644 --- a/src/frontend/src/lib/components/convert/ConvertReviewTokens.svelte +++ b/src/frontend/src/lib/components/convert/ConvertReviewTokens.svelte @@ -9,7 +9,7 @@ const { sourceToken, destinationToken } = getContext(CONVERT_CONTEXT_KEY); -
+

{$i18n.convert.text.review_tokens_info_title}

diff --git a/src/frontend/src/lib/components/core/Menu.svelte b/src/frontend/src/lib/components/core/Menu.svelte index b6f2620316..55cb850a06 100644 --- a/src/frontend/src/lib/components/core/Menu.svelte +++ b/src/frontend/src/lib/components/core/Menu.svelte @@ -6,6 +6,7 @@ import MenuAddresses from '$lib/components/core/MenuAddresses.svelte'; import SignOut from '$lib/components/core/SignOut.svelte'; import IconGitHub from '$lib/components/icons/IconGitHub.svelte'; + import IconActivity from '$lib/components/icons/iconly/IconActivity.svelte'; import IconlySettings from '$lib/components/icons/iconly/IconlySettings.svelte'; import IconlyUfo from '$lib/components/icons/iconly/IconlyUfo.svelte'; import LicenseLink from '$lib/components/license-agreement/LicenseLink.svelte'; @@ -19,7 +20,12 @@ import { NAVIGATION_MENU_BUTTON, NAVIGATION_MENU } from '$lib/constants/test-ids.constants'; import { networkId } from '$lib/derived/network.derived'; import { i18n } from '$lib/stores/i18n.store'; - import { isRouteDappExplorer, isRouteSettings, networkParam } from '$lib/utils/nav.utils'; + import { + isRouteActivity, + isRouteDappExplorer, + isRouteSettings, + networkParam + } from '$lib/utils/nav.utils'; let visible = false; let button: HTMLButtonElement | undefined; @@ -30,17 +36,26 @@ hidePopover(); await goto(`${AppPath.Settings}?${networkParam($networkId)}`); }; + const goToDappExplorer = async () => { hidePopover(); await goto(AppPath.Explore); }; + const goToActivity = async () => { + hidePopover(); + await goto(AppPath.Activity); + }; + let settingsRoute = false; $: settingsRoute = isRouteSettings($page); let dAppExplorerRoute = false; $: dAppExplorerRoute = isRouteDappExplorer($page); + let activityRoute = false; + $: activityRoute = isRouteActivity($page); + let addressesOption = true; $: addressesOption = !settingsRoute && !dAppExplorerRoute; @@ -61,6 +76,13 @@ {/if} + {#if !activityRoute && !settingsRoute} + + + {$i18n.navigation.text.activity} + + {/if} + {#if !dAppExplorerRoute && !settingsRoute} diff --git a/src/frontend/src/lib/components/hero/Balance.svelte b/src/frontend/src/lib/components/hero/Balance.svelte index 901952612e..3eefbe9b19 100644 --- a/src/frontend/src/lib/components/hero/Balance.svelte +++ b/src/frontend/src/lib/components/hero/Balance.svelte @@ -4,6 +4,7 @@ import TokenExchangeBalance from '$lib/components/tokens/TokenExchangeBalance.svelte'; import Amount from '$lib/components/ui/Amount.svelte'; import { HERO_CONTEXT_KEY, type HeroContext } from '$lib/stores/hero.store'; + import { i18n } from '$lib/stores/i18n.store'; import type { OptionTokenUi } from '$lib/types/token'; export let token: OptionTokenUi; @@ -16,13 +17,17 @@ class="inline-flex w-full flex-row justify-center gap-3 break-words text-4xl font-bold lg:text-5xl" > {#if nonNullish(token?.balance) && nonNullish(token?.symbol) && !token.balance.isZero()} - {token.symbol} + {token.symbol} {:else} 0.00 {/if} - + diff --git a/src/frontend/src/lib/components/icons/iconly/IconActivity.svelte b/src/frontend/src/lib/components/icons/iconly/IconActivity.svelte new file mode 100644 index 0000000000..afd89abdd8 --- /dev/null +++ b/src/frontend/src/lib/components/icons/iconly/IconActivity.svelte @@ -0,0 +1,29 @@ + + + + + + + + diff --git a/src/frontend/src/lib/components/icons/lucide/IconClose.svelte b/src/frontend/src/lib/components/icons/lucide/IconClose.svelte new file mode 100644 index 0000000000..8de5b0bf79 --- /dev/null +++ b/src/frontend/src/lib/components/icons/lucide/IconClose.svelte @@ -0,0 +1,15 @@ + + + + + diff --git a/src/frontend/src/lib/components/info/InfoBoxWrapper.svelte b/src/frontend/src/lib/components/info/InfoBoxWrapper.svelte index cf84fe65a0..e977b615fb 100644 --- a/src/frontend/src/lib/components/info/InfoBoxWrapper.svelte +++ b/src/frontend/src/lib/components/info/InfoBoxWrapper.svelte @@ -1,7 +1,7 @@ + + diff --git a/src/frontend/src/lib/components/receive/ReceiveAddress.svelte b/src/frontend/src/lib/components/receive/ReceiveAddress.svelte index 71f823280a..2140312a8a 100644 --- a/src/frontend/src/lib/components/receive/ReceiveAddress.svelte +++ b/src/frontend/src/lib/components/receive/ReceiveAddress.svelte @@ -1,16 +1,14 @@ {#if nonNullish(balance) && nonNullish(usdBalance)} {formatUSD({ value: usdBalance })} + {:else if isNullish(balance)} + {nullishBalanceMessage ?? '-'} {:else} - {formatUSD({ - value: 0, - options: { minFraction: 0, maxFraction: 0 } - }).replace('0', '-')} - + {$i18n.tokens.balance.error.not_applicable} {/if} diff --git a/src/frontend/src/lib/components/tokens/TokenLogo.svelte b/src/frontend/src/lib/components/tokens/TokenLogo.svelte index e0e53b8b27..8e6e6f357e 100644 --- a/src/frontend/src/lib/components/tokens/TokenLogo.svelte +++ b/src/frontend/src/lib/components/tokens/TokenLogo.svelte @@ -1,5 +1,6 @@
@@ -44,9 +41,9 @@ {:else if badge?.type === 'network'}
- diff --git a/src/frontend/src/lib/components/tokens/TokenSymbol.svelte b/src/frontend/src/lib/components/tokens/TokenSymbol.svelte index e0862473b5..87f9edb30c 100644 --- a/src/frontend/src/lib/components/tokens/TokenSymbol.svelte +++ b/src/frontend/src/lib/components/tokens/TokenSymbol.svelte @@ -1,9 +1,7 @@ -{#if nonNullish(sortedTransactions) && sortedTransactions.length > 0} - {#each transactions as transaction, index (`${transaction.id}-${index}`)} -
- -
+{#if nonNullish(groupedTransactions) && sortedTransactions.length > 0} + {#each Object.entries(groupedTransactions) as [date, transactions] (date)} + {/each} {/if} -{#if isNullish(sortedTransactions) || sortedTransactions.length === 0} +{#if isNullish(groupedTransactions) || sortedTransactions.length === 0} {/if} diff --git a/src/frontend/src/lib/components/transactions/Transaction.svelte b/src/frontend/src/lib/components/transactions/Transaction.svelte index e03941c713..5d3455151b 100644 --- a/src/frontend/src/lib/components/transactions/Transaction.svelte +++ b/src/frontend/src/lib/components/transactions/Transaction.svelte @@ -7,7 +7,7 @@ import Amount from '$lib/components/ui/Amount.svelte'; import Card from '$lib/components/ui/Card.svelte'; import RoundedIcon from '$lib/components/ui/RoundedIcon.svelte'; - import type { OptionToken } from '$lib/types/token'; + import type { Token } from '$lib/types/token'; import type { TransactionStatus, TransactionType } from '$lib/types/transaction'; import { formatSecondsToDate } from '$lib/utils/format.utils.js'; import { mapTransactionIcon } from '$lib/utils/transaction.utils'; @@ -17,7 +17,8 @@ export let status: TransactionStatus; export let timestamp: number | undefined; export let styleClass: string | undefined = undefined; - export let token: OptionToken = undefined; + export let token: Token; + export let iconType: 'token' | 'transaction' = 'transaction'; let icon: ComponentType; $: icon = mapTransactionIcon({ type, status }); @@ -31,16 +32,16 @@
- {#if nonNullish(token)} + {#if iconType === 'token'} {:else} - + {/if}
{#if nonNullish(amount)} - + {/if} diff --git a/src/frontend/src/lib/components/transactions/TransactionsDateGroup.svelte b/src/frontend/src/lib/components/transactions/TransactionsDateGroup.svelte new file mode 100644 index 0000000000..c3bfe9a36c --- /dev/null +++ b/src/frontend/src/lib/components/transactions/TransactionsDateGroup.svelte @@ -0,0 +1,22 @@ + + +{#if transactions.length > 0} +
+ {date} + + {#each transactions as transaction, index (`${transaction.id}-${index}`)} + {@const { component, token } = transaction} + +
+ +
+ {/each} +
+{/if} diff --git a/src/frontend/src/lib/components/ui/Amount.svelte b/src/frontend/src/lib/components/ui/Amount.svelte index 95932dfe5e..5727e44fcd 100644 --- a/src/frontend/src/lib/components/ui/Amount.svelte +++ b/src/frontend/src/lib/components/ui/Amount.svelte @@ -1,22 +1,22 @@ diff --git a/src/frontend/src/lib/components/ui/ContentWithToolbar.svelte b/src/frontend/src/lib/components/ui/ContentWithToolbar.svelte index 7f9586782c..1a57d3e151 100644 --- a/src/frontend/src/lib/components/ui/ContentWithToolbar.svelte +++ b/src/frontend/src/lib/components/ui/ContentWithToolbar.svelte @@ -4,7 +4,7 @@ export let minHeight: string | undefined = undefined; -
+
diff --git a/src/frontend/src/lib/components/ui/InputCurrency.svelte b/src/frontend/src/lib/components/ui/InputCurrency.svelte index d961364fe3..4baeae0de4 100644 --- a/src/frontend/src/lib/components/ui/InputCurrency.svelte +++ b/src/frontend/src/lib/components/ui/InputCurrency.svelte @@ -1,7 +1,7 @@ -
+{#if visible}
- -
-
- +
+ +
+
+ +
+ {#if closable} + + {/if}
-
+{/if} diff --git a/src/frontend/src/lib/components/ui/RoundedIcon.svelte b/src/frontend/src/lib/components/ui/RoundedIcon.svelte index 4e6442b389..17bcd55c7d 100644 --- a/src/frontend/src/lib/components/ui/RoundedIcon.svelte +++ b/src/frontend/src/lib/components/ui/RoundedIcon.svelte @@ -2,11 +2,11 @@ import type { ComponentType } from 'svelte'; export let icon: ComponentType; - export let iconStyleClass = ''; + export let opacity = false;
- +
diff --git a/src/frontend/src/lib/constants/test-ids.constants.ts b/src/frontend/src/lib/constants/test-ids.constants.ts index 7d07b540d5..644867b544 100644 --- a/src/frontend/src/lib/constants/test-ids.constants.ts +++ b/src/frontend/src/lib/constants/test-ids.constants.ts @@ -41,5 +41,6 @@ export const SEND_FORM_NEXT_BUTTON = 'send-form-next-button'; export const REVIEW_FORM_SEND_BUTTON = 'review-form-send-button'; export const NAVIGATION_ITEM_TOKENS = 'navigation-item-tokens'; +export const NAVIGATION_ITEM_ACTIVITY = 'navigation-item-activity'; export const NAVIGATION_ITEM_EXPLORER = 'navigation-item-explore'; export const NAVIGATION_ITEM_SETTINGS = 'navigation-item-settings'; diff --git a/src/frontend/src/lib/constants/tokens.constants.ts b/src/frontend/src/lib/constants/tokens.constants.ts index 8b43584f44..4365bee696 100644 --- a/src/frontend/src/lib/constants/tokens.constants.ts +++ b/src/frontend/src/lib/constants/tokens.constants.ts @@ -1,3 +1,16 @@ +import { BTC_MAINNET_ENABLED } from '$env/networks.btc.env'; +import { BTC_MAINNET_TOKEN, BTC_REGTEST_TOKEN, BTC_TESTNET_TOKEN } from '$env/tokens.btc.env'; import { SUPPORTED_ETHEREUM_TOKENS } from '$env/tokens.env'; +import type { Token } from '$lib/types/token'; export const [DEFAULT_ETHEREUM_TOKEN, _rest] = SUPPORTED_ETHEREUM_TOKENS; + +// The following tokens are used as fallback for any Bitcoin token defined in the token store. +// That means that the order of the tokens in the array is important, to have a correct fallback chain. +export const SUPPORTED_BITCOIN_TOKENS: [...Token[], Token] = [ + ...(BTC_MAINNET_ENABLED ? [BTC_MAINNET_TOKEN] : []), + BTC_TESTNET_TOKEN, + BTC_REGTEST_TOKEN +]; + +export const [DEFAULT_BITCOIN_TOKEN, _] = SUPPORTED_BITCOIN_TOKENS; diff --git a/src/frontend/src/lib/i18n/en.json b/src/frontend/src/lib/i18n/en.json index 26ada0cd95..68a96e4d31 100644 --- a/src/frontend/src/lib/i18n/en.json +++ b/src/frontend/src/lib/i18n/en.json @@ -36,6 +36,7 @@ "tokens": "Assets", "settings": "Settings", "dapp_explorer": "Explore", + "activity": "Activity", "source_code_on_github": "Source code on GitHub", "view_on_explorer": "View on explorer", "source_code": "Source code", @@ -46,6 +47,7 @@ "tokens": "Go to the assets view", "settings": "Open your settings", "dapp_explorer": "Open dapp explorer", + "activity": "Open the activity view", "more_settings": "More settings", "menu": "Your wallet address, settings, sign-out and external links", "changelog": "Open the changelog of $oisy_name on GitHub to review the latest updates", @@ -183,6 +185,7 @@ "hero": { "text": { "available_balance": "Available balance", + "unavailable_balance": "$ value is not available", "top_up": "Top up your wallet to start using it!", "learn_more_about_erc20_icp": "Learn more about ERC20 ICP on Ethereum." } @@ -436,6 +439,11 @@ "twin_token": "Twin token", "standard": "Standard" }, + "balance": { + "error": { + "not_applicable": "n/a" + } + }, "import": { "text": { "title": "Import token", diff --git a/src/frontend/src/lib/services/rest.services.ts b/src/frontend/src/lib/services/rest.services.ts new file mode 100644 index 0000000000..02d7638f7b --- /dev/null +++ b/src/frontend/src/lib/services/rest.services.ts @@ -0,0 +1,44 @@ +export interface RestRequestParams { + request: () => Promise; + onSuccess: (response: Response) => Success | undefined; + onError?: (error: Error) => Success | undefined; + onRetry?: (options: { error: Error; retryCount: number }) => Promise; + maxRetries?: number; +} + +/** + * A utility function to make a REST request with possible retries. + * + * @param request - The request function to call. + * @param onSuccess - The function to call when the request is successful. + * @param onError - The optional function to call when the request fails and the max retries are reached. + * @param onRetry - The optional function to call when the request fails and a retry is needed. + * @param maxRetries - The maximum number of retries to attempt. + * @returns The result of the onSuccess/onError function or undefined if the max retries are reached. + */ +export const restRequest = async ({ + request, + onSuccess, + onError, + onRetry, + maxRetries = 3 +}: RestRequestParams): Promise => { + let retryCount = 0; + + while (retryCount <= maxRetries) { + try { + const response = await request(); + return onSuccess(response); + } catch (error: unknown) { + retryCount++; + + if (retryCount > maxRetries) { + console.error(`Max retries reached. Error:`, error); + return onError?.(error as Error); + } + + await onRetry?.({ error: error as Error, retryCount }); + console.warn(`Request attempt ${retryCount} failed. Retrying...`); + } + } +}; diff --git a/src/frontend/src/lib/stores/modal.store.ts b/src/frontend/src/lib/stores/modal.store.ts index 4ba09659d6..0d564c67c5 100644 --- a/src/frontend/src/lib/stores/modal.store.ts +++ b/src/frontend/src/lib/stores/modal.store.ts @@ -32,20 +32,21 @@ export interface Modal { | 'btc-transaction' | 'dapp-details'; data?: T; + id?: symbol; } export type ModalData = Option>; export interface ModalStore extends Readable> { - openEthReceive: (data: D) => void; - openIcpReceive: (data: D) => void; - openIcrcReceive: (data: D) => void; - openCkBTCReceive: (data: D) => void; - openCkETHReceive: (data: D) => void; - openBtcReceive: (data: D) => void; - openReceive: (data: D) => void; - openSend: (data: D) => void; - openBuy: (data: D) => void; + openEthReceive: (id: symbol) => void; + openIcpReceive: (id: symbol) => void; + openIcrcReceive: (id: symbol) => void; + openCkBTCReceive: (id: symbol) => void; + openCkETHReceive: (id: symbol) => void; + openBtcReceive: (id: symbol) => void; + openReceive: (id: symbol) => void; + openSend: (id: symbol) => void; + openBuy: (id: symbol) => void; openConvertCkBTCToBTC: () => void; openConvertBTCToCkBTC: () => void; openConvertToTwinTokenCkEth: () => void; @@ -71,35 +72,44 @@ export interface ModalStore extends Readable> { const initModalStore = (): ModalStore => { const { subscribe, set } = writable>(undefined); + const setType = (type: Modal['type']) => () => set({ type }); + + const setTypeWithId = (type: Modal['type']) => (id: symbol) => set({ type, id }); + + const setTypeWithData = + (type: Modal['type']) => + (data: D) => + set({ type, data }); + return { - openEthReceive: (data: D) => set({ type: 'eth-receive', data }), - openIcpReceive: (data: D) => set({ type: 'icp-receive', data }), - openIcrcReceive: (data: D) => set({ type: 'icrc-receive', data }), - openCkBTCReceive: (data: D) => set({ type: 'ckbtc-receive', data }), - openCkETHReceive: (data: D) => set({ type: 'cketh-receive', data }), - openBtcReceive: (data: D) => set({ type: 'btc-receive', data }), - openReceive: (data: D) => set({ type: 'receive', data }), - openSend: (data: D) => set({ type: 'send', data }), - openBuy: (data: D) => set({ type: 'buy', data }), - openConvertCkBTCToBTC: () => set({ type: 'convert-ckbtc-btc' }), - openConvertBTCToCkBTC: () => set({ type: 'convert-btc-ckbtc' }), - openConvertToTwinTokenCkEth: () => set({ type: 'convert-to-twin-token-cketh' }), - openConvertToTwinTokenEth: () => set({ type: 'convert-to-twin-token-eth' }), - openHowToConvertToTwinTokenEth: () => set({ type: 'how-to-convert-to-twin-token-eth' }), - openWalletConnectAuth: () => set({ type: 'wallet-connect-auth' }), - openWalletConnectSign: (data: D) => set({ type: 'wallet-connect-sign', data }), - openWalletConnectSend: (data: D) => set({ type: 'wallet-connect-send', data }), - openEthTransaction: (data: D) => set({ type: 'eth-transaction', data }), - openIcTransaction: (data: D) => set({ type: 'ic-transaction', data }), - openBtcTransaction: (data: D) => set({ type: 'btc-transaction', data }), - openManageTokens: () => set({ type: 'manage-tokens' }), - openHideToken: () => set({ type: 'hide-token' }), - openIcHideToken: () => set({ type: 'ic-hide-token' }), - openToken: () => set({ type: 'token' }), - openIcToken: () => set({ type: 'ic-token' }), - openReceiveBitcoin: () => set({ type: 'receive-bitcoin' }), - openAboutWhyOisy: () => set({ type: 'about-why-oisy' }), - openDappDetails: (data: D) => set({ type: 'dapp-details', data }), + openEthReceive: setTypeWithId('eth-receive'), + openIcpReceive: setTypeWithId('icp-receive'), + openIcrcReceive: setTypeWithId('icrc-receive'), + openCkBTCReceive: setTypeWithId('ckbtc-receive'), + openCkETHReceive: setTypeWithId('cketh-receive'), + openBtcReceive: setTypeWithId('btc-receive'), + openReceive: setTypeWithId('receive'), + openSend: setTypeWithId('send'), + openBuy: setTypeWithId('buy'), + openConvertCkBTCToBTC: setType('convert-ckbtc-btc'), + openConvertBTCToCkBTC: setType('convert-btc-ckbtc'), + openConvertToTwinTokenCkEth: setType('convert-to-twin-token-cketh'), + openConvertToTwinTokenEth: setType('convert-to-twin-token-eth'), + openHowToConvertToTwinTokenEth: setType('how-to-convert-to-twin-token-eth'), + openWalletConnectAuth: setType('wallet-connect-auth'), + openWalletConnectSign: setTypeWithData('wallet-connect-sign'), + openWalletConnectSend: setTypeWithData('wallet-connect-send'), + openEthTransaction: setTypeWithData('eth-transaction'), + openIcTransaction: setTypeWithData('ic-transaction'), + openBtcTransaction: setTypeWithData('btc-transaction'), + openManageTokens: setType('manage-tokens'), + openHideToken: setType('hide-token'), + openIcHideToken: setType('ic-hide-token'), + openToken: setType('token'), + openIcToken: setType('ic-token'), + openReceiveBitcoin: setType('receive-bitcoin'), + openAboutWhyOisy: setType('about-why-oisy'), + openDappDetails: setTypeWithData('dapp-details'), close: () => set(null), subscribe }; diff --git a/src/frontend/src/lib/types/i18n.d.ts b/src/frontend/src/lib/types/i18n.d.ts index 97893fc5e3..a53f4cf050 100644 --- a/src/frontend/src/lib/types/i18n.d.ts +++ b/src/frontend/src/lib/types/i18n.d.ts @@ -32,6 +32,7 @@ interface I18nNavigation { tokens: string; settings: string; dapp_explorer: string; + activity: string; source_code_on_github: string; view_on_explorer: string; source_code: string; @@ -42,6 +43,7 @@ interface I18nNavigation { tokens: string; settings: string; dapp_explorer: string; + activity: string; more_settings: string; menu: string; changelog: string; @@ -160,7 +162,12 @@ interface I18nInit { } interface I18nHero { - text: { available_balance: string; top_up: string; learn_more_about_erc20_icp: string }; + text: { + available_balance: string; + unavailable_balance: string; + top_up: string; + learn_more_about_erc20_icp: string; + }; } interface I18nSettings { @@ -392,6 +399,7 @@ interface I18nTokens { twin_token: string; standard: string; }; + balance: { error: { not_applicable: string } }; import: { text: { title: string; diff --git a/src/frontend/src/lib/types/send.ts b/src/frontend/src/lib/types/send.ts index 08fd2afeb5..00d02457a4 100644 --- a/src/frontend/src/lib/types/send.ts +++ b/src/frontend/src/lib/types/send.ts @@ -10,3 +10,6 @@ export interface TransferParams { } export class InsufficientFundsError extends Error {} + +export type Amount = string | number; +export type OptionAmount = Amount | undefined; diff --git a/src/frontend/src/lib/types/transaction.ts b/src/frontend/src/lib/types/transaction.ts index c96c6bd621..f11c4a88b5 100644 --- a/src/frontend/src/lib/types/transaction.ts +++ b/src/frontend/src/lib/types/transaction.ts @@ -36,7 +36,11 @@ export type TransactionUiCommon = Pick = Record; diff --git a/src/frontend/src/lib/utils/convert.utils.ts b/src/frontend/src/lib/utils/convert.utils.ts new file mode 100644 index 0000000000..f5bd23ffec --- /dev/null +++ b/src/frontend/src/lib/utils/convert.utils.ts @@ -0,0 +1,39 @@ +import { ZERO } from '$lib/constants/app.constants'; +import type { ConvertAmountErrorType } from '$lib/types/convert'; +import { formatToken } from '$lib/utils/format.utils'; +import { nonNullish } from '@dfinity/utils'; +import { BigNumber } from '@ethersproject/bignumber'; +import { Utils } from 'alchemy-sdk'; + +// TODO: standardise and re-use this util between Send and Convert flows +export const validateConvertAmount = ({ + userAmount, + decimals, + balance, + totalFee +}: { + userAmount: BigNumber; + decimals: number; + balance?: BigNumber; + totalFee?: bigint; +}): ConvertAmountErrorType => { + // We should align balance and userAmount to avoid issues caused by comparing formatted and unformatted BN + const parsedSendBalance = nonNullish(balance) + ? Utils.parseUnits( + formatToken({ + value: balance, + unitName: decimals, + displayDecimals: decimals + }), + decimals + ) + : ZERO; + + if (userAmount.gt(parsedSendBalance)) { + return 'insufficient-funds'; + } + + if (nonNullish(totalFee) && userAmount.add(totalFee).gt(parsedSendBalance)) { + return 'insufficient-funds-for-fee'; + } +}; diff --git a/src/frontend/src/lib/utils/format.utils.ts b/src/frontend/src/lib/utils/format.utils.ts index f3e567e205..522ac5eaa8 100644 --- a/src/frontend/src/lib/utils/format.utils.ts +++ b/src/frontend/src/lib/utils/format.utils.ts @@ -85,6 +85,8 @@ export const formatNanosecondsToDate = (nanoseconds: bigint): string => { return date.toLocaleDateString('en', DATE_TIME_FORMAT_OPTIONS); }; +const relativeTimeFormatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }); + /** Formats a number of seconds to a normalized date string. * * If the date is within the same year, it returns the day and month name. @@ -109,7 +111,7 @@ export const formatSecondsToNormalizedDate = ({ if (Math.abs(daysDifference) < 2) { // TODO: When the method is called many times with the same arguments, it is better to create a Intl.DateTimeFormat object and use its format() method, because a DateTimeFormat object remembers the arguments passed to it and may decide to cache a slice of the database, so future format calls can search for localization strings within a more constrained context. - return new Intl.RelativeTimeFormat('en', { numeric: 'auto' }).format(daysDifference, 'day'); + return relativeTimeFormatter.format(daysDifference, 'day'); } // Same year, return day and month name diff --git a/src/frontend/src/icp/utils/ck.utils.ts b/src/frontend/src/lib/utils/info.utils.ts similarity index 100% rename from src/frontend/src/icp/utils/ck.utils.ts rename to src/frontend/src/lib/utils/info.utils.ts diff --git a/src/frontend/src/lib/utils/input.utils.ts b/src/frontend/src/lib/utils/input.utils.ts index 692152dcea..1acb285e5b 100644 --- a/src/frontend/src/lib/utils/input.utils.ts +++ b/src/frontend/src/lib/utils/input.utils.ts @@ -1,8 +1,9 @@ +import type { OptionAmount } from '$lib/types/send'; import type { OptionString } from '$lib/types/string'; import { isNullish } from '@dfinity/utils'; export const isNullishOrEmpty = (value: OptionString): value is undefined | null => isNullish(value) || value === ''; -export const invalidAmount = (amount: number | undefined): boolean => - isNullish(amount) || amount < 0; +export const invalidAmount = (amount: OptionAmount): boolean => + isNullish(amount) || Number(amount) < 0; diff --git a/src/frontend/src/lib/utils/nav.utils.ts b/src/frontend/src/lib/utils/nav.utils.ts index 1de6b5fc19..906ebc2675 100644 --- a/src/frontend/src/lib/utils/nav.utils.ts +++ b/src/frontend/src/lib/utils/nav.utils.ts @@ -26,6 +26,9 @@ export const isRouteSettings = ({ route: { id } }: Page): boolean => export const isRouteDappExplorer = ({ route: { id } }: Page): boolean => id === `${ROUTE_ID_GROUP_APP}${AppPath.Explore}`; +export const isRouteActivity = ({ route: { id } }: Page): boolean => + id === `${ROUTE_ID_GROUP_APP}${AppPath.Activity}`; + export const isRouteTokens = ({ route: { id } }: Page): boolean => id === ROUTE_ID_GROUP_APP; const tokenUrl = ({ diff --git a/src/frontend/src/lib/utils/transaction.utils.ts b/src/frontend/src/lib/utils/transaction.utils.ts index 5b289a1a28..18a9a2a436 100644 --- a/src/frontend/src/lib/utils/transaction.utils.ts +++ b/src/frontend/src/lib/utils/transaction.utils.ts @@ -4,7 +4,12 @@ import IconConvertFrom from '$lib/components/icons/IconConvertFrom.svelte'; import IconConvertTo from '$lib/components/icons/IconConvertTo.svelte'; import IconReceive from '$lib/components/icons/IconReceive.svelte'; import IconSend from '$lib/components/icons/IconSend.svelte'; -import type { AnyTransactionUi, TransactionStatus, TransactionType } from '$lib/types/transaction'; +import type { + AnyTransactionUi, + TransactionStatus, + TransactionsUiDateGroup, + TransactionType +} from '$lib/types/transaction'; import { formatSecondsToNormalizedDate } from '$lib/utils/format.utils'; import { isNullish } from '@dfinity/utils'; import type { ComponentType } from 'svelte'; @@ -43,10 +48,12 @@ export const mapTransactionIcon = ({ * @param transactions - List of transactions to group. * @returns Object where the keys are the date and the values are the transactions for that date. */ -export const groupTransactionsByDate = (transactions: T[]) => { +export const groupTransactionsByDate = ( + transactions: T[] +): TransactionsUiDateGroup => { const currentDate = new Date(); - return transactions.reduce>((acc, transaction) => { + return transactions.reduce>((acc, transaction) => { if (isNullish(transaction.timestamp)) { return { ...acc, undefined: [...(acc['undefined'] ?? []), transaction] }; } diff --git a/src/frontend/src/lib/utils/transactions.utils.ts b/src/frontend/src/lib/utils/transactions.utils.ts index 85dec0f1e3..c8deff4cd1 100644 --- a/src/frontend/src/lib/utils/transactions.utils.ts +++ b/src/frontend/src/lib/utils/transactions.utils.ts @@ -16,7 +16,7 @@ import type { CertifiedStoreData } from '$lib/stores/certified.store'; import type { TransactionsData } from '$lib/stores/transactions.store'; import type { OptionEthAddress } from '$lib/types/address'; import type { Token } from '$lib/types/token'; -import type { AllTransactionsUi, AnyTransactionUi } from '$lib/types/transaction'; +import type { AllTransactionUi, AnyTransactionUi } from '$lib/types/transaction'; import { isNetworkIdBTCMainnet, isNetworkIdEthereum, @@ -53,7 +53,7 @@ export const mapAllTransactionsUi = ({ $ethAddress: OptionEthAddress; $icTransactions: CertifiedStoreData>; $btcStatuses: CertifiedStoreData; -}): AllTransactionsUi => { +}): AllTransactionUi[] => { const ckEthMinterInfoAddressesMainnet = toCkMinterInfoAddresses({ minterInfo: $ckEthMinterInfo?.[ETHEREUM_TOKEN_ID], networkId: ETHEREUM_NETWORK_ID @@ -64,7 +64,7 @@ export const mapAllTransactionsUi = ({ networkId: SEPOLIA_NETWORK_ID }); - return tokens.reduce((acc, token) => { + return tokens.reduce((acc, token) => { const { id: tokenId, network: { id: networkId } diff --git a/src/frontend/src/tests/btc/components/convert/BtcConvertFeeTotal.spec.ts b/src/frontend/src/tests/btc/components/convert/BtcConvertFeeTotal.spec.ts new file mode 100644 index 0000000000..10587c4968 --- /dev/null +++ b/src/frontend/src/tests/btc/components/convert/BtcConvertFeeTotal.spec.ts @@ -0,0 +1,84 @@ +import BtcConvertFeeTotal from '$btc/components/convert/BtcConvertFeeTotal.svelte'; +import { BTC_CONVERT_FEE } from '$btc/constants/btc.constants'; +import { + initUtxosFeeStore, + UTXOS_FEE_CONTEXT_KEY, + type UtxosFeeStore +} from '$btc/stores/utxos-fee.store'; +import { BTC_MAINNET_TOKEN } from '$env/tokens.btc.env'; +import { ICP_TOKEN } from '$env/tokens.env'; +import { CONVERT_CONTEXT_KEY } from '$lib/stores/convert.store'; +import type { TokenId } from '$lib/types/token'; +import { mockUtxosFee } from '$tests/mocks/btc.mock'; +import { mockCkBtcMinterInfo } from '$tests/mocks/ckbtc.mock'; +import { mockPage } from '$tests/mocks/page.store.mock'; +import { setupCkBTCStores } from '$tests/utils/ckbtc-stores.test-utils'; +import { render } from '@testing-library/svelte'; +import { readable } from 'svelte/store'; + +describe('BtcConvertFeeTotal', () => { + let store: UtxosFeeStore; + const exchangeRate = 0.01; + const mockContext = ({ + utxosFeeStore, + destinationTokenId = ICP_TOKEN.id + }: { + utxosFeeStore: UtxosFeeStore; + destinationTokenId?: TokenId; + }) => + new Map([ + [UTXOS_FEE_CONTEXT_KEY, { store: utxosFeeStore }], + [ + CONVERT_CONTEXT_KEY, + { + sourceToken: readable(BTC_MAINNET_TOKEN), + sourceTokenExchangeRate: readable(exchangeRate), + destinationToken: readable({ ...ICP_TOKEN, id: destinationTokenId }) + } + ] + ]); + + beforeEach(() => { + mockPage.reset(); + store = initUtxosFeeStore(); + store.reset(); + }); + + it('should calculate totalFee correctly if only default fee is available', () => { + const { component } = render(BtcConvertFeeTotal, { + context: mockContext({ utxosFeeStore: store }) + }); + expect(component.$$.ctx[component.$$.props['totalFee']]).toBe(BTC_CONVERT_FEE); + }); + + it('should calculate totalFee correctly if default and utxos fees are available', () => { + store.setUtxosFee({ utxosFee: mockUtxosFee }); + const { component } = render(BtcConvertFeeTotal, { + context: mockContext({ utxosFeeStore: store }) + }); + expect(component.$$.ctx[component.$$.props['totalFee']]).toBe( + BTC_CONVERT_FEE + mockUtxosFee.feeSatoshis + ); + }); + + it('should calculate totalFee correctly if default and ckBTC minter fees are available', () => { + const tokenId = setupCkBTCStores(); + const { component } = render(BtcConvertFeeTotal, { + context: mockContext({ utxosFeeStore: store, destinationTokenId: tokenId }) + }); + expect(component.$$.ctx[component.$$.props['totalFee']]).toBe( + BTC_CONVERT_FEE + mockCkBtcMinterInfo.kyt_fee + ); + }); + + it('should calculate totalFee correctly if all fees are available', () => { + store.setUtxosFee({ utxosFee: mockUtxosFee }); + const tokenId = setupCkBTCStores(); + const { component } = render(BtcConvertFeeTotal, { + context: mockContext({ utxosFeeStore: store, destinationTokenId: tokenId }) + }); + expect(component.$$.ctx[component.$$.props['totalFee']]).toBe( + BTC_CONVERT_FEE + mockCkBtcMinterInfo.kyt_fee + mockUtxosFee.feeSatoshis + ); + }); +}); diff --git a/src/frontend/src/tests/btc/components/convert/BtcConvertForm.spec.ts b/src/frontend/src/tests/btc/components/convert/BtcConvertForm.spec.ts new file mode 100644 index 0000000000..4091479059 --- /dev/null +++ b/src/frontend/src/tests/btc/components/convert/BtcConvertForm.spec.ts @@ -0,0 +1,161 @@ +import BtcConvertForm from '$btc/components/convert/BtcConvertForm.svelte'; +import * as btcPendingSendTransactionsStatusStore from '$btc/derived/btc-pending-sent-transactions-status.derived'; +import { + initUtxosFeeStore, + UTXOS_FEE_CONTEXT_KEY, + type UtxosFeeStore +} from '$btc/stores/utxos-fee.store'; +import { BTC_MAINNET_TOKEN } from '$env/tokens.btc.env'; +import { ICP_TOKEN } from '$env/tokens.env'; +import { CONVERT_CONTEXT_KEY } from '$lib/stores/convert.store'; +import * as convertUtils from '$lib/utils/convert.utils'; +import { mockBtcAddress, mockUtxosFee } from '$tests/mocks/btc.mock'; +import en from '$tests/mocks/i18n.mock'; +import { mockPage } from '$tests/mocks/page.store.mock'; +import { render, waitFor } from '@testing-library/svelte'; +import { BigNumber } from 'alchemy-sdk'; +import { readable } from 'svelte/store'; + +describe('BtcConvertForm', () => { + let store: UtxosFeeStore; + const mockContext = ({ + utxosFeeStore, + sourceTokenBalance = 1000000n + }: { + utxosFeeStore: UtxosFeeStore; + sourceTokenBalance?: bigint; + }) => + new Map([ + [UTXOS_FEE_CONTEXT_KEY, { store: utxosFeeStore }], + [ + CONVERT_CONTEXT_KEY, + { + sourceToken: readable(BTC_MAINNET_TOKEN), + sourceTokenBalance: readable(BigNumber.from(sourceTokenBalance)), + destinationToken: readable(ICP_TOKEN) + } + ] + ]); + const props = { + source: mockBtcAddress, + sendAmount: 0.001, + receiveAmount: 0.001 + }; + const mockBtcPendingSendTransactionsStatusStore = ( + status: + | btcPendingSendTransactionsStatusStore.BtcPendingSentTransactionsStatus + | undefined = btcPendingSendTransactionsStatusStore.BtcPendingSentTransactionsStatus.NONE + ) => + vi + .spyOn(btcPendingSendTransactionsStatusStore, 'initPendingSentTransactionsStatus') + .mockImplementation(() => readable(status)); + + const buttonTestId = 'convert-form-button-next'; + const insufficientFundsForFeeTestId = 'btc-convert-form-insufficient-funds-for-fee'; + const btcSendWarningsTestId = 'btc-convert-form-send-warnings'; + + beforeEach(() => { + mockPage.reset(); + store = initUtxosFeeStore(); + store.reset(); + }); + + it('should keep the next button clickable if all requirements are met', () => { + store.setUtxosFee({ utxosFee: mockUtxosFee }); + mockBtcPendingSendTransactionsStatusStore(); + + const { getByTestId } = render(BtcConvertForm, { + props, + context: mockContext({ utxosFeeStore: store }) + }); + + expect(getByTestId(buttonTestId)).not.toHaveAttribute('disabled'); + }); + + it('should keep the next button disabled if amount is undefined', () => { + store.setUtxosFee({ utxosFee: mockUtxosFee }); + mockBtcPendingSendTransactionsStatusStore(); + + const { getByTestId } = render(BtcConvertForm, { + props: { + ...props, + sendAmount: undefined + }, + context: mockContext({ utxosFeeStore: store }) + }); + + expect(getByTestId(buttonTestId)).toHaveAttribute('disabled'); + }); + + it('should keep the next button disabled if amount is invalid', () => { + store.setUtxosFee({ utxosFee: mockUtxosFee }); + mockBtcPendingSendTransactionsStatusStore(); + + const { getByTestId } = render(BtcConvertForm, { + props: { + ...props, + sendAmount: -1 + }, + context: mockContext({ utxosFeeStore: store }) + }); + + expect(getByTestId(buttonTestId)).toHaveAttribute('disabled'); + }); + + it('should keep the next button disabled if utxos are undefined', () => { + mockBtcPendingSendTransactionsStatusStore(); + + const { getByTestId } = render(BtcConvertForm, { + props, + context: mockContext({ utxosFeeStore: store }) + }); + + expect(getByTestId(buttonTestId)).toHaveAttribute('disabled'); + }); + + it('should keep the next button disabled if utxos are not available', () => { + store.setUtxosFee({ utxosFee: { ...mockUtxosFee, utxos: [] } }); + mockBtcPendingSendTransactionsStatusStore(); + + const { getByTestId } = render(BtcConvertForm, { + props, + context: mockContext({ utxosFeeStore: store }) + }); + + expect(getByTestId(buttonTestId)).toHaveAttribute('disabled'); + }); + + it('should render insufficient funds for fee message', async () => { + vi.spyOn(convertUtils, 'validateConvertAmount').mockImplementation( + () => 'insufficient-funds-for-fee' + ); + + const { getByTestId } = render(BtcConvertForm, { + props, + context: mockContext({ utxosFeeStore: store, sourceTokenBalance: 0n }) + }); + + await waitFor(() => { + expect(getByTestId(insufficientFundsForFeeTestId)).toHaveTextContent( + en.convert.assertion.insufficient_funds_for_fee + ); + }); + }); + + it('should render btc send warning message', async () => { + mockBtcPendingSendTransactionsStatusStore( + btcPendingSendTransactionsStatusStore.BtcPendingSentTransactionsStatus.SOME + ); + + const { getByTestId } = render(BtcConvertForm, { + props, + context: mockContext({ utxosFeeStore: store }) + }); + + await waitFor(() => { + expect(getByTestId(btcSendWarningsTestId)).toHaveTextContent( + en.send.info.pending_bitcoin_transaction + ); + }); + }); +}); diff --git a/src/frontend/src/tests/btc/components/fee/UtxosFeeContext.spec.ts b/src/frontend/src/tests/btc/components/fee/UtxosFeeContext.spec.ts index e4b6cbe795..f79eb5e30b 100644 --- a/src/frontend/src/tests/btc/components/fee/UtxosFeeContext.spec.ts +++ b/src/frontend/src/tests/btc/components/fee/UtxosFeeContext.spec.ts @@ -14,7 +14,6 @@ import { mockPage } from '$tests/mocks/page.store.mock'; import type { Identity } from '@dfinity/agent'; import { render, waitFor } from '@testing-library/svelte'; import { readable } from 'svelte/store'; -import { expect } from 'vitest'; describe('UtxosFeeContext', () => { const amount = 10; diff --git a/src/frontend/src/tests/btc/derived/tokens.derived.spec.ts b/src/frontend/src/tests/btc/derived/tokens.derived.spec.ts index 26c1d03452..a5b4678d90 100644 --- a/src/frontend/src/tests/btc/derived/tokens.derived.spec.ts +++ b/src/frontend/src/tests/btc/derived/tokens.derived.spec.ts @@ -4,7 +4,6 @@ import { BTC_MAINNET_TOKEN, BTC_REGTEST_TOKEN, BTC_TESTNET_TOKEN } from '$env/to import * as appContants from '$lib/constants/app.constants'; import { testnetsStore } from '$lib/stores/settings.store'; import { get } from 'svelte/store'; -import { expect } from 'vitest'; describe('tokens.derived', () => { describe('enabledBitcoinTokens', () => { diff --git a/src/frontend/src/tests/btc/utils/btc-transactions.utils.spec.ts b/src/frontend/src/tests/btc/utils/btc-transactions.utils.spec.ts index 37b87d3eeb..7743d502ed 100644 --- a/src/frontend/src/tests/btc/utils/btc-transactions.utils.spec.ts +++ b/src/frontend/src/tests/btc/utils/btc-transactions.utils.spec.ts @@ -5,7 +5,8 @@ import { import type { BtcTransactionStatus } from '$btc/types/btc'; import { mapBtcTransaction, sortBtcTransactions } from '$btc/utils/btc-transactions.utils'; import type { BitcoinTransaction } from '$lib/types/blockchain'; -import { mockBtcAddress, mockBtcTransaction, mockBtcTransactionUi } from '$tests/mocks/btc.mock'; +import { mockBtcTransaction, mockBtcTransactionUi } from '$tests/mocks/btc-transactions.mock'; +import { mockBtcAddress } from '$tests/mocks/btc.mock'; describe('mapBtcTransaction', () => { const sendTransaction = { diff --git a/src/frontend/src/tests/eth/components/loaders/LoaderEthTransactions.spec.ts b/src/frontend/src/tests/eth/components/loaders/LoaderEthTransactions.spec.ts index 4d9e0cba40..4ef0b1d3dd 100644 --- a/src/frontend/src/tests/eth/components/loaders/LoaderEthTransactions.spec.ts +++ b/src/frontend/src/tests/eth/components/loaders/LoaderEthTransactions.spec.ts @@ -2,19 +2,21 @@ import { SEPOLIA_NETWORK_ID } from '$env/networks.env'; import { SEPOLIA_PEPE_TOKEN } from '$env/tokens-erc20/tokens.pepe.env'; import { ICP_TOKEN, SEPOLIA_TOKEN, SEPOLIA_TOKEN_ID } from '$env/tokens.env'; import LoaderEthTransactions from '$eth/components/loaders/LoaderEthTransactions.svelte'; -import { loadTransactions } from '$eth/services/transactions.services'; +import { loadEthereumTransactions } from '$eth/services/eth-transactions.services'; import { erc20UserTokensStore } from '$eth/stores/erc20-user-tokens.store'; import { token } from '$lib/stores/token.store'; import { mockPage } from '$tests/mocks/page.store.mock'; import { render, waitFor } from '@testing-library/svelte'; -import { vi, type MockedFunction } from 'vitest'; +import type { MockedFunction } from 'vitest'; -vi.mock('$eth/services/transactions.services', () => ({ - loadTransactions: vi.fn() +vi.mock('$eth/services/eth-transactions.services', () => ({ + loadEthereumTransactions: vi.fn() })); describe('LoaderEthTransactions', () => { - const mockLoadTransactions = loadTransactions as MockedFunction; + const mockLoadTransactions = loadEthereumTransactions as MockedFunction< + typeof loadEthereumTransactions + >; beforeEach(() => { vi.clearAllMocks(); @@ -33,7 +35,7 @@ describe('LoaderEthTransactions', () => { render(LoaderEthTransactions); await waitFor(() => { - expect(loadTransactions).not.toHaveBeenCalled(); + expect(loadEthereumTransactions).not.toHaveBeenCalled(); }); }); @@ -43,7 +45,7 @@ describe('LoaderEthTransactions', () => { mockPage.mock({ token: ICP_TOKEN.name }); await waitFor(() => { - expect(loadTransactions).not.toHaveBeenCalled(); + expect(loadEthereumTransactions).not.toHaveBeenCalled(); }); }); @@ -54,7 +56,7 @@ describe('LoaderEthTransactions', () => { token.set(ICP_TOKEN); await waitFor(() => { - expect(loadTransactions).not.toHaveBeenCalled(); + expect(loadEthereumTransactions).not.toHaveBeenCalled(); }); }); @@ -65,7 +67,7 @@ describe('LoaderEthTransactions', () => { token.set(SEPOLIA_TOKEN); await waitFor(() => { - expect(loadTransactions).toHaveBeenCalledWith({ + expect(loadEthereumTransactions).toHaveBeenCalledWith({ tokenId: SEPOLIA_TOKEN_ID, networkId: SEPOLIA_NETWORK_ID }); @@ -79,7 +81,7 @@ describe('LoaderEthTransactions', () => { token.set(SEPOLIA_PEPE_TOKEN); await waitFor(() => { - expect(loadTransactions).toHaveBeenCalledWith({ + expect(loadEthereumTransactions).toHaveBeenCalledWith({ tokenId: SEPOLIA_PEPE_TOKEN.id, networkId: SEPOLIA_PEPE_TOKEN.network.id }); @@ -96,8 +98,8 @@ describe('LoaderEthTransactions', () => { token.set(SEPOLIA_TOKEN); await waitFor(() => { - expect(loadTransactions).toHaveBeenCalledOnce(); - expect(loadTransactions).toHaveBeenCalledWith({ + expect(loadEthereumTransactions).toHaveBeenCalledOnce(); + expect(loadEthereumTransactions).toHaveBeenCalledWith({ tokenId: SEPOLIA_TOKEN_ID, networkId: SEPOLIA_NETWORK_ID }); @@ -111,7 +113,7 @@ describe('LoaderEthTransactions', () => { token.set(SEPOLIA_TOKEN); await waitFor(() => { - expect(loadTransactions).toHaveBeenCalledWith({ + expect(loadEthereumTransactions).toHaveBeenCalledWith({ tokenId: SEPOLIA_TOKEN_ID, networkId: SEPOLIA_NETWORK_ID }); @@ -121,13 +123,13 @@ describe('LoaderEthTransactions', () => { token.set(SEPOLIA_PEPE_TOKEN); await waitFor(() => { - expect(loadTransactions).toHaveBeenCalledWith({ + expect(loadEthereumTransactions).toHaveBeenCalledWith({ tokenId: SEPOLIA_PEPE_TOKEN.id, networkId: SEPOLIA_PEPE_TOKEN.network.id }); }); - expect(loadTransactions).toHaveBeenCalledTimes(2); + expect(loadEthereumTransactions).toHaveBeenCalledTimes(2); }); it('should not call the load function everytime the token changes but it was already loaded before', async () => { @@ -144,8 +146,8 @@ describe('LoaderEthTransactions', () => { }); await waitFor(() => { - expect(loadTransactions).not.toHaveBeenCalledTimes(n); - expect(loadTransactions).toHaveBeenCalledOnce(); + expect(loadEthereumTransactions).not.toHaveBeenCalledTimes(n); + expect(loadEthereumTransactions).toHaveBeenCalledOnce(); }); }); @@ -164,7 +166,7 @@ describe('LoaderEthTransactions', () => { token.set(SEPOLIA_PEPE_TOKEN); await waitFor(() => { - expect(loadTransactions).toHaveBeenCalledTimes(2); + expect(loadEthereumTransactions).toHaveBeenCalledTimes(2); }); }); @@ -175,7 +177,7 @@ describe('LoaderEthTransactions', () => { token.set(SEPOLIA_TOKEN); await waitFor(() => { - expect(loadTransactions).toHaveBeenCalledOnce(); + expect(loadEthereumTransactions).toHaveBeenCalledOnce(); }); render(LoaderEthTransactions); @@ -187,7 +189,7 @@ describe('LoaderEthTransactions', () => { token.set(SEPOLIA_TOKEN); await waitFor(() => { - expect(loadTransactions).toHaveBeenCalledTimes(2); + expect(loadEthereumTransactions).toHaveBeenCalledTimes(2); }); }); }); diff --git a/src/frontend/src/tests/eth/components/loaders/LoaderMultipleEthTransactions.spec.ts b/src/frontend/src/tests/eth/components/loaders/LoaderMultipleEthTransactions.spec.ts index 5ab8207a84..95005882c1 100644 --- a/src/frontend/src/tests/eth/components/loaders/LoaderMultipleEthTransactions.spec.ts +++ b/src/frontend/src/tests/eth/components/loaders/LoaderMultipleEthTransactions.spec.ts @@ -2,15 +2,15 @@ import { ETHEREUM_NETWORK_ID, SEPOLIA_NETWORK_ID } from '$env/networks.env'; import * as ethEnv from '$env/networks.eth.env'; import { ETHEREUM_TOKEN_ID, SEPOLIA_TOKEN_ID } from '$env/tokens.env'; import LoaderMultipleEthTransactions from '$eth/components/loaders/LoaderMultipleEthTransactions.svelte'; -import { loadTransactions } from '$eth/services/transactions.services'; +import { loadEthereumTransactions } from '$eth/services/eth-transactions.services'; import { erc20UserTokensStore } from '$eth/stores/erc20-user-tokens.store'; import * as appContants from '$lib/constants/app.constants'; import { testnetsStore } from '$lib/stores/settings.store'; import { createMockErc20UserTokens } from '$tests/mocks/erc20-tokens.mock'; import { render, waitFor } from '@testing-library/svelte'; -vi.mock('$eth/services/transactions.services', () => ({ - loadTransactions: vi.fn() +vi.mock('$eth/services/eth-transactions.services', () => ({ + loadEthereumTransactions: vi.fn() })); describe('LoaderMultipleEthTransactions', () => { @@ -34,7 +34,7 @@ describe('LoaderMultipleEthTransactions', () => { render(LoaderMultipleEthTransactions); await waitFor(() => { - expect(loadTransactions).not.toHaveBeenCalled(); + expect(loadEthereumTransactions).not.toHaveBeenCalled(); }); }); @@ -51,7 +51,7 @@ describe('LoaderMultipleEthTransactions', () => { await waitFor(() => { // mockErc20UserTokens.length + both native tokens (Ethereum and Sepolia) - expect(loadTransactions).toHaveBeenCalledTimes(mockErc20UserTokens.length + 2); + expect(loadEthereumTransactions).toHaveBeenCalledTimes(mockErc20UserTokens.length + 2); }); }); @@ -60,21 +60,21 @@ describe('LoaderMultipleEthTransactions', () => { await waitFor(() => { // mockErc20UserTokens.length + Ethereum native token - expect(loadTransactions).toHaveBeenCalledTimes(mockErc20UserTokens.length + 1); + expect(loadEthereumTransactions).toHaveBeenCalledTimes(mockErc20UserTokens.length + 1); }); await new Promise((resolve) => setTimeout(resolve, 3000)); // same number of calls as before - expect(loadTransactions).toHaveBeenCalledTimes(mockErc20UserTokens.length + 1); + expect(loadEthereumTransactions).toHaveBeenCalledTimes(mockErc20UserTokens.length + 1); }); it('should not load transactions for native Sepolia token when testnets flag is disabled', async () => { render(LoaderMultipleEthTransactions); await waitFor(() => { - expect(loadTransactions).toHaveBeenCalledTimes(mockErc20UserTokens.length + 1); - expect(loadTransactions).not.toHaveBeenCalledWith({ + expect(loadEthereumTransactions).toHaveBeenCalledTimes(mockErc20UserTokens.length + 1); + expect(loadEthereumTransactions).not.toHaveBeenCalledWith({ networkId: SEPOLIA_NETWORK_ID, tokenId: SEPOLIA_TOKEN_ID }); @@ -88,8 +88,8 @@ describe('LoaderMultipleEthTransactions', () => { render(LoaderMultipleEthTransactions); await waitFor(() => { - expect(loadTransactions).toHaveBeenCalledTimes(mockErc20UserTokens.length + 1); - expect(loadTransactions).not.toHaveBeenCalledWith({ + expect(loadEthereumTransactions).toHaveBeenCalledTimes(mockErc20UserTokens.length + 1); + expect(loadEthereumTransactions).not.toHaveBeenCalledWith({ networkId: ETHEREUM_NETWORK_ID, tokenId: ETHEREUM_TOKEN_ID }); @@ -107,7 +107,7 @@ describe('LoaderMultipleEthTransactions', () => { await waitFor(() => { // mockErc20UserTokens.length + Ethereum native token - expect(loadTransactions).toHaveBeenCalledTimes(mockErc20UserTokens.length + 1); + expect(loadEthereumTransactions).toHaveBeenCalledTimes(mockErc20UserTokens.length + 1); }); erc20UserTokensStore.resetAll(); @@ -115,10 +115,10 @@ describe('LoaderMultipleEthTransactions', () => { await waitFor(() => { // the number of calls as before + mockAdditionalTokens.length - expect(loadTransactions).toHaveBeenCalledTimes( + expect(loadEthereumTransactions).toHaveBeenCalledTimes( mockErc20UserTokens.length + 1 + mockAdditionalTokens.length ); - expect(loadTransactions).not.toHaveBeenCalledTimes( + expect(loadEthereumTransactions).not.toHaveBeenCalledTimes( 2 * (mockErc20UserTokens.length + 1) + mockAdditionalTokens.length ); }); @@ -135,7 +135,7 @@ describe('LoaderMultipleEthTransactions', () => { await waitFor(() => { // mockErc20UserTokens.length + Ethereum native token - expect(loadTransactions).toHaveBeenCalledTimes(mockErc20UserTokens.length + 1); + expect(loadEthereumTransactions).toHaveBeenCalledTimes(mockErc20UserTokens.length + 1); }); erc20UserTokensStore.resetAll(); @@ -143,7 +143,7 @@ describe('LoaderMultipleEthTransactions', () => { await waitFor(() => { // the number of calls as before + mockAdditionalTokens.length - expect(loadTransactions).toHaveBeenCalledTimes( + expect(loadEthereumTransactions).toHaveBeenCalledTimes( mockErc20UserTokens.length + 1 + mockAdditionalTokens.length ); }); @@ -152,7 +152,7 @@ describe('LoaderMultipleEthTransactions', () => { await waitFor(() => { // the number of calls of the first render + the number of additional tokens + Sepolia native token - expect(loadTransactions).toHaveBeenCalledTimes( + expect(loadEthereumTransactions).toHaveBeenCalledTimes( mockErc20UserTokens.length + 1 + mockAdditionalTokens.length + 1 ); }); diff --git a/src/frontend/src/tests/eth/derived/tokens.derived.spec.ts b/src/frontend/src/tests/eth/derived/tokens.derived.spec.ts index d73cff6309..d81aeef338 100644 --- a/src/frontend/src/tests/eth/derived/tokens.derived.spec.ts +++ b/src/frontend/src/tests/eth/derived/tokens.derived.spec.ts @@ -4,7 +4,6 @@ import { enabledEthereumTokens } from '$eth/derived/tokens.derived'; import * as appContants from '$lib/constants/app.constants'; import { testnetsStore } from '$lib/stores/settings.store'; import { get } from 'svelte/store'; -import { expect } from 'vitest'; describe('tokens.derived', () => { describe('enabledEthereumTokens', () => { diff --git a/src/frontend/src/tests/eth/providers/etherscan.providers.spec.ts b/src/frontend/src/tests/eth/providers/etherscan.providers.spec.ts new file mode 100644 index 0000000000..2b2c1941a4 --- /dev/null +++ b/src/frontend/src/tests/eth/providers/etherscan.providers.spec.ts @@ -0,0 +1,71 @@ +import { ETHEREUM_NETWORK_ID, ICP_NETWORK_ID, SEPOLIA_NETWORK_ID } from '$env/networks.env'; +import { ETHERSCAN_NETWORK_HOMESTEAD } from '$env/networks.eth.env'; +import { EtherscanProvider, etherscanProviders } from '$eth/providers/etherscan.providers'; +import { replacePlaceholders } from '$lib/utils/i18n.utils'; +import { mockEthAddress } from '$tests/mocks/eth.mocks'; +import en from '$tests/mocks/i18n.mock'; +import { EtherscanProvider as EtherscanProviderLib } from '@ethersproject/providers'; +import type { MockedClass } from 'vitest'; + +vi.mock('@ethersproject/providers', () => { + const provider = vi.fn(); + provider.prototype.getHistory = vi.fn().mockResolvedValue([]); + return { EtherscanProvider: provider }; +}); + +vi.mock('$env/rest/etherscan.env', () => ({ + ETHERSCAN_API_KEY: 'test-api-key' +})); + +describe('etherscan.providers', () => { + describe('EtherscanProvider', () => { + const network = ETHERSCAN_NETWORK_HOMESTEAD; + const address = mockEthAddress; + const ETHERSCAN_API_KEY = 'test-api-key'; + + const mockGetHistory = vi.fn().mockResolvedValue([]); + const mockProvider = EtherscanProviderLib as MockedClass; + mockProvider.prototype.getHistory = mockGetHistory; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should initialise the provider with the correct network and API key', () => { + const provider = new EtherscanProvider(network); + + expect(provider).toBeDefined(); + expect(EtherscanProviderLib).toHaveBeenCalledWith(network, ETHERSCAN_API_KEY); + }); + + it('should call getHistory with correct parameters', async () => { + mockGetHistory.mockResolvedValueOnce([]); + + const provider = new EtherscanProvider(network); + + const result = await provider.transactions({ address }); + + expect(provider).toBeDefined(); + expect(mockGetHistory).toHaveBeenCalledWith(address, undefined); + expect(result).toStrictEqual([]); + }); + }); + + describe('etherscanProviders', () => { + it('should return the correct provider for Ethereum network', () => { + expect(etherscanProviders(ETHEREUM_NETWORK_ID)).toBeInstanceOf(EtherscanProvider); + }); + + it('should return the correct provider for Sepolia network', () => { + expect(etherscanProviders(SEPOLIA_NETWORK_ID)).toBeInstanceOf(EtherscanProvider); + }); + + it('should throw an error for an unsupported network ID', () => { + expect(() => etherscanProviders(ICP_NETWORK_ID)).toThrowError( + replacePlaceholders(en.init.error.no_etherscan_provider, { + $network: ICP_NETWORK_ID.toString() + }) + ); + }); + }); +}); diff --git a/src/frontend/src/tests/eth/rest/etherscan.rest.spec.ts b/src/frontend/src/tests/eth/rest/etherscan.rest.spec.ts new file mode 100644 index 0000000000..681ad2bd99 --- /dev/null +++ b/src/frontend/src/tests/eth/rest/etherscan.rest.spec.ts @@ -0,0 +1,136 @@ +import { ETHEREUM_NETWORK_ID, ICP_NETWORK_ID, SEPOLIA_NETWORK_ID } from '$env/networks.env'; +import { ETHERSCAN_API_URL_HOMESTEAD } from '$env/networks.eth.env'; +import { EtherscanRest, etherscanRests } from '$eth/rest/etherscan.rest'; +import { replacePlaceholders } from '$lib/utils/i18n.utils'; +import { mockValidErc20Token } from '$tests/mocks/erc20-tokens.mock'; +import { mockEthAddress } from '$tests/mocks/eth.mocks'; +import en from '$tests/mocks/i18n.mock'; +import { BigNumber } from 'ethers'; +import type { MockedFunction } from 'vitest'; + +global.fetch = vi.fn(); + +vi.mock('$env/rest/etherscan.env', () => ({ + ETHERSCAN_API_KEY: 'test-api-key' +})); + +describe('etherscan.rest', () => { + describe('EtherscanRest', () => { + const API_URL = ETHERSCAN_API_URL_HOMESTEAD; + + const mockApiResponse = { + status: '1', + message: 'OK', + result: [ + { + nonce: '1', + gas: '21000', + gasPrice: '20000000000', + hash: '0x123abc', + blockNumber: '123456', + blockHash: '0x456def', + timeStamp: '1697049600', + confirmations: '10', + from: '0xabc...', + to: '0xdef...', + value: '1000000000000000000' + } + ] + }; + + const mockEtherscanErrorResponse = { + status: '0', + message: 'NOTOK', + result: 'Invalid API Key' + }; + + it('should fetch and map transactions correctly', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: () => mockApiResponse + } as unknown as Response); + + const mockFetch = fetch as MockedFunction; + + const etherscanRest = new EtherscanRest(API_URL); + + const result = await etherscanRest.transactions({ + address: mockEthAddress, + contract: mockValidErc20Token + }); + + const urlString = mockFetch.mock.calls[0][0].toString(); + + expect(fetch).toHaveBeenCalledOnce(); + expect(urlString).toBe( + `${API_URL}?module=account&action=tokentx&contractaddress=${mockValidErc20Token.address}&address=${mockEthAddress}&startblock=0&endblock=99999999&sort=desc&apikey=test-api-key` + ); + + expect(result).toEqual([ + { + hash: '0x123abc', + blockNumber: 123456, + blockHash: '0x456def', + timestamp: 1697049600, + confirmations: '10', + from: '0xabc...', + to: '0xdef...', + nonce: 1, + gasLimit: BigNumber.from('21000'), + gasPrice: BigNumber.from('20000000000'), + value: BigNumber.from('1000000000000000000'), + chainId: 0 + } + ]); + }); + + it('should throw an error if the API response status is not OK', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: () => mockEtherscanErrorResponse + } as unknown as Response); + + const etherscanRest = new EtherscanRest(API_URL); + + await expect( + etherscanRest.transactions({ + address: mockEthAddress, + contract: mockValidErc20Token + }) + ).rejects.toThrow(mockEtherscanErrorResponse.result); + }); + + it('should throw an error if the API call fails', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false + } as unknown as Response); + + const etherscanRest = new EtherscanRest(API_URL); + + await expect( + etherscanRest.transactions({ + address: mockEthAddress, + contract: mockValidErc20Token + }) + ).rejects.toThrow('Fetching transactions with Etherscan API failed.'); + }); + }); + + describe('etherscanRests', () => { + it('should return the correct provider for Ethereum network', () => { + expect(etherscanRests(ETHEREUM_NETWORK_ID)).toBeInstanceOf(EtherscanRest); + }); + + it('should return the correct provider for Sepolia network', () => { + expect(etherscanRests(SEPOLIA_NETWORK_ID)).toBeInstanceOf(EtherscanRest); + }); + + it('should throw an error for an unsupported network ID', () => { + expect(() => etherscanRests(ICP_NETWORK_ID)).toThrowError( + replacePlaceholders(en.init.error.no_etherscan_rest_api, { + $network: ICP_NETWORK_ID.toString() + }) + ); + }); + }); +}); diff --git a/src/frontend/src/tests/icp-eth/services/custom-token.services.spec.ts b/src/frontend/src/tests/icp-eth/services/custom-token.services.spec.ts index d7611d642b..1e420f2370 100644 --- a/src/frontend/src/tests/icp-eth/services/custom-token.services.spec.ts +++ b/src/frontend/src/tests/icp-eth/services/custom-token.services.spec.ts @@ -20,7 +20,7 @@ import { IcrcLedgerCanister } from '@dfinity/ledger-icrc'; import { Principal } from '@dfinity/principal'; import { isNullish, toNullable } from '@dfinity/utils'; import { get } from 'svelte/store'; -import { expect, type MockInstance } from 'vitest'; +import type { MockInstance } from 'vitest'; import { mock } from 'vitest-mock-extended'; describe('custom-token.services', () => { diff --git a/src/frontend/src/tests/icp/components/transactions/IcTransactions.spec.ts b/src/frontend/src/tests/icp/components/transactions/IcTransactions.spec.ts index 57a17635e6..910be2376e 100644 --- a/src/frontend/src/tests/icp/components/transactions/IcTransactions.spec.ts +++ b/src/frontend/src/tests/icp/components/transactions/IcTransactions.spec.ts @@ -4,7 +4,6 @@ import { icTransactionsStore } from '$icp/stores/ic-transactions.store'; import { token } from '$lib/stores/token.store'; import { mockPage } from '$tests/mocks/page.store.mock'; import { render } from '@testing-library/svelte'; -import { vi } from 'vitest'; describe('IcTransactions', () => { beforeEach(() => { diff --git a/src/frontend/src/tests/icp/services/icrc.services.spec.ts b/src/frontend/src/tests/icp/services/icrc.services.spec.ts index a2ff1e46fc..801200e632 100644 --- a/src/frontend/src/tests/icp/services/icrc.services.spec.ts +++ b/src/frontend/src/tests/icp/services/icrc.services.spec.ts @@ -14,7 +14,7 @@ import { IcrcLedgerCanister } from '@dfinity/ledger-icrc'; import { Principal } from '@dfinity/principal'; import { fromNullable, nonNullish } from '@dfinity/utils'; import { get } from 'svelte/store'; -import { expect, type MockInstance } from 'vitest'; +import { type MockInstance } from 'vitest'; import { mock } from 'vitest-mock-extended'; describe('icrc.services', () => { diff --git a/src/frontend/src/tests/icp/utils/cketh-transactions.utils.spec.ts b/src/frontend/src/tests/icp/utils/cketh-transactions.utils.spec.ts index 4d24506256..e03c627479 100644 --- a/src/frontend/src/tests/icp/utils/cketh-transactions.utils.spec.ts +++ b/src/frontend/src/tests/icp/utils/cketh-transactions.utils.spec.ts @@ -6,7 +6,6 @@ import { import type { IcrcTransaction } from '$icp/types/ic-transaction'; import { mapCkEthereumTransaction } from '$icp/utils/cketh-transactions.utils'; import { Principal } from '@dfinity/principal'; -import { describe, expect, it } from 'vitest'; describe('mapCkEthereumTransaction', () => { const mockTransaction: IcrcTransaction = { diff --git a/src/frontend/src/tests/icp/utils/map-icrc-data.spec.ts b/src/frontend/src/tests/icp/utils/map-icrc-data.spec.ts new file mode 100644 index 0000000000..f994418ef7 --- /dev/null +++ b/src/frontend/src/tests/icp/utils/map-icrc-data.spec.ts @@ -0,0 +1,48 @@ +import { mapIcrcData } from '$icp/utils/map-icrc-data'; +import * as appConstants from '$lib/constants/app.constants'; + +describe('mapIcrcData', () => { + const token = { + TESTTOKEN: { + ledgerCanisterId: 'dummy-ledger-id', + indexCanisterId: 'dummy-canister-id' + } + }; + + const expected = { + TESTTOKEN: { ...token.TESTTOKEN, exchangeCoinId: 'internet-computer' } + }; + + const envs = [ + { env: 'PROD', expected }, + { env: 'BETA', expected }, + { env: 'STAGING', expected }, + { env: 'LOCAL', expected: {} } + ]; + + describe.each(envs)('when %s is true', ({ env, expected }) => { + beforeEach(() => { + vi.resetAllMocks(); + + envs.forEach(({ env: e }) => { + vi.spyOn(appConstants, e as keyof typeof appConstants, 'get').mockImplementation( + () => false + ); + }); + + vi.spyOn(appConstants, env as keyof typeof appConstants, 'get').mockImplementation( + () => true + ); + }); + + it('should map ICRC tokens correctly', () => { + const result = mapIcrcData(token); + expect(result).toEqual(expected); + }); + }); + + it('should handle empty input', () => { + const result = mapIcrcData({}); + expect(result).toEqual({}); + }); +}); diff --git a/src/frontend/src/tests/lib/components/convert/ConvertAmountDestination.spec.ts b/src/frontend/src/tests/lib/components/convert/ConvertAmountDestination.spec.ts new file mode 100644 index 0000000000..820b4301d6 --- /dev/null +++ b/src/frontend/src/tests/lib/components/convert/ConvertAmountDestination.spec.ts @@ -0,0 +1,92 @@ +import { BTC_MAINNET_TOKEN } from '$env/tokens.btc.env'; +import ConvertAmountDestination from '$lib/components/convert/ConvertAmountDestination.svelte'; +import { CONVERT_CONTEXT_KEY } from '$lib/stores/convert.store'; +import { render } from '@testing-library/svelte'; +import { BigNumber } from 'alchemy-sdk'; +import { readable } from 'svelte/store'; + +describe('ConvertAmountDestination', () => { + const sendAmount = 20; + const totalFee = 10000n; + const receiveAmount = 19.9999; + const exchangeRate = 0.01; + const balance = BigNumber.from(10000n); + const insufficientFunds = false; + + const props = { + sendAmount, + insufficientFunds, + totalFee + }; + + const mockContext = new Map([ + [ + CONVERT_CONTEXT_KEY, + { + destinationToken: readable(BTC_MAINNET_TOKEN), + destinationTokenBalance: readable(balance), + destinationTokenExchangeRate: readable(exchangeRate) + } + ] + ]); + + const amountInfoTestId = 'convert-amount-destination-amount-info'; + const balanceTestId = 'convert-amount-destination-balance'; + + it('should display values correctly', () => { + const { getByTestId } = render(ConvertAmountDestination, { + props, + context: mockContext + }); + + expect(getByTestId(amountInfoTestId)).toHaveTextContent('$0.20'); + expect(getByTestId(balanceTestId)).toHaveTextContent('0.0001 BTC'); + }); + + it('should calculate receiveAmount correctly', () => { + const { component } = render(ConvertAmountDestination, { + props, + context: mockContext + }); + + expect(component.$$.ctx[component.$$.props['receiveAmount']]).toBe(receiveAmount); + }); + + it('should calculate receiveAmount correctly if sendAmount is not provided', () => { + const { sendAmount: _, ...newProps } = props; + const { component } = render(ConvertAmountDestination, { + props: newProps, + context: mockContext + }); + + expect(component.$$.ctx[component.$$.props['receiveAmount']]).toBeUndefined(); + }); + + it('should calculate receiveAmount correctly if totalFee is not provided', () => { + const { totalFee: _, ...newProps } = props; + const { component } = render(ConvertAmountDestination, { + props: newProps, + context: mockContext + }); + + expect(component.$$.ctx[component.$$.props['receiveAmount']]).toBeUndefined(); + }); + + it('should calculate receiveAmount correctly if insufficientFunds is true', () => { + const { component } = render(ConvertAmountDestination, { + props: { ...props, insufficientFunds: true }, + context: mockContext + }); + + expect(component.$$.ctx[component.$$.props['receiveAmount']]).toBeUndefined(); + }); + + it('should calculate receiveAmount correctly if parsedSendAmountAfterFee is less than zero', () => { + const { component } = render(ConvertAmountDestination, { + props: { ...props, sendAmount: 0.000001 }, + context: mockContext + }); + + expect(component.$$.ctx[component.$$.props['receiveAmount']]).toBe(0); + }); +}); diff --git a/src/frontend/src/tests/lib/components/convert/ConvertAmountSource.spec.ts b/src/frontend/src/tests/lib/components/convert/ConvertAmountSource.spec.ts new file mode 100644 index 0000000000..28d481221f --- /dev/null +++ b/src/frontend/src/tests/lib/components/convert/ConvertAmountSource.spec.ts @@ -0,0 +1,103 @@ +import { BTC_MAINNET_TOKEN } from '$env/tokens.btc.env'; +import ConvertAmountSource from '$lib/components/convert/ConvertAmountSource.svelte'; +import { ZERO } from '$lib/constants/app.constants'; +import { CONVERT_CONTEXT_KEY } from '$lib/stores/convert.store'; +import en from '$tests/mocks/i18n.mock'; +import { fireEvent, render } from '@testing-library/svelte'; +import { BigNumber } from 'alchemy-sdk'; +import { readable } from 'svelte/store'; + +describe('ConvertAmountSource', () => { + const sendAmount = 20; + const totalFee = 10000n; + const exchangeRate = 0.01; + const defaultBalance = BigNumber.from(5000000n); + const insufficientFunds = false; + const insufficientFundsForFee = false; + + const props = { + sendAmount, + insufficientFunds, + insufficientFundsForFee, + totalFee + }; + + const mockContext = (balance: BigNumber = defaultBalance) => + new Map([ + [ + CONVERT_CONTEXT_KEY, + { + sourceToken: readable(BTC_MAINNET_TOKEN), + sourceTokenBalance: readable(balance), + sourceTokenExchangeRate: readable(exchangeRate) + } + ] + ]); + + const amountInfoTestId = 'convert-amount-source-amount-info'; + const balanceTestId = 'convert-amount-source-balance'; + + it('should display values correctly without error if insufficientFundsForFee is false', () => { + const { getByTestId } = render(ConvertAmountSource, { + props, + context: mockContext() + }); + + expect(getByTestId(amountInfoTestId)).toHaveTextContent('$0.20'); + expect(getByTestId(balanceTestId)).toHaveTextContent('0.05 BTC'); + }); + + it('should display values correctly without error if insufficientFundsForFee is true', async () => { + const { getByTestId, rerender } = render(ConvertAmountSource, { + props, + context: mockContext() + }); + + await rerender({ + ...props, + insufficientFundsForFee: true + }); + + expect(getByTestId(amountInfoTestId)).toHaveTextContent('$0.20'); + expect(getByTestId(balanceTestId)).toHaveTextContent('0.05 BTC'); + }); + + it('should display values correctly with error', async () => { + const { getByTestId, rerender } = render(ConvertAmountSource, { + props, + context: mockContext() + }); + + await rerender({ + ...props, + insufficientFunds: true + }); + + expect(getByTestId(amountInfoTestId)).toHaveTextContent( + en.convert.assertion.insufficient_funds + ); + expect(getByTestId(balanceTestId)).toHaveTextContent('0.05 BTC'); + }); + + it('should set sendAmount correctly on max button click', async () => { + const { getByTestId, component } = render(ConvertAmountSource, { + props, + context: mockContext() + }); + + await fireEvent.click(getByTestId(balanceTestId)); + + expect(component.$$.ctx[component.$$.props['sendAmount']]).toBe(0.05); + }); + + it('should not change sendAmount on max button click if balance is zero', async () => { + const { getByTestId, component } = render(ConvertAmountSource, { + props, + context: mockContext(ZERO) + }); + + await fireEvent.click(getByTestId(balanceTestId)); + + expect(component.$$.ctx[component.$$.props['sendAmount']]).toBe(props.sendAmount); + }); +}); diff --git a/src/frontend/src/tests/lib/components/networks/NetworkLogo.spec.ts b/src/frontend/src/tests/lib/components/networks/NetworkLogo.spec.ts new file mode 100644 index 0000000000..62faef1a5d --- /dev/null +++ b/src/frontend/src/tests/lib/components/networks/NetworkLogo.spec.ts @@ -0,0 +1,52 @@ +import { ICP_NETWORK } from '$env/networks.env'; +import NetworkLogo from '$lib/components/networks/NetworkLogo.svelte'; +import { replacePlaceholders } from '$lib/utils/i18n.utils'; +import en from '$tests/mocks/i18n.mock'; +import { render } from '@testing-library/svelte'; + +describe('NetworkLogo', () => { + const mockNetwork = ICP_NETWORK; + const testId = 'test-logo'; + const altText = replacePlaceholders(en.core.alt.logo, { $name: mockNetwork.name }); + + it('should apply testId as a data attribute', () => { + const { getByTestId } = render(NetworkLogo, { + props: { + network: mockNetwork, + testId + } + }); + + const logo = getByTestId(testId); + expect(logo).toBeTruthy(); + }); + + it('should render the Logo component with correct props', () => { + const { getByAltText } = render(NetworkLogo, { + props: { + network: mockNetwork, + testId + } + }); + + const logo = getByAltText(altText); + + expect(logo).toBeInTheDocument(); + expect(logo).toHaveAttribute('src', ICP_NETWORK.icon); + }); + + it('should render black and white icon when blackAndWhite is true', () => { + const { getByAltText } = render(NetworkLogo, { + props: { + network: mockNetwork, + blackAndWhite: true, + testId + } + }); + + const logo = getByAltText(altText); + + expect(logo).toBeInTheDocument(); + expect(logo).toHaveAttribute('src', ICP_NETWORK.iconBW); + }); +}); diff --git a/src/frontend/src/tests/lib/components/transactions/AllTransactionsList.spec.ts b/src/frontend/src/tests/lib/components/transactions/AllTransactionsList.spec.ts index 04fb04de17..6514364f5d 100644 --- a/src/frontend/src/tests/lib/components/transactions/AllTransactionsList.spec.ts +++ b/src/frontend/src/tests/lib/components/transactions/AllTransactionsList.spec.ts @@ -4,13 +4,15 @@ import * as networkEnv from '$env/networks.env'; import { ETHEREUM_NETWORK_ID, SEPOLIA_NETWORK_ID } from '$env/networks.env'; import * as ethEnv from '$env/networks.eth.env'; import { BTC_MAINNET_TOKEN_ID } from '$env/tokens.btc.env'; -import { ETHEREUM_TOKEN_ID } from '$env/tokens.env'; +import { ETHEREUM_TOKEN_ID, ICP_TOKEN_ID } from '$env/tokens.env'; import { ethTransactionsStore } from '$eth/stores/eth-transactions.store'; +import { icTransactionsStore } from '$icp/stores/ic-transactions.store'; import AllTransactionsList from '$lib/components/transactions/AllTransactionsList.svelte'; import * as transactionsUtils from '$lib/utils/transactions.utils'; -import { createMockBtcTransactionsUi } from '$tests/mocks/btc.mock'; +import { createMockBtcTransactionsUi } from '$tests/mocks/btc-transactions.mock'; import { createMockEthTransactions } from '$tests/mocks/eth-transactions.mock'; import en from '$tests/mocks/i18n.mock'; +import { createMockIcTransactionsUi } from '$tests/mocks/ic-transactions.mock'; import { render } from '@testing-library/svelte'; describe('AllTransactionsList', () => { @@ -35,8 +37,12 @@ describe('AllTransactionsList', () => { describe('when the transactions list is not empty', () => { const btcTransactionsNumber = 5; const ethTransactionsNumber = 3; + const icTransactionsNumber = 7; - beforeEach(() => { + const todayTimestamp = new Date().getTime(); + const yesterdayTimestamp = todayTimestamp - 24 * 60 * 60 * 1000; + + beforeAll(() => { vi.resetAllMocks(); vi.spyOn(btcEnv, 'BTC_MAINNET_ENABLED', 'get').mockImplementation(() => true); @@ -46,19 +52,30 @@ describe('AllTransactionsList', () => { ETHEREUM_NETWORK_ID, SEPOLIA_NETWORK_ID ]); - }); - - btcTransactionsStore.append({ - tokenId: BTC_MAINNET_TOKEN_ID, - transactions: createMockBtcTransactionsUi(btcTransactionsNumber).map((transaction) => ({ - data: transaction, - certified: false - })) - }); - ethTransactionsStore.add({ - tokenId: ETHEREUM_TOKEN_ID, - transactions: createMockEthTransactions(ethTransactionsNumber) + btcTransactionsStore.append({ + tokenId: BTC_MAINNET_TOKEN_ID, + transactions: createMockBtcTransactionsUi(btcTransactionsNumber).map((transaction) => ({ + data: { ...transaction, timestamp: BigInt(todayTimestamp) }, + certified: false + })) + }); + + ethTransactionsStore.add({ + tokenId: ETHEREUM_TOKEN_ID, + transactions: createMockEthTransactions(ethTransactionsNumber).map((transaction) => ({ + ...transaction, + timestamp: yesterdayTimestamp + })) + }); + + icTransactionsStore.append({ + tokenId: ICP_TOKEN_ID, + transactions: createMockIcTransactionsUi(icTransactionsNumber).map((transaction) => ({ + data: { ...transaction, timestamp: BigInt(todayTimestamp) }, + certified: false + })) + }); }); it('should not render the placeholder', () => { @@ -67,14 +84,29 @@ describe('AllTransactionsList', () => { expect(queryByText(en.transactions.text.transaction_history)).not.toBeInTheDocument(); }); - it('should render the transactions list', () => { - const { container } = render(AllTransactionsList); + it('should render the transactions list with group of dates', () => { + const { container, getByText } = render(AllTransactionsList); const transactionComponents = Array.from(container.querySelectorAll('div')).filter( (el) => el.parentElement === container ); - expect(transactionComponents).toHaveLength(btcTransactionsNumber + ethTransactionsNumber); + // today and yesterday + expect(transactionComponents).toHaveLength(2); + expect(getByText('today')).toBeInTheDocument(); + expect(getByText('yesterday')).toBeInTheDocument(); + }); + + it('should render the transactions list with all the transactions', () => { + const { container } = render(AllTransactionsList); + + const transactionComponents = Array.from(container.querySelectorAll('div')).filter( + (el) => el.parentElement?.parentElement === container + ); + + expect(transactionComponents).toHaveLength( + btcTransactionsNumber + ethTransactionsNumber + icTransactionsNumber + ); }); }); }); diff --git a/src/frontend/src/tests/lib/components/transactions/TransactionsDateGroup.spec.ts b/src/frontend/src/tests/lib/components/transactions/TransactionsDateGroup.spec.ts new file mode 100644 index 0000000000..1f71160649 --- /dev/null +++ b/src/frontend/src/tests/lib/components/transactions/TransactionsDateGroup.spec.ts @@ -0,0 +1,67 @@ +import BtcTransaction from '$btc/components/transactions/BtcTransaction.svelte'; +import { BTC_MAINNET_TOKEN } from '$env/tokens.btc.env'; +import { ETHEREUM_TOKEN } from '$env/tokens.env'; +import EthTransaction from '$eth/components/transactions/EthTransaction.svelte'; +import TransactionsDateGroup from '$lib/components/transactions/TransactionsDateGroup.svelte'; +import type { AllTransactionUi, AllTransactionUiNonEmptyList } from '$lib/types/transaction'; +import { createMockBtcTransactionsUi } from '$tests/mocks/btc-transactions.mock'; +import { createMockEthTransactions } from '$tests/mocks/eth-transactions.mock'; +import { render } from '@testing-library/svelte'; + +describe('TransactionsDateGroup', () => { + const btcTransactionsNumber = 5; + const ethTransactionsNumber = 3; + + const todayTimestamp = new Date().getTime(); + + const mockBtcTransactionsUi: AllTransactionUi[] = createMockBtcTransactionsUi( + btcTransactionsNumber + ).map((transaction) => ({ + ...transaction, + timestamp: BigInt(todayTimestamp), + token: BTC_MAINNET_TOKEN, + component: BtcTransaction + })); + + const mockEthTransactionsUi: AllTransactionUi[] = createMockEthTransactions( + ethTransactionsNumber + ).map((transaction) => ({ + ...transaction, + timestamp: todayTimestamp, + id: transaction.hash, + uiType: 'send', + token: ETHEREUM_TOKEN, + component: EthTransaction + })); + + const mockTransactions = [ + ...mockBtcTransactionsUi, + ...mockEthTransactionsUi + ] as AllTransactionUiNonEmptyList; + + it('should render the date', () => { + const { getByText } = render(TransactionsDateGroup, { + props: { + date: 'today', + transactions: mockTransactions + } + }); + + expect(getByText('today')).toBeInTheDocument(); + }); + + it('should render the transactions list', () => { + const { container } = render(TransactionsDateGroup, { + props: { + date: 'today', + transactions: mockTransactions + } + }); + + const transactionComponents = Array.from(container.querySelectorAll('div')).filter( + (el) => el.parentElement?.parentElement === container + ); + + expect(transactionComponents).toHaveLength(btcTransactionsNumber + ethTransactionsNumber); + }); +}); diff --git a/src/frontend/src/tests/lib/derived/exchange.derived.spec.ts b/src/frontend/src/tests/lib/derived/exchange.derived.spec.ts index 8bb15e154a..338abdcee4 100644 --- a/src/frontend/src/tests/lib/derived/exchange.derived.spec.ts +++ b/src/frontend/src/tests/lib/derived/exchange.derived.spec.ts @@ -2,7 +2,6 @@ import * as exchangeEnv from '$env/exchange.env'; import { exchangeInitialized } from '$lib/derived/exchange.derived'; import { exchangeStore } from '$lib/stores/exchange.store'; import { get } from 'svelte/store'; -import { expect } from 'vitest'; describe('exchange.derived', () => { describe('exchangeInitialized', () => { diff --git a/src/frontend/src/tests/lib/derived/tokens.derived.spec.ts b/src/frontend/src/tests/lib/derived/tokens.derived.spec.ts index 8380f3f7a0..9714d074da 100644 --- a/src/frontend/src/tests/lib/derived/tokens.derived.spec.ts +++ b/src/frontend/src/tests/lib/derived/tokens.derived.spec.ts @@ -17,7 +17,6 @@ import { parseTokenId } from '$lib/validation/token.validation'; import { mockValidErc20Token } from '$tests/mocks/erc20-tokens.mock'; import { mockValidIcToken } from '$tests/mocks/ic-tokens.mock'; import { get } from 'svelte/store'; -import { expect } from 'vitest'; describe('tokens.derived', () => { const mockErc20DefaultToken: Erc20Token = { diff --git a/src/frontend/src/tests/lib/services/rest.services.spec.ts b/src/frontend/src/tests/lib/services/rest.services.spec.ts new file mode 100644 index 0000000000..b8ff0c0773 --- /dev/null +++ b/src/frontend/src/tests/lib/services/rest.services.spec.ts @@ -0,0 +1,167 @@ +import { restRequest } from '$lib/services/rest.services'; +import { expect } from 'vitest'; + +describe('rest.services', () => { + describe('restRequest', () => { + const mockSuccessfulRequest = vi.fn().mockResolvedValue('success'); + const mockFailedRequest = vi.fn().mockRejectedValue(new Error('Failed')); + + const mockOnSuccess = vi.fn(); + const mockOnError = vi.fn(); + const mockOnRetry = vi.fn(); + + // we mock console.error and console.warn just to avoid unnecessary logs while running the tests + vi.spyOn(console, 'error').mockImplementation(() => undefined); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should call onSuccess when the request succeeds on the first try', async () => { + await restRequest({ + request: mockSuccessfulRequest, + onSuccess: mockOnSuccess + }); + + expect(mockSuccessfulRequest).toHaveBeenCalledTimes(1); + expect(mockOnSuccess).toHaveBeenCalledWith('success'); + }); + + it('should retry up to maxRetries and then call onError', async () => { + const maxRetries = 3; + + await restRequest({ + request: mockFailedRequest, + onSuccess: mockOnSuccess, + onError: mockOnError, + maxRetries + }); + + expect(mockFailedRequest).toHaveBeenCalledTimes(maxRetries + 1); + expect(mockOnError).toHaveBeenCalledWith(new Error('Failed')); + + expect(console.error).toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith( + 'Max retries reached. Error:', + new Error('Failed') + ); + }); + + it('should call onRetry on each retry attempt', async () => { + const mockRequest = vi + .fn() + .mockRejectedValueOnce(new Error('First attempt failed')) + .mockRejectedValueOnce(new Error('Second attempt failed')) + .mockResolvedValue('success'); + + await restRequest({ + request: mockRequest, + onSuccess: mockOnSuccess, + onRetry: mockOnRetry + }); + + expect(mockRequest).toHaveBeenCalledTimes(3); + expect(mockOnRetry).toHaveBeenCalledTimes(2); + expect(mockOnRetry).toHaveBeenCalledWith({ + error: new Error('First attempt failed'), + retryCount: 1 + }); + expect(mockOnRetry).toHaveBeenCalledWith({ + error: new Error('Second attempt failed'), + retryCount: 2 + }); + expect(mockOnSuccess).toHaveBeenCalledWith('success'); + + expect(console.warn).toHaveBeenCalledTimes(2); + expect(console.warn).toHaveBeenCalledWith('Request attempt 1 failed. Retrying...'); + expect(console.warn).toHaveBeenCalledWith('Request attempt 2 failed. Retrying...'); + }); + + it('should stop retrying and call onError after maxRetries', async () => { + const maxRetries = 2; + + await restRequest({ + request: mockFailedRequest, + onSuccess: mockOnSuccess, + onRetry: mockOnRetry, + onError: mockOnError, + maxRetries + }); + + expect(mockFailedRequest).toHaveBeenCalledTimes(maxRetries + 1); + expect(mockOnRetry).toHaveBeenCalledTimes(maxRetries); + expect(mockOnError).toHaveBeenCalledWith(new Error('Failed')); + }); + + it('should not retry if maxRetries is set to 0', async () => { + await restRequest({ + request: mockFailedRequest, + onSuccess: mockOnSuccess, + onError: mockOnError, + onRetry: mockOnRetry, + maxRetries: 0 + }); + + expect(mockFailedRequest).toHaveBeenCalledTimes(1); + expect(mockOnError).toHaveBeenCalledWith(new Error('Failed')); + expect(mockOnRetry).not.toHaveBeenCalled(); + + expect(console.error).toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith( + 'Max retries reached. Error:', + new Error('Failed') + ); + expect(console.warn).not.toHaveBeenCalled(); + }); + + it('should handle optional onError gracefully when maxRetries is exceeded', async () => { + await restRequest({ + request: mockFailedRequest, + onSuccess: mockOnSuccess, + maxRetries: 1 + }); + + expect(mockFailedRequest).toHaveBeenCalledTimes(2); + }); + + it('should succeed after retries if a later attempt is successful', async () => { + const mockRequest = vi + .fn() + .mockRejectedValueOnce(new Error('First attempt failed')) + .mockResolvedValue('success'); + + await restRequest({ + request: mockRequest, + onSuccess: mockOnSuccess, + onRetry: mockOnRetry, + maxRetries: 2 + }); + + expect(mockRequest).toHaveBeenCalledTimes(2); + expect(mockOnRetry).toHaveBeenCalledTimes(1); + expect(mockOnRetry).toHaveBeenCalledWith({ + error: new Error('First attempt failed'), + retryCount: 1 + }); + expect(mockOnSuccess).toHaveBeenCalledWith('success'); + }); + + it('should not call onRetry or onError if the first attempt succeeds', async () => { + await restRequest({ + request: mockSuccessfulRequest, + onSuccess: mockOnSuccess, + onRetry: mockOnRetry, + onError: mockOnError + }); + + expect(mockSuccessfulRequest).toHaveBeenCalledTimes(1); + expect(mockOnSuccess).toHaveBeenCalledWith('success'); + expect(mockOnRetry).not.toHaveBeenCalled(); + expect(mockOnError).not.toHaveBeenCalled(); + + expect(console.error).not.toHaveBeenCalled(); + expect(console.warn).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/frontend/src/tests/lib/stores/modal.store.spec.ts b/src/frontend/src/tests/lib/stores/modal.store.spec.ts new file mode 100644 index 0000000000..d2039a35f9 --- /dev/null +++ b/src/frontend/src/tests/lib/stores/modal.store.spec.ts @@ -0,0 +1,42 @@ +import { modalStore } from '$lib/stores/modal.store'; +import { get } from 'svelte/store'; + +describe('modal.store', () => { + it('should initialise with undefined', () => { + expect(get(modalStore)).toBeUndefined(); + }); + + it('should open eth-receive modal with modalId', () => { + const id = Symbol('modalId'); + modalStore.openEthReceive(id); + + expect(get(modalStore)).toEqual({ type: 'eth-receive', id }); + }); + + it('should open icp-receive modal with modalId', () => { + const id = Symbol('modalId'); + modalStore.openIcpReceive(id); + + expect(get(modalStore)).toEqual({ type: 'icp-receive', id }); + }); + + it('should open wallet-connect-sign modal with data', () => { + const data = { value: 12345 }; + modalStore.openWalletConnectSign(data); + + expect(get(modalStore)).toEqual({ type: 'wallet-connect-sign', data }); + }); + + it('should open convert-ckbtc-btc modal without modalId', () => { + modalStore.openConvertCkBTCToBTC(); + + expect(get(modalStore)).toEqual({ type: 'convert-ckbtc-btc' }); + }); + + it('should close the modal and reset the store', () => { + modalStore.openToken(); + modalStore.close(); + + expect(get(modalStore)).toBeNull(); + }); +}); diff --git a/src/frontend/src/tests/lib/utils/convert.utils.spec.ts b/src/frontend/src/tests/lib/utils/convert.utils.spec.ts new file mode 100644 index 0000000000..c071b2a1b2 --- /dev/null +++ b/src/frontend/src/tests/lib/utils/convert.utils.spec.ts @@ -0,0 +1,53 @@ +import { validateConvertAmount } from '$lib/utils/convert.utils'; +import { BigNumber } from 'alchemy-sdk'; +import { describe } from 'vitest'; + +describe('validateConvertAmount', () => { + const userAmount = BigNumber.from(200000n); + const decimals = 8; + const balance = BigNumber.from(9000000n); + const totalFee = 10000n; + + it('should return undefined if all data satisfies the conditions', () => { + expect( + validateConvertAmount({ + userAmount, + decimals, + balance, + totalFee + }) + ).toBeUndefined(); + }); + + it('should return insufficient funds error', () => { + expect( + validateConvertAmount({ + userAmount: balance.add(userAmount), + decimals, + balance, + totalFee + }) + ).toBe('insufficient-funds'); + }); + + it('should return insufficient funds for fee error', () => { + expect( + validateConvertAmount({ + userAmount: balance.sub(BigNumber.from(totalFee).div(2)), + decimals, + balance, + totalFee + }) + ).toBe('insufficient-funds-for-fee'); + }); + + it('should not return insufficient funds for fee error if totalFee is undefined', () => { + expect( + validateConvertAmount({ + userAmount: balance.sub(BigNumber.from(totalFee).div(2)), + decimals, + balance + }) + ).toBeUndefined(); + }); +}); diff --git a/src/frontend/src/tests/lib/utils/format.utils.spec.ts b/src/frontend/src/tests/lib/utils/format.utils.spec.ts index 21ae3c00b6..8585ab902d 100644 --- a/src/frontend/src/tests/lib/utils/format.utils.spec.ts +++ b/src/frontend/src/tests/lib/utils/format.utils.spec.ts @@ -5,270 +5,271 @@ import { formatTokenBigintToNumber } from '$lib/utils/format.utils'; import { BigNumber } from 'ethers'; -import { describe } from 'vitest'; -describe('formatToken', () => { - const value = BigNumber.from('1000000000000000000'); - const valueD1 = BigNumber.from('1200000000000000000'); - const valueD2 = BigNumber.from('1234000000000000000'); - const valueD3 = BigNumber.from('1234567000000000000'); +describe('format.utils', () => { + describe('formatToken', () => { + const value = BigNumber.from('1000000000000000000'); + const valueD1 = BigNumber.from('1200000000000000000'); + const valueD2 = BigNumber.from('1234000000000000000'); + const valueD3 = BigNumber.from('1234567000000000000'); - it('formats value with default parameters', () => { - expect(formatToken({ value })).toBe('1'); - }); + it('formats value with default parameters', () => { + expect(formatToken({ value })).toBe('1'); + }); - it('formats value with specified displayDecimals', () => { - expect(formatToken({ value, displayDecimals: 2 })).toBe('1'); - }); + it('formats value with specified displayDecimals', () => { + expect(formatToken({ value, displayDecimals: 2 })).toBe('1'); + }); - it('formats value with trailing zeros', () => { - expect(formatToken({ value, trailingZeros: true })).toBe('1.0000'); - }); + it('formats value with trailing zeros', () => { + expect(formatToken({ value, trailingZeros: true })).toBe('1.0000'); + }); - it('formats value with specified displayDecimals and trailing zeros', () => { - expect(formatToken({ value, displayDecimals: 2, trailingZeros: true })).toBe('1.00'); - }); + it('formats value with specified displayDecimals and trailing zeros', () => { + expect(formatToken({ value, displayDecimals: 2, trailingZeros: true })).toBe('1.00'); + }); - it('formats non-rounded value', () => { - expect(formatToken({ value: valueD1 })).toBe('1.2'); + it('formats non-rounded value', () => { + expect(formatToken({ value: valueD1 })).toBe('1.2'); - expect(formatToken({ value: valueD2 })).toBe('1.234'); + expect(formatToken({ value: valueD2 })).toBe('1.234'); - expect(formatToken({ value: valueD3 })).toBe('1.2346'); - }); + expect(formatToken({ value: valueD3 })).toBe('1.2346'); + }); - it('formats non-rounded value with specified displayDecimals', () => { - expect(formatToken({ value: valueD1, displayDecimals: 6 })).toBe('1.2'); + it('formats non-rounded value with specified displayDecimals', () => { + expect(formatToken({ value: valueD1, displayDecimals: 6 })).toBe('1.2'); - expect(formatToken({ value: valueD1, displayDecimals: 2 })).toBe('1.2'); + expect(formatToken({ value: valueD1, displayDecimals: 2 })).toBe('1.2'); - expect(formatToken({ value: valueD2, displayDecimals: 6 })).toBe('1.234'); + expect(formatToken({ value: valueD2, displayDecimals: 6 })).toBe('1.234'); - expect(formatToken({ value: valueD2, displayDecimals: 2 })).toBe('1.23'); - }); + expect(formatToken({ value: valueD2, displayDecimals: 2 })).toBe('1.23'); + }); - it('formats non-rounded value with trailing zeros', () => { - expect(formatToken({ value: valueD1, trailingZeros: true })).toBe('1.2000'); + it('formats non-rounded value with trailing zeros', () => { + expect(formatToken({ value: valueD1, trailingZeros: true })).toBe('1.2000'); - expect(formatToken({ value: valueD2, trailingZeros: true })).toBe('1.2340'); - }); + expect(formatToken({ value: valueD2, trailingZeros: true })).toBe('1.2340'); + }); - it('formats non-rounded value with specified displayDecimals and trailing zeros', () => { - expect(formatToken({ value: valueD1, displayDecimals: 6, trailingZeros: true })).toBe( - '1.200000' - ); + it('formats non-rounded value with specified displayDecimals and trailing zeros', () => { + expect(formatToken({ value: valueD1, displayDecimals: 6, trailingZeros: true })).toBe( + '1.200000' + ); - expect(formatToken({ value: valueD1, displayDecimals: 2, trailingZeros: true })).toBe('1.20'); + expect(formatToken({ value: valueD1, displayDecimals: 2, trailingZeros: true })).toBe('1.20'); - expect(formatToken({ value: valueD2, displayDecimals: 6, trailingZeros: true })).toBe( - '1.234000' - ); + expect(formatToken({ value: valueD2, displayDecimals: 6, trailingZeros: true })).toBe( + '1.234000' + ); - expect(formatToken({ value: valueD2, displayDecimals: 2, trailingZeros: true })).toBe('1.23'); - }); + expect(formatToken({ value: valueD2, displayDecimals: 2, trailingZeros: true })).toBe('1.23'); + }); - it('formats zero with default parameters', () => { - expect(formatToken({ value: ZERO })).toBe('0'); - }); + it('formats zero with default parameters', () => { + expect(formatToken({ value: ZERO })).toBe('0'); + }); - it('formats zero with specified displayDecimals', () => { - expect(formatToken({ value: ZERO, displayDecimals: 2 })).toBe('0'); - }); + it('formats zero with specified displayDecimals', () => { + expect(formatToken({ value: ZERO, displayDecimals: 2 })).toBe('0'); + }); - it('formats zero with trailing zeros', () => { - expect(formatToken({ value: ZERO, trailingZeros: true })).toBe('0.0000'); - }); + it('formats zero with trailing zeros', () => { + expect(formatToken({ value: ZERO, trailingZeros: true })).toBe('0.0000'); + }); - it('formats zero with specified displayDecimals and trailing zeros', () => { - expect(formatToken({ value: ZERO, displayDecimals: 2, trailingZeros: true })).toBe('0.00'); - }); + it('formats zero with specified displayDecimals and trailing zeros', () => { + expect(formatToken({ value: ZERO, displayDecimals: 2, trailingZeros: true })).toBe('0.00'); + }); - it('formats value with different unitName', () => { - expect(formatToken({ value, unitName: '20' })).toBe('0.01'); - }); + it('formats value with different unitName', () => { + expect(formatToken({ value, unitName: '20' })).toBe('0.01'); + }); - it('formats value with different unitName and specified displayDecimals', () => { - expect(formatToken({ value, unitName: '20', displayDecimals: 3 })).toBe('0.01'); + it('formats value with different unitName and specified displayDecimals', () => { + expect(formatToken({ value, unitName: '20', displayDecimals: 3 })).toBe('0.01'); - expect(formatToken({ value, unitName: '20', displayDecimals: 1 })).toBe('0'); - }); + expect(formatToken({ value, unitName: '20', displayDecimals: 1 })).toBe('0'); + }); - it('formats value with different unitName, specified displayDecimals and trailing zeros', () => { - expect(formatToken({ value, unitName: '20', displayDecimals: 3, trailingZeros: true })).toBe( - '0.010' - ); + it('formats value with different unitName, specified displayDecimals and trailing zeros', () => { + expect(formatToken({ value, unitName: '20', displayDecimals: 3, trailingZeros: true })).toBe( + '0.010' + ); - expect(formatToken({ value, unitName: '20', displayDecimals: 1, trailingZeros: true })).toBe( - '0.0' - ); + expect(formatToken({ value, unitName: '20', displayDecimals: 1, trailingZeros: true })).toBe( + '0.0' + ); + }); }); -}); -describe('formatSecondsToNormalizedDate', () => { - describe('when the current date is not provided', () => { - it('should return "today" for the current date', () => { - const currentDate = new Date(); - const currentDateTimestamp = Math.floor(currentDate.getTime() / 1000); + describe('formatSecondsToNormalizedDate', () => { + describe('when the current date is not provided', () => { + it('should return "today" for the current date', () => { + const currentDate = new Date(); + const currentDateTimestamp = Math.floor(currentDate.getTime() / 1000); - expect(formatSecondsToNormalizedDate({ seconds: currentDateTimestamp })).toBe('today'); - }); + expect(formatSecondsToNormalizedDate({ seconds: currentDateTimestamp })).toBe('today'); + }); - it('should return "yesterday" for the previous date', () => { - const currentDate = new Date(); - const yesterday = new Date(currentDate); - yesterday.setDate(currentDate.getDate() - 1); - const yesterdayTimestamp = Math.floor(yesterday.getTime() / 1000); + it('should return "yesterday" for the previous date', () => { + const currentDate = new Date(); + const yesterday = new Date(currentDate); + yesterday.setDate(currentDate.getDate() - 1); + const yesterdayTimestamp = Math.floor(yesterday.getTime() / 1000); - expect(formatSecondsToNormalizedDate({ seconds: yesterdayTimestamp })).toBe('yesterday'); - }); + expect(formatSecondsToNormalizedDate({ seconds: yesterdayTimestamp })).toBe('yesterday'); + }); + + it('should return day and month if within the same year', () => { + const currentDate = new Date(2024, 1, 25); + const earlierThisYear = new Date(currentDate); + earlierThisYear.setMonth(currentDate.getMonth() - 1); + const timestampThisYear = Math.floor(earlierThisYear.getTime() / 1000); - it('should return day and month if within the same year', () => { - const currentDate = new Date(2024, 1, 25); - const earlierThisYear = new Date(currentDate); - earlierThisYear.setMonth(currentDate.getMonth() - 1); - const timestampThisYear = Math.floor(earlierThisYear.getTime() / 1000); + const expected = earlierThisYear.toLocaleDateString('en', { + day: 'numeric', + month: 'long' + }); - const expected = earlierThisYear.toLocaleDateString('en', { - day: 'numeric', - month: 'long' + expect(formatSecondsToNormalizedDate({ seconds: timestampThisYear })).toBe(expected); }); - expect(formatSecondsToNormalizedDate({ seconds: timestampThisYear })).toBe(expected); - }); + it('should return day, month, and year if from a different year', () => { + const currentDate = new Date(2024, 1, 25); + const lastYear = new Date(currentDate); + lastYear.setFullYear(currentDate.getFullYear() - 1); + const timestampLastYear = Math.floor(lastYear.getTime() / 1000); - it('should return day, month, and year if from a different year', () => { - const currentDate = new Date(2024, 1, 25); - const lastYear = new Date(currentDate); - lastYear.setFullYear(currentDate.getFullYear() - 1); - const timestampLastYear = Math.floor(lastYear.getTime() / 1000); + const expected = lastYear.toLocaleDateString('en', { + day: 'numeric', + month: 'long', + year: 'numeric' + }); - const expected = lastYear.toLocaleDateString('en', { - day: 'numeric', - month: 'long', - year: 'numeric' + expect(formatSecondsToNormalizedDate({ seconds: timestampLastYear })).toBe(expected); }); - expect(formatSecondsToNormalizedDate({ seconds: timestampLastYear })).toBe(expected); - }); - - it('should not give an error if the date is in the future', () => { - const currentDate = new Date(2024, 1, 25); - const futureDate = new Date(currentDate); - futureDate.setDate(currentDate.getDate() + 1); - const futureTimestamp = Math.floor(futureDate.getTime() / 1000); + it('should not give an error if the date is in the future', () => { + const currentDate = new Date(2024, 1, 25); + const futureDate = new Date(currentDate); + futureDate.setDate(currentDate.getDate() + 1); + const futureTimestamp = Math.floor(futureDate.getTime() / 1000); - expect(() => formatSecondsToNormalizedDate({ seconds: futureTimestamp })).not.toThrow(); - }); + expect(() => formatSecondsToNormalizedDate({ seconds: futureTimestamp })).not.toThrow(); + }); - it('should return "yesterday" even if the date was in the past year', () => { - vi.useFakeTimers().setSystemTime(new Date(2024, 0, 1)); + it('should return "yesterday" even if the date was in the past year', () => { + vi.useFakeTimers().setSystemTime(new Date(2024, 0, 1)); - const currentDate = new Date(2024, 0, 1); - const yesterday = new Date(currentDate); - yesterday.setDate(currentDate.getDate() - 1); - const yesterdayTimestamp = Math.floor(yesterday.getTime() / 1000); + const currentDate = new Date(2024, 0, 1); + const yesterday = new Date(currentDate); + yesterday.setDate(currentDate.getDate() - 1); + const yesterdayTimestamp = Math.floor(yesterday.getTime() / 1000); - expect(formatSecondsToNormalizedDate({ seconds: yesterdayTimestamp })).toBe('yesterday'); + expect(formatSecondsToNormalizedDate({ seconds: yesterdayTimestamp })).toBe('yesterday'); - vi.useRealTimers(); - }); + vi.useRealTimers(); + }); - it('should return "yesterday" even if the date was in the past month', () => { - vi.useFakeTimers().setSystemTime(new Date(2024, 1, 1)); + it('should return "yesterday" even if the date was in the past month', () => { + vi.useFakeTimers().setSystemTime(new Date(2024, 1, 1)); - const currentDate = new Date(2024, 1, 1); - const yesterday = new Date(currentDate); - yesterday.setDate(currentDate.getDate() - 1); - const yesterdayTimestamp = Math.floor(yesterday.getTime() / 1000); + const currentDate = new Date(2024, 1, 1); + const yesterday = new Date(currentDate); + yesterday.setDate(currentDate.getDate() - 1); + const yesterdayTimestamp = Math.floor(yesterday.getTime() / 1000); - expect(formatSecondsToNormalizedDate({ seconds: yesterdayTimestamp })).toBe('yesterday'); + expect(formatSecondsToNormalizedDate({ seconds: yesterdayTimestamp })).toBe('yesterday'); - vi.useRealTimers(); + vi.useRealTimers(); + }); }); - }); - describe('when the reference date is provided', () => { - const currentDate = new Date(1990, 1, 19); + describe('when the reference date is provided', () => { + const currentDate = new Date(1990, 1, 19); - it('should return "today" for the current date', () => { - const currentDateTimestamp = Math.floor(currentDate.getTime() / 1000); + it('should return "today" for the current date', () => { + const currentDateTimestamp = Math.floor(currentDate.getTime() / 1000); - expect(formatSecondsToNormalizedDate({ seconds: currentDateTimestamp, currentDate })).toBe( - 'today' - ); - }); + expect(formatSecondsToNormalizedDate({ seconds: currentDateTimestamp, currentDate })).toBe( + 'today' + ); + }); - it('should return "yesterday" for the previous date', () => { - const yesterday = new Date(currentDate); - yesterday.setDate(currentDate.getDate() - 1); - const yesterdayTimestamp = Math.floor(yesterday.getTime() / 1000); + it('should return "yesterday" for the previous date', () => { + const yesterday = new Date(currentDate); + yesterday.setDate(currentDate.getDate() - 1); + const yesterdayTimestamp = Math.floor(yesterday.getTime() / 1000); - expect(formatSecondsToNormalizedDate({ seconds: yesterdayTimestamp, currentDate })).toBe( - 'yesterday' - ); - }); + expect(formatSecondsToNormalizedDate({ seconds: yesterdayTimestamp, currentDate })).toBe( + 'yesterday' + ); + }); - it('should return day and month if within the same year', () => { - const earlierThisYear = new Date(currentDate); - earlierThisYear.setMonth(currentDate.getMonth() - 1); - const timestampThisYear = Math.floor(earlierThisYear.getTime() / 1000); + it('should return day and month if within the same year', () => { + const earlierThisYear = new Date(currentDate); + earlierThisYear.setMonth(currentDate.getMonth() - 1); + const timestampThisYear = Math.floor(earlierThisYear.getTime() / 1000); - const expected = earlierThisYear.toLocaleDateString('en', { - day: 'numeric', - month: 'long' + const expected = earlierThisYear.toLocaleDateString('en', { + day: 'numeric', + month: 'long' + }); + + expect(formatSecondsToNormalizedDate({ seconds: timestampThisYear, currentDate })).toBe( + expected + ); }); - expect(formatSecondsToNormalizedDate({ seconds: timestampThisYear, currentDate })).toBe( - expected - ); - }); + it('should return day, month, and year if from a different year', () => { + const lastYear = new Date(currentDate); + lastYear.setFullYear(currentDate.getFullYear() - 1); + const timestampLastYear = Math.floor(lastYear.getTime() / 1000); - it('should return day, month, and year if from a different year', () => { - const lastYear = new Date(currentDate); - lastYear.setFullYear(currentDate.getFullYear() - 1); - const timestampLastYear = Math.floor(lastYear.getTime() / 1000); + const expected = lastYear.toLocaleDateString('en', { + day: 'numeric', + month: 'long', + year: 'numeric' + }); - const expected = lastYear.toLocaleDateString('en', { - day: 'numeric', - month: 'long', - year: 'numeric' + expect(formatSecondsToNormalizedDate({ seconds: timestampLastYear, currentDate })).toBe( + expected + ); }); - - expect(formatSecondsToNormalizedDate({ seconds: timestampLastYear, currentDate })).toBe( - expected - ); }); }); -}); -describe('formatTokenBigintToNumber', () => { - it('should format correctly', () => { - expect( - formatTokenBigintToNumber({ - value: 2000000n, - displayDecimals: 4, - unitName: 4 - }) - ).toBe(200); - - expect( - formatTokenBigintToNumber({ - value: 50000n, - displayDecimals: 8, - unitName: 4 - }) - ).toBe(5); - - expect( - formatTokenBigintToNumber({ - value: 1000000000000000n - }) - ).toBe(0.001); - - expect( - formatTokenBigintToNumber({ - value: 0n - }) - ).toBe(0); + describe('formatTokenBigintToNumber', () => { + it('should format correctly', () => { + expect( + formatTokenBigintToNumber({ + value: 2000000n, + displayDecimals: 4, + unitName: 4 + }) + ).toBe(200); + + expect( + formatTokenBigintToNumber({ + value: 50000n, + displayDecimals: 8, + unitName: 4 + }) + ).toBe(5); + + expect( + formatTokenBigintToNumber({ + value: 1000000000000000n + }) + ).toBe(0.001); + + expect( + formatTokenBigintToNumber({ + value: 0n + }) + ).toBe(0); + }); }); }); diff --git a/src/frontend/src/tests/lib/utils/info.utils.spec.ts b/src/frontend/src/tests/lib/utils/info.utils.spec.ts new file mode 100644 index 0000000000..343ba2ae83 --- /dev/null +++ b/src/frontend/src/tests/lib/utils/info.utils.spec.ts @@ -0,0 +1,87 @@ +import { saveHideInfo, shouldHideInfo, type HideInfoKey } from '$lib/utils/info.utils'; + +describe('info.utils', () => { + describe('saveHideInfo', () => { + const key = 'someKey' as HideInfoKey; + + beforeEach(() => { + vi.resetAllMocks(); + + localStorage.clear(); + + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('should save a value in localStorage', () => { + saveHideInfo(key); + + expect(localStorage.getItem(key)).toBe('true'); + }); + + it('should not throw errors even if localStorage is unavailable', () => { + const originalLocalStorage = window.localStorage; + + Object.defineProperty(window, 'localStorage', { + value: { + setItem: vi.fn(() => { + throw new Error('LocalStorage is full'); + }) + }, + writable: true + }); + + expect(() => saveHideInfo(key)).not.toThrow(); + + Object.defineProperty(window, 'localStorage', { + value: originalLocalStorage, + writable: true + }); + }); + }); + + describe('shouldHideInfo', () => { + const key = 'someKey' as HideInfoKey; + + beforeEach(() => { + vi.resetAllMocks(); + + localStorage.clear(); + + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('should return true if the value for the key is "true"', () => { + localStorage.setItem(key, 'true'); + expect(shouldHideInfo(key)).toBe(true); + }); + + it('should return false if the value for the key is "false"', () => { + localStorage.setItem(key, 'false'); + expect(shouldHideInfo(key)).toBe(false); + }); + + it('should return false if the key does not exist in localStorage', () => { + expect(shouldHideInfo(key)).toBe(false); + }); + + it('should return false if localStorage is unavailable or throws an error', () => { + const originalLocalStorage = window.localStorage; + + Object.defineProperty(window, 'localStorage', { + value: { + getItem: vi.fn(() => { + throw new Error('LocalStorage is full'); + }) + }, + writable: true + }); + + expect(shouldHideInfo(key)).toBe(false); + + Object.defineProperty(window, 'localStorage', { + value: originalLocalStorage, + writable: true + }); + }); + }); +}); diff --git a/src/frontend/src/tests/lib/utils/nav.utils.spec.ts b/src/frontend/src/tests/lib/utils/nav.utils.spec.ts index 1441926d89..e01d11353e 100644 --- a/src/frontend/src/tests/lib/utils/nav.utils.spec.ts +++ b/src/frontend/src/tests/lib/utils/nav.utils.spec.ts @@ -1,100 +1,236 @@ -import { AppPath, ROUTE_ID_GROUP_APP } from '$lib/constants/routes.constants'; +import * as appNavigation from '$app/navigation'; +import { ICP_NETWORK_ID } from '$env/networks.env'; import { + AppPath, + NETWORK_PARAM, + ROUTE_ID_GROUP_APP, + TOKEN_PARAM, + URI_PARAM +} from '$lib/constants/routes.constants'; +import { + back, + gotoReplaceRoot, + isRouteActivity, isRouteDappExplorer, isRouteSettings, isRouteTokens, isRouteTransactions, + loadRouteParams, + networkParam, resetRouteParams, type RouteParams } from '$lib/utils/nav.utils'; -import type { Page } from '@sveltejs/kit'; +import type { LoadEvent, Page } from '@sveltejs/kit'; import { describe, expect } from 'vitest'; -describe('resetRouteParams', () => { - it('should return an object with all values set to null', () => { - const result = resetRouteParams(); +describe('nav.utils', () => { + const mockGoTo = vi.fn(); + + beforeAll(() => { + vi.resetAllMocks(); + + vi.spyOn(appNavigation, 'goto').mockImplementation(mockGoTo); + }); - Object.keys(result).forEach((key) => { - expect(result[key as keyof RouteParams]).toBeNull(); + describe('networkParam', () => { + it('should return an empty string when networkId is undefined', () => { + expect(networkParam(undefined)).toBe(''); + }); + + it('should return the formatted network parameter when networkId is provided', () => { + expect(networkParam(ICP_NETWORK_ID)).toBe(`${NETWORK_PARAM}=${ICP_NETWORK_ID.description}`); }); }); -}); -describe('Route Check Functions', () => { - const mockPage = (id: string): Page => ({ - params: {}, - route: { id }, - status: 200, - error: null, - data: {}, - url: URL.prototype, - state: {}, - form: null + describe('back', () => { + it('should call history.back when pop is true', async () => { + const historyBackMock = vi.spyOn(history, 'back'); + await back({ pop: true }); + expect(historyBackMock).toHaveBeenCalled(); + }); + + it('should navigate to "/" when pop is false', async () => { + await back({ pop: false }); + expect(mockGoTo).toHaveBeenCalledWith('/'); + }); }); - describe('isRouteTransactions', () => { - it('should return true when route id matches Transactions path', () => { - expect(isRouteTransactions(mockPage(`${ROUTE_ID_GROUP_APP}${AppPath.Transactions}`))).toBe( - true - ); + describe('gotoReplaceRoot', () => { + it('should navigate to "/" with replaceState', async () => { + await gotoReplaceRoot(); + expect(mockGoTo).toHaveBeenCalledWith('/', { replaceState: true }); }); + }); - it('should return false when route id does not match Transactions path', () => { - expect(isRouteTransactions(mockPage(`${ROUTE_ID_GROUP_APP}/wrongPath`))).toBe(false); + describe('loadRouteParams', () => { + it('should return undefined values if not in a browser', () => { + const result = loadRouteParams({ + url: { + searchParams: { + get: vi.fn((_) => null) + } + } + } as unknown as LoadEvent); + expect(result).toEqual({ + [TOKEN_PARAM]: null, + [NETWORK_PARAM]: null, + [URI_PARAM]: null + }); + }); - expect(isRouteTransactions(mockPage(`${ROUTE_ID_GROUP_APP}${AppPath.Settings}`))).toBe(false); + it('should parse route parameters correctly', () => { + expect( + loadRouteParams({ + url: { + searchParams: { + get: vi.fn((key) => (key === TOKEN_PARAM ? 'testToken' : null)) + } + } + } as unknown as LoadEvent) + ).toEqual({ + [TOKEN_PARAM]: 'testToken', + [NETWORK_PARAM]: null, + [URI_PARAM]: null + }); + + expect( + loadRouteParams({ + url: { + searchParams: { + get: vi.fn((key) => (key === NETWORK_PARAM ? 'testNetwork' : null)) + } + } + } as unknown as LoadEvent) + ).toEqual({ + [TOKEN_PARAM]: null, + [NETWORK_PARAM]: 'testNetwork', + [URI_PARAM]: null + }); + + expect( + loadRouteParams({ + url: { + searchParams: { + get: vi.fn((key) => (key === URI_PARAM ? 'testURI' : null)) + } + } + } as unknown as LoadEvent) + ).toEqual({ + [TOKEN_PARAM]: null, + [NETWORK_PARAM]: null, + [URI_PARAM]: 'testURI' + }); + }); + }); - expect(isRouteTransactions(mockPage(`${ROUTE_ID_GROUP_APP}`))).toBe(false); + describe('resetRouteParams', () => { + it('should return an object with all values set to null', () => { + const result = resetRouteParams(); - expect(isRouteTransactions(mockPage(`/anotherGroup/${AppPath.Transactions}`))).toBe(false); + Object.keys(result).forEach((key) => { + expect(result[key as keyof RouteParams]).toBeNull(); + }); }); }); - describe('isRouteSettings', () => { - it('should return true when route id matches Settings path', () => { - expect(isRouteSettings(mockPage(`${ROUTE_ID_GROUP_APP}${AppPath.Settings}`))).toBe(true); + describe('Route Check Functions', () => { + const mockPage = (id: string): Page => ({ + params: {}, + route: { id }, + status: 200, + error: null, + data: {}, + url: URL.prototype, + state: {}, + form: null }); - it('should return false when route id does not match Settings path', () => { - expect(isRouteSettings(mockPage(`${ROUTE_ID_GROUP_APP}/wrongPath`))).toBe(false); + describe('isRouteTransactions', () => { + it('should return true when route id matches Transactions path', () => { + expect(isRouteTransactions(mockPage(`${ROUTE_ID_GROUP_APP}${AppPath.Transactions}`))).toBe( + true + ); + }); - expect(isRouteSettings(mockPage(`${ROUTE_ID_GROUP_APP}${AppPath.Transactions}`))).toBe(false); + it('should return false when route id does not match Transactions path', () => { + expect(isRouteTransactions(mockPage(`${ROUTE_ID_GROUP_APP}/wrongPath`))).toBe(false); - expect(isRouteSettings(mockPage(`${ROUTE_ID_GROUP_APP}`))).toBe(false); + expect(isRouteTransactions(mockPage(`${ROUTE_ID_GROUP_APP}${AppPath.Settings}`))).toBe( + false + ); - expect(isRouteSettings(mockPage(`/anotherGroup/${AppPath.Settings}`))).toBe(false); + expect(isRouteTransactions(mockPage(`${ROUTE_ID_GROUP_APP}`))).toBe(false); + + expect(isRouteTransactions(mockPage(`/anotherGroup/${AppPath.Transactions}`))).toBe(false); + }); }); - }); - describe('isRouteDappExplorer', () => { - it('should return true when route id matches Explore path', () => { - expect(isRouteDappExplorer(mockPage(`${ROUTE_ID_GROUP_APP}${AppPath.Explore}`))).toBe(true); + describe('isRouteSettings', () => { + it('should return true when route id matches Settings path', () => { + expect(isRouteSettings(mockPage(`${ROUTE_ID_GROUP_APP}${AppPath.Settings}`))).toBe(true); + }); + + it('should return false when route id does not match Settings path', () => { + expect(isRouteSettings(mockPage(`${ROUTE_ID_GROUP_APP}/wrongPath`))).toBe(false); + + expect(isRouteSettings(mockPage(`${ROUTE_ID_GROUP_APP}${AppPath.Transactions}`))).toBe( + false + ); + + expect(isRouteSettings(mockPage(`${ROUTE_ID_GROUP_APP}`))).toBe(false); + + expect(isRouteSettings(mockPage(`/anotherGroup/${AppPath.Settings}`))).toBe(false); + }); }); - it('should return false when route id does not match Explore path', () => { - expect(isRouteDappExplorer(mockPage(`${ROUTE_ID_GROUP_APP}/wrongPath`))).toBe(false); + describe('isRouteDappExplorer', () => { + it('should return true when route id matches Explore path', () => { + expect(isRouteDappExplorer(mockPage(`${ROUTE_ID_GROUP_APP}${AppPath.Explore}`))).toBe(true); + }); + + it('should return false when route id does not match Explore path', () => { + expect(isRouteDappExplorer(mockPage(`${ROUTE_ID_GROUP_APP}/wrongPath`))).toBe(false); - expect(isRouteDappExplorer(mockPage(`${ROUTE_ID_GROUP_APP}${AppPath.Settings}`))).toBe(false); + expect(isRouteDappExplorer(mockPage(`${ROUTE_ID_GROUP_APP}${AppPath.Settings}`))).toBe( + false + ); - expect(isRouteDappExplorer(mockPage(`${ROUTE_ID_GROUP_APP}`))).toBe(false); + expect(isRouteDappExplorer(mockPage(`${ROUTE_ID_GROUP_APP}`))).toBe(false); - expect(isRouteDappExplorer(mockPage(`/anotherGroup/${AppPath.Explore}`))).toBe(false); + expect(isRouteDappExplorer(mockPage(`/anotherGroup/${AppPath.Explore}`))).toBe(false); + }); }); - }); - describe('isRouteTokens', () => { - it('should return true when route id matches ROUTE_ID_GROUP_APP exactly', () => { - expect(isRouteTokens(mockPage(ROUTE_ID_GROUP_APP))).toBe(true); + describe('isRouteActivity', () => { + it('should return true when route id matches Activity path', () => { + expect(isRouteActivity(mockPage(`${ROUTE_ID_GROUP_APP}${AppPath.Activity}`))).toBe(true); + }); + + it('should return false when route id does not match Activity path', () => { + expect(isRouteActivity(mockPage(`${ROUTE_ID_GROUP_APP}/wrongPath`))).toBe(false); + + expect(isRouteActivity(mockPage(`${ROUTE_ID_GROUP_APP}${AppPath.Settings}`))).toBe(false); + + expect(isRouteActivity(mockPage(`${ROUTE_ID_GROUP_APP}`))).toBe(false); + + expect(isRouteActivity(mockPage(`/anotherGroup/${AppPath.Activity}`))).toBe(false); + }); }); - it('should return false when route id does not match ROUTE_ID_GROUP_APP exactly', () => { - expect(isRouteTokens(mockPage(`${ROUTE_ID_GROUP_APP}/wrongPath`))).toBe(false); + describe('isRouteTokens', () => { + it('should return true when route id matches ROUTE_ID_GROUP_APP exactly', () => { + expect(isRouteTokens(mockPage(ROUTE_ID_GROUP_APP))).toBe(true); + }); + + it('should return false when route id does not match ROUTE_ID_GROUP_APP exactly', () => { + expect(isRouteTokens(mockPage(`${ROUTE_ID_GROUP_APP}/wrongPath`))).toBe(false); - expect(isRouteTokens(mockPage(`${ROUTE_ID_GROUP_APP}${AppPath.Settings}`))).toBe(false); + expect(isRouteTokens(mockPage(`${ROUTE_ID_GROUP_APP}${AppPath.Settings}`))).toBe(false); - expect(isRouteTokens(mockPage(`${ROUTE_ID_GROUP_APP}${AppPath.Transactions}`))).toBe(false); + expect(isRouteTokens(mockPage(`${ROUTE_ID_GROUP_APP}${AppPath.Transactions}`))).toBe(false); - expect(isRouteTokens(mockPage('/anotherGroup'))).toBe(false); + expect(isRouteTokens(mockPage('/anotherGroup'))).toBe(false); + }); }); }); }); diff --git a/src/frontend/src/tests/lib/utils/token-toggle.utils.spec.ts b/src/frontend/src/tests/lib/utils/token-toggle.utils.spec.ts index b53bfe84ad..4db69951e1 100644 --- a/src/frontend/src/tests/lib/utils/token-toggle.utils.spec.ts +++ b/src/frontend/src/tests/lib/utils/token-toggle.utils.spec.ts @@ -7,7 +7,6 @@ import { isEthereumTokenToggleDisabled, isIcrcTokenToggleDisabled } from '$lib/utils/token-toggle.utils'; -import { describe, expect } from 'vitest'; describe('isEthereumUserTokenDisabled', () => { it('should check if default ethereum user token is disabled for token toggle', () => { diff --git a/src/frontend/src/tests/lib/utils/token.utils.spec.ts b/src/frontend/src/tests/lib/utils/token.utils.spec.ts index 74fb55012c..2998bc8eee 100644 --- a/src/frontend/src/tests/lib/utils/token.utils.spec.ts +++ b/src/frontend/src/tests/lib/utils/token.utils.spec.ts @@ -23,7 +23,7 @@ import { mockExchanges } from '$tests/mocks/exchanges.mock'; import { mockValidIcCkToken, mockValidIcToken } from '$tests/mocks/ic-tokens.mock'; import { mockTokens } from '$tests/mocks/tokens.mock'; import { BigNumber } from 'alchemy-sdk'; -import { describe, type MockedFunction } from 'vitest'; +import type { MockedFunction } from 'vitest'; const tokenDecimals = 8; const tokenStandards: TokenStandard[] = ['ethereum', 'icp', 'icrc', 'bitcoin']; diff --git a/src/frontend/src/tests/lib/utils/transactions.utils.spec.ts b/src/frontend/src/tests/lib/utils/transactions.utils.spec.ts index 23bc13a7f5..2b7d816dd8 100644 --- a/src/frontend/src/tests/lib/utils/transactions.utils.spec.ts +++ b/src/frontend/src/tests/lib/utils/transactions.utils.spec.ts @@ -24,9 +24,9 @@ import IcTransaction from '$icp/components/transactions/IcTransaction.svelte'; import type { IcTransactionUi } from '$icp/types/ic-transaction'; import type { CertifiedStoreData } from '$lib/stores/certified.store'; import type { TransactionsData } from '$lib/stores/transactions.store'; -import type { AllTransactionsUi, AnyTransactionUi, Transaction } from '$lib/types/transaction'; +import type { AllTransactionUi, AnyTransactionUi, Transaction } from '$lib/types/transaction'; import { mapAllTransactionsUi, sortTransactions } from '$lib/utils/transactions.utils'; -import { createMockBtcTransactionsUi } from '$tests/mocks/btc.mock'; +import { createMockBtcTransactionsUi } from '$tests/mocks/btc-transactions.mock'; import { createMockEthTransactions } from '$tests/mocks/eth-transactions.mock'; import { createMockIcTransactionsUi } from '$tests/mocks/ic-transactions.mock'; @@ -66,7 +66,7 @@ describe('transactions.utils', () => { [ICP_TOKEN_ID]: mockIcTransactionsUi.map((data) => ({ data, certified })) }; - const expectedBtcMainnetTransactions: AllTransactionsUi = [ + const expectedBtcMainnetTransactions: AllTransactionUi[] = [ ...mockBtcMainnetTransactions.map((transaction) => ({ ...transaction, token: BTC_MAINNET_TOKEN, @@ -76,7 +76,7 @@ describe('transactions.utils', () => { const uiType = 'receive' as EthTransactionType; - const expectedEthMainnetTransactions: AllTransactionsUi = [ + const expectedEthMainnetTransactions: AllTransactionUi[] = [ ...mockEthMainnetTransactions.map((transaction) => ({ ...transaction, id: transaction.hash, @@ -86,7 +86,7 @@ describe('transactions.utils', () => { })) ]; - const expectedSepoliaTransactions: AllTransactionsUi = [ + const expectedSepoliaTransactions: AllTransactionUi[] = [ ...mockSepoliaTransactions.map((transaction) => ({ ...transaction, id: transaction.hash, @@ -96,7 +96,7 @@ describe('transactions.utils', () => { })) ]; - const expectedErc20Transactions: AllTransactionsUi = [ + const expectedErc20Transactions: AllTransactionUi[] = [ ...mockErc20Transactions.map((transaction) => ({ ...transaction, id: transaction.hash, @@ -106,13 +106,13 @@ describe('transactions.utils', () => { })) ]; - const expectedEthTransactions: AllTransactionsUi = [ + const expectedEthTransactions: AllTransactionUi[] = [ ...expectedEthMainnetTransactions, ...expectedSepoliaTransactions, ...expectedErc20Transactions ]; - const expectedIcTransactions: AllTransactionsUi = [ + const expectedIcTransactions: AllTransactionUi[] = [ ...mockIcTransactionsUi.map((transaction) => ({ ...transaction, token: ICP_TOKEN, @@ -120,7 +120,7 @@ describe('transactions.utils', () => { })) ]; - const expectedTransactions: AllTransactionsUi = [ + const expectedTransactions: AllTransactionUi[] = [ ...expectedBtcMainnetTransactions, ...expectedEthTransactions, ...expectedIcTransactions diff --git a/src/frontend/src/tests/lib/validation/coingecko.validation.spec.ts b/src/frontend/src/tests/lib/validation/coingecko.validation.spec.ts index 6706a5ade3..4569f8bdf1 100644 --- a/src/frontend/src/tests/lib/validation/coingecko.validation.spec.ts +++ b/src/frontend/src/tests/lib/validation/coingecko.validation.spec.ts @@ -1,5 +1,4 @@ import { CoingeckoCoinsIdSchema } from '$lib/validation/coingecko.validation'; -import { describe, expect, it } from 'vitest'; describe('CoingeckoCoinsIdSchema', () => { it('should pass validation for "ethereum"', () => { diff --git a/src/frontend/src/tests/lib/validation/token.validation.spec.ts b/src/frontend/src/tests/lib/validation/token.validation.spec.ts index 15ec66013d..1eb70c5dee 100644 --- a/src/frontend/src/tests/lib/validation/token.validation.spec.ts +++ b/src/frontend/src/tests/lib/validation/token.validation.spec.ts @@ -1,7 +1,6 @@ import { isToken, parseTokenId } from '$lib/validation/token.validation'; import { mockValidIcToken } from '$tests/mocks/ic-tokens.mock'; import { mockValidToken } from '$tests/mocks/tokens.mock'; -import { describe, expect, it } from 'vitest'; describe('token.validation', () => { describe('parseTokenId', () => { diff --git a/src/frontend/src/tests/mocks/btc-transactions.mock.ts b/src/frontend/src/tests/mocks/btc-transactions.mock.ts new file mode 100644 index 0000000000..94f900d322 --- /dev/null +++ b/src/frontend/src/tests/mocks/btc-transactions.mock.ts @@ -0,0 +1,183 @@ +import type { BtcTransactionUi } from '$btc/types/btc'; +import type { BitcoinTransaction } from '$lib/types/blockchain'; + +export const mockBtcTransactionUi: BtcTransactionUi = { + blockNumber: 123213, + from: 'bc1q3jlulk7pw9p5tjqcrwdec9a6vdaw9pqhw0wg4g', + id: 'e793cab7e155a0e8f825c4609548faf759c57715fecac587580a1d716bb2b89e', + status: 'confirmed', + timestamp: 1727175987n, + to: 'bc1qt0nkp96r7p95xfacyp98pww2eu64yzuf78l4a2wy0sttt83hux4q6u2nl7', + type: 'receive', + value: 126527n, + confirmations: 1 +}; + +export const createMockBtcTransactionsUi = (n: number): BtcTransactionUi[] => + Array.from({ length: n }, () => ({ + ...mockBtcTransactionUi, + blockNumber: Math.floor(Math.random() * 100000), + id: crypto.randomUUID(), + timestamp: BigInt(Math.floor(Math.random() * 100000)), + value: BigInt(Math.floor(Math.random() * 100000)) + })); + +export const mockBtcTransaction: BitcoinTransaction = { + hash: 'e793cab7e155a0e8f825c4609548faf759c57715fecac587580a1d716bb2b89e', + ver: 1, + vin_sz: 1, + vout_sz: 12, + size: 562, + weight: 1918, + fee: 1019, + relayed_by: '0.0.0.0', + lock_time: 0, + tx_index: 5584515345818529, + double_spend: false, + time: 1727175987, + block_index: null, + block_height: null, + inputs: [ + { + sequence: 4294967295, + witness: + '024830450221009cbfe364a7b248dcfd65cc6fff448054122fc4f421fed7575012c84cfaed8b8202206c000eb2a68165710c4903ca6d327ada012afbdc6ba11cc49fea718efbaf96b70121027d7959dc46db36c408c3f51c5880b97a82dd43f611b6677e40dce683dd4223c4', + script: '', + index: 0, + prev_out: { + type: 0, + spent: true, + value: 202300943, + spending_outpoints: [{ tx_index: 5584515345818529, n: 0 }], + n: 6, + tx_index: 6542046908886347, + script: '00148cbfcfdbc1714345c8181b9b9c17ba637ae28417', + addr: 'bc1q3jlulk7pw9p5tjqcrwdec9a6vdaw9pqhw0wg4g' + } + } + ], + out: [ + { + type: 0, + spent: false, + value: 46642, + spending_outpoints: [], + n: 0, + tx_index: 5584515345818529, + script: 'a9141d20b026a54ac240a783a03c3056535aff8c56af87', + addr: '34M2gKELCJUQe189oWwSGHzwqwynVebzVz' + }, + { + type: 0, + spent: false, + value: 50760, + spending_outpoints: [], + n: 1, + tx_index: 5584515345818529, + script: '00141acefe0e6df5dce899ebcda855e35afb6749a25d', + addr: 'bc1qrt80urnd7hww3x0tek59tc66ldn5ngjaze4afa' + }, + { + type: 0, + spent: false, + value: 126527, + spending_outpoints: [], + n: 2, + tx_index: 5584515345818529, + script: '00205be7609743f04b4327b8204a70b9cacf35520b89f1ff5ea9c47c16b59e37e1aa', + addr: 'bc1qt0nkp96r7p95xfacyp98pww2eu64yzuf78l4a2wy0sttt83hux4q6u2nl7' + }, + { + type: 0, + spent: false, + value: 441376, + spending_outpoints: [], + n: 3, + tx_index: 5584515345818529, + script: '001454df0b1bbb1d96542cc64a7c492dc083f386bdd7', + addr: 'bc1q2n0skxamrkt9gtxxff7yjtwqs0ecd0whmv3nmf' + }, + { + type: 0, + spent: false, + value: 4218191, + spending_outpoints: [], + n: 4, + tx_index: 5584515345818529, + script: '76a9147d4210b159b85b83a046ea6c41ef736389b4d74488ac', + addr: '1CRJcGec4Zhrz4fpyN92JtezAs2fEYJsQ9' + }, + { + type: 0, + spent: false, + value: 110307, + spending_outpoints: [], + n: 5, + tx_index: 5584515345818529, + script: 'a91422a7139cef08c62e868570213596f2ab456f7b0e87', + addr: '34rF3kbzjHMWvQVT3yQCDXHmdTscBL2Mj1' + }, + { + type: 0, + spent: false, + value: 196068150, + spending_outpoints: [], + n: 6, + tx_index: 5584515345818529, + script: '001408ab827599c51bf4c9b7319bcf5efab8bea55ca6', + addr: 'bc1qpz4cyavec5dlfjdhxxdu7hh6hzl22h9x74f3t6' + }, + { + type: 0, + spent: false, + value: 37177, + spending_outpoints: [], + n: 7, + tx_index: 5584515345818529, + script: '00142e634c652a052aea42bfd214c107410fedd230f3', + addr: 'bc1q9e35cef2q54w5s4l6g2vzp6pplkayv8n4hsjgd' + }, + { + type: 0, + spent: false, + value: 141866, + spending_outpoints: [], + n: 8, + tx_index: 5584515345818529, + script: '002073a338ffa6b1ecd3ba5b40980317ea1db3594e5f0a3a4b8584f348347016cace', + addr: 'bc1qww3n3laxk8kd8wjmgzvqx9l2rke4jnjlpgayhpvy7dyrguqket8q99x3z0' + }, + { + type: 0, + spent: false, + value: 945864, + spending_outpoints: [], + n: 9, + tx_index: 5584515345818529, + script: '00148d7cb4252dc4e262b1c823412c69686ab2853551', + addr: 'bc1q347tgffdcn3x9vwgydqjc6tgd2eg2d235hq25k' + }, + { + type: 0, + spent: false, + value: 93093, + spending_outpoints: [], + n: 10, + tx_index: 5584515345818529, + script: '0014ae35caf67bf79a46ca951158f29ebbd1466cb999', + addr: 'bc1q4c6u4anm77dydj54z9v0984m69rxewveqn37p2' + }, + { + type: 0, + spent: false, + value: 19971, + spending_outpoints: [], + n: 11, + tx_index: 5584515345818529, + script: '0014bd13a6b0373052c63687d35fd5580e2bdb1b0a60', + addr: 'bc1qh5f6dvphxpfvvd586d0a2kqw90d3kznqvpx5dv' + } + ], + result: 37177, + balance: 37177 +}; diff --git a/src/frontend/src/tests/mocks/btc.mock.ts b/src/frontend/src/tests/mocks/btc.mock.ts index 9835f2d9e1..ab2238b1a4 100644 --- a/src/frontend/src/tests/mocks/btc.mock.ts +++ b/src/frontend/src/tests/mocks/btc.mock.ts @@ -1,21 +1,7 @@ -import type { BtcTransactionUi } from '$btc/types/btc'; import type { UtxosFee } from '$btc/types/btc-send'; -import type { BitcoinTransaction } from '$lib/types/blockchain'; export const mockBtcAddress = 'bc1qt0nkp96r7p95xfacyp98pww2eu64yzuf78l4a2wy0sttt83hux4q6u2nl7'; -export const mockBtcTransactionUi: BtcTransactionUi = { - blockNumber: 123213, - from: 'bc1q3jlulk7pw9p5tjqcrwdec9a6vdaw9pqhw0wg4g', - id: 'e793cab7e155a0e8f825c4609548faf759c57715fecac587580a1d716bb2b89e', - status: 'confirmed', - timestamp: 1727175987n, - to: 'bc1qt0nkp96r7p95xfacyp98pww2eu64yzuf78l4a2wy0sttt83hux4q6u2nl7', - type: 'receive', - value: 126527n, - confirmations: 1 -}; - export const mockUtxosFee: UtxosFee = { feeSatoshis: 1000n, utxos: [ @@ -29,172 +15,3 @@ export const mockUtxosFee: UtxosFee = { } ] }; - -export const createMockBtcTransactionsUi = (n: number): BtcTransactionUi[] => - Array.from({ length: n }, () => ({ - ...mockBtcTransactionUi, - blockNumber: Math.floor(Math.random() * 100000), - id: Math.random().toString(36).substring(7), - timestamp: BigInt(Math.floor(Math.random() * 100000)), - value: BigInt(Math.floor(Math.random() * 100000)) - })); - -export const mockBtcTransaction: BitcoinTransaction = { - hash: 'e793cab7e155a0e8f825c4609548faf759c57715fecac587580a1d716bb2b89e', - ver: 1, - vin_sz: 1, - vout_sz: 12, - size: 562, - weight: 1918, - fee: 1019, - relayed_by: '0.0.0.0', - lock_time: 0, - tx_index: 5584515345818529, - double_spend: false, - time: 1727175987, - block_index: null, - block_height: null, - inputs: [ - { - sequence: 4294967295, - witness: - '024830450221009cbfe364a7b248dcfd65cc6fff448054122fc4f421fed7575012c84cfaed8b8202206c000eb2a68165710c4903ca6d327ada012afbdc6ba11cc49fea718efbaf96b70121027d7959dc46db36c408c3f51c5880b97a82dd43f611b6677e40dce683dd4223c4', - script: '', - index: 0, - prev_out: { - type: 0, - spent: true, - value: 202300943, - spending_outpoints: [{ tx_index: 5584515345818529, n: 0 }], - n: 6, - tx_index: 6542046908886347, - script: '00148cbfcfdbc1714345c8181b9b9c17ba637ae28417', - addr: 'bc1q3jlulk7pw9p5tjqcrwdec9a6vdaw9pqhw0wg4g' - } - } - ], - out: [ - { - type: 0, - spent: false, - value: 46642, - spending_outpoints: [], - n: 0, - tx_index: 5584515345818529, - script: 'a9141d20b026a54ac240a783a03c3056535aff8c56af87', - addr: '34M2gKELCJUQe189oWwSGHzwqwynVebzVz' - }, - { - type: 0, - spent: false, - value: 50760, - spending_outpoints: [], - n: 1, - tx_index: 5584515345818529, - script: '00141acefe0e6df5dce899ebcda855e35afb6749a25d', - addr: 'bc1qrt80urnd7hww3x0tek59tc66ldn5ngjaze4afa' - }, - { - type: 0, - spent: false, - value: 126527, - spending_outpoints: [], - n: 2, - tx_index: 5584515345818529, - script: '00205be7609743f04b4327b8204a70b9cacf35520b89f1ff5ea9c47c16b59e37e1aa', - addr: 'bc1qt0nkp96r7p95xfacyp98pww2eu64yzuf78l4a2wy0sttt83hux4q6u2nl7' - }, - { - type: 0, - spent: false, - value: 441376, - spending_outpoints: [], - n: 3, - tx_index: 5584515345818529, - script: '001454df0b1bbb1d96542cc64a7c492dc083f386bdd7', - addr: 'bc1q2n0skxamrkt9gtxxff7yjtwqs0ecd0whmv3nmf' - }, - { - type: 0, - spent: false, - value: 4218191, - spending_outpoints: [], - n: 4, - tx_index: 5584515345818529, - script: '76a9147d4210b159b85b83a046ea6c41ef736389b4d74488ac', - addr: '1CRJcGec4Zhrz4fpyN92JtezAs2fEYJsQ9' - }, - { - type: 0, - spent: false, - value: 110307, - spending_outpoints: [], - n: 5, - tx_index: 5584515345818529, - script: 'a91422a7139cef08c62e868570213596f2ab456f7b0e87', - addr: '34rF3kbzjHMWvQVT3yQCDXHmdTscBL2Mj1' - }, - { - type: 0, - spent: false, - value: 196068150, - spending_outpoints: [], - n: 6, - tx_index: 5584515345818529, - script: '001408ab827599c51bf4c9b7319bcf5efab8bea55ca6', - addr: 'bc1qpz4cyavec5dlfjdhxxdu7hh6hzl22h9x74f3t6' - }, - { - type: 0, - spent: false, - value: 37177, - spending_outpoints: [], - n: 7, - tx_index: 5584515345818529, - script: '00142e634c652a052aea42bfd214c107410fedd230f3', - addr: 'bc1q9e35cef2q54w5s4l6g2vzp6pplkayv8n4hsjgd' - }, - { - type: 0, - spent: false, - value: 141866, - spending_outpoints: [], - n: 8, - tx_index: 5584515345818529, - script: '002073a338ffa6b1ecd3ba5b40980317ea1db3594e5f0a3a4b8584f348347016cace', - addr: 'bc1qww3n3laxk8kd8wjmgzvqx9l2rke4jnjlpgayhpvy7dyrguqket8q99x3z0' - }, - { - type: 0, - spent: false, - value: 945864, - spending_outpoints: [], - n: 9, - tx_index: 5584515345818529, - script: '00148d7cb4252dc4e262b1c823412c69686ab2853551', - addr: 'bc1q347tgffdcn3x9vwgydqjc6tgd2eg2d235hq25k' - }, - { - type: 0, - spent: false, - value: 93093, - spending_outpoints: [], - n: 10, - tx_index: 5584515345818529, - script: '0014ae35caf67bf79a46ca951158f29ebbd1466cb999', - addr: 'bc1q4c6u4anm77dydj54z9v0984m69rxewveqn37p2' - }, - { - type: 0, - spent: false, - value: 19971, - spending_outpoints: [], - n: 11, - tx_index: 5584515345818529, - script: '0014bd13a6b0373052c63687d35fd5580e2bdb1b0a60', - addr: 'bc1qh5f6dvphxpfvvd586d0a2kqw90d3kznqvpx5dv' - } - ], - result: 37177, - balance: 37177 -}; diff --git a/src/frontend/src/tests/mocks/ic-transactions.mock.ts b/src/frontend/src/tests/mocks/ic-transactions.mock.ts index 1ab7eec083..9c4de5a3b7 100644 --- a/src/frontend/src/tests/mocks/ic-transactions.mock.ts +++ b/src/frontend/src/tests/mocks/ic-transactions.mock.ts @@ -4,5 +4,6 @@ export const createMockIcTransactionsUi = (n: number): IcTransactionUi[] => Array.from({ length: n }, () => ({ id: crypto.randomUUID(), type: 'send', - status: 'executed' + status: 'executed', + value: BigInt(1) })); diff --git a/src/shared/src/types.rs b/src/shared/src/types.rs index 018a3b7eee..8b6d990604 100644 --- a/src/shared/src/types.rs +++ b/src/shared/src/types.rs @@ -271,7 +271,7 @@ pub mod signer { } } /// The default cycles ledger top up threshold. If the cycles ledger balance falls below this, it should be topped up. - pub const DEFAULT_CYCLES_LEDGER_TOP_UP_THRESHOLD: u128 = 10_000_000_000_000; // 10T + pub const DEFAULT_CYCLES_LEDGER_TOP_UP_THRESHOLD: u128 = 50_000_000_000_000; // 50T /// The proportion of the backend canister's own cycles to send to the cycles ledger. pub const DEFAULT_CYCLES_LEDGER_TOP_UP_PERCENTAGE: u8 = 50; /// The minimum sensible percentage to send to the cycles ledger.