diff --git a/.env.example b/.env.example index 7a3b65d627..63a97dac23 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,4 @@ VITE_AUTH_DERIVATION_ORIGIN= VITE_ONRAMPER_API_KEY_DEV=pk_test_ VITE_ONRAMPER_API_KEY_PROD=pk_prod_ VITE_BTC_TO_CKBTC_EXCHANGE_ENABLED= +VITE_SWAP_ACTION_ENABLED= diff --git a/.github/workflows/deploy-to-environment.yml b/.github/workflows/deploy-to-environment.yml index 4aaffd0623..f5f0721b44 100644 --- a/.github/workflows/deploy-to-environment.yml +++ b/.github/workflows/deploy-to-environment.yml @@ -104,6 +104,7 @@ jobs: echo "VITE_POUH_ENABLED=${{ secrets.VITE_POUH_ENABLED_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_SWAP_ACTION_ENABLED=${{ secrets.VITE_SWAP_ACTION_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 @@ -136,6 +137,7 @@ jobs: echo "VITE_AUTH_DERIVATION_ORIGIN=${{ secrets.VITE_AUTH_DERIVATION_ORIGIN_BETA }}" >> $GITHUB_ENV echo "VITE_BTC_TO_CKBTC_EXCHANGE_ENABLED=${{ secrets.VITE_BTC_TO_CKBTC_EXCHANGE_ENABLED_BETA }}" >> $GITHUB_ENV echo "VITE_ONRAMPER_API_KEY_DEV=${{ secrets.VITE_ONRAMPER_API_KEY_DEV_BETA }}" >> $GITHUB_ENV + echo "VITE_SWAP_ACTION_ENABLED=${{ secrets.VITE_SWAP_ACTION_ENABLED_BETA }}" >> $GITHUB_ENV echo "VITE_ONRAMPER_API_KEY_PROD=${{ secrets.VITE_ONRAMPER_API_KEY_PROD_BETA }}" >> $GITHUB_ENV { echo 'DFX_DEPLOY_KEY<=5" } }, + "node_modules/@solana/promises": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@solana/promises/-/promises-2.0.0.tgz", + "integrity": "sha512-4teQ52HDjK16ORrZe1zl+Q9WcZdQ+YEl0M1gk59XG7D0P9WqaVEQzeXGnKSCs+Y9bnB1u5xCJccwpUhHYWq6gg==", + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5" + } + }, "node_modules/@solana/rpc": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@solana/rpc/-/rpc-2.0.0.tgz", @@ -3389,6 +3402,89 @@ "typescript": ">=5" } }, + "node_modules/@solana/rpc-subscriptions": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions/-/rpc-subscriptions-2.0.0.tgz", + "integrity": "sha512-AdwMJHMrhlj7q1MPjZmVcKq3iLqMW3N0MT8kzIAP2vP+8o/d6Fn4aqGxoz2Hlfn3OYIZoYStN2VBtwzbcfEgMA==", + "license": "MIT", + "dependencies": { + "@solana/errors": "2.0.0", + "@solana/fast-stable-stringify": "2.0.0", + "@solana/functional": "2.0.0", + "@solana/promises": "2.0.0", + "@solana/rpc-spec-types": "2.0.0", + "@solana/rpc-subscriptions-api": "2.0.0", + "@solana/rpc-subscriptions-channel-websocket": "2.0.0", + "@solana/rpc-subscriptions-spec": "2.0.0", + "@solana/rpc-transformers": "2.0.0", + "@solana/rpc-types": "2.0.0", + "@solana/subscribable": "2.0.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5" + } + }, + "node_modules/@solana/rpc-subscriptions-api": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions-api/-/rpc-subscriptions-api-2.0.0.tgz", + "integrity": "sha512-NAJQvSFXYIIf8zxsMFBCkSbZNZgT32pzPZ1V6ZAd+U2iDEjx3L+yFwoJgfOcHp8kAV+alsF2lIsGBlG4u+ehvw==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "2.0.0", + "@solana/keys": "2.0.0", + "@solana/rpc-subscriptions-spec": "2.0.0", + "@solana/rpc-transformers": "2.0.0", + "@solana/rpc-types": "2.0.0", + "@solana/transaction-messages": "2.0.0", + "@solana/transactions": "2.0.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5" + } + }, + "node_modules/@solana/rpc-subscriptions-channel-websocket": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions-channel-websocket/-/rpc-subscriptions-channel-websocket-2.0.0.tgz", + "integrity": "sha512-hSQDZBmcp2t+gLZsSBqs/SqVw4RuNSC7njiP46azyzW7oGg8X2YPV36AHGsHD12KPsc0UpT1OAZ4+AN9meVKww==", + "license": "MIT", + "dependencies": { + "@solana/errors": "2.0.0", + "@solana/functional": "2.0.0", + "@solana/rpc-subscriptions-spec": "2.0.0", + "@solana/subscribable": "2.0.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5", + "ws": "^8.18.0" + } + }, + "node_modules/@solana/rpc-subscriptions-spec": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions-spec/-/rpc-subscriptions-spec-2.0.0.tgz", + "integrity": "sha512-VXMiI3fYtU1PkVVTXL87pcY48ZY8aCi1N6FqtxSP2xg/GASL01j1qbwyIL1OvoCqGyRgIxdd/YfaByW9wmWBhA==", + "license": "MIT", + "dependencies": { + "@solana/errors": "2.0.0", + "@solana/promises": "2.0.0", + "@solana/rpc-spec-types": "2.0.0", + "@solana/subscribable": "2.0.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5" + } + }, "node_modules/@solana/rpc-transformers": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@solana/rpc-transformers/-/rpc-transformers-2.0.0.tgz", @@ -3450,6 +3546,21 @@ "typescript": ">=5" } }, + "node_modules/@solana/subscribable": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@solana/subscribable/-/subscribable-2.0.0.tgz", + "integrity": "sha512-Ex7d2GnTSNVMZDU3z6nKN4agRDDgCgBDiLnmn1hmt0iFo3alr3gRAqiqa7qGouAtYh9/29pyc8tVJCijHWJPQQ==", + "license": "MIT", + "dependencies": { + "@solana/errors": "2.0.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5" + } + }, "node_modules/@solana/transaction-messages": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@solana/transaction-messages/-/transaction-messages-2.0.0.tgz", diff --git a/package.json b/package.json index 35da0e30b2..e67b4e319b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dfinity/oisy-wallet", - "version": "0.11.0", + "version": "0.12.0", "private": true, "license": "Apache-2.0", "repository": { @@ -66,6 +66,7 @@ "@metamask/detect-provider": "^2.0.0", "@solana/addresses": "^2.0.0", "@solana/rpc": "^2.0.0", + "@solana/rpc-subscriptions": "^2.0.0", "@walletconnect/web3wallet": "1.14.0", "alchemy-sdk": "3.4.1", "buffer": "^6.0.3", diff --git a/scripts/build.utils.mjs b/scripts/build.utils.mjs index bb6a021a26..1387d41f46 100644 --- a/scripts/build.utils.mjs +++ b/scripts/build.utils.mjs @@ -7,9 +7,11 @@ export const findHtmlFiles = (dir = join(process.cwd(), 'build')) => export const ENV = process.env.ENV === 'ic' ? 'production' - : ['staging', 'beta'].includes(process.env.ENV) - ? process.env.ENV - : 'development'; + : (process.env.ENV ?? '').startsWith('test_fe_') + ? 'staging' + : ['staging', 'beta'].includes(process.env.ENV) + ? process.env.ENV + : 'development'; export const replaceEnv = ({ content, pattern, value }) => { const regex = new RegExp(pattern, 'g'); diff --git a/scripts/deploy.backend.sh b/scripts/deploy.backend.sh index 3b61bd396c..49739149ea 100755 --- a/scripts/deploy.backend.sh +++ b/scripts/deploy.backend.sh @@ -4,6 +4,8 @@ II_CANISTER_ID="$(dfx canister id internet_identity --network "${ENV:-local}")" BACKEND_CANISTER_ID="$(dfx canister id backend --network "${ENV:-local}")" POUH_ISSUER_CANISTER_ID="$(dfx canister id pouh_issuer --network "${ENV:-local}")" SIGNER_CANISTER_ID="$(dfx canister id signer --network "${ENV:-local}")" +# Rewards canister is optional for local deployments: +REWARDS_CANISTER_ID="$(dfx canister id rewards --network "${ENV:-local}" || [[ "${ENV:-local}" == "local" ]])" case $ENV in "staging") @@ -41,6 +43,13 @@ case $ENV in ;; esac +# If the rewards canister is known, it may perform priviliged actions such as find which users are eligible for rewards. +if [[ "${REWARDS_CANISTER_ID:-}" == "" ]]; then + ALLOWED_CALLERS="vec {}" +else + ALLOWED_CALLERS="vec{ principal \"$REWARDS_CANISTER_ID\" }" +fi + # URL used by II-issuer in the id_alias-verifiable credentials (hard-coded in II) # Represents more an ID than a URL II_VC_URL="https://identity.ic0.app" @@ -51,7 +60,7 @@ if [ -n "${ENV+1}" ]; then dfx deploy backend --argument "(variant { Init = record { ecdsa_key_name = \"$ECDSA_KEY_NAME\"; - allowed_callers = vec {}; + allowed_callers = $ALLOWED_CALLERS; cfs_canister_id = opt principal \"$SIGNER_CANISTER_ID\"; derivation_origin = opt \"$DERIVATION_ORIGIN\"; supported_credentials = opt vec { @@ -71,7 +80,7 @@ else dfx deploy backend --argument "(variant { Init = record { ecdsa_key_name = \"$ECDSA_KEY_NAME\"; - allowed_callers = vec {}; + allowed_callers = $ALLOWED_CALLERS; cfs_canister_id = opt principal \"$SIGNER_CANISTER_ID\"; derivation_origin = opt \"$DERIVATION_ORIGIN\"; supported_credentials = opt vec { diff --git a/scripts/did.update.types.mjs b/scripts/did.update.types.mjs index 688e680779..5b13afb8b6 100644 --- a/scripts/did.update.types.mjs +++ b/scripts/did.update.types.mjs @@ -105,7 +105,7 @@ const copyCertifiedFactory = async ({ dest = `./src/declarations` }) => { const certifiedFactoryPath = join(dest, dir, `${dir}.factory.certified.did.js`); - await writeFile(certifiedFactoryPath, content.toString().replace(/\['query']/g, '')); + await writeFile(certifiedFactoryPath, content.toString().replace(/\['query'],?/g, '')); resolve(); }; diff --git a/src/frontend/src/btc/schedulers/btc-wallet.scheduler.ts b/src/frontend/src/btc/schedulers/btc-wallet.scheduler.ts index 3a27c073e2..8842db60dc 100644 --- a/src/frontend/src/btc/schedulers/btc-wallet.scheduler.ts +++ b/src/frontend/src/btc/schedulers/btc-wallet.scheduler.ts @@ -187,6 +187,7 @@ export class BtcWalletScheduler implements Scheduler const newBalance = isNullish(this.store.balance) || this.store.balance.data !== balance.data || + // TODO, align with sol-wallet.scheduler.ts, crash if certified changes (!this.store.balance.certified && balance.certified); const newTransactions = uncertifiedTransactions.length > 0; diff --git a/src/frontend/src/env/actions.env.ts b/src/frontend/src/env/actions.env.ts new file mode 100644 index 0000000000..8628d9c780 --- /dev/null +++ b/src/frontend/src/env/actions.env.ts @@ -0,0 +1,3 @@ +// TODO: to be removed when the feature is fully implemented +export const SWAP_ACTION_ENABLED = + JSON.parse(import.meta.env.VITE_SWAP_ACTION_ENABLED ?? false) === true; diff --git a/src/frontend/src/env/networks/networks.icrc.env.ts b/src/frontend/src/env/networks/networks.icrc.env.ts index 91065292c1..a2b79fd9ca 100644 --- a/src/frontend/src/env/networks/networks.icrc.env.ts +++ b/src/frontend/src/env/networks/networks.icrc.env.ts @@ -396,6 +396,20 @@ const BURN_IC_DATA: IcInterface | undefined = nonNullish(ADDITIONAL_ICRC_PRODUCT } : undefined; +const POPEYE_IC_DATA: IcInterface | undefined = nonNullish(ADDITIONAL_ICRC_PRODUCTION_DATA?.POPEYE) + ? { + ...ADDITIONAL_ICRC_PRODUCTION_DATA.POPEYE, + position: 13 + } + : undefined; + +const CLOUD_IC_DATA: IcInterface | undefined = nonNullish(ADDITIONAL_ICRC_PRODUCTION_DATA?.CLOUD) + ? { + ...ADDITIONAL_ICRC_PRODUCTION_DATA.CLOUD, + position: 14 + } + : 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) @@ -460,7 +474,11 @@ const ICRC_CK_TOKENS: IcInterface[] = [ ...(nonNullish(CKXAUT_IC_DATA) ? [CKXAUT_IC_DATA] : []) ]; -const ADDITIONAL_ICRC_TOKENS: IcInterface[] = [...(nonNullish(BURN_IC_DATA) ? [BURN_IC_DATA] : [])]; +const ADDITIONAL_ICRC_TOKENS: IcInterface[] = [ + ...(nonNullish(BURN_IC_DATA) ? [BURN_IC_DATA] : []), + ...(nonNullish(POPEYE_IC_DATA) ? [POPEYE_IC_DATA] : []), + ...(nonNullish(CLOUD_IC_DATA) ? [CLOUD_IC_DATA] : []) +]; export const ICRC_TOKENS: IcInterface[] = [ ...PUBLIC_ICRC_TOKENS, diff --git a/src/frontend/src/env/networks/networks.sol.env.ts b/src/frontend/src/env/networks/networks.sol.env.ts index 286453b531..799bbc1f49 100644 --- a/src/frontend/src/env/networks/networks.sol.env.ts +++ b/src/frontend/src/env/networks/networks.sol.env.ts @@ -18,6 +18,11 @@ export const SOLANA_RPC_HTTP_URL_TESTNET = 'https://api.testnet.solana.com'; export const SOLANA_RPC_HTTP_URL_DEVNET = `https://solana-devnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}`; export const SOLANA_RPC_HTTP_URL_LOCAL = 'http://localhost:8899'; +export const SOLANA_RPC_WS_URL_MAINNET = 'wss://api.mainnet-beta.solana.com/'; +export const SOLANA_RPC_WS_URL_TESTNET = 'wss://api.testnet.solana.com/'; +export const SOLANA_RPC_WS_URL_DEVNET = 'wss://api.devnet.solana.com/'; +export const SOLANA_RPC_WS_URL_LOCAL = 'ws://localhost:8900'; + /** * SOL */ diff --git a/src/frontend/src/env/tokens/tokens.icrc.json b/src/frontend/src/env/tokens/tokens.icrc.json index 76f635f9c5..b909f6f960 100644 --- a/src/frontend/src/env/tokens/tokens.icrc.json +++ b/src/frontend/src/env/tokens/tokens.icrc.json @@ -2,5 +2,13 @@ "BURN": { "indexCanisterId": "nrant-tyaaa-aaaag-atsjq-cai", "ledgerCanisterId": "egjwt-lqaaa-aaaak-qi2aa-cai" + }, + "POPEYE": { + "indexCanisterId": "gg3c3-6iaaa-aaaah-aq6dq-cai", + "ledgerCanisterId": "6fvyi-faaaa-aaaam-qbiga-cai" + }, + "CLOUD": { + "indexCanisterId": "72uqs-pqaaa-aaaak-aes7a-cai", + "ledgerCanisterId": "pcj6u-uaaaa-aaaak-aewnq-cai" } } diff --git a/src/frontend/src/eth/components/send/SendAmount.svelte b/src/frontend/src/eth/components/send/EthSendAmount.svelte similarity index 100% rename from src/frontend/src/eth/components/send/SendAmount.svelte rename to src/frontend/src/eth/components/send/EthSendAmount.svelte diff --git a/src/frontend/src/eth/components/send/SendDestination.svelte b/src/frontend/src/eth/components/send/EthSendDestination.svelte similarity index 100% rename from src/frontend/src/eth/components/send/SendDestination.svelte rename to src/frontend/src/eth/components/send/EthSendDestination.svelte diff --git a/src/frontend/src/eth/components/send/SendForm.svelte b/src/frontend/src/eth/components/send/SendForm.svelte index bc77400d8b..0fad8f03b1 100644 --- a/src/frontend/src/eth/components/send/SendForm.svelte +++ b/src/frontend/src/eth/components/send/SendForm.svelte @@ -2,8 +2,8 @@ import { isNullish } from '@dfinity/utils'; import { createEventDispatcher, getContext } from 'svelte'; import FeeDisplay from '$eth/components/fee/FeeDisplay.svelte'; - import SendAmount from '$eth/components/send/SendAmount.svelte'; - import SendDestination from '$eth/components/send/SendDestination.svelte'; + import EthSendAmount from '$eth/components/send/EthSendAmount.svelte'; + import EthSendDestination from '$eth/components/send/EthSendDestination.svelte'; import SendInfo from '$eth/components/send/SendInfo.svelte'; import SendNetworkICP from '$eth/components/send/SendNetworkICP.svelte'; import type { EthereumNetwork } from '$eth/types/network'; @@ -42,7 +42,7 @@
dispatch('icNext')} method="POST"> {#if destinationEditable} - {/if} - + ): Promise => { + const { getUserInfo } = await rewardCanister({ identity }); + + return getUserInfo({ certified }); +}; + +export const getNewVipReward = async ({ + identity +}: CanisterApiFunctionParams): Promise => { + const { getNewVipReward } = await rewardCanister({ identity }); + + return getNewVipReward(); +}; + +export const claimVipReward = async ({ + vipReward, + identity +}: CanisterApiFunctionParams<{ + vipReward: VipReward; +}>): Promise => { + const { claimVipReward } = await rewardCanister({ identity }); + + return claimVipReward(vipReward); +}; + +const rewardCanister = async ({ + identity, + nullishIdentityErrorMessage, + canisterId = REWARDS_CANISTER_ID +}: CanisterApiFunctionParams): Promise => { + assertNonNullish(identity, nullishIdentityErrorMessage); + + if (isNullish(canister)) { + canister = await RewardCanister.create({ + identity, + canisterId: Principal.fromText(canisterId) + }); + } + + return canister; +}; diff --git a/src/frontend/src/lib/assets/failed-reward.svg b/src/frontend/src/lib/assets/failed-reward.svg new file mode 100644 index 0000000000..59867936dc --- /dev/null +++ b/src/frontend/src/lib/assets/failed-reward.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/frontend/src/lib/assets/successful-reward.svg b/src/frontend/src/lib/assets/successful-reward.svg new file mode 100644 index 0000000000..0ced10b25f --- /dev/null +++ b/src/frontend/src/lib/assets/successful-reward.svg @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/frontend/src/lib/components/core/Menu.svelte b/src/frontend/src/lib/components/core/Menu.svelte index 9a887e0815..683348024e 100644 --- a/src/frontend/src/lib/components/core/Menu.svelte +++ b/src/frontend/src/lib/components/core/Menu.svelte @@ -1,12 +1,15 @@ + + + +{#if $modalSuccessfulRewardModal} + +{:else if $modalFailedRewardModal} + +{/if} diff --git a/src/frontend/src/lib/components/icons/IconVipQr.svelte b/src/frontend/src/lib/components/icons/IconVipQr.svelte new file mode 100644 index 0000000000..02bb182731 --- /dev/null +++ b/src/frontend/src/lib/components/icons/IconVipQr.svelte @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + diff --git a/src/frontend/src/lib/components/loaders/LoaderWallets.svelte b/src/frontend/src/lib/components/loaders/LoaderWallets.svelte index dfbbdee7e8..b4e4167953 100644 --- a/src/frontend/src/lib/components/loaders/LoaderWallets.svelte +++ b/src/frontend/src/lib/components/loaders/LoaderWallets.svelte @@ -1,10 +1,13 @@ - + + + diff --git a/src/frontend/src/lib/components/loaders/Loaders.svelte b/src/frontend/src/lib/components/loaders/Loaders.svelte index 6e8bcd25df..c1c3e84017 100644 --- a/src/frontend/src/lib/components/loaders/Loaders.svelte +++ b/src/frontend/src/lib/components/loaders/Loaders.svelte @@ -2,6 +2,7 @@ import LoaderEthBalances from '$eth/components/loaders/LoaderEthBalances.svelte'; import ExchangeWorker from '$lib/components/exchange/ExchangeWorker.svelte'; import AddressGuard from '$lib/components/guard/AddressGuard.svelte'; + import RewardGuard from '$lib/components/guard/RewardGuard.svelte'; import Loader from '$lib/components/loaders/Loader.svelte'; import LoaderMetamask from '$lib/components/loaders/LoaderMetamask.svelte'; import LoaderUserProfile from '$lib/components/loaders/LoaderUserProfile.svelte'; @@ -10,16 +11,18 @@ - - - - - - - - - - + + + + + + + + + + + + diff --git a/src/frontend/src/lib/components/manage/ManageTokens.svelte b/src/frontend/src/lib/components/manage/ManageTokens.svelte index 78b4c2706f..65e0600065 100644 --- a/src/frontend/src/lib/components/manage/ManageTokens.svelte +++ b/src/frontend/src/lib/components/manage/ManageTokens.svelte @@ -32,6 +32,8 @@ import { isNullishOrEmpty } from '$lib/utils/input.utils'; import { filterTokensForSelectedNetwork } from '$lib/utils/network.utils'; import { pinEnabledTokensAtTop, sortTokens } from '$lib/utils/tokens.utils'; + import SolManageTokenToggle from '$sol/components/tokens/SolManageTokenToggle.svelte'; + import { isSolanaToken } from '$sol/utils/token.utils'; const dispatch = createEventDispatcher(); @@ -201,6 +203,8 @@ {:else if isBitcoinToken(token)} + {:else if isSolanaToken(token)} + {/if} diff --git a/src/frontend/src/lib/components/qr/FailedRewardModal.svelte b/src/frontend/src/lib/components/qr/FailedRewardModal.svelte new file mode 100644 index 0000000000..d3e0691986 --- /dev/null +++ b/src/frontend/src/lib/components/qr/FailedRewardModal.svelte @@ -0,0 +1,33 @@ + + + + + {$i18n.vip.reward.text.title_failed} + + + + + +

{$i18n.vip.reward.text.reward_failed}

+ {$i18n.vip.reward.text.reward_failed_description} + + +
+
diff --git a/src/frontend/src/lib/components/qr/SuccessfulRewardModal.svelte b/src/frontend/src/lib/components/qr/SuccessfulRewardModal.svelte new file mode 100644 index 0000000000..6e80f9595b --- /dev/null +++ b/src/frontend/src/lib/components/qr/SuccessfulRewardModal.svelte @@ -0,0 +1,34 @@ + + + + + {$i18n.vip.reward.text.title_successful} + + + + + +

{$i18n.vip.reward.text.reward_received}

+ {$i18n.vip.reward.text.reward_received_description} + + +
+
diff --git a/src/frontend/src/lib/components/ui/InProgressWizard.svelte b/src/frontend/src/lib/components/ui/InProgressWizard.svelte index 24e194ed03..194b9917e8 100644 --- a/src/frontend/src/lib/components/ui/InProgressWizard.svelte +++ b/src/frontend/src/lib/components/ui/InProgressWizard.svelte @@ -15,10 +15,14 @@ export let steps: ProgressSteps; export let warningType: 'transaction' | 'manage' = 'transaction'; - const startConfirmToClose = () => dirtyWizardState.set(true); - const stopConfirmToClose = () => dirtyWizardState.set(false); - - $: confirmToCloseBrowser($dirtyWizardState); + const startConfirmToClose = () => { + dirtyWizardState.set(true); + confirmToCloseBrowser(true); + }; + const stopConfirmToClose = () => { + dirtyWizardState.set(false); + confirmToCloseBrowser(false); + }; onMount(startConfirmToClose); onDestroy(stopConfirmToClose); diff --git a/src/frontend/src/lib/constants/app.constants.ts b/src/frontend/src/lib/constants/app.constants.ts index 04b20ae9a6..7bbfca587f 100644 --- a/src/frontend/src/lib/constants/app.constants.ts +++ b/src/frontend/src/lib/constants/app.constants.ts @@ -51,6 +51,12 @@ export const BACKEND_CANISTER_ID = LOCAL export const BACKEND_CANISTER_PRINCIPAL = Principal.fromText(BACKEND_CANISTER_ID); +export const REWARDS_CANISTER_ID = LOCAL + ? import.meta.env.VITE_LOCAL_REWARDS_CANISTER_ID + : STAGING + ? import.meta.env.VITE_STAGING_REWARDS_CANISTER_ID + : import.meta.env.VITE_IC_REWARDS_CANISTER_ID; + export const SIGNER_CANISTER_ID = LOCAL ? import.meta.env.VITE_LOCAL_SIGNER_CANISTER_ID : STAGING diff --git a/src/frontend/src/lib/constants/test-ids.constants.ts b/src/frontend/src/lib/constants/test-ids.constants.ts index 68497a3465..4867369c2c 100644 --- a/src/frontend/src/lib/constants/test-ids.constants.ts +++ b/src/frontend/src/lib/constants/test-ids.constants.ts @@ -1,5 +1,6 @@ export const NAVIGATION_MENU_BUTTON = 'navigation-menu-button'; export const NAVIGATION_MENU = 'navigation-menu'; +export const NAVIGATION_MENU_VIP_BUTTON = 'navigation-menu-vip-button'; export const LOGOUT_BUTTON = 'logout-button'; export const LOGIN_BUTTON = 'login-button'; diff --git a/src/frontend/src/lib/derived/exchange.derived.ts b/src/frontend/src/lib/derived/exchange.derived.ts index 03f2d36c90..7af940c517 100644 --- a/src/frontend/src/lib/derived/exchange.derived.ts +++ b/src/frontend/src/lib/derived/exchange.derived.ts @@ -91,9 +91,11 @@ export const exchanges: Readable = derived( ? ckEthereumPrice : exchangeCoinId === 'bitcoin' ? btcPrice - : exchangeCoinId === 'internet-computer' - ? icpPrice - : undefined + : exchangeCoinId === 'solana' + ? solPrice + : exchangeCoinId === 'internet-computer' + ? icpPrice + : undefined }; }, {}) }; diff --git a/src/frontend/src/lib/derived/modal.derived.ts b/src/frontend/src/lib/derived/modal.derived.ts index cccc85a24d..eb6687357b 100644 --- a/src/frontend/src/lib/derived/modal.derived.ts +++ b/src/frontend/src/lib/derived/modal.derived.ts @@ -117,6 +117,14 @@ export const modalDAppDetails: Readable = derived( modalStore, ($modalStore) => $modalStore?.type === 'dapp-details' ); +export const modalSuccessfulRewardModal: Readable = derived( + modalStore, + ($modalStore) => $modalStore?.type === 'successful-reward' +); +export const modalFailedRewardModal: Readable = derived( + modalStore, + ($modalStore) => $modalStore?.type === 'failed-reward' +); export const modalWalletConnect: Readable = derived( [modalWalletConnectAuth, modalWalletConnectSign, modalWalletConnectSend], diff --git a/src/frontend/src/lib/i18n/en.json b/src/frontend/src/lib/i18n/en.json index 95cbd8f2c8..1705a51a5f 100644 --- a/src/frontend/src/lib/i18n/en.json +++ b/src/frontend/src/lib/i18n/en.json @@ -41,7 +41,8 @@ "source_code": "Source code", "changelog": "Changelog", "submit_ticket": "Submit a ticket", - "confirm_navigate": "Are you sure you want to navigate away?" + "confirm_navigate": "Are you sure you want to navigate away?", + "vip_qr_code": "VIP QR codes" }, "alt": { "tokens": "Go to the assets view", @@ -52,7 +53,8 @@ "menu": "Your wallet address, settings, sign-out and external links", "changelog": "Open the changelog of $oisy_name on GitHub to review the latest updates", "submit_ticket": "Report an issue or request a feature on GitHub", - "open_twitter": "Open the DFINITY X/Twitter feed" + "open_twitter": "Open the DFINITY X/Twitter feed", + "vip_qr_code": "Generate an invitation link" } }, "auth": { @@ -156,6 +158,7 @@ "no_infura_erc20_provider": "No Infura ERC20 provider for network $network", "no_infura_erc20_icp_provider": "No Infura ERC20 Icp provider for network $network", "no_solana_rpc": "No Solana RPC for network $network", + "no_solana_network": "No Solana network for network $network", "eth_address_unknown": "ETH address is unknown.", "loading_address": "Error while loading the $symbol address.", "loading_balance": "Error while loading the ETH balance.", @@ -745,6 +748,24 @@ } } }, + "vip": { + "reward": { + "text": { + "open_wallet": "Take me to the wallet", + "title_successful": "Congratulations!", + "title_failed": "Something went wrong", + "reward_received": "You've received a welcome gift!", + "reward_failed": "Oops, the link has expired", + "reward_received_description": "You've just received your welcome tokens from Oisy", + "reward_failed_description": "Request a new one and try again" + }, + "error": { + "loading_reward": "Error while loading a new reward code.", + "loading_user_data": "Failed to load user data from reward canister.", + "claiming_reward": "Error while claiming reward." + } + } + }, "signer": { "sign_in": { "text": { diff --git a/src/frontend/src/lib/schema/transaction.schema.ts b/src/frontend/src/lib/schema/transaction.schema.ts index 31c0012347..131518d37d 100644 --- a/src/frontend/src/lib/schema/transaction.schema.ts +++ b/src/frontend/src/lib/schema/transaction.schema.ts @@ -14,6 +14,8 @@ export const ethTransactionTypes = z.enum([...commonTypes, ...ethSpecificTypes]) export const icpTransactionTypes = z.enum([...commonTypes, ...icpSpecificTypes]); +export const solTransactionTypes = z.enum(commonTypes); + export const TransactionTypeSchema = z.enum(allTypes); export const TransactionStatusSchema = z.enum(['confirmed', 'pending', 'unconfirmed']); diff --git a/src/frontend/src/lib/services/auth.services.ts b/src/frontend/src/lib/services/auth.services.ts index b819da1372..17f7bf91b9 100644 --- a/src/frontend/src/lib/services/auth.services.ts +++ b/src/frontend/src/lib/services/auth.services.ts @@ -121,6 +121,11 @@ const clearTestnetsOption = async () => { testnetsStore.reset({ key: 'testnets' }); }; +// eslint-disable-next-line require-await +const clearSessionStorage = async () => { + sessionStorage.clear(); +}; + const logout = async ({ msg = undefined, clearStorages = true, @@ -142,6 +147,8 @@ const logout = async ({ ]); } + await clearSessionStorage(); + await authStore.signOut(); if (msg) { diff --git a/src/frontend/src/lib/services/reward-code.services.ts b/src/frontend/src/lib/services/reward-code.services.ts new file mode 100644 index 0000000000..138e8c957c --- /dev/null +++ b/src/frontend/src/lib/services/reward-code.services.ts @@ -0,0 +1,149 @@ +import type { VipReward } from '$declarations/rewards/rewards.did'; +import { + claimVipReward as claimVipRewardApi, + getNewVipReward as getNewVipRewardApi, + getUserInfo as getUserInfoApi +} from '$lib/api/reward.api'; +import { i18n } from '$lib/stores/i18n.store'; +import { toastsError } from '$lib/stores/toasts.store'; +import { AlreadyClaimedError, InvalidCodeError, UserNotVipError } from '$lib/types/errors'; +import type { ResultSuccess } from '$lib/types/utils'; +import type { Identity } from '@dfinity/agent'; +import { fromNullable } from '@dfinity/utils'; +import { get } from 'svelte/store'; + +const queryVipUser = async (params: { + identity: Identity; + certified: boolean; +}): Promise => { + const userData = await getUserInfoApi({ + ...params, + nullishIdentityErrorMessage: get(i18n).auth.error.no_internet_identity + }); + + return { success: fromNullable(userData.is_vip) === true }; +}; + +/** + * Checks if a user is a VIP user. + * + * This function performs **always** a query (not certified) to determine the VIP status of a user. + * + * @async + * @param {Object} params - The parameters required to check VIP status. + * @param {Identity} params.identity - The user's identity for authentication. + * @returns {Promise} - Resolves with the result indicating if the user is a VIP. + * + * @throws {Error} Displays an error toast and logs the error if the query fails. + */ +export const isVipUser = async (params: { identity: Identity }): Promise => { + try { + return await queryVipUser({ ...params, certified: false }); + } catch (err: unknown) { + const { vip } = get(i18n); + toastsError({ + msg: { text: vip.reward.error.loading_user_data }, + err + }); + + return { success: false, err }; + } +}; + +const updateReward = async (identity: Identity): Promise => { + const response = await getNewVipRewardApi({ + identity, + nullishIdentityErrorMessage: get(i18n).auth.error.no_internet_identity + }); + + if ('VipReward' in response) { + return response.VipReward; + } + if ('NotImportantPerson' in response) { + throw new UserNotVipError(); + } + throw new Error('Unknown error'); +}; + +/** + * Generates a new VIP reward code. + * + * This function **always** makes an **update** call and cannot be a query. + * + * @async + * @param {Identity} identity - The user's identity for authentication. + * @returns {Promise} - Resolves with the generated VIP reward or `undefined` if the operation fails. + * + * @throws {Error} Displays an error toast and logs the error if the update call fails. + */ +export const getNewReward = async (identity: Identity): Promise => { + try { + return await updateReward(identity); + } catch (err: unknown) { + const { vip } = get(i18n); + toastsError({ + msg: { text: vip.reward.error.loading_reward }, + err + }); + } + + // TODO: should return a ResultSuccess +}; + +const updateVipReward = async ({ + identity, + code +}: { + identity: Identity; + code: string; +}): Promise => { + const response = await claimVipRewardApi({ + identity, + vipReward: { code }, + nullishIdentityErrorMessage: get(i18n).auth.error.no_internet_identity + }); + + if ('Success' in response) { + return; + } + + if ('InvalidCode' in response) { + throw new InvalidCodeError(); + } + + if ('AlreadyClaimed' in response) { + throw new AlreadyClaimedError(); + } + + throw new Error('Unknown error'); +}; + +/** + * Claims a VIP reward using a provided reward code. + * + * This function **always** makes an **update** call and cannot be a query. + * + * @async + * @param {Object} params - The parameters required to claim the reward. + * @param {Identity} params.identity - The user's identity for authentication. + * @param {string} params.code - The reward code to claim the VIP reward. + * @returns {Promise} - Resolves with the result of the claim if successful. + * + * @throws {Error} Throws an error if the update call fails or the reward cannot be claimed. + */ +export const claimVipReward = async (params: { + identity: Identity; + code: string; +}): Promise => { + try { + await updateVipReward(params); + return { success: true }; + } catch (err: unknown) { + const { vip } = get(i18n); + toastsError({ + msg: { text: vip.reward.error.claiming_reward }, + err + }); + return { success: false, err }; + } +}; diff --git a/src/frontend/src/lib/stores/modal.store.ts b/src/frontend/src/lib/stores/modal.store.ts index bbeda45447..bf9e82518c 100644 --- a/src/frontend/src/lib/stores/modal.store.ts +++ b/src/frontend/src/lib/stores/modal.store.ts @@ -31,7 +31,9 @@ export interface Modal { | 'receive-bitcoin' | 'about-why-oisy' | 'btc-transaction' - | 'dapp-details'; + | 'dapp-details' + | 'successful-reward' + | 'failed-reward'; data?: T; id?: symbol; } @@ -68,6 +70,8 @@ export interface ModalStore extends Readable> { openReceiveBitcoin: () => void; openAboutWhyOisy: () => void; openDappDetails: (data: D) => void; + openSuccessfulReward: () => void; + openFailedReward: () => void; close: () => void; } @@ -113,6 +117,8 @@ const initModalStore = (): ModalStore => { openReceiveBitcoin: setType('receive-bitcoin'), openAboutWhyOisy: setType('about-why-oisy'), openDappDetails: setTypeWithData('dapp-details'), + openSuccessfulReward: setType('successful-reward'), + openFailedReward: setType('failed-reward'), close: () => set(null), subscribe }; diff --git a/src/frontend/src/lib/stores/transactions.store.ts b/src/frontend/src/lib/stores/transactions.store.ts index be5beb3715..e7c2906915 100644 --- a/src/frontend/src/lib/stores/transactions.store.ts +++ b/src/frontend/src/lib/stores/transactions.store.ts @@ -3,6 +3,7 @@ import type { IcTransactionUi } from '$icp/types/ic-transaction'; import { initCertifiedStore, type CertifiedStore } from '$lib/stores/certified.store'; import type { CertifiedData } from '$lib/types/store'; import type { TokenId } from '$lib/types/token'; +import type { SolTransactionUi } from '$sol/types/sol-transaction'; import { nonNullish } from '@dfinity/utils'; export type CertifiedTransaction = CertifiedData; @@ -19,7 +20,7 @@ export interface TransactionsStore extends CertifiedStore } export const initTransactionsStore = < - T extends IcTransactionUi | BtcTransactionUi + T extends IcTransactionUi | BtcTransactionUi | SolTransactionUi >(): TransactionsStore => { const { subscribe, update, reset } = initCertifiedStore>(); diff --git a/src/frontend/src/lib/types/errors.ts b/src/frontend/src/lib/types/errors.ts index b5f547beee..ac17808669 100644 --- a/src/frontend/src/lib/types/errors.ts +++ b/src/frontend/src/lib/types/errors.ts @@ -2,6 +2,10 @@ import type { TokenId } from '$lib/types/token'; export class UserProfileNotFoundError extends Error {} +export class UserNotVipError extends Error {} +export class InvalidCodeError extends Error {} +export class AlreadyClaimedError extends Error {} + export class LoadIdbAddressError extends Error { constructor(private readonly _tokenId: TokenId) { super(); diff --git a/src/frontend/src/lib/types/i18n.d.ts b/src/frontend/src/lib/types/i18n.d.ts index 1b7a29b18e..73e96b9c2a 100644 --- a/src/frontend/src/lib/types/i18n.d.ts +++ b/src/frontend/src/lib/types/i18n.d.ts @@ -38,6 +38,7 @@ interface I18nNavigation { changelog: string; submit_ticket: string; confirm_navigate: string; + vip_qr_code: string; }; alt: { tokens: string; @@ -49,6 +50,7 @@ interface I18nNavigation { changelog: string; submit_ticket: string; open_twitter: string; + vip_qr_code: string; }; } @@ -135,6 +137,7 @@ interface I18nInit { no_infura_erc20_provider: string; no_infura_erc20_icp_provider: string; no_solana_rpc: string; + no_solana_network: string; eth_address_unknown: string; loading_address: string; loading_balance: string; @@ -655,6 +658,21 @@ interface I18nAbout { }; } +interface I18nVip { + reward: { + text: { + open_wallet: string; + title_successful: string; + title_failed: string; + reward_received: string; + reward_failed: string; + reward_received_description: string; + reward_failed_description: string; + }; + error: { loading_reward: string; loading_user_data: string; claiming_reward: string }; + }; +} + interface I18nSigner { sign_in: { text: { access_your_wallet: string; open_or_create: string } }; idle: { text: { waiting: string }; alt: { img_placeholder: string } }; @@ -743,6 +761,7 @@ interface I18n { transaction: I18nTransaction; transactions: I18nTransactions; about: I18nAbout; + vip: I18nVip; signer: I18nSigner; carousel: I18nCarousel; license_agreement: I18nLicense_agreement; diff --git a/src/frontend/src/lib/types/transaction.ts b/src/frontend/src/lib/types/transaction.ts index 67155acd6c..eb98a8023d 100644 --- a/src/frontend/src/lib/types/transaction.ts +++ b/src/frontend/src/lib/types/transaction.ts @@ -6,6 +6,7 @@ import type { TransactionTypeSchema } from '$lib/schema/transaction.schema'; import type { Token } from '$lib/types/token'; +import type { SolTransactionUi } from '$sol/types/sol-transaction'; import type { TransactionResponse } from '@ethersproject/abstract-provider'; import type { BigNumber } from '@ethersproject/bignumber'; import type { FeeData } from '@ethersproject/providers'; @@ -33,7 +34,11 @@ export type TransactionUiCommon = Pick { try { - localStorage.setItem(key, 'true'); + sessionStorage.setItem(key, 'true'); } catch (err: unknown) { - // We use the local storage for the operational part of the app but, not crucial + // We use the session storage for the operational part of the app but, not crucial console.error(err); } }; export const shouldHideInfo = (key: HideInfoKey): boolean => { try { - const store: Storage = browser ? localStorage : ({ [key]: 'false' } as unknown as Storage); + const store: Storage = browser ? sessionStorage : ({ [key]: 'false' } as unknown as Storage); return store[key] === 'true'; } catch (err: unknown) { - // We use the local storage for the operational part of the app but, not crucial + // We use the session storage for the operational part of the app but, not crucial console.error(err); return false; } diff --git a/src/frontend/src/lib/utils/input.utils.ts b/src/frontend/src/lib/utils/input.utils.ts index 1acb285e5b..822864c2d1 100644 --- a/src/frontend/src/lib/utils/input.utils.ts +++ b/src/frontend/src/lib/utils/input.utils.ts @@ -1,9 +1,9 @@ import type { OptionAmount } from '$lib/types/send'; import type { OptionString } from '$lib/types/string'; -import { isNullish } from '@dfinity/utils'; +import { isEmptyString, isNullish } from '@dfinity/utils'; export const isNullishOrEmpty = (value: OptionString): value is undefined | null => - isNullish(value) || value === ''; + isEmptyString(value); 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 1fa02fd618..dc0b0edeec 100644 --- a/src/frontend/src/lib/utils/nav.utils.ts +++ b/src/frontend/src/lib/utils/nav.utils.ts @@ -1,5 +1,5 @@ import { browser } from '$app/environment'; -import { goto } from '$app/navigation'; +import { goto, pushState } from '$app/navigation'; import { AppPath, NETWORK_PARAM, @@ -80,6 +80,11 @@ export const gotoReplaceRoot = async () => { await goto('/', { replaceState: true }); }; +export const removeSearchParam = ({ url, searchParam }: { url: URL; searchParam: string }) => { + url.searchParams.delete(searchParam); + pushState(url, {}); +}; + export interface RouteParams { [TOKEN_PARAM]: OptionString; [NETWORK_PARAM]: OptionString; diff --git a/src/frontend/src/sol/api/solana.api.ts b/src/frontend/src/sol/api/solana.api.ts index d1d5e82497..e1f9d9bb4a 100644 --- a/src/frontend/src/sol/api/solana.api.ts +++ b/src/frontend/src/sol/api/solana.api.ts @@ -2,6 +2,7 @@ import type { SolAddress } from '$lib/types/address'; import { solanaHttpRpc } from '$sol/providers/sol-rpc.providers'; import type { SolanaNetworkType } from '$sol/types/network'; import { address as solAddress } from '@solana/addresses'; +import type { Lamports } from '@solana/rpc-types'; //lamports are like satoshis: https://solana.com/docs/terminology#lamport export const loadSolLamportsBalance = async ({ @@ -10,7 +11,7 @@ export const loadSolLamportsBalance = async ({ }: { address: SolAddress; network: SolanaNetworkType; -}): Promise => { +}): Promise => { const { getBalance } = solanaHttpRpc(network); const wallet = solAddress(address); diff --git a/src/frontend/src/sol/components/core/SolLoaderWallets.svelte b/src/frontend/src/sol/components/core/SolLoaderWallets.svelte new file mode 100644 index 0000000000..14d2f18d40 --- /dev/null +++ b/src/frontend/src/sol/components/core/SolLoaderWallets.svelte @@ -0,0 +1,32 @@ + + + + + diff --git a/src/frontend/src/sol/components/send/SolSendAmount.svelte b/src/frontend/src/sol/components/send/SolSendAmount.svelte new file mode 100644 index 0000000000..abeeec5110 --- /dev/null +++ b/src/frontend/src/sol/components/send/SolSendAmount.svelte @@ -0,0 +1,37 @@ + + + diff --git a/src/frontend/src/sol/components/tokens/SolManageTokenToggle.svelte b/src/frontend/src/sol/components/tokens/SolManageTokenToggle.svelte new file mode 100644 index 0000000000..8ea3dabe4e --- /dev/null +++ b/src/frontend/src/sol/components/tokens/SolManageTokenToggle.svelte @@ -0,0 +1,7 @@ + + + + diff --git a/src/frontend/src/sol/derived/sol-transactions.derived.ts b/src/frontend/src/sol/derived/sol-transactions.derived.ts new file mode 100644 index 0000000000..e8c6643b28 --- /dev/null +++ b/src/frontend/src/sol/derived/sol-transactions.derived.ts @@ -0,0 +1,10 @@ +import { tokenWithFallback } from '$lib/derived/token.derived'; +import { solTransactionsStore } from '$sol/stores/sol-transactions.store'; +import type { SolTransactionUi } from '$sol/types/sol-transaction'; +import { derived, type Readable } from 'svelte/store'; + +export const solTransactions: Readable = derived( + [tokenWithFallback, solTransactionsStore], + ([$token, $solTransactionsStore]) => + ($solTransactionsStore?.[$token.id] ?? []).map(({ data: transaction }) => transaction) +); diff --git a/src/frontend/src/sol/providers/sol-rpc.providers.ts b/src/frontend/src/sol/providers/sol-rpc.providers.ts index c80fb90179..d2437b7ed8 100644 --- a/src/frontend/src/sol/providers/sol-rpc.providers.ts +++ b/src/frontend/src/sol/providers/sol-rpc.providers.ts @@ -2,49 +2,55 @@ import { SOLANA_RPC_HTTP_URL_DEVNET, SOLANA_RPC_HTTP_URL_LOCAL, SOLANA_RPC_HTTP_URL_MAINNET, - SOLANA_RPC_HTTP_URL_TESTNET + SOLANA_RPC_HTTP_URL_TESTNET, + SOLANA_RPC_WS_URL_DEVNET, + SOLANA_RPC_WS_URL_LOCAL, + SOLANA_RPC_WS_URL_MAINNET, + SOLANA_RPC_WS_URL_TESTNET } from '$env/networks/networks.sol.env'; -import { i18n } from '$lib/stores/i18n.store'; -import { replacePlaceholders } from '$lib/utils/i18n.utils'; import { SolanaNetworks, type SolRpcConnectionConfig, type SolanaNetworkType } from '$sol/types/network'; -import { assertNonNullish } from '@dfinity/utils'; -import { createSolanaRpc } from '@solana/rpc'; -import { get } from 'svelte/store'; +import { createSolanaRpc, type Rpc, type SolanaRpcApi } from '@solana/rpc'; +import { + createSolanaRpcSubscriptions, + type RpcSubscriptions, + type SolanaRpcSubscriptionsApi +} from '@solana/rpc-subscriptions'; const rpcs: Record = { [SolanaNetworks.mainnet]: { - httpUrl: SOLANA_RPC_HTTP_URL_MAINNET + httpUrl: SOLANA_RPC_HTTP_URL_MAINNET, + websocketUrl: SOLANA_RPC_WS_URL_MAINNET }, [SolanaNetworks.testnet]: { - httpUrl: SOLANA_RPC_HTTP_URL_TESTNET + httpUrl: SOLANA_RPC_HTTP_URL_TESTNET, + websocketUrl: SOLANA_RPC_WS_URL_TESTNET }, [SolanaNetworks.devnet]: { - httpUrl: SOLANA_RPC_HTTP_URL_DEVNET + httpUrl: SOLANA_RPC_HTTP_URL_DEVNET, + websocketUrl: SOLANA_RPC_WS_URL_DEVNET }, [SolanaNetworks.local]: { - httpUrl: SOLANA_RPC_HTTP_URL_LOCAL + httpUrl: SOLANA_RPC_HTTP_URL_LOCAL, + websocketUrl: SOLANA_RPC_WS_URL_LOCAL } }; -const solanaRpcConfig = (network: SolanaNetworkType): SolRpcConnectionConfig => { - const solRpc = rpcs[network]; +const solanaRpcConfig = (network: SolanaNetworkType): SolRpcConnectionConfig => rpcs[network]; - assertNonNullish( - solRpc, - replacePlaceholders(get(i18n).init.error.no_solana_rpc, { - $network: network.toString() - }) - ); +export const solanaHttpRpc = (network: SolanaNetworkType): Rpc => { + const { httpUrl } = solanaRpcConfig(network); - return solRpc; + return createSolanaRpc(httpUrl); }; -export const solanaHttpRpc = (network: SolanaNetworkType): ReturnType => { - const rpc = solanaRpcConfig(network); +export const solanaWebSocketRpc = ( + network: SolanaNetworkType +): RpcSubscriptions => { + const { websocketUrl } = solanaRpcConfig(network); - return createSolanaRpc(rpc.httpUrl); + return createSolanaRpcSubscriptions(websocketUrl); }; diff --git a/src/frontend/src/sol/schedulers/sol-wallet.scheduler.ts b/src/frontend/src/sol/schedulers/sol-wallet.scheduler.ts new file mode 100644 index 0000000000..b14f3dfe94 --- /dev/null +++ b/src/frontend/src/sol/schedulers/sol-wallet.scheduler.ts @@ -0,0 +1,118 @@ +import { WALLET_TIMER_INTERVAL_MILLIS } from '$lib/constants/app.constants'; +import { SchedulerTimer, type Scheduler, type SchedulerJobData } from '$lib/schedulers/scheduler'; +import type { SolAddress } from '$lib/types/address'; +import type { + PostMessageDataRequestSol, + PostMessageDataResponseError +} from '$lib/types/post-message'; +import type { CertifiedData } from '$lib/types/store'; +import type { Option } from '$lib/types/utils'; +import { loadSolLamportsBalance } from '$sol/api/solana.api'; +import type { SolanaNetworkType } from '$sol/types/network'; +import type { SolPostMessageDataResponseWallet } from '$sol/types/sol-post-message'; +import { assertNonNullish, isNullish } from '@dfinity/utils'; +import type { Lamports } from '@solana/rpc-types'; + +interface LoadSolWalletParams { + solanaNetwork: SolanaNetworkType; + address: SolAddress; +} + +interface SolWalletStore { + balance: CertifiedData> | undefined; +} + +interface SolWalletData { + balance: CertifiedData; +} + +export class SolWalletScheduler implements Scheduler { + private timer = new SchedulerTimer('syncSolWalletStatus'); + + private store: SolWalletStore = { + balance: undefined + }; + + stop() { + this.timer.stop(); + } + + async start(data: PostMessageDataRequestSol | undefined) { + await this.timer.start({ + interval: WALLET_TIMER_INTERVAL_MILLIS, + job: this.syncWallet, + data + }); + } + + async trigger(data: PostMessageDataRequestSol | undefined) { + await this.timer.trigger({ + job: this.syncWallet, + data + }); + } + + private loadBalance = async ({ + address, + solanaNetwork + }: LoadSolWalletParams): Promise> => ({ + data: await loadSolLamportsBalance({ network: solanaNetwork, address }), + certified: false + }); + + private syncWallet = async ({ data }: SchedulerJobData) => { + assertNonNullish(data, 'No data provided to get Solana balance.'); + + try { + const balance = await this.loadBalance({ + address: data.address.data, + solanaNetwork: data.solanaNetwork + }); + + //todo implement loading transactions + + this.syncWalletData({ response: { balance } }); + } catch (error: unknown) { + this.postMessageWalletError({ error }); + } + }; + + private syncWalletData = ({ response: { balance } }: { response: SolWalletData }) => { + if (!this.store.balance?.certified && balance.certified) { + throw new Error('Balance certification status cannot change from uncertified to certified'); + } + + const newBalance = isNullish(this.store.balance) || this.store.balance.data !== balance.data; + + if (!newBalance) { + return; + } + + this.store = { + ...this.store, + balance + }; + + this.postMessageWallet({ + wallet: { + balance + } + }); + }; + + private postMessageWallet(data: SolPostMessageDataResponseWallet) { + this.timer.postMsg({ + msg: 'syncSolWallet', + data + }); + } + + protected postMessageWalletError({ error }: { error: unknown }) { + this.timer.postMsg({ + msg: 'syncSolWalletError', + data: { + error + } + }); + } +} diff --git a/src/frontend/src/sol/schema/network.schema.ts b/src/frontend/src/sol/schema/network.schema.ts index 7bf8c6b44d..c1c510ea11 100644 --- a/src/frontend/src/sol/schema/network.schema.ts +++ b/src/frontend/src/sol/schema/network.schema.ts @@ -2,7 +2,8 @@ import { UrlSchema } from '$lib/validation/url.validation'; import { z } from 'zod'; export const SolRpcConnectionConfigSchema = z.object({ - httpUrl: UrlSchema + httpUrl: UrlSchema, + websocketUrl: UrlSchema }); export const SolanaNetworkSchema = z.enum(['mainnet', 'testnet', 'devnet', 'local']); diff --git a/src/frontend/src/sol/schema/sol-post-message.schema.ts b/src/frontend/src/sol/schema/sol-post-message.schema.ts index ccc686c63a..fb71e99b3e 100644 --- a/src/frontend/src/sol/schema/sol-post-message.schema.ts +++ b/src/frontend/src/sol/schema/sol-post-message.schema.ts @@ -1,13 +1,10 @@ -import { - JsonTransactionsTextSchema, - PostMessageDataResponseSchema -} from '$lib/schema/post-message.schema'; +import { PostMessageDataResponseSchema } from '$lib/schema/post-message.schema'; import type { CertifiedData } from '$lib/types/store'; +import type { Lamports } from '@solana/rpc-types'; import { z } from 'zod'; const SolPostMessageWalletDataSchema = z.object({ - balance: z.custom>(), - newTransactions: JsonTransactionsTextSchema + balance: z.custom>() }); export const SolPostMessageDataResponseWalletSchema = PostMessageDataResponseSchema.extend({ diff --git a/src/frontend/src/sol/services/sol-balance.services.ts b/src/frontend/src/sol/services/sol-balance.services.ts new file mode 100644 index 0000000000..07384002a8 --- /dev/null +++ b/src/frontend/src/sol/services/sol-balance.services.ts @@ -0,0 +1,48 @@ +import { balancesStore } from '$lib/stores/balances.store'; +import { i18n } from '$lib/stores/i18n.store'; +import type { SolAddress } from '$lib/types/address'; +import type { Token } from '$lib/types/token'; +import type { ResultSuccess } from '$lib/types/utils'; +import { replacePlaceholders } from '$lib/utils/i18n.utils'; +import { loadSolLamportsBalance } from '$sol/api/solana.api'; +import { mapNetworkIdToNetwork } from '$sol/utils/network.utils'; +import { assertNonNullish } from '@dfinity/utils'; +import { BigNumber } from '@ethersproject/bignumber'; +import { get } from 'svelte/store'; + +export const loadSolBalance = async ({ + address, + token +}: { + address: SolAddress; + token: Token; +}): Promise => { + const { + id: tokenId, + network: { id: networkId } + } = token; + + const solNetwork = mapNetworkIdToNetwork(networkId); + + assertNonNullish( + solNetwork, + replacePlaceholders(get(i18n).init.error.no_solana_network, { + $network: networkId.description ?? '' + }) + ); + + try { + const balance = await loadSolLamportsBalance({ address, network: solNetwork }); + + balancesStore.set({ tokenId, data: { data: BigNumber.from(balance), certified: false } }); + + return { success: true }; + } catch (err: unknown) { + balancesStore.reset(tokenId); + + // We don't want to disrupt the user experience if we can't load the balance. + console.error(`Error fetching ${tokenId.description} balance data:`, err); + + return { success: false }; + } +}; diff --git a/src/frontend/src/sol/services/worker.sol-wallet.services.ts b/src/frontend/src/sol/services/worker.sol-wallet.services.ts new file mode 100644 index 0000000000..700f9f271c --- /dev/null +++ b/src/frontend/src/sol/services/worker.sol-wallet.services.ts @@ -0,0 +1,95 @@ +import { + solAddressDevnetStore, + solAddressLocalnetStore, + solAddressMainnetStore, + solAddressTestnetStore +} from '$lib/stores/address.store'; +import type { WalletWorker } from '$lib/types/listener'; +import type { + PostMessage, + PostMessageDataRequestSol, + PostMessageDataResponseError +} from '$lib/types/post-message'; +import type { Token } from '$lib/types/token'; +import { + isNetworkIdSOLDevnet, + isNetworkIdSOLLocal, + isNetworkIdSOLTestnet +} from '$lib/utils/network.utils'; +import { syncWallet, syncWalletError } from '$sol/services/sol-listener.services'; +import type { SolPostMessageDataResponseWallet } from '$sol/types/sol-post-message'; +import { mapNetworkIdToNetwork } from '$sol/utils/network.utils'; +import { assertNonNullish } from '@dfinity/utils'; +import { get } from 'svelte/store'; + +export const initSolWalletWorker = async ({ token }: { token: Token }): Promise => { + const { + id: tokenId, + network: { id: networkId } + } = token; + + const WalletWorker = await import('$sol/workers/sol-wallet.worker?worker'); + const worker: Worker = new WalletWorker.default(); + + const isTestnetNetwork = isNetworkIdSOLTestnet(networkId); + const isDevnetNetwork = isNetworkIdSOLDevnet(networkId); + const isLocalNetwork = isNetworkIdSOLLocal(networkId); + + worker.onmessage = ({ data }: MessageEvent>) => { + const { msg } = data; + + switch (msg) { + case 'syncSolWallet': + syncWallet({ + tokenId, + data: data.data as SolPostMessageDataResponseWallet + }); + return; + + case 'syncSolWalletError': + syncWalletError({ + tokenId, + error: (data.data as PostMessageDataResponseError).error, + hideToast: isTestnetNetwork || isDevnetNetwork || isLocalNetwork + }); + return; + } + }; + + // TODO: stop/start the worker on address change (same as for worker.btc-wallet.services.ts) + const address = get( + isTestnetNetwork + ? solAddressTestnetStore + : isDevnetNetwork + ? solAddressDevnetStore + : isLocalNetwork + ? solAddressLocalnetStore + : solAddressMainnetStore + ); + assertNonNullish(address, 'No Solana address provided to start Solana wallet worker.'); + + const network = mapNetworkIdToNetwork(token.network.id); + assertNonNullish(network, 'No Solana network provided to start Solana wallet worker.'); + + const data: PostMessageDataRequestSol = { address, solanaNetwork: network }; + + return { + start: () => { + worker.postMessage({ + msg: 'startSolWalletTimer', + data + }); + }, + stop: () => { + worker.postMessage({ + msg: 'stopSolWalletTimer' + }); + }, + trigger: () => { + worker.postMessage({ + msg: 'triggerSolWalletTimer', + data + }); + } + }; +}; diff --git a/src/frontend/src/sol/stores/sol-transactions.store.ts b/src/frontend/src/sol/stores/sol-transactions.store.ts new file mode 100644 index 0000000000..c18c9fc3d4 --- /dev/null +++ b/src/frontend/src/sol/stores/sol-transactions.store.ts @@ -0,0 +1,6 @@ +import { initTransactionsStore, type CertifiedTransaction } from '$lib/stores/transactions.store'; +import type { SolTransactionUi } from '$sol/types/sol-transaction'; + +export type SolCertifiedTransaction = CertifiedTransaction; + +export const solTransactionsStore = initTransactionsStore(); diff --git a/src/frontend/src/sol/types/sol-send.ts b/src/frontend/src/sol/types/sol-send.ts new file mode 100644 index 0000000000..a476e0271d --- /dev/null +++ b/src/frontend/src/sol/types/sol-send.ts @@ -0,0 +1 @@ +export class SolAmountAssertionError extends Error {} diff --git a/src/frontend/src/sol/types/sol-transaction.ts b/src/frontend/src/sol/types/sol-transaction.ts new file mode 100644 index 0000000000..a05532cfc6 --- /dev/null +++ b/src/frontend/src/sol/types/sol-transaction.ts @@ -0,0 +1,22 @@ +import { solTransactionTypes } from '$lib/schema/transaction.schema'; +import type { TransactionType, TransactionUiCommon } from '$lib/types/transaction'; +import type { GetTransactionApi } from '@solana/rpc'; +import type { Commitment } from '@solana/rpc-types'; + +export type SolTransactionType = Extract< + TransactionType, + (typeof solTransactionTypes.options)[number] +>; + +export interface SolTransactionUi extends TransactionUiCommon { + id: string; + type: SolTransactionType; + status: Commitment | null; + value?: bigint; + fee?: bigint; +} + +export type SolRpcTransaction = NonNullable> & { + id: string; + confirmationStatus: Commitment | null; +}; diff --git a/src/frontend/src/sol/utils/sol-transactions.utils.ts b/src/frontend/src/sol/utils/sol-transactions.utils.ts new file mode 100644 index 0000000000..1935c4ab22 --- /dev/null +++ b/src/frontend/src/sol/utils/sol-transactions.utils.ts @@ -0,0 +1,49 @@ +import type { SolAddress } from '$lib/types/address'; +import type { SolRpcTransaction, SolTransactionUi } from '$sol/types/sol-transaction'; +import { address as solAddress } from '@solana/addresses'; + +/** + * It maps a transaction to a Solana transaction UI object + */ +export const mapSolTransactionUi = ({ + transaction, + address +}: { + transaction: SolRpcTransaction; + address: SolAddress; +}): SolTransactionUi => { + const { + id, + blockTime, + confirmationStatus: status, + transaction: { + message: { accountKeys } + }, + meta + } = transaction; + + const from = accountKeys[0]; + const to = accountKeys[1]; + + const accountIndex = accountKeys.indexOf(solAddress(address)); + + const { preBalances, postBalances, fee } = meta ?? {}; + + const relevantFee = from === address ? (fee ?? 0n) : 0n; + + const amount = + (postBalances?.[accountIndex] ?? 0n) - (preBalances?.[accountIndex] ?? 0n) + relevantFee; + + const type = amount > 0n ? 'receive' : 'send'; + + return { + id, + timestamp: blockTime ?? 0n, + from, + to, + type, + status, + value: amount, + fee + }; +}; diff --git a/src/frontend/src/sol/utils/token.utils.ts b/src/frontend/src/sol/utils/token.utils.ts new file mode 100644 index 0000000000..26a8a1b95d --- /dev/null +++ b/src/frontend/src/sol/utils/token.utils.ts @@ -0,0 +1,3 @@ +import type { Token } from '$lib/types/token'; + +export const isSolanaToken = (token: Token): boolean => token.standard === 'solana'; diff --git a/src/frontend/src/sol/workers/sol-wallet.worker.ts b/src/frontend/src/sol/workers/sol-wallet.worker.ts new file mode 100644 index 0000000000..f0a2d9ae8d --- /dev/null +++ b/src/frontend/src/sol/workers/sol-wallet.worker.ts @@ -0,0 +1,20 @@ +import type { PostMessage, PostMessageDataRequestSol } from '$lib/types/post-message'; +import { SolWalletScheduler } from '$sol/schedulers/sol-wallet.scheduler'; + +const scheduler: SolWalletScheduler = new SolWalletScheduler(); + +onmessage = async ({ data: dataMsg }: MessageEvent>) => { + const { msg, data } = dataMsg; + + switch (msg) { + case 'stopSolWalletTimer': + scheduler.stop(); + return; + case 'startSolWalletTimer': + await scheduler.start(data); + return; + case 'triggerSolWalletTimer': + await scheduler.trigger(data); + return; + } +}; diff --git a/src/frontend/src/tests/btc/components/convert/ConvertToCkBTC.spec.ts b/src/frontend/src/tests/btc/components/convert/ConvertToCkBTC.spec.ts index a895ab21bd..7024a53984 100644 --- a/src/frontend/src/tests/btc/components/convert/ConvertToCkBTC.spec.ts +++ b/src/frontend/src/tests/btc/components/convert/ConvertToCkBTC.spec.ts @@ -8,7 +8,6 @@ import { HERO_CONTEXT_KEY } from '$lib/stores/hero.store'; import { mockValidIcCkToken } from '$tests/mocks/ic-tokens.mock'; import { render } from '@testing-library/svelte'; import { readable } from 'svelte/store'; -import { expect } from 'vitest'; describe('ConvertToCkBTC', () => { const buttonId = 'convert-to-ckbtc-button'; diff --git a/src/frontend/src/tests/btc/workers/btc-wallet.worker.spec.ts b/src/frontend/src/tests/btc/workers/btc-wallet.worker.spec.ts index 482228296b..c661b0b74b 100644 --- a/src/frontend/src/tests/btc/workers/btc-wallet.worker.spec.ts +++ b/src/frontend/src/tests/btc/workers/btc-wallet.worker.spec.ts @@ -14,7 +14,7 @@ import { HttpAgent } from '@dfinity/agent'; import { BitcoinCanister, type BitcoinNetwork } from '@dfinity/ckbtc'; import { jsonReplacer } from '@dfinity/utils'; import { waitFor } from '@testing-library/svelte'; -import { type MockInstance } from 'vitest'; +import type { MockInstance } from 'vitest'; import { mock } from 'vitest-mock-extended'; describe('btc-wallet.worker', () => { diff --git a/src/frontend/src/tests/eth/stores/eth-transactions.store.spec.ts b/src/frontend/src/tests/eth/stores/eth-transactions.store.spec.ts index fa70227b8e..e3d3650e04 100644 --- a/src/frontend/src/tests/eth/stores/eth-transactions.store.spec.ts +++ b/src/frontend/src/tests/eth/stores/eth-transactions.store.spec.ts @@ -3,7 +3,6 @@ import { ethTransactionsStore } from '$eth/stores/eth-transactions.store'; import { bn3 } from '$tests/mocks/balances.mock'; import { createMockEthTransactions } from '$tests/mocks/eth-transactions.mock'; import { get } from 'svelte/store'; -import { expect } from 'vitest'; describe('eth-transactions.store', () => { const tokenId = ETHEREUM_TOKEN_ID; diff --git a/src/frontend/src/tests/icp/services/ic-transactions.services.spec.ts b/src/frontend/src/tests/icp/services/ic-transactions.services.spec.ts index ec0034d9b3..f9625d6e2d 100644 --- a/src/frontend/src/tests/icp/services/ic-transactions.services.spec.ts +++ b/src/frontend/src/tests/icp/services/ic-transactions.services.spec.ts @@ -7,7 +7,7 @@ import * as toastsStore from '$lib/stores/toasts.store'; import { bn1 } from '$tests/mocks/balances.mock'; import { createMockIcTransactionsUi } from '$tests/mocks/ic-transactions.mock'; import { get } from 'svelte/store'; -import { expect, type MockInstance } from 'vitest'; +import type { MockInstance } from 'vitest'; describe('ic-transactions.services', () => { describe('onLoadTransactionsError', () => { 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 f193f51c4b..db8c8256aa 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 { 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/lib/api/idb.api.spec.ts b/src/frontend/src/tests/lib/api/idb.api.spec.ts index 0877c663c8..68646ffa2f 100644 --- a/src/frontend/src/tests/lib/api/idb.api.spec.ts +++ b/src/frontend/src/tests/lib/api/idb.api.spec.ts @@ -14,7 +14,6 @@ import { } from '$lib/api/idb.api'; import { mockPrincipal } from '$tests/mocks/identity.mock'; import * as idbKeyval from 'idb-keyval'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('idb-keyval', () => ({ createStore: vi.fn(() => ({ diff --git a/src/frontend/src/tests/lib/components/core/Menu.spec.ts b/src/frontend/src/tests/lib/components/core/Menu.spec.ts new file mode 100644 index 0000000000..71235041bb --- /dev/null +++ b/src/frontend/src/tests/lib/components/core/Menu.spec.ts @@ -0,0 +1,80 @@ +import type { UserData } from '$declarations/rewards/rewards.did'; +import * as rewardApi from '$lib/api/reward.api'; +import Menu from '$lib/components/core/Menu.svelte'; +import { + NAVIGATION_MENU_BUTTON, + NAVIGATION_MENU_VIP_BUTTON +} from '$lib/constants/test-ids.constants'; +import * as authStore from '$lib/derived/auth.derived'; +import { userProfileStore } from '$lib/stores/user-profile.store'; +import { mockIdentity } from '$tests/mocks/identity.mock'; +import type { Identity } from '@dfinity/agent'; +import { render, waitFor } from '@testing-library/svelte'; +import { beforeEach } from 'node:test'; +import { readable } from 'svelte/store'; + +describe('Menu', () => { + const menuButtonSelector = `button[data-tid="${NAVIGATION_MENU_BUTTON}"]`; + const menuItemVipButtonSelector = `button[data-tid="${NAVIGATION_MENU_VIP_BUTTON}"]`; + + beforeEach(() => { + userProfileStore.reset(); + }); + + const mockAuthStore = (value: Identity | null = mockIdentity) => + vi.spyOn(authStore, 'authIdentity', 'get').mockImplementation(() => readable(value)); + + it('renders the vip menu item', async () => { + const mockedUserData: UserData = { + is_vip: [true], + airdrops: [], + sprinkles: [] + }; + vi.spyOn(rewardApi, 'getUserInfo').mockResolvedValue(mockedUserData); + mockAuthStore(); + + const { container } = render(Menu); + const menuButton: HTMLButtonElement | null = container.querySelector(menuButtonSelector); + + expect(menuButton).toBeInTheDocument(); + + menuButton?.click(); + + await waitFor(() => { + const menuItemVipButton: HTMLButtonElement | null = + container.querySelector(menuItemVipButtonSelector); + if (menuItemVipButton == null) { + throw new Error('menu item not yet loaded'); + } + + expect(menuItemVipButton).toBeInTheDocument(); + }); + }); + + it('does not render the vip menu item', async () => { + const mockedUserData: UserData = { + is_vip: [false], + airdrops: [], + sprinkles: [] + }; + vi.spyOn(rewardApi, 'getUserInfo').mockResolvedValue(mockedUserData); + mockAuthStore(); + + const { container } = render(Menu); + const menuButton: HTMLButtonElement | null = container.querySelector(menuButtonSelector); + + expect(menuButton).toBeInTheDocument(); + + menuButton?.click(); + + await waitFor(() => { + const menuItemVipButton: HTMLButtonElement | null = + container.querySelector(menuItemVipButtonSelector); + if (menuItemVipButton == null) { + expect(menuItemVipButton).toBeNull(); + } else { + throw new Error('menu item loaded'); + } + }); + }); +}); diff --git a/src/frontend/src/tests/lib/components/qr/FailedRewardModal.spec.ts b/src/frontend/src/tests/lib/components/qr/FailedRewardModal.spec.ts new file mode 100644 index 0000000000..1ac83776e1 --- /dev/null +++ b/src/frontend/src/tests/lib/components/qr/FailedRewardModal.spec.ts @@ -0,0 +1,15 @@ +import FailedRewardModal from '$lib/components/qr/FailedRewardModal.svelte'; +import { i18n } from '$lib/stores/i18n.store'; +import { render } from '@testing-library/svelte'; +import { get } from 'svelte/store'; + +describe('FailedRewardModal', () => { + it('should render expected texts', () => { + const { getByText } = render(FailedRewardModal); + + expect(getByText(get(i18n).vip.reward.text.title_failed)).toBeInTheDocument(); + expect(getByText(get(i18n).vip.reward.text.reward_failed)).toBeInTheDocument(); + expect(getByText(get(i18n).vip.reward.text.reward_failed_description)).toBeInTheDocument(); + expect(getByText(get(i18n).vip.reward.text.open_wallet)).toBeInTheDocument(); + }); +}); diff --git a/src/frontend/src/tests/lib/components/qr/SuccessfulRewardModal.spec.ts b/src/frontend/src/tests/lib/components/qr/SuccessfulRewardModal.spec.ts new file mode 100644 index 0000000000..3c98a1237e --- /dev/null +++ b/src/frontend/src/tests/lib/components/qr/SuccessfulRewardModal.spec.ts @@ -0,0 +1,15 @@ +import SuccessfulRewardModal from '$lib/components/qr/SuccessfulRewardModal.svelte'; +import { i18n } from '$lib/stores/i18n.store'; +import { render } from '@testing-library/svelte'; +import { get } from 'svelte/store'; + +describe('SuccessfulRewardModal', () => { + it('should render expected texts', () => { + const { getByText } = render(SuccessfulRewardModal); + + expect(getByText(get(i18n).vip.reward.text.title_successful)).toBeInTheDocument(); + expect(getByText(get(i18n).vip.reward.text.reward_received)).toBeInTheDocument(); + expect(getByText(get(i18n).vip.reward.text.reward_received_description)).toBeInTheDocument(); + expect(getByText(get(i18n).vip.reward.text.open_wallet)).toBeInTheDocument(); + }); +}); diff --git a/src/frontend/src/tests/lib/derived/all-tokens.derived.spec.ts b/src/frontend/src/tests/lib/derived/all-tokens.derived.spec.ts index 21cd3629d9..c4ca90ece7 100644 --- a/src/frontend/src/tests/lib/derived/all-tokens.derived.spec.ts +++ b/src/frontend/src/tests/lib/derived/all-tokens.derived.spec.ts @@ -23,7 +23,6 @@ import { mockEthAddress } from '$tests/mocks/eth.mocks'; import { mockValidIcCkToken, mockValidIcToken } from '$tests/mocks/ic-tokens.mock'; import { mockValidToken } from '$tests/mocks/tokens.mock'; import { get } from 'svelte/store'; -import { beforeEach } from 'vitest'; describe('all-tokens.derived', () => { const mockIcrcToken: IcrcCustomToken = { diff --git a/src/frontend/src/tests/lib/derived/network.derived.spec.ts b/src/frontend/src/tests/lib/derived/network.derived.spec.ts index e65a1791d6..c74fe19c3c 100644 --- a/src/frontend/src/tests/lib/derived/network.derived.spec.ts +++ b/src/frontend/src/tests/lib/derived/network.derived.spec.ts @@ -7,7 +7,6 @@ import { mockIdentity } from '$tests/mocks/identity.mock'; import { mockPage } from '$tests/mocks/page.store.mock'; import { encodeIcrcAccount } from '@dfinity/ledger-icrc'; import { get } from 'svelte/store'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; describe('network.derived', () => { beforeEach(() => { diff --git a/src/frontend/src/tests/lib/derived/token.derived.spec.ts b/src/frontend/src/tests/lib/derived/token.derived.spec.ts index a36cd783a7..80cc2c21e3 100644 --- a/src/frontend/src/tests/lib/derived/token.derived.spec.ts +++ b/src/frontend/src/tests/lib/derived/token.derived.spec.ts @@ -8,7 +8,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('token.derived', () => { const mockEr20UserToken: Erc20UserToken = { diff --git a/src/frontend/src/tests/lib/services/auth.services.spec.ts b/src/frontend/src/tests/lib/services/auth.services.spec.ts index 70f6385d93..145ac26f58 100644 --- a/src/frontend/src/tests/lib/services/auth.services.spec.ts +++ b/src/frontend/src/tests/lib/services/auth.services.spec.ts @@ -1,6 +1,5 @@ import { signOut } from '$lib/services/auth.services'; import { authStore } from '$lib/stores/auth.store'; -import { vi } from 'vitest'; const rootLocation = 'https://oisy.com/'; const activityLocation = 'https://oisy.com/activity'; @@ -43,5 +42,17 @@ describe('auth.services', () => { expect(signOutSpy).toHaveBeenCalled(); expect(window.location.href).toEqual(rootLocation); }); + + it('should call the signOut function of the authStore and clear the session storage', async () => { + const signOutSpy = vi.spyOn(authStore, 'signOut'); + + sessionStorage.setItem('key', 'value'); + expect(sessionStorage.getItem('key')).toEqual('value'); + + await signOut({}); + + expect(signOutSpy).toHaveBeenCalled(); + expect(sessionStorage.getItem('key')).toBeNull(); + }); }); }); diff --git a/src/frontend/src/tests/lib/services/batch.services.spec.ts b/src/frontend/src/tests/lib/services/batch.services.spec.ts index d5dadaa3bd..33b8fc04e3 100644 --- a/src/frontend/src/tests/lib/services/batch.services.spec.ts +++ b/src/frontend/src/tests/lib/services/batch.services.spec.ts @@ -1,5 +1,4 @@ import { batch } from '$lib/services/batch.services'; -import { expect } from 'vitest'; describe('batch.services', () => { describe('batch', () => { diff --git a/src/frontend/src/tests/lib/services/rest.services.spec.ts b/src/frontend/src/tests/lib/services/rest.services.spec.ts index 58eb2ed887..f7f3efab44 100644 --- a/src/frontend/src/tests/lib/services/rest.services.spec.ts +++ b/src/frontend/src/tests/lib/services/rest.services.spec.ts @@ -1,5 +1,4 @@ import { retry } from '$lib/services/rest.services'; -import { expect } from 'vitest'; describe('rest.services', () => { describe('retry', () => { diff --git a/src/frontend/src/tests/lib/services/reward-code.services.spec.ts b/src/frontend/src/tests/lib/services/reward-code.services.spec.ts new file mode 100644 index 0000000000..df79120d9b --- /dev/null +++ b/src/frontend/src/tests/lib/services/reward-code.services.spec.ts @@ -0,0 +1,152 @@ +import type { + ClaimVipRewardResponse, + NewVipRewardResponse, + UserData +} from '$declarations/rewards/rewards.did'; +import * as rewardApi from '$lib/api/reward.api'; +import { claimVipReward, getNewReward, isVipUser } from '$lib/services/reward-code.services'; +import { i18n } from '$lib/stores/i18n.store'; +import * as toastsStore from '$lib/stores/toasts.store'; +import { AlreadyClaimedError, InvalidCodeError } from '$lib/types/errors'; +import en from '$tests/mocks/i18n.mock'; +import { mockIdentity } from '$tests/mocks/identity.mock'; +import { get } from 'svelte/store'; + +const nullishIdentityErrorMessage = en.auth.error.no_internet_identity; + +describe('reward-code', () => { + beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + describe('isVip', () => { + const mockedUserData: UserData = { + is_vip: [true], + airdrops: [], + sprinkles: [] + }; + + it('should return true if user is vip', async () => { + const getUserInfoSpy = vi.spyOn(rewardApi, 'getUserInfo').mockResolvedValue(mockedUserData); + + const result = await isVipUser({ identity: mockIdentity }); + + expect(getUserInfoSpy).toHaveBeenCalledWith({ + identity: mockIdentity, + certified: false, + nullishIdentityErrorMessage + }); + expect(result).toEqual({ success: true }); + }); + + it('should return false if user is not vip', async () => { + const userData: UserData = { ...mockedUserData, is_vip: [false] }; + const getUserInfoSpy = vi.spyOn(rewardApi, 'getUserInfo').mockResolvedValue(userData); + + const result = await isVipUser({ identity: mockIdentity }); + + expect(getUserInfoSpy).toHaveBeenCalledWith({ + identity: mockIdentity, + certified: false, + nullishIdentityErrorMessage + }); + expect(result).toEqual({ success: false }); + }); + }); + + describe('getNewReward', () => { + const mockedNewRewardResponse: NewVipRewardResponse = { + VipReward: { + code: '1234567890' + } + }; + + it('should get a vip reward code for vip user', async () => { + const getNewVipRewardSpy = vi + .spyOn(rewardApi, 'getNewVipReward') + .mockResolvedValue(mockedNewRewardResponse); + + const vipReward = await getNewReward(mockIdentity); + + expect(getNewVipRewardSpy).toHaveBeenCalledWith({ + identity: mockIdentity, + nullishIdentityErrorMessage + }); + expect(vipReward).toEqual(mockedNewRewardResponse.VipReward); + }); + + it('should display an error message for non vip user', async () => { + const err = new Error('test'); + const getNewVipRewardSpy = vi.spyOn(rewardApi, 'getNewVipReward').mockRejectedValue(err); + const spyToastsError = vi.spyOn(toastsStore, 'toastsError'); + + await getNewReward(mockIdentity); + + expect(getNewVipRewardSpy).toHaveBeenCalledWith({ + identity: mockIdentity, + nullishIdentityErrorMessage + }); + expect(spyToastsError).toHaveBeenNthCalledWith(1, { + msg: { text: get(i18n).vip.reward.error.loading_reward }, + err + }); + }); + }); + + describe('claimVipReward', () => { + const mockedClaimRewardResponse: ClaimVipRewardResponse = { + Success: null + }; + + it('should return true if a valid vip reward code is used', async () => { + const claimRewardSpy = vi + .spyOn(rewardApi, 'claimVipReward') + .mockResolvedValue(mockedClaimRewardResponse); + + const result = await claimVipReward({ identity: mockIdentity, code: '1234567890' }); + + expect(claimRewardSpy).toHaveBeenCalledWith({ + identity: mockIdentity, + vipReward: { code: '1234567890' }, + nullishIdentityErrorMessage + }); + expect(result).toEqual({ success: true }); + }); + + it('should return false if an invalid vip reward code is used', async () => { + const claimRewardResponse: ClaimVipRewardResponse = { InvalidCode: null }; + const claimRewardSpy = vi + .spyOn(rewardApi, 'claimVipReward') + .mockResolvedValue(claimRewardResponse); + + const result = await claimVipReward({ identity: mockIdentity, code: '1234567890' }); + + expect(claimRewardSpy).toHaveBeenCalledWith({ + identity: mockIdentity, + vipReward: { code: '1234567890' }, + nullishIdentityErrorMessage + }); + expect(result.success).toBeFalsy(); + expect(result.err).not.toBeUndefined(); + expect(result.err).toBeInstanceOf(InvalidCodeError); + }); + + it('should return false if an already used vip reward code is used', async () => { + const claimRewardResponse: ClaimVipRewardResponse = { AlreadyClaimed: null }; + const claimRewardSpy = vi + .spyOn(rewardApi, 'claimVipReward') + .mockResolvedValue(claimRewardResponse); + + const result = await claimVipReward({ identity: mockIdentity, code: '1234567890' }); + + expect(claimRewardSpy).toHaveBeenCalledWith({ + identity: mockIdentity, + vipReward: { code: '1234567890' }, + nullishIdentityErrorMessage + }); + expect(result.success).toBeFalsy(); + expect(result.err).not.toBeUndefined(); + expect(result.err).toBeInstanceOf(AlreadyClaimedError); + }); + }); +}); diff --git a/src/frontend/src/tests/lib/utils/convert.utils.spec.ts b/src/frontend/src/tests/lib/utils/convert.utils.spec.ts index c071b2a1b2..6e4e3b2b08 100644 --- a/src/frontend/src/tests/lib/utils/convert.utils.spec.ts +++ b/src/frontend/src/tests/lib/utils/convert.utils.spec.ts @@ -1,6 +1,5 @@ import { validateConvertAmount } from '$lib/utils/convert.utils'; import { BigNumber } from 'alchemy-sdk'; -import { describe } from 'vitest'; describe('validateConvertAmount', () => { const userAmount = BigNumber.from(200000n); diff --git a/src/frontend/src/tests/lib/utils/info.utils.spec.ts b/src/frontend/src/tests/lib/utils/info.utils.spec.ts index 343ba2ae83..4eb65190c0 100644 --- a/src/frontend/src/tests/lib/utils/info.utils.spec.ts +++ b/src/frontend/src/tests/lib/utils/info.utils.spec.ts @@ -7,7 +7,7 @@ describe('info.utils', () => { beforeEach(() => { vi.resetAllMocks(); - localStorage.clear(); + sessionStorage.clear(); vi.spyOn(console, 'error').mockImplementation(() => {}); }); @@ -15,16 +15,16 @@ describe('info.utils', () => { it('should save a value in localStorage', () => { saveHideInfo(key); - expect(localStorage.getItem(key)).toBe('true'); + expect(sessionStorage.getItem(key)).toBe('true'); }); - it('should not throw errors even if localStorage is unavailable', () => { - const originalLocalStorage = window.localStorage; + it('should not throw errors even if sessionStorage is unavailable', () => { + const originalSessionStorage = window.sessionStorage; - Object.defineProperty(window, 'localStorage', { + Object.defineProperty(window, 'sessionStorage', { value: { setItem: vi.fn(() => { - throw new Error('LocalStorage is full'); + throw new Error('SessionStorage is full'); }) }, writable: true @@ -32,8 +32,8 @@ describe('info.utils', () => { expect(() => saveHideInfo(key)).not.toThrow(); - Object.defineProperty(window, 'localStorage', { - value: originalLocalStorage, + Object.defineProperty(window, 'sessionStorage', { + value: originalSessionStorage, writable: true }); }); @@ -45,32 +45,32 @@ describe('info.utils', () => { beforeEach(() => { vi.resetAllMocks(); - localStorage.clear(); + sessionStorage.clear(); vi.spyOn(console, 'error').mockImplementation(() => {}); }); it('should return true if the value for the key is "true"', () => { - localStorage.setItem(key, 'true'); + sessionStorage.setItem(key, 'true'); expect(shouldHideInfo(key)).toBe(true); }); it('should return false if the value for the key is "false"', () => { - localStorage.setItem(key, 'false'); + sessionStorage.setItem(key, 'false'); expect(shouldHideInfo(key)).toBe(false); }); - it('should return false if the key does not exist in localStorage', () => { + it('should return false if the key does not exist in sessionStorage', () => { expect(shouldHideInfo(key)).toBe(false); }); - it('should return false if localStorage is unavailable or throws an error', () => { - const originalLocalStorage = window.localStorage; + it('should return false if sessionStorage is unavailable or throws an error', () => { + const originalSessionStorage = window.sessionStorage; - Object.defineProperty(window, 'localStorage', { + Object.defineProperty(window, 'sessionStorage', { value: { getItem: vi.fn(() => { - throw new Error('LocalStorage is full'); + throw new Error('SessionStorage is full'); }) }, writable: true @@ -78,8 +78,8 @@ describe('info.utils', () => { expect(shouldHideInfo(key)).toBe(false); - Object.defineProperty(window, 'localStorage', { - value: originalLocalStorage, + Object.defineProperty(window, 'sessionStorage', { + value: originalSessionStorage, 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 ca7e9d9780..e5d9ea0011 100644 --- a/src/frontend/src/tests/lib/utils/nav.utils.spec.ts +++ b/src/frontend/src/tests/lib/utils/nav.utils.spec.ts @@ -18,11 +18,11 @@ import { loadRouteParams, networkParam, networkUrl, + removeSearchParam, resetRouteParams, type RouteParams } from '$lib/utils/nav.utils'; import type { LoadEvent, NavigationTarget, Page } from '@sveltejs/kit'; -import { describe, expect } from 'vitest'; describe('nav.utils', () => { const mockGoTo = vi.fn(); @@ -138,6 +138,25 @@ describe('nav.utils', () => { }); }); + describe('removeSearchParam', () => { + it('should remove search param from URL', () => { + const pushStateMock = vi.spyOn(appNavigation, 'pushState').mockImplementation(vi.fn()); + const urlString = 'https://example.com/'; + const url = new URL(urlString); + const searchParams = new URLSearchParams({ + code: '123' + }); + url.search = searchParams.toString(); + + expect(url.toString()).toBe(`${urlString}?code=123`); + + removeSearchParam({ url, searchParam: 'code' }); + + expect(pushStateMock).toHaveBeenCalledWith(url, {}); + expect(url.toString()).toBe(urlString); + }); + }); + describe('loadRouteParams', () => { it('should return undefined values if not in a browser', () => { const result = loadRouteParams({ diff --git a/src/frontend/src/tests/lib/utils/onramper.utils.spec.ts b/src/frontend/src/tests/lib/utils/onramper.utils.spec.ts index 9022254c78..e9b21f5d17 100644 --- a/src/frontend/src/tests/lib/utils/onramper.utils.spec.ts +++ b/src/frontend/src/tests/lib/utils/onramper.utils.spec.ts @@ -17,7 +17,6 @@ import { import { mockBtcAddress } from '$tests/mocks/btc.mock'; import { mockEthAddress } from '$tests/mocks/eth.mocks'; import { mockAccountIdentifierText } from '$tests/mocks/identity.mock'; -import { describe } from 'vitest'; describe('onramper.utils', () => { describe('buildOnramperLink', () => { diff --git a/src/frontend/src/tests/mocks/sol-transactions.mock.ts b/src/frontend/src/tests/mocks/sol-transactions.mock.ts new file mode 100644 index 0000000000..228b3772b7 --- /dev/null +++ b/src/frontend/src/tests/mocks/sol-transactions.mock.ts @@ -0,0 +1,150 @@ +import type { SolRpcTransaction, SolTransactionUi } from '$sol/types/sol-transaction'; +import { mockSolAddress } from '$tests/mocks/sol.mock'; +import { address } from '@solana/addresses'; +import { + blockhash, + lamports, + type Base58EncodedBytes, + type UnixTimestamp +} from '@solana/rpc-types'; + +export const createMockSolTransactionUi = (id: string): SolTransactionUi => ({ + id, + timestamp: 0n, + type: 'send', + value: BigInt(100), + from: 'sender', + to: 'receiver', + status: 'finalized' +}); + +export const mockSolRpcReceiveTransaction: SolRpcTransaction = { + blockTime: 1736257946n as UnixTimestamp, + confirmationStatus: 'finalized', + id: '4UjEjyVYfPNkr5TzZ3oH8ZS8PiEzbHsBdhvRtrLiuBfk8pQMRNvY3UUxjHe4nSzxAnhd8JCSQ3YYmAj651ZWeArM', + meta: { + computeUnitsConsumed: 150n, + err: null, + fee: lamports(5000n), + innerInstructions: [], + loadedAddresses: { + readonly: [], + writable: [] + }, + logMessages: [ + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success' + ], + postBalances: [lamports(14808188293851n), lamports(5849985100n), lamports(1n)], + postTokenBalances: [], + preBalances: [lamports(14813188298851n), lamports(849985100n), lamports(1n)], + preTokenBalances: [], + rewards: [], + status: { + Ok: null + } + }, + slot: 352454651n, + transaction: { + message: { + addressTableLookups: [], + accountKeys: [ + address('devwuNsNYACyiEYxRNqMNseBpNnGfnd4ZwNHL7sphqv'), + address(mockSolAddress), + address('11111111111111111111111111111111') + ], + header: { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 1, + numRequiredSignatures: 1 + }, + instructions: [ + { + accounts: [0, 1], + data: '3Bxs411qCLLRMUsZ' as Base58EncodedBytes, + programIdIndex: 2, + stackHeight: undefined + } + ], + recentBlockhash: blockhash('ARU13JbajMAevpuyAdaUEg2Fx4eb7H46wMqga2w5F6me') + }, + signatures: [ + '4UjEjyVYfPNkr5TzZ3oH8ZS8PiEzbHsBdhvRtrLiuBfk8pQMRNvY3UUxjHe4nSzxAnhd8JCSQ3YYmAj651ZWeArM' + ] as Base58EncodedBytes[] + }, + version: 'legacy' +}; + +export const mockSolRpcSendTransaction: SolRpcTransaction = { + blockTime: 1736256974n as UnixTimestamp, + confirmationStatus: 'finalized', + id: '4xiJZFz8wVnFHhjNfLV2ZaGnFFkoJ1U2RcYhTFmyq8szGDNTvha2MtUhzPjqQwcNF9JqNwG4h5FVohFNWrqzrwVc', + meta: { + computeUnitsConsumed: 450n, + err: null, + fee: lamports(14900n), + innerInstructions: [], + loadedAddresses: { + readonly: [], + writable: [] + }, + logMessages: [ + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program 11111111111111111111111111111111 invoke [1]', + 'Program 11111111111111111111111111111111 success' + ], + postBalances: [lamports(849985100n), lamports(150000000n), lamports(1n), lamports(1n)], + postTokenBalances: [], + preBalances: [lamports(1000000000n), lamports(0n), lamports(1n), lamports(1n)], + preTokenBalances: [], + rewards: [], + status: { + Ok: null + } + }, + slot: 352452048n, + transaction: { + message: { + addressTableLookups: [], + accountKeys: [ + address(mockSolAddress), + address('4DAtqyYPYCj2WK4RpPQwCNxz3xYLm5G9vTuZqnP2ZzcQ'), + address('11111111111111111111111111111111'), + address('ComputeBudget111111111111111111111111111111') + ], + header: { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 2, + numRequiredSignatures: 1 + }, + instructions: [ + { + accounts: [], + data: '3DVGviTXKAPH' as Base58EncodedBytes, + programIdIndex: 3, + stackHeight: undefined + }, + { + accounts: [], + data: 'LCQ37u' as Base58EncodedBytes, + programIdIndex: 3, + stackHeight: undefined + }, + { + accounts: [0, 1], + data: '3Bxs4NQNnDSisSzK' as Base58EncodedBytes, + programIdIndex: 2, + stackHeight: undefined + } + ], + recentBlockhash: blockhash('Hz2ewskR9apeDBd9i38tYLATZgHujbjnp9AuRDSQuZB7') + }, + signatures: [ + '4xiJZFz8wVnFHhjNfLV2ZaGnFFkoJ1U2RcYhTFmyq8szGDNTvha2MtUhzPjqQwcNF9JqNwG4h5FVohFNWrqzrwVc' + ] as Base58EncodedBytes[] + }, + version: 'legacy' +}; diff --git a/src/frontend/src/tests/sol/components/core/SolLoaderWallets.spec.ts b/src/frontend/src/tests/sol/components/core/SolLoaderWallets.spec.ts new file mode 100644 index 0000000000..09041b287f --- /dev/null +++ b/src/frontend/src/tests/sol/components/core/SolLoaderWallets.spec.ts @@ -0,0 +1,124 @@ +import * as solEnv from '$env/networks/networks.sol.env'; +import { + SOLANA_DEVNET_TOKEN, + SOLANA_TESTNET_TOKEN, + SOLANA_TOKEN +} from '$env/tokens/tokens.sol.env'; +import * as appConstants from '$lib/constants/app.constants'; +import { + solAddressDevnetStore, + solAddressLocalnetStore, + solAddressMainnetStore, + solAddressTestnetStore +} from '$lib/stores/address.store'; +import { testnetsStore } from '$lib/stores/settings.store'; +import SolLoaderWallets from '$sol/components/core/SolLoaderWallets.svelte'; +import { enabledSolanaTokens } from '$sol/derived/tokens.derived'; +import { initSolWalletWorker } from '$sol/services/worker.sol-wallet.services'; +import { render } from '@testing-library/svelte'; +import { get } from 'svelte/store'; + +vi.mock('$sol/services/worker.sol-wallet.services', () => ({ + initSolWalletWorker: vi.fn() +})); + +describe('SolLoaderWallets', () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Reset all address stores + solAddressLocalnetStore.reset(); + solAddressTestnetStore.reset(); + solAddressDevnetStore.reset(); + solAddressMainnetStore.reset(); + testnetsStore.reset({ key: 'testnets' }); + + vi.spyOn(solEnv, 'SOLANA_NETWORK_ENABLED', 'get').mockImplementation(() => true); + + vi.spyOn(appConstants, 'LOCAL', 'get').mockImplementation(() => false); + }); + + it('should not initialize wallet workers when no addresses are available', () => { + testnetsStore.set({ key: 'testnets', value: { enabled: true } }); + + render(SolLoaderWallets); + + // With testnets enabled, we expect mainnet + testnet + devnet tokens + expect(get(enabledSolanaTokens).length).toBe(3); + expect(initSolWalletWorker).not.toHaveBeenCalled(); + }); + + it('should initialize wallet workers only for networks with available addresses', () => { + const testnetAddress = 'testnet-address'; + const mainnetAddress = 'mainnet-address'; + + testnetsStore.set({ key: 'testnets', value: { enabled: true } }); + solAddressTestnetStore.set({ data: testnetAddress, certified: true }); + solAddressMainnetStore.set({ data: mainnetAddress, certified: true }); + + render(SolLoaderWallets); + + const walletWorkerTokens = get(enabledSolanaTokens).filter( + ({ network: { id: networkId } }) => + (networkId === SOLANA_TESTNET_TOKEN.network.id && testnetAddress) || + (networkId === SOLANA_TOKEN.network.id && mainnetAddress) + ); + + expect(walletWorkerTokens.length).toBe(2); + }); + + it('should update wallet workers when addresses change', async () => { + const devnetAddress = 'devnet-address'; + testnetsStore.set({ key: 'testnets', value: { enabled: true } }); + + const { rerender } = render(SolLoaderWallets); + + expect(initSolWalletWorker).not.toHaveBeenCalled(); + + solAddressDevnetStore.set({ data: devnetAddress, certified: true }); + await rerender({}); + + const walletWorkerTokens = get(enabledSolanaTokens).filter( + ({ network: { id: networkId } }) => + networkId === SOLANA_DEVNET_TOKEN.network.id && devnetAddress + ); + + expect(walletWorkerTokens.length).toBe(1); + }); + + it('should handle empty enabled tokens list when Solana network is disabled', () => { + vi.spyOn(solEnv, 'SOLANA_NETWORK_ENABLED', 'get').mockImplementation(() => false); + render(SolLoaderWallets); + expect(get(enabledSolanaTokens).length).toBe(0); + expect(initSolWalletWorker).not.toHaveBeenCalled(); + }); + + it('should handle all networks having addresses', () => { + testnetsStore.set({ key: 'testnets', value: { enabled: true } }); + solAddressLocalnetStore.set({ data: 'local-address', certified: true }); + solAddressTestnetStore.set({ data: 'testnet-address', certified: true }); + solAddressDevnetStore.set({ data: 'devnet-address', certified: true }); + solAddressMainnetStore.set({ data: 'mainnet-address', certified: true }); + + render(SolLoaderWallets); + + const walletWorkerTokens = get(enabledSolanaTokens).filter( + ({ network: { id: networkId } }) => + networkId === SOLANA_TESTNET_TOKEN.network.id || + networkId === SOLANA_TOKEN.network.id || + networkId === SOLANA_DEVNET_TOKEN.network.id + ); + + expect(walletWorkerTokens.length).toBe(3); + }); + + it('should include local network token when LOCAL is true', () => { + vi.spyOn(appConstants, 'LOCAL', 'get').mockImplementation(() => true); + testnetsStore.set({ key: 'testnets', value: { enabled: true } }); + + render(SolLoaderWallets); + + // With LOCAL true and testnets enabled, we expect mainnet + testnet + devnet + local tokens + expect(get(enabledSolanaTokens).length).toBe(4); + }); +}); diff --git a/src/frontend/src/tests/sol/derived/sol-transactions.derived.spec.ts b/src/frontend/src/tests/sol/derived/sol-transactions.derived.spec.ts new file mode 100644 index 0000000000..a04a8907dd --- /dev/null +++ b/src/frontend/src/tests/sol/derived/sol-transactions.derived.spec.ts @@ -0,0 +1,51 @@ +import { SOLANA_TOKEN, SOLANA_TOKEN_ID } from '$env/tokens/tokens.sol.env'; +import { token } from '$lib/stores/token.store'; +import { solTransactions } from '$sol/derived/sol-transactions.derived'; +import { solTransactionsStore } from '$sol/stores/sol-transactions.store'; +import { createMockSolTransactionUi } from '$tests/mocks/sol-transactions.mock'; +import { get } from 'svelte/store'; + +describe('sol-transactions.derived', () => { + const transactions = [ + { + data: createMockSolTransactionUi('tx1'), + certified: false + }, + { + data: createMockSolTransactionUi('tx2'), + certified: false + } + ]; + + beforeEach(() => { + token.set(SOLANA_TOKEN); + }); + + it('should return an empty array when transactions store is empty', () => { + const result = get(solTransactions); + expect(result).toEqual([]); + }); + + it('should return empty array when transactions is nullish', () => { + solTransactionsStore.append({ + tokenId: SOLANA_TOKEN_ID, + transactions + }); + + solTransactionsStore.nullify(SOLANA_TOKEN_ID); + + const result = get(solTransactions); + expect(result).toHaveLength(0); + expect(result).toEqual([]); + }); + + it('should return transactions for the current token', () => { + solTransactionsStore.append({ + tokenId: SOLANA_TOKEN_ID, + transactions + }); + + const result = get(solTransactions); + expect(result).toEqual(transactions.map(({ data }) => data)); + }); +}); diff --git a/src/frontend/src/tests/sol/schedulers/sol-wallet.scheduler.spec.ts b/src/frontend/src/tests/sol/schedulers/sol-wallet.scheduler.spec.ts new file mode 100644 index 0000000000..2861183f31 --- /dev/null +++ b/src/frontend/src/tests/sol/schedulers/sol-wallet.scheduler.spec.ts @@ -0,0 +1,178 @@ +import { WALLET_TIMER_INTERVAL_MILLIS } from '$lib/constants/app.constants'; +import type { PostMessageDataRequestSol } from '$lib/types/post-message'; +import * as authUtils from '$lib/utils/auth.utils'; +import * as solanaApi from '$sol/api/solana.api'; +import { SolWalletScheduler } from '$sol/schedulers/sol-wallet.scheduler'; +import { SolanaNetworks } from '$sol/types/network'; +import { mockIdentity } from '$tests/mocks/identity.mock'; +import { lamports } from '@solana/rpc-types'; +import { type MockInstance } from 'vitest'; + +describe('sol-wallet.scheduler', () => { + let spyLoadBalance: MockInstance; + + const mockBalance = lamports(100n); + + const mockPostMessageStatusInProgress = { + msg: 'syncSolWalletStatus', + data: { + state: 'in_progress' + } + }; + + const mockPostMessageStatusIdle = { + msg: 'syncSolWalletStatus', + data: { + state: 'idle' + } + }; + + const mockPostMessage = ({ certified }: { certified: boolean }) => ({ + msg: 'syncSolWallet', + data: { + wallet: { + balance: { + certified, + data: mockBalance + } + } + } + }); + + const postMessageMock = vi.fn(); + + let originalPostmessage: unknown; + + beforeAll(() => { + originalPostmessage = window.postMessage; + window.postMessage = postMessageMock; + }); + + afterAll(() => { + // @ts-expect-error redo original + window.postMessage = originalPostmessage; + }); + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + + spyLoadBalance = vi.spyOn(solanaApi, 'loadSolLamportsBalance').mockResolvedValue(mockBalance); + + vi.spyOn(authUtils, 'loadIdentity').mockResolvedValue(mockIdentity); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + const testWorker = ({ + startData = undefined + }: { + startData?: PostMessageDataRequestSol | undefined; + }) => { + const scheduler: SolWalletScheduler = new SolWalletScheduler(); + + const mockPostMessageCertified = mockPostMessage({ + certified: false + }); + + afterEach(() => { + // reset internal store with balance + scheduler['store'] = { + balance: undefined + }; + + scheduler.stop(); + }); + + it('should trigger postMessage with correct data', async () => { + await scheduler.start(startData); + + expect(postMessageMock).toHaveBeenCalledTimes(3); + expect(postMessageMock).toHaveBeenNthCalledWith(1, mockPostMessageStatusInProgress); + expect(postMessageMock).toHaveBeenCalledWith(mockPostMessageCertified); + expect(postMessageMock).toHaveBeenNthCalledWith(3, mockPostMessageStatusIdle); + + await vi.advanceTimersByTimeAsync(WALLET_TIMER_INTERVAL_MILLIS); + + expect(postMessageMock).toHaveBeenCalledTimes(5); + expect(postMessageMock).toHaveBeenNthCalledWith(4, mockPostMessageStatusInProgress); + expect(postMessageMock).toHaveBeenNthCalledWith(5, mockPostMessageStatusIdle); + + await vi.advanceTimersByTimeAsync(WALLET_TIMER_INTERVAL_MILLIS); + + expect(postMessageMock).toHaveBeenCalledTimes(7); + expect(postMessageMock).toHaveBeenNthCalledWith(6, mockPostMessageStatusInProgress); + expect(postMessageMock).toHaveBeenNthCalledWith(7, mockPostMessageStatusIdle); + }); + + it('should start the scheduler with an interval', async () => { + await scheduler.start(startData); + + expect(scheduler['timer']['timer']).toBeDefined(); + }); + + it('should trigger the scheduler manually', async () => { + await scheduler.trigger(startData); + + expect(spyLoadBalance).toHaveBeenCalledTimes(1); + }); + + it('should stop the scheduler', () => { + scheduler.stop(); + expect(scheduler['timer']['timer']).toBeUndefined(); + }); + + it('should trigger syncWallet periodically', async () => { + await scheduler.start(startData); + + expect(spyLoadBalance).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(WALLET_TIMER_INTERVAL_MILLIS); + + expect(spyLoadBalance).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(WALLET_TIMER_INTERVAL_MILLIS); + + expect(spyLoadBalance).toHaveBeenCalledTimes(3); + }); + + it('should postMessage with status of the worker', async () => { + await scheduler.start(startData); + + expect(postMessageMock).toHaveBeenCalledWith(mockPostMessageStatusInProgress); + expect(postMessageMock).toHaveBeenCalledWith(mockPostMessageStatusIdle); + }); + + it('should trigger postMessage with error', async () => { + const err = new Error('test'); + spyLoadBalance.mockRejectedValue(err); + + await scheduler.start(startData); + + // idle and in_progress + // error + expect(postMessageMock).toHaveBeenCalledTimes(3); + + expect(postMessageMock).toHaveBeenCalledWith({ + msg: 'syncSolWalletError', + data: { + error: err + } + }); + }); + }; + + describe('sol-wallet worker should work', () => { + const startData = { + address: { + certified: false, + data: 'mock-sol-address' + }, + solanaNetwork: SolanaNetworks.mainnet + }; + + testWorker({ startData }); + }); +}); diff --git a/src/frontend/src/tests/sol/services/sol-balance.services.spec.ts b/src/frontend/src/tests/sol/services/sol-balance.services.spec.ts new file mode 100644 index 0000000000..5fc7f7eb9b --- /dev/null +++ b/src/frontend/src/tests/sol/services/sol-balance.services.spec.ts @@ -0,0 +1,23 @@ +import { SOLANA_TESTNET_TOKEN } from '$env/tokens/tokens.sol.env'; +import { balancesStore } from '$lib/stores/balances.store'; +import type { Token } from '$lib/types/token'; +import { loadSolBalance } from '$sol/services/sol-balance.services'; +import { mockSolAddress } from '$tests/mocks/sol.mock'; +import { get } from 'svelte/store'; + +describe('sol-balance.services', () => { + // TODO: change DEVNET to use the normal RPC and not alchemy, and add it to this tests + const solanaTokens: Token[] = [SOLANA_TESTNET_TOKEN]; + + describe('loadSolBalance', () => { + it.each(solanaTokens)( + 'should return the balance in SOL of the $name native token for the address', + async (token) => { + const result = await loadSolBalance({ address: mockSolAddress, token }); + + expect(result).toEqual({ success: true }); + expect(get(balancesStore)?.[token.id]?.data.toNumber()).toBeGreaterThanOrEqual(0); + } + ); + }, 60000); +}); diff --git a/src/frontend/src/tests/sol/services/sol-listener.services.spec.ts b/src/frontend/src/tests/sol/services/sol-listener.services.spec.ts index 632aa196ca..14ac9debec 100644 --- a/src/frontend/src/tests/sol/services/sol-listener.services.spec.ts +++ b/src/frontend/src/tests/sol/services/sol-listener.services.spec.ts @@ -4,23 +4,23 @@ import { parseTokenId } from '$lib/validation/token.validation'; import { syncWallet, syncWalletError } from '$sol/services/sol-listener.services'; import type { SolPostMessageDataResponseWallet } from '$sol/types/sol-post-message'; import { BigNumber } from '@ethersproject/bignumber'; +import { lamports, type Lamports } from '@solana/rpc-types'; import { get } from 'svelte/store'; describe('sol-listener', () => { const tokenId: TokenId = parseTokenId('testTokenId'); - const mockBalance = 1000n; + const mockBalance = lamports(1000n); const mockPostMessage = ({ balance = mockBalance }: { - balance?: bigint | null; + balance?: Lamports | null; }): SolPostMessageDataResponseWallet => ({ wallet: { balance: { certified: true, data: balance - }, - newTransactions: '' + } } }); diff --git a/src/frontend/src/tests/sol/utils/sol-transactions.utils.spec.ts b/src/frontend/src/tests/sol/utils/sol-transactions.utils.spec.ts new file mode 100644 index 0000000000..1abe0911c2 --- /dev/null +++ b/src/frontend/src/tests/sol/utils/sol-transactions.utils.spec.ts @@ -0,0 +1,65 @@ +import { mapSolTransactionUi } from '$sol/utils/sol-transactions.utils'; +import { + mockSolRpcReceiveTransaction, + mockSolRpcSendTransaction +} from '$tests/mocks/sol-transactions.mock'; +import { mockSolAddress } from '$tests/mocks/sol.mock'; +import { describe, expect, it } from 'vitest'; + +describe('sol-transactions.utils', () => { + describe('mapSolTransactionUi', () => { + it('should map a receive transaction correctly', () => { + const result = mapSolTransactionUi({ + transaction: mockSolRpcReceiveTransaction, + address: mockSolAddress + }); + + const { + transaction: { + signatures, + message: { accountKeys } + }, + meta, + blockTime + } = mockSolRpcReceiveTransaction; + + expect(result).toEqual({ + id: signatures[0], + fee: meta?.fee, + from: accountKeys[0], + to: accountKeys[1], + type: 'receive', + status: 'finalized', + value: 5000000000n, + timestamp: blockTime + }); + }); + + it('should map a send transaction correctly', () => { + const result = mapSolTransactionUi({ + transaction: mockSolRpcSendTransaction, + address: mockSolAddress + }); + + const { + transaction: { + signatures, + message: { accountKeys } + }, + meta, + blockTime + } = mockSolRpcSendTransaction; + + expect(result).toEqual({ + id: signatures[0], + fee: meta?.fee, + from: accountKeys[0], + to: accountKeys[1], + type: 'send', + status: 'finalized', + value: -150000000n, + timestamp: blockTime + }); + }); + }); +});