diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 3cc258ca1..893c67e74 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - env: [prod, hydrogen] + env: [prod, staging, hydrogen] extension_type: [chrome, firefox] environment: ${{ matrix.env }} @@ -29,8 +29,9 @@ jobs: # API URLs ARGENT_API_BASE_URL: ${{ vars.ARGENT_API_BASE_URL }} ARGENT_X_STATUS_URL: ${{ vars.ARGENT_X_STATUS_URL }} + ARGENT_X_NEWS_URL: ${{ vars.ARGENT_X_NEWS_URL }} ARGENT_TESTNET_RPC_URL: ${{ vars.ARGENT_TESTNET_RPC_URL }} - + ARGENT_HEALTHCHECK_BASE_URL: ${{ vars.ARGENT_HEALTHCHECK_BASE_URL }} # API ENVIRONMENT ARGENT_X_ENVIRONMENT: ${{ matrix.env }} @@ -44,6 +45,7 @@ jobs: RAMP_API_KEY: ${{ secrets.RAMP_API_KEY }} SAFE_ENV_VARS: false MULTICALL_MAX_BATCH_SIZE: 20 + NEW_CAIRO_0_ENABLED: false # Refresh intervals FAST: 20 # 20s @@ -111,7 +113,7 @@ jobs: run: (cd ./packages/extension/dist && zip -r "../../../${{ env.FILENAME_PREFIX }}-${{ matrix.extension_type }}" .) - name: Upload ${{ matrix.extension_type }} extension - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: ${{ env.FILENAME_PREFIX }}-${{ matrix.extension_type }} path: "*-${{ matrix.extension_type }}.zip" @@ -125,7 +127,9 @@ jobs: env: ARGENT_API_BASE_URL: ${{ vars.ARGENT_API_BASE_URL }} ARGENT_TESTNET_RPC_URL: ${{ vars.ARGENT_TESTNET_RPC_URL }} + ARGENT_HEALTHCHECK_BASE_URL: ${{ vars.ARGENT_HEALTHCHECK_BASE_URL }} ARGENT_X_STATUS_URL: ${{ vars.ARGENT_X_STATUS_URL }} + ARGENT_X_NEWS_URL: ${{ vars.ARGENT_X_NEWS_URL }} ARGENT_X_ENVIRONMENT: "hydrogen" services: @@ -174,7 +178,7 @@ jobs: run: pnpm run test:ci - name: SonarCloud Scan # TODO replace with master as soon as sonarcloud fixes the issue with action https://community.sonarsource.com/t/sonarsource-sonarcloud-github-action-failing-with-node-js-12-error/89664/2 - uses: SonarSource/sonarcloud-github-action@v1.9 + uses: SonarSource/sonarcloud-github-action@v2.1.1 with: projectBaseDir: ./packages/extension env: @@ -198,12 +202,20 @@ jobs: E2E_ACCOUNT_1_SEED2: ${{ secrets.E2E_ACCOUNT_1_SEED2 }} E2E_ACCOUNT_1_SEED3: ${{ secrets.E2E_ACCOUNT_1_SEED3 }} ## BANK ACCOUNT, USED FOR FUND OTHER ACCOUNTS - E2E_SENDER_ADDRESS: ${{ secrets.E2E_SENDER_ADDRESS }} - E2E_SENDER_PRIVATEKEY: ${{ secrets.E2E_SENDER_PRIVATEKEY }} + E2E_SENDER_ADDRESSES: ${{ secrets.E2E_SENDER_ADDRESSES }} + E2E_SENDER_PRIVATEKEYS: ${{ secrets.E2E_SENDER_PRIVATEKEYS }} E2E_SENDER_SEED: ${{ secrets.E2E_SENDER_SEED }} STARKNET_TESTNET_URL: ${{ secrets.STARKNET_TESTNET_URL }} STARKSCAN_TESTNET_URL: ${{ secrets.STARKSCAN_TESTNET_URL }} ARGENT_TESTNET_RPC_URL: ${{ secrets.ARGENT_TESTNET_RPC_URL }} + ARGENT_HEALTHCHECK_BASE_URL: ${{ secrets.ARGENT_HEALTHCHECK_BASE_URL }} + E2E_SPOK_CAMPAIGN_URL: ${{ secrets.E2E_SPOK_CAMPAIGN_URL }} + E2E_SPOK_CAMPAIGN_NAME: ${{ secrets.E2E_SPOK_CAMPAIGN_NAME }} + # Refresh intervals + REFRESH_INTERVAL_FAST: 1 # 1s + REFRESH_INTERVAL_MEDIUM: 5 # 5s + REFRESH_INTERVAL_SLOW: 20 # 20s + REFRESH_INTERVAL_VERY_SLOW: 60 * 10 # 10m steps: - uses: actions/checkout@v4 @@ -241,19 +253,20 @@ jobs: run: xvfb-run --auto-servernum pnpm test:e2e:extension --project=${{ matrix.project }} --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} - name: Upload artifacts - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: always() with: - name: test-artifacts + name: test-artifacts-${{ matrix.shardIndex }} path: | packages/e2e/artifacts/playwright/ + !packages/e2e/artifacts/playwright/*.webm retention-days: 5 - name: Upload blob report to GitHub Actions Artifacts if: always() - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: all-blob-reports + name: all-blob-reports-${{ matrix.shardIndex }} path: packages/e2e/blob-report/ retention-days: 5 @@ -286,19 +299,20 @@ jobs: ${{ runner.os }}-pnpm-store- - name: Download blob reports from GitHub Actions Artifacts - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: - name: all-blob-reports path: all-blob-reports + pattern: all-blob-reports-* + merge-multiple: true - name: Merge into HTML Report - run: npx playwright merge-reports --reporter html ./all-blob-reports + run: npx playwright merge-reports -c ./packages/e2e/merge-reports.config.js ./all-blob-reports - name: Upload HTML report - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: html-report--attempt-${{ github.run_attempt }} - path: playwright-report + path: packages/e2e/playwright-report retention-days: 14 add_pr_comments: @@ -408,6 +422,23 @@ jobs: NEXT_PUBLIC_ARGENT_API_BASE_URL: ${{ vars.ARGENT_API_BASE_URL }} NEXT_PUBLIC_ARGENT_TESTNET_RPC_URL: ${{ vars.ARGENT_TESTNET_RPC_URL }} + ARGENT_API_BASE_URL: ${{ secrets.ARGENT_API_BASE_URL }} + E2E_TESTNET_SEED1: ${{ secrets.E2E_TESTNET_SEED1 }} + E2E_TESTNET_SEED2: ${{ secrets.E2E_TESTNET_SEED2 }} + E2E_TESTNET_SEED3: ${{ secrets.E2E_TESTNET_SEED3 }} + E2E_ACCOUNT_1_SEED2: ${{ secrets.E2E_ACCOUNT_1_SEED2 }} + E2E_ACCOUNT_1_SEED3: ${{ secrets.E2E_ACCOUNT_1_SEED3 }} + ## BANK ACCOUNT, USED FOR FUND OTHER ACCOUNTS + E2E_SENDER_ADDRESSES: ${{ secrets.E2E_SENDER_ADDRESSES }} + E2E_SENDER_PRIVATEKEYS: ${{ secrets.E2E_SENDER_PRIVATEKEYS }} + E2E_SENDER_SEED: ${{ secrets.E2E_SENDER_SEED }} + STARKNET_TESTNET_URL: ${{ secrets.STARKNET_TESTNET_URL }} + STARKSCAN_TESTNET_URL: ${{ secrets.STARKSCAN_TESTNET_URL }} + ARGENT_TESTNET_RPC_URL: ${{ secrets.ARGENT_TESTNET_RPC_URL }} + ARGENT_HEALTHCHECK_BASE_URL: ${{ secrets.ARGENT_HEALTHCHECK_BASE_URL }} + E2E_SPOK_CAMPAIGN_URL: ${{ secrets.E2E_SPOK_CAMPAIGN_URL }} + E2E_SPOK_CAMPAIGN_NAME: ${{ secrets.E2E_SPOK_CAMPAIGN_NAME }} + steps: - uses: actions/checkout@v4 @@ -443,19 +474,19 @@ jobs: pnpm run test:e2e:webwallet - name: Upload artifacts - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: always() with: - name: test-artifacts + name: test-artifacts-${{ matrix.shardIndex }} path: | packages/e2e/artifacts/playwright/ retention-days: 5 - name: Upload blob report to GitHub Actions Artifacts if: always() - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: all-blob-reports-webwallet + name: all-blob-reports-webwallet-${{ matrix.shardIndex }} path: packages/e2e/blob-report/ retention-days: 5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f5dd457f3..cd7fcf1b7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,13 +30,16 @@ jobs: SAFE_ENV_VARS: true ARGENT_API_BASE_URL: ${{ vars.ARGENT_API_BASE_URL }} ARGENT_TESTNET_RPC_URL: ${{ vars.ARGENT_TESTNET_RPC_URL }} + ARGENT_HEALTHCHECK_BASE_URL: ${{ vars.ARGENT_HEALTHCHECK_BASE_URL }} ARGENT_X_STATUS_URL: ${{ vars.ARGENT_X_STATUS_URL }} + ARGENT_X_NEWS_URL: ${{ vars.ARGENT_X_NEWS_URL }} ARGENT_X_ENVIRONMENT: "prod" MULTICALL_MAX_BATCH_SIZE: 20 FAST: 20 # 20s MEDIUM: 60 # 60s SLOW: 60 * 5 # 5m VERY_SLOW: 24 * 60 * 60 # 1d + NEW_CAIRO_0_ENABLED: false if: ${{ !startsWith(github.ref, 'refs/tags/') || startsWith(github.ref, 'refs/tags/') && contains(github.ref, 'extension') }} runs-on: ubuntu-latest @@ -82,7 +85,7 @@ jobs: run: pnpm bundlewatch - name: Upload artifacts for chrome - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: chrome path: "*-chrome.zip" @@ -90,7 +93,7 @@ jobs: if-no-files-found: error - name: Upload artifacts for firefox - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: firefox path: "*-firefox.zip" @@ -132,10 +135,6 @@ jobs: if: startsWith(github.ref, 'refs/tags/') run: | npm config set "//registry.npmjs.org/:_authToken" "$NPM_ACCESS_TOKEN" - cp Readme.md ./packages/get-starknet/README.md - pnpm --filter @argent/get-starknet publish --no-git-checks --access public || exit 0 - pnpm --filter @argent/web-sdk publish --no-git-checks --access public || exit 0 - pnpm --filter @argent/starknet-react-webwallet-connector publish --no-git-checks --access public || exit 0 pnpm --filter @argent/x-sessions publish --no-git-checks --access public || exit 0 - name: Get product version diff --git a/.nvmrc b/.nvmrc index d5a159609..8b0beab16 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.10.0 +20.11.0 diff --git a/Readme.md b/Readme.md index 2806f8d11..a903ea175 100644 --- a/Readme.md +++ b/Readme.md @@ -5,7 +5,7 @@ --- -

⬇️ Get Argent X for StarkNet today:

+

⬇️ Get Argent X for Starknet today:

@@ -44,7 +44,7 @@ The example dapp is also contained in this repository. ## 🌐 Usage with your dapp -If you want to use this StarkNet Wallet extension with your dapp, the easiest way is to checkout the [starknetkit](https://github.com/argentlabs/starknetkit) package +If you want to use this Starknet Wallet extension with your dapp, the easiest way is to checkout the [starknetkit](https://github.com/argentlabs/starknetkit) package ```bash # starknet.js is a peer dependency diff --git a/docs/Upgrade_v3.md b/docs/Upgrade_v3.md index 4c520da6c..78eecb5f2 100644 --- a/docs/Upgrade_v3.md +++ b/docs/Upgrade_v3.md @@ -35,9 +35,9 @@ As a user of Argent X 2.x, after upgrading to version 3.0.0, your extension will 4. Disable Argent X 3.0.0 by switching off the toggle in the lower right corner of the Argent X panel in the Chrome extensions page. -5. Open the extension v2.2.3 and restore from your backup file, the one you downloaded in step 1. Now you can transfer all your tokens to your new address, the one you copied at step 2. For ERC721 assets, you'll have to do it in [Voyager](https://voyager.online/), the StarkNet block explorer, by connecting your wallet and transferring manually. +5. Open the extension v2.2.3 and restore from your backup file, the one you downloaded in step 1. Now you can transfer all your tokens to your new address, the one you copied at step 2. For ERC721 assets, you'll have to do it in [Voyager](https://voyager.online/), the Starknet block explorer, by connecting your wallet and transferring manually. -## What does it mean as a StarkNet dapp developer? +## What does it mean as a Starknet dapp developer? The `starknet` object (returned by `get-starknet`) will now expose an `account` object instead of a `signer` object. This `account` object implements the [AccountInterface](https://github.com/0xs34n/starknet.js/blob/develop/src/account/interface.ts), specifically it exposes the methods `execute()` and `signMessage()`: @@ -51,9 +51,9 @@ where `Call` is defined by: ```typescript interface Call { - contractAddress: string; - entrypoint: string; - calldata?: BigNumberish[]; + contractAddress: string + entrypoint: string + calldata?: BigNumberish[] } ``` diff --git a/package.json b/package.json index e5928a8e4..ecf4144f8 100644 --- a/package.json +++ b/package.json @@ -25,21 +25,21 @@ "scripts": { "format": "prettier --loglevel warn --write \"**/*.{js,jsx,ts,tsx,css,md,yml,json}\"", "dev": "NODE_ENV=development pnpm run -r --stream --parallel dev", - "dev:ui": "NODE_ENV=development pnpm --parallel run dev:ui ", + "dev:ui": "NODE_ENV=development pnpm --parallel run dev:ui", "dev:extension": "NODE_ENV=development pnpm run --filter @argent-x/extension -r --stream --parallel dev", "build-storybook": "pnpm run --filter @argent-x/storybook build-storybook", - "clean": "rm -rf packages/extension/dist packages/get-starket/dist", + "clean": "rm -rf packages/extension/dist", "build": "pnpm run -r --parallel --stream build", "build:extension": "pnpm run --filter @argent-x/extension build", "build:web": "pnpm run --filter @argent/web build", "build:sourcemaps": "GEN_SOURCE_MAPS=true pnpm run build", - "lint": "pnpm run -r --parallel lint ", + "lint": "pnpm run -r --parallel lint", "test": "pnpm run -r --parallel --stream test", - "test:watch": "pnpm run -r --paralle; --stream test:watch ", + "test:watch": "pnpm run -r --parallel; --stream test:watch", "test:e2e:extension": "pnpm run --filter @argent-x/e2e test:extension", "test:e2e:webwallet": "pnpm run --filter @argent-x/e2e test:webwallet", "setup": "pnpm install --frozen-lockfile && pnpm allow-scripts && husky install && patch-package && pnpm run -r --stream setup", - "test:ci": "pnpm run --stream --parallel test:ci ", + "test:ci": "pnpm run --stream --parallel test:ci", "storybook": "cd packages/storybook && pnpm run storybook", "devnet:upgrade-helper": "NODE_NO_WARNINGS=1 ts-node ./scripts/devnet-upgrade-helper.ts", "devnet:setup-contracts": "NODE_NO_WARNINGS=1 ts-node ./scripts/devnet-setup-contracts.ts", diff --git a/packages/dapp/package.json b/packages/dapp/package.json index 2ffb47161..7b73d2c99 100644 --- a/packages/dapp/package.json +++ b/packages/dapp/package.json @@ -14,17 +14,17 @@ "@argent/ui": "^6.3.1", "@argent/x-sessions": "^6.3.1", "@chakra-ui/react": "^2.6.1", - "@starknet-react/chains": "0.1.0", - "@starknet-react/core": "2.1.1", + "@starknet-react/chains": "0.1.5", + "@starknet-react/core": "2.2.2", "micro-starknet": "^0.2.3", "next": "^13.4.6", "react": "^18.0.0", "react-dom": "^18.0.0", - "starknet": "5.24.3", - "starknetkit": "^1.0.22" + "starknet": "5.25.0", + "starknetkit": "^1.1.0" }, "devDependencies": { - "@types/node": "20.10.4", + "@types/node": "20.11.0", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", "eslint": "8", diff --git a/packages/dapp/src/components/AddNetwork.tsx b/packages/dapp/src/components/AddNetwork.tsx index 397ef6573..03416dc0e 100644 --- a/packages/dapp/src/components/AddNetwork.tsx +++ b/packages/dapp/src/components/AddNetwork.tsx @@ -13,6 +13,7 @@ const AddNetwork = () => { chainId: "SN_DAPP_TEST", chainName: "Test chain name", baseUrl: "http://localhost:5050", + rpcUrls: ["http://localhost:5050/rpc"], }) setAddNetworkError("") } catch (error) { diff --git a/packages/dapp/src/components/InfoRow.tsx b/packages/dapp/src/components/InfoRow.tsx index b7585a3fa..bc9e77200 100644 --- a/packages/dapp/src/components/InfoRow.tsx +++ b/packages/dapp/src/components/InfoRow.tsx @@ -1,10 +1,10 @@ import { H4 } from "@argent/ui" import { Code, Flex } from "@chakra-ui/react" -import { FC } from "react" +import { FC, ReactNode } from "react" const InfoRow: FC<{ title: string - content?: string + content?: ReactNode copyContent?: string }> = ({ title, content, copyContent }) => { return ( diff --git a/packages/dapp/src/pages/index.tsx b/packages/dapp/src/pages/index.tsx index 0496dc1c0..ea3dd1343 100644 --- a/packages/dapp/src/pages/index.tsx +++ b/packages/dapp/src/pages/index.tsx @@ -1,36 +1,53 @@ import { supportsSessions } from "@argent/x-sessions" import type { StarknetWindowObject } from "get-starknet-core" -import { useCallback, useEffect, useState } from "react" +import { useCallback, useEffect, useMemo, useState } from "react" import { AccountInterface } from "starknet" import { Header } from "../components/Header" +import { + Button, + Code, + Flex, + Menu, + MenuButton, + MenuItem, + MenuList, + Text, +} from "@chakra-ui/react" +import { ChevronDownIcon, CheckIcon } from "@chakra-ui/icons" -import { Button } from "@argent/ui" import { InfoRow } from "../components/InfoRow" import { truncateAddress } from "../services/address.service" import { - addWalletChangeListener, + addWalletAccountsChangedListener, + addWalletNetworkChangedListener, connectWallet, disconnectWallet, getChainId, - removeWalletChangeListener, + removeWalletAccountsChangedListener, + removeWalletNetworkChangedListener, silentConnectWallet, + switchNetwork, } from "../services/wallet.service" import { TokenDapp } from "../components/TokenDapp" -import { Flex } from "@chakra-ui/react" + +const chainIds = ["SN_MAIN", "SN_GOERLI", "SN_SEPOLIA"] const StarknetKitDapp = () => { const [supportSessions, setSupportsSessions] = useState(null) - const [chain, setChain] = useState(undefined) + const [chainId, setChainId] = useState(undefined) const [isConnected, setConnected] = useState(false) const [account, setAccount] = useState(null) const [isSilentConnect, setIsSilentConnect] = useState(true) useEffect(() => { - const handler = async () => { + const onAccountsChanged = async () => { try { const wallet = await silentConnectWallet() const chainId = await getChainId(wallet?.provider as any) - setChain(chainId) + if (chainId && !chainIds.includes(chainId)) { + chainIds.push(chainId) + } + setChainId(chainId) setConnected(!!wallet?.isConnected) if (wallet?.account) { setAccount(wallet.account as any) @@ -54,13 +71,27 @@ const StarknetKitDapp = () => { } } + const onNetworkChanged = (chainId?: any) => { + setChainId(chainId) + } + ;(async () => { - await handler() - addWalletChangeListener(handler) + await onAccountsChanged() + addWalletAccountsChangedListener(onAccountsChanged) + addWalletNetworkChangedListener(onNetworkChanged) })() return () => { - removeWalletChangeListener(handler) + removeWalletAccountsChangedListener(onAccountsChanged) + removeWalletNetworkChangedListener(onNetworkChanged) + } + }, []) + + const handleNetworkClick = useCallback(async (chainId: string) => { + try { + await switchNetwork(chainId) + } catch (e) { + console.error(e) } }, []) @@ -74,7 +105,7 @@ const StarknetKitDapp = () => { async () => { const wallet = await connectWallet(enableWebWallet) const chainId = await getChainId(wallet?.provider as any) - setChain(chainId) + setChainId(chainId) setConnected(!!wallet?.isConnected) if (wallet?.account) { setAccount(wallet.account as any) @@ -99,7 +130,7 @@ const StarknetKitDapp = () => { () => async () => { try { await disconnectWallet() - setChain(undefined) + setChainId(undefined) setConnected(false) setAccount(null) setSupportsSessions(null) @@ -110,6 +141,32 @@ const StarknetKitDapp = () => { [], ) + const chainMenu = useMemo(() => { + return ( +

+ + + {chainId ?? "undefined"} + + + + {chainIds.map((id) => { + const checked = id === chainId + return ( + handleNetworkClick(id)} + icon={} + > + {id} + + ) + })} + + + ) + }, [chainId, handleNetworkClick]) + if (isSilentConnect) { return ( @@ -140,6 +197,7 @@ const StarknetKitDapp = () => { {isConnected ? ( <>
+ { title="Supports sessions:" content={`${supportSessions}`} /> - {account && ( )} diff --git a/packages/dapp/src/pages/starknetReactDapp.tsx b/packages/dapp/src/pages/starknetReactDapp.tsx index f25dcf1a9..e26549725 100644 --- a/packages/dapp/src/pages/starknetReactDapp.tsx +++ b/packages/dapp/src/pages/starknetReactDapp.tsx @@ -16,8 +16,10 @@ import { InjectedConnector } from "starknetkit/injected" import { WebWalletConnector } from "starknetkit/webwallet" import { Header } from "../components/Header" +import { H2 } from "@argent/ui" import { Flex, Image } from "@chakra-ui/react" import React, { useEffect, useState } from "react" +import { useStarknetkitConnectModal } from "starknetkit" import { InfoRow } from "../components/InfoRow" import { TokenDapp } from "../components/TokenDapp" import { truncateAddress } from "../services/address.service" @@ -39,10 +41,14 @@ const StarknetReactDappContent = () => { const chains = [goerli] const { account, status } = useAccount() - const { connect, connectors } = useConnect() + const { connectAsync, connectors } = useConnect() const { disconnect } = useDisconnect() const [isClient, setIsClient] = useState(false) + const { starknetkitConnectModal } = useStarknetkitConnectModal({ + connectors: availableConnectors, + }) + /* https://nextjs.org/docs/messages/react-hydration-error#solution-1-using-useeffect-to-run-on-the-client-only starknet react had an issue with the `available` method need to check their code, probably is executed only on client causing an hydration issue @@ -79,6 +85,7 @@ const StarknetReactDappContent = () => { ) : ( <>
+ {connectors.filter(inAppBrowserFilter).map((connector) => { if (!connector.available()) { @@ -92,7 +99,7 @@ const StarknetReactDappContent = () => { as="button" key={connector.id} borderRadius="full" - onClick={() => connect({ connector })} + onClick={async () => connectAsync({ connector })} alignItems="center" background="neutrals.700" _hover={{ @@ -119,6 +126,30 @@ const StarknetReactDappContent = () => { ) })} + +

Starknetkit modal + starknet-react

+ { + const { connector } = await starknetkitConnectModal() + if (!connector) return // or throw error + await connectAsync({ connector }) + }} + alignItems="center" + background="neutrals.700" + _hover={{ + background: "neutrals.600", + }} + cursor="pointer" + maxW="350px" + gap="2" + py="2" + px="4" + mt="2" + > + Starknetkit modal with starknet-react + )} diff --git a/packages/dapp/src/services/wallet.service.ts b/packages/dapp/src/services/wallet.service.ts index dc5d51879..f081a5c2a 100644 --- a/packages/dapp/src/services/wallet.service.ts +++ b/packages/dapp/src/services/wallet.service.ts @@ -29,7 +29,7 @@ export let windowStarknet: StarknetWindowObjectV5 | null = null export const starknetVersion = "v5" export const silentConnectWallet = async () => { - const _windowStarknet = await connect({ + const { wallet } = await connect({ modalMode: "neverAsk", webWalletUrl, argentMobileOptions: { @@ -40,12 +40,12 @@ export const silentConnectWallet = async () => { // comment this when using webwallet -- enable is already done by @argent/get-starknet and webwallet is currently using only v4 // to remove when @argent/get-starknet will support both v4 and v5 //await _windowStarknet?.enable({ starknetVersion }) - windowStarknet = _windowStarknet as StarknetWindowObjectV5 | null + windowStarknet = wallet as StarknetWindowObjectV5 | null return windowStarknet ?? undefined } export const connectWallet = async () => { - const _windowStarknet = await connect({ + const { wallet } = await connect({ webWalletUrl, // TODO: remove hardcoding argentMobileOptions: { dappName: "Example dapp", @@ -56,7 +56,7 @@ export const connectWallet = async () => { // comment this when using webwallet -- enable is already done by @argent/get-starknet and webwallet is currently using only v4 // to remove when @argent/get-starknet will support both v4 and v5 //await _windowStarknet?.enable({ starknetVersion }) - windowStarknet = _windowStarknet as StarknetWindowObjectV5 | null + windowStarknet = wallet as StarknetWindowObjectV5 | null return windowStarknet ?? undefined } @@ -168,7 +168,7 @@ export const waitForTransaction = async (hash: string) => { return windowStarknet.provider.waitForTransaction(hash) } -export const addWalletChangeListener = async ( +export const addWalletAccountsChangedListener = async ( handleEvent: (accounts: string[]) => void, ) => { if (!windowStarknet?.isConnected) { @@ -177,7 +177,7 @@ export const addWalletChangeListener = async ( windowStarknet.on("accountsChanged", handleEvent) } -export const removeWalletChangeListener = async ( +export const removeWalletAccountsChangedListener = async ( handleEvent: (accounts: string[]) => void, ) => { if (!windowStarknet?.isConnected) { @@ -186,6 +186,24 @@ export const removeWalletChangeListener = async ( windowStarknet.off("accountsChanged", handleEvent) } +export const addWalletNetworkChangedListener = async ( + handleEvent: (network?: string) => void, +) => { + if (!windowStarknet?.isConnected) { + return + } + windowStarknet.on("networkChanged", handleEvent) +} + +export const removeWalletNetworkChangedListener = async ( + handleEvent: (network?: string) => void, +) => { + if (!windowStarknet?.isConnected) { + return + } + windowStarknet.off("networkChanged", handleEvent) +} + export const declare = async ( payload: DeclareContractPayload, transactionsDetail?: InvocationsDetails, @@ -228,3 +246,15 @@ export const addNetwork = async (params: AddStarknetChainParameters) => { params, }) } + +export const switchNetwork = async (chainId: string) => { + if (!windowStarknet?.isConnected) { + throw Error("starknet wallet not connected") + } + await windowStarknet.request({ + type: "wallet_switchStarknetChain", + params: { + chainId, + }, + }) +} diff --git a/packages/e2e/extension/network-setup/Dockerfile b/packages/e2e/extension/network-setup/Dockerfile deleted file mode 100644 index 728e9bee5..000000000 --- a/packages/e2e/extension/network-setup/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ - -FROM shardlabs/starknet-devnet:0.6.3 -RUN addgroup -S localuser \ - && adduser -S localuser -G localuser - -USER localuser - -COPY ./dump.pkl ./dump.pkl -ENTRYPOINT [ "starknet-devnet", "--host", "0.0.0.0", "--port", "5050", "--seed", "0", "--lite-mode" ] \ No newline at end of file diff --git a/packages/e2e/extension/network-setup/build_and_push.sh b/packages/e2e/extension/network-setup/build_and_push.sh deleted file mode 100755 index da2fd886d..000000000 --- a/packages/e2e/extension/network-setup/build_and_push.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/sh - -docker build . --tag e2e-starknet-devnet:latest - -docker login argentlabs-argent-x.jfrog.io -docker tag e2e-starknet-devnet argentlabs-argent-x.jfrog.io/e2e-starknet-devnet:latest -docker push argentlabs-argent-x.jfrog.io/e2e-starknet-devnet:latest diff --git a/packages/e2e/extension/network-setup/dump.pkl b/packages/e2e/extension/network-setup/dump.pkl deleted file mode 100644 index cecd13391..000000000 Binary files a/packages/e2e/extension/network-setup/dump.pkl and /dev/null differ diff --git a/packages/e2e/extension/playwright.config.ts b/packages/e2e/extension/playwright.config.ts index 360c9bb54..dd3e9c23a 100644 --- a/packages/e2e/extension/playwright.config.ts +++ b/packages/e2e/extension/playwright.config.ts @@ -1,7 +1,5 @@ import type { PlaywrightTestConfig } from "@playwright/test" -import config from "./src/config" - -const isCI = Boolean(process.env.CI) +import { isCI, artifactsDir } from "../shared/cfg/test" const playwrightConfig: PlaywrightTestConfig = { projects: [ @@ -9,27 +7,29 @@ const playwrightConfig: PlaywrightTestConfig = { name: "ArgentX", use: { trace: "on-first-retry", - viewport: { width: 360, height: 600 }, - actionTimeout: 60 * 1000, // 1 minute + viewport: { width: 360, height: 800 }, + actionTimeout: 120 * 1000, // 2 minute permissions: ["clipboard-read", "clipboard-write"], }, timeout: 5 * 60e3, // 5 minutes - expect: { timeout: 30 * 1000 }, // 30 seconds + expect: { timeout: 120 * 1000 }, // 2 minute testDir: "./src/specs", testMatch: /\.spec.ts$/, retries: isCI ? 1 : 0, - outputDir: config.artifactsDir, + outputDir: artifactsDir, }, ], - workers: 1, + workers: isCI ? 2 : 1, + fullyParallel: true, reportSlowTests: { - threshold: 1 * 60e3, // 1 minute + threshold: 2 * 60e3, // 2 minutes max: 5, }, - reporter: isCI ? [["github"], ["blob"]] : "list", + reporter: isCI ? [["github"], ["blob"], ["list"]] : "list", forbidOnly: isCI, - outputDir: config.artifactsDir, + outputDir: artifactsDir, preserveOutput: isCI ? "failures-only" : "never", + globalTeardown: "../shared/cfg/global.teardown.ts", } export default playwrightConfig diff --git a/packages/e2e/extension/src/languages/ILanguage.ts b/packages/e2e/extension/src/languages/ILanguage.ts index 249930d9c..f29c87f63 100644 --- a/packages/e2e/extension/src/languages/ILanguage.ts +++ b/packages/e2e/extension/src/languages/ILanguage.ts @@ -10,7 +10,6 @@ export interface ILanguage { no: string unlock: string showSettings: string - lockWallet: string reset: string confirmReset: string save: string @@ -19,8 +18,20 @@ export interface ILanguage { privacyStatement: string approve: string addArgentShield: string + removeArgentShield: string + argentShieldAdded: string + argentShieldRemoved: string dismiss: string reviewSend: string + hide: string + hiddenAccounts: string + copy: string + beforeYouContinue: string + seedWarning: string + revealSeedPhrase: string + copied: string + confirmRecovery: string + remove: string } account: { noAccounts: string @@ -44,6 +55,16 @@ export interface ILanguage { invalidCheckSumError: string invalidAddress: string createMultisig: string + activateAccount: string + notEnoughFoundsFee: string + newToken: string + argentShield: { + wrong2faCode: string + failed2faCode: string + codeNotRequested2fa: string + emailInUse: string + } + removedFromMultisig: string } wallet: { //first screen @@ -70,44 +91,57 @@ export interface ILanguage { finish: string } settings: { - addresBook: string - connectedDapps: string - showRecoveryPhase: string - developerSettings: string - privacy: string - hideAccount: string - deleteAccount: string //only available for local network - exportPrivateKey: string + account: { + manageOwners: { + manageOwners: string + removeOwner: string + replaceOwner: string + } + setConfirmations: string + viewOnStarkScan: string + viewOnVoyager: string + hideAccount: string + deployAccount: string + connectedDapps: { + connectedDapps: string + connect: string + reject: string + disconnectAll: string + noConnectedDapps: string + } + exportPrivateKey: string + } + preferences: { + preferences: string + hideTokens: string + defaultBlockExplorer: string + defaultNFTMarket: string + emailNotifications: string + } + securityPrivacy: { + securityPrivacy: string + autoLockTimer: string + recoveryPhase: string + automaticErrorReporting: string + shareAnonymousData: string + } + addressBook: { + addressBook: string + nameRequired: string + addressRequired: string + removeAddress: string + delete: string + } + developerSettings: { + developerSettings: string + manageNetworks: { + manageNetworks: string + restoreDefaultNetworks: string + } + smartContractDevelopment: string + experimental: string + } extendedView: string - hide: string - hiddenAccounts: string - delete: string - copy: string - copied: string - confirmRecovery: string - revealSeedPhrase: string - beforeYouContinue: string - seedWarning: string - deployAccount: string - } - developerSettings: { - manageNetworks: string - blockExplorer: string - smartContractDevelopment: string - experimental: string - restoreDefaultNetworks: string - } - address: { - nameRequired: string - addressRequired: string - removeAddress: string - delete: string - addressBook: string - } - dapps: { - connect: string - reject: string - disconnectAll: string - noConnectedDapps: string + lockWallet: string } } diff --git a/packages/e2e/extension/src/languages/en/index.ts b/packages/e2e/extension/src/languages/en/index.ts index 5334efad7..f5927ac21 100644 --- a/packages/e2e/extension/src/languages/en/index.ts +++ b/packages/e2e/extension/src/languages/en/index.ts @@ -4,13 +4,12 @@ const texts = { close: "Close", confirm: "Confirm", done: "Done", - continue: "Continue", next: "Next", + continue: "Continue", yes: "Yes", no: "No", unlock: "Unlock", showSettings: "Show settings", - lockWallet: "Lock wallet", reset: "Reset", confirmReset: "RESET", save: "Save", @@ -20,11 +19,25 @@ const texts = { "GDPR statement for browser extension wallet: Argent takes the privacy and security of individuals very seriously and takes every reasonable measure and precaution to protect and secure the personal data that we process. The browser extension wallet does not collect any personal information nor does it correlate any of your personal information with anonymous data processed as part of its services. On top of this Argent has robust information security policies and procedures in place to make sure any processing complies with applicable laws. If you would like to know more or have any questions then please visit our website at https://www.argent.xyz/", approve: "Approve", addArgentShield: "Add Argent Shield", + removeArgentShield: "Remove Argent Shield", + argentShieldAdded: "Argent Shield Added", + argentShieldRemoved: "Argent Shield Removed", dismiss: "Dismiss", reviewSend: "Review send", + hide: "Hide", + hiddenAccounts: "Hidden accounts", + copy: "Copy", + beforeYouContinue: "Before you continue...", + seedWarning: + "Please save your recovery phrase. This is the only way you will be able to recover your Argent X accounts", + revealSeedPhrase: "Click to reveal recovery phrase", + copied: "Copied", + confirmRecovery: + "I have saved my recovery phrase and understand I should never share it with anyone else", + remove: "Remove", }, account: { - noAccounts: "You have no accounts on ", + noAccounts: "You have no accounts on", createAccount: "Create account", addFunds: "Add funds", fundsFromStarkNet: "From another Starknet wallet", @@ -47,21 +60,34 @@ const texts = { invalidCheckSumError: "Invalid address (checksum error)", invalidAddress: "Invalid address", createMultisig: "Create multisig", + activateAccount: "Activate Account", + notEnoughFoundsFee: "Insufficient funds to pay fee", + newToken: "New token", + argentShield: { + wrong2faCode: "Looks like the wrong code. Please try again.", + failed2faCode: + "You have reached the maximum number of attempts. Please wait 30 minutes and request a new code.", + codeNotRequested2fa: + "You have not requested a verification code. Please request a new one.", + emailInUse: + "This address is associated with accounts from another seedphrase.Please enter another email address to continue.", + }, + removedFromMultisig: "You were removed from this multisig", }, wallet: { //first screen banner1: "Welcome to Argent X", - desc1: "Enjoy the security of Ethereum with the scale of StarkNet", + desc1: "Enjoy the security of Ethereum with the scale of Starknet", createButton: "Create a new wallet", restoreButton: "Restore an existing wallet", //second screen banner2: "Disclaimer", desc2: - "StarkNet is in Alpha and may experience technical issues or introduce breaking changes from time to time. Please accept this before continuing.", + "Starknet is in Alpha and may experience technical issues or introduce breaking changes from time to time. Please accept this before continuing.", lossOfFunds: - "I understand that StarkNet will introduce changes (e.g. Cairo 1.0) that will affect my existing account(s) (e.g. rendering unusable) if I do not complete account upgrades.", + "I understand that Starknet will introduce changes (e.g. Cairo 1.0) that will affect my existing account(s) (e.g. rendering unusable) if I do not complete account upgrades.", alphaVersion: - "I understand that StarkNet may experience performance issues and my transactions may fail for various reasons.", + "I understand that Starknet may experience performance issues and my transactions may fail for various reasons.", //third screen banner3: "New wallet", desc3: "Enter a password to protect your wallet", @@ -76,47 +102,58 @@ const texts = { finish: "Finish", }, settings: { - addresBook: "Address book", - connectedDapps: "Connected dapps", - showRecoveryPhase: "Recovery phrase", - developerSettings: "Developer settings", - privacy: "Privacy", - hideAccount: "Hide account", - deleteAccount: "Delete account", //only available for local network - exportPrivateKey: "Export private key", + account: { + manageOwners: { + manageOwners: "Manage owners", + removeOwner: "Remove owner", + replaceOwner: "Replace owner", + }, + setConfirmations: "Set confirmations", + viewOnStarkScan: "View on StarkScan", + viewOnVoyager: "View on Voyager", + hideAccount: "Hide account", + deployAccount: "Deploy account", + connectedDapps: { + connectedDapps: "Connected dapps", + connect: "Connect", + reject: "Reject", + disconnectAll: "Disconnect all", + noConnectedDapps: "No connected dapps", + }, + exportPrivateKey: "Export private key", + }, + preferences: { + preferences: "Preferences", + hideTokens: "Hide tokens with no balance", + defaultBlockExplorer: "Default block explorer", + defaultNFTMarket: "Default NFT marketplace", + emailNotifications: "Email notifications", + }, + securityPrivacy: { + securityPrivacy: "Security & privacy", + autoLockTimer: "Auto lock timer", + recoveryPhase: "Recovery phrase", + automaticErrorReporting: "Automatic Error Reporting", + shareAnonymousData: "Share anonymous data", + }, + addressBook: { + addressBook: "Address book", + nameRequired: "Contact Name is required", + addressRequired: "Address is required", + removeAddress: "Remove from address book", + delete: "Delete", + }, + developerSettings: { + developerSettings: "Developer settings", + manageNetworks: { + manageNetworks: "Manage networks", + restoreDefaultNetworks: "Restore default networks", + }, + smartContractDevelopment: "Smart Contract Development", + experimental: "Experimental", + }, extendedView: "Extended view", - hide: "Hide", - hiddenAccounts: "Hidden accounts", - delete: "Delete", - copy: "Copy", - copied: "Copied", - confirmRecovery: - "I have saved my recovery phrase and understand I should never share it with anyone else", - revealSeedPhrase: "Click to reveal recovery phrase", - beforeYouContinue: "Before you continue...", - seedWarning: - "Please save your recovery phrase. This is the only way you will be able to recover your Argent X accounts", - deployAccount: "Deploy account", - }, - developerSettings: { - manageNetworks: "Manage networks", - blockExplorer: "Block explorer", - smartContractDevelopment: "Smart Contract Development", - experimental: "Experimental", - restoreDefaultNetworks: "Restore default networks", - }, - address: { - nameRequired: "Contact Name is required", - addressRequired: "Address is required", - removeAddress: "Remove from address book", - delete: "Delete", - addressBook: "Address book", - }, - dapps: { - connect: "Connect", - reject: "Reject", - disconnectAll: "Disconnect all", - noConnectedDapps: "No connected dapps", + lockWallet: "Lock wallet", }, } diff --git a/packages/e2e/extension/src/page-objects/Account.ts b/packages/e2e/extension/src/page-objects/Account.ts index 81139e0d1..f47c077ba 100644 --- a/packages/e2e/extension/src/page-objects/Account.ts +++ b/packages/e2e/extension/src/page-objects/Account.ts @@ -2,8 +2,12 @@ import { Page, expect } from "@playwright/test" import { lang } from "../languages" import Activity from "./Activity" +import { + FeeTokens, + TokenSymbol, + getTokenInfo, +} from "../../../shared/src/assets" -type TokenName = "Ethereum" export interface IAsset { name: string balance: number @@ -46,24 +50,28 @@ export default class Account extends Activity { } get send() { - return this.page.locator(`button:text-is("${lang.account.send}")`) + return this.page.locator(`button:has-text("${lang.account.send}")`) } get deployAccount() { return this.page.locator( - `button :text-is("${lang.settings.deployAccount}")`, + `button :text-is("${lang.settings.account.deployAccount}")`, ) } - token(tkn: TokenName) { - return this.page.locator(`button :text-is('${tkn}')`) + token(tkn: TokenSymbol) { + const tokenInfo = getTokenInfo(tkn) + if (!tokenInfo) { + throw new Error(`Invalid token: ${tkn}`) + } + return this.page.locator(`button :text-is('${tokenInfo.name}')`) } get accountListSelector() { return this.page.locator(`[aria-label="Show account list"]`) } - get addANewccountFromAccountList() { + get addANewAccountFromAccountList() { return this.page.locator('[aria-label="Create new wallet"]') } @@ -107,10 +115,8 @@ export default class Account extends Activity { return this.page.locator('[data-testid="tokenBalance"]') } - currentBalance(tkn: "Ethereum") { - return this.page.locator( - ` //button//h6[contains(text(), '${tkn}')]/following::p`, - ) + currentBalance(tkn: TokenSymbol) { + return this.page.locator(`[data-testid="${tkn}-balance"]`) } currentBalanceDevNet(tkn: "ETH") { @@ -150,7 +156,7 @@ export default class Account extends Activity { await this.createAccount.click() } else { await this.accountListSelector.click() - await this.addANewccountFromAccountList.click() + await this.addANewAccountFromAccountList.click() } await this.addStandardAccountFromNewAccountScreen.click() @@ -163,7 +169,7 @@ export default class Account extends Activity { await this.createAccount.click() } else { await this.accountListSelector.click() - await this.addANewccountFromAccountList.click() + await this.addANewAccountFromAccountList.click() } await this.addStandardAccountFromNewAccountScreen.click() @@ -174,7 +180,7 @@ export default class Account extends Activity { const accountAddress = await this.accountAddress .textContent() .then((v) => v?.replaceAll(" ", "")) - await this.close.last().click() + await this.closeLocator.last().click() const accountName = await this.accountListSelector.textContent() return [accountName, accountAddress] } @@ -207,13 +213,13 @@ export default class Account extends Activity { return assetsList } - async ensureAsset(accountName: string, name: "Ethereum", value: string) { + async ensureAsset( + accountName: string, + name: TokenSymbol = "ETH", + value: string, + ) { await this.ensureSelectedAccount(accountName) - await expect( - this.page.locator( - `//*[text() = '${name}']/following-sibling::div/p[text() = '${value}']`, - ), - ).toBeVisible({ timeout: 1000 * 60 * 4 }) + await expect(this.currentBalance(name)).toContainText(value) } async getTotalFeeValue() { @@ -227,27 +233,27 @@ export default class Account extends Activity { return parseFloat(fee.split(" ")[0]) } - async txValidations(txAmount: string) { + async txValidations(feAmount: string) { const trxAmountHeader = await this.page .locator(`//*[starts-with(text(),'Send ')]`) .textContent() .then((v) => v?.split(" ")[1]) - const amountLocator = this.page.locator( - `//div//label[text()='Send']/following-sibling::div[1]//*[@data-testid]`, - ) - const sendAmount = await amountLocator - .textContent() - .then((v) => v?.split(" ")[0]) - - expect(sendAmount!.substring(1)).toBe(`${trxAmountHeader}`) - if (txAmount != "MAX") { - expect(txAmount.toString()).toBe(trxAmountHeader) + const sendAmountFEText = await this.page + .locator("[data-fe-value]") + .getAttribute("data-fe-value") + const sendAmountTXText = await this.page + .locator("[data-tx-value]") + .getAttribute("data-tx-value") + const sendAmountFE = sendAmountFEText!.split(" ")[0] + const sendAmountTX = parseInt(sendAmountTXText!) + console.log({ sendAmountFE, sendAmountTX }) + expect(sendAmountFE).toBe(`${trxAmountHeader}`) + + if (feAmount != "MAX") { + expect(feAmount).toBe(trxAmountHeader) } - const amount = await amountLocator - .getAttribute("data-testid") - .then((value) => parseInt(value!) / Math.pow(10, 18)) - return amount + return { sendAmountTX, sendAmountFE } } async fillRecipientAddress({ @@ -271,23 +277,34 @@ export default class Account extends Activity { } } } + + async confirmTransaction() { + const failPredict = this.page.getByText("Transaction fail") + await expect(failPredict) + .toBeVisible({ timeout: 1000 * 5 }) + .then(async (_) => await failPredict.click()) + .catch(async (_) => await this.confirmLocator.click()) + } + async transfer({ originAccountName, recipientAddress, - tokenName, + token, amount, fillRecipientAddress = "paste", submit = true, + feeToken = "ETH", }: { originAccountName: string recipientAddress: string - tokenName: TokenName + token: TokenSymbol amount: number | "MAX" fillRecipientAddress?: "typing" | "paste" submit?: boolean + feeToken?: FeeTokens }) { await this.ensureSelectedAccount(originAccountName) - await this.token(tokenName).click() + await this.token(token).click() await this.fillRecipientAddress({ recipientAddress, fillRecipientAddress }) if (amount === "MAX") { await expect(this.balance).toBeVisible() @@ -297,12 +314,17 @@ export default class Account extends Activity { await this.amount.fill(amount.toString()) } - await this.reviewSend.click() - const trxAmount = await this.txValidations(amount.toString()) + await this.reviewSendLocator.click() if (submit) { - await this.confirm.click() + if (feeToken) { + await this.selectFeeToken(feeToken) + } + await this.confirmTransaction() } - return trxAmount + const { sendAmountFE, sendAmountTX } = await this.txValidations( + amount.toString(), + ) + return { sendAmountTX, sendAmountFE } } async ensureTokenBalance({ @@ -311,7 +333,7 @@ export default class Account extends Activity { balance, }: { accountName: string - token: TokenName + token: TokenSymbol balance: number }) { await this.ensureSelectedAccount(accountName) @@ -319,7 +341,7 @@ export default class Account extends Activity { await expect(this.page.locator('[data-testid="tokenBalance"]')).toHaveText( balance.toString(), ) - await this.back.click() + await this.backLocator.click() } get password() { @@ -356,9 +378,7 @@ export default class Account extends Activity { } get recipientAddress() { - return this.page.locator( - `//textarea[@placeholder="${lang.account.recipientAddress}"]/following::button[1]`, - ) + return this.page.locator('[data-testid="recipient-input"]') } get saveAddress() { @@ -386,22 +406,20 @@ export default class Account extends Activity { } async saveRecoveryPhrase() { - const nextModal = await this.next.isVisible({ timeout: 60 }) + const nextModal = await this.nextLocator.isVisible({ timeout: 60 }) if (nextModal) { await Promise.all([ expect( - this.page.locator( - `h3:has-text("${lang.settings.beforeYouContinue}")`, - ), + this.page.locator(`h3:has-text("${lang.common.beforeYouContinue}")`), ).toBeVisible(), expect( - this.page.locator(`p:has-text("${lang.settings.seedWarning}")`), + this.page.locator(`p:has-text("${lang.common.seedWarning}")`), ).toBeVisible(), ]) - await this.next.click() + await this.nextLocator.click() } await this.page - .locator(`span:has-text("${lang.settings.revealSeedPhrase}")`) + .locator(`span:has-text("${lang.common.revealSeedPhrase}")`) .click() const pos = Array.from({ length: 12 }, (_, i) => i + 1) const seed = await Promise.all( @@ -414,15 +432,15 @@ export default class Account extends Activity { ).then((result) => result.join(" ")) await Promise.all([ - this.page.locator(`button:has-text("${lang.settings.copy}")`).click(), + this.page.locator(`button:has-text("${lang.common.copy}")`).click(), expect( - this.page.locator(`button:has-text("${lang.settings.copied}")`), + this.page.locator(`button:has-text("${lang.common.copied}")`), ).toBeVisible(), ]) await this.page - .locator(`p:has-text("${lang.settings.confirmRecovery}")`) + .locator(`p:has-text("${lang.common.confirmRecovery}")`) .click() - await this.done.click() + await this.doneLocator.click() const seedPhraseCopied = await this.page.evaluate( `navigator.clipboard.readText();`, ) @@ -449,6 +467,18 @@ export default class Account extends Activity { return this.saveRecoveryPhrase().then((adr) => String(adr)) } + get addedArgentShieldLocator() { + return this.page.getByRole("heading", { + name: lang.common.argentShieldAdded, + }) + } + + get removedArgentShieldLocator() { + return this.page.getByRole("heading", { + name: lang.common.argentShieldRemoved, + }) + } + // Multisig get deployNeededWarning() { return this.page.locator(`p:has-text("${lang.account.deployFirst}")`) @@ -462,10 +492,6 @@ export default class Account extends Activity { return this.page.locator(`[data-testid="decrease-threshold"]`) } - get manageOwners() { - return this.page.locator(`button:text-is("Manage owners")`) - } - get setConfirmationsLocator() { return this.page.locator(`button:has-text("Set confirmations")`) } @@ -478,7 +504,7 @@ export default class Account extends Activity { confirmations?: number }) { await this.accountListSelector.click() - await this.addANewccountFromAccountList.click() + await this.addANewAccountFromAccountList.click() await this.addMultisigAccountFromNewAccountScreen.click() const [pages] = await Promise.all([ @@ -488,6 +514,7 @@ export default class Account extends Activity { const tabs = pages.context().pages() await tabs[1].waitForLoadState("load") await expect(tabs[1].locator('[name^="signerKeys.0.key"]')).toHaveCount(1) + if (signers.length > 0) { for (let index = 0; index < signers.length; index++) { await tabs[1] @@ -515,14 +542,14 @@ export default class Account extends Activity { } await tabs[1].locator('button:text-is("Next")').click() - const currentTheshold = await tabs[1] + const currentThreshold = await tabs[1] .locator('[data-testid="threshold"]') .innerText() .then((v) => parseInt(v!)) //set confirmations - if (confirmations > currentTheshold) { - for (let i = currentTheshold; i < confirmations; i++) { + if (confirmations > currentThreshold) { + for (let i = currentThreshold; i < confirmations; i++) { await tabs[1].locator('[data-testid="increase-threshold"]').click() } } @@ -535,7 +562,7 @@ export default class Account extends Activity { async joinMultisig() { await this.accountListSelector.click() - await this.addANewccountFromAccountList.click() + await this.addANewAccountFromAccountList.click() await this.addMultisigAccountFromNewAccountScreen.click() await this.joinExistingMultisig.click() @@ -544,14 +571,48 @@ export default class Account extends Activity { return String(await this.page.evaluate(`navigator.clipboard.readText()`)) } + async addOwnerToMultisig({ + accountName, + pubKey, + confirmations = 1, + }: { + accountName: string + pubKey: string + confirmations?: number + }) { + await this.showSettingsLocator.click() + await this.account(accountName).click() + await this.manageOwners.click() + await this.page.locator('[data-testid="add-owners"]').click() + //hydrogen build will always have 2 inputs + const locs = await this.page.locator('[data-testid^="closeButton."]').all() + for (let index = 0; locs.length - 1 > index; index++) { + await this.page.locator(`[data-testid^="closeButton.${index}"]`).click() + } + await this.page.locator('[name^="signerKeys.0.key"]').fill(pubKey) + + await this.nextLocator.click() + + const currentThreshold = await this.page + .locator('[data-testid="threshold"]') + .innerText() + .then((v) => parseInt(v!)) + //set confirmations + if (confirmations > currentThreshold) { + for (let i = currentThreshold; i < confirmations; i++) { + await this.page.locator('[data-testid="increase-threshold"]').click() + } + } + await this.nextLocator.click() + await this.confirmLocator.click() + } + ensureMultisigActivated() { return Promise.all([ - expect(this.page.locator("label:has-text('Not activated')")).toBeHidden({ - timeout: 60000, - }), + expect(this.page.locator("label:has-text('Not activated')")).toBeHidden(), expect( this.page.locator('[data-testid="activating-multisig"]'), - ).toBeHidden({ timeout: 60000 }), + ).toBeHidden(), ]) } @@ -566,35 +627,121 @@ export default class Account extends Activity { } async acceptTx(tx: string) { - await this.menuActivity.click() + await this.menuActivityLocator.click() await this.page.locator(`[data-tx-hash="${tx}"]`).click() - await this.confirm.click() + await this.confirmTransaction() } async setConfirmations(accountName: string, confirmations: number) { await this.ensureSelectedAccount(accountName) - await this.showSettings.click() + await this.showSettingsLocator.click() await this.account(accountName).click() await this.setConfirmationsLocator.click() - const currentTheshold = await this.page + const currentThreshold = await this.page .locator('[data-testid="threshold"]') .innerText() .then((v) => parseInt(v!)) - if (confirmations > currentTheshold) { - for (let i = currentTheshold; i < confirmations; i++) { + if (confirmations > currentThreshold) { + for (let i = currentThreshold; i < confirmations; i++) { await this.increaseThreshold.click() } - } else if (confirmations < currentTheshold) { - for (let i = currentTheshold; i > confirmations; i--) { + } else if (confirmations < currentThreshold) { + for (let i = currentThreshold; i > confirmations; i--) { await this.decreaseThreshold.click() } } await this.page.locator('[data-testid="update-confirmations"]').click() - await this.confirm.click() + await this.confirmTransaction() await Promise.all([ - expect(this.confirm).toBeHidden(), - expect(this.menuActivity).toBeVisible(), + expect(this.confirmLocator).toBeHidden(), + expect(this.menuActivityLocator).toBeVisible(), ]) } + + async ensure2FANotEnabled(accountName: string) { + await this.selectAccount(accountName) + await Promise.all([ + expect(this.menuPendingTransactionsIndicatorLocator).toBeHidden(), + expect( + this.page.locator('[data-testid="shield-on-account-view"]'), + ).toBeHidden(), + ]) + await this.showSettingsLocator.click() + await Promise.all([ + expect( + this.page.locator('[data-testid="shield-on-settings"]'), + ).toBeHidden(), + expect( + this.page.locator('[data-testid="shield-not-activated"]'), + ).toBeVisible(), + ]) + await this.account(accountName).click() + await expect( + this.page.locator('[data-testid="shield-switch"]'), + ).not.toBeChecked() + } + + editOwnerLocator(owner: string) { + return this.page.locator(`[data-testid="edit-${owner}"]`) + } + get manageOwners() { + return this.page.locator( + `//button//*[text()="${lang.settings.account.manageOwners.manageOwners}"]`, + ) + } + + get removeOwnerLocator() { + return this.page.locator( + `//button[text()="${lang.settings.account.manageOwners.removeOwner}"]`, + ) + } + + get removedFromMultisigLocator() { + return this.page.getByText(lang.account.removedFromMultisig) + } + + async removeMultiSigOwner(accountName: string, owner: string) { + await this.showSettingsLocator.click() + await this.account(accountName).click() + await this.manageOwners.click() + await this.editOwnerLocator(owner).click() + await this.removeOwnerLocator.click() + await this.removeLocator.click() + await this.nextLocator.click() + await this.confirmTransaction() + } + + //TX v3 + get feeTokenPickerLoc() { + return this.page.locator('[data-testid="fee-token-picker"]') + } + + feeTokenLoc(token: FeeTokens) { + return this.page.locator(`[data-testid="fee-token-${token}"]`) + } + + feeTokenBalanceLoc(token: FeeTokens) { + return this.page.locator(`[data-testid="fee-token-${token}-balance"]`) + } + + selectedFeeTokenLoc(token: FeeTokens) { + return this.feeTokenPickerLoc.locator(`img[alt=${token}]`) + } + + async selectFeeToken(token: FeeTokens) { + //wait for locator to be visible + await Promise.race([ + expect(this.selectedFeeTokenLoc("ETH")).toBeVisible(), + expect(this.selectedFeeTokenLoc("STRK")).toBeVisible(), + ]) + const tokenAlreadySelected = await this.selectedFeeTokenLoc( + token, + ).isVisible() + if (!tokenAlreadySelected) { + await this.feeTokenPickerLoc.click() + await this.feeTokenLoc(token).click() + await expect(this.selectedFeeTokenLoc(token)).toBeVisible() + } + } } diff --git a/packages/e2e/extension/src/page-objects/Activity.ts b/packages/e2e/extension/src/page-objects/Activity.ts index 2ebeaaac2..bdb833b70 100644 --- a/packages/e2e/extension/src/page-objects/Activity.ts +++ b/packages/e2e/extension/src/page-objects/Activity.ts @@ -21,7 +21,7 @@ export default class Activity extends Navigation { this.page.locator( `h6 div:text-is("${lang.account.pendingTransactions}") >> div`, ), - ).not.toBeVisible({ timeout: 60000 }) + ).not.toBeVisible() } activityByDestination(destination: string) { @@ -32,7 +32,7 @@ export default class Activity extends Navigation { checkActivity(nbr: number) { return Promise.all([ - this.menuPendingTransactionsIndicator.click(), + this.menuPendingTransactionsIndicatorLocator.click(), this.ensurePendingTransactions(nbr), ]) } @@ -46,9 +46,9 @@ export default class Activity extends Navigation { } async getLastTxHash() { - await this.menuActivityActive.isVisible().then(async (visible) => { + await this.menuActivityActiveLocator.isVisible().then(async (visible) => { if (!visible) { - await this.menuActivity.click() + await this.menuActivityLocator.click() } }) const txHashs = await this.activityTxHashs() diff --git a/packages/e2e/extension/src/page-objects/AddressBook.ts b/packages/e2e/extension/src/page-objects/AddressBook.ts index 999968ad8..e702d2ba8 100644 --- a/packages/e2e/extension/src/page-objects/AddressBook.ts +++ b/packages/e2e/extension/src/page-objects/AddressBook.ts @@ -24,27 +24,27 @@ export default class AddressBook extends Navigation { return this.page.locator('[aria-label="network-selector"]') } - get save() { + get saveLocator() { return this.page.locator(`button:text-is("${lang.common.save}")`) } - get cancel() { + get cancelLocator() { return this.page.locator(`button:text-is("${lang.common.cancel}")`) } - networkOption(name: "Localhost 5050" | "Testnet" | "Mainnet") { + networkOption(name: "Localhost 5050" | "Goerli" | "Mainnet") { return this.page.locator(`button[role="menuitem"]:text-is("${name}")`) } get nameRequired() { return this.page.locator( - `//input[@name="name"]/following::label[contains(text(), '${lang.address.nameRequired}')]`, + `//input[@name="name"]/following::label[contains(text(), '${lang.settings.addressBook.nameRequired}')]`, ) } get addressRequired() { return this.page.locator( - `//textarea[@name="address"]/following::label[contains(text(), '${lang.address.addressRequired}')]`, + `//textarea[@name="address"]/following::label[contains(text(), '${lang.settings.addressBook.addressRequired}')]`, ) } @@ -56,15 +56,19 @@ export default class AddressBook extends Navigation { get deleteAddress() { return this.page.locator( - `button[aria-label="${lang.address.removeAddress}"]`, + `button[aria-label="${lang.settings.addressBook.removeAddress}"]`, ) } get delete() { - return this.page.locator(`button:text-is("${lang.address.delete}")`) + return this.page.locator( + `button:text-is("${lang.settings.addressBook.delete}")`, + ) } get addressBook() { - return this.page.locator(`button:text-is("${lang.address.addressBook}")`) + return this.page.locator( + `button:text-is("${lang.settings.addressBook.addressBook}")`, + ) } } diff --git a/packages/e2e/extension/src/page-objects/Dapps.ts b/packages/e2e/extension/src/page-objects/Dapps.ts index e7a5f612a..4aba31f2a 100644 --- a/packages/e2e/extension/src/page-objects/Dapps.ts +++ b/packages/e2e/extension/src/page-objects/Dapps.ts @@ -2,7 +2,12 @@ import { ChromiumBrowserContext, Page, expect } from "@playwright/test" import { lang } from "../languages" import Navigation from "./Navigation" +import config from "../../../shared/config" +type DappUrl = + | "https://goerli.app.starknet.id" + | "https://dapp-argentlabs.vercel.app" + | "https://starknetkit-blacked-listed.vercel.app" export default class Dapps extends Navigation { constructor(page: Page) { super(page) @@ -23,50 +28,129 @@ export default class Dapps extends Navigation { } get noConnectedDapps() { - return this.page.locator(`text=${lang.dapps.noConnectedDapps}`) + return this.page.locator( + `text=${lang.settings.account.connectedDapps.noConnectedDapps}`, + ) } - connected(url: string) { + connected(url: DappUrl) { return this.page.locator(`//div/*[contains(text(),'${url.slice(8, 30)}')]`) } - disconnect(url: string) { + disconnect(url: DappUrl) { return this.page.locator( `//div/*[contains(text(),'${url.slice(8, 30)}')]/following::button[1]`, ) } disconnectAll() { - return this.page.locator(`p:text-is("${lang.dapps.disconnectAll}")`) + return this.page.locator( + `p:text-is("${lang.settings.account.connectedDapps.disconnectAll}")`, + ) } get accept() { - return this.page.locator(`button:text-is("${lang.dapps.connect}")`) + return this.page.locator( + `button:text-is("${lang.settings.account.connectedDapps.connect}")`, + ) } get reject() { - return this.page.locator(`button:text-is("${lang.dapps.reject}")`) + return this.page.locator( + `button:text-is("${lang.settings.account.connectedDapps.reject}")`, + ) + } + + get knownDappButton() { + return this.page.locator('[data-testid="KnownDappButton"]') } + async ensureKnowDappText() { + return Promise.all([ + expect(this.page.locator('h5:text-is("Known Dapp")')).toBeVisible(), + expect( + this.page.locator('p:text-is("This dapp is listed on Dappland")'), + ).toBeVisible(), + ]) + } async requestConnectionFromDapp( browserContext: ChromiumBrowserContext, - url: string, + dappUrl: DappUrl, ) { //open dapp page const dapp = await browserContext.newPage() await dapp.setViewportSize({ width: 1080, height: 720 }) await dapp.goto("chrome://inspect/#extensions") await dapp.waitForTimeout(5000) - await dapp.goto(url) - const warningLoc = dapp.locator("text=enter anyway") - if (await warningLoc.isVisible()) { - await warningLoc.click() + await dapp.goto(dappUrl) + + if ( + dappUrl === "https://dapp-argentlabs.vercel.app" || + dappUrl === "https://starknetkit-blacked-listed.vercel.app" + ) { + await expect(dapp.locator('button:has-text("Connect")')).toHaveCount(1) + await dapp.locator('button:has-text("Connect")').first().click() + await expect(dapp.locator("text=Argent X")).toBeVisible() + await dapp.locator("text=Argent X").click() + } else { + await expect(dapp.getByRole("button", { name: "Argent X" })).toBeVisible() + await dapp.getByRole("button", { name: "Argent X" }).click() } - await dapp - .locator('div :text-matches("Connect Wallet", "i")') - .first() - .click() + return dapp + } + + async claimSpok(browserContext: ChromiumBrowserContext) { + const spokCampaignUrl = config.spokCampaignUrl! + //open dapp page + const dapp = await browserContext.newPage() + await dapp.setViewportSize({ width: 1080, height: 720 }) + await dapp.goto("chrome://inspect/#extensions") + await dapp.waitForTimeout(5000) + await dapp.goto(spokCampaignUrl) + await dapp.getByRole("button", { name: "Check eligibility" }).click() await expect(dapp.locator("text=Argent X")).toBeVisible() await dapp.locator("text=Argent X").click() + return dapp + } + + checkCriticalRiskConnectionScreen() { + return Promise.all([ + expect( + this.page.locator( + `//span[text()="Critical risk"]/following-sibling::label[text()="Use of a blacklisted domain"]`, + ), + ).toBeVisible(), + expect( + this.page.locator( + `//p[@data-testid="review-footer" and text()="Please review warnings before continuing"]`, + ), + ).toBeVisible(), + expect(this.page.getByRole("button", { name: "Connect" })).toBeDisabled(), + ]) + } + + async acceptCriticalRiskConnection() { + await this.page.getByRole("button", { name: "Review" }).click() + await Promise.all([ + expect( + this.page.locator( + `//header[@title="1 risk identified"]//label[text()="We strongly recommend you do not proceed with this transaction"]`, + ), + ).toBeVisible(), + expect( + this.page.locator( + '//span[text()="Critical risk"]/following-sibling::span[text()="Use of a blacklisted domain"]/following-sibling::p[text()="You are currently on an unsafe domain. Be aware of the risks."]', + ), + ).toBeVisible(), + ]) + await this.page.getByRole("button", { name: "Accept risk" }).click() + } + + async connectedDappsTooltip(dappUrl: string) { + await this.showSettingsLocator.click() + await this.page.hover('[data-testid="connected-dapp"]') + await expect( + this.page.locator('[data-testid="connected-dapp"]'), + ).toHaveText(`Connected to ${dappUrl}`) } } diff --git a/packages/e2e/extension/src/page-objects/DeveloperSettings.ts b/packages/e2e/extension/src/page-objects/DeveloperSettings.ts index 5933a1928..6c9c5ba0d 100644 --- a/packages/e2e/extension/src/page-objects/DeveloperSettings.ts +++ b/packages/e2e/extension/src/page-objects/DeveloperSettings.ts @@ -7,25 +7,25 @@ export default class DeveloperSettings { get manageNetworks() { return this.page.locator( - `//a//*[text()="${lang.developerSettings.manageNetworks}"]`, + `//a//*[text()="${lang.settings.developerSettings.manageNetworks.manageNetworks}"]`, ) } get blockExplorer() { return this.page.locator( - `//a//*[text()="${lang.developerSettings.blockExplorer}"]`, + `//a//*[text()="${lang.settings.preferences.defaultBlockExplorer}"]`, ) } get smartCOntractDevelopment() { return this.page.locator( - `//a//*[text()="${lang.developerSettings.smartContractDevelopment}"]`, + `//a//*[text()="${lang.settings.developerSettings.smartContractDevelopment}"]`, ) } get experimental() { return this.page.locator( - `//a//*[text()="${lang.developerSettings.experimental}"]`, + `//a//*[text()="${lang.settings.developerSettings.experimental}"]`, ) } @@ -56,7 +56,7 @@ export default class DeveloperSettings { get restoreDefaultNetworks() { return this.page.locator( - `button:has-text("${lang.developerSettings.restoreDefaultNetworks}")`, + `button:has-text("${lang.settings.developerSettings.manageNetworks.restoreDefaultNetworks}")`, ) } diff --git a/packages/e2e/extension/src/page-objects/ExtensionPage.ts b/packages/e2e/extension/src/page-objects/ExtensionPage.ts index f104b3568..e0bc7feeb 100644 --- a/packages/e2e/extension/src/page-objects/ExtensionPage.ts +++ b/packages/e2e/extension/src/page-objects/ExtensionPage.ts @@ -1,6 +1,6 @@ import { expect, type Page } from "@playwright/test" -import Messages from "../utils/Messages" +import Messages from "./Messages" import Account from "./Account" import Activity from "./Activity" import AddressBook from "./AddressBook" @@ -10,8 +10,17 @@ import Navigation from "./Navigation" import Network from "./Network" import Settings from "./Settings" import Wallet from "./Wallet" -import config from "../config" -import { transferEth, AccountsToSetup, validateTx } from "../utils/account" +import config from "../../../shared/config" +import Nfts from "./Nfts" +import Preferences from "./Preferences" +import { + transferTokens, + AccountsToSetup, + validateTx, + isScientific, + convertScientificToDecimal, + FeeTokens, +} from "../../../shared/src/assets" export default class ExtensionPage { page: Page @@ -25,6 +34,8 @@ export default class ExtensionPage { developerSettings: DeveloperSettings addressBook: AddressBook dapps: Dapps + nfts: Nfts + preferences: Preferences constructor(page: Page, private extensionUrl: string) { this.page = page this.wallet = new Wallet(page) @@ -38,6 +49,8 @@ export default class ExtensionPage { this.developerSettings = new DeveloperSettings(page) this.addressBook = new AddressBook(page) this.dapps = new Dapps(page) + this.nfts = new Nfts(page) + this.preferences = new Preferences(page) } async open() { @@ -45,10 +58,10 @@ export default class ExtensionPage { } async resetExtension() { - await this.navigation.showSettings.click() - await this.navigation.lockWallet.click() - await this.navigation.reset.click() - await this.navigation.confirmReset.click() + await this.navigation.showSettingsLocator.click() + await this.navigation.lockWalletLocator.click() + await this.navigation.resetLocator.click() + await this.navigation.confirmResetLocator.click() } async paste() { @@ -69,15 +82,13 @@ export default class ExtensionPage { await this.wallet.restoreExistingWallet.click() await this.setClipBoardContent(seed) await this.pasteSeed() - await this.navigation.continue.click() + await this.navigation.continueLocator.click() await this.wallet.password.fill(password ?? config.password) await this.wallet.repeatPassword.fill(password ?? config.password) - await this.navigation.continue.click() - await expect(this.wallet.finish.first()).toBeVisible({ - timeout: 180000, - }) + await this.navigation.continueLocator.click() + await expect(this.wallet.finish.first()).toBeVisible() await this.open() await expect(this.network.networkSelector).toBeVisible() @@ -95,51 +106,67 @@ export default class ExtensionPage { return accountAddress } - async deployAccount(accountName: string) { + async deployAccount(accountName: string, feeToken?: FeeTokens) { if (accountName) { await this.account.ensureSelectedAccount(accountName) } - await this.navigation.showSettings.click() - await this.page.locator(`text=${accountName}`).click() + await this.navigation.showSettingsLocator.click() + await this.page.locator(`[data-testid="${accountName}"]`).click() await this.settings.deployAccount.click() - await this.navigation.confirm.click() - await this.navigation.back.click() - await this.navigation.close.click() - await this.navigation.menuActivity.click() + if (feeToken) { + await this.account.selectFeeToken(feeToken) + } + await this.account.confirmTransaction() + await this.navigation.backLocator.click() + await this.navigation.closeLocator.click() + await this.navigation.menuActivityLocator.click() await expect( this.page.getByText( /(Account created and transfer|Contract interaction)/, ), - ).toBeVisible({ timeout: 120000 }) - await this.navigation.showSettings.click() - await expect(this.page.getByText("Deploying")).toBeHidden({ - timeout: 90000, - }) - await this.navigation.close.click() - await this.navigation.menuTokens.click() + ).toBeVisible() + await this.navigation.showSettingsLocator.click() + await expect(this.page.getByText("Deploying")).toBeHidden() + await this.navigation.closeLocator.click() + await this.navigation.menuTokensLocator.click() } - async activate2fa(accountName: string, email: string, pin = "111111") { + async activate2fa({ + accountName, + email, + pin = "111111", + validSession = false, + }: { + accountName: string + email: string + pin?: string + validSession?: boolean + }) { await this.account.ensureSelectedAccount(accountName) - await this.navigation.showSettings.click() + await this.navigation.showSettingsLocator.click() await this.settings.account(accountName).click() await this.settings.argentShield().click() - await this.navigation.next.click() - await this.account.email.fill(email) - await this.navigation.next.first().click() - await this.account.fillPin(pin) - await this.navigation.addArgentShield.click() - await this.navigation.confirm.click() - await this.navigation.dismiss.click() - await this.navigation.back.click() - await this.navigation.close.click() + await this.navigation.nextLocator.click() + if (!validSession) { + await this.account.email.fill(email) + await this.navigation.nextLocator.first().click() + await this.account.fillPin(pin) + } + await this.navigation.addArgentShieldLocator.click() + await this.account.confirmTransaction() + await expect(this.account.addedArgentShieldLocator).toBeVisible() + await this.navigation.doneLocator.click() + await this.navigation.backLocator.click() + await this.navigation.closeLocator.click() await Promise.all([ - expect(this.activity.menuPendingTransactionsIndicator).toBeHidden(), + expect( + this.activity.menuPendingTransactionsIndicatorLocator, + ).toBeHidden(), expect( this.page.locator('[data-testid="shield-on-account-view"]'), ).toBeVisible(), ]) - await this.navigation.showSettings.click() + await this.navigation.showSettingsLocator.click() await expect( this.page.locator('[data-testid="shield-on-settings"]'), ).toBeVisible() @@ -147,8 +174,71 @@ export default class ExtensionPage { await expect( this.page.locator('[data-testid="shield-switch"]'), ).toBeEnabled() - await this.navigation.back.click() - await this.navigation.close.click() + await this.navigation.backLocator.click() + await this.navigation.closeLocator.click() + } + + async disable2fa({ + accountName, + email, + pin = "111111", + validSession = false, + }: { + accountName: string + email: string + pin?: string + validSession?: boolean + }) { + await this.account.ensureSelectedAccount(accountName) + await this.navigation.showSettingsLocator.click() + await this.settings.account(accountName).click() + await this.settings.argentShield().click() + await this.navigation.nextLocator.click() + if (!validSession) { + await this.account.email.fill(email) + await this.navigation.nextLocator.first().click() + await this.account.fillPin(pin) + } + await this.navigation.removeArgentShieldLocator.click() + await this.account.confirmTransaction() + await expect(this.account.removedArgentShieldLocator).toBeVisible() + await this.navigation.doneLocator.click() + await this.navigation.backLocator.click() + await this.navigation.closeLocator.click() + await this.account.ensure2FANotEnabled(accountName) + } + + async fundAccount( + acc: AccountsToSetup, + accountAddress: string, + accIndex: number, + ) { + let expectedTokenValue + for (const [assetIndex, asset] of acc.assets.entries()) { + console.log({ op: "fundAccount", assetIndex, asset }) + if (asset.balance > 0) { + await transferTokens( + asset.balance, + accountAddress, // receiver wallet address + asset.token, + ) + expectedTokenValue = `${asset.balance} ${asset.token}` + if (isScientific(asset.balance)) { + expectedTokenValue = `${convertScientificToDecimal(asset.balance)} ${ + asset.token + }` + } + await this.account.ensureAsset( + `Account ${accIndex + 1}`, + asset.token, + expectedTokenValue, + ) + } + } + + if (acc.deploy) { + await this.deployAccount(`Account ${accIndex + 1}`, acc.feeToken) + } } async setupWallet({ @@ -162,7 +252,6 @@ export default class ExtensionPage { const accountAddresses: string[] = [] for (const [accIndex, acc] of accountsToSetup.entries()) { - console.log(accIndex, acc) if (accIndex !== 0) { await this.account.addAccount({ firstAccount: false }) } @@ -172,40 +261,48 @@ export default class ExtensionPage { ) expect(accountAddress).toMatch(/^0x0/) accountAddresses.push(accountAddress) - - if (acc.initialBalance > 0) { - await transferEth( - `${acc.initialBalance * Math.pow(10, 18)}`, // amount Ethereum has 18 decimals - accountAddress, // reciever wallet address - ) - await this.account.ensureAsset( - `Account ${accIndex + 1}`, - "Ethereum", - `${acc.initialBalance} ETH`, - ) - if (acc.deploy) { - await this.deployAccount(`Account ${accIndex + 1}`) - } + if (acc.assets[0].balance > 0) { + await this.fundAccount(acc, accountAddress, accIndex) } } - console.log(accountAddresses.length, accountAddresses, seed) + console.log({ + op: "setupWallet", + accountsNbr: accountAddresses.length, + accountAddresses, + seed, + }) return { accountAddresses, seed } } - async validateTx( - txHash: string, - reciever: string, - amount?: number, - uniqLocator?: boolean, - ) { - await this.navigation.menuActivityActive + async validateTx({ + txHash, + receiver, + sendAmountFE, + sendAmountTX, + uniqLocator, + }: { + txHash: string + receiver: string + sendAmountFE?: string + sendAmountTX?: number + uniqLocator?: boolean + }) { + console.log({ + op: "validateTx", + txHash, + receiver, + sendAmountFE, + sendAmountTX, + uniqLocator, + }) + await this.navigation.menuActivityActiveLocator .isVisible() .then(async (visible) => { if (!visible) { - await this.navigation.menuActivity.click() + await this.navigation.menuActivityLocator.click() } }) - if (amount) { + if (sendAmountFE) { const activityAmountLocator = this.page.locator( `button[data-tx-hash$="${txHash.substring(3)}"] [data-value]`, ) @@ -216,19 +313,21 @@ export default class ExtensionPage { const activityAmount = await activityAmountElement .textContent() .then((text) => text?.match(/[\d|.]+/)![0]) - if (amount.toString().length > 6) { + if (sendAmountFE.toString().length > 6) { expect(activityAmount).toBe( - parseFloat(amount.toString()) + parseFloat(sendAmountFE.toString()) .toFixed(4) .toString() .match(/[\d\\.]+[^0]+/)?.[0], ) } else { - expect(activityAmount).toBe(parseFloat(amount.toString()).toString()) + expect(activityAmount).toBe( + parseFloat(sendAmountFE.toString()).toString(), + ) } } await this.activity.ensureNoPendingTransactions() - await validateTx(txHash, reciever, amount) + await validateTx(txHash, receiver, sendAmountTX) } async fundMultisigAccount({ @@ -241,11 +340,11 @@ export default class ExtensionPage { await this.account.ensureSelectedAccount(accountName) await this.account.copyAddress.click() const accountAddress = await this.getClipboard().then((adr) => String(adr)) - await transferEth( - `${amount * Math.pow(10, 18)}`, // amount Ethereum has 18 decimals - accountAddress, // reciever wallet address + await transferTokens( + amount, + accountAddress, // receiver wallet address ) - await this.account.ensureAsset(accountName, "Ethereum", `${amount} ETH`) + await this.account.ensureAsset(accountName, "ETH", `${amount} ETH`) } async activateMultisig(accountName: string) { @@ -254,17 +353,21 @@ export default class ExtensionPage { this.page.locator("label:has-text('Not activated')"), ).toBeVisible() await this.page.locator('[data-testid="activate-multisig"]').click() - await this.navigation.confirm.click() + await this.account.confirmTransaction() await expect( this.page.locator('[data-testid="activating-multisig"]'), ).toBeVisible() await Promise.all([ - expect(this.page.locator("label:has-text('Not activated')")).toBeHidden({ - timeout: 60000, - }), + expect(this.page.locator("label:has-text('Not activated')")).toBeHidden(), expect( this.page.locator('[data-testid="activating-multisig"]'), - ).toBeHidden({ timeout: 60000 }), + ).toBeHidden(), ]) } + + async removeMultisigOwner(accountName: string) { + await this.account.ensureSelectedAccount(accountName) + await this.navigation.showSettingsLocator.click() + await this.settings.account(accountName).click() + } } diff --git a/packages/e2e/extension/src/utils/Messages.ts b/packages/e2e/extension/src/page-objects/Messages.ts similarity index 100% rename from packages/e2e/extension/src/utils/Messages.ts rename to packages/e2e/extension/src/page-objects/Messages.ts diff --git a/packages/e2e/extension/src/page-objects/Navigation.ts b/packages/e2e/extension/src/page-objects/Navigation.ts index 650fa7dd6..8a959d08a 100644 --- a/packages/e2e/extension/src/page-objects/Navigation.ts +++ b/packages/e2e/extension/src/page-objects/Navigation.ts @@ -8,109 +8,123 @@ export default class Navigation { this.page = page } - get back() { + get backLocator() { return this.page.locator(`[aria-label="${lang.common.back}"]`).first() } - get close() { + get closeLocator() { return this.page.locator(`[aria-label="${lang.common.close}"]`) } - - get confirm() { + get closeButtonLocator() { + return this.page.locator('[data-testid="close-button"]') + } + get confirmLocator() { return this.page.locator(`button:text-is("${lang.common.confirm}")`) } - get next() { + get nextLocator() { return this.page.locator(`button:text-is("${lang.common.next}")`) } - get reviewSend() { + get reviewSendLocator() { return this.page.locator(`button:text-is("${lang.common.reviewSend}")`) } - get done() { + get doneLocator() { return this.page.locator(`button:text-is("${lang.common.done}")`) } - get continue() { + get continueLocator() { return this.page .locator(`button:text-is("${lang.common.continue}")`) .first() } - get yes() { + get yesLocator() { return this.page.locator(`button:text-is("${lang.common.yes}")`) } - get no() { + get noLocator() { return this.page.locator(`button:text-is("${lang.common.no}")`) } - get unlock() { + get unlockLocator() { return this.page.locator(`button:text-is("${lang.common.unlock}")`) } - get showSettings() { + get showSettingsLocator() { return this.page.locator('[aria-label="Show settings"]') } - get lockWallet() { - return this.page.locator(`//button//*[text()="${lang.common.lockWallet}"]`) + get lockWalletLocator() { + return this.page.locator( + `//button//*[text()="${lang.settings.lockWallet}"]`, + ) } - get reset() { + get resetLocator() { return this.page.locator(`a:text-is("${lang.common.reset}")`) } - get confirmReset() { + get confirmResetLocator() { return this.page.locator(`button:text-is("${lang.common.confirmReset}")`) } - get menuPendingTransactionsIndicator() { + get menuPendingTransactionsIndicatorLocator() { return this.page.locator('[aria-label="Pending transactions"]') } - get menuTokens() { + get menuTokensLocator() { return this.page.locator('[aria-label="Tokens"]') } - get menuNTFs() { + get menuNTFsLocator() { return this.page.locator('[aria-label="NFTs"]') } - get menuSwaps() { + get menuSwapsLocator() { return this.page.locator('[aria-label="Swap"]') } - get menuActivity() { + get menuActivityLocator() { return this.page.locator('[aria-label="Activity"]') } - get menuActivityActive() { + get menuActivityActiveLocator() { return this.page.locator('[aria-label="Activity"][class*="active"]') } - get save() { + get saveLocator() { return this.page.locator(`button:text-is("${lang.common.save}")`) } - get create() { + get createLocator() { return this.page.locator(`button:text-is("${lang.common.create}")`) } - get cancel() { + get cancelLocator() { return this.page.locator(`button:text-is("${lang.common.cancel}")`) } - get approve() { + get approveLocator() { return this.page.locator(`button:text-is("${lang.common.approve}")`) } - get addArgentShield() { + get addArgentShieldLocator() { return this.page.locator(`button:text-is("${lang.common.addArgentShield}")`) } - get dismiss() { + get removeArgentShieldLocator() { + return this.page.locator( + `button:text-is("${lang.common.removeArgentShield}")`, + ) + } + + get dismissLocator() { return this.page.locator(`button:text-is("${lang.common.dismiss}")`) } + + get removeLocator() { + return this.page.locator(`button:text-is("${lang.common.remove}")`) + } } diff --git a/packages/e2e/extension/src/page-objects/Network.ts b/packages/e2e/extension/src/page-objects/Network.ts index fb52427c6..a9f22d9e0 100644 --- a/packages/e2e/extension/src/page-objects/Network.ts +++ b/packages/e2e/extension/src/page-objects/Network.ts @@ -1,5 +1,7 @@ import { Page, expect } from "@playwright/test" -type NetworkName = "Localhost 5050" | "Testnet" | "Mainnet" | "My Network" + +type NetworkName = "Devnet" | "Goerli" | "Mainnet" | "My Network" + export function getDefaultNetwork() { const argentXEnv = process.env.ARGENT_X_ENVIRONMENT @@ -39,6 +41,12 @@ export default class Network { await this.networkOption(networkName).click() } + async selectDefaultNetwork() { + const networkName = this.getDefaultNetworkName() + await this.networkSelector.click() + await this.networkOption(networkName).click() + } + async ensureAvailableNetworks(networks: string[]) { await this.networkSelector.click() const availableNetworks = await this.page @@ -52,8 +60,10 @@ export default class Network { switch (defaultNetworkId.toLowerCase()) { case "mainnet-alpha": return "Mainnet" + case "sepolia-alpha": + return "Sepolia" case "goerli-alpha": - return "Testnet" + return "Goerli" default: throw new Error(`Unknown ARGENTX_Network: ${defaultNetworkId}`) } diff --git a/packages/e2e/extension/src/page-objects/Nfts.ts b/packages/e2e/extension/src/page-objects/Nfts.ts new file mode 100644 index 000000000..4db6bbff4 --- /dev/null +++ b/packages/e2e/extension/src/page-objects/Nfts.ts @@ -0,0 +1,21 @@ +import { Page } from "@playwright/test" + +import Navigation from "./Navigation" + +export default class Nfts extends Navigation { + constructor(page: Page) { + super(page) + } + + collection(name: string) { + return this.page.locator(`h6:text-is("${name}")`) + } + + ntf(name: string) { + return this.page.getByRole("group", { name }).getByRole("img") + } + + nftByPosition(position: number = 0) { + return this.page.locator('[data-testid="nft-item-name"]').nth(position) + } +} diff --git a/packages/e2e/extension/src/page-objects/Preferences.ts b/packages/e2e/extension/src/page-objects/Preferences.ts new file mode 100644 index 000000000..7c15b11da --- /dev/null +++ b/packages/e2e/extension/src/page-objects/Preferences.ts @@ -0,0 +1,40 @@ +import { Page } from "@playwright/test" + +import { lang } from "../languages" +import Navigation from "./Navigation" + +export default class Preferences extends Navigation { + constructor(page: Page) { + super(page) + } + + get hideTokens() { + return this.page.locator( + `//p[contains(text(),'${lang.settings.preferences.hideTokens}')]`, + ) + } + + get hideTokensStatus() { + return this.page.locator( + `//p[contains(text(),'${lang.settings.preferences.hideTokens}')]/following::input`, + ) + } + + get defaultBlockExplorer() { + return this.page.locator( + `//p[contains(text(),'${lang.settings.preferences.defaultBlockExplorer}')]`, + ) + } + + get defaultNFTMarket() { + return this.page.locator( + `//p[contains(text(),'${lang.settings.preferences.defaultNFTMarket}')]`, + ) + } + + get emailNotifications() { + return this.page.locator( + `//p[contains(text(),'${lang.settings.preferences.emailNotifications}')]`, + ) + } +} diff --git a/packages/e2e/extension/src/page-objects/Settings.ts b/packages/e2e/extension/src/page-objects/Settings.ts index fdf42062d..8381825e0 100644 --- a/packages/e2e/extension/src/page-objects/Settings.ts +++ b/packages/e2e/extension/src/page-objects/Settings.ts @@ -10,22 +10,26 @@ export default class Settings { } get addressBook() { - return this.page.locator(`//a//*[text()="${lang.settings.addresBook}"]`) + return this.page.locator( + `//a//*[text()="${lang.settings.addressBook.addressBook}"]`, + ) } get connectedDapps() { - return this.page.locator(`//a//*[text()="${lang.settings.connectedDapps}"]`) + return this.page.locator( + `//a//*[text()="${lang.settings.account.connectedDapps.connectedDapps}"]`, + ) } - get showRecoveryPhase() { + get developerSettings() { return this.page.locator( - `//a//*[text()="${lang.settings.showRecoveryPhase}"]`, + `//a//*[text()="${lang.settings.developerSettings.developerSettings}"]`, ) } - get developerSettings() { + get preferences() { return this.page.locator( - `//a//*[text()="${lang.settings.developerSettings}"]`, + `//a//*[text()="${lang.settings.preferences.preferences}"]`, ) } @@ -36,19 +40,19 @@ export default class Settings { get exportPrivateKey() { return this.page.locator( - `//button//*[text()="${lang.settings.exportPrivateKey}"]`, + `//button//*[text()="${lang.settings.account.exportPrivateKey}"]`, ) } get deployAccount() { return this.page.locator( - `//button//*[text()="${lang.settings.deployAccount}"]`, + `//button//*[text()="${lang.settings.account.deployAccount}"]`, ) } get hideAccount() { return this.page.locator( - `//button//*[text()="${lang.settings.hideAccount}"]`, + `//button//*[text()="${lang.settings.account.hideAccount}"]`, ) } @@ -63,12 +67,10 @@ export default class Settings { } get confirmHide() { - return this.page.locator(`button:text-is("${lang.settings.hide}")`) + return this.page.locator(`button:text-is("${lang.common.hide}")`) } get hiddenAccounts() { - return this.page.locator( - `button:text-is("${lang.settings.hiddenAccounts}")`, - ) + return this.page.locator(`button:text-is("${lang.common.hiddenAccounts}")`) } unhideAccount(accountName: string) { @@ -80,11 +82,11 @@ export default class Settings { } get privateKey() { - return this.page.locator('[data-testid="privateKey"]') + return this.page.locator('[aria-label="Private key"]') } get copy() { - return this.page.locator(`button:text-is("${lang.settings.copy}")`) + return this.page.locator(`button:text-is("${lang.common.copy}")`) } get help() { @@ -99,11 +101,9 @@ export default class Settings { return this.page.getByRole("link", { name: "Github" }) } - get privacyStatement() { - return this.page.getByRole("link", { name: "Privacy statement" }) - } - - get privacyStatementText() { - return this.page.locator('[aria-label="privacyStatementText"]') + get viewOnStarkScanLocator() { + return this.page.getByRole("button", { + name: lang.settings.account.viewOnStarkScan, + }) } } diff --git a/packages/e2e/extension/src/page-objects/Wallet.ts b/packages/e2e/extension/src/page-objects/Wallet.ts index 22cc24c54..bc523eb28 100644 --- a/packages/e2e/extension/src/page-objects/Wallet.ts +++ b/packages/e2e/extension/src/page-objects/Wallet.ts @@ -1,6 +1,6 @@ import { Page, expect } from "@playwright/test" -import config from "../config" +import config from "../../../shared/config" import { lang } from "../languages" import Navigation from "./Navigation" @@ -40,12 +40,8 @@ export default class Wallet extends Navigation { ) } - get privacyStatement() { - return this.page.getByRole("link", { name: "Privacy statement" }) - } - - get privacyStatementText() { - return this.page.locator('[aria-label="privacyStatementText"]') + get privacyPolicyLink() { + return this.page.getByRole("link", { name: "Privacy Policy" }) } //third screen @@ -94,21 +90,6 @@ export default class Wallet extends Navigation { ]) await this.createNewWallet.click() - await Promise.all([ - expect(this.banner2).toBeVisible(), - expect(this.description2).toBeVisible(), - ]) - await expect(this.privacyStatement).toBeVisible() - await this.privacyStatement.click() - await expect(this.privacyStatementText).toHaveText( - lang.common.privacyStatement, - ) - - await this.page.locator('button:text-is("Back")').click() - await this.disclaimerLostOfFunds.click() - await this.disclaimerAlphaVersion.click() - await this.continue.click() - await Promise.all([ expect(this.banner3).toBeVisible(), expect(this.description3).toBeVisible(), diff --git a/packages/e2e/extension/src/specs/2FA.spec.ts b/packages/e2e/extension/src/specs/2FA.spec.ts index 773cfda9d..136cf9df9 100644 --- a/packages/e2e/extension/src/specs/2FA.spec.ts +++ b/packages/e2e/extension/src/specs/2FA.spec.ts @@ -1,18 +1,22 @@ import { expect } from "@playwright/test" import test from "../test" -import { expireBESession } from "../utils/common" +import { expireBESession } from "../../../shared/src/common" import { v4 as uuid } from "uuid" -import config from "../config" +import config from "../../../shared/config" +import { lang } from "../languages" const generateEmail = () => `e2e_2fa_${uuid()}@mail.com` test.describe("2FA", () => { + test.slow() test("User should not be able to enable 2FA for a non deployed account", async ({ extension, }) => { - await extension.setupWallet({ accountsToSetup: [{ initialBalance: 0 }] }) - await extension.navigation.showSettings.click() + await extension.setupWallet({ + accountsToSetup: [{ assets: [{ token: "ETH", balance: 0 }] }], + }) + await extension.navigation.showSettingsLocator.click() await extension.settings.account(extension.account.accountName1).click() await extension.settings.argentShield().first().click() await expect(extension.account.deployNeededWarning).toBeVisible() @@ -23,16 +27,61 @@ test.describe("2FA", () => { }) => { const email = generateEmail() await extension.setupWallet({ - accountsToSetup: [{ initialBalance: 0.002, deploy: true }], + accountsToSetup: [ + { assets: [{ token: "ETH", balance: 0.002 }], deploy: true }, + { assets: [{ token: "ETH", balance: 0 }] }, + ], + }) + await extension.activate2fa({ + accountName: extension.account.accountName1, + email, }) - await extension.activate2fa(extension.account.accountName1, email) await extension.account.transfer({ originAccountName: extension.account.accountName1, - recipientAddress: config.senderAddr!, - tokenName: "Ethereum", + recipientAddress: config.destinationAddress!, + token: "ETH", amount: "MAX", }) await extension.activity.checkActivity(1) + await extension.activity.ensureNoPendingTransactions() + await extension.navigation.menuTokensLocator.click() + + //other accounts should have independent Argent Shield + await extension.account.ensure2FANotEnabled(extension.account.accountName2) + }) + + test("User should be able to enable/disable 2FA for all accounts", async ({ + extension, + }) => { + const email = generateEmail() + await extension.setupWallet({ + accountsToSetup: [ + { assets: [{ token: "ETH", balance: 0.001 }], deploy: true }, + { assets: [{ token: "ETH", balance: 0.001 }], deploy: true }, + ], + }) + await extension.activate2fa({ + accountName: extension.account.accountName1, + email, + }) + await extension.activate2fa({ + accountName: extension.account.accountName2, + email, + validSession: true, + }) + + await extension.disable2fa({ + accountName: extension.account.accountName1, + email, + validSession: true, + }) + await extension.navigation.backLocator.click() + await extension.navigation.closeLocator.click() + await extension.disable2fa({ + accountName: extension.account.accountName2, + email, + validSession: true, + }) }) test("Recover wallet with 2FA, authentication needed before create a TX", async ({ @@ -40,27 +89,33 @@ test.describe("2FA", () => { }) => { const email = generateEmail() const { seed } = await extension.setupWallet({ - accountsToSetup: [{ initialBalance: 0.002, deploy: true }], + accountsToSetup: [ + { assets: [{ token: "ETH", balance: 0.002 }], deploy: true }, + ], + }) + + await extension.activate2fa({ + accountName: extension.account.accountName1, + email, }) - await extension.activate2fa(extension.account.accountName1, email) await extension.resetExtension() await extension.recoverWallet(seed) - await extension.account.token("Ethereum").click() + await extension.account.token("ETH").click() await extension.account.fillRecipientAddress({ - recipientAddress: config.senderAddr!, + recipientAddress: config.destinationAddress!, }) await extension.account.email.fill(email) - await extension.navigation.next.first().click() + await extension.navigation.nextLocator.first().click() await extension.account.fillPin("111111") await Promise.all([ expect(extension.account.balance).toBeVisible(), expect(extension.account.sendMax).toBeVisible(), ]) await extension.account.sendMax.click() - await extension.account.reviewSend.click() - await extension.account.confirm.click() + await extension.account.reviewSendLocator.click() + await extension.account.confirmTransaction() await extension.activity.checkActivity(1) }) @@ -69,13 +124,19 @@ test.describe("2FA", () => { }) => { const email = generateEmail() await extension.setupWallet({ - accountsToSetup: [{ initialBalance: 0.002, deploy: true }], + accountsToSetup: [ + { assets: [{ token: "ETH", balance: 0.002 }], deploy: true }, + ], }) - await extension.activate2fa(extension.account.accountName1, email) - await expireBESession(email) - await extension.account.token("Ethereum").click() + + await extension.activate2fa({ + accountName: extension.account.accountName1, + email, + }) + await expireBESession(email, "argentx") + await extension.account.token("ETH").click() await extension.account.fillRecipientAddress({ - recipientAddress: config.senderAddr!, + recipientAddress: config.destinationAddress!, }) await extension.account.fillPin("111111") await Promise.all([ @@ -83,8 +144,64 @@ test.describe("2FA", () => { expect(extension.account.sendMax).toBeVisible(), ]) await extension.account.sendMax.click() - await extension.account.reviewSend.click() - await extension.account.confirm.click() + await extension.account.reviewSendLocator.click() + await extension.account.confirmTransaction() await extension.activity.checkActivity(1) }) + + test("Try to activate 2FA with an email already in use", async ({ + extension, + }) => { + await extension.setupWallet({ + accountsToSetup: [ + { assets: [{ token: "ETH", balance: 0.001 }], deploy: true }, + ], + }) + await extension.account.ensureSelectedAccount( + extension.account.accountName1, + ) + await extension.navigation.showSettingsLocator.click() + await extension.settings.account(extension.account.accountName1).click() + await extension.settings.argentShield().click() + await extension.navigation.nextLocator.click() + await extension.account.email.fill("registeredemail@argent.xyz") + await extension.navigation.nextLocator.first().click() + await extension.account.fillPin("111111") + await expect( + extension.page.getByText(lang.account.argentShield.emailInUse), + ).toBeVisible() + }) + + test("Verify error message when user insert wrong code 3 times", async ({ + extension, + }) => { + await extension.setupWallet({ + accountsToSetup: [ + { assets: [{ token: "ETH", balance: 0.001 }], deploy: true }, + ], + }) + const email = generateEmail() + await extension.account.ensureSelectedAccount( + extension.account.accountName1, + ) + await extension.navigation.showSettingsLocator.click() + await extension.settings.account(extension.account.accountName1).click() + await extension.settings.argentShield().click() + await extension.navigation.nextLocator.click() + await extension.account.email.fill(email) + await extension.navigation.nextLocator.first().click() + await extension.account.fillPin("222222") + await expect( + extension.page.getByText(lang.account.argentShield.wrong2faCode), + ).toBeVisible() + await extension.page.locator('input[data-index="5"]').fill("3") + await extension.page.locator('input[data-index="5"]').fill("4") + await expect( + extension.page.getByText(lang.account.argentShield.failed2faCode), + ).toBeVisible() + await extension.page.locator('input[data-index="5"]').fill("5") + await expect( + extension.page.getByText(lang.account.argentShield.codeNotRequested2fa), + ).toBeVisible() + }) }) diff --git a/packages/e2e/extension/src/specs/accountSettings.spec.ts b/packages/e2e/extension/src/specs/accountSettings.spec.ts index cd2301817..bf1dd7c12 100644 --- a/packages/e2e/extension/src/specs/accountSettings.spec.ts +++ b/packages/e2e/extension/src/specs/accountSettings.spec.ts @@ -1,6 +1,6 @@ import { expect } from "@playwright/test" -import config from "../config" +import config from "../../../shared/config" import test from "../test" import { lang } from "../languages" @@ -9,19 +9,19 @@ test.describe("Account settings", () => { await extension.wallet.newWalletOnboarding() await extension.open() await expect(extension.network.networkSelector).toBeVisible() - await extension.network.selectNetwork("Testnet") + await extension.network.selectDefaultNetwork() const [accountName1] = await extension.account.addAccount({ firstAccount: false, }) - await extension.navigation.showSettings.click() + await extension.navigation.showSettingsLocator.click() await extension.settings.account(accountName1!).click() await extension.settings.setAccountName("My new account name") await expect(extension.settings.accountName).toHaveValue( "My new account name", ) - await extension.navigation.back.click() - await extension.navigation.close.click() + await extension.navigation.backLocator.click() + await extension.navigation.closeLocator.click() await extension.account.ensureSelectedAccount("My new account name") }) @@ -30,17 +30,17 @@ test.describe("Account settings", () => { await extension.wallet.newWalletOnboarding() await extension.open() await expect(extension.network.networkSelector).toBeVisible() - await extension.network.selectNetwork("Testnet") + await extension.network.selectDefaultNetwork() const [accountName1] = await extension.account.addAccount({ firstAccount: false, }) - await extension.navigation.showSettings.click() + await extension.navigation.showSettingsLocator.click() await extension.settings.account(accountName1!).click() await extension.settings.exportPrivateKey.click() await extension.account.password.fill(config.password) - await extension.account.exportPrivateKey.click() - await extension.settings.privateKey.click() + await extension.account.unlockLocator.click() + await extension.settings.copy.click() //ensure that copy is working const clipboardPrivateKey = await extension.page.evaluate( "navigator.clipboard.readText()", @@ -49,7 +49,7 @@ test.describe("Account settings", () => { await extension.settings.privateKey.textContent(), ) - await extension.navigation.done.click() + await extension.navigation.backLocator.click() await expect(extension.settings.exportPrivateKey).toBeVisible() }) @@ -57,12 +57,12 @@ test.describe("Account settings", () => { await extension.wallet.newWalletOnboarding() await extension.open() await expect(extension.network.networkSelector).toBeVisible() - await extension.network.selectNetwork("Testnet") + await extension.network.selectDefaultNetwork() const [accountName2] = await extension.account.addAccount({ firstAccount: false, }) - await extension.navigation.showSettings.click() + await extension.navigation.showSettingsLocator.click() await extension.settings.account(accountName2!).click() await extension.settings.hideAccount.click() await extension.settings.confirmHide.click() @@ -71,7 +71,7 @@ test.describe("Account settings", () => { await extension.settings.hiddenAccounts.click() await extension.settings.unhideAccount(accountName2!).click() - await extension.navigation.back.click() + await extension.navigation.backLocator.click() await expect(extension.settings.hiddenAccounts).toBeHidden() await expect(extension.account.account(accountName2!)).toBeVisible() }) @@ -82,11 +82,11 @@ test.describe("Account settings", () => { await extension.wallet.newWalletOnboarding() await extension.open() await expect(extension.network.networkSelector).toBeVisible() - await extension.navigation.showSettings.click() - await extension.navigation.lockWallet.click() + await extension.navigation.showSettingsLocator.click() + await extension.navigation.lockWalletLocator.click() await extension.account.password.fill(config.password) - await extension.navigation.unlock.click() + await extension.navigation.unlockLocator.click() await expect(extension.network.networkSelector).toBeVisible() }) @@ -96,14 +96,50 @@ test.describe("Account settings", () => { await extension.wallet.newWalletOnboarding() await extension.open() await expect(extension.network.networkSelector).toBeVisible() - await extension.navigation.showSettings.click() - await extension.navigation.lockWallet.click() + await extension.navigation.showSettingsLocator.click() + await extension.navigation.lockWalletLocator.click() - await extension.account.password.fill("wrongpassword123!") - await extension.navigation.unlock.click() + await extension.account.password.fill("wrongPassword123!") + await extension.navigation.unlockLocator.click() await expect( extension.page.locator(`label:text-is("${lang.account.wrongPassword}")`), ).toBeVisible() await expect(extension.account.password).toBeVisible() }) + + test("Detect outside deploy", async ({ extension, secondExtension }) => { + const { seed } = await extension.setupWallet({ + accountsToSetup: [{ assets: [{ token: "ETH", balance: 0.01 }] }], + }) + await secondExtension.open() + await Promise.all([ + extension.account.transfer({ + originAccountName: extension.account.accountName1, + recipientAddress: config.destinationAddress!, + token: "ETH", + amount: "MAX", + }), + secondExtension.recoverWallet(seed), + ]) + + //ensure that balance is updated + await expect(extension.account.currentBalance("ETH")).toContainText("0.00") + await extension.navigation.showSettingsLocator.click() + await extension.settings.account(extension.account.accountName1).click() + await Promise.all([ + expect(extension.settings.deployAccount).toBeHidden(), + expect(extension.settings.viewOnStarkScanLocator).toBeVisible(), + ]) + + await expect(secondExtension.network.networkSelector).toBeVisible() + await secondExtension.network.selectDefaultNetwork() + await secondExtension.navigation.showSettingsLocator.click() + await secondExtension.settings + .account(extension.account.accountName1) + .click() + await Promise.all([ + expect(secondExtension.settings.deployAccount).toBeHidden(), + expect(secondExtension.settings.viewOnStarkScanLocator).toBeVisible(), + ]) + }) }) diff --git a/packages/e2e/extension/src/specs/addressBook.spec.ts b/packages/e2e/extension/src/specs/addressBook.spec.ts index 6617ffa2c..bb8bbc44d 100644 --- a/packages/e2e/extension/src/specs/addressBook.spec.ts +++ b/packages/e2e/extension/src/specs/addressBook.spec.ts @@ -1,54 +1,57 @@ import { expect } from "@playwright/test" -import config from "../config" +import config from "../../../shared/config" import test from "../test" test.describe("Address Book", () => { test("Add, update, use and delete address", async ({ extension }) => { await extension.setupWallet({ - accountsToSetup: [{ initialBalance: 0.002 }], + accountsToSetup: [{ assets: [{ token: "ETH", balance: 0.002 }] }], }) - await extension.navigation.showSettings.click() + await extension.navigation.showSettingsLocator.click() await extension.settings.addressBook.click() //create await extension.addressBook.add.click() - await extension.addressBook.save.click() + await extension.addressBook.saveLocator.click() await expect(extension.addressBook.nameRequired).toBeVisible() await expect(extension.addressBook.addressRequired).toBeVisible() await extension.addressBook.name.fill("My first address") - await extension.addressBook.save.click() + await extension.addressBook.saveLocator.click() await expect(extension.addressBook.nameRequired).not.toBeVisible() await expect(extension.addressBook.addressRequired).toBeVisible() await extension.addressBook.address.fill(config.account1Seed2!) await expect(extension.addressBook.nameRequired).not.toBeVisible() await expect(extension.addressBook.addressRequired).not.toBeVisible() await extension.addressBook.network.click() - await extension.addressBook.networkOption("Testnet").click() - await extension.addressBook.save.click() + await extension.addressBook.networkOption("Goerli").click() + await extension.addressBook.saveLocator.click() // update await extension.addressBook.addressByName("My first address").click() await extension.addressBook.name.fill("New name") - await extension.addressBook.save.click() + await extension.addressBook.saveLocator.click() await expect(extension.addressBook.addressByName("New name")).toBeVisible() - await extension.navigation.back.click() - await extension.navigation.close.click() + await extension.navigation.backLocator.click() + await extension.navigation.closeLocator.click() //transfer to address - await extension.account.token("Ethereum").click() + await extension.account.token("ETH").click() await extension.addressBook.addressBook.click() await extension.addressBook.addressByName("New name").click() await extension.account.sendMax.click() - await extension.navigation.reviewSend.click() - await extension.navigation.confirm.click() + await extension.navigation.reviewSendLocator.click() + await extension.account.confirmTransaction() const txHash = await extension.activity.getLastTxHash() - await extension.validateTx(txHash!, config.account1Seed2!) + await extension.validateTx({ + txHash: txHash!, + receiver: config.account1Seed2!, + }) //delete address - await extension.navigation.menuTokens.click() - await extension.navigation.showSettings.click() + await extension.navigation.menuTokensLocator.click() + await extension.navigation.showSettingsLocator.click() await extension.settings.addressBook.click() await extension.addressBook.addressByName("New name").click() await extension.addressBook.deleteAddress.click() @@ -60,38 +63,41 @@ test.describe("Address Book", () => { test("Add address after typing", async ({ extension }) => { await extension.setupWallet({ - accountsToSetup: [{ initialBalance: 0.002 }], + accountsToSetup: [{ assets: [{ token: "ETH", balance: 0.002 }] }], }) await expect(extension.network.networkSelector).toBeVisible() - await extension.network.selectNetwork("Testnet") + await extension.network.selectDefaultNetwork() - await extension.account.token("Ethereum").click() + await extension.account.token("ETH").click() await extension.account.recipientAddressQuery.type(config.account1Seed2!) await extension.addressBook.add.click() await expect(extension.addressBook.address).toHaveText( config.account1Seed2!, ) await extension.addressBook.name.fill("My address") - await extension.addressBook.save.click() + await extension.addressBook.saveLocator.click() await extension.account.contact("My address").click() await extension.account.sendMax.click() - await extension.navigation.reviewSend.click() - await extension.navigation.confirm.click() + await extension.navigation.reviewSendLocator.click() + await extension.account.confirmTransaction() const txHash = await extension.activity.getLastTxHash() - await extension.validateTx(txHash!, config.account1Seed2!) + await extension.validateTx({ + txHash: txHash!, + receiver: config.account1Seed2!, + }) }) test("Add address from send window", async ({ extension }) => { await extension.setupWallet({ - accountsToSetup: [{ initialBalance: 0.002 }], + accountsToSetup: [{ assets: [{ token: "ETH", balance: 0.002 }] }], }) await expect(extension.network.networkSelector).toBeVisible() - await extension.network.selectNetwork("Testnet") + await extension.network.selectDefaultNetwork() - await extension.account.token("Ethereum").click() + await extension.account.token("ETH").click() await extension.setClipBoardContent(config.account1Seed2!) await extension.account.recipientAddressQuery.focus() await extension.paste() @@ -101,13 +107,42 @@ test.describe("Address Book", () => { config.account1Seed2!, ) await extension.addressBook.name.fill("My address") - await extension.addressBook.save.click() + await extension.addressBook.saveLocator.click() await extension.account.sendMax.click() - await extension.navigation.reviewSend.click() - await extension.navigation.confirm.click() + await extension.navigation.reviewSendLocator.click() + await extension.account.confirmTransaction() const txHash = await extension.activity.getLastTxHash() - await extension.validateTx(txHash!, config.account1Seed2!) + await extension.validateTx({ + txHash: txHash!, + receiver: config.account1Seed2!, + }) + }) + + test("Add address - starknet.id", async ({ extension }) => { + await extension.setupWallet({ + accountsToSetup: [{ assets: [{ token: "ETH", balance: 0.002 }] }], + }) + + await expect(extension.network.networkSelector).toBeVisible() + await extension.network.selectDefaultNetwork() + + await extension.account.token("ETH").click() + await extension.account.recipientAddressQuery.type("qateste2e.stark") + await extension.addressBook.add.click() + await expect(extension.addressBook.address).toHaveText("qateste2e.stark") + await extension.addressBook.name.fill("My address") + await extension.addressBook.saveLocator.click() + await extension.account.contact("My address").click() + + await extension.account.sendMax.click() + await extension.navigation.reviewSendLocator.click() + await extension.account.confirmTransaction() + const txHash = await extension.activity.getLastTxHash() + await extension.validateTx({ + txHash: txHash!, + receiver: config.senderAddrs![0], + }) }) }) diff --git a/packages/e2e/extension/src/specs/dapps.spec.ts b/packages/e2e/extension/src/specs/dapps.spec.ts index 8766058a1..af6c651a5 100644 --- a/packages/e2e/extension/src/specs/dapps.spec.ts +++ b/packages/e2e/extension/src/specs/dapps.spec.ts @@ -1,29 +1,34 @@ import { expect } from "@playwright/test" import test from "../test" - -const aspectUrl = "https://testnet.aspect.co" -const testDappUrl = "https://dapp-argentlabs.vercel.app" +import { lang } from "../languages" test.describe("Dapps", () => { - test("connect from aspect", async ({ extension, browserContext }) => { + test("connect from starknet.id", async ({ extension, browserContext }) => { //setup wallet await extension.wallet.newWalletOnboarding() await extension.open() - await extension.dapps.requestConnectionFromDapp(browserContext, aspectUrl) + await extension.dapps.requestConnectionFromDapp( + browserContext, + "https://goerli.app.starknet.id", + ) //accept connection from ArgentX await extension.dapps.accept.click() //check connect dapps - await extension.navigation.showSettings.click() + await extension.navigation.showSettingsLocator.click() await extension.settings.connectedDapps.click() await expect( extension.dapps.connectedDapps(extension.account.accountName1, 1), ).toBeVisible() await extension.dapps.account(extension.account.accountName1).click() - await expect(extension.dapps.connected(aspectUrl)).toBeVisible() + await expect( + extension.dapps.connected("https://goerli.app.starknet.id"), + ).toBeVisible() //disconnect dapp from ArgentX - await extension.dapps.disconnect(aspectUrl).click() - await expect(extension.dapps.connected(testDappUrl)).toBeHidden() + await extension.dapps.disconnect("https://goerli.app.starknet.id").click() + await expect( + extension.dapps.connected("https://dapp-argentlabs.vercel.app"), + ).toBeHidden() await expect(extension.dapps.noConnectedDapps.first()).toBeVisible() }) @@ -31,20 +36,29 @@ test.describe("Dapps", () => { //setup wallet await extension.wallet.newWalletOnboarding() await extension.open() - await extension.dapps.requestConnectionFromDapp(browserContext, testDappUrl) + await extension.dapps.requestConnectionFromDapp( + browserContext, + "https://dapp-argentlabs.vercel.app", + ) //accept connection from ArgentX await extension.dapps.accept.click() //check connect dapps - await extension.navigation.showSettings.click() + await extension.navigation.showSettingsLocator.click() await extension.settings.connectedDapps.click() await expect( extension.dapps.connectedDapps(extension.account.accountName1, 1), ).toBeVisible() await extension.dapps.account(extension.account.accountName1).click() - await expect(extension.dapps.connected(testDappUrl)).toBeVisible() + await expect( + extension.dapps.connected("https://dapp-argentlabs.vercel.app"), + ).toBeVisible() //disconnect dapp from ArgentX - await extension.dapps.disconnect(testDappUrl).click() - await expect(extension.dapps.connected(testDappUrl)).toBeHidden() + await extension.dapps + .disconnect("https://dapp-argentlabs.vercel.app") + .click() + await expect( + extension.dapps.connected("https://dapp-argentlabs.vercel.app"), + ).toBeHidden() await expect(extension.dapps.noConnectedDapps.first()).toBeVisible() }) @@ -52,13 +66,19 @@ test.describe("Dapps", () => { //setup wallet await extension.wallet.newWalletOnboarding() await extension.open() - await extension.dapps.requestConnectionFromDapp(browserContext, aspectUrl) + await extension.dapps.requestConnectionFromDapp( + browserContext, + "https://goerli.app.starknet.id", + ) //accept connection from ArgentX await extension.dapps.accept.click() - await extension.dapps.requestConnectionFromDapp(browserContext, testDappUrl) + await extension.dapps.requestConnectionFromDapp( + browserContext, + "https://dapp-argentlabs.vercel.app", + ) //accept connection from ArgentX await extension.dapps.accept.click() - await extension.navigation.showSettings.click() + await extension.navigation.showSettingsLocator.click() await extension.settings.connectedDapps.click() await expect( extension.dapps.connectedDapps(extension.account.accountName1, 2), @@ -66,14 +86,22 @@ test.describe("Dapps", () => { await extension.dapps.account(extension.account.accountName1).click() await Promise.all([ - expect(extension.dapps.connected(testDappUrl)).toBeVisible(), - expect(extension.dapps.connected(aspectUrl)).toBeVisible(), + expect( + extension.dapps.connected("https://dapp-argentlabs.vercel.app"), + ).toBeVisible(), + expect( + extension.dapps.connected("https://goerli.app.starknet.id"), + ).toBeVisible(), ]) await extension.dapps.disconnectAll().click() await Promise.all([ - expect(extension.dapps.connected(testDappUrl)).toBeHidden(), - expect(extension.dapps.connected(aspectUrl)).toBeHidden(), + expect( + extension.dapps.connected("https://dapp-argentlabs.vercel.app"), + ).toBeHidden(), + expect( + extension.dapps.connected("https://goerli.app.starknet.id"), + ).toBeHidden(), ]) await expect(extension.dapps.noConnectedDapps.first()).toBeVisible() }) @@ -85,13 +113,19 @@ test.describe("Dapps", () => { //setup wallet await extension.wallet.newWalletOnboarding() await extension.open() - await extension.dapps.requestConnectionFromDapp(browserContext, aspectUrl) + await extension.dapps.requestConnectionFromDapp( + browserContext, + "https://goerli.app.starknet.id", + ) //accept connection from ArgentX await extension.dapps.accept.click() - await extension.dapps.requestConnectionFromDapp(browserContext, testDappUrl) + await extension.dapps.requestConnectionFromDapp( + browserContext, + "https://dapp-argentlabs.vercel.app", + ) //accept connection from ArgentX await extension.dapps.accept.click() - await extension.navigation.showSettings.click() + await extension.navigation.showSettingsLocator.click() await extension.settings.connectedDapps.click() await expect( extension.dapps.connectedDapps(extension.account.accountName1, 2), @@ -99,16 +133,26 @@ test.describe("Dapps", () => { await extension.dapps.account(extension.account.accountName1).click() await Promise.all([ - expect(extension.dapps.connected(testDappUrl)).toBeVisible(), - expect(extension.dapps.connected(aspectUrl)).toBeVisible(), + expect( + extension.dapps.connected("https://dapp-argentlabs.vercel.app"), + ).toBeVisible(), + expect( + extension.dapps.connected("https://goerli.app.starknet.id"), + ).toBeVisible(), ]) - await extension.dapps.disconnect(testDappUrl).click() + await extension.dapps + .disconnect("https://dapp-argentlabs.vercel.app") + .click() await Promise.all([ - expect(extension.dapps.connected(testDappUrl)).toBeHidden(), - expect(extension.dapps.connected(aspectUrl)).toBeVisible(), + expect( + extension.dapps.connected("https://dapp-argentlabs.vercel.app"), + ).toBeHidden(), + expect( + extension.dapps.connected("https://goerli.app.starknet.id"), + ).toBeVisible(), ]) - await extension.navigation.back.click() + await extension.navigation.backLocator.click() await expect( extension.dapps.connectedDapps(extension.account.accountName1, 1), ).toBeVisible() @@ -117,20 +161,29 @@ test.describe("Dapps", () => { test("connect dapps by account", async ({ extension, browserContext }) => { //setup wallet await extension.setupWallet({ - accountsToSetup: [{ initialBalance: 0 }, { initialBalance: 0 }], + accountsToSetup: [ + { assets: [{ token: "ETH", balance: 0 }] }, + { assets: [{ token: "ETH", balance: 0 }] }, + ], }) await extension.open() await extension.account.selectAccount(extension.account.accountName1) - await extension.dapps.requestConnectionFromDapp(browserContext, aspectUrl) + await extension.dapps.requestConnectionFromDapp( + browserContext, + "https://goerli.app.starknet.id", + ) //accept connection from ArgentX await extension.dapps.accept.click() await extension.account.selectAccount(extension.account.accountName2) - await extension.dapps.requestConnectionFromDapp(browserContext, testDappUrl) + await extension.dapps.requestConnectionFromDapp( + browserContext, + "https://dapp-argentlabs.vercel.app", + ) //accept connection from ArgentX await extension.dapps.accept.click() - await extension.navigation.showSettings.click() + await extension.navigation.showSettingsLocator.click() await extension.settings.connectedDapps.click() await Promise.all([ expect( @@ -143,15 +196,99 @@ test.describe("Dapps", () => { await extension.dapps.account(extension.account.accountName1).click() await Promise.all([ - expect(extension.dapps.connected(testDappUrl)).toBeHidden(), - expect(extension.dapps.connected(aspectUrl)).toBeVisible(), + expect( + extension.dapps.connected("https://dapp-argentlabs.vercel.app"), + ).toBeHidden(), + expect( + extension.dapps.connected("https://goerli.app.starknet.id"), + ).toBeVisible(), ]) - await extension.navigation.back.click() + await extension.navigation.backLocator.click() await extension.dapps.account(extension.account.accountName2).click() await Promise.all([ - expect(extension.dapps.connected(testDappUrl)).toBeVisible(), - expect(extension.dapps.connected(aspectUrl)).toBeHidden(), + expect( + extension.dapps.connected("https://dapp-argentlabs.vercel.app"), + ).toBeVisible(), + expect( + extension.dapps.connected("https://goerli.app.starknet.id"), + ).toBeHidden(), ]) }) + + test("try sign a message using testDapp with a non deployed account", async ({ + extension, + browserContext, + }) => { + //setup wallet + await extension.wallet.newWalletOnboarding() + await extension.open() + const dapp = await extension.dapps.requestConnectionFromDapp( + browserContext, + "https://dapp-argentlabs.vercel.app", + ) + //accept connection from ArgentX + await extension.dapps.accept.click() + await dapp.locator('[id="short-text"]').fill("Test message!") + await dapp.locator('button:text-is("Sign")').click() + await expect( + extension.page.locator( + `button:text-is("${lang.account.activateAccount}")`, + ), + ).toBeVisible() + await extension.page + .locator(`button:text-is("${lang.account.activateAccount}")`) + .click() + await expect( + extension.page.locator(`text="${lang.account.notEnoughFoundsFee}"`), + ).toBeVisible() + }) + + test("sign message using testDapp with a deployed account", async ({ + extension, + browserContext, + }) => { + //setup wallet + await extension.setupWallet({ + accountsToSetup: [ + { assets: [{ token: "ETH", balance: 0.001 }], deploy: true }, + ], + }) + await extension.open() + const dapp = await extension.dapps.requestConnectionFromDapp( + browserContext, + "https://dapp-argentlabs.vercel.app", + ) + //accept connection from ArgentX + await extension.dapps.accept.click() + await dapp.locator('[id="short-text"]').fill("Test message!") + await dapp.locator('button:text-is("Sign")').click() + await expect( + extension.page.locator( + '//p[text()="Message"]//following::p[text()="Test message!"]', + ), + ).toBeVisible() + await extension.page.locator('[id="Sign"]').click() + }) + + test("connect to dapp flagged as critical", async ({ + extension, + browserContext, + }) => { + //setup wallet + await extension.setupWallet({ + accountsToSetup: [{ assets: [{ token: "ETH", balance: 0 }] }], + }) + await extension.open() + + await extension.dapps.requestConnectionFromDapp( + browserContext, + "https://starknetkit-blacked-listed.vercel.app", + ) + await extension.dapps.checkCriticalRiskConnectionScreen() + await extension.dapps.acceptCriticalRiskConnection() + await extension.dapps.accept.click() + //https://argent.atlassian.net/browse/BLO-1939 + // await extension.dapps.connectedDappsTooltip("https://starknetkit-blacked-listed.vercel.app") + }) }) diff --git a/packages/e2e/extension/src/specs/dappsBanner.spec.ts b/packages/e2e/extension/src/specs/dappsBanner.spec.ts deleted file mode 100644 index 83842f3e2..000000000 --- a/packages/e2e/extension/src/specs/dappsBanner.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { expect } from "@playwright/test" - -import test from "../test" -import config from "../config" - -test.describe("Banner", () => { - test("avnu banner should be visible after login", async ({ extension }) => { - await extension.setupWallet({ - accountsToSetup: [{ initialBalance: 0.0001 }], - }) - - await expect(extension.network.networkSelector).toBeVisible() - await expect(extension.account.avnuBanner).toBeVisible() - }) - - test("avnu banner should not be visible after dismissed", async ({ - extension, - }) => { - await extension.setupWallet({ - accountsToSetup: [{ initialBalance: 0.0001 }], - }) - - await expect(extension.network.networkSelector).toBeVisible() - await expect(extension.account.avnuBanner).toBeVisible() - await extension.account.avnuBannerClose.click() - await expect(extension.account.avnuBanner).toBeHidden() - }) - - test("avnu banner shoud be visible after account recovery", async ({ - extension, - }) => { - await extension.open() - await extension.recoverWallet(config.testNetSeed1!) - await expect(extension.account.avnuBanner).toBeVisible() - }) - - test("ekubo banner should be visible after avnu banner has been dismissed", async ({ - extension, - }) => { - await extension.setupWallet({ - accountsToSetup: [{ initialBalance: 0.0001 }], - }) - - await expect(extension.network.networkSelector).toBeVisible() - await expect(extension.account.avnuBanner).toBeVisible() - await extension.account.avnuBannerClose.click() - await expect(extension.account.avnuBanner).toBeHidden() - await expect(extension.account.ekuboBanner).toBeVisible() - }) -}) diff --git a/packages/e2e/extension/src/specs/invalidAddress.spec.ts b/packages/e2e/extension/src/specs/invalidAddress.spec.ts index 987616a56..f3512f8af 100644 --- a/packages/e2e/extension/src/specs/invalidAddress.spec.ts +++ b/packages/e2e/extension/src/specs/invalidAddress.spec.ts @@ -4,12 +4,12 @@ import test from "../test" test.describe("Invalid address", () => { test("Invalid starknet id", async ({ extension }) => { await extension.setupWallet({ - accountsToSetup: [{ initialBalance: 0.0001 }], + accountsToSetup: [{ assets: [{ token: "ETH", balance: 0.0001 }] }], }) await extension.account.ensureSelectedAccount( extension.account.accountName1, ) - await extension.account.token("Ethereum").click() + await extension.account.token("ETH").click() await extension.account.fillRecipientAddress({ recipientAddress: "e2e-test5345346eertgegeggfgdgdgdfgdgdf.stark", validAddress: false, @@ -23,12 +23,12 @@ test.describe("Invalid address", () => { test("Invalid address (short address)", async ({ extension }) => { await extension.setupWallet({ - accountsToSetup: [{ initialBalance: 0.0001 }], + accountsToSetup: [{ assets: [{ token: "ETH", balance: 0.0001 }] }], }) await extension.account.ensureSelectedAccount( extension.account.accountName1, ) - await extension.account.token("Ethereum").click() + await extension.account.token("ETH").click() await extension.account.fillRecipientAddress({ recipientAddress: "0x0451fCcB2617Db213E0e661D525F16a52eCCF9E2b8D735f13E4F7de49A4Dc3a", @@ -40,12 +40,12 @@ test.describe("Invalid address", () => { test("Invalid address (checksum error)", async ({ extension }) => { await extension.setupWallet({ - accountsToSetup: [{ initialBalance: 0.0001 }], + accountsToSetup: [{ assets: [{ token: "ETH", balance: 0.0001 }] }], }) await extension.account.ensureSelectedAccount( extension.account.accountName1, ) - await extension.account.token("Ethereum").click() + await extension.account.token("ETH").click() await extension.account.fillRecipientAddress({ recipientAddress: "0x0451fCcB2617Db213E0e661D525F16a52eCCF9E2b8D735f13E4F7de49A4Dc3a3", @@ -57,12 +57,12 @@ test.describe("Invalid address", () => { test("Invalid address", async ({ extension }) => { await extension.setupWallet({ - accountsToSetup: [{ initialBalance: 0.0001 }], + accountsToSetup: [{ assets: [{ token: "ETH", balance: 0.0001 }] }], }) await extension.account.ensureSelectedAccount( extension.account.accountName1, ) - await extension.account.token("Ethereum").click() + await extension.account.token("ETH").click() await extension.account.fillRecipientAddress({ recipientAddress: "0x0451fCcB2617Db213E0e661D525F16a52eCCF9E2b8D735f13E4F7de49A4Dc3aq", diff --git a/packages/e2e/extension/src/specs/links.spec.ts b/packages/e2e/extension/src/specs/links.spec.ts index db7e72f4b..571062712 100644 --- a/packages/e2e/extension/src/specs/links.spec.ts +++ b/packages/e2e/extension/src/specs/links.spec.ts @@ -1,13 +1,12 @@ import { expect } from "@playwright/test" -import { lang } from "../languages" import test from "../test" test.describe("Links", () => { test("Check settings links", async ({ extension }) => { await extension.wallet.newWalletOnboarding() await extension.open() - await extension.navigation.showSettings.click() + await extension.navigation.showSettingsLocator.click() let href = await extension.settings.discord.getAttribute("href") expect(href).toContain("https://discord.gg/T4PDFHxm6T") href = await extension.settings.help.getAttribute("href") @@ -16,9 +15,5 @@ test.describe("Links", () => { ) href = await extension.settings.github.getAttribute("href") expect(href).toContain("https://github.com/argentlabs/argent-x/issues") - await extension.settings.privacyStatement.click() - await expect(extension.settings.privacyStatementText).toHaveText( - lang.common.privacyStatement, - ) }) }) diff --git a/packages/e2e/extension/src/specs/multisig.spec.ts b/packages/e2e/extension/src/specs/multisig.spec.ts index 193f64b82..b5c266106 100644 --- a/packages/e2e/extension/src/specs/multisig.spec.ts +++ b/packages/e2e/extension/src/specs/multisig.spec.ts @@ -1,44 +1,50 @@ import { expect } from "@playwright/test" import test from "../test" -import config from "../config" -import { sleep } from "../utils/common" +import config from "../../../shared/config" +import { sleep } from "../../../shared/src/common" +import { lang } from "../languages" test.describe("Multisig", () => { - test("add and activate 1/1 multisig ", async ({ extension }) => { + test.slow() + test("add and activate 1/1 multisig", async ({ extension }) => { await extension.setupWallet({ accountsToSetup: [], }) await extension.account.addMultisigAccount({}) - await extension.navigation.close.click() + await extension.navigation.closeLocator.click() + await expect( + extension.page.locator('[data-testid="activate-multisig"]'), + ).toBeHidden() await extension.fundMultisigAccount({ accountName: extension.account.accountNameMulti1, amount: 0.002, }) + await expect( + extension.page.locator('[data-testid="activate-multisig"]'), + ).toBeVisible() await extension.activateMultisig(extension.account.accountNameMulti1) - const amountTrx = await extension.account.transfer({ + const { sendAmountTX, sendAmountFE } = await extension.account.transfer({ originAccountName: extension.account.accountNameMulti1, recipientAddress: config.destinationAddress!, - tokenName: "Ethereum", - amount: "MAX", + token: "ETH", + amount: 0.001, }) const txHash = await extension.activity.getLastTxHash() - await extension.validateTx( - txHash!, - config.destinationAddress!, - amountTrx, - true, - ) - await extension.navigation.menuTokens.click() + await extension.validateTx({ + txHash: txHash!, + receiver: config.destinationAddress!, + sendAmountFE, + sendAmountTX, + uniqLocator: true, + }) + await extension.navigation.menuTokensLocator.click() //ensure that balance is updated - await expect(extension.account.currentBalance("Ethereum")).toContainText( - "0.000", - { timeout: 60000 }, - ) + await expect(extension.account.currentBalance("ETH")).toContainText("0.000") }) - test("add and activate 1/2 multisig ", async ({ + test("add and activate 1/2 multisig", async ({ extension, secondExtension, }) => { @@ -50,7 +56,7 @@ test.describe("Multisig", () => { }) const pubKey = await secondExtension.account.joinMultisig() await extension.account.addMultisigAccount({ signers: [pubKey] }) - await extension.navigation.close.click() + await extension.navigation.closeLocator.click() await extension.fundMultisigAccount({ accountName: extension.account.accountNameMulti1, amount: 0.002, @@ -62,7 +68,7 @@ test.describe("Multisig", () => { secondExtension.account.accountNameMulti1, ), ).toHaveText("1/2") - await secondExtension.navigation.close.click() + await secondExtension.navigation.closeLocator.click() await secondExtension.account.selectAccount( secondExtension.account.accountNameMulti1, ) @@ -75,41 +81,39 @@ test.describe("Multisig", () => { ), ]) - const amountTrx = await extension.account.transfer({ + const { sendAmountTX, sendAmountFE } = await extension.account.transfer({ originAccountName: extension.account.accountNameMulti1, recipientAddress: config.destinationAddress!, - tokenName: "Ethereum", - amount: "MAX", + token: "ETH", + amount: 0.001, }) const txHash = await extension.activity.getLastTxHash() - await extension.validateTx( - txHash!, - config.destinationAddress!, - amountTrx, - true, - ) - await extension.navigation.menuTokens.click() + await extension.validateTx({ + txHash: txHash!, + receiver: config.destinationAddress!, + sendAmountFE, + sendAmountTX, + uniqLocator: true, + }) + await extension.navigation.menuTokensLocator.click() //ensure that balance is updated await Promise.all([ - expect(extension.account.currentBalance("Ethereum")).toContainText( - "0.000", - { timeout: 120000 }, - ), - expect(secondExtension.account.currentBalance("Ethereum")).toContainText( + expect(extension.account.currentBalance("ETH")).toContainText("0.000"), + expect(secondExtension.account.currentBalance("ETH")).toContainText( "0.000", - { timeout: 120000 }, ), ]) - await secondExtension.validateTx( - txHash!, - config.destinationAddress!, - amountTrx, - true, - ) + await secondExtension.validateTx({ + txHash: txHash!, + receiver: config.destinationAddress!, + sendAmountFE, + sendAmountTX, + uniqLocator: true, + }) }) - test("add and activate 2/2 multisig ", async ({ + test("add and activate 2/2 multisig", async ({ extension, secondExtension, }) => { @@ -124,7 +128,7 @@ test.describe("Multisig", () => { signers: [pubKey], confirmations: 2, }) - await extension.navigation.close.click() + await extension.navigation.closeLocator.click() await extension.fundMultisigAccount({ accountName: extension.account.accountNameMulti1, amount: 0.002, @@ -136,7 +140,7 @@ test.describe("Multisig", () => { secondExtension.account.accountNameMulti1, ), ).toHaveText("2/2") - await secondExtension.navigation.close.click() + await secondExtension.navigation.closeLocator.click() await secondExtension.account.selectAccount( secondExtension.account.accountNameMulti1, ) @@ -149,41 +153,38 @@ test.describe("Multisig", () => { ), ]) - const amountTrx = await extension.account.transfer({ + const { sendAmountTX, sendAmountFE } = await extension.account.transfer({ originAccountName: extension.account.accountNameMulti1, recipientAddress: config.destinationAddress!, - tokenName: "Ethereum", - amount: "MAX", + token: "ETH", + amount: 0.001, }) - await extension.navigation.menuActivity.click() + await extension.navigation.menuActivityLocator.click() const txHash = await extension.activity.getLastTxHash() - await extension.navigation.menuTokens.click() + await extension.navigation.menuTokensLocator.click() - //acept tx from second extension + //accept tx from second extension await expect( - secondExtension.activity.menuPendingTransactionsIndicator, - ).toBeVisible({ timeout: 120000 }) + secondExtension.activity.menuPendingTransactionsIndicatorLocator, + ).toBeVisible() await secondExtension.account.acceptTx(txHash!) - await extension.validateTx( - txHash!, - config.destinationAddress!, - amountTrx, - true, - ) - await extension.navigation.menuTokens.click() - await secondExtension.navigation.menuTokens.click() + await extension.validateTx({ + txHash: txHash!, + receiver: config.destinationAddress!, + sendAmountFE, + sendAmountTX, + uniqLocator: true, + }) + await extension.navigation.menuTokensLocator.click() + await secondExtension.navigation.menuTokensLocator.click() //ensure that balance is updated await Promise.all([ - expect(extension.account.currentBalance("Ethereum")).toContainText( + expect(extension.account.currentBalance("ETH")).toContainText("0.000"), + expect(secondExtension.account.currentBalance("ETH")).toContainText( "0.000", - { timeout: 120000 }, - ), - expect(secondExtension.account.currentBalance("Ethereum")).toContainText( - "0.000", - { timeout: 120000 }, ), ]) }) @@ -203,7 +204,7 @@ test.describe("Multisig", () => { signers: [pubKey], confirmations: 2, }) - await extension.navigation.close.click() + await extension.navigation.closeLocator.click() await extension.fundMultisigAccount({ accountName: extension.account.accountNameMulti1, amount: 0.002, @@ -215,7 +216,7 @@ test.describe("Multisig", () => { secondExtension.account.accountNameMulti1, ), ).toHaveText("2/2") - await secondExtension.navigation.close.click() + await secondExtension.navigation.closeLocator.click() await secondExtension.account.selectAccount( secondExtension.account.accountNameMulti1, ) @@ -236,15 +237,15 @@ test.describe("Multisig", () => { let txHash = await extension.activity.getLastTxHash() await secondExtension.account.acceptTx(txHash!) - await secondExtension.navigation.menuTokens.click() - await extension.navigation.menuTokens.click() + await secondExtension.navigation.menuTokensLocator.click() + await extension.navigation.menuTokensLocator.click() await Promise.all([ - expect(extension.account.menuPendingTransactionsIndicator).toBeHidden({ - timeout: 120000, - }), expect( - secondExtension.account.menuPendingTransactionsIndicator, - ).toBeHidden({ timeout: 120000 }), + extension.account.menuPendingTransactionsIndicatorLocator, + ).toBeHidden(), + expect( + secondExtension.account.menuPendingTransactionsIndicatorLocator, + ).toBeHidden(), ]) //wait for events to be updated @@ -258,48 +259,315 @@ test.describe("Multisig", () => { ), ]) //transfer - const amountTrx = await extension.account.transfer({ + const { sendAmountTX, sendAmountFE } = await extension.account.transfer({ originAccountName: extension.account.accountNameMulti1, recipientAddress: config.destinationAddress!, - tokenName: "Ethereum", - amount: "MAX", + token: "ETH", + amount: 0.001, }) txHash = await extension.activity.getLastTxHash() //wait for events to be updated await sleep(20 * 1000) await Promise.all([ - expect(extension.account.menuPendingTransactionsIndicator).toBeHidden({ + expect( + extension.account.menuPendingTransactionsIndicatorLocator, + ).toBeHidden(), + expect( + secondExtension.account.menuPendingTransactionsIndicatorLocator, + ).toBeHidden(), + ]) + await Promise.all([ + extension.validateTx({ + txHash: txHash!, + receiver: config.destinationAddress!, + sendAmountFE, + sendAmountTX, + uniqLocator: true, + }), + secondExtension.validateTx({ + txHash: txHash!, + receiver: config.destinationAddress!, + sendAmountFE, + sendAmountTX, + uniqLocator: true, + }), + ]) + await Promise.all([ + extension.navigation.menuTokensLocator.click(), + secondExtension.navigation.menuTokensLocator.click(), + ]) + + //ensure that balance is updated + await Promise.all([ + expect(extension.account.currentBalance("ETH")).toContainText("0.000"), + expect(secondExtension.account.currentBalance("ETH")).toContainText( + "0.000", + ), + ]) + }) + + test("User is notified after get removed from a multisig account", async ({ + extension, + secondExtension, + }) => { + await extension.setupWallet({ + accountsToSetup: [], + }) + await secondExtension.setupWallet({ + accountsToSetup: [], + }) + const pubKey = await secondExtension.account.joinMultisig() + await extension.account.addMultisigAccount({ + signers: [pubKey], + confirmations: 1, + }) + await extension.navigation.closeLocator.click() + await extension.fundMultisigAccount({ + accountName: extension.account.accountNameMulti1, + amount: 0.002, + }) + await extension.activateMultisig(extension.account.accountNameMulti1) + + await expect( + secondExtension.account.accountListConfirmations( + secondExtension.account.accountNameMulti1, + ), + ).toHaveText("1/2") + await secondExtension.navigation.closeLocator.click() + await secondExtension.account.selectAccount( + secondExtension.account.accountNameMulti1, + ) + await Promise.all([ + expect(extension.account.accountViewConfirmations).toHaveText( + "1/2 multisig", + ), + expect(secondExtension.account.accountViewConfirmations).toHaveText( + "1/2 multisig", + ), + ]) + + await extension.account.removeMultiSigOwner( + extension.account.accountNameMulti1, + pubKey, + ) + await expect( + extension.account.menuPendingTransactionsIndicatorLocator, + ).toBeHidden() + await expect( + secondExtension.account.removedFromMultisigLocator, + ).toBeVisible() + }) + + test("Removed user should not see Multisig account after recover wallet", async ({ + extension, + }) => { + await extension.open() + await extension.recoverWallet(config.testNetSeed3!) + await expect(extension.network.networkSelector).toBeVisible() + await extension.network.selectDefaultNetwork() + + await extension.account.accountListSelector.click() + await expect( + extension.account.account(extension.account.accountName1), + ).toBeVisible() + await expect( + extension.account.account(extension.account.accountNameMulti1), + ).toBeHidden() + }) + //https://argent.atlassian.net/browse/BLO-1935 + test.skip("User should be able to hide/unhide Multisig account", async ({ + extension, + }) => { + await extension.open() + await extension.recoverWallet(config.testNetSeed2!) + await expect(extension.network.networkSelector).toBeVisible() + await extension.network.selectDefaultNetwork() + + await extension.account.selectAccount(extension.account.accountNameMulti1) + await extension.navigation.showSettingsLocator.click() + await extension.settings + .account(extension.account.accountNameMulti1) + .click() + await extension.settings.hideAccount.click() + await extension.settings.confirmHide.click() + + await expect( + extension.account.account(extension.account.accountNameMulti1), + ).toBeHidden() + + await extension.settings.hiddenAccounts.click() + await extension.settings + .unhideAccount(extension.account.accountNameMulti1) + .click() + await extension.navigation.backLocator.click() + await expect(extension.settings.hiddenAccounts).toBeHidden() + await expect( + extension.account.account(extension.account.accountNameMulti1), + ).toBeVisible() + }) + + test("Add token, token should only be visible if preference is set", async ({ + extension, + }) => { + await extension.setupWallet({ + accountsToSetup: [], + }) + await extension.account.addMultisigAccount({}) + await extension.navigation.closeLocator.click() + await expect( + extension.page.locator('[data-testid="activate-multisig"]'), + ).toBeHidden() + await extension.fundMultisigAccount({ + accountName: extension.account.accountNameMulti1, + amount: 0.001, + }) + await expect( + extension.page.locator('[data-testid="activate-multisig"]'), + ).toBeVisible() + await extension.activateMultisig(extension.account.accountNameMulti1) + await extension.page + .getByRole("link", { name: lang.account.newToken }) + .click() + await extension.page + .locator('[name="address"]') + .fill( + "0x05A6B68181bb48501a7A447a3f99936827E41D77114728960f22892F02E24928", + ) + await expect(extension.page.locator('[name="name"]')).toHaveValue("Astraly") + await Promise.all([ + extension.navigation.continueLocator.click(), + extension.page.locator('text="Token added"').click(), + ]) + + await expect(extension.account.token("AST")).toBeHidden() + await extension.navigation.showSettingsLocator.click() + await extension.settings.preferences.click() + await expect(extension.preferences.hideTokensStatus).toBeEnabled() + await extension.preferences.hideTokens.click() + await extension.navigation.backLocator.click() + await extension.navigation.closeLocator.click() + await expect(extension.account.token("AST")).toBeVisible() + }) + + test("add owner to 1/1 activated multisig", async ({ + extension, + secondExtension, + }) => { + await Promise.all([ + extension.setupWallet({ + accountsToSetup: [], + }), + ]) + await Promise.all([ + extension.account.addMultisigAccount({}), + secondExtension.setupWallet({ + accountsToSetup: [], + }), + ]) + + await extension.navigation.closeLocator.click() + await expect( + extension.page.locator('[data-testid="activate-multisig"]'), + ).toBeHidden() + await extension.fundMultisigAccount({ + accountName: extension.account.accountNameMulti1, + amount: 0.002, + }) + await expect( + extension.page.locator('[data-testid="activate-multisig"]'), + ).toBeVisible() + await extension.activateMultisig(extension.account.accountNameMulti1) + + const pubKey = await secondExtension.account.joinMultisig() + + await extension.account.addOwnerToMultisig({ + accountName: extension.account.accountNameMulti1, + pubKey, + confirmations: 2, + }) + extension.navigation.menuTokensLocator.click(), + await expect( + extension.account.menuPendingTransactionsIndicatorLocator, + ).toBeHidden({ + timeout: 120000, + }) + + await expect( + secondExtension.account.accountListConfirmations( + secondExtension.account.accountNameMulti1, + ), + ).toHaveText("2/2") + await secondExtension.navigation.closeLocator.click() + await secondExtension.account.selectAccount( + secondExtension.account.accountNameMulti1, + ) + await Promise.all([ + expect(extension.account.accountViewConfirmations).toHaveText( + "2/2 multisig", + ), + expect(secondExtension.account.accountViewConfirmations).toHaveText( + "2/2 multisig", + ), + ]) + + const { sendAmountTX, sendAmountFE } = await extension.account.transfer({ + originAccountName: extension.account.accountNameMulti1, + recipientAddress: config.destinationAddress!, + token: "ETH", + amount: 0.001, + }) + const txHash = await extension.activity.getLastTxHash() + await extension.navigation.menuTokensLocator.click() + + //accept tx from second extension + await expect( + secondExtension.activity.menuPendingTransactionsIndicatorLocator, + ).toBeVisible({ timeout: 120000 }) + + await secondExtension.account.acceptTx(txHash!) + await secondExtension.navigation.menuTokensLocator.click() + + await sleep(20 * 1000) + await Promise.all([ + expect( + extension.account.menuPendingTransactionsIndicatorLocator, + ).toBeHidden({ timeout: 120000, }), expect( - secondExtension.account.menuPendingTransactionsIndicator, + secondExtension.account.menuPendingTransactionsIndicatorLocator, ).toBeHidden({ timeout: 120000 }), ]) - - await extension.validateTx( - txHash!, - config.destinationAddress!, - amountTrx, - true, - ) - await extension.navigation.menuTokens.click() + await Promise.all([ + extension.validateTx({ + txHash: txHash!, + receiver: config.destinationAddress!, + sendAmountFE, + sendAmountTX, + uniqLocator: true, + }), + secondExtension.validateTx({ + txHash: txHash!, + receiver: config.destinationAddress!, + sendAmountFE, + sendAmountTX, + uniqLocator: true, + }), + ]) + await Promise.all([ + extension.navigation.menuTokensLocator.click(), + secondExtension.navigation.menuTokensLocator.click(), + ]) //ensure that balance is updated await Promise.all([ - expect(extension.account.currentBalance("Ethereum")).toContainText( - "0.000", - { timeout: 120000 }, - ), - expect(secondExtension.account.currentBalance("Ethereum")).toContainText( + expect(extension.account.currentBalance("ETH")).toContainText("0.000", { + timeout: 120000, + }), + expect(secondExtension.account.currentBalance("ETH")).toContainText( "0.000", { timeout: 120000 }, ), ]) - await secondExtension.validateTx( - txHash!, - config.destinationAddress!, - amountTrx, - true, - ) }) }) diff --git a/packages/e2e/extension/src/specs/network.spec.ts b/packages/e2e/extension/src/specs/network.spec.ts index 281e70bd4..f3a2e3bb4 100644 --- a/packages/e2e/extension/src/specs/network.spec.ts +++ b/packages/e2e/extension/src/specs/network.spec.ts @@ -1,7 +1,7 @@ import { expect } from "@playwright/test" import test from "../test" -import config from "../config" +import config from "../../../shared/config" test.describe("Network", () => { test("Available networks", async ({ extension }) => { @@ -10,8 +10,8 @@ test.describe("Network", () => { await expect(extension.network.networkSelector).toBeVisible() await extension.network.ensureAvailableNetworks([ "Mainnet", - `Testnet`, - "Localhost 5050\nhttp://localhost:5050/rpc", + "Goerli", + "Devnet\nhttp://localhost:5050", ]) }) @@ -20,7 +20,7 @@ test.describe("Network", () => { }) => { await extension.wallet.newWalletOnboarding() await extension.open() - await extension.navigation.showSettings.click() + await extension.navigation.showSettingsLocator.click() await extension.settings.developerSettings.click() await extension.developerSettings.manageNetworks.click() @@ -29,37 +29,37 @@ test.describe("Network", () => { await extension.developerSettings.networkName.fill("My Network") await extension.developerSettings.chainId.fill("SN_GOERLI") await extension.developerSettings.rpcUrl.fill(config.testnetRpcUrl!) - await extension.navigation.create.click() + await extension.navigation.createLocator.click() await expect( extension.developerSettings.networkByName("My Network"), ).toBeVisible() - await extension.navigation.back.click() - await extension.navigation.back.click() - await extension.navigation.close.click() - await extension.network.ensureSelectedNetwork("Testnet") + await extension.navigation.backLocator.click() + await extension.navigation.backLocator.click() + await extension.navigation.closeLocator.click() + await extension.network.ensureSelectedNetwork("Goerli") // select network await extension.network.selectNetwork("My Network") // try to delete network - await extension.navigation.showSettings.click() + await extension.navigation.showSettingsLocator.click() await extension.settings.developerSettings.click() await extension.developerSettings.manageNetworks.click() await extension.developerSettings.deleteNetworkByName("My Network").click() - await extension.navigation.cancel.click() + await extension.navigation.cancelLocator.click() await expect( extension.developerSettings.networkByName("My Network"), ).toBeVisible() - await extension.navigation.back.click() - await extension.navigation.back.click() - await extension.navigation.close.click() + await extension.navigation.backLocator.click() + await extension.navigation.backLocator.click() + await extension.navigation.closeLocator.click() await extension.network.ensureSelectedNetwork("My Network") await expect(extension.account.createAccount).toBeVisible() await expect(extension.account.noAccountBanner).toBeVisible() // select other network - await extension.network.selectNetwork("Testnet") + await extension.network.selectDefaultNetwork() // delete network - await extension.navigation.showSettings.click() + await extension.navigation.showSettingsLocator.click() await extension.settings.developerSettings.click() await extension.developerSettings.manageNetworks.click() await extension.developerSettings.deleteNetworkByName("My Network").click() @@ -73,7 +73,7 @@ test.describe("Network", () => { }) => { await extension.wallet.newWalletOnboarding() await extension.open() - await extension.navigation.showSettings.click() + await extension.navigation.showSettingsLocator.click() await extension.settings.developerSettings.click() await extension.developerSettings.manageNetworks.click() @@ -83,21 +83,21 @@ test.describe("Network", () => { await extension.developerSettings.chainId.fill("SN_GOERLI") await extension.developerSettings.rpcUrl.fill(config.testnetRpcUrl!) - await extension.navigation.create.click() + await extension.navigation.createLocator.click() await expect( extension.developerSettings.networkByName("My Network"), ).toBeVisible() - await extension.navigation.back.click() - await extension.navigation.back.click() - await extension.navigation.close.click() + await extension.navigation.backLocator.click() + await extension.navigation.backLocator.click() + await extension.navigation.closeLocator.click() // add account await extension.network.selectNetwork("My Network") await extension.account.addAccount({ firstAccount: true }) - await extension.network.selectNetwork("Testnet") + await extension.network.selectDefaultNetwork() // try to restore networks - await extension.navigation.showSettings.click() + await extension.navigation.showSettingsLocator.click() await extension.settings.developerSettings.click() await extension.developerSettings.manageNetworks.click() await extension.developerSettings.restoreDefaultNetworks.click() diff --git a/packages/e2e/extension/src/specs/nfts.spec.ts b/packages/e2e/extension/src/specs/nfts.spec.ts new file mode 100644 index 000000000..8c1fef13c --- /dev/null +++ b/packages/e2e/extension/src/specs/nfts.spec.ts @@ -0,0 +1,72 @@ +import { expect } from "@playwright/test" + +import config from "../../../shared/config" +import test from "../test" +const spokCampaignName = `${config.spokCampaignName!}` +for (const feeToken of ["STRK", "ETH"] as const) { + test.describe(`Nfts ${feeToken}`, () => { + test(`User should be able to claim and send a NFT`, async ({ + extension, + browserContext, + }) => { + const { accountAddresses } = await extension.setupWallet({ + accountsToSetup: [ + { + assets: [ + { token: "ETH", balance: 0.01 }, + { token: "STRK", balance: 0.005 }, + ], + deploy: true, + feeToken, + }, + { assets: [{ token: "ETH", balance: 0 }] }, + ], + }) + await extension.account.ensureSelectedAccount( + extension.account.accountName1, + ) + const dapp = await extension.dapps.claimSpok(browserContext) + await extension.dapps.knownDappButton.click() + await extension.dapps.ensureKnowDappText() + await extension.dapps.closeButtonLocator.click() + await extension.dapps.accept.click() + await dapp.getByRole("button", { name: "Claim now" }).click() + await Promise.all([ + extension.navigation.confirmLocator.click(), + expect( + extension.activity.menuPendingTransactionsIndicatorLocator, + ).toBeVisible(), + ]) + await expect( + extension.activity.menuPendingTransactionsIndicatorLocator, + ).toBeHidden() + + await extension.navigation.menuNTFsLocator.click() + await extension.nfts.collection(spokCampaignName).click() + await extension.nfts.nftByPosition().click() + + await extension.account.send.click() + await extension.account.fillRecipientAddress({ + recipientAddress: accountAddresses[1], + }) + await extension.nfts.reviewSendLocator.click() + await Promise.all([ + extension.account.confirmLocator.click(), + expect( + extension.activity.menuPendingTransactionsIndicatorLocator, + ).toBeVisible(), + ]) + await expect( + extension.activity.menuPendingTransactionsIndicatorLocator, + ).toBeHidden() + await extension.navigation.menuNTFsLocator.click() + await expect(extension.nfts.collection(spokCampaignName)).toBeHidden() + + await extension.account.ensureSelectedAccount( + extension.account.accountName2, + ) + await extension.nfts.collection(spokCampaignName).click() + await extension.nfts.nftByPosition().click() + }) + }) +} diff --git a/packages/e2e/extension/src/specs/recovery.spec.ts b/packages/e2e/extension/src/specs/recovery.spec.ts index feead1738..e09531a0e 100644 --- a/packages/e2e/extension/src/specs/recovery.spec.ts +++ b/packages/e2e/extension/src/specs/recovery.spec.ts @@ -1,6 +1,6 @@ import { expect } from "@playwright/test" -import config from "../config" +import config from "../../../shared/config" import test from "../test" test.describe("Recovery Wallet", () => { @@ -9,10 +9,7 @@ test.describe("Recovery Wallet", () => { }) => { const { seed } = await extension.setupWallet({ accountsToSetup: [ - { - initialBalance: 0.0005, - deploy: true, - }, + { assets: [{ token: "ETH", balance: 0.0005 }], deploy: true }, ], }) @@ -31,7 +28,7 @@ test.describe("Recovery Wallet", () => { await expect(extension.account.showAccountRecovery).toBeVisible() await extension.account.showAccountRecovery.click() await extension.account.confirmTheSeedPhrase.click() - await extension.navigation.done.click() + await extension.navigation.doneLocator.click() await expect(extension.account.setUpAccountRecovery).toBeHidden() }) @@ -41,9 +38,9 @@ test.describe("Recovery Wallet", () => { await extension.open() await extension.recoverWallet(config.testNetSeed1!) await expect(extension.network.networkSelector).toBeVisible() - await extension.network.selectNetwork("Testnet") + await extension.network.selectDefaultNetwork() await extension.account.selectAccount("Account 33") - await expect(extension.account.currentBalance("Ethereum")).toContainText( + await expect(extension.account.currentBalance("ETH")).toContainText( "0.0000097 ETH", ) }) diff --git a/packages/e2e/extension/src/specs/sendMaxFunds.spec.ts b/packages/e2e/extension/src/specs/sendMaxFunds.spec.ts index 78ea94904..9406b215a 100644 --- a/packages/e2e/extension/src/specs/sendMaxFunds.spec.ts +++ b/packages/e2e/extension/src/specs/sendMaxFunds.spec.ts @@ -1,80 +1,121 @@ import { expect } from "@playwright/test" -import config from "../config" +import config from "../../../shared/config" import test from "../test" +for (const feeToken of ["STRK", "ETH"] as const) { + test.describe(`Send MAX funds fee ${feeToken}`, () => { + test.slow() + //using STRK to pay fee, user should be able to transfer all ETH funds + const expectedUpdatedBalance = feeToken === "STRK" ? "0.0 ETH" : "0.00" + test("send MAX funds to other self account", async ({ extension }) => { + const { accountAddresses } = await extension.setupWallet({ + accountsToSetup: [ + { + assets: [ + { token: "ETH", balance: 0.01 }, + { token: "STRK", balance: 0.005 }, + ], + }, + { assets: [{ token: "ETH", balance: 0 }] }, + ], + }) + const { sendAmountTX, sendAmountFE } = await extension.account.transfer({ + originAccountName: extension.account.accountName1, + recipientAddress: accountAddresses[1], + token: "ETH", + amount: "MAX", + feeToken, + }) + const txHash = await extension.activity.getLastTxHash() + await extension.validateTx({ + txHash: txHash!, + receiver: accountAddresses[1], + sendAmountFE, + sendAmountTX, + }) + await extension.navigation.menuTokensLocator.click() -test.describe("Send funds", () => { - test("send MAX funds to other self account", async ({ extension }) => { - const { accountAddresses } = await extension.setupWallet({ - accountsToSetup: [{ initialBalance: 0.01 }, { initialBalance: 0 }], - }) - const amountTrx = await extension.account.transfer({ - originAccountName: extension.account.accountName1, - recipientAddress: accountAddresses[1], - tokenName: "Ethereum", - amount: "MAX", + //ensure that balance is updated + await expect(extension.account.currentBalance("ETH")).toContainText( + expectedUpdatedBalance, + ) + let balance = await extension.account.currentBalance("ETH").innerText() + expect(parseFloat(balance)).toBeLessThan(0.01) + + await extension.account.ensureSelectedAccount( + extension.account.accountName2, + ) + await expect(extension.account.currentBalance("ETH")).toContainText( + "0.01", + ) + balance = await extension.account.currentBalance("ETH").innerText() + expect(parseFloat(balance)).toBeGreaterThan(0.0001) }) - const txHash = await extension.activity.getLastTxHash() - await extension.validateTx(txHash!, accountAddresses[1], amountTrx) - await extension.navigation.menuTokens.click() - //ensure that balance is updated - await expect(extension.account.currentBalance("Ethereum")).toContainText( - "0.00", - { timeout: 60000 }, - ) - let balance = await extension.account.currentBalance("Ethereum").innerText() - expect(parseFloat(balance)).toBeLessThan(0.01) + test("send MAX funds to other wallet/account", async ({ extension }) => { + await extension.setupWallet({ + accountsToSetup: [ + { + assets: [ + { token: "ETH", balance: 0.002 }, + { token: "STRK", balance: 0.005 }, + ], + }, + ], + }) - await extension.account.ensureSelectedAccount( - extension.account.accountName2, - ) - await expect(extension.account.currentBalance("Ethereum")).toContainText( - "0.01", - { timeout: 60000 }, - ) - balance = await extension.account.currentBalance("Ethereum").innerText() - expect(parseFloat(balance)).toBeGreaterThan(0.0001) - }) + const { sendAmountTX, sendAmountFE } = await extension.account.transfer({ + originAccountName: extension.account.accountName1, + recipientAddress: config.destinationAddress!, + token: "ETH", + amount: "MAX", + feeToken, + }) - test("send MAX funds to other wallet/account", async ({ extension }) => { - await extension.setupWallet({ - accountsToSetup: [{ initialBalance: 0.002 }], - }) + const txHash = await extension.activity.getLastTxHash() + await extension.validateTx({ + txHash: txHash!, + receiver: config.destinationAddress!, + sendAmountFE, + sendAmountTX, + }) + await extension.navigation.menuTokensLocator.click() - const amountTrx = await extension.account.transfer({ - originAccountName: extension.account.accountName1, - recipientAddress: config.destinationAddress!, - tokenName: "Ethereum", - amount: "MAX", + //ensure that balance is updated + await expect(extension.account.currentBalance("ETH")).toContainText( + expectedUpdatedBalance, + ) + const balance = await extension.account.currentBalance("ETH").innerText() + expect(parseFloat(balance)).toBeLessThan(0.002) }) - const txHash = await extension.activity.getLastTxHash() - await extension.validateTx(txHash!, config.destinationAddress!, amountTrx) - await extension.navigation.menuTokens.click() - - //ensure that balance is updated - await expect(extension.account.currentBalance("Ethereum")).toContainText( - "0.000", - { timeout: 60000 }, - ) - const balance = await extension.account - .currentBalance("Ethereum") - .innerText() - expect(parseFloat(balance)).toBeLessThan(0.002) - }) - - test("User should be able to send funds to starknet id", async ({ - extension, - }) => { - await extension.setupWallet({ accountsToSetup: [{ initialBalance: 0.01 }] }) - const amountTrx = await extension.account.transfer({ - originAccountName: extension.account.accountName1, - recipientAddress: "e2e-test.stark", - tokenName: "Ethereum", - amount: "MAX", + test("User should be able to send funds to starknet id", async ({ + extension, + }) => { + await extension.setupWallet({ + accountsToSetup: [ + { + assets: [ + { token: "ETH", balance: 0.01 }, + { token: "STRK", balance: 0.005 }, + ], + }, + ], + }) + const { sendAmountTX, sendAmountFE } = await extension.account.transfer({ + originAccountName: extension.account.accountName1, + recipientAddress: "qateste2e.stark", + token: "ETH", + amount: "MAX", + feeToken, + }) + const txHash = await extension.activity.getLastTxHash() + await extension.validateTx({ + txHash: txHash!, + receiver: config.destinationAddress!, + sendAmountFE, + sendAmountTX, + }) }) - const txHash = await extension.activity.getLastTxHash() - await extension.validateTx(txHash!, config.account1Seed3!, amountTrx) }) -}) +} diff --git a/packages/e2e/extension/src/specs/sendPartialFunds.spec.ts b/packages/e2e/extension/src/specs/sendPartialFunds.spec.ts index 8876b7456..3712b7c40 100644 --- a/packages/e2e/extension/src/specs/sendPartialFunds.spec.ts +++ b/packages/e2e/extension/src/specs/sendPartialFunds.spec.ts @@ -1,86 +1,128 @@ import { expect } from "@playwright/test" -import config from "../config" +import config from "../../../shared/config" import test from "../test" +for (const feeToken of ["STRK", "ETH"] as const) { + test.describe(`Send partial funds fee ${feeToken}`, () => { + test.slow() + test("send partial funds to other self account", async ({ extension }) => { + const { accountAddresses } = await extension.setupWallet({ + accountsToSetup: [ + { + assets: [ + { token: "ETH", balance: 0.01 }, + { token: "STRK", balance: 0.005 }, + ], + }, + { assets: [{ token: "ETH", balance: 0 }] }, + ], + }) + const { sendAmountTX, sendAmountFE } = await extension.account.transfer({ + originAccountName: extension.account.accountName1, + recipientAddress: accountAddresses[1], + token: "ETH", + amount: 0.005, + feeToken, + }) + const txHash = await extension.activity.getLastTxHash() + await extension.validateTx({ + txHash: txHash!, + receiver: accountAddresses[1], + sendAmountFE, + sendAmountTX, + }) + await extension.navigation.menuTokensLocator.click() -test.describe("Send funds", () => { - test("send partial funds to other self account", async ({ extension }) => { - const { accountAddresses } = await extension.setupWallet({ - accountsToSetup: [{ initialBalance: 0.01 }, { initialBalance: 0 }], - }) - const amountTrx = await extension.account.transfer({ - originAccountName: extension.account.accountName1, - recipientAddress: accountAddresses[1], - tokenName: "Ethereum", - amount: 0.005, - }) - const txHash = await extension.activity.getLastTxHash() - await extension.validateTx(txHash!, accountAddresses[1], amountTrx) - await extension.navigation.menuTokens.click() + //ensure that balance is updated + await expect(extension.account.currentBalance("ETH")).toContainText( + "0.00", + ) + const balance = await extension.account.currentBalance("ETH").innerText() + expect(parseFloat(balance)).toBeLessThan(0.01) - //ensure that balance is updated - await expect(extension.account.currentBalance("Ethereum")).toContainText( - "0.00", - { timeout: 60000 }, - ) - const balance = await extension.account - .currentBalance("Ethereum") - .innerText() - expect(parseFloat(balance)).toBeLessThan(0.01) - - await extension.account.ensureSelectedAccount( - extension.account.accountName2, - ) - await expect(extension.account.currentBalance("Ethereum")).toContainText( - "0.005", - { timeout: 60000 }, - ) - }) - - test("send partial funds to other wallet/account", async ({ extension }) => { - await extension.setupWallet({ accountsToSetup: [{ initialBalance: 0.01 }] }) - const amountTrx = await extension.account.transfer({ - originAccountName: extension.account.accountName1, - recipientAddress: config.destinationAddress!, - tokenName: "Ethereum", - amount: 0.005, - fillRecipientAddress: "typing", + await extension.account.ensureSelectedAccount( + extension.account.accountName2, + ) + await expect(extension.account.currentBalance("ETH")).toContainText( + "0.005", + ) }) - const txHash = await extension.activity.getLastTxHash() - await extension.validateTx(txHash!, config.destinationAddress!, amountTrx) - await extension.navigation.menuTokens.click() + test("send partial funds to other wallet/account", async ({ + extension, + }) => { + await extension.setupWallet({ + accountsToSetup: [ + { + assets: [ + { token: "ETH", balance: 0.01 }, + { token: "STRK", balance: 0.005 }, + ], + }, + ], + }) + const { sendAmountTX, sendAmountFE } = await extension.account.transfer({ + originAccountName: extension.account.accountName1, + recipientAddress: config.destinationAddress!, + token: "ETH", + amount: 0.005, + fillRecipientAddress: "typing", + feeToken, + }) + + const txHash = await extension.activity.getLastTxHash() + await extension.validateTx({ + txHash: txHash!, + receiver: config.destinationAddress!, + sendAmountFE, + sendAmountTX, + }) + await extension.navigation.menuTokensLocator.click() - //ensure that balance is updated - await expect(extension.account.currentBalance("Ethereum")).toContainText( - "0.00", - { timeout: 60000 }, - ) - const balance = await extension.account - .currentBalance("Ethereum") - .innerText() - expect(parseFloat(balance)).toBeLessThan(0.01) + //ensure that balance is updated + await expect(extension.account.currentBalance("ETH")).toContainText( + "0.00", + ) + const balance = await extension.account.currentBalance("ETH").innerText() + expect(parseFloat(balance)).toBeLessThan(0.01) - //send back remaining funds - await extension.account.transfer({ - originAccountName: extension.account.accountName1, - recipientAddress: config.destinationAddress!, - tokenName: "Ethereum", - amount: "MAX", + //send back remaining funds + await extension.account.transfer({ + originAccountName: extension.account.accountName1, + recipientAddress: config.destinationAddress!, + token: "ETH", + amount: "MAX", + feeToken, + }) }) - }) - test("User should be able to send funds to starknet id", async ({ - extension, - }) => { - await extension.setupWallet({ accountsToSetup: [{ initialBalance: 0.01 }] }) - const amountTrx = await extension.account.transfer({ - originAccountName: extension.account.accountName1, - recipientAddress: "e2e-test.stark", - tokenName: "Ethereum", - amount: "MAX", + test("User should be able to send funds to starknet id", async ({ + extension, + }) => { + await extension.setupWallet({ + accountsToSetup: [ + { + assets: [ + { token: "ETH", balance: 0.01 }, + { token: "STRK", balance: 0.005 }, + ], + }, + ], + }) + const { sendAmountTX, sendAmountFE } = await extension.account.transfer({ + originAccountName: extension.account.accountName1, + recipientAddress: "qateste2e.stark", + token: "ETH", + amount: 0.009, + feeToken, + }) + const txHash = await extension.activity.getLastTxHash() + await extension.validateTx({ + txHash: txHash!, + receiver: config.destinationAddress!, + sendAmountFE, + sendAmountTX, + }) }) - const txHash = await extension.activity.getLastTxHash() - await extension.validateTx(txHash!, config.account1Seed3!, amountTrx) }) -}) +} diff --git a/packages/e2e/extension/src/specs/tokens.spec.ts b/packages/e2e/extension/src/specs/tokens.spec.ts new file mode 100644 index 000000000..e164df628 --- /dev/null +++ b/packages/e2e/extension/src/specs/tokens.spec.ts @@ -0,0 +1,64 @@ +import { expect } from "@playwright/test" + +import test from "../test" +import { lang } from "../languages" +import { transferTokens } from "../../../shared/src/assets" + +test.describe("Tokens", () => { + test("Add token, token should only be visible if preference is set", async ({ + extension, + }) => { + await extension.setupWallet({ + accountsToSetup: [{ assets: [{ token: "ETH", balance: 0.0001 }] }], + }) + await extension.page + .getByRole("link", { name: lang.account.newToken }) + .click() + await extension.page + .locator('[name="address"]') + .fill( + "0x05A6B68181bb48501a7A447a3f99936827E41D77114728960f22892F02E24928", + ) + await expect(extension.page.locator('[name="name"]')).toHaveValue("Astraly") + await Promise.all([ + extension.navigation.continueLocator.click(), + extension.page.locator('text="Token added"').click(), + ]) + + await expect(extension.account.token("AST")).toBeHidden() + await extension.navigation.showSettingsLocator.click() + await extension.settings.preferences.click() + await expect(extension.preferences.hideTokensStatus).toBeEnabled() + await extension.preferences.hideTokens.click() + await extension.navigation.backLocator.click() + await extension.navigation.closeLocator.click() + await expect(extension.account.token("AST")).toBeVisible() + }) + + test("Token should be auto discovered", async ({ extension }) => { + const { accountAddresses } = await extension.setupWallet({ + accountsToSetup: [{ assets: [{ token: "ETH", balance: 0.00000001 }] }], + }) + + //ensure that balance is updated + await Promise.all([ + expect(extension.account.currentBalance("ETH")).toHaveText( + "0.00000001 ETH", + ), + expect(extension.account.currentBalance("WBTC")).toBeHidden(), + ]) + + await transferTokens(0.00000002, accountAddresses[0], "WBTC") + + //ensure that balance is updated + await Promise.all([ + expect(extension.account.currentBalance("ETH")).toHaveText( + "0.00000001 ETH", + ), + expect(extension.account.currentBalance("WBTC")).toHaveText( + "0.00000002 WBTC", + { timeout: 180 * 1000 }, + ), + ]) + }) +}) diff --git a/packages/e2e/extension/src/specs/welcome.spec.ts b/packages/e2e/extension/src/specs/welcome.spec.ts index 8a3281582..00d0568cb 100644 --- a/packages/e2e/extension/src/specs/welcome.spec.ts +++ b/packages/e2e/extension/src/specs/welcome.spec.ts @@ -12,26 +12,6 @@ test.describe("Welcome screen", () => { ]) }) - test("Disclaimer - Continue button should only be enabled if both options are accepted", async ({ - extension, - }) => { - await extension.wallet.createNewWallet.click() - await Promise.all([ - expect(extension.wallet.banner2).toBeVisible(), - expect(extension.wallet.disclaimerLostOfFunds).not.toBeChecked(), - expect(extension.wallet.disclaimerAlphaVersion).not.toBeChecked(), - expect(extension.wallet.continue).toBeDisabled(), - ]) - await extension.wallet.disclaimerLostOfFunds.check() - await expect(extension.wallet.continue).toBeDisabled() - - await extension.wallet.disclaimerAlphaVersion.check() - await expect(extension.wallet.continue).toBeEnabled() - - await extension.wallet.disclaimerLostOfFunds.uncheck() - await expect(extension.wallet.continue).toBeDisabled() - }) - test("create new account with success", async ({ extension }) => { await extension.wallet.newWalletOnboarding() await extension.open() diff --git a/packages/e2e/extension/src/test.ts b/packages/e2e/extension/src/test.ts index 1b2bbbba2..db27af857 100644 --- a/packages/e2e/extension/src/test.ts +++ b/packages/e2e/extension/src/test.ts @@ -1,8 +1,11 @@ -import * as fs from "fs" +import { + artifactsDir, + isCI, + isKeepArtifacts, + keepVideos, + saveHtml, +} from "../../shared/cfg/test" import path from "path" -import dotenv from "dotenv" - -dotenv.config() import { ChromiumBrowserContext, Page, @@ -11,23 +14,13 @@ import { test as testBase, } from "@playwright/test" import { v4 as uuid } from "uuid" - -import config from "./config" import type { TestExtensions } from "./fixtures" import ExtensionPage from "./page-objects/ExtensionPage" -const isCI = Boolean(process.env.CI) const isExtensionURL = (url: string) => url.startsWith("chrome-extension://") let browserCtx: ChromiumBrowserContext -const outputFolder = (testInfo: TestInfo) => - testInfo.title.replace(/\s+/g, "_").replace(/\W/g, "") -const artifactFilename = (testInfo: TestInfo) => - `${testInfo.retry}-${testInfo.status}-${pageId++}-${testInfo.workerIndex}` -const keepArtifacts = (testInfo: TestInfo) => - testInfo.config.preserveOutput === "always" || - (testInfo.config.preserveOutput === "failures-only" && - testInfo.status === "failed") || - testInfo.status === "timedOut" +const distDir = path.join(__dirname, "../../../extension/dist/") + const closePages = async (browserContext: ChromiumBrowserContext) => { const pages = browserContext?.pages() || [] for (const page of pages) { @@ -38,31 +31,6 @@ const closePages = async (browserContext: ChromiumBrowserContext) => { } } -const keepHtml = async (testInfo: TestInfo, page: Page) => { - if (keepArtifacts(testInfo)) { - const htmlContent = await page.content() - await fs.promises - .mkdir(path.resolve(config.artifactsDir, outputFolder(testInfo)), { - recursive: true, - }) - .catch((error) => { - console.error(error) - }) - await fs.promises - .writeFile( - path.resolve( - config.artifactsDir, - outputFolder(testInfo), - `${artifactFilename(testInfo)}.html`, - ), - htmlContent, - ) - .catch((error) => { - console.error(error) - }) - } -} - const createBrowserContext = () => { const userDataDir = `/tmp/test-user-data-${uuid()}` return chromium.launchPersistentContext(userDataDir, { @@ -71,57 +39,23 @@ const createBrowserContext = () => { `${isCI ? "--headless=new" : ""}`, "--disable-dev-shm-usage", "--ipc=host", - `--disable-extensions-except=${config.distDir}`, - `--load-extension=${config.distDir}`, + `--disable-extensions-except=${distDir}`, + `--load-extension=${distDir}`, "--disable-gpu", ], ignoreDefaultArgs: ["--disable-component-extensions-with-background-pages"], recordVideo: { - dir: config.artifactsDir, + dir: artifactsDir, size: { width: 800, - height: 600, + height: 700, }, }, }) } -const initBrowserWithExtension = async (testInfo: TestInfo) => { +const initBrowserWithExtension = async () => { const browserContext = await createBrowserContext() - // save video - browserContext.on("page", async (page) => { - page.on("load", async (page) => { - try { - await page.title() - } catch (err) { - console.warn(err) - } - }) - - page.on("close", async (page) => { - if (keepArtifacts(testInfo)) { - await page - .video() - ?.saveAs( - path.resolve( - config.artifactsDir, - outputFolder(testInfo), - `${artifactFilename(testInfo)}.webm`, - ), - ) - .catch((error) => { - console.error(error) - }) - } - await page - .video() - ?.delete() - .catch((error) => { - console.error(error) - }) - }) - }) - await browserContext.addInitScript("window.PLAYWRIGHT = true;") await browserContext.addInitScript(() => { window.localStorage.setItem( @@ -130,7 +64,7 @@ const initBrowserWithExtension = async (testInfo: TestInfo) => { ) }) - let page = browserContext.pages()[0] + let page: Page = browserContext.pages()[0] await page.bringToFront() await page.goto("chrome://inspect/#extensions") @@ -145,7 +79,9 @@ const initBrowserWithExtension = async (testInfo: TestInfo) => { await page.waitForTimeout(500) const pages = browserContext.pages() - const extPage = pages.find((x) => x.url() === extensionURL) + const extPage = pages.find( + (x: { url: () => string }) => x.url() === extensionURL, + ) if (extPage) { page = extPage } @@ -154,34 +90,26 @@ const initBrowserWithExtension = async (testInfo: TestInfo) => { } await page.emulateMedia({ reducedMotion: "reduce" }) - return { browserContext, extensionURL, page } } -//delete videos related with chrome://extensions/ page -function cleanArtifactDir() { - try { - fs.readdirSync(config.artifactsDir) - .filter((f) => f.endsWith("webm")) - .forEach((fileToDelete) => - fs.rmSync(`${config.artifactsDir}/${fileToDelete}`), - ) - } catch (error) { - console.log(error) - } -} - -function createExtension() { +function createExtension(label: string) { return async ({}, use: any, testInfo: TestInfo) => { const { browserContext, page, extensionURL } = - await initBrowserWithExtension(testInfo) + await initBrowserWithExtension() + const extension = new ExtensionPage(page, extensionURL) await closePages(browserContext) browserCtx = browserContext await use(extension) - await keepHtml(testInfo, page) - await browserContext.close() - cleanArtifactDir() + const keepArtifacts = isKeepArtifacts(testInfo) + if (keepArtifacts) { + await saveHtml(testInfo, page, label) + await browserContext.close() + await keepVideos(testInfo, page, label) + } else { + await browserContext.close() + } } } @@ -191,10 +119,9 @@ function getContext() { } } -let pageId = 0 const test = testBase.extend({ - extension: createExtension(), - secondExtension: createExtension(), + extension: createExtension("extension"), + secondExtension: createExtension("secondExtension"), browserContext: getContext(), }) diff --git a/packages/e2e/extension/src/utils/account.ts b/packages/e2e/extension/src/utils/account.ts deleted file mode 100644 index c8e87c485..000000000 --- a/packages/e2e/extension/src/utils/account.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { - Account, - uint256, - num, - TransactionExecutionStatus, - RpcProvider, - constants, -} from "starknet" -import { bigDecimal, isEqualAddress } from "@argent/shared" -import { getBatchProvider } from "@argent/x-multicall" -import config from "../config" -import { expect } from "@playwright/test" - -export interface AccountsToSetup { - initialBalance: number - deploy?: boolean -} - -console.log( - "Creating RPC provider with url", - process.env.ARGENT_TESTNET_RPC_URL, -) -if (!process.env.ARGENT_TESTNET_RPC_URL) { - throw new Error("Missing ARGENT_TESTNET_RPC_URL env variable") -} - -const provider = new RpcProvider({ - nodeUrl: process.env.ARGENT_TESTNET_RPC_URL, - chainId: constants.StarknetChainId.SN_GOERLI, - headers: { - "argent-version": process.env.VERSION || "Unknown version", - "argent-client": "argent-x", - }, -}) -const tnkETH = - "0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7" // address of ETH - -const maxRetries = 4 - -const formatAmount = (amount: string) => { - return parseInt(amount, 16) / Math.pow(10, 18) -} - -export async function transferEth(amount: string, to: string) { - console.log( - "########################### transferEth ##################################", - ) - const account = new Account( - provider, - config.senderAddr!, - config.senderKey!, - "1", - ) - const initialBalance = await balanceEther(account.address) - const initialBalanceFormatted = parseFloat(initialBalance) * Math.pow(10, 18) - const { low, high } = uint256.bnToUint256(amount) - - if (initialBalanceFormatted < parseInt(amount)) { - throw `Failed to tranfer: Not enought balance ${initialBalanceFormatted} < ${amount}` - } - let placeTXAttempt = 0 - let txHash - while (placeTXAttempt < maxRetries) { - /** timemout if we don't receive a valid execution response */ - const placeTXTimeout = setTimeout(() => { - throw new Error("Place tx timed out") - }, 30 * 1000) /** 30 seconds */ - try { - placeTXAttempt++ - const tx = await account.execute({ - contractAddress: tnkETH, - entrypoint: "transfer", - calldata: [to, low, high], - }) - txHash = tx.transaction_hash - const txStatusResponse = await provider.waitForTransaction( - tx.transaction_hash, - ) - if ( - txStatusResponse && - "execution_status" in txStatusResponse && - txStatusResponse.execution_status === - TransactionExecutionStatus.SUCCEEDED - ) { - console.log( - `[TX execution_status ${TransactionExecutionStatus.SUCCEEDED}] ${config.starkscanTestNetUrl}/tx/${tx.transaction_hash}`, - ) - return tx.transaction_hash - } - const elements = [ - `[Failed to place TX] ${config.starkscanTestNetUrl}/tx/${tx.transaction_hash}`, - ] - if (txStatusResponse) { - if ("execution_status" in txStatusResponse) { - elements.push( - `execution_status: ${txStatusResponse.execution_status}`, - ) - } - if ("revert_reason" in txStatusResponse) { - elements.push(`revert_reason: ${txStatusResponse.revert_reason}`) - } - elements.push(`status: ${txStatusResponse.execution_status}`) - } else { - elements.push("unable to get tx status response") - } - const message = elements.join(", ") - console.error(message) - } catch (e) { - //for debug only - console.log(`Exception: ${txHash}`, e) - } finally { - clearTimeout(placeTXTimeout) - } - console.warn("Transfer failed, going to try again ") - } -} - -export async function balanceEther(accountAddress: string) { - const balanceOfCall = { - contractAddress: tnkETH, - entrypoint: "balanceOf", - calldata: [accountAddress], - } - - const multicall = getBatchProvider(provider) - const { result } = await multicall.callContract(balanceOfCall) - const [low, high] = result - const balance = bigDecimal.formatEther(uint256.uint256ToBN({ low, high })) - return parseFloat(balance).toFixed(4) -} - -export async function validateTx( - txHash: string, - reciever: string, - amount?: number, -) { - console.log("txHash:", txHash) - await provider.waitForTransaction(txHash) - const txData = await provider.getTransaction(txHash) - if (!("calldata" in txData)) { - throw new Error(`Invalid transaction data: ${JSON.stringify(txData)}`) - } - const accAdd = txData.calldata[4].toString() - expect(isEqualAddress(accAdd, reciever)).toBe(true) - if (amount) { - expect(formatAmount(txData.calldata[5].toString())).toBe(amount) - } -} diff --git a/packages/e2e/merge-reports.config.js b/packages/e2e/merge-reports.config.js new file mode 100644 index 000000000..c535276af --- /dev/null +++ b/packages/e2e/merge-reports.config.js @@ -0,0 +1,4 @@ +export default { + testDir: "./all-blob-reports", + reporter: [["html", { open: "never" }]], +} diff --git a/packages/e2e/package.json b/packages/e2e/package.json index 81179d70a..40bd4e31d 100644 --- a/packages/e2e/package.json +++ b/packages/e2e/package.json @@ -6,12 +6,11 @@ "license": "MIT", "devDependencies": { "@argent/shared": "workspace:^", - "@argent/x-multicall": "^7.0.12", - "@playwright/test": "^1.37.1", + "@playwright/test": "^1.40.1", "@types/node": "^20.5.7", "@types/uuid": "^9.0.3", "dotenv": "^16.3.1", - "starknet": "5.24.3", + "starknet": "6.0.0-beta.13", "uuid": "^9.0.0" }, "scripts": { diff --git a/packages/e2e/shared/cfg/global.teardown.ts b/packages/e2e/shared/cfg/global.teardown.ts new file mode 100644 index 000000000..2153e09df --- /dev/null +++ b/packages/e2e/shared/cfg/global.teardown.ts @@ -0,0 +1,16 @@ +import { artifactsDir } from "./test" +import * as fs from "fs" + +export default function cleanArtifactDir() { + console.time("cleanArtifactDir") + try { + fs.readdirSync(artifactsDir) + .filter((f) => f.endsWith("webm")) + .forEach((fileToDelete) => { + fs.rmSync(`${artifactsDir}/${fileToDelete}`) + }) + } catch (error) { + console.error({ op: "cleanArtifactDir", error }) + } + console.timeEnd("cleanArtifactDir") +} diff --git a/packages/e2e/shared/cfg/test.ts b/packages/e2e/shared/cfg/test.ts new file mode 100644 index 000000000..455aaa230 --- /dev/null +++ b/packages/e2e/shared/cfg/test.ts @@ -0,0 +1,74 @@ +import dotenv from "dotenv" +dotenv.config() + +import * as fs from "fs" +import path from "path" + +import { Page, TestInfo } from "@playwright/test" +export const artifactsDir = path.resolve( + __dirname, + "../../artifacts/playwright", +) +export const reportsDir = path.resolve(__dirname, "../../artifacts/reports") +export const isCI = Boolean(process.env.CI) +export const outputFolder = (testInfo: TestInfo) => + testInfo.title.replace(/\s+/g, "_").replace(/\W/g, "") +export const artifactFilename = (testInfo: TestInfo, label: string) => + `${testInfo.retry}-${testInfo.status}-${label}-${testInfo.workerIndex}` +export const isKeepArtifacts = (testInfo: TestInfo) => + testInfo.config.preserveOutput === "always" || + (testInfo.config.preserveOutput === "failures-only" && + testInfo.status === "failed") || + testInfo.status === "timedOut" + +export const artifactSetup = async (testInfo: TestInfo, label: string) => { + await fs.promises + .mkdir(path.resolve(artifactsDir, outputFolder(testInfo)), { + recursive: true, + }) + .catch((error) => { + console.error({ op: "artifactSetup", error }) + }) + return artifactFilename(testInfo, label) +} + +export const saveHtml = async ( + testInfo: TestInfo, + page: Page, + label: string, +) => { + console.log({ + op: "saveHtml", + label, + }) + const fileName = await artifactSetup(testInfo, label) + const htmlContent = await page.content() + await fs.promises + .writeFile( + path.resolve(artifactsDir, outputFolder(testInfo), `${fileName}.html`), + htmlContent, + ) + .catch((error) => { + console.error({ op: "saveHtml", error }) + }) +} + +export const keepVideos = async ( + testInfo: TestInfo, + page: Page, + label: string, +) => { + console.log({ + op: "keepVideos", + label, + }) + const fileName = await artifactSetup(testInfo, label) + await page + .video() + ?.saveAs( + path.resolve(artifactsDir, outputFolder(testInfo), `${fileName}.webm`), + ) + .catch((error) => { + console.error({ op: "keepVideos", error }) + }) +} diff --git a/packages/e2e/extension/src/config.ts b/packages/e2e/shared/config.ts similarity index 64% rename from packages/e2e/extension/src/config.ts rename to packages/e2e/shared/config.ts index ca132f580..8f024471d 100644 --- a/packages/e2e/extension/src/config.ts +++ b/packages/e2e/shared/config.ts @@ -2,28 +2,35 @@ import path from "path" import dotenv from "dotenv" import fs from "fs" -const envPath = path.resolve(__dirname, "../../.env") +const envPath = path.resolve(__dirname, "../.env") if (fs.existsSync(envPath)) { dotenv.config({ path: envPath }) } const config = { password: "MyP@ss3!", - artifactsDir: path.resolve(__dirname, "../../artifacts/playwright"), - reportsDir: path.resolve(__dirname, "../../artifacts/reports"), - distDir: path.join(__dirname, "../../../extension/dist/"), testNetSeed1: process.env.E2E_TESTNET_SEED1, //wallet with 32 deployed accounts testNetSeed2: process.env.E2E_TESTNET_SEED2, //wallet with 1 deployed account testNetSeed3: process.env.E2E_TESTNET_SEED3, //wallet with 1 deployed account - destinationAddress: process.env.E2E_SENDER_ADDRESS, //used as transfers destination - senderAddr: process.env.E2E_SENDER_ADDRESS, senderSeed: process.env.E2E_SENDER_SEED, - senderKey: process.env.E2E_SENDER_PRIVATEKEY, account1Seed2: process.env.E2E_ACCOUNT_1_SEED2, account1Seed3: process.env.E2E_ACCOUNT_1_SEED3, starknetTestNetUrl: process.env.STARKNET_TESTNET_URL, starkscanTestNetUrl: process.env.STARKSCAN_TESTNET_URL, testnetRpcUrl: process.env.ARGENT_TESTNET_RPC_URL, + senderAddrs: process.env.E2E_SENDER_ADDRESSES?.split(","), + senderKeys: process.env.E2E_SENDER_PRIVATEKEYS?.split(","), + destinationAddress: process.env.E2E_SENDER_ADDRESSES?.split(",")[0], //used as transfers destination + spokCampaignName: process.env.E2E_SPOK_CAMPAIGN_NAME, + spokCampaignUrl: process.env.E2E_SPOK_CAMPAIGN_URL, + + //webwallet + validLogin: { + email: "testuser@mail.com", + pin: "1111111", + password: "myNewPass12!", + }, + url: "http://localhost:3005", } // check that no value of config is undefined, otherwise throw error diff --git a/packages/e2e/shared/src/assets.ts b/packages/e2e/shared/src/assets.ts new file mode 100644 index 000000000..30d1f2cc8 --- /dev/null +++ b/packages/e2e/shared/src/assets.ts @@ -0,0 +1,288 @@ +import { + Account, + uint256, + TransactionExecutionStatus, + RpcProvider, + constants, + TransactionFinalityStatus, +} from "starknet" +import { isEqualAddress } from "@argent/shared" +import config from "../config" +import { expect } from "@playwright/test" +import { sleep } from "./common" + +export type TokenSymbol = "ETH" | "WBTC" | "STRK" | "AST" +export type FeeTokens = "ETH" | "STRK" +export interface AccountsToSetup { + assets: { + token: TokenSymbol + balance: number + }[] + deploy?: boolean + feeToken?: FeeTokens +} +const rpcUrl = process.env.ARGENT_TESTNET_RPC_URL +console.log("Creating RPC provider with url", rpcUrl) +if (!rpcUrl) { + throw new Error("Missing ARGENT_TESTNET_RPC_URL env variable") +} +const startScanUrl = config.starkscanTestNetUrl +if (!startScanUrl) { + throw new Error("Missing STARKSCAN_TESTNET_URL env variable") +} +const provider = new RpcProvider({ + nodeUrl: rpcUrl, + chainId: constants.StarknetChainId.SN_GOERLI, + headers: { + "argent-version": process.env.VERSION || "Unknown version", + "argent-client": "argent-x", + }, +}) + +interface TokenInfo { + name: string + address: string + decimals: number +} +const tokenAddresses = new Map() +tokenAddresses.set("ETH", { + name: "Ethereum", + address: "0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + decimals: 18, +}) +tokenAddresses.set("WBTC", { + name: "Wrapped BTC", + address: "0x12d537dc323c439dc65c976fad242d5610d27cfb5f31689a0a319b8be7f3d56", + decimals: 8, +}) +tokenAddresses.set("STRK", { + name: "Starknet Token", + address: "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + decimals: 18, +}) +tokenAddresses.set("AST", { + name: "Astraly", + address: "0x05A6B68181bb48501a7A447a3f99936827E41D77114728960f22892F02E24928", + decimals: 18, +}) + +export const getTokenInfo = (tkn: string) => { + const tokenInfo = tokenAddresses.get(tkn) + if (!tokenInfo) { + throw new Error(`Invalid token: ${tkn}`) + } + return tokenInfo +} + +const maxRetries = 4 + +const formatAmount = (amount: string) => { + return parseInt(amount, 16) +} + +export const formatAmountBase18 = (amount: number) => { + return amount * Math.pow(10, 18) +} + +const getAccount = async (amount: string, token: TokenSymbol) => { + const log: string[] = [] + const maxAttempts = 5 + let i = 0 + while (i < maxAttempts) { + i++ + const randomAccountPosition = Math.floor( + Math.random() * config.senderKeys!.length, + ) + const acc = new Account( + provider, + config.senderAddrs![randomAccountPosition], + config.senderKeys![randomAccountPosition], + "1", + ) + + const initialBalance = await getBalance(acc.address, token) + const initialBalanceFormatted = + parseFloat(initialBalance) * Math.pow(10, 18) + if (initialBalanceFormatted < parseInt(amount)) { + log.push( + `${ + config.senderAddrs![randomAccountPosition] + } Not enough balance ${initialBalanceFormatted} < ${amount}`, + ) + } else { + console.log({ + op: "getAccount", + randomAccountPosition, + address: acc.address, + balance: initialBalance, + }) + return acc + } + } + console.error(log.join("\n")) + throw new Error("No account with enough balance") +} + +const isTXProcessed = async (txHash: string) => { + let txProcessed = false + let txAcceptedRetries = 10 + let txStatusResponse + while (!txProcessed && txAcceptedRetries > 0) { + txAcceptedRetries-- + txStatusResponse = await provider.getTransactionStatus(txHash) + if ( + txStatusResponse.finality_status === + TransactionFinalityStatus.ACCEPTED_ON_L2 || + txStatusResponse.finality_status === + TransactionFinalityStatus.ACCEPTED_ON_L1 + ) { + txProcessed = true + } else { + await sleep(2 * 1000) + } + } + if (!txProcessed) { + console.error("txStatusResponse", txStatusResponse) + } + return { txProcessed, txStatusResponse } +} + +const getTXData = async (txHash: string) => { + const isProcessed = await isTXProcessed(txHash) + if (!isProcessed) { + throw new Error(`Transaction not processed: ${txHash}`) + } + let nodeUpdated = false + let txAcceptedRetries = 10 + let txData + while (!nodeUpdated && txAcceptedRetries > 0) { + txAcceptedRetries-- + txData = await provider.getTransactionByHash(txHash) + if (txData.type) { + nodeUpdated = true + } else { + await sleep(2 * 1000) + } + } + if (!nodeUpdated) { + console.error("txData", txData) + } + return { nodeUpdated, txData } +} +export async function transferTokens( + amount: number, + to: string, + token: TokenSymbol = "ETH", +) { + const tokenInfo = getTokenInfo(token) + const amountToTransfer = `${amount * Math.pow(10, tokenInfo.decimals)}` + console.log({ op: "transferTokens", amount, amountToTransfer, to, token }) + + const { low, high } = uint256.bnToUint256(amountToTransfer) + let placeTXAttempt = 0 + let txHash: string | null = null + let account + while (placeTXAttempt < maxRetries) { + account = await getAccount(amountToTransfer, token) + /** timeout if we don't receive a valid execution response */ + const placeTXTimeout = setTimeout(() => { + throw new Error(`Place tx timed out: ${txHash}`) + }, 60 * 1000) /** 60 seconds */ + try { + placeTXAttempt++ + const tx = await account.execute({ + contractAddress: tokenInfo.address, + entrypoint: "transfer", + calldata: [to, low, high], + }) + txHash = tx.transaction_hash + const { txProcessed, txStatusResponse } = await isTXProcessed( + tx.transaction_hash, + ) + if (txProcessed) { + console.log({ + TxStatus: TransactionExecutionStatus.SUCCEEDED, + url: `${startScanUrl}/tx/${tx.transaction_hash}`, + }) + return tx.transaction_hash + } + + console.error( + `[Failed to place TX] ${startScanUrl}/tx/${tx.transaction_hash} ${txStatusResponse}`, + ) + } catch (e) { + if (e instanceof Error) { + //for debug only + console.error( + `placeTXAttempt: ${placeTXAttempt}, Exception: ${txHash}`, + e, + ) + } + } finally { + clearTimeout(placeTXTimeout) + } + console.warn("Transfer failed, going to try again ") + } + return null +} + +async function getBalance(accountAddress: string, token: TokenSymbol = "ETH") { + const tokenInfo = getTokenInfo(token) + console.log({ op: "getBalance", accountAddress, token, tokenInfo }) + const balanceOfCall = { + contractAddress: tokenInfo.address, + entrypoint: "balanceOf", + calldata: [accountAddress], + } + const [low] = await provider.callContract(balanceOfCall) + const balance = ( + parseInt(low, 16) / Math.pow(10, tokenInfo.decimals) + ).toFixed(4) + + console.log({ + op: "getBalance", + balance, + formattedBalance: balance, + }) + return balance +} + +export async function validateTx( + txHash: string, + receiver: string, + amount?: number, +) { + const log: string[] = [] + log.push("validateTx txHash:", txHash) + const processed = await isTXProcessed(txHash) + if (!processed) { + throw new Error(`Transaction not processed: ${txHash}`) + } + const { nodeUpdated, txData } = await getTXData(txHash) + if (!nodeUpdated) { + console.error(log.join("\n")) + throw new Error(`Transaction data not found: ${txHash}`) + } + log.push("txData", JSON.stringify(txData)) + if (!("calldata" in txData!)) { + console.error(log.join("\n")) + throw new Error( + `Invalid transaction data: ${txHash}, ${JSON.stringify(txData)}`, + ) + } + const accAdd = txData.calldata[4].toString() + expect(isEqualAddress(accAdd, receiver)).toBe(true) + if (amount) { + expect(formatAmount(txData.calldata[5].toString())).toBe(amount) + } +} + +export function isScientific(num: number) { + const scientificPattern = /(.*)([eE])(.*)$/ + return scientificPattern.test(String(num)) +} + +export function convertScientificToDecimal(num: number) { + const exponent = String(num).split("e")[1] + return Number(num).toFixed(Math.abs(Number(exponent))) +} diff --git a/packages/e2e/extension/src/utils/common.ts b/packages/e2e/shared/src/common.ts similarity index 70% rename from packages/e2e/extension/src/utils/common.ts rename to packages/e2e/shared/src/common.ts index b18c36337..a16f5c800 100644 --- a/packages/e2e/extension/src/utils/common.ts +++ b/packages/e2e/shared/src/common.ts @@ -1,12 +1,15 @@ export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) -export const expireBESession = async (email: string) => { +export const expireBESession = async ( + email: string, + app: "webwallet" | "argentx", +) => { const requestOptions = { method: "GET", } const request = `${ process.env.ARGENT_API_BASE_URL - }/debug/expireCredentials?application=argentx&email=${encodeURIComponent( + }/debug/expireCredentials?application=${app}&email=${encodeURIComponent( email, )}` const response = await fetch(request, requestOptions) diff --git a/packages/e2e/tsconfig.json b/packages/e2e/tsconfig.json index 6a764dd01..4c48caee8 100644 --- a/packages/e2e/tsconfig.json +++ b/packages/e2e/tsconfig.json @@ -14,8 +14,9 @@ "inlineSourceMap": true, "composite": true, "types": ["node"], - "noEmit": true + "noEmit": true, + "skipLibCheck": true }, - "include": ["**/src"], + "include": ["**/src", "**/shared"], "exclude": ["node_modules"] } diff --git a/packages/e2e/webwallet/playwright.config.ts b/packages/e2e/webwallet/playwright.config.ts index d4d2d962c..d8f6c52e5 100644 --- a/packages/e2e/webwallet/playwright.config.ts +++ b/packages/e2e/webwallet/playwright.config.ts @@ -1,7 +1,5 @@ import type { PlaywrightTestConfig } from "@playwright/test" -import config from "./src/config" - -const isCI = Boolean(process.env.CI) +import { artifactsDir, isCI } from "../shared/cfg/test" const playwrightConfig: PlaywrightTestConfig = { projects: [ @@ -17,12 +15,13 @@ const playwrightConfig: PlaywrightTestConfig = { browserName: "firefox", }, }, - { + /*{ name: "WebWallet - WebKit", use: { browserName: "webkit", }, }, + */ ], expect: { timeout: 20 * 1000, // 20 seconds @@ -37,8 +36,9 @@ const playwrightConfig: PlaywrightTestConfig = { reporter: isCI ? [["github"], ["blob"]] : "list", forbidOnly: isCI, - outputDir: config.artifactsDir, + outputDir: artifactsDir, preserveOutput: isCI ? "failures-only" : "never", + globalTeardown: "../shared/cfg/global.teardown.ts", } export default playwrightConfig diff --git a/packages/e2e/webwallet/src/config.ts b/packages/e2e/webwallet/src/config.ts deleted file mode 100644 index 78ace2245..000000000 --- a/packages/e2e/webwallet/src/config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import path from "path" - -export default { - validLogin: { - email: "testuser@mail.com", - pin: "1111111", - password: "myNewPass12!", - }, - url: "http://localhost:3005", - artifactsDir: path.resolve(__dirname, "../../artifacts/playwright"), - reportsDir: path.resolve(__dirname, "../../artifacts/reports"), -} diff --git a/packages/e2e/webwallet/src/fixtures.ts b/packages/e2e/webwallet/src/fixtures.ts index 6a7f1c148..e5e30c187 100644 --- a/packages/e2e/webwallet/src/fixtures.ts +++ b/packages/e2e/webwallet/src/fixtures.ts @@ -1,5 +1,7 @@ +import { BrowserContext } from "@playwright/test" import type WebWalletPage from "./page-objects/WebWalletPage" export interface TestPages { webWallet: WebWalletPage + browserContext: BrowserContext } diff --git a/packages/e2e/webwallet/src/page-objects/Dapps.ts b/packages/e2e/webwallet/src/page-objects/Dapps.ts new file mode 100644 index 000000000..f858e0413 --- /dev/null +++ b/packages/e2e/webwallet/src/page-objects/Dapps.ts @@ -0,0 +1,67 @@ +import { ChromiumBrowserContext, Page, expect } from "@playwright/test" +import { ICredentials } from "./Login" +import Navigation from "./Navigation" + +type DappUrl = + | "https://goerli.app.starknet.id" + | "https://dapp-argentlabs.vercel.app" + | "https://starknetkit-blacked-listed.vercel.app" + +export default class Dapps extends Navigation { + constructor(page: Page) { + super(page) + } + + async requestConnectionFromDapp({ + browserContext, + dappUrl, + credentials, + newAccount = false, + }: { + browserContext: ChromiumBrowserContext + dappUrl: DappUrl + credentials: ICredentials + newAccount: boolean + }) { + //open dapp page + const dapp = await browserContext.newPage() + await dapp.setViewportSize({ width: 1080, height: 720 }) + await dapp.goto(dappUrl) + + if ( + dappUrl === "https://dapp-argentlabs.vercel.app" || + dappUrl === "https://starknetkit-blacked-listed.vercel.app" + ) { + await expect(dapp.locator('button:has-text("Connect")')).toHaveCount(1) + await dapp.locator('button:has-text("Connect")').first().click() + } else { + await expect(dapp.locator("text=CONNECT ARGENT")).toBeVisible() + await dapp.locator("text=CONNECT ARGENT").click() + } + + const popupPromise = dapp.waitForEvent("popup") + await expect(dapp.locator("p:text-is('Email')")).toBeVisible() + await dapp.locator("p:text-is('Email')").click() + const popup = await popupPromise + // Wait for the popup to load. + await popup.waitForLoadState() + + await popup.locator("[name=email]").fill(credentials.email) + await popup.locator('button[type="submit"]').click() + await popup.locator('[id^="pin-input"]').first().click() + await popup.locator('[id^="pin-input"]').first().fill(credentials.pin) + if (newAccount) { + await popup.locator("[name=password]").fill(credentials.password) + await popup.locator("[name=repeatPassword]").fill(credentials.password) + } else { + await popup.locator("[name=password]").fill(credentials.password) + } + await popup.locator('button[type="submit"]').click() + await popup.waitForLoadState() + await expect( + popup.locator(`p:text-is("${credentials.email}")`), + ).toBeVisible() + await popup.locator('button[type="submit"]').click() + return dapp + } +} diff --git a/packages/e2e/webwallet/src/page-objects/Login.ts b/packages/e2e/webwallet/src/page-objects/Login.ts index b2f0ae1df..f73257405 100644 --- a/packages/e2e/webwallet/src/page-objects/Login.ts +++ b/packages/e2e/webwallet/src/page-objects/Login.ts @@ -1,9 +1,9 @@ import { Page, expect } from "@playwright/test" -import config from "../config" +import config from "../../../shared/config" import Navigation from "./Navigation" -interface ICredentials { +export interface ICredentials { email: string pin: string password: string @@ -25,7 +25,9 @@ export default class Login extends Navigation { get password() { return this.page.locator("input[name=password]") } - + get repeatPassword() { + return this.page.locator("input[name=repeatPassword]") + } get wrongPassword() { return this.page.locator( '//input[@name="password"][@aria-invalid="true"]/following::label[contains(text(), "Wrong password")]', @@ -63,4 +65,17 @@ export default class Login extends Navigation { ]) await expect(this.lock).toBeVisible() } + + async createWallet(credentials: ICredentials) { + await this.email.fill(credentials.email) + //await this.continue.click() + await this.fillPin(credentials.pin) + await this.password.fill(credentials.password) + await this.repeatPassword.fill(credentials.password) + await Promise.all([ + this.page.waitForURL(`${config.url}/dashboard`), + this.continue.click(), + ]) + await expect(this.lock).toBeVisible() + } } diff --git a/packages/e2e/webwallet/src/page-objects/Navigation.ts b/packages/e2e/webwallet/src/page-objects/Navigation.ts index 3937ea355..17b0cf6dd 100644 --- a/packages/e2e/webwallet/src/page-objects/Navigation.ts +++ b/packages/e2e/webwallet/src/page-objects/Navigation.ts @@ -6,6 +6,10 @@ export default class Navigation { this.page = page } + get backupPassword() { + return this.page.locator(`button:text-is("I've backed up my password")`) + } + get continue() { return this.page.locator(`button:text-is("Continue")`) } diff --git a/packages/e2e/webwallet/src/page-objects/WebWalletPage.ts b/packages/e2e/webwallet/src/page-objects/WebWalletPage.ts index 3ddf03b3b..d0765254c 100644 --- a/packages/e2e/webwallet/src/page-objects/WebWalletPage.ts +++ b/packages/e2e/webwallet/src/page-objects/WebWalletPage.ts @@ -1,21 +1,29 @@ import type { Page } from "@playwright/test" +import { v4 as uuid } from "uuid" -import config from "../config" +import config from "../../../shared/config" import Login from "./Login" import Navigation from "./Navigation" +export const generateEmail = () => `newWallet_${uuid()}@mail.com` + +import Dapps from "./Dapps" export default class WebWalletPage { page: Page login: Login navigation: Navigation + dapps: Dapps constructor(page: Page) { this.page = page this.login = new Login(page) this.navigation = new Navigation(page) + this.dapps = new Dapps(page) } open() { return this.page.goto(config.url) } + + generateEmail = () => `e2e_webwallet_${uuid()}@mail.com` } diff --git a/packages/e2e/webwallet/src/specs/dapps.spec.ts b/packages/e2e/webwallet/src/specs/dapps.spec.ts new file mode 100644 index 000000000..316a16727 --- /dev/null +++ b/packages/e2e/webwallet/src/specs/dapps.spec.ts @@ -0,0 +1,30 @@ +import test from "../test" +import config from "../../../shared/config" + +test.describe(`Dapps`, () => { + test("Create wallet from Dapp", async ({ webWallet, browserContext }) => { + const email = webWallet.generateEmail() + const credentials = { + email, + pin: config.validLogin.pin, + password: config.validLogin.password, + } + + await webWallet.dapps.requestConnectionFromDapp({ + browserContext, + dappUrl: "https://dapp-argentlabs.vercel.app", + credentials, + newAccount: true, + }) + await webWallet.login.success(credentials) + }) + + test("Connect from Dapp", async ({ webWallet, browserContext }) => { + await webWallet.dapps.requestConnectionFromDapp({ + browserContext, + dappUrl: "https://dapp-argentlabs.vercel.app", + credentials: config.validLogin, + newAccount: false, + }) + }) +}) diff --git a/packages/e2e/webwallet/src/specs/login.spec.ts b/packages/e2e/webwallet/src/specs/login.spec.ts index a14ccfa0c..86a3e9ce2 100644 --- a/packages/e2e/webwallet/src/specs/login.spec.ts +++ b/packages/e2e/webwallet/src/specs/login.spec.ts @@ -1,9 +1,20 @@ import { expect } from "@playwright/test" -import config from "../config" +import config from "../../../shared/config" import test from "../test" +import { generateEmail } from "../page-objects/WebWalletPage" test.describe(`Login page`, () => { + test("create new wallet", async ({ webWallet }) => { + await webWallet.login.createWallet({ + email: generateEmail(), + pin: config.validLogin.pin, + password: config.validLogin.password, + }) + await webWallet.navigation.backupPassword.click() + await expect(webWallet.navigation.backupPassword).not.toBeVisible() + }) + test("can log in", async ({ webWallet }) => { await webWallet.login.success() }) diff --git a/packages/e2e/webwallet/src/test.ts b/packages/e2e/webwallet/src/test.ts index b6753f254..5d2e57b53 100644 --- a/packages/e2e/webwallet/src/test.ts +++ b/packages/e2e/webwallet/src/test.ts @@ -1,48 +1,26 @@ -import * as fs from "fs" -import path from "path" +import { + artifactsDir, + isKeepArtifacts, + keepVideos, + saveHtml, +} from "../../shared/cfg/test" -import { Browser, Page, TestInfo, test as testBase } from "@playwright/test" +import { + BrowserContext, + Browser, + TestInfo, + test as testBase, +} from "@playwright/test" -import config from "./config" +import config from "../../shared/config" import { TestPages } from "./fixtures" import WebWalletPage from "./page-objects/WebWalletPage" -const keepArtifacts = async (testInfo: TestInfo, page: Page) => { - if ( - testInfo.config.preserveOutput === "always" || - (testInfo.config.preserveOutput === "failures-only" && - testInfo.status !== "passed") - ) { - //save HTML - const folder = testInfo.title.replace(/\s+/g, "_").replace(/\W/g, "") - const filename = `${testInfo.retry}-${testInfo.status}-${pageId}-${testInfo.workerIndex}.html` - try { - const htmlContent = await page.content() - await fs.promises - .mkdir(path.resolve(config.artifactsDir, folder), { recursive: true }) - .catch((error) => { - console.error(error) - }) - await fs.promises - .writeFile( - path.resolve(config.artifactsDir, folder, filename), - htmlContent, - ) - .catch((error) => { - console.error(error) - }) - } catch (error) { - console.error("Error while saving HTML content", error) - } - } -} -let pageId = 0 +let browserCtx: BrowserContext async function createContext({ browser, baseURL, - name, - testInfo, }: { browser: Browser baseURL: string @@ -52,54 +30,16 @@ async function createContext({ const context = await browser.newContext({ ignoreHTTPSErrors: true, acceptDownloads: true, - recordVideo: process.env.CI - ? { - dir: config.artifactsDir, - size: { - width: 1366, - height: 768, - }, - } - : undefined, + recordVideo: { + dir: artifactsDir, + size: { + width: 1366, + height: 768, + }, + }, baseURL, viewport: { width: 1366, height: 768 }, }) - context.on("page", async (page) => { - page.on("load", async (page) => { - try { - await page.title() - } catch (err) { - console.warn(err) - } - }) - - page.on("close", async (page) => { - if ( - testInfo.config.preserveOutput === "always" || - (testInfo.config.preserveOutput === "failures-only" && - testInfo.status === "failed") || - testInfo.status === "timedOut" - ) { - const folder = testInfo.title.replace(/\s+/g, "_").replace(/\W/g, "") - const filename = `${testInfo.retry}-${name}-${ - testInfo.status - }-${pageId++}-${testInfo.workerIndex}.webm` - - await page - .video() - ?.saveAs(path.resolve(config.artifactsDir, folder, filename)) - .catch((error) => { - console.error(error) - }) - } - page - .video() - ?.delete() - .catch((error) => { - console.error(error) - }) - }) - }) await context.addInitScript("window.PLAYWRIGHT = true;") return context @@ -123,13 +63,27 @@ function createPage() { const webWalletPage = new WebWalletPage(page) await webWalletPage.open() - await keepArtifacts(testInfo, page) + browserCtx = context await use(webWalletPage) - await context.close() + const keepArtifacts = isKeepArtifacts(testInfo) + if (keepArtifacts) { + await saveHtml(testInfo, page, "WebWallet") + await context.close() + await keepVideos(testInfo, page, "WebWallet") + } else { + await context.close() + } } } +function getContext() { + return async ({}, use: any, _testInfo: TestInfo) => { + await use(browserCtx) + } +} + const test = testBase.extend({ webWallet: createPage(), + browserContext: getContext(), }) export default test diff --git a/packages/extension/.env.example b/packages/extension/.env.example index 4cae5919e..2f96e0482 100644 --- a/packages/extension/.env.example +++ b/packages/extension/.env.example @@ -3,8 +3,10 @@ SENTRY_AUTH_TOKEN= RAMP_API_KEY= ARGENT_API_BASE_URL= ARGENT_X_STATUS_URL= +ARGENT_X_NEWS_URL= ARGENT_X_ENVIRONMENT= ARGENT_TESTNET_RPC_URL= +NEW_CAIRO_0_ENABLED= # difference between commented and not commented variables is that the release CI will throw when a not commented env var is missing # this is used to ensure the ci release has everything we expect it to have without doing explicit testing of the result @@ -25,4 +27,5 @@ ARGENT_TESTNET_RPC_URL= FAST=20 MEDIUM=60 SLOW=60*5 -VERY_SLOW=24*60*60 \ No newline at end of file +VERY_SLOW=24*60*60 +ARGENT_HEALTHCHECK_BASE_URL=https://healthcheck.hydrogen.argent47.net \ No newline at end of file diff --git a/packages/extension/CHANGELOG.md b/packages/extension/CHANGELOG.md index 085fcb817..3bbca7b10 100644 --- a/packages/extension/CHANGELOG.md +++ b/packages/extension/CHANGELOG.md @@ -1,5 +1,35 @@ # @argent-x/extension +## 6.13.4 + +### Patch Changes + +- e871c0954: Release + +## 6.13.3 + +### Patch Changes + +- 6b1326d64: Release + +## 6.13.2 + +### Patch Changes + +- 0e774f90f: Release + +## 6.13.1 + +### Patch Changes + +- d89a36f73: Release + +## 6.13.0 + +### Minor Changes + +- 914e376fb: Release + ## 6.12.8 ### Patch Changes diff --git a/packages/extension/manifest/v2.json b/packages/extension/manifest/v2.json index 0a3b2b561..aded2d052 100644 --- a/packages/extension/manifest/v2.json +++ b/packages/extension/manifest/v2.json @@ -1,8 +1,8 @@ { "$schema": "https://json.schemastore.org/chrome-manifest.json", - "name": "Argent X", - "description": "The security of Ethereum with the scale of StarkNet", - "version": "5.12.8", + "name": "Argent X - Starknet Wallet", + "description": "7 out of 10 Starknet users choose Argent X as their Starknet wallet. Join 2m+ Argent users now.", + "version": "5.13.4", "manifest_version": 2, "browser_action": { "default_icon": { diff --git a/packages/extension/manifest/v3.json b/packages/extension/manifest/v3.json index c709d536a..ddec98bfc 100644 --- a/packages/extension/manifest/v3.json +++ b/packages/extension/manifest/v3.json @@ -1,8 +1,8 @@ { "$schema": "https://json.schemastore.org/chrome-manifest.json", - "name": "Argent X", - "description": "The security of Ethereum with the scale of StarkNet", - "version": "5.12.8", + "name": "Argent X - Starknet Wallet", + "description": "7 out of 10 Starknet users choose Argent X as their Starknet wallet. Join 2m+ Argent users now.", + "version": "5.13.4", "manifest_version": 3, "action": { "default_icon": { diff --git a/packages/extension/package.json b/packages/extension/package.json index 39f1901a5..c788c90ea 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -1,6 +1,6 @@ { "name": "@argent-x/extension", - "version": "6.12.8", + "version": "6.13.4", "main": "index.js", "private": true, "license": "MIT", @@ -10,7 +10,7 @@ "@testing-library/jest-dom": "^6.0.0", "@testing-library/react": "^14.0.0", "@types/async-retry": "^1.4.5", - "@types/chrome": "^0.0.254", + "@types/chrome": "^0.0.256", "@types/fs-extra": "^11.0.1", "@types/lodash-es": "^4.17.6", "@types/object-hash": "^3.0.2", @@ -25,13 +25,13 @@ "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", "@vitejs/plugin-react-swc": "^3.3.1", - "@vitest/browser": "1.0.2", + "@vitest/browser": "1.2.0", "@vitest/coverage-istanbul": "^1.0.0", - "@vitest/coverage-v8": "1.0.2", - "@vitest/ui": "1.0.2", + "@vitest/coverage-v8": "1.2.0", + "@vitest/ui": "1.2.0", "chokidar": "^3.5.2", "concurrently": "^8.0.1", - "copy-webpack-plugin": "^11.0.0", + "copy-webpack-plugin": "^12.0.0", "cross-fetch": "^4.0.0", "dotenv": "^16.1.4", "dotenv-webpack": "^8.0.0", @@ -43,7 +43,7 @@ "file-loader": "^6.2.0", "fork-ts-checker-webpack-plugin": "^9.0.0", "fs-extra": "^11.1.1", - "happy-dom": "^12.10.3", + "happy-dom": "^13.0.0", "html-webpack-plugin": "^5.5.0", "isomorphic-fetch": "^3.0.0", "minimatch": "^9.0.1", @@ -55,7 +55,7 @@ "typescript-styled-plugin": "^0.18.2", "url-loader": "^4.1.1", "vite": "^5.0.0", - "vitest": "1.0.2", + "vitest": "1.2.0", "wait-for-expect": "^3.0.2", "webpack": "^5.88.0", "webpack-cli": "^5.1.1", @@ -127,7 +127,8 @@ "react-select": "^5.4.0", "react-textarea-autosize": "^8.3.4", "semver": "^7.5.2", - "starknet": "5.24.3", + "starknet": "5.25.0", + "starknet6": "npm:starknet@6.0.0-beta.11", "starknet4": "npm:starknet@4.22.0", "starknet4-deprecated": "npm:starknet@4.4.0", "styled-components": "^5.3.5", diff --git a/packages/extension/scripts/export.ts b/packages/extension/scripts/export.ts index 35df97c5c..2994b99d6 100644 --- a/packages/extension/scripts/export.ts +++ b/packages/extension/scripts/export.ts @@ -36,8 +36,6 @@ const exclude = [ "**.log**", "**/node_modules**", "packages/dapp**", - "packages/get-starknet**", - "packages/starknet-react-webwallet-connector**", "packages/web**", ] diff --git a/packages/extension/src/assets/default-tokens.json b/packages/extension/src/assets/default-tokens.json index 90aee589b..1c3db1657 100644 --- a/packages/extension/src/assets/default-tokens.json +++ b/packages/extension/src/assets/default-tokens.json @@ -19,6 +19,17 @@ "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", "showAlways": true }, + + { + "id": 1, + "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "name": "Ether", + "symbol": "ETH", + "decimals": "18", + "network": "sepolia-alpha", + "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", + "showAlways": true + }, { "id": 1, "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", @@ -42,10 +53,41 @@ { "id": 2, "address": "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", - "name": "Stark", + "name": "Starknet", + "symbol": "STRK", + "decimals": "18", + "network": "mainnet-alpha", + "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/strk.png", + "showAlways": true + }, + { + "id": 2, + "address": "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + "name": "Starknet", "symbol": "STRK", "decimals": "18", "network": "integration", + "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/strk.png", + "showAlways": true + }, + { + "id": 2, + "address": "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + "name": "Starknet", + "symbol": "STRK", + "decimals": "18", + "network": "goerli-alpha", + "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/strk.png", + "showAlways": true + }, + { + "id": 2, + "address": "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + "name": "Starknet", + "symbol": "STRK", + "decimals": "18", + "network": "sepolia-alpha", + "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/strk.png", "showAlways": true }, { @@ -66,6 +108,15 @@ "network": "goerli-alpha", "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/dai.png" }, + { + "id": 6, + "address": "0x03e85bfbb8e2a42b7bead9e88e9a1b19dbccf661471061807292120462396ec9", + "name": "DAI", + "symbol": "DAI", + "decimals": "18", + "network": "sepolia-alpha", + "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/dai.png" + }, { "id": 4, "address": "0x03fe2b97c1fd336e750087d68b9b867997fd64a2661ff3ca5a7c771641e8e7ac", @@ -84,6 +135,15 @@ "network": "goerli-alpha", "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/wbtc.png" }, + { + "id": 4, + "address": "0x12d537dc323c439dc65c976fad242d5610d27cfb5f31689a0a319b8be7f3d56", + "name": "Wrapped BTC", + "symbol": "WBTC", + "decimals": "8", + "network": "sepolia-alpha", + "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/wbtc.png" + }, { "id": 2, "address": "0x053c91253bc9682c04929ca02ed00b3e423f6710d2ee7e0d5ebb06f3ecf368a8", @@ -102,6 +162,15 @@ "network": "goerli-alpha", "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/usdc.png" }, + { + "id": 2, + "address": "0x005a643907b9a4bc6a55e9069c4fd5fd1f5c79a22470690f75556c4736e34426", + "name": "USD Coin", + "symbol": "USDC", + "decimals": "6", + "network": "sepolia-alpha", + "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/usdc.png" + }, { "id": 3, "address": "0x068f5c6a61780768455de69077e07e89787839bf8166decfbf92b645209c0fb8", @@ -119,5 +188,14 @@ "decimals": "6", "network": "goerli-alpha", "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/usdt.png" + }, + { + "id": 3, + "address": "0x386e8d061177f19b3b485c20e31137e6f6bc497cc635ccdfcab96fadf5add6a", + "name": "Tether USD", + "symbol": "USDT", + "decimals": "6", + "network": "sepolia-alpha", + "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/usdt.png" } ] diff --git a/packages/extension/src/assets/vSTRK.json b/packages/extension/src/assets/vSTRK.json new file mode 100644 index 000000000..87b58286d --- /dev/null +++ b/packages/extension/src/assets/vSTRK.json @@ -0,0 +1,8 @@ +{ + "address": "0x01a881a75bb478cedfd4d3ea19d2a4564350d78ea463a5287833526a416d5e31", + "name": "vSTRK", + "symbol": "STRK", + "decimals": 18, + "network": "mainnet-alpha", + "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/strk.png" +} diff --git a/packages/extension/src/background/__new/procedures/accountMessaging/cancelEscape.ts b/packages/extension/src/background/__new/procedures/accountMessaging/cancelEscape.ts index 29e18fcfa..d56f24fe0 100644 --- a/packages/extension/src/background/__new/procedures/accountMessaging/cancelEscape.ts +++ b/packages/extension/src/background/__new/procedures/accountMessaging/cancelEscape.ts @@ -2,7 +2,6 @@ import { z } from "zod" import { extensionOnlyProcedure } from "../permissions" import { baseWalletAccountSchema } from "../../../../shared/wallet.model" -import { Account } from "starknet" import { getEntryPointSafe } from "../../../../shared/utils/transactions" import { AccountMessagingError } from "../../../../shared/errors/accountMessaging" @@ -20,8 +19,8 @@ export const cancelEscapeProcedure = extensionOnlyProcedure }, }) => { try { - const starknetAccount = - (await wallet.getSelectedStarknetAccount()) as Account // Old accounts are not supported + const starknetAccount = await wallet.getSelectedStarknetAccount() + await actionService.add( { type: "TRANSACTION", diff --git a/packages/extension/src/background/__new/procedures/accountMessaging/changeGuardian.ts b/packages/extension/src/background/__new/procedures/accountMessaging/changeGuardian.ts index bc127e5f2..2d2adc420 100644 --- a/packages/extension/src/background/__new/procedures/accountMessaging/changeGuardian.ts +++ b/packages/extension/src/background/__new/procedures/accountMessaging/changeGuardian.ts @@ -2,7 +2,7 @@ import { z } from "zod" import { extensionOnlyProcedure } from "../permissions" import { baseWalletAccountSchema } from "../../../../shared/wallet.model" -import { constants, num, Account } from "starknet" +import { constants, num } from "starknet" import { getEntryPointSafe } from "../../../../shared/utils/transactions" import { AccountMessagingError } from "../../../../shared/errors/accountMessaging" import { changeGuardianCalldataSchema } from "@argent/shared" @@ -23,8 +23,8 @@ export const changeGuardianProcedure = extensionOnlyProcedure }) => { try { const newGuardian = num.hexToDecimalString(guardian) - const starknetAccount = - (await wallet.getSelectedStarknetAccount()) as Account // Old accounts are not supported + const starknetAccount = await wallet.getSelectedStarknetAccount() + const isRemoveGuardian = num.toBigInt(newGuardian) === constants.ZERO await actionService.add( { @@ -48,7 +48,6 @@ export const changeGuardianProcedure = extensionOnlyProcedure }, }, { - origin, title: isRemoveGuardian ? "Remove Argent Shield" : "Add Argent Shield", diff --git a/packages/extension/src/background/__new/procedures/accountMessaging/escapeAndChangeGuardian.ts b/packages/extension/src/background/__new/procedures/accountMessaging/escapeAndChangeGuardian.ts index e76542c84..51a931b7c 100644 --- a/packages/extension/src/background/__new/procedures/accountMessaging/escapeAndChangeGuardian.ts +++ b/packages/extension/src/background/__new/procedures/accountMessaging/escapeAndChangeGuardian.ts @@ -2,7 +2,7 @@ import { z } from "zod" import { extensionOnlyProcedure } from "../permissions" import { baseWalletAccountSchema } from "../../../../shared/wallet.model" -import { constants, num, Account } from "starknet" +import { constants, num } from "starknet" import { getEntryPointSafe } from "../../../../shared/utils/transactions" import { AccountMessagingError } from "../../../../shared/errors/accountMessaging" import { AccountError } from "../../../../shared/errors/account" @@ -34,8 +34,7 @@ export const escapeAndChangeGuardianProcedure = extensionOnlyProcedure */ const selectedAccount = await wallet.getAccount(account) - const starknetAccount = - (await wallet.getSelectedStarknetAccount()) as Account // Old accounts are not supported + const starknetAccount = await wallet.getSelectedStarknetAccount() if (!selectedAccount) { throw new AccountError({ diff --git a/packages/extension/src/background/__new/procedures/accountMessaging/getAccountDeploymentPayload.ts b/packages/extension/src/background/__new/procedures/accountMessaging/getAccountDeploymentPayload.ts index 5236a326c..39dc2a950 100644 --- a/packages/extension/src/background/__new/procedures/accountMessaging/getAccountDeploymentPayload.ts +++ b/packages/extension/src/background/__new/procedures/accountMessaging/getAccountDeploymentPayload.ts @@ -1,7 +1,10 @@ import { z } from "zod" import { connectedDappsProcedure } from "../permissions" -import { baseWalletAccountSchema } from "../../../../shared/wallet.model" +import { + baseWalletAccountSchema, + cairoVersionSchema, +} from "../../../../shared/wallet.model" import { AccountMessagingError } from "../../../../shared/errors/accountMessaging" import { SessionError } from "../../../../shared/errors/session" import { AccountError } from "../../../../shared/errors/account" @@ -17,12 +20,15 @@ const getAccountDeploymentPayloadInputSchema = z }) .optional() -const deployAccountContractSchema = z.object({ - classHash: z.string(), - constructorCalldata: rawArgsSchema, - addressSalt: bigNumberishSchema.optional(), - contractAddress: addressSchema.optional(), -}) +const deployAccountContractSchema = z + .object({ + classHash: z.string(), + constructorCalldata: rawArgsSchema, + addressSalt: bigNumberishSchema.optional(), + contractAddress: addressSchema.optional(), + version: cairoVersionSchema.optional(), + }) + .or(z.null()) export const getAccountDeploymentPayloadProcedure = connectedDappsProcedure .input(getAccountDeploymentPayloadInputSchema) @@ -32,6 +38,7 @@ export const getAccountDeploymentPayloadProcedure = connectedDappsProcedure input, ctx: { services: { wallet }, + senderType, }, }) => { if (!(await wallet.isSessionOpen())) { @@ -49,11 +56,15 @@ export const getAccountDeploymentPayloadProcedure = connectedDappsProcedure }) } + if (senderType !== "extension" && walletAccount.needsDeploy === false) { + return null + } + return await wallet.getAccountDeploymentPayload(walletAccount) } catch (e) { throw new AccountMessagingError({ options: { error: e }, - code: "GET_ENCRYPTED_KEY_FAILED", + code: "ACCOUNT_DEPLOYMENT_PAYLOAD_FAILED", }) } }, diff --git a/packages/extension/src/background/__new/procedures/accountMessaging/triggerEscapeGuardian.ts b/packages/extension/src/background/__new/procedures/accountMessaging/triggerEscapeGuardian.ts index 9641c5509..28e93aa5b 100644 --- a/packages/extension/src/background/__new/procedures/accountMessaging/triggerEscapeGuardian.ts +++ b/packages/extension/src/background/__new/procedures/accountMessaging/triggerEscapeGuardian.ts @@ -1,5 +1,4 @@ import { z } from "zod" -import { Account } from "starknet" import { extensionOnlyProcedure } from "../permissions" import { baseWalletAccountSchema } from "../../../../shared/wallet.model" @@ -20,8 +19,8 @@ export const triggerEscapeGuardianProcedure = extensionOnlyProcedure }, }) => { try { - const starknetAccount = - (await wallet.getSelectedStarknetAccount()) as Account // Old accounts are not supported + const starknetAccount = await wallet.getSelectedStarknetAccount() + await actionService.add( { type: "TRANSACTION", diff --git a/packages/extension/src/background/__new/procedures/discover/index.ts b/packages/extension/src/background/__new/procedures/discover/index.ts new file mode 100644 index 000000000..f8375c654 --- /dev/null +++ b/packages/extension/src/background/__new/procedures/discover/index.ts @@ -0,0 +1,6 @@ +import { router } from "../../trpc" +import { viewedAtProcedure } from "./viewedAt" + +export const discoverRouter = router({ + viewedAt: viewedAtProcedure, +}) diff --git a/packages/extension/src/background/__new/procedures/discover/viewedAt.ts b/packages/extension/src/background/__new/procedures/discover/viewedAt.ts new file mode 100644 index 000000000..f8ce79298 --- /dev/null +++ b/packages/extension/src/background/__new/procedures/discover/viewedAt.ts @@ -0,0 +1,12 @@ +import { z } from "zod" + +import { openSessionMiddleware } from "../../middleware/session" +import { discoverService } from "../../services/discover" +import { extensionOnlyProcedure } from "../permissions" + +export const viewedAtProcedure = extensionOnlyProcedure + .use(openSessionMiddleware) + .input(z.number()) + .mutation(async ({ input }) => { + return discoverService.setViewedAt(input) + }) diff --git a/packages/extension/src/background/__new/procedures/feeToken/avoidFeeToken.ts b/packages/extension/src/background/__new/procedures/feeToken/avoidFeeToken.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/extension/src/background/__new/procedures/feeToken/index.ts b/packages/extension/src/background/__new/procedures/feeToken/index.ts new file mode 100644 index 000000000..cea3c6fe2 --- /dev/null +++ b/packages/extension/src/background/__new/procedures/feeToken/index.ts @@ -0,0 +1,6 @@ +import { router } from "../../trpc" +import { preferFeeTokenProcedure } from "./preferFeeToken" + +export const feeTokenRouter = router({ + preferFeeToken: preferFeeTokenProcedure, +}) diff --git a/packages/extension/src/background/__new/procedures/feeToken/preferFeeToken.ts b/packages/extension/src/background/__new/procedures/feeToken/preferFeeToken.ts new file mode 100644 index 000000000..a4b81b147 --- /dev/null +++ b/packages/extension/src/background/__new/procedures/feeToken/preferFeeToken.ts @@ -0,0 +1,15 @@ +import { addressSchema } from "@argent/shared" +import { extensionOnlyProcedure } from "../permissions" + +export const preferFeeTokenProcedure = extensionOnlyProcedure + .input(addressSchema) + .mutation( + ({ + input: feeTokenAddress, + ctx: { + services: { feeTokenService }, + }, + }) => { + return feeTokenService.preferFeeToken(feeTokenAddress) + }, + ) diff --git a/packages/extension/src/background/__new/procedures/permissions.ts b/packages/extension/src/background/__new/procedures/permissions.ts index aed4dec9e..b0893ed5d 100644 --- a/packages/extension/src/background/__new/procedures/permissions.ts +++ b/packages/extension/src/background/__new/procedures/permissions.ts @@ -48,10 +48,15 @@ export const connectedDappsProcedure = publicProcedure.use( }) } + const senderType = isPreauthorized + ? ("preauthorized" as const) + : ("extension" as const) + return next({ ctx: { ...ctx, sender, // by passing it after checking, every method after this middleware will have a mandatory sender + senderType, }, }) }, diff --git a/packages/extension/src/background/__new/procedures/provision/getStatus.ts b/packages/extension/src/background/__new/procedures/provision/getStatus.ts new file mode 100644 index 000000000..1838994fa --- /dev/null +++ b/packages/extension/src/background/__new/procedures/provision/getStatus.ts @@ -0,0 +1,17 @@ +import { openSessionMiddleware } from "../../middleware/session" +import { extensionOnlyProcedure } from "../permissions" +import { provisionStatusSchema } from "../../../../shared/provision/types" + +export const getStatusProcedure = extensionOnlyProcedure + .use(openSessionMiddleware) + .output(provisionStatusSchema) + .query( + async ({ + ctx: { + services: { provisionService }, + }, + }) => { + const response = await provisionService.getStatus() + return response + }, + ) diff --git a/packages/extension/src/background/__new/procedures/provision/index.ts b/packages/extension/src/background/__new/procedures/provision/index.ts new file mode 100644 index 000000000..b653912da --- /dev/null +++ b/packages/extension/src/background/__new/procedures/provision/index.ts @@ -0,0 +1,6 @@ +import { router } from "../../trpc" +import { getStatusProcedure } from "./getStatus" + +export const provisionRouter = router({ + getStatus: getStatusProcedure, +}) diff --git a/packages/extension/src/background/__new/procedures/recovery/clearErrorRecovering.ts b/packages/extension/src/background/__new/procedures/recovery/clearErrorRecovering.ts new file mode 100644 index 000000000..30749018a --- /dev/null +++ b/packages/extension/src/background/__new/procedures/recovery/clearErrorRecovering.ts @@ -0,0 +1,11 @@ +import { extensionOnlyProcedure } from "../permissions" + +export const clearErrorRecoveringProcedure = extensionOnlyProcedure.mutation( + async ({ + ctx: { + services: { recoveryService }, + }, + }) => { + return recoveryService.clearErrorRecovering() + }, +) diff --git a/packages/extension/src/background/__new/procedures/recovery/index.ts b/packages/extension/src/background/__new/procedures/recovery/index.ts index e9acca882..88d5e228e 100644 --- a/packages/extension/src/background/__new/procedures/recovery/index.ts +++ b/packages/extension/src/background/__new/procedures/recovery/index.ts @@ -1,8 +1,10 @@ import { router } from "../../trpc" +import { clearErrorRecoveringProcedure } from "./clearErrorRecovering" import { recoverBackupProcedure } from "./recoverBackup" import { recoverSeedphraseProcedure } from "./recoverSeedphrase" export const recoveryRouter = router({ recoverBackup: recoverBackupProcedure, recoverSeedPhrase: recoverSeedphraseProcedure, + clearErrorRecovering: clearErrorRecoveringProcedure, }) diff --git a/packages/extension/src/background/__new/procedures/riskAssessment/assessRisk.ts b/packages/extension/src/background/__new/procedures/riskAssessment/assessRisk.ts new file mode 100644 index 000000000..4cb669cc9 --- /dev/null +++ b/packages/extension/src/background/__new/procedures/riskAssessment/assessRisk.ts @@ -0,0 +1,15 @@ +import { extensionOnlyProcedure } from "../permissions" +import { dappContextSchema } from "../../../../shared/riskAssessment/interface" + +export const assessRiskProcedure = extensionOnlyProcedure + .input(dappContextSchema) + .query( + async ({ + input: dappContext, + ctx: { + services: { riskAssessmentService }, + }, + }) => { + return riskAssessmentService.assessRisk({ dappContext }) + }, + ) diff --git a/packages/extension/src/background/__new/procedures/riskAssessment/index.ts b/packages/extension/src/background/__new/procedures/riskAssessment/index.ts new file mode 100644 index 000000000..c90af3b40 --- /dev/null +++ b/packages/extension/src/background/__new/procedures/riskAssessment/index.ts @@ -0,0 +1,6 @@ +import { router } from "../../trpc" +import { assessRiskProcedure } from "./assessRisk" + +export const riskAssessmentRouter = router({ + assessRisk: assessRiskProcedure, +}) diff --git a/packages/extension/src/background/__new/procedures/tokens/addToken.ts b/packages/extension/src/background/__new/procedures/tokens/addToken.ts index e89c02ef7..7b85d6d88 100644 --- a/packages/extension/src/background/__new/procedures/tokens/addToken.ts +++ b/packages/extension/src/background/__new/procedures/tokens/addToken.ts @@ -1,10 +1,16 @@ import { extensionOnlyProcedure } from "../permissions" import { TokenSchema } from "../../../../shared/token/__new/types/token.model" -import { tokenService } from "../../../../shared/token/__new/service" export const addTokenProcedure = extensionOnlyProcedure .input(TokenSchema) - .mutation(async ({ input: token }) => { - // tokens that are added from the UI should never be shown with custom flag, even if the balance is 0 - return await tokenService.addToken({ ...token, custom: true }) - }) + .mutation( + async ({ + input: token, + ctx: { + services: { tokenService }, + }, + }) => { + // tokens that are added from the UI should never be shown with custom flag, even if the balance is 0 + return await tokenService.addToken({ ...token, custom: true }) + }, + ) diff --git a/packages/extension/src/background/__new/procedures/transactionEstimate/accountDeploy.ts b/packages/extension/src/background/__new/procedures/transactionEstimate/accountDeploy.ts index 07ea0f4c2..947174261 100644 --- a/packages/extension/src/background/__new/procedures/transactionEstimate/accountDeploy.ts +++ b/packages/extension/src/background/__new/procedures/transactionEstimate/accountDeploy.ts @@ -29,7 +29,7 @@ export const estimateAccountDeployProcedure = extensionOnlyProcedure : await wallet.getSelectedAccount() if (!account) { - throw Error("no accounts") + throw new AccountError({ code: "NOT_FOUND" }) } try { diff --git a/packages/extension/src/background/__new/procedures/transactionEstimate/estimate.ts b/packages/extension/src/background/__new/procedures/transactionEstimate/estimate.ts index dc3e2ab8a..c5e48b275 100644 --- a/packages/extension/src/background/__new/procedures/transactionEstimate/estimate.ts +++ b/packages/extension/src/background/__new/procedures/transactionEstimate/estimate.ts @@ -11,6 +11,7 @@ import { } from "./helpers" import { estimatedFeesSchema } from "../../../../shared/transactionSimulation/fees/fees.model" import { AccountError } from "../../../../shared/errors/account" +import { getTxVersionFromFeeToken } from "../../../../shared/utils/getTransactionVersion" const estimateRequestSchema = z.object({ account: baseWalletAccountSchema, @@ -51,21 +52,15 @@ export const estimateTransactionProcedure = extensionOnlyProcedure wallet, ) - // TODO: consider selected fee token + const version = getTxVersionFromFeeToken(feeTokenAddress) + const estimatedFees = await snAccount.estimateFeeBulk(allInvocations, { - skipValidate: true, + // skipValidate is true by default + version, }) try { - const aggregatedResponse = estimatedFeesToResponse( - estimatedFees, - allInvocations, - ) - - return { - ...aggregatedResponse, - feeTokenAddress, - } + return estimatedFeesToResponse(estimatedFees, feeTokenAddress) } catch (e) { throw new AccountError({ code: "CANNOT_ESTIMATE_TRANSACTIONS", diff --git a/packages/extension/src/background/__new/procedures/transactionEstimate/helpers.ts b/packages/extension/src/background/__new/procedures/transactionEstimate/helpers.ts index c0cd6df08..84d990f3f 100644 --- a/packages/extension/src/background/__new/procedures/transactionEstimate/helpers.ts +++ b/packages/extension/src/background/__new/procedures/transactionEstimate/helpers.ts @@ -4,12 +4,12 @@ import { Invocations, ProviderInterface, TransactionType, -} from "starknet" +} from "starknet6" import { isAccountDeployed } from "../../../accountDeploy" import type { EstimatedFees } from "../../../../shared/transactionSimulation/fees/fees.model" import type { WalletAccount } from "../../../../shared/wallet.model" import type { Wallet } from "../../../wallet" -import { ETH_TOKEN_ADDRESS } from "../../../../shared/network/constants" +import type { Address } from "@argent/shared" type Invocation = Invocations[number] @@ -22,20 +22,15 @@ export function callsToInvocation(calls: Call[]): Invocation { export function estimatedFeesToResponse( estimatedFees: EstimateFeeBulk, - invocations: Invocations, + feeTokenAddress: Address, ): EstimatedFees { - // check length is same - if (estimatedFees.length !== invocations.length) { - throw new Error("estimatedFeesToResponse: length mismatch") - } - if (estimatedFees.length !== 1 && estimatedFees.length !== 2) { throw new Error("estimatedFeesToResponse: length must be 1 or 2") } const fees: EstimatedFees = { transactions: { - feeTokenAddress: ETH_TOKEN_ADDRESS, + feeTokenAddress, amount: 0n, pricePerUnit: 0n, }, @@ -50,7 +45,7 @@ export function estimatedFeesToResponse( } fees.deployment = { - feeTokenAddress: ETH_TOKEN_ADDRESS, + feeTokenAddress, amount: gas_consumed, pricePerUnit: gas_price, } @@ -66,7 +61,7 @@ export function estimatedFeesToResponse( } fees.transactions = { - feeTokenAddress: ETH_TOKEN_ADDRESS, + feeTokenAddress, amount: gas_consumed, pricePerUnit: gas_price, } diff --git a/packages/extension/src/background/__new/procedures/transactionReview/simulateAndReview.ts b/packages/extension/src/background/__new/procedures/transactionReview/simulateAndReview.ts index 7758e921d..14ed57ea3 100644 --- a/packages/extension/src/background/__new/procedures/transactionReview/simulateAndReview.ts +++ b/packages/extension/src/background/__new/procedures/transactionReview/simulateAndReview.ts @@ -4,8 +4,12 @@ import { openSessionMiddleware } from "../../middleware/session" import { extensionOnlyProcedure } from "../permissions" import { transactionReviewTransactionsSchema } from "../../../../shared/transactionReview/interface" import { enrichedSimulateAndReviewSchema } from "../../../../shared/transactionReview/schema" +import { addressSchema } from "@argent/shared" -const simulateAndReviewSchema = z.array(transactionReviewTransactionsSchema) +const simulateAndReviewSchema = z.object({ + feeTokenAddress: addressSchema, + transactions: z.array(transactionReviewTransactionsSchema), +}) export const simulateAndReviewProcedure = extensionOnlyProcedure .use(openSessionMiddleware) @@ -13,7 +17,5 @@ export const simulateAndReviewProcedure = extensionOnlyProcedure .output(enrichedSimulateAndReviewSchema) .query(async ({ input, ctx: { services } }) => { const { transactionReviewService } = services - return transactionReviewService.simulateAndReview({ - transactions: input, - }) + return transactionReviewService.simulateAndReview(input) }) diff --git a/packages/extension/src/background/__new/procedures/transfer/send.ts b/packages/extension/src/background/__new/procedures/transfer/send.ts index 56c64fa0b..0cd43e775 100644 --- a/packages/extension/src/background/__new/procedures/transfer/send.ts +++ b/packages/extension/src/background/__new/procedures/transfer/send.ts @@ -11,6 +11,7 @@ const sendSchema = z.object({ }), title: z.string(), subtitle: z.string().optional(), + isMaxSend: z.boolean().optional(), }) export const sendProcedure = extensionOnlyProcedure @@ -18,7 +19,7 @@ export const sendProcedure = extensionOnlyProcedure .output(z.string()) .mutation( async ({ - input: { transactions, title, subtitle }, + input: { transactions, title, subtitle, isMaxSend }, ctx: { services: { actionService }, }, @@ -28,6 +29,9 @@ export const sendProcedure = extensionOnlyProcedure type: "TRANSACTION", payload: { transactions, + meta: { + isMaxSend, + }, }, }, { diff --git a/packages/extension/src/background/__new/router.ts b/packages/extension/src/background/__new/router.ts index 19dfec54f..d4c8d815f 100644 --- a/packages/extension/src/background/__new/router.ts +++ b/packages/extension/src/background/__new/router.ts @@ -26,6 +26,14 @@ import { backgroundStarknetAddressService } from "./services/address" import { networkService } from "../../shared/network/service" import { sharedSwapService } from "../../shared/swap/service" import { transactionEstimateRouter } from "./procedures/transactionEstimate" +import { tokenService } from "../../shared/token/__new/service" +import { riskAssessmentRouter } from "./procedures/riskAssessment" +import { riskAssessmentService } from "./services/riskAssessment" +import { feeTokenService } from "../../shared/feeToken/service" +import { feeTokenRouter } from "./procedures/feeToken" +import { provisionRouter } from "./procedures/provision" +import { provisionService } from "./services/provision" +import { discoverRouter } from "./procedures/discover" const appRouter = router({ account: accountRouter, @@ -43,6 +51,10 @@ const appRouter = router({ transactionReview: transactionReviewRouter, transfer: transferRouter, udc: udcRouter, + riskAssessment: riskAssessmentRouter, + feeToken: feeTokenRouter, + provision: provisionRouter, + discover: discoverRouter, }) export type AppRouter = typeof appRouter @@ -62,7 +74,11 @@ createChromeHandler({ transactionReviewService: backgroundTransactionReviewService, swapService: sharedSwapService, starknetAddressService: backgroundStarknetAddressService, + tokenService, + feeTokenService, networkService, + riskAssessmentService, + provisionService, }, }), }) diff --git a/packages/extension/src/background/__new/services/account/worker/implementation.test.ts b/packages/extension/src/background/__new/services/account/worker/implementation.test.ts index 2be14d9d8..cae33e6e1 100644 --- a/packages/extension/src/background/__new/services/account/worker/implementation.test.ts +++ b/packages/extension/src/background/__new/services/account/worker/implementation.test.ts @@ -1,14 +1,11 @@ import { Mocked, describe, expect, it, vi } from "vitest" import { addressSchema } from "@argent/shared" -import { noop } from "lodash-es" import { getMockWalletAccount } from "../../../../../../test/walletAccount.mock" import { IAccountService } from "../../../../../shared/account/service/interface" -import { IDebounceService } from "../../../../../shared/debounce" -import { STANDARD_ACCOUNT_CLASS_HASH } from "../../../../../shared/network/constants" +import { TXV1_ACCOUNT_CLASS_HASH } from "../../../../../shared/network/constants" import { IScheduleService } from "../../../../../shared/schedule/interface" import { IActivityService } from "../../activity/interface" -import { IBackgroundUIService } from "../../ui/interface" import { AccountWorker } from "./implementation" vi.mock("../../../../../shared/account/details/getAccountCairoVersionFromChain") @@ -43,16 +40,10 @@ describe("AccountWorker", () => { }, } as unknown as Mocked - const backgroundUIService = {} as unknown as Mocked - - const debounceService = {} as unknown as Mocked - const accountWorker = new AccountWorker( accountService, activityService, scheduleService, - backgroundUIService, - debounceService, ) return { @@ -60,8 +51,6 @@ describe("AccountWorker", () => { accountService, activityService, scheduleService, - backgroundUIService, - debounceService, } } @@ -133,16 +122,12 @@ describe("AccountWorker", () => { accountService.get = vi.fn().mockResolvedValueOnce([mockAccount]) - worker.updateAccountClassHashImmediately = vi - .fn() - .mockImplementationOnce(noop) - await worker.updateAccountClassHash() expect(accountService.upsert).toHaveBeenCalledWith([ { ...mockAccount, - classHash: addressSchema.parse(STANDARD_ACCOUNT_CLASS_HASH), + classHash: addressSchema.parse(TXV1_ACCOUNT_CLASS_HASH), }, ]) }) @@ -166,10 +151,6 @@ describe("AccountWorker", () => { }, ]) - worker.updateAccountClassHashImmediately = vi - .fn() - .mockImplementationOnce(noop) - await worker.updateAccountCairoVersion() expect(accountService.upsert).toHaveBeenCalledWith([ diff --git a/packages/extension/src/background/__new/services/account/worker/implementation.ts b/packages/extension/src/background/__new/services/account/worker/implementation.ts index ecfb84ad6..e331f3b3d 100644 --- a/packages/extension/src/background/__new/services/account/worker/implementation.ts +++ b/packages/extension/src/background/__new/services/account/worker/implementation.ts @@ -4,22 +4,22 @@ import { WalletAccount } from "../../../../../shared/wallet.model" import { getAccountClassHashFromChain } from "../../../../../shared/account/details/getAccountClassHashFromChain" import { getAccountCairoVersionFromChain } from "../../../../../shared/account/details/getAccountCairoVersionFromChain" import { IAccountService } from "../../../../../shared/account/service/interface" -import { isUndefined, keyBy } from "lodash-es" -import { - onInstallAndUpgrade, - onStartup, -} from "../../worker/schedule/decorators" +import { keyBy } from "lodash-es" +import { onInstallAndUpgrade } from "../../worker/schedule/decorators" import { AllowArray } from "../../../../../shared/storage/__new/interface" import { pipe } from "../../worker/schedule/pipe" -import { IBackgroundUIService } from "../../ui/interface" -import { IDebounceService } from "../../../../../shared/debounce" import { getOwnerForAccount } from "../../../../../shared/account/details/getOwner" import { + GuardianChangedActivity, IActivityService, - SecurityActivityPayload, + AccountActivityPayload, SignerChangedActivity, + AccountDeployActivity, + ProvisionActivity, } from "../../activity/interface" +import { getGuardianForAccount } from "../../../../../shared/account/details/getGuardian" +import { ProvisionActivityPayload } from "../../../../../shared/activity/types" export enum AccountUpdaterTaskId { UPDATE_DEPLOYED = "accountUpdateDeployed", @@ -38,13 +38,23 @@ export class AccountWorker { private readonly accountService: IAccountService, private readonly activityService: IActivityService, private readonly scheduleService: IScheduleService, - private readonly backgroundUIService: IBackgroundUIService, - private readonly debounceService: IDebounceService, ) { this.activityService.emitter.on( SignerChangedActivity, this.onSignerChanged.bind(this), ) + this.activityService.emitter.on( + AccountDeployActivity, + this.updateDeployed.bind(this), + ) + this.activityService.emitter.on( + GuardianChangedActivity, + this.onGuardianChanged.bind(this), + ) + this.activityService.emitter.on( + ProvisionActivity, + this.onProvisionActivity.bind(this), + ) } runUpdaterForAllTasks = pipe( @@ -57,6 +67,7 @@ export class AccountWorker { ]) }) + /** @internal just exposed for testing */ async runUpdaterTask(tasks: AllowArray): Promise { const updaterTasks = Array.isArray(tasks) ? tasks : [tasks] for (const task of updaterTasks) { @@ -74,6 +85,7 @@ export class AccountWorker { } } + /** @internal just exposed for testing */ async updateDeployed(): Promise { const accounts = await this.accountService.get( (account) => account.needsDeploy !== false, @@ -101,6 +113,7 @@ export class AccountWorker { await this.accountService.upsert(newlyDeployedAccounts) } + /** @internal just exposed for testing */ async updateAccountClassHash(): Promise { const accounts = await this.accountService.get() @@ -122,6 +135,7 @@ export class AccountWorker { await this.accountService.upsert(updated) } + /** @internal just exposed for testing */ async updateAccountCairoVersion(): Promise { const accounts = await this.accountService.get() @@ -145,54 +159,47 @@ export class AccountWorker { await this.accountService.upsert(updated) } - async updateAccountClassHashImmediately(): Promise { - const accounts = await this.accountService.get() - const needsImmediateUpdate = accounts.some((account) => - isUndefined(account.classHash), + async onSignerChanged(payload: AccountActivityPayload) { + const accounts = await this.accountService.getFromBaseWalletAccounts( + payload, ) - if (needsImmediateUpdate) { - // Let's keep console.log here for now, as it's a critical path. - // It will be removed in Prod. - console.log("Updating account class hash immediately") - await this.updateAccountClassHash() - } else { - console.log("Account class hash up to date") - } - } - - async updateAccountCairoVersionImmediately(): Promise { - const accounts = await this.accountService.get() - // Using every here, because we don't want to fetch the cairo version for undeployed accounts - const needsImmediateUpdate = accounts.every((account) => - isUndefined(account.cairoVersion), + const results = await Promise.allSettled( + accounts.map((account) => { + return getOwnerForAccount(account) + }), ) - if (needsImmediateUpdate) { - // Let's keep console.log here for now, as it's a critical path. - // It will be removed in Prod. - console.log("Updating account cairo version immediately") - await this.updateAccountCairoVersion() - } else { - console.log("Account cairo version up to date") - } + const updated = accounts.map((account, index) => { + const result = results[index] + const owner = result.status === "fulfilled" ? result.value : undefined + return { + ...account, + owner, + } + }) + await this.accountService.upsert(updated) } - async onSignerChanged(payload: SecurityActivityPayload) { + async onGuardianChanged(payload: AccountActivityPayload) { const accounts = await this.accountService.getFromBaseWalletAccounts( payload, ) const results = await Promise.allSettled( accounts.map((account) => { - return getOwnerForAccount(account) + return getGuardianForAccount(account) }), ) const updated = accounts.map((account, index) => { const result = results[index] - const owner = result.status === "fulfilled" ? result.value : undefined + const guardian = result.status === "fulfilled" ? result.value : undefined return { ...account, - owner, + guardian, } }) await this.accountService.upsert(updated) } + + async onProvisionActivity(payload: ProvisionActivityPayload) { + await this.accountService.handleProvisionedAccount(payload) + } } diff --git a/packages/extension/src/background/__new/services/account/worker/index.ts b/packages/extension/src/background/__new/services/account/worker/index.ts index 3c05dd309..ef4f15e85 100644 --- a/packages/extension/src/background/__new/services/account/worker/index.ts +++ b/packages/extension/src/background/__new/services/account/worker/index.ts @@ -1,14 +1,10 @@ import { chromeScheduleService } from "../../../../../shared/schedule" import { accountService } from "../../../../../shared/account/service" import { AccountWorker } from "./implementation" -import { backgroundUIService } from "../../ui" -import { debounceService } from "../../../../../shared/debounce" import { activityService } from "../../activity" export const accountWorker = new AccountWorker( accountService, activityService, chromeScheduleService, - backgroundUIService, - debounceService, ) diff --git a/packages/extension/src/background/__new/services/action/background.ts b/packages/extension/src/background/__new/services/action/background.ts index d88522d74..c240fa95a 100644 --- a/packages/extension/src/background/__new/services/action/background.ts +++ b/packages/extension/src/background/__new/services/action/background.ts @@ -19,6 +19,7 @@ import type { Respond } from "../../../respond" import { Wallet } from "../../../wallet" import type { IBackgroundActionService } from "./interface" import { ActionError } from "../../../../shared/errors/action" +import { IFeeTokenService } from "../../../../shared/feeToken/service/interface" const getResultData = (resultMessage?: MessageType) => { if (resultMessage && "data" in resultMessage) { @@ -39,6 +40,7 @@ export default class BackgroundActionService constructor( private queue: IActionQueue, private wallet: Wallet, + private feeTokenService: IFeeTokenService, private respond: Respond, ) {} @@ -55,7 +57,7 @@ export default class BackgroundActionService /** * Don't await handleActionApproval, this allows for existing patterns to use 'waitForMessage' after calling await clientActionService.approve(...) */ - handleActionApproval(action, this.wallet) + handleActionApproval(action, this.wallet, this.feeTokenService) .then((resultMessage) => { const error = getResultDataError(resultMessage) if (error) { @@ -86,7 +88,11 @@ export default class BackgroundActionService startedApproving: Date.now(), errorApproving: undefined, }) - const resultMessage = await handleActionApproval(action, this.wallet) + const resultMessage = await handleActionApproval( + action, + this.wallet, + this.feeTokenService, + ) const error = getResultDataError(resultMessage) if (error) { await this.queue.updateMeta(actionHash, { @@ -149,6 +155,13 @@ export default class BackgroundActionService return this.queue.add(action, meta) } + async addFront( + action: T, + meta?: Partial, + ): Promise> { + return this.queue.addFront(action, meta) + } + async remove(actionHash: string): Promise | null> { return this.queue.remove(actionHash) } diff --git a/packages/extension/src/background/__new/services/action/index.ts b/packages/extension/src/background/__new/services/action/index.ts index 4ec7fa3a1..e63d4dc0e 100644 --- a/packages/extension/src/background/__new/services/action/index.ts +++ b/packages/extension/src/background/__new/services/action/index.ts @@ -1,4 +1,5 @@ import { actionQueue } from "../../../../shared/actionQueue" +import { feeTokenService } from "../../../../shared/feeToken/service" import { respond } from "../../../respond" import { walletSingleton } from "../../../walletSingleton" import BackgroundActionService from "./background" @@ -6,5 +7,6 @@ import BackgroundActionService from "./background" export const backgroundActionService = new BackgroundActionService( actionQueue, walletSingleton, + feeTokenService, respond, ) diff --git a/packages/extension/src/background/__new/services/action/interface.ts b/packages/extension/src/background/__new/services/action/interface.ts index aee69b794..522a609b6 100644 --- a/packages/extension/src/background/__new/services/action/interface.ts +++ b/packages/extension/src/background/__new/services/action/interface.ts @@ -13,5 +13,9 @@ export interface IBackgroundActionService extends IActionService { action: T, meta?: Partial, ): Promise> + addFront( + action: T, + meta?: Partial, + ): Promise> remove(actionHash: ActionHash): Promise | null> } diff --git a/packages/extension/src/background/__new/services/activity/implementation.test.ts b/packages/extension/src/background/__new/services/activity/implementation.test.ts index e9db33e41..93ecdc523 100644 --- a/packages/extension/src/background/__new/services/activity/implementation.test.ts +++ b/packages/extension/src/background/__new/services/activity/implementation.test.ts @@ -7,7 +7,10 @@ import type { IAccountService } from "../../../../shared/account/service/interfa import type { IActivityStorage } from "../../../../shared/activity/types" import type { IDebounceService } from "../../../../shared/debounce" import { createScheduleServiceMock } from "../../../../shared/schedule/mock" -import { InMemoryObjectStore } from "../../../../shared/storage/__new/__test__/inmemoryImplementations" +import { + InMemoryKeyValueStore, + InMemoryObjectStore, +} from "../../../../shared/storage/__new/__test__/inmemoryImplementations" import type { ContractAddress, INftsContractsRepository, @@ -19,8 +22,9 @@ import type { IBackgroundUIService } from "../ui/interface" import { ActivityService } from "./implementation" import { GuardianChangedActivity, NftActivity, type Events } from "./interface" -import activities from "./__fixtures__/activities.json" -import state from "./__fixtures__/state.json" +import activities from "../../../../shared/activity/__fixtures__/activities.json" +import state from "../../../../shared/activity/__fixtures__/state.json" +import { WalletStorageProps } from "../../../wallet/backup/backup.service" describe("ActivityService", () => { const makeService = () => { @@ -35,6 +39,10 @@ describe("ActivityService", () => { }, }) + const walletStore = new InMemoryKeyValueStore({ + namespace: "wallet", + }) + const walletSingleton = { getSelectedAccount: vi.fn(), } as unknown as Mocked @@ -76,6 +84,7 @@ describe("ActivityService", () => { scheduleService, backgroundUIService, debounceService, + walletStore, ) return { emitter, diff --git a/packages/extension/src/background/__new/services/activity/implementation.ts b/packages/extension/src/background/__new/services/activity/implementation.ts index 0474419b0..6c0733715 100644 --- a/packages/extension/src/background/__new/services/activity/implementation.ts +++ b/packages/extension/src/background/__new/services/activity/implementation.ts @@ -8,7 +8,10 @@ import { import type Emittery from "emittery" import type { IAccountService } from "../../../../shared/account/service/interface" -import type { IActivityStorage } from "../../../../shared/activity/types" +import type { + ActivitiesPayload, + IActivityStorage, +} from "../../../../shared/activity/types" import { ARGENT_API_BASE_URL } from "../../../../shared/api/constants" import { argentApiNetworkForNetwork } from "../../../../shared/api/headers" import { RefreshInterval } from "../../../../shared/config" @@ -22,7 +25,7 @@ import { urlWithQuery } from "../../../../shared/utils/url" import type { BaseWalletAccount } from "../../../../shared/wallet.model" import type { Wallet } from "../../../wallet" import type { IBackgroundUIService } from "../ui/interface" -import { everyWhenOpen } from "../worker/schedule/decorators" +import { everyWhenOpen, onAccountChanged } from "../worker/schedule/decorators" import { pipe } from "../worker/schedule/pipe" import { AccountUpgradedActivity, @@ -38,18 +41,22 @@ import { TokenActivity, TriggerEscapeGuardianActivity, TriggerEscapeSignerActivity, - type ActivitiesPayload, type Events, type IActivityService, + AccountDeployActivity, + ProvisionActivity, } from "./interface" import { isActivityDetailsAction, type ActivityDetailsAction, type ActivityResponse, -} from "./schema" -import { getOverallLastModified } from "./utils/getOverallLastModified" -import { parseFinanceActivities } from "./utils/parseFinanceActivities" -import { parseSecurityActivities } from "./utils/parseSecurityActivities" +} from "../../../../shared/activity/schema" +import { getOverallLastModified } from "../../../../shared/activity/utils/getOverallLastModified" +import { parseFinanceActivities } from "../../../../shared/activity/utils/parseFinanceActivities" +import { parseAccountActivities } from "../../../../shared/activity/utils/parseAccountActivities" +import { IKeyValueStorage } from "../../../../shared/storage" +import { WalletStorageProps } from "../../../wallet/backup/backup.service" +import { parseProvisionActivity } from "../../../../shared/activity/utils/parseProvisionActivity" /** maps activity details action to an equivalent Event to emit */ @@ -81,9 +88,11 @@ export class ActivityService implements IActivityService { private readonly scheduleService: IScheduleService, private readonly backgroundUIService: IBackgroundUIService, private readonly debounceService: IDebounceService, + private readonly old_walletStore: IKeyValueStorage, ) {} runUpdateSelectedAccountActivities = pipe( + onAccountChanged(this.old_walletStore), everyWhenOpen( this.backgroundUIService, this.scheduleService, @@ -248,7 +257,7 @@ export class ActivityService implements IActivityService { /** security */ - const accountAddressesByAction = parseSecurityActivities({ + const accountAddressesByAction = parseAccountActivities({ activities: filteredActivities, accountAddressesOnNetwork, }) @@ -262,8 +271,23 @@ export class ActivityService implements IActivityService { if (accounts.length) { void this.emitter.emit(event, accounts) } + } else if (action === "deploy") { + const accounts = accountsOnNetwork.filter((account) => + includesAddress(account.address, addresses), + ) + if (accounts.length) { + void this.emitter.emit(AccountDeployActivity, accounts) + } } }) + /** Provision */ + const provisionActivity = parseProvisionActivity(filteredActivities) + if (provisionActivity !== undefined) { + void this.emitter.emit(ProvisionActivity, { + account: activityAccount, + activity: provisionActivity, + }) + } } async getModifiedAfter( diff --git a/packages/extension/src/background/__new/services/activity/index.ts b/packages/extension/src/background/__new/services/activity/index.ts index 57bc6891f..310ca67b5 100644 --- a/packages/extension/src/background/__new/services/activity/index.ts +++ b/packages/extension/src/background/__new/services/activity/index.ts @@ -11,6 +11,7 @@ import type { Events } from "./interface" import { accountService } from "../../../../shared/account/service" import { tokenService } from "../../../../shared/token/__new/service" import { nftsContractsRepository } from "../../../../shared/storage/__new/repositories/nft" +import { old_walletStore } from "../../../../shared/wallet/walletStore" export { Activities as Activities } from "./interface" @@ -27,4 +28,5 @@ export const activityService = new ActivityService( chromeScheduleService, backgroundUIService, debounceService, + old_walletStore, ) diff --git a/packages/extension/src/background/__new/services/activity/interface.ts b/packages/extension/src/background/__new/services/activity/interface.ts index 380e8b06b..d6ebe1993 100644 --- a/packages/extension/src/background/__new/services/activity/interface.ts +++ b/packages/extension/src/background/__new/services/activity/interface.ts @@ -3,7 +3,11 @@ import type Emittery from "emittery" import type { ContractAddress } from "../../../../shared/storage/__new/repositories/nft" import type { BaseWalletAccount } from "../../../../shared/wallet.model" -import type { Activity } from "./schema" +import type { Activity } from "../../../../shared/activity/schema" +import { + ActivitiesPayload, + ProvisionActivityPayload, +} from "../../../../shared/activity/types" /** raw */ export const Activities = Symbol("Activities") @@ -12,7 +16,8 @@ export const Activities = Symbol("Activities") export const TokenActivity = Symbol("TokenActivity") export const NftActivity = Symbol("NftActivity") -/** security */ +/** account */ +export const AccountDeployActivity = Symbol("AccountDeployActivity") export const TriggerEscapeGuardianActivity = Symbol( "TriggerEscapeGuardianActivity", ) @@ -30,10 +35,8 @@ export const MultisigConfigurationUpdatedActivity = Symbol( "MultisigConfigurationUpdatedActivity", ) -export type ActivitiesPayload = { - account: BaseWalletAccount - activities: Activity[] -} +/** Provision */ +export const ProvisionActivity = Symbol("ProvisionActivity") export type TokenActivityPayload = { accounts: BaseWalletAccount[] @@ -45,7 +48,7 @@ export type NftActivityPayload = { nfts: ContractAddress[] } -export type SecurityActivityPayload = BaseWalletAccount[] +export type AccountActivityPayload = BaseWalletAccount[] /** * Fired when new activities are discovered on an individual account @@ -55,16 +58,18 @@ export type Events = { [Activities]: ActivitiesPayload [TokenActivity]: TokenActivityPayload [NftActivity]: NftActivityPayload - [TriggerEscapeGuardianActivity]: SecurityActivityPayload - [TriggerEscapeSignerActivity]: SecurityActivityPayload - [EscapeGuardianActivity]: SecurityActivityPayload - [EscapeSignerActivity]: SecurityActivityPayload - [GuardianChangedActivity]: SecurityActivityPayload - [GuardianBackupChangedActivity]: SecurityActivityPayload - [SignerChangedActivity]: SecurityActivityPayload - [CancelEscapeActivity]: SecurityActivityPayload - [AccountUpgradedActivity]: SecurityActivityPayload - [MultisigConfigurationUpdatedActivity]: SecurityActivityPayload + [AccountDeployActivity]: AccountActivityPayload + [TriggerEscapeGuardianActivity]: AccountActivityPayload + [TriggerEscapeSignerActivity]: AccountActivityPayload + [EscapeGuardianActivity]: AccountActivityPayload + [EscapeSignerActivity]: AccountActivityPayload + [GuardianChangedActivity]: AccountActivityPayload + [GuardianBackupChangedActivity]: AccountActivityPayload + [SignerChangedActivity]: AccountActivityPayload + [CancelEscapeActivity]: AccountActivityPayload + [AccountUpgradedActivity]: AccountActivityPayload + [MultisigConfigurationUpdatedActivity]: AccountActivityPayload + [ProvisionActivity]: ProvisionActivityPayload } export interface IActivityService { diff --git a/packages/extension/src/background/__new/services/activity/utils/parseSecurityActivities.test.ts b/packages/extension/src/background/__new/services/activity/utils/parseSecurityActivities.test.ts deleted file mode 100644 index 311704c85..000000000 --- a/packages/extension/src/background/__new/services/activity/utils/parseSecurityActivities.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { Address } from "@argent/shared" -import { describe, expect, test } from "vitest" - -import activities from "../__fixtures__/activities.json" -import activitiesManyEscapes from "../__fixtures__/activities-many-escapes.json" -import activitiesSignerChanged from "../__fixtures__/activities-signer-changed.json" -import state from "../__fixtures__/state.json" -import type { Activity } from "../schema" -import { parseSecurityActivities } from "./parseSecurityActivities" - -describe("background/services/activity/utils", () => { - describe("parseSecurityActivities", () => { - describe("when valid", () => { - test("returns a map of actions to account addresses", () => { - expect( - parseSecurityActivities({ - activities: activities as Activity[], - accountAddressesOnNetwork: - state.accountAddressesOnNetwork as Address[], - }), - ).toMatchInlineSnapshot(` - { - "guardianChanged": [ - "0x05f1f0a38429dcab9ffd8a786c0d827e84c1cbd8f60243e6d25d066a13af4a25", - ], - } - `) - expect( - parseSecurityActivities({ - activities: activitiesManyEscapes as Activity[], - accountAddressesOnNetwork: [ - "0x00c90c89d339d1611f971e9211bc6a8efafc82541a61703a702c17d291afe9bb", - ] as Address[], - }), - ).toMatchInlineSnapshot(` - { - "cancelEscape": [ - "0x00c90c89d339d1611f971e9211bc6a8efafc82541a61703a702c17d291afe9bb", - ], - "guardianChanged": [ - "0x00c90c89d339d1611f971e9211bc6a8efafc82541a61703a702c17d291afe9bb", - ], - "triggerEscapeGuardian": [ - "0x00c90c89d339d1611f971e9211bc6a8efafc82541a61703a702c17d291afe9bb", - ], - } - `) - expect( - parseSecurityActivities({ - activities: activitiesSignerChanged as Activity[], - accountAddressesOnNetwork: [ - "0x02470ea294aa4b28ee4a473aaa8a1edc6c810c11684d1f29f1f3edd336fd0f34", - ] as Address[], - }), - ).toMatchInlineSnapshot(` - { - "signerChanged": [ - "0x02470ea294aa4b28ee4a473aaa8a1edc6c810c11684d1f29f1f3edd336fd0f34", - ], - } - `) - }) - }) - }) -}) diff --git a/packages/extension/src/background/__new/services/activity/utils/parseSecurityActivities.ts b/packages/extension/src/background/__new/services/activity/utils/parseSecurityActivities.ts deleted file mode 100644 index 3d56a7b24..000000000 --- a/packages/extension/src/background/__new/services/activity/utils/parseSecurityActivities.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { type Address, includesAddress } from "@argent/shared" - -import type { Activity, ActivityDetailsAction } from "../schema" - -interface ParseFinanceActivitiesProps { - activities: Activity[] - accountAddressesOnNetwork: Address[] -} - -/** - * Parses security-related activities from the provided list of activities, grouping them by action - * and returning a map of addresses associated with each action. - * - * @param activities: Array of activities to parse. - * @param accountAddressesOnNetwork: Array of account addresses that are known to be active on the - * network. - * @returns A map of actions to their associated addresses, where the addresses represent the - * accounts involved in the respective actions. - */ - -export function parseSecurityActivities({ - activities, - accountAddressesOnNetwork, -}: ParseFinanceActivitiesProps) { - const accountAddressesByAction: Partial< - Record - > = {} - - activities.forEach((activity) => { - if (activity.group === "security") { - const address = activity.wallet - if (includesAddress(address, accountAddressesOnNetwork)) { - const action = activity.details.action - if (action) { - if (!accountAddressesByAction[action]) { - accountAddressesByAction[action] = [] - } - if (!includesAddress(address, accountAddressesByAction[action])) { - accountAddressesByAction[action]?.push(address) - } - } - } - } - }) - - return accountAddressesByAction -} diff --git a/packages/extension/src/background/__new/services/discover/implementation.ts b/packages/extension/src/background/__new/services/discover/implementation.ts new file mode 100644 index 000000000..1f2d0e344 --- /dev/null +++ b/packages/extension/src/background/__new/services/discover/implementation.ts @@ -0,0 +1,68 @@ +import { type IHttpService } from "@argent/shared" + +import { RefreshInterval } from "../../../../shared/config" +import type { IDebounceService } from "../../../../shared/debounce" +import type { + IDiscoverStorage, + IDiscoverService, +} from "../../../../shared/discover/interface" +import { newsApiReponseSchema } from "../../../../shared/discover/schema" +import type { IScheduleService } from "../../../../shared/schedule/interface" +import type { IObjectStore } from "../../../../shared/storage/__new/interface" +import type { IBackgroundUIService } from "../ui/interface" +import { everyWhenOpen } from "../worker/schedule/decorators" +import { pipe } from "../worker/schedule/pipe" +import { ARGENT_X_NEWS_URL } from "../../../../shared/api/constants" + +export class DiscoverService implements IDiscoverService { + constructor( + private readonly discoverStore: IObjectStore, + private readonly httpService: IHttpService, + private readonly scheduleService: IScheduleService, + private readonly backgroundUIService: IBackgroundUIService, + private readonly debounceService: IDebounceService, + ) {} + + runUpdateSelectedAccountActivities = pipe( + everyWhenOpen( + this.backgroundUIService, + this.scheduleService, + this.debounceService, + RefreshInterval.MEDIUM, + "DiscoverService.updateDiscover", + ), + )(async () => { + await this.updateDiscover() + }) + + async updateDiscover() { + if (!ARGENT_X_NEWS_URL) { + return this.resetData() + } + /** FIXME: cacheBust param is a temporary fix to force fresh content from static server */ + const cacheBust = Date.now() + const result = await this.httpService.get( + `${ARGENT_X_NEWS_URL}?v=${cacheBust}`, + ) + const parsedResult = newsApiReponseSchema.safeParse(result) + if (!parsedResult.success) { + // on failure, ensure we don't show stale data to end user + return this.resetData() + } + await this.discoverStore.set({ + data: parsedResult.data, + }) + } + + private async resetData() { + await this.discoverStore.set({ + data: null, + }) + } + + async setViewedAt(viewedAt: number) { + await this.discoverStore.set({ + viewedAt, + }) + } +} diff --git a/packages/extension/src/background/__new/services/discover/index.ts b/packages/extension/src/background/__new/services/discover/index.ts new file mode 100644 index 000000000..e04d704e0 --- /dev/null +++ b/packages/extension/src/background/__new/services/discover/index.ts @@ -0,0 +1,14 @@ +import { debounceService } from "../../../../shared/debounce" +import { discoverStore } from "../../../../shared/discover/storage" +import { httpService } from "../../../../shared/http/singleton" +import { chromeScheduleService } from "../../../../shared/schedule" +import { backgroundUIService } from "../ui" +import { DiscoverService } from "./implementation" + +export const discoverService = new DiscoverService( + discoverStore, + httpService, + chromeScheduleService, + backgroundUIService, + debounceService, +) diff --git a/packages/extension/src/background/__new/services/network/background.test.ts b/packages/extension/src/background/__new/services/network/background.test.ts index 5734c5e18..768ebe2bd 100644 --- a/packages/extension/src/background/__new/services/network/background.test.ts +++ b/packages/extension/src/background/__new/services/network/background.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, vi } from "vitest" +import { Mocked, describe, expect, test, vi } from "vitest" import { Network } from "../../../../shared/network" import { defaultReadonlyNetworks } from "../../../../shared/network/defaults" @@ -7,53 +7,75 @@ import { networksEqual } from "../../../../shared/network/store" import { InMemoryRepository } from "../../../../shared/storage/__new/__test__/inmemoryImplementations" import BackgroundNetworkService from "./background" import { NetworkWithStatus } from "../../../../shared/network/type" +import { IHttpService } from "@argent/shared" +import { ETH_TOKEN_ADDRESS } from "../../../../shared/network/constants" describe("BackgroundNetworkService", () => { const makeService = () => { const networkRepo = new InMemoryRepository({ namespace: "core:allNetworks", compare: networksEqual, + defaults: [ + ...defaultReadonlyNetworks, + { + id: "katana", + chainId: "katana", + name: "Katana", + rpcUrl: "https://katana.rpc", + possibleFeeTokenAddresses: [ETH_TOKEN_ADDRESS], + }, + ], }) const networkWithStatusRepo = new InMemoryRepository({ namespace: "core:allNetworkStatus", compare: networksEqual, }) const getNetworkStatuses = vi.fn() + const httpService = { + get: getNetworkStatuses, + post: vi.fn(), + delete: vi.fn(), + } as Mocked const backgroundNetworkService = new BackgroundNetworkService( networkRepo, networkWithStatusRepo, defaultReadonlyNetworks, - getNetworkStatuses, + httpService, ) return { backgroundNetworkService, networkWithStatusRepo, - getNetworkStatuses, + httpService, } } - test("updateStatuses", async () => { - const { - backgroundNetworkService, - networkWithStatusRepo, - getNetworkStatuses, - } = makeService() + test(" updateStatuses with valid networks", async () => { + const { backgroundNetworkService, networkWithStatusRepo, httpService } = + makeService() - getNetworkStatuses.mockResolvedValueOnce({ - "mainnet-alpha": "ok", - "goerli-alpha": "degraded", + httpService.get.mockResolvedValueOnce({ + state: "green", }) await backgroundNetworkService.updateStatuses() - expect(getNetworkStatuses).toHaveBeenCalled() + expect(httpService.get).toHaveBeenCalled() const [ok] = await networkWithStatusRepo.get( networkSelector("mainnet-alpha"), ) - expect(ok).toHaveProperty("status", "ok") + expect(ok).toHaveProperty("status", "green") + }) + test(" updateStatuses with unknown networks", async () => { + const { backgroundNetworkService, networkWithStatusRepo, httpService } = + makeService() - const [degraded] = await networkWithStatusRepo.get( - networkSelector("goerli-alpha"), - ) - expect(degraded).toHaveProperty("status", "degraded") + httpService.get.mockResolvedValueOnce({ + state: "whatever", + }) + + await backgroundNetworkService.updateStatuses() + expect(httpService.get).toHaveBeenCalled() + + const [ok] = await networkWithStatusRepo.get(networkSelector("katana")) + expect(ok).toHaveProperty("status", "unknown") }) }) diff --git a/packages/extension/src/background/__new/services/network/background.ts b/packages/extension/src/background/__new/services/network/background.ts index 995d43771..d8f540a47 100644 --- a/packages/extension/src/background/__new/services/network/background.ts +++ b/packages/extension/src/background/__new/services/network/background.ts @@ -1,9 +1,14 @@ import { uniqWith } from "lodash-es" -import { Network } from "../../../../shared/network" +import { Network, NetworkStatus } from "../../../../shared/network" import { INetworkRepo, networksEqual } from "../../../../shared/network/store" -import { GetNetworkStatusesFn, IBackgroundNetworkService } from "./interface" +import { IBackgroundNetworkService } from "./interface" import { INetworkWithStatusRepo } from "../../../../shared/network/statusStore" +import { IHttpService } from "@argent/shared" +import urlJoin from "url-join" +import { argentApiNetworkForNetwork } from "../../../../shared/api/headers" +import { ARGENT_NETWORK_STATUS } from "../../../../shared/api/constants" +import { NetworkError } from "../../../../shared/errors/network" export default class BackgroundNetworkService implements IBackgroundNetworkService @@ -12,7 +17,7 @@ export default class BackgroundNetworkService private readonly networkRepo: INetworkRepo, private readonly networkWithStatusRepo: INetworkWithStatusRepo, readonly defaultNetworks: Network[], - private readonly getNetworkStatuses: GetNetworkStatusesFn, + private readonly httpService: IHttpService, ) {} private async loadNetworks() { @@ -23,13 +28,52 @@ export default class BackgroundNetworkService return allNetworks } + async getNetworkStatuses(networks: Network[]) { + return Promise.all( + networks.map(async (network) => { + if (ARGENT_NETWORK_STATUS === undefined) { + throw new NetworkError({ code: "ARGENT_NETWORK_STATUS_NOT_DEFINED" }) + } + const backendNetworkId = argentApiNetworkForNetwork(network.id) + + if (!backendNetworkId) { + return { + id: network.id, + status: "unknown" as NetworkStatus, + } + } + + const url = urlJoin(ARGENT_NETWORK_STATUS, backendNetworkId) + try { + const response = await this.httpService.get<{ + state: NetworkStatus + }>(url) + + return { + status: response.state, + id: network.id, + } + } catch (error) { + return { + id: network.id, + status: "unknown" as NetworkStatus, + } + } + }), + ) + } async updateStatuses() { const networks = await this.loadNetworks() + const networkStatuses = await this.getNetworkStatuses(networks) + const networkWithUpdatedStatuses = networks.map((network) => { return { id: network.id, - status: networkStatuses[network.id] ?? "unknown", + status: + networkStatuses.find( + (networkStatus) => networkStatus.id === network.id, + )?.status ?? "unknown", } }) diff --git a/packages/extension/src/background/__new/services/network/index.ts b/packages/extension/src/background/__new/services/network/index.ts index 27d38cd7f..337cbd931 100644 --- a/packages/extension/src/background/__new/services/network/index.ts +++ b/packages/extension/src/background/__new/services/network/index.ts @@ -1,18 +1,18 @@ import { debounceService } from "../../../../shared/debounce" +import { httpService } from "../../../../shared/http/singleton" import { defaultNetworks } from "../../../../shared/network" import { networkStatusRepo } from "../../../../shared/network/statusStore" import { networkRepo } from "../../../../shared/network/store" import { chromeScheduleService } from "../../../../shared/schedule" import { backgroundUIService } from "../ui" import BackgroundNetworkService from "./background" -import { getNetworkStatuses } from "./status" import { NetworkWorker } from "./worker" export const backgroundNetworkService = new BackgroundNetworkService( networkRepo, networkStatusRepo, defaultNetworks, - getNetworkStatuses, + httpService, ) export const networkWorker = new NetworkWorker( diff --git a/packages/extension/src/background/__new/services/network/status.ts b/packages/extension/src/background/__new/services/network/status.ts deleted file mode 100644 index f9d43e6fd..000000000 --- a/packages/extension/src/background/__new/services/network/status.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Network, NetworkStatus } from "../../../../shared/network" -import { GetNetworkStatusesFn } from "./interface" -import { getProvider } from "../../../../shared/network/provider" - -async function getNetworkStatus(network: Network): Promise { - const provider = getProvider(network) - const sync = await provider.getSyncingStats() // throws if not connected - - // Can only be false but inproperly typed in the current version of snjs - if (typeof sync === "boolean") { - // not syncing - return "ok" - } - - const blockDifference = sync.highest_block_num - sync.current_block_num - if (blockDifference <= 2) { - return "ok" - } - return "degraded" -} - -export const getNetworkStatuses: GetNetworkStatusesFn = async (networks) => { - const statuses = await Promise.allSettled( - networks.map(async (network) => getNetworkStatus(network)), - ) - - return Object.fromEntries( - networks.map(({ id }, i) => { - const promise = statuses[i] - if (promise.status === "fulfilled") { - return [id, promise.value] - } else { - return [id, "error"] - } - }), - ) -} diff --git a/packages/extension/src/background/__new/services/network/worker.ts b/packages/extension/src/background/__new/services/network/worker.ts index 3dc3a9f4d..70977bcb2 100644 --- a/packages/extension/src/background/__new/services/network/worker.ts +++ b/packages/extension/src/background/__new/services/network/worker.ts @@ -1,7 +1,7 @@ import { IScheduleService } from "../../../../shared/schedule/interface" import { IBackgroundNetworkService } from "./interface" -// import { RefreshInterval } from "../../../../shared/config" -// import { everyWhenOpen } from "../worker/schedule/decorators" +import { RefreshInterval } from "../../../../shared/config" +import { everyWhenOpen } from "../worker/schedule/decorators" import { IBackgroundUIService } from "../ui/interface" import { IDebounceService } from "../../../../shared/debounce" @@ -15,14 +15,13 @@ export class NetworkWorker { private readonly debounceService: IDebounceService, ) {} - // Temp: This is commented out until we have a final decision on RPC provider - //updateNetworkStatuses = everyWhenOpen( - // this.backgroundUIService, - // this.scheduleService, - // this.debounceService, - // RefreshInterval.MEDIUM, - // "NetworkWorker.updateNetworkStatuses", - //)(async (): Promise => { - // await this.backgroundNetworkService.updateStatuses() - //}) + updateNetworkStatuses = everyWhenOpen( + this.backgroundUIService, + this.scheduleService, + this.debounceService, + RefreshInterval.MEDIUM, + "NetworkWorker.updateNetworkStatuses", + )(async (): Promise => { + await this.backgroundNetworkService.updateStatuses() + }) } diff --git a/packages/extension/src/background/__new/services/nft/worker/implementation.ts b/packages/extension/src/background/__new/services/nft/worker/implementation.ts index 283af4ba6..077765e4d 100644 --- a/packages/extension/src/background/__new/services/nft/worker/implementation.ts +++ b/packages/extension/src/background/__new/services/nft/worker/implementation.ts @@ -48,7 +48,10 @@ export class NftsWorker { changeSet?.oldValue, ) if (hasSuccessTx) { - setTimeout(() => void this.updateNftsCallback(), 5000) // Add a delay so the backend has time to index the nft + setTimeout( + () => void this.updateNftsCallback(), + RefreshInterval.FAST * 1000, + ) // Add a delay so the backend has time to index the nft } }) } @@ -83,6 +86,7 @@ export class NftsWorker { "starknet", account.networkId, contractsAddresses, + addressSchema.parse(account.address), ) } catch (e) { console.error(e) diff --git a/packages/extension/src/background/__new/services/provision/implementation.ts b/packages/extension/src/background/__new/services/provision/implementation.ts new file mode 100644 index 000000000..e045b47f1 --- /dev/null +++ b/packages/extension/src/background/__new/services/provision/implementation.ts @@ -0,0 +1,15 @@ +import { IHttpService } from "@argent/shared" +import { IProvisionService } from "../../../../shared/provision/interface" +import { ProvisionStatus } from "../../../../shared/provision/types" +import { PROVISION_STATUS_ENDPOINT } from "../../../../shared/api/constants" + +export class ProvisionService implements IProvisionService { + constructor(private httpService: IHttpService) {} + + getStatus() { + if (!PROVISION_STATUS_ENDPOINT) { + throw new Error("Provision status endpoint not defined") + } + return this.httpService.get(PROVISION_STATUS_ENDPOINT) + } +} diff --git a/packages/extension/src/background/__new/services/provision/index.ts b/packages/extension/src/background/__new/services/provision/index.ts new file mode 100644 index 000000000..e04d288b2 --- /dev/null +++ b/packages/extension/src/background/__new/services/provision/index.ts @@ -0,0 +1,4 @@ +import { httpService } from "../../../../shared/http/singleton" +import { ProvisionService } from "./implementation" + +export const provisionService = new ProvisionService(httpService) diff --git a/packages/extension/src/background/__new/services/recovery/implementation.test.ts b/packages/extension/src/background/__new/services/recovery/implementation.test.ts index 03cf26843..0ddba8e10 100644 --- a/packages/extension/src/background/__new/services/recovery/implementation.test.ts +++ b/packages/extension/src/background/__new/services/recovery/implementation.test.ts @@ -35,9 +35,12 @@ describe("BackgroundRecoveryService", () => { const { recoveryStore, wallet, backgroundRecoveryService } = makeService() await backgroundRecoveryService.byBackup("foo") expect(recoveryStore.set).toHaveBeenNthCalledWith(1, { - isRecovering: true, + errorRecovering: false, }) expect(recoveryStore.set).toHaveBeenNthCalledWith(2, { + isRecovering: true, + }) + expect(recoveryStore.set).toHaveBeenNthCalledWith(3, { isRecovering: false, }) expect(wallet.importBackup).toHaveBeenCalledWith("foo") @@ -53,9 +56,12 @@ describe("BackgroundRecoveryService", () => { } = makeService() await backgroundRecoveryService.bySeedPhrase("foo", "bar") expect(recoveryStore.set).toHaveBeenNthCalledWith(1, { - isRecovering: true, + errorRecovering: false, }) expect(recoveryStore.set).toHaveBeenNthCalledWith(2, { + isRecovering: true, + }) + expect(recoveryStore.set).toHaveBeenNthCalledWith(3, { isRecovering: false, }) expect(wallet.restoreSeedPhrase).toHaveBeenCalledWith("foo", "bar") diff --git a/packages/extension/src/background/__new/services/recovery/implementation.ts b/packages/extension/src/background/__new/services/recovery/implementation.ts index 648354083..f0eb7bc21 100644 --- a/packages/extension/src/background/__new/services/recovery/implementation.ts +++ b/packages/extension/src/background/__new/services/recovery/implementation.ts @@ -1,4 +1,5 @@ import { IRecoveryService } from "../../../../shared/recovery/service/interface" +import { recoveredAtKeyValueStore } from "../../../../shared/recovery/storage" import { IRecoveryStorage } from "../../../../shared/recovery/types" import { IObjectStore } from "../../../../shared/storage/__new/interface" import { TransactionTrackerWorker } from "../../../transactions/service/starknet.service" @@ -15,22 +16,51 @@ export class BackgroundRecoveryService implements IRecoveryService { await this.recoveryStore.set({ isRecovering }) } + private async setErrorRecovering(errorRecovering: string | false) { + await this.recoveryStore.set({ errorRecovering }) + } + async byBackup(backup: string) { try { + await this.clearErrorRecovering() await this.setIsRecovering(true) await this.wallet.importBackup(backup) + } catch (error) { + console.error(error) + await this.setErrorRecovering(`${error}`) + throw error } finally { + await this.updateLastRecoveredAt() await this.setIsRecovering(false) } } + private async updateLastRecoveredAt() { + const lastRecoveredAt = await recoveredAtKeyValueStore.get( + "lastRecoveredAt", + ) + if (lastRecoveredAt === null) { + void recoveredAtKeyValueStore.set("lastRecoveredAt", Date.now()) + } + } + async bySeedPhrase(seedPhrase: string, newPassword: string) { try { + await this.clearErrorRecovering() await this.setIsRecovering(true) await this.wallet.restoreSeedPhrase(seedPhrase, newPassword) void this.transactionTracker.loadHistory() + } catch (error) { + console.error(error) + await this.setErrorRecovering(`${error}`) + throw error } finally { + await this.updateLastRecoveredAt() await this.setIsRecovering(false) } } + + async clearErrorRecovering() { + await this.setErrorRecovering(false) + } } diff --git a/packages/extension/src/background/__new/services/riskAssessment/background.ts b/packages/extension/src/background/__new/services/riskAssessment/background.ts new file mode 100644 index 000000000..c0fd2e195 --- /dev/null +++ b/packages/extension/src/background/__new/services/riskAssessment/background.ts @@ -0,0 +1,45 @@ +import { IHttpService } from "@argent/shared" +import { + DappContext, + IRiskAssessmentService, +} from "../../../../shared/riskAssessment/interface" +import { RiskAssessment } from "../../../../shared/riskAssessment/schema" +import { ARGENT_TRANSACTION_REVIEW_API_BASE_URL } from "../../../../shared/api/constants" +import urlJoin from "url-join" +import { argentApiNetworkForNetwork } from "../../../../shared/api/headers" +import { RiskAssessmentError } from "../../../../shared/errors/riskAssessment" + +const riskAssessmentBaseEndpoint = urlJoin( + ARGENT_TRANSACTION_REVIEW_API_BASE_URL || "", + "domains/info/starknet", +) + +export default class BackgroundRiskAssessmentService + implements IRiskAssessmentService +{ + constructor(private httpService: IHttpService) {} + + private getRiskAssessmentEndpoint({ + dappDomain, + network, + }: { + dappDomain: string + network: string + }) { + return `${riskAssessmentBaseEndpoint}/${argentApiNetworkForNetwork( + network, + )}?domain=${dappDomain}` + } + + async assessRisk({ dappContext }: { dappContext: DappContext }) { + try { + const riskAssessmentEndpoint = this.getRiskAssessmentEndpoint(dappContext) + const result = await this.httpService.get( + riskAssessmentEndpoint, + ) + return result + } catch (e) { + throw new RiskAssessmentError({ code: "ERROR_FETCHING" }) + } + } +} diff --git a/packages/extension/src/background/__new/services/riskAssessment/index.ts b/packages/extension/src/background/__new/services/riskAssessment/index.ts new file mode 100644 index 000000000..ed08e6656 --- /dev/null +++ b/packages/extension/src/background/__new/services/riskAssessment/index.ts @@ -0,0 +1,6 @@ +import { httpService } from "../../../../shared/http/singleton" +import BackgroundRiskAssessmentService from "./background" + +export const riskAssessmentService = new BackgroundRiskAssessmentService( + httpService, +) diff --git a/packages/extension/src/background/__new/services/sentry/index.ts b/packages/extension/src/background/__new/services/sentry/index.ts new file mode 100644 index 000000000..e89bce14e --- /dev/null +++ b/packages/extension/src/background/__new/services/sentry/index.ts @@ -0,0 +1,5 @@ +import { baseSentryOptions } from "../../../../shared/sentry/options" +import { settingsStore } from "../../../../shared/settings" +import { SentryWorker } from "./worker" + +export const sentryWorker = new SentryWorker(baseSentryOptions, settingsStore) diff --git a/packages/extension/src/background/__new/services/sentry/worker.ts b/packages/extension/src/background/__new/services/sentry/worker.ts new file mode 100644 index 000000000..3bee666bc --- /dev/null +++ b/packages/extension/src/background/__new/services/sentry/worker.ts @@ -0,0 +1,50 @@ +import * as Sentry from "@sentry/browser" + +import { ISettingsStorage } from "../../../../shared/settings/types" +import { KeyValueStorage } from "../../../../shared/storage" + +export class SentryWorker { + constructor( + private readonly baseSentryOptions: Sentry.BrowserOptions, + private readonly settingsStore: KeyValueStorage, + ) { + // init Sentry immediately to capture any exceptions on startup + Sentry.init({ + ...this.baseSentryOptions, + enabled: true, + }) + this.settingsStore.subscribe( + "privacyErrorReporting", + this.onSettingsStoreChange.bind(this), + ) + this.settingsStore.subscribe( + "privacyAutomaticErrorReporting", + this.onSettingsStoreChange.bind(this), + ) + // re-init with async preferences + void this.initSentry() + } + + onSettingsStoreChange() { + void this.initSentry() + } + + async initSentry() { + const privacyErrorReporting = await this.settingsStore.get( + "privacyErrorReporting", + ) + const privacyAutomaticErrorReporting = await this.settingsStore.get( + "privacyAutomaticErrorReporting", + ) + Sentry.init({ + ...this.baseSentryOptions, + enabled: privacyErrorReporting, + beforeSend(event) { + if (privacyAutomaticErrorReporting) { + return event + } + return null + }, + }) + } +} diff --git a/packages/extension/src/background/__new/services/token/worker/implementation.test.ts b/packages/extension/src/background/__new/services/token/worker/implementation.test.ts index 1e8098e73..62d0aac7b 100644 --- a/packages/extension/src/background/__new/services/token/worker/implementation.test.ts +++ b/packages/extension/src/background/__new/services/token/worker/implementation.test.ts @@ -12,10 +12,12 @@ import { IScheduleService } from "../../../../../shared/schedule/interface" import { emitterMock, recoverySharedServiceMock, + sessionServiceMock, } from "../../../../wallet/test.utils" import { IBackgroundUIService } from "../../ui/interface" import { getMockNetwork } from "../../../../../../test/network.mock" import { + getMockApiTokenDetails, getMockBaseToken, getMockToken, getMockTokenPriceDetails, @@ -49,7 +51,6 @@ describe("TokenWorker", () => { beforeEach(() => { // Initialize mocks mockTokenService = { - fetchTokensFromBackend: vi.fn(), updateTokens: vi.fn(), addToken: vi.fn(), removeToken: vi.fn(), @@ -65,6 +66,11 @@ describe("TokenWorker", () => { updateTokenPrices: vi.fn(), getFeeTokens: vi.fn(), getBestFeeToken: vi.fn(), + fetchAccountTokenBalancesFromBackend: vi.fn(), + getTokensInfoFromBackendForNetwork: vi.fn(), + preferFeeToken: vi.fn(), + getFeeTokenPreference: vi.fn(), + handleProvisionTokens: vi.fn(), } as Mocked mockNetworkService = { @@ -110,6 +116,7 @@ describe("TokenWorker", () => { mockScheduleService, mockDebounceService, mockActivityService, + sessionServiceMock, ) }) @@ -117,33 +124,62 @@ describe("TokenWorker", () => { it("should fetch tokens for all networks and update the token service", async () => { // Arrange const mockNetworks = [ - getMockNetwork({ id: "1" }), - getMockNetwork({ id: "2" }), + getMockNetwork({ id: "mainnet-alpha" }), + getMockNetwork({ id: "invalid-backend-network" }), ] + const mockTokens = [ - [getMockToken({ address: tokenAddress1, networkId: "1" })], - [getMockToken({ address: tokenAddress2, networkId: "2" })], + getMockToken({ address: tokenAddress1 }), + getMockToken({ address: tokenAddress2 }), + ] + + const mockApiTokens = [ + getMockApiTokenDetails({ address: tokenAddress1 }), + getMockApiTokenDetails({ address: tokenAddress2 }), ] + mockNetworkService.get.mockResolvedValue(mockNetworks) - mockTokenService.fetchTokensFromBackend - .mockResolvedValueOnce(mockTokens[0]) - .mockResolvedValueOnce(mockTokens[1]) - await tokenWorker.fetchAndUpdateTokensFromBackend() + mockTokenService.getTokensInfoFromBackendForNetwork + .mockResolvedValueOnce([mockApiTokens[0]]) + .mockResolvedValueOnce([mockApiTokens[1]]) + + mockTokenService.getTokens + .mockResolvedValueOnce([mockTokens[0]]) + .mockResolvedValueOnce([mockTokens[1]]) + + await tokenWorker.refreshTokenRepoWithTokensInfoFromBackend() expect(mockNetworkService.get).toHaveBeenCalled() - expect(mockTokenService.fetchTokensFromBackend).toHaveBeenCalledTimes(2) - expect(mockTokenService.fetchTokensFromBackend).toHaveBeenNthCalledWith( - 1, - mockNetworks[0].id, - ) - expect(mockTokenService.fetchTokensFromBackend).toHaveBeenNthCalledWith( - 2, - mockNetworks[1].id, - ) - expect(mockTokenService.updateTokens).toHaveBeenCalledWith( - mockTokens.flat(), - ) + + expect( + mockTokenService.getTokensInfoFromBackendForNetwork, + ).toHaveBeenCalledTimes(2) + expect( + mockTokenService.getTokensInfoFromBackendForNetwork, + ).toHaveBeenNthCalledWith(1, mockNetworks[0].id) + expect( + mockTokenService.getTokensInfoFromBackendForNetwork, + ).toHaveBeenNthCalledWith(2, mockNetworks[1].id) + + // merged tokens + expect(mockTokenService.updateTokens).toHaveBeenNthCalledWith(1, [ + { + ...mockApiTokens[0], + ...mockTokens[0], + }, + ]) + expect(mockTokenService.updateTokens).toHaveBeenNthCalledWith(2, [ + { + ...mockApiTokens[1], // from api + ...mockTokens[1], + }, + { + ...mockApiTokens[1], // from tradable + ...mockTokens[1], + networkId: "invalid-backend-network", + }, + ]) }) }) diff --git a/packages/extension/src/background/__new/services/token/worker/implementation.ts b/packages/extension/src/background/__new/services/token/worker/implementation.ts index 1d53f8210..82eae4db2 100644 --- a/packages/extension/src/background/__new/services/token/worker/implementation.ts +++ b/packages/extension/src/background/__new/services/token/worker/implementation.ts @@ -1,3 +1,8 @@ +import { + isArgentNetworkId, + includesAddress, + isEqualAddress, +} from "@argent/shared" import { RefreshInterval } from "../../../../../shared/config" import type { IDebounceService } from "../../../../../shared/debounce" import { defaultNetwork } from "../../../../../shared/network" @@ -18,21 +23,22 @@ import { BaseWalletAccount } from "../../../../../shared/wallet.model" import type { WalletStorageProps } from "../../../../../shared/wallet/walletStore" import { Recovered } from "../../../../wallet/recovery/interface" import { WalletRecoverySharedService } from "../../../../wallet/recovery/shared.service" +import { WalletSessionService } from "../../../../wallet/session/session.service" import { TokenActivity, type IActivityService, type TokenActivityPayload, + ProvisionActivity, } from "../../activity/interface" import type { IBackgroundUIService } from "../../ui/interface" import { every, everyWhenOpen, onInstallAndUpgrade, - onStartup, } from "../../worker/schedule/decorators" import { pipe } from "../../worker/schedule/pipe" - -const NETWORKS_WITH_BACKEND_SUPPORT = ["goerli-alpha", "mainnet-alpha"] +import { mergeTokensWithDefaults } from "../../../../../shared/token/__new/repository/mergeTokens" +import { ProvisionActivityPayload } from "../../../../../shared/activity/types" /** * This class is responsible for managing token updates, including token balances and prices. @@ -49,6 +55,7 @@ export class TokenWorker { private readonly scheduleService: IScheduleService, private readonly debounceService: IDebounceService, private readonly activityService: IActivityService, + private readonly sessionService: WalletSessionService, ) { // Listen for account changes this.walletStore.subscribe( @@ -73,6 +80,12 @@ export class TokenWorker { TokenActivity, this.onTokenActivity.bind(this), ) + + // Listen to provision + this.activityService.emitter.on( + ProvisionActivity, + this.onProvisionActivity.bind(this), + ) } async getSelectedAccount() { @@ -84,15 +97,15 @@ export class TokenWorker { * Update tokens * Fetches tokens for all networks and updates the token service */ - runFetchAndUpdateTokensFromBackend = pipe( + runRefreshTokenRepoWithTokensInfoFromBackend = pipe( onInstallAndUpgrade(this.scheduleService), // This will run the function on update every( this.scheduleService, - RefreshInterval.VERY_SLOW, - "TokenWorker.updateTokens", - ), // This will run the function every 24 hours + RefreshInterval.SLOW, + "TokenWorker.refreshTokenRepoWithTokensInfoFromBackend", + ), // This will run the function every 5 mins )(async (): Promise => { - await this.fetchAndUpdateTokensFromBackend() + await this.refreshTokenRepoWithTokensInfoFromBackend() }) /** @@ -119,19 +132,40 @@ export class TokenWorker { this.backgroundUIService, this.scheduleService, this.debounceService, - RefreshInterval.MEDIUM, + RefreshInterval.FAST, "TokenWorker.fetchAndUpdateTokenPricesFromBackend", ), // This will run the function every minute when the UI is open )(async (): Promise => { await this.fetchAndUpdateTokenPricesFromBackend() }) + runOnOpenAndUnlocked = pipe( + everyWhenOpen( + this.backgroundUIService, + this.scheduleService, + this.debounceService, + RefreshInterval.MEDIUM, + "TokenWorker.onOpenAndUnlocked", + ), // This will run the function when the wallet is opened and unlocked, debounced to one minute + )(async () => { + const selectedAccount = await this.getSelectedAccount() + if (!selectedAccount) { + return + } + await this.runUpdatesForAccount(selectedAccount) + }) + async onSelectedAccountChange(account?: BaseWalletAccount | null) { if (!account) { return } + await this.runUpdatesForAccount(account) + } + + async runUpdatesForAccount(account: BaseWalletAccount) { void this.maybeUpdateTokensFromBackendForAccount(account) void this.updateTokenBalancesFromOnChain(account) + void this.discoverTokensFromBackendForAccount(account) } async onTransactionRepoChange(changeSet: StorageChange) { @@ -171,20 +205,43 @@ export class TokenWorker { void this.fetchAndUpdateTokenPricesFromBackend() } - async fetchAndUpdateTokensFromBackend() { + async refreshTokenRepoWithTokensInfoFromBackend() { const networks = await this.networkService.get() - // Fetch tokens for all networks in parallel - const tokensFromAllNetworks = await Promise.allSettled( + await Promise.allSettled( networks.map((network) => - this.tokenService.fetchTokensFromBackend(network.id), + this.refreshTokenRepoWithTokensInfoFromBackendForNetwork(network.id), ), ) - const tokens = tokensFromAllNetworks - .map((result) => result.status === "fulfilled" && result.value) - .filter((t): t is Token[] => Boolean(t)) - .flat() + } - await this.tokenService.updateTokens(tokens) + async refreshTokenRepoWithTokensInfoFromBackendForNetwork(networkId: string) { + const tokensInfoOnNetwork = + await this.tokenService.getTokensInfoFromBackendForNetwork(networkId) + if (!tokensInfoOnNetwork) { + return + } + const tokensOnNetwork = await this.tokenService.getTokens( + (token) => token.networkId === networkId, + ) + // refresh the local tokens with tokens info + const updatedTokens = tokensOnNetwork.map((tokenOnNetwork) => { + const tokenInfoOnNetwork = tokensInfoOnNetwork.find( + (tokenInfoOnNetwork) => + isEqualAddress(tokenInfoOnNetwork.address, tokenOnNetwork.address), + ) + return tokenInfoOnNetwork + ? { ...tokenOnNetwork, ...tokenInfoOnNetwork } + : tokenOnNetwork + }) + + // explicitly filter out tokens that are not tradable + const tradableTokens = tokensInfoOnNetwork + .filter((token) => token.tradable) + .map((t) => ({ ...t, networkId })) + + await this.tokenService.updateTokens( + mergeTokensWithDefaults(tradableTokens, updatedTokens), + ) } async updateTokenBalancesFromOnChain( @@ -201,7 +258,7 @@ export class TokenWorker { const tokensWithBalance = await this.tokenService.fetchTokenBalancesFromOnChain(accounts) - return await this.tokenService.updateTokenBalances(tokensWithBalance) + return await this.tokenService.updateTokenBalances(tokensWithBalance) // Update token balances in the token service } async maybeUpdateTokensFromBackendForAccount(account: BaseWalletAccount) { @@ -210,7 +267,7 @@ export class TokenWorker { ) if (!tokens.length) { - await this.fetchAndUpdateTokensFromBackend() + await this.refreshTokenRepoWithTokensInfoFromBackend() } } @@ -228,7 +285,7 @@ export class TokenWorker { if (!selectedAccount) { return } - if (NETWORKS_WITH_BACKEND_SUPPORT.includes(selectedAccount.networkId)) { + if (isArgentNetworkId(selectedAccount.networkId)) { return } await this.fetchAndUpdateTokenBalancesFromOnChain(selectedAccount) @@ -254,7 +311,7 @@ export class TokenWorker { */ async onRecovered(recoveredAccounts: BaseWalletAccount[]) { if (recoveredAccounts.length > 0) { - await this.fetchAndUpdateTokensFromBackend() + await this.refreshTokenRepoWithTokensInfoFromBackend() await this.fetchAndUpdateTokenPricesFromBackend() await this.fetchAndUpdateTokenBalancesFromOnChain(recoveredAccounts) } @@ -267,4 +324,54 @@ export class TokenWorker { async onTokenActivity({ accounts, tokens }: TokenActivityPayload) { await this.fetchAndUpdateTokenBalancesFromOnChain(accounts, tokens) } + + async discoverTokensFromBackendForAccount(account: BaseWalletAccount) { + const accountTokenBalancesFromBackend = + await this.tokenService.fetchAccountTokenBalancesFromBackend(account) + + const tokensOnNetwork = await this.tokenService.getTokens( + (token) => account.networkId === token.networkId, + ) + + const knownTokenAddresses = tokensOnNetwork.map((token) => token.address) + + const discoveredTokens = accountTokenBalancesFromBackend.filter( + (accountTokenBalance) => { + return !includesAddress( + accountTokenBalance.address, + knownTokenAddresses, + ) + }, + ) + if (!discoveredTokens.length) { + return + } + + const tokensInfoOnNetwork = + await this.tokenService.getTokensInfoFromBackendForNetwork( + account.networkId, + ) + if (!tokensInfoOnNetwork) { + return + } + /** both sets of tokens are already on the same network */ + const discoveredTokensInfo: Token[] = [] + discoveredTokens.forEach((discoveredToken) => { + const tokenInfo = tokensInfoOnNetwork.find((tokenInfo) => + isEqualAddress(discoveredToken.address, tokenInfo.address), + ) + if (tokenInfo) { + discoveredTokensInfo.push({ + ...tokenInfo, + networkId: account.networkId, + }) + } + }) + await this.tokenService.addToken(discoveredTokensInfo) + } + + async onProvisionActivity(payload: ProvisionActivityPayload) { + await this.tokenService.handleProvisionTokens(payload) + await this.refreshTokenRepoWithTokensInfoFromBackend() + } } diff --git a/packages/extension/src/background/__new/services/token/worker/index.ts b/packages/extension/src/background/__new/services/token/worker/index.ts index 7c065d3d2..f200df4fb 100644 --- a/packages/extension/src/background/__new/services/token/worker/index.ts +++ b/packages/extension/src/background/__new/services/token/worker/index.ts @@ -1,7 +1,10 @@ import { activityService } from "../../activity" import { backgroundUIService } from "../../ui" import { transactionsRepo } from "../../../../../shared/transactions/store" -import { recoverySharedService } from "../../../../walletSingleton" +import { + recoverySharedService, + sessionService, +} from "../../../../walletSingleton" import { debounceService } from "../../../../../shared/debounce" import { networkService } from "../../../../../shared/network/service" import { chromeScheduleService } from "../../../../../shared/schedule" @@ -22,4 +25,5 @@ export const tokenWorker = new TokenWorker( chromeScheduleService, debounceService, activityService, + sessionService, ) diff --git a/packages/extension/src/background/__new/services/transactionReview/background.test.ts b/packages/extension/src/background/__new/services/transactionReview/background.test.ts new file mode 100644 index 000000000..6bd9eda32 --- /dev/null +++ b/packages/extension/src/background/__new/services/transactionReview/background.test.ts @@ -0,0 +1,222 @@ +import { Address, IHttpService } from "@argent/shared" +import { Account, EstimateFee } from "starknet6" +import { Mocked, describe, expect, test, vi } from "vitest" + +import type { KeyValueStorage } from "../../../../shared/storage" +import type { + ITransactionReviewLabelsStore, + TransactionReviewTransactions, +} from "../../../../shared/transactionReview/interface" +import type { WalletAccount } from "../../../../shared/wallet.model" +import type { Wallet } from "../../../wallet" +import BackgroundTransactionReviewService from "./background" +import type { ITransactionReviewWorker } from "./worker/interface" + +import sendFixture from "../../../../shared/transactionReview/__fixtures__/send.json" +import simulationErrorUnexpectedFixture from "../../../../shared/transactionReview/__fixtures__/simulation-error-unexpected.json" + +describe("BackgroundTransactionReviewService", () => { + const makeService = () => { + const walletSingleton = { + getSelectedAccount: vi.fn(), + getSelectedStarknetAccount: vi.fn(), + } as unknown as Mocked + + const httpService = { + get: vi.fn(), + post: vi.fn(), + } as unknown as Mocked + + const transactionReviewLabelsStore = { + get: vi.fn(), + set: vi.fn(), + subscribe: vi.fn(), + } as unknown as Mocked> + + const transactionReviewWorker = { + maybeUpdateLabels: vi.fn(), + } as unknown as Mocked + + const backgroundTransactionReviewService = + new BackgroundTransactionReviewService( + walletSingleton, + httpService, + transactionReviewLabelsStore, + transactionReviewWorker, + ) + + const networkId = "goerli-alpha" + + walletSingleton.getSelectedAccount.mockResolvedValue({ + address: "0x123", + networkId, + network: { + id: networkId, + }, + } as WalletAccount) + + const starknetAccount = { + cairoVersion: "1", + getNonce: vi.fn(), + getChainId: vi.fn(), + estimateFee: vi.fn(), + } as unknown as Mocked + + starknetAccount.estimateFee.mockResolvedValue({ + gas_consumed: 123n, + gas_price: 456n, + } as EstimateFee) + + walletSingleton.getSelectedStarknetAccount.mockResolvedValue( + starknetAccount, + ) + + const feeTokenAddress: Address = "0x123456" + + return { + backgroundTransactionReviewService, + walletSingleton, + httpService, + transactionReviewLabelsStore, + transactionReviewWorker, + feeTokenAddress, + starknetAccount, + } + } + describe("simulateAndReview", () => { + describe("when backend returns success", () => { + describe("and there are no errors", () => { + test("returns simulation and review", async () => { + const { + backgroundTransactionReviewService, + httpService, + feeTokenAddress, + } = makeService() + + const transactions: TransactionReviewTransactions[] = [ + { + type: "INVOKE", + calls: [], + }, + ] + + httpService.post.mockResolvedValueOnce(sendFixture) + + const result = + await backgroundTransactionReviewService.simulateAndReview({ + transactions, + feeTokenAddress, + }) + + expect(result).toMatchObject(sendFixture) + + expect(result.enrichedFeeEstimation).toMatchInlineSnapshot(` + { + "deployment": undefined, + "transactions": { + "amount": 1674n, + "feeTokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "max": { + "maxFee": 3348008526928n, + }, + "pricePerUnit": 1000000009n, + }, + } + `) + }) + }) + describe("and there are errors", () => { + test("falls back to on-chain simulation", async () => { + const { + backgroundTransactionReviewService, + httpService, + feeTokenAddress, + starknetAccount, + } = makeService() + + const transactions: TransactionReviewTransactions[] = [ + { + type: "INVOKE", + calls: [], + }, + ] + + httpService.post.mockResolvedValueOnce( + simulationErrorUnexpectedFixture, + ) + + const fallbackToOnchainFeeEstimationSpy = vi.spyOn( + backgroundTransactionReviewService, + "fallbackToOnchainFeeEstimation", + ) + + const result = + await backgroundTransactionReviewService.simulateAndReview({ + transactions, + feeTokenAddress, + }) + + expect(fallbackToOnchainFeeEstimationSpy).toHaveBeenCalledOnce() + + expect(starknetAccount.estimateFee).toHaveBeenCalledOnce() + + expect(result).toMatchObject({ + isBackendDown: true, + enrichedFeeEstimation: { + transactions: { + amount: 123n, + feeTokenAddress, + pricePerUnit: 456n, + }, + }, + }) + }) + }) + describe("when backend fails with error", () => { + test("falls back to on-chain simulation", async () => { + const { + backgroundTransactionReviewService, + httpService, + feeTokenAddress, + starknetAccount, + } = makeService() + + const transactions: TransactionReviewTransactions[] = [ + { + type: "INVOKE", + calls: [], + }, + ] + + httpService.post.mockRejectedValueOnce(new Error()) + + const fallbackToOnchainFeeEstimationSpy = vi.spyOn( + backgroundTransactionReviewService, + "fallbackToOnchainFeeEstimation", + ) + + const result = + await backgroundTransactionReviewService.simulateAndReview({ + transactions, + feeTokenAddress, + }) + + expect(fallbackToOnchainFeeEstimationSpy).toHaveBeenCalledOnce() + + expect(starknetAccount.estimateFee).toHaveBeenCalledOnce() + + expect(result).toMatchObject({ + isBackendDown: true, + enrichedFeeEstimation: { + transactions: { + amount: 123n, + feeTokenAddress, + pricePerUnit: 456n, + }, + }, + }) + }) + }) + }) + }) +}) diff --git a/packages/extension/src/background/__new/services/transactionReview/background.ts b/packages/extension/src/background/__new/services/transactionReview/background.ts index 4f45459e8..adfcdbb0d 100644 --- a/packages/extension/src/background/__new/services/transactionReview/background.ts +++ b/packages/extension/src/background/__new/services/transactionReview/background.ts @@ -1,6 +1,6 @@ import urlJoin from "url-join" -import { type IHttpService, ensureArray } from "@argent/shared" +import { type IHttpService, ensureArray, Address } from "@argent/shared" import { Account, CairoVersion, @@ -8,9 +8,8 @@ import { Calldata, Invocations, TransactionType, - hash, num, -} from "starknet" +} from "starknet6" import type { ITransactionReviewLabelsStore, @@ -31,8 +30,10 @@ import { EstimatedFees } from "../../../../shared/transactionSimulation/fees/fee import { KeyValueStorage } from "../../../../shared/storage" import { ITransactionReviewWorker } from "./worker/interface" import { ARGENT_TRANSACTION_REVIEW_API_BASE_URL } from "../../../../shared/api/constants" -import { ETH_TOKEN_ADDRESS } from "../../../../shared/network/constants" import { getEstimatedFeeFromSimulationAndRespectWatermarkFee } from "../../../../shared/transactionSimulation/utils" +import { getTxVersionFromFeeToken } from "../../../../shared/utils/getTransactionVersion" +import { isArgentNetwork } from "../../../../shared/network/utils" +import { getNonce } from "../../../nonce" interface ApiTransactionReviewV2RequestBody { transactions: Array<{ @@ -66,10 +67,12 @@ export default class BackgroundTransactionReviewService starknetAccount, calls, isDeployed, + feeTokenAddress, }: { starknetAccount: Account calls: Call[] isDeployed: boolean + feeTokenAddress: Address }) { try { const selectedAccount = await this.wallet.getSelectedAccount() @@ -78,9 +81,11 @@ export default class BackgroundTransactionReviewService throw new AccountError({ code: "NOT_FOUND" }) } + const version = getTxVersionFromFeeToken(feeTokenAddress) + const fees: EstimatedFees = { transactions: { - feeTokenAddress: ETH_TOKEN_ADDRESS, + feeTokenAddress, amount: 0n, pricePerUnit: 0n, }, @@ -100,11 +105,14 @@ export default class BackgroundTransactionReviewService payload: calls, }, ] - const [deployEstimate, txEstimate] = - await starknetAccount.estimateFeeBulk(bulkTransactions, { - skipValidate: true, + const [deployEstimate, txEstimate] = await starknetAccount + .estimateFeeBulk(bulkTransactions, { + version, + }) + .catch((error) => { + console.error(error) + throw error }) - if ( !deployEstimate.gas_consumed || !deployEstimate.gas_price || @@ -118,12 +126,12 @@ export default class BackgroundTransactionReviewService } fees.deployment = { - feeTokenAddress: ETH_TOKEN_ADDRESS, + feeTokenAddress, amount: deployEstimate.gas_consumed, pricePerUnit: deployEstimate.gas_price, } fees.transactions = { - feeTokenAddress: ETH_TOKEN_ADDRESS, + feeTokenAddress, amount: txEstimate.gas_consumed, pricePerUnit: txEstimate.gas_price, } @@ -133,6 +141,7 @@ export default class BackgroundTransactionReviewService calls, { skipValidate: true, + version, }, ) @@ -144,13 +153,16 @@ export default class BackgroundTransactionReviewService } fees.transactions = { - feeTokenAddress: ETH_TOKEN_ADDRESS, + feeTokenAddress, amount: gas_consumed, pricePerUnit: gas_price, } } - await addEstimatedFee(fees, calls) + await addEstimatedFee(fees, { + type: TransactionType.INVOKE, + payload: calls, + }) return fees } catch (error) { @@ -216,26 +228,50 @@ export default class BackgroundTransactionReviewService simulateAndReviewResult, ) - await addEstimatedFee( - fee, - initialTransactions[isDeploymentTransaction ? 1 : 0].calls ?? [], - ) + await addEstimatedFee(fee, { + type: TransactionType.INVOKE, + payload: initialTransactions[isDeploymentTransaction ? 1 : 0].calls ?? [], + }) return fee } async simulateAndReview({ transactions, + feeTokenAddress, }: { transactions: TransactionReviewTransactions[] + feeTokenAddress: Address }) { + const selectedAccount = await this.wallet.getSelectedAccount() const account = await this.wallet.getSelectedStarknetAccount() - const isDeploymentTransaction = Boolean( - transactions.find((tx) => tx.type === "DEPLOY_ACCOUNT"), + const isDeploymentTransaction = transactions.some( + (tx) => tx.type === "DEPLOY_ACCOUNT", ) + + if (!selectedAccount) { + throw new AccountError({ code: "NOT_SELECTED" }) + } + try { - const nonce = isDeploymentTransaction ? "0x0" : await account.getNonce() - const version = num.toHex(hash.feeTransactionVersion) + if (!isArgentNetwork(selectedAccount?.network)) { + // If it's not an argent network we fallback to onchain fee estimation + console.warn( + `Falling back to onchain fee estimation as ${selectedAccount?.network.id} is not an argent network`, + ) + return this.fallbackToOnchainFeeEstimation({ + account, + transactions, + isDeploymentTransaction, + feeTokenAddress, + }) + } + + const version = getTxVersionFromFeeToken(feeTokenAddress) + + const nonce = isDeploymentTransaction + ? "0x0" + : await getNonce(selectedAccount, account) if (!("getChainId" in account)) { throw new AccountError({ @@ -263,10 +299,10 @@ export default class BackgroundTransactionReviewService }), ), } + const result = await this.httpService.post( simulateAndReviewEndpoint, { - method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", @@ -276,14 +312,18 @@ export default class BackgroundTransactionReviewService simulateAndReviewSchema, ) - // if there is a simulation error then there is also no actual simulation - // or fee information, and no way to proceed with fee estimation - // returning the result will surface the error to the user in the ui + // if there is any simulation error then we should fall-back to on-chain so the user is not blocked const hasSimulationError = result.transactions.some((transaction) => isTransactionSimulationError(transaction), ) if (hasSimulationError) { - return result + console.warn( + `Falling back to onchain fee estimation as there was an error in the backend simulation response:`, + result, + ) + throw new ReviewError({ + code: "BACKEND_SIMULATION_ERROR", + }) } const enrichedFeeEstimation = await this.getEnrichedFeeEstimation( @@ -297,34 +337,54 @@ export default class BackgroundTransactionReviewService } } catch (e) { console.error(e) - try { - const invokeCalls = isDeploymentTransaction - ? this.getCallsFromTx(transactions[1]) - : this.getCallsFromTx(transactions[0]) + return this.fallbackToOnchainFeeEstimation({ + transactions, + account, + isDeploymentTransaction, + feeTokenAddress, + }) + } + } - if (!invokeCalls) { - throw new ReviewError({ - code: "NO_CALLS_FOUND", - }) - } - // Backend is failing we use the fallback method to estimate fees - const enrichedFeeEstimation = await this.fetchFeesOnchain({ - starknetAccount: account, - calls: invokeCalls, - isDeployed: !isDeploymentTransaction, - }) - return { - transactions: [], - enrichedFeeEstimation, - isBackendDown: true, - } - } catch (error) { - console.error(error) + async fallbackToOnchainFeeEstimation({ + transactions, + account, + isDeploymentTransaction, + feeTokenAddress, + }: { + transactions: TransactionReviewTransactions[] + account: Account + isDeploymentTransaction: boolean + feeTokenAddress: Address + }) { + try { + const invokeCalls = isDeploymentTransaction + ? this.getCallsFromTx(transactions[1]) + : this.getCallsFromTx(transactions[0]) + + if (!invokeCalls) { throw new ReviewError({ - message: `${error}`, - code: "SIMULATE_AND_REVIEW_FAILED", + code: "NO_CALLS_FOUND", }) } + // Backend is failing we use the fallback method to estimate fees + const enrichedFeeEstimation = await this.fetchFeesOnchain({ + starknetAccount: account, + calls: invokeCalls, + isDeployed: !isDeploymentTransaction, + feeTokenAddress, + }) + return { + transactions: [], + enrichedFeeEstimation, + isBackendDown: true, + } + } catch (error) { + console.error(error) + throw new ReviewError({ + message: `${error}`, + code: "SIMULATE_AND_REVIEW_FAILED", + }) } } diff --git a/packages/extension/src/background/__new/services/worker/schedule/decorators.test.ts b/packages/extension/src/background/__new/services/worker/schedule/decorators.test.ts index c21d3c273..7e9ead5b8 100644 --- a/packages/extension/src/background/__new/services/worker/schedule/decorators.test.ts +++ b/packages/extension/src/background/__new/services/worker/schedule/decorators.test.ts @@ -8,9 +8,13 @@ import { onlyIfOpen, debounce, everyWhenOpen, + onUnlocked, + onlyIfUnlocked, + everyWhenOpenAndUnlocked, } from "./decorators" import { getMockBackgroundUIService } from "./mockBackgroundUIService" import { getMockDebounceService } from "../../../../../shared/debounce/mock" +import { getMockSessionService } from "./mockSessionService" describe("decorators", () => { describe("onStartup", () => { @@ -90,6 +94,42 @@ describe("decorators", () => { }) }) + describe("onUnlock", async () => { + test("should call the function when the session service is unlocked", async () => { + const [mockSessionServiceManager, mockSessionService] = + getMockSessionService() + const fn = vi.fn() + onUnlocked(mockSessionService)(fn) + expect(fn).toHaveBeenCalledTimes(0) + await mockSessionServiceManager.setLocked(false) + expect(fn).toHaveBeenCalledTimes(1) + await mockSessionServiceManager.setLocked(true) + expect(fn).toHaveBeenCalledTimes(1) + await mockSessionServiceManager.setLocked(false) + expect(fn).toHaveBeenCalledTimes(2) + }) + }) + + describe("onlyIfUnlocked", async () => { + test("should call the function when the session service is unlocked", async () => { + const [mockSessionServiceManager, mockSessionService] = + getMockSessionService() + const fn = vi.fn() + const oiuFn = onlyIfUnlocked(mockSessionService)(fn) + await oiuFn() + expect(fn).toHaveBeenCalledTimes(1) + await mockSessionServiceManager.setLocked(true) + await oiuFn() + expect(fn).toHaveBeenCalledTimes(1) + await mockSessionServiceManager.setLocked(false) + await oiuFn() + expect(fn).toHaveBeenCalledTimes(2) + await mockSessionServiceManager.setLocked(true) + await oiuFn() + expect(fn).toHaveBeenCalledTimes(2) + }) + }) + describe("debounce", () => { test("should call the function when not in debounce interval", async () => { const debounceService = getMockDebounceService() @@ -163,4 +203,75 @@ describe("decorators", () => { expect(fn).toHaveBeenCalledTimes(4) }) }) + + describe("everyWhenOpenAndUnlocked", () => { + test("should call the function when the background ui service is opened and the session service is unlocked", async () => { + const [mockBackgroundUIServiceManager, mockBackgroundUIService] = + getMockBackgroundUIService() + const [scheduleServiceManager, scheduleService] = + createScheduleServiceMock() + const [mockSessionServiceManager, mockSessionService] = + getMockSessionService() + + const debounceService = getMockDebounceService() + const fn = vi.fn() + const fnExec = everyWhenOpenAndUnlocked( + mockBackgroundUIService, + scheduleService, + mockSessionService, + debounceService, + 1, + "test", + )(fn) + + // wait 1 loop + await new Promise((resolve) => setTimeout(resolve, 0)) + + // test scheduleService + expect(scheduleService.registerImplementation).toHaveBeenCalledWith({ + id: expect.stringContaining("every@1s:"), + callback: expect.any(Function), + }) + expect(scheduleService.every).toBeCalledTimes(1) + + // test onLocked and onOpen + await mockSessionServiceManager.setLocked(true) + expect(fn).toHaveBeenCalledTimes(0) + await mockBackgroundUIServiceManager.setOpened(true) + expect(fn).toHaveBeenCalledTimes(0) + await mockSessionServiceManager.setLocked(false) + expect(fn).toHaveBeenCalledTimes(1) + await mockBackgroundUIServiceManager.setOpened(false) + expect(fn).toHaveBeenCalledTimes(1) + await mockBackgroundUIServiceManager.setOpened(true) + expect(fn).toHaveBeenCalledTimes(2) + await mockBackgroundUIServiceManager.setOpened(false) + await mockSessionServiceManager.setLocked(true) + await mockBackgroundUIServiceManager.setOpened(true) + expect(fn).toHaveBeenCalledTimes(2) + await mockSessionServiceManager.setLocked(false) + expect(fn).toHaveBeenCalledTimes(3) + + // test scheduleService + await scheduleServiceManager.fireAll("every") + expect(fn).toHaveBeenCalledTimes(4) + + // test debounce + expect(debounceService.debounce).toHaveBeenCalledTimes(4) + await fnExec() + expect(debounceService.debounce).toHaveBeenCalledTimes(5) + expect(debounceService.debounce).toHaveBeenCalledWith({ + id: expect.stringContaining("debounce@1s:"), + debounce: 1, + callback: expect.any(Function), + }) + expect(fn).toHaveBeenCalledTimes(5) + + // does not call debounce when not open + await mockBackgroundUIServiceManager.setOpened(false) + await fnExec() + expect(debounceService.debounce).toHaveBeenCalledTimes(5) + expect(fn).toHaveBeenCalledTimes(5) + }) + }) }) diff --git a/packages/extension/src/background/__new/services/worker/schedule/decorators.ts b/packages/extension/src/background/__new/services/worker/schedule/decorators.ts index 90cf2295d..e2dee126c 100644 --- a/packages/extension/src/background/__new/services/worker/schedule/decorators.ts +++ b/packages/extension/src/background/__new/services/worker/schedule/decorators.ts @@ -1,10 +1,22 @@ import { IDebounceService } from "../../../../../shared/debounce" import { IScheduleService } from "../../../../../shared/schedule/interface" +import { Locked } from "../../../../wallet/session/interface" +import { WalletSessionService } from "../../../../wallet/session/session.service" +import { IKeyValueStorage } from "../../../../../shared/storage" +import { WalletStorageProps } from "../../../../wallet/backup/backup.service" import { IBackgroundUIService, Opened } from "../../ui/interface" import { pipe } from "./pipe" type Fn = (...args: unknown[]) => Promise +export const onAccountChanged = + (walletStore: IKeyValueStorage) => + (fn: T): T => { + walletStore.subscribe("selected", fn) + + return fn + } + /** * Function to schedule a task on startup. * @param {IScheduleService} scheduleService - The schedule service. @@ -68,6 +80,10 @@ export type MinimalIBackgroundUIService = Pick< "opened" | "emitter" > +export type MinimalWalletSessionService = Pick< + WalletSessionService, + "locked" | "emitter" +> /** * Function to schedule a task to run when the UI is opened. * @param {IBackgroundUIService} backgroundUIService - The background UI service. @@ -97,6 +113,26 @@ export const onClose = return fn } +export const onUnlocked = + (sessionService: MinimalWalletSessionService) => + (fn: T): T => { + sessionService.emitter.on(Locked, async (locked) => { + if (!locked) { + await fn() + } + }) + + return fn + } + +export const onlyIfUnlocked = + (sessionService: MinimalWalletSessionService) => + (fn: T): T => { + return ((...args: unknown[]) => { + return !sessionService.locked ? fn(...args) : noopAs(fn)(...args) + }) as T + } + function noopAs(_fn: T): T { const noop = () => {} return noop as T @@ -160,3 +196,46 @@ export const everyWhenOpen = ( every(scheduleService, seconds, name), ) } + +/** + * Function to run a task when the wallet is opened and unlocked, debounced by specified seconds + */ + +export const whenOpenAndUnlocked = ( + backgroundUIService: MinimalIBackgroundUIService, + sessionService: MinimalWalletSessionService, + debounceService: IDebounceService, + seconds: number, + name: string, +) => { + return pipe( + debounce(debounceService, seconds, name), + onlyIfOpen(backgroundUIService), + onlyIfUnlocked(sessionService), + onOpen(backgroundUIService), + onUnlocked(sessionService), + ) +} + +/** + * Function to schedule a task to run every specified seconds when the UI is opened and unlocked + */ + +export const everyWhenOpenAndUnlocked = ( + backgroundUIService: MinimalIBackgroundUIService, + scheduleService: IScheduleService, + sessionService: MinimalWalletSessionService, + debounceService: IDebounceService, + seconds: number, + name: string, +) => { + return pipe( + debounce(debounceService, seconds, name), + onlyIfOpen(backgroundUIService), + onlyIfUnlocked(sessionService), + onOpen(backgroundUIService), + onUnlocked(sessionService), + onInstallAndUpgrade(scheduleService), + every(scheduleService, seconds, name), + ) +} diff --git a/packages/extension/src/background/__new/services/worker/schedule/mockSessionService.ts b/packages/extension/src/background/__new/services/worker/schedule/mockSessionService.ts new file mode 100644 index 000000000..bc37613d1 --- /dev/null +++ b/packages/extension/src/background/__new/services/worker/schedule/mockSessionService.ts @@ -0,0 +1,31 @@ +import Emittery from "emittery" +import { MinimalWalletSessionService } from "./decorators" +import { Events, Locked } from "../../../../wallet/session/interface" + +interface MockSssionServiceManager { + setLocked(locked: boolean): Promise +} + +export const getMockSessionService = (): [ + MockSssionServiceManager, + MinimalWalletSessionService, +] => { + const emitter = new Emittery() + let locked = false + const setLocked = (newLocked: boolean) => { + locked = newLocked + return emitter.emit(Locked, locked) + } + const sessionService: MinimalWalletSessionService = { + get locked() { + return locked + }, + emitter, + } + return [ + { + setLocked, + }, + sessionService, + ] +} diff --git a/packages/extension/src/background/__new/trpc.ts b/packages/extension/src/background/__new/trpc.ts index 339fd092b..ce45d4384 100644 --- a/packages/extension/src/background/__new/trpc.ts +++ b/packages/extension/src/background/__new/trpc.ts @@ -13,6 +13,10 @@ import type { IStarknetAddressService } from "@argent/shared" import type { INetworkService } from "../../shared/network/service/interface" import { ISharedSwapService } from "../../shared/swap/service/interface" import superjson from "superjson" +import { ITokenService } from "../../shared/token/__new/service/interface" +import { IRiskAssessmentService } from "../../shared/riskAssessment/interface" +import { IFeeTokenService } from "../../shared/feeToken/service/interface" +import { IProvisionService } from "../../shared/provision/interface" interface Context { sender?: chrome.runtime.MessageSender @@ -26,7 +30,11 @@ interface Context { recoveryService: IRecoveryService starknetAddressService: IStarknetAddressService swapService: ISharedSwapService + tokenService: ITokenService + feeTokenService: IFeeTokenService networkService: INetworkService + riskAssessmentService: IRiskAssessmentService + provisionService: IProvisionService } } diff --git a/packages/extension/src/background/accountDeployAction.ts b/packages/extension/src/background/accountDeployAction.ts index 09a8a243f..8546704e4 100644 --- a/packages/extension/src/background/accountDeployAction.ts +++ b/packages/extension/src/background/accountDeployAction.ts @@ -1,11 +1,14 @@ import { ExtensionActionItemOfType } from "../shared/actionQueue/types" +import { IFeeTokenService } from "../shared/feeToken/service/interface" import { addTransaction } from "../shared/transactions/store" import { checkTransactionHash } from "../shared/transactions/utils" +import { getTxVersionFromFeeToken } from "../shared/utils/getTransactionVersion" import { Wallet } from "./wallet" export const accountDeployAction = async ( action: ExtensionActionItemOfType<"DEPLOY_ACCOUNT">, wallet: Wallet, + feeTokenService: IFeeTokenService, ) => { if (!(await wallet.isSessionOpen())) { throw Error("you need an open session") @@ -19,7 +22,12 @@ export const accountDeployAction = async ( throw Error("Account already deployed") } - const { account, txHash } = await wallet.deployAccount(selectedAccount) + const bestFeeToken = await feeTokenService.getBestFeeToken(selectedAccount) + const version = getTxVersionFromFeeToken(bestFeeToken.address) + + const { account, txHash } = await wallet.deployAccount(selectedAccount, { + version, + }) if (!checkTransactionHash(txHash)) { throw Error( diff --git a/packages/extension/src/background/accountUpgrade.ts b/packages/extension/src/background/accountUpgrade.ts index 00a59d006..465e04b8d 100644 --- a/packages/extension/src/background/accountUpgrade.ts +++ b/packages/extension/src/background/accountUpgrade.ts @@ -5,7 +5,7 @@ import { ArgentAccountType, BaseWalletAccount } from "../shared/wallet.model" import { IBackgroundActionService } from "./__new/services/action/interface" import { Wallet } from "./wallet" import { AccountError } from "../shared/errors/account" -import { isAccountV5 } from "@argent/shared" +import { addressSchema, isAccountV5 } from "@argent/shared" export interface IUpgradeAccount { account: BaseWalletAccount wallet: Wallet @@ -40,18 +40,22 @@ export const upgradeAccount = async ({ const implementationClassHash = newImplementation[accountTypeWithCairo0Check] ?? newImplementation.standard + const parsedImplClassHash = addressSchema.parse(implementationClassHash) + if (!isAccountV5(starknetAccount)) { throw new AccountError({ code: "UPGRADE_NOT_SUPPORTED" }) } const upgradeCalldata = { - implementation: implementationClassHash, + implementation: parsedImplClassHash, // new starknet accounts have a new upgrade interface to allow for transactions right after upgrade calldata: [0], } const calldata = CallData.compile(upgradeCalldata) - await actionService.add( + + // Always add upgrade transaction to the front of the queue + await actionService.addFront( { type: "TRANSACTION", payload: { @@ -60,7 +64,10 @@ export const upgradeAccount = async ({ entrypoint: "upgrade", calldata, }, - meta: { isUpgrade: true, title: "Switch account type" }, + meta: { + title: "Switch account type", + newClassHash: parsedImplClassHash, + }, }, }, { diff --git a/packages/extension/src/background/actionHandlers.ts b/packages/extension/src/background/actionHandlers.ts index 56310c346..516be5b49 100644 --- a/packages/extension/src/background/actionHandlers.ts +++ b/packages/extension/src/background/actionHandlers.ts @@ -19,6 +19,7 @@ import { Wallet } from "./wallet" import { preAuthorizationService } from "../shared/preAuthorization/service" import { networkSchema } from "../shared/network" import { encodeChainId } from "../shared/utils/encodeChainId" +import { IFeeTokenService } from "../shared/feeToken/service/interface" const handleTransactionAction = async ({ action, @@ -66,6 +67,7 @@ const handleTransactionAction = async ({ export const handleActionApproval = async ( action: ExtensionActionItem, wallet: Wallet, + feeTokenService: IFeeTokenService, ): Promise => { const actionHash = action.meta.hash const selectedAccount = await wallet.getSelectedAccount() @@ -94,7 +96,11 @@ export const handleActionApproval = async ( } case "TRANSACTION": { - return handleTransactionAction({ action, networkId, wallet }) + return handleTransactionAction({ + action, + networkId, + wallet, + }) } case "DEPLOY_ACCOUNT": { @@ -103,7 +109,11 @@ export const handleActionApproval = async ( // networkId, // }) // TODO: temporary disabled - const txHash = await accountDeployAction(action, wallet) + const txHash = await accountDeployAction( + action, + wallet, + feeTokenService, + ) void analytics.track("deployAccount", { status: "success", diff --git a/packages/extension/src/background/background.ts b/packages/extension/src/background/background.ts index 6794900ad..9a78acd84 100644 --- a/packages/extension/src/background/background.ts +++ b/packages/extension/src/background/background.ts @@ -5,11 +5,13 @@ import type { MessagingKeys } from "./keys/messagingKeys" import type { Respond } from "./respond" import { Wallet } from "./wallet" import { TransactionTrackerWorker } from "./transactions/service/starknet.service" +import { IFeeTokenService } from "../shared/feeToken/service/interface" export interface BackgroundService { wallet: Wallet transactionTrackerWorker: TransactionTrackerWorker actionService: IBackgroundActionService + feeTokenService: IFeeTokenService } export class UnhandledMessage extends Error { diff --git a/packages/extension/src/background/devnet/declareAccounts.ts b/packages/extension/src/background/devnet/declareAccounts.ts index 57280dce0..44c5cc4d2 100644 --- a/packages/extension/src/background/devnet/declareAccounts.ts +++ b/packages/extension/src/background/devnet/declareAccounts.ts @@ -4,10 +4,6 @@ import urlJoin from "url-join" import { Network, getProvider } from "../../shared/network" import { LoadContracts } from "../wallet/loadContracts" -import { - ARGENT_ACCOUNT_CONTRACT_CLASS_HASHES, - PROXY_CONTRACT_CLASS_HASHES, -} from "../wallet/starknet.constants" interface PreDeployedAccount { address: string @@ -71,7 +67,7 @@ export const declareContracts = memoize( if (!isProxyClassDeclared) { const proxy = await deployAccount.declare({ - classHash: PROXY_CONTRACT_CLASS_HASHES[0], + classHash: computedProxyClassHash, contract: proxyContract, }) @@ -84,7 +80,7 @@ export const declareContracts = memoize( if (!isAccountClassDeclared) { const account = await deployAccount.declare({ - classHash: ARGENT_ACCOUNT_CONTRACT_CLASS_HASHES[0], + classHash: computedAccountClassHash, contract: accountContract, }) diff --git a/packages/extension/src/background/index.ts b/packages/extension/src/background/index.ts index 5ded3ae6b..cc72db17e 100644 --- a/packages/extension/src/background/index.ts +++ b/packages/extension/src/background/index.ts @@ -1,6 +1,15 @@ -import browser from "webextension-polyfill" import * as Sentry from "@sentry/browser" +import browser from "webextension-polyfill" + import { getBrowserAction } from "../shared/browser" +import { sentryWorker } from "./__new/services/sentry" + +try { + // Try to start Sentry immediately + initSentryWorker() +} catch (error) { + console.error("Exception while initialising sentryWorker", error) +} try { // catch any errors from init.ts @@ -16,3 +25,10 @@ try { }) }, 0) } + +// Prevent tree-shaking unused worker variables +function initSentryWorker() { + return { + sentryWorker, + } +} diff --git a/packages/extension/src/background/keys/keyDerivation.ts b/packages/extension/src/background/keys/keyDerivation.ts index 21d49152a..7ee9287b9 100644 --- a/packages/extension/src/background/keys/keyDerivation.ts +++ b/packages/extension/src/background/keys/keyDerivation.ts @@ -54,7 +54,7 @@ export function getStarkPair( } /** - * Grinds a private key to a valid StarkNet private key + * Grinds a private key to a valid Starknet private key * @param privateKey * @returns Unsantized hex string */ diff --git a/packages/extension/src/background/messageHandling/handle.ts b/packages/extension/src/background/messageHandling/handle.ts index fc535ca29..382f46c26 100644 --- a/packages/extension/src/background/messageHandling/handle.ts +++ b/packages/extension/src/background/messageHandling/handle.ts @@ -25,6 +25,7 @@ import { handleUdcMessaging } from "../udcMessaging" import { walletSingleton } from "../walletSingleton" import { safeMessages, safeIfPreauthorizedMessages } from "./messages" import browser from "webextension-polyfill" +import { feeTokenService } from "../../shared/feeToken/service" const handlers = [ handleAccountMessage, @@ -50,6 +51,7 @@ export const handleMessage = async ( wallet: walletSingleton, transactionTrackerWorker: transactionTrackerWorker, actionService: backgroundActionService, + feeTokenService, } const extensionUrl = browser.runtime.getURL("") diff --git a/packages/extension/src/background/migrations/index.ts b/packages/extension/src/background/migrations/index.ts index 60474a231..ddce1b337 100644 --- a/packages/extension/src/background/migrations/index.ts +++ b/packages/extension/src/background/migrations/index.ts @@ -18,12 +18,12 @@ enum WalletMigrations { } enum NetworkMigrations { - rpcEverywhere = "network:rpcEverywhere", + rpcEverywhere = "network:rpcEverywhere:r2", } enum TokenMigrations { v59 = "token:v59", - v510 = "token:v510:r2", + v510 = "token:v510:r3", } enum PreAuthorizationMigrations { diff --git a/packages/extension/src/background/migrations/wallet/v5.8.1.ts b/packages/extension/src/background/migrations/wallet/v5.8.1.ts index bf60171e8..39c53b6ef 100644 --- a/packages/extension/src/background/migrations/wallet/v5.8.1.ts +++ b/packages/extension/src/background/migrations/wallet/v5.8.1.ts @@ -8,6 +8,7 @@ import { BaseWalletAccount, WalletAccount } from "../../../shared/wallet.model" import { accountsEqual } from "../../../shared/utils/accountsEqual" import { WalletCryptoStarknetService } from "../../wallet/crypto/starknet.service" import { WalletStorageProps } from "../../../shared/wallet/walletStore" +import { getAccountContractAddress } from "../../wallet/findImplementationForAddress" export async function determineMigrationNeededV581( cryptoStarknetService: WalletCryptoStarknetService, @@ -20,11 +21,11 @@ export async function determineMigrationNeededV581( const { pubKey } = await cryptoStarknetService.getKeyPairByDerivationPath( account.signer.derivationPath, ) - const falseyAccountAddress = - cryptoStarknetService.getCairo1AccountContractAddress( - STANDARD_CAIRO_0_ACCOUNT_CLASS_HASH, - pubKey, - ) + const falseyAccountAddress = getAccountContractAddress( + "1", + STANDARD_CAIRO_0_ACCOUNT_CLASS_HASH, + pubKey, + ) return [account, isEqualAddress(falseyAccountAddress, account.address)] }), diff --git a/packages/extension/src/background/multisig/multisigDeployAction.ts b/packages/extension/src/background/multisig/multisigDeployAction.ts index e2d91880a..0677ba2d6 100644 --- a/packages/extension/src/background/multisig/multisigDeployAction.ts +++ b/packages/extension/src/background/multisig/multisigDeployAction.ts @@ -5,13 +5,17 @@ import { addTransaction } from "../../shared/transactions/store" import { Wallet } from "../wallet" import { estimatedFeeToMaxFeeTotal } from "../../shared/transactionSimulation/utils" import { checkTransactionHash } from "../../shared/transactions/utils" +import { ETH_TOKEN_ADDRESS } from "../../shared/network/constants" +import { TransactionInvokeVersion } from "../../shared/utils/transactionVersion" +import { AccountError } from "../../shared/errors/account" +import { SessionError } from "../../shared/errors/session" export const addMultisigDeployAction = async ( action: ExtensionActionItemOfType<"DEPLOY_MULTISIG">, wallet: Wallet, ) => { if (!(await wallet.isSessionOpen())) { - throw Error("you need an open session") + throw new SessionError({ code: "NO_OPEN_SESSION" }) } const { account: baseAccount } = action.payload const selectedMultisig = await wallet.getMultisigAccount(baseAccount) @@ -19,16 +23,22 @@ export const addMultisigDeployAction = async ( const multisigNeedsDeploy = selectedMultisig.needsDeploy if (!multisigNeedsDeploy) { - throw Error("Account already deployed") + throw new AccountError({ code: "ACCOUNT_ALREADY_DEPLOYED" }) } + // TODO: TXV3 - allow for deploying multisig with STRK fee token + const version: TransactionInvokeVersion = "0x1" + const feeTokenAddress = ETH_TOKEN_ADDRESS + + // TODO: refactor to use the fee estimation repo const maxFee = await wallet - .getAccountDeploymentFee(selectedMultisig) + .getAccountDeploymentFee(selectedMultisig, feeTokenAddress) .then(estimatedFeeToMaxFeeTotal) .catch(() => num.toBigInt(20e14)) const { account, txHash } = await wallet.deployAccount(selectedMultisig, { maxFee, + version, }) if (!checkTransactionHash(txHash)) { diff --git a/packages/extension/src/background/nonce.ts b/packages/extension/src/background/nonce.ts index 935528ff1..30b83644b 100644 --- a/packages/extension/src/background/nonce.ts +++ b/packages/extension/src/background/nonce.ts @@ -1,4 +1,4 @@ -import { num, Account } from "starknet" +import { num, Account } from "starknet6" import { KeyValueStorage } from "../shared/storage" import { BaseWalletAccount, WalletAccount } from "../shared/wallet.model" @@ -17,8 +17,15 @@ export async function getNonce( starknetAccount: Account, ): Promise { const storageAddress = getAccountIdentifier(account) - const result = await starknetAccount.getNonce() - const nonceBn = num.toBigInt(result) + let nonceBn = BigInt(0) + + try { + const result = await starknetAccount.getNonce() + nonceBn = num.toBigInt(result) + } catch { + console.warn("Onchain getNonce failed, using stored nonce.") + } + const storedNonce = await nonceStore.get(storageAddress) if (account.type === "multisig") { diff --git a/packages/extension/src/background/transactions/onupdate/index.ts b/packages/extension/src/background/transactions/onupdate/index.ts index 03453ef72..35c84817b 100644 --- a/packages/extension/src/background/transactions/onupdate/index.ts +++ b/packages/extension/src/background/transactions/onupdate/index.ts @@ -8,8 +8,8 @@ import { TransactionUpdateListener } from "./type" import { handleUpgradeTransaction } from "./upgrade" const addedOrUpdatedHandlers: TransactionUpdateListener[] = [ - handleUpgradeTransaction, handleDeployAccountTransaction, + handleUpgradeTransaction, handleDeclareContractTransaction, handleChangeGuardianTransaction, handleMultisigUpdates, @@ -19,9 +19,11 @@ const addedOrUpdatedHandlers: TransactionUpdateListener[] = [ export const runAddedOrUpdatedHandlers: TransactionUpdateListener = async ( updates, ) => { - await Promise.allSettled( - addedOrUpdatedHandlers.map((handler) => handler(updates)), - ) + // We need this in serial and not parallel because some handlers depend on the + // results of others (e.g. the upgrade handler needs the account to be deployed) + for (const handler of addedOrUpdatedHandlers) { + await handler(updates) + } } const changedStatusHandlers: TransactionUpdateListener[] = [ diff --git a/packages/extension/src/background/transactions/onupdate/upgrade.ts b/packages/extension/src/background/transactions/onupdate/upgrade.ts index e81ea7729..6463c3d8c 100644 --- a/packages/extension/src/background/transactions/onupdate/upgrade.ts +++ b/packages/extension/src/background/transactions/onupdate/upgrade.ts @@ -1,16 +1,35 @@ -import { updateAccountDetails } from "../../../shared/account/update" import { TransactionUpdateListener } from "./type" +import { optimisticImplUpdate } from "../../../shared/account/optimisticImplUpdate" +import { accountService } from "../../../shared/account/service" +import { isSafeUpgradeTransaction } from "../../../shared/utils/isUpgradeTransaction" +import { isSuccessfulTransaction } from "../../../shared/transactions/utils" +import { accountsEqual } from "../../../shared/utils/accountsEqual" +import { WalletAccount } from "../../../shared/wallet.model" export const handleUpgradeTransaction: TransactionUpdateListener = async ( transactions, ) => { - const upgrades = transactions.filter( - (transaction) => transaction.meta?.isUpgrade, - ) - if (upgrades.length > 0) { - await updateAccountDetails( - "implementation", - upgrades.map((transaction) => transaction.account), - ) + const upgradeTxns = transactions.filter( + (tx) => isSafeUpgradeTransaction(tx) && isSuccessfulTransaction(tx), + ) // Check if the transaction is a safe upgrade transaction and if it was successful + + if (upgradeTxns.length === 0) { + return } + + const allAccounts = await accountService.get() + + const updatedAccounts = upgradeTxns.reduce((acc, tx) => { + const account = allAccounts.find((a) => accountsEqual(a, tx.account)) + if (account) { + const updatedAccount = optimisticImplUpdate( + account, + tx.meta?.newClassHash, + ) + acc.push(updatedAccount) + } + return acc + }, []) + + await accountService.upsert(updatedAccounts) } diff --git a/packages/extension/src/background/transactions/sources/onchain.spec.ts b/packages/extension/src/background/transactions/sources/onchain.spec.ts index 344cd28d0..5b021a465 100644 --- a/packages/extension/src/background/transactions/sources/onchain.spec.ts +++ b/packages/extension/src/background/transactions/sources/onchain.spec.ts @@ -35,7 +35,9 @@ describe("getTransactionsUpdate", () => { getTransactionStatus: () => ({ finality_status: status, }), - getTransactionReceipt, + getTransactionReceipt: () => ({ + finality_status: status, + }), }) const test = await getTransactionsUpdate([mockTransaction]) @@ -86,9 +88,11 @@ describe("getTransactionsUpdate", () => { account: { address: "0x1", networkId: "goerli-alpha" } as WalletAccount, } - const getTransactionReceipt = vi - .fn() - .mockResolvedValue({ revert_reason: "foo" }) + const getTransactionReceipt = vi.fn().mockResolvedValue({ + revert_reason: "foo", + execution_status: "REVERTED", + finality_status: "RECEIVED", + }) vi.mocked(mocks).getProvider.mockReturnValue({ getTransactionStatus: () => ({ diff --git a/packages/extension/src/background/transactions/sources/onchain.ts b/packages/extension/src/background/transactions/sources/onchain.ts index 03dfe5814..13e49e386 100644 --- a/packages/extension/src/background/transactions/sources/onchain.ts +++ b/packages/extension/src/background/transactions/sources/onchain.ts @@ -1,8 +1,8 @@ import { getProvider } from "../../../shared/network" import { - ExtendedFinalityStatus, Transaction, getInFlightTransactions, + SUCCESS_STATUSES, } from "../../../shared/transactions" import { getTransactionsStatusUpdate } from "../determineUpdates" import { getTransactionStatus } from "../../../shared/transactions/utils" @@ -18,14 +18,30 @@ export async function getTransactionsUpdate(transactions: Transaction[]) { const { finality_status, execution_status } = await provider.getTransactionStatus(transaction.hash) + // getTransactionStatus goes straight to the sequencer, hence it's much faster than the RPC nodes + // because of that we need to wait for the RPC nodes to have a receipt as well try { - if (execution_status === "REVERTED") { - const tx = await provider.getTransactionReceipt(transaction.hash) + if ( + execution_status === "REVERTED" || + SUCCESS_STATUSES.includes(finality_status) + ) { + const receipt = await provider.getTransactionReceipt(transaction.hash) + const { + finality_status: receiptFinalityStatus, + execution_status: receiptExecutionStatus, + } = receipt - if ("revert_reason" in tx) { + if ( + finality_status !== receiptFinalityStatus || + execution_status !== receiptExecutionStatus + ) { + return transaction + } + + if ("revert_reason" in receipt) { return { ...transaction, - revertReason: tx.revert_reason, + revertReason: receipt.revert_reason, status: { finality_status, execution_status, diff --git a/packages/extension/src/background/transactions/transactionExecution.ts b/packages/extension/src/background/transactions/transactionExecution.ts index d6dbcc383..ca836a5e4 100644 --- a/packages/extension/src/background/transactions/transactionExecution.ts +++ b/packages/extension/src/background/transactions/transactionExecution.ts @@ -1,4 +1,4 @@ -import { num } from "starknet" +import { TransactionType, num } from "starknet" import { ExtQueueItem, TransactionActionPayload, @@ -24,7 +24,12 @@ import { getTransactionStatus, } from "../../shared/transactions/utils" import { isAccountV5 } from "@argent/shared" -import { estimatedFeeToMaxFeeTotal } from "../../shared/transactionSimulation/utils" +import { estimatedFeeToMaxResourceBounds } from "../../shared/transactionSimulation/utils" +import { SessionError } from "../../shared/errors/session" +import { AccountError } from "../../shared/errors/account" +import { getTxVersionFromFeeToken } from "../../shared/utils/getTransactionVersion" +import { TransactionError } from "../../shared/errors/transaction" +import { isSafeUpgradeTransaction } from "../../shared/utils/isUpgradeTransaction" export type TransactionAction = ExtQueueItem<{ type: "TRANSACTION" @@ -37,33 +42,26 @@ export const executeTransactionAction = async ( ) => { const { transactions, abis, transactionsDetail, meta = {} } = action.payload const allTransactions = await transactionsStore.get() - const preComputedFees = await getEstimatedFees(transactions) + const preComputedFees = await getEstimatedFees({ + type: TransactionType.INVOKE, + payload: transactions, + }) if (!preComputedFees) { - throw Error("PreComputedFees not defined") + throw new TransactionError({ code: "NO_PRE_COMPUTED_FEES" }) } - const suggestedMaxFee = - transactionsDetail?.maxFee ?? - estimatedFeeToMaxFeeTotal(preComputedFees.transactions) - const suggestedMaxADFee = preComputedFees.deployment - ? estimatedFeeToMaxFeeTotal(preComputedFees.deployment) - : 0n - - const maxFee = suggestedMaxFee - const maxADFee = suggestedMaxADFee - // void analytics.track("executeTransaction", { // usesCachedFees: Boolean(preComputedFees), // }) // TODO: temporary disabled if (!(await wallet.isSessionOpen())) { - throw Error("you need an open session") + throw new SessionError({ code: "NO_OPEN_SESSION" }) } const selectedAccount = await wallet.getSelectedAccount() if (!selectedAccount) { - throw Error("no accounts") + throw new AccountError({ code: "NOT_FOUND" }) } const multisig = @@ -80,7 +78,7 @@ export const executeTransactionAction = async ( }) const hasUpgradePending = pendingAccountTransactions.some( - (tx) => tx.meta?.isUpgrade, + isSafeUpgradeTransaction, ) const starknetAccount = await wallet.getStarknetAccount( @@ -101,9 +99,14 @@ export const executeTransactionAction = async ( ? num.toHex(transactionsDetail?.nonce || 0) : await getNonce(selectedAccount, starknetAccount) - if (accountNeedsDeploy) { + const version = getTxVersionFromFeeToken( + preComputedFees.transactions.feeTokenAddress, + ) + + if (accountNeedsDeploy && preComputedFees.deployment) { const { account, txHash } = await wallet.deployAccount(selectedAccount, { - maxFee: maxADFee, + version, + ...estimatedFeeToMaxResourceBounds(preComputedFees.deployment), }) if (!checkTransactionHash(txHash)) { throw Error( @@ -141,7 +144,8 @@ export const executeTransactionAction = async ( const transaction = await acc.execute(transactions, abis, { ...transactionsDetail, nonce, - maxFee, + version, + ...estimatedFeeToMaxResourceBounds(preComputedFees.transactions), }) if (!checkTransactionHash(transaction.transaction_hash, selectedAccount)) { @@ -171,9 +175,5 @@ export const executeTransactionAction = async ( await increaseStoredNonce(selectedAccount) } - if ("isUpgrade" in meta && meta.isUpgrade) { - await resetStoredNonce(selectedAccount) // reset nonce after upgrade. This is needed because nonce was managed by AccountContract before 0.10.0 - } - return transaction } diff --git a/packages/extension/src/background/transactions/transactionMessaging.ts b/packages/extension/src/background/transactions/transactionMessaging.ts index 12fee92d0..07a6a13a2 100644 --- a/packages/extension/src/background/transactions/transactionMessaging.ts +++ b/packages/extension/src/background/transactions/transactionMessaging.ts @@ -2,7 +2,6 @@ import { CallData, Invocations, TransactionType, - hash, num, transaction, } from "starknet" @@ -20,12 +19,21 @@ import { TransactionError } from "../../shared/errors/transaction" import { getEstimatedFeeFromBulkSimulation } from "../../shared/transactionSimulation/utils" import { isAccountV4, isAccountV5 } from "@argent/shared" import { EstimatedFees } from "../../shared/transactionSimulation/fees/fees.model" -import { ETH_TOKEN_ADDRESS } from "../../shared/network/constants" import { addEstimatedFee } from "../../shared/transactionSimulation/fees/estimatedFeesRepository" +import { + getSimulationTxVersionFromFeeToken, + getTxVersionFromFeeToken, + getTxVersionFromFeeTokenForDeclareContract, +} from "../../shared/utils/getTransactionVersion" export const handleTransactionMessage: HandleMessage< TransactionMessage -> = async ({ msg, origin, background: { wallet, actionService }, respond }) => { +> = async ({ + msg, + origin, + background: { wallet, actionService, feeTokenService }, + respond, +}) => { switch (msg.type) { case "EXECUTE_TRANSACTION": { const { meta } = await actionService.add( @@ -46,13 +54,12 @@ export const handleTransactionMessage: HandleMessage< } case "ESTIMATE_DECLARE_CONTRACT_FEE": { - const { address, networkId, ...rest } = msg.data + const { account, feeTokenAddress, payload } = msg.data const selectedAccount = await wallet.getSelectedAccount() - const selectedStarknetAccount = - address && networkId - ? await wallet.getStarknetAccount({ address, networkId }) - : await wallet.getSelectedStarknetAccount() + const selectedStarknetAccount = account + ? await wallet.getStarknetAccount(account) + : await wallet.getSelectedStarknetAccount() if (!selectedStarknetAccount) { throw Error("no accounts") @@ -60,13 +67,18 @@ export const handleTransactionMessage: HandleMessage< const fees: EstimatedFees = { transactions: { - feeTokenAddress: ETH_TOKEN_ADDRESS, + feeTokenAddress, amount: 0n, pricePerUnit: 0n, }, } try { + const version = getTxVersionFromFeeTokenForDeclareContract( + feeTokenAddress, + payload, + ) + if ( selectedAccount?.needsDeploy && !(await isAccountDeployed( @@ -86,15 +98,14 @@ export const handleTransactionMessage: HandleMessage< }, { type: TransactionType.DECLARE, - payload: { - ...rest, - }, + payload, }, ] const estimateFeeBulk = await selectedStarknetAccount.estimateFeeBulk(bulkTransactions, { skipValidate: true, + version, }) if ( @@ -107,7 +118,7 @@ export const handleTransactionMessage: HandleMessage< } fees.deployment = { - feeTokenAddress: ETH_TOKEN_ADDRESS, + feeTokenAddress, amount: num.toBigInt(estimateFeeBulk[0].gas_consumed), pricePerUnit: num.toBigInt(estimateFeeBulk[0].gas_price), } @@ -131,8 +142,8 @@ export const handleTransactionMessage: HandleMessage< } else { if ("estimateDeclareFee" in selectedStarknetAccount) { const { gas_consumed, gas_price } = - await selectedStarknetAccount.estimateDeclareFee({ - ...rest, + await selectedStarknetAccount.estimateDeclareFee(payload, { + version, }) if (!gas_consumed || !gas_price) { @@ -146,6 +157,11 @@ export const handleTransactionMessage: HandleMessage< } } + await addEstimatedFee(fees, { + type: TransactionType.DECLARE, + payload, + }) + return respond({ type: "ESTIMATE_DECLARE_CONTRACT_FEE_RES", data: fees, @@ -165,10 +181,12 @@ export const handleTransactionMessage: HandleMessage< } case "ESTIMATE_DEPLOY_CONTRACT_FEE": { - const { classHash, constructorCalldata, salt, unique } = msg.data + const { payload, account, feeTokenAddress } = msg.data const selectedAccount = await wallet.getSelectedAccount() - const selectedStarknetAccount = await wallet.getSelectedStarknetAccount() + const selectedStarknetAccount = account + ? await wallet.getStarknetAccount(account) + : await wallet.getSelectedStarknetAccount() if (!selectedStarknetAccount || !selectedAccount) { throw Error("no accounts") @@ -176,12 +194,14 @@ export const handleTransactionMessage: HandleMessage< const fees: EstimatedFees = { transactions: { - feeTokenAddress: ETH_TOKEN_ADDRESS, + feeTokenAddress, amount: 0n, pricePerUnit: 0n, }, } + const version = getTxVersionFromFeeToken(feeTokenAddress) + try { if ( selectedAccount?.needsDeploy && @@ -200,12 +220,7 @@ export const handleTransactionMessage: HandleMessage< }, { type: TransactionType.DEPLOY, - payload: { - classHash, - salt, - unique, - constructorCalldata, - }, + payload, }, ] @@ -222,7 +237,7 @@ export const handleTransactionMessage: HandleMessage< } fees.deployment = { - feeTokenAddress: ETH_TOKEN_ADDRESS, + feeTokenAddress, amount: num.toBigInt(estimateFeeBulk[0].gas_consumed), pricePerUnit: num.toBigInt(estimateFeeBulk[0].gas_price), } @@ -246,11 +261,8 @@ export const handleTransactionMessage: HandleMessage< } else { if ("estimateDeployFee" in selectedStarknetAccount) { const { gas_consumed, gas_price } = - await selectedStarknetAccount.estimateDeployFee({ - classHash, - salt, - unique, - constructorCalldata, + await selectedStarknetAccount.estimateDeployFee(payload, { + version, }) if (!gas_consumed || !gas_price) { @@ -264,6 +276,11 @@ export const handleTransactionMessage: HandleMessage< } } + await addEstimatedFee(fees, { + type: TransactionType.DEPLOY, + payload, + }) + return respond({ type: "ESTIMATE_DEPLOY_CONTRACT_FEE_RES", data: fees, @@ -300,17 +317,14 @@ export const handleTransactionMessage: HandleMessage< }) } - let nonce - - try { - nonce = await starknetAccount.getNonce() - } catch { - nonce = "0" - } + const nonce = await starknetAccount.getNonce().catch(() => "0") const chainId = await starknetAccount.getChainId() - const version = num.toHex(hash.feeTransactionVersion) + const bestFeeToken = await feeTokenService.getBestFeeToken( + selectedAccount, + ) + const version = getSimulationTxVersionFromFeeToken(bestFeeToken.address) const calldata = transaction.getExecuteCalldata( transactions, @@ -375,7 +389,9 @@ export const handleTransactionMessage: HandleMessage< } case "SIMULATE_TRANSACTIONS": { - const transactions = Array.isArray(msg.data) ? msg.data : [msg.data] + const transactions = Array.isArray(msg.data.call) + ? msg.data.call + : [msg.data.call] try { const selectedAccount = await wallet.getSelectedAccount() @@ -396,18 +412,13 @@ export const handleTransactionMessage: HandleMessage< }) } - let nonce - - try { - nonce = await starknetAccount.getNonce() - } catch { - nonce = "0" - } + const nonce = await starknetAccount.getNonce().catch(() => "0") const chainId = await starknetAccount.getChainId() - const version = num.toHex(hash.feeTransactionVersion) - + const version = getSimulationTxVersionFromFeeToken( + msg.data.feeTokenAddress, + ) const calldata = transaction.getExecuteCalldata( transactions, starknetAccount.cairoVersion, @@ -453,7 +464,7 @@ export const handleTransactionMessage: HandleMessage< const result = await fetchTransactionBulkSimulation({ invocations, - chainId, + chainId: chainId as any, // TODO: migrate to snjsv6 completely }) const estimatedFee = getEstimatedFeeFromBulkSimulation(result) @@ -461,7 +472,10 @@ export const handleTransactionMessage: HandleMessage< let simulationWithFees = null if (result) { - await addEstimatedFee(estimatedFee, transactions) + await addEstimatedFee(estimatedFee, { + type: TransactionType.INVOKE, + payload: transactions, + }) simulationWithFees = { simulation: result, feeEstimation: estimatedFee, diff --git a/packages/extension/src/background/udcAction.ts b/packages/extension/src/background/udcAction.ts index c1967b8ed..079dea2da 100644 --- a/packages/extension/src/background/udcAction.ts +++ b/packages/extension/src/background/udcAction.ts @@ -1,7 +1,6 @@ import { CallData, DeclareContractPayload, - Invocations, TransactionType, UniversalDeployerContractPayload, constants, @@ -13,12 +12,18 @@ import { isAccountDeployed } from "./accountDeploy" import { analytics } from "./analytics" import { getNonce, increaseStoredNonce } from "./nonce" import { addTransaction } from "../shared/transactions/store" -import { modifySnjsFeeOverhead } from "../shared/utils/argentMaxFee" import { Wallet } from "./wallet" import { AccountError } from "../shared/errors/account" import { WalletError } from "../shared/errors/wallet" import { UdcError } from "../shared/errors/udc" import { checkTransactionHash } from "../shared/transactions/utils" +import { getEstimatedFees } from "../shared/transactionSimulation/fees/estimatedFeesRepository" +import { + getTxVersionFromFeeToken, + getTxVersionFromFeeTokenForDeclareContract, +} from "../shared/utils/getTransactionVersion" +import { estimatedFeeToMaxResourceBounds } from "../shared/transactionSimulation/utils" +import { TransactionError } from "../shared/errors/transaction" const { UDC } = constants @@ -54,47 +59,35 @@ export const udcDeclareContract = async ( networkId: selectedAccount.networkId, }) - let maxADFee = "0" - let maxDeclareFee = "0" + const preComputedFees = await getEstimatedFees({ + type: TransactionType.DECLARE, + payload, + }) + + if (!preComputedFees) { + throw new TransactionError({ code: "NO_PRE_COMPUTED_FEES" }) + } + + const version = getTxVersionFromFeeTokenForDeclareContract( + preComputedFees.transactions.feeTokenAddress, + payload, + ) + + const accountNeedsDeploy = !(await isAccountDeployed( + selectedAccount, + starknetAccount.getClassAt.bind(starknetAccount), + )) - const declareNonce = selectedAccount.needsDeploy + const declareNonce = accountNeedsDeploy ? num.toHex(1) : await getNonce(selectedAccount, starknetAccount) - if ( - selectedAccount.needsDeploy && - !(await isAccountDeployed( - selectedAccount, - starknetAccount.getClassAt.bind(starknetAccount), - )) - ) { - if ("estimateFeeBulk" in starknetAccount) { - const bulkTransactions: Invocations = [ - { - type: TransactionType.DEPLOY_ACCOUNT, - payload: await wallet.getAccountDeploymentPayload(selectedAccount), - }, - { - type: TransactionType.DECLARE, - payload, - }, - ] - const estimateFeeBulk = await starknetAccount.estimateFeeBulk( - bulkTransactions, - { skipValidate: true }, - ) - - maxADFee = modifySnjsFeeOverhead({ - suggestedMaxFee: estimateFeeBulk[0].suggestedMaxFee, - }) - maxDeclareFee = modifySnjsFeeOverhead({ - suggestedMaxFee: estimateFeeBulk[1].suggestedMaxFee, - }) - } + if (accountNeedsDeploy && preComputedFees.deployment) { const { account, txHash: accountDeployTxHash } = await wallet.deployAccount( selectedAccount, { - maxFee: maxADFee, + version, + ...estimatedFeeToMaxResourceBounds(preComputedFees.deployment), }, ) @@ -102,7 +95,7 @@ export const udcDeclareContract = async ( throw new UdcError({ code: "DEPLOY_TX_NOT_ADDED" }) } - analytics.track("deployAccount", { + void analytics.track("deployAccount", { status: "success", trigger: "transaction", networkId: account.networkId, @@ -117,32 +110,18 @@ export const udcDeclareContract = async ( type: TransactionType.DEPLOY_ACCOUNT, }, }) - } else { - if ("getSuggestedMaxFee" in starknetAccount) { - const suggestedMaxFee = await starknetAccount.getSuggestedMaxFee( - { - type: TransactionType.DECLARE, - payload, - }, - { - nonce: declareNonce, - }, - ) - maxDeclareFee = modifySnjsFeeOverhead({ suggestedMaxFee }) - } else { - throw new UdcError({ code: "NO_STARKNET_DECLARE_FEE" }) - } } if ("declareIfNot" in starknetAccount) { const { transaction_hash: declareTxHash, class_hash: deployedClassHash } = await starknetAccount.declareIfNot(payload, { nonce: declareNonce, - maxFee: maxDeclareFee, + version, + ...estimatedFeeToMaxResourceBounds(preComputedFees.transactions), }) if (!checkTransactionHash(declareTxHash)) { - throw new UdcError({ code: "DEPLOY_TX_NOT_ADDED" }) + throw new UdcError({ code: "CONTRACT_ALREADY_DECLARED" }) } await increaseStoredNonce(selectedAccount) @@ -184,52 +163,34 @@ export const udcDeployContract = async ( networkId: selectedAccount.networkId, }) - let maxADFee = "0" - let maxDeployFee = "0" + const preComputedFees = await getEstimatedFees({ + type: TransactionType.DEPLOY, + payload, + }) + + if (!preComputedFees) { + throw new TransactionError({ code: "NO_PRE_COMPUTED_FEES" }) + } - const deployNonce = selectedAccount.needsDeploy + const version = getTxVersionFromFeeToken( + preComputedFees.transactions.feeTokenAddress, + ) + + const accountNeedsDeploy = !(await isAccountDeployed( + selectedAccount, + starknetAccount.getClassAt.bind(starknetAccount), + )) + + const deployNonce = accountNeedsDeploy ? num.toHex(num.toBigInt(1)) : await getNonce(selectedAccount, starknetAccount) - if ( - selectedAccount.needsDeploy && - !(await isAccountDeployed( - selectedAccount, - starknetAccount.getClassAt.bind(starknetAccount), - )) - ) { - if ("estimateFeeBulk" in starknetAccount) { - const bulkTransactions: Invocations = [ - { - type: TransactionType.DEPLOY_ACCOUNT, - payload: await wallet.getAccountDeploymentPayload(selectedAccount), - }, - { - type: TransactionType.DEPLOY, - payload: { - classHash: payload.classHash, - constructorCalldata: payload.constructorCalldata, - salt: payload.salt, - unique: payload.unique, - }, - }, - ] - const estimateFeeBulk = await starknetAccount.estimateFeeBulk( - bulkTransactions, - { skipValidate: true }, - ) - - maxADFee = modifySnjsFeeOverhead({ - suggestedMaxFee: estimateFeeBulk[0].suggestedMaxFee, - }) - maxDeployFee = modifySnjsFeeOverhead({ - suggestedMaxFee: estimateFeeBulk[1].suggestedMaxFee, - }) - } + if (accountNeedsDeploy && preComputedFees.deployment) { const { account, txHash: accountDeployTxHash } = await wallet.deployAccount( selectedAccount, { - maxFee: maxADFee, + version, + ...estimatedFeeToMaxResourceBounds(preComputedFees.deployment), }, ) @@ -237,7 +198,7 @@ export const udcDeployContract = async ( throw new UdcError({ code: "DEPLOY_TX_NOT_ADDED" }) } - analytics.track("deployAccount", { + void analytics.track("deployAccount", { status: "success", trigger: "transaction", networkId: account.networkId, @@ -252,26 +213,6 @@ export const udcDeployContract = async ( type: "DEPLOY_ACCOUNT", }, }) - } else { - if ("getSuggestedMaxFee" in starknetAccount) { - const suggestedMaxFee = await starknetAccount.getSuggestedMaxFee( - { - type: TransactionType.DEPLOY, - payload: { - classHash: payload.classHash, - constructorCalldata: payload.constructorCalldata, - salt: payload.salt, - unique: payload.unique, - }, - }, - { - nonce: deployNonce, - }, - ) - maxDeployFee = modifySnjsFeeOverhead({ suggestedMaxFee }) - } else { - throw new UdcError({ code: "NO_STARKNET_DECLARE_FEE" }) - } } if ("deploy" in starknetAccount) { @@ -291,12 +232,13 @@ export const udcDeployContract = async ( }, { nonce: deployNonce, - maxFee: maxDeployFee, + version, + ...estimatedFeeToMaxResourceBounds(preComputedFees.transactions), }, ) if (!checkTransactionHash(deployTxHash)) { - throw new UdcError({ code: "DEPLOY_TX_NOT_ADDED" }) + throw new UdcError({ code: "NO_DEPLOY_CONTRACT" }) } const contractAddress = contract_address[0] diff --git a/packages/extension/src/background/udcMessaging.ts b/packages/extension/src/background/udcMessaging.ts index cafdc9689..14a529715 100644 --- a/packages/extension/src/background/udcMessaging.ts +++ b/packages/extension/src/background/udcMessaging.ts @@ -12,20 +12,18 @@ export const handleUdcMessaging: HandleMessage = async ({ // TODO: refactor after we have a plan for inpage case "REQUEST_DECLARE_CONTRACT": { const { data } = msg - const { address, networkId, ...rest } = data - if (address && networkId) { + const { account, payload } = data + if (account) { await wallet.selectAccount({ - address, - networkId, + address: account.address, + networkId: account.networkId, }) } const action = await actionService.add( { type: "DECLARE_CONTRACT", - payload: { - ...rest, - }, + payload, }, { origin, diff --git a/packages/extension/src/background/wallet/account/shared.service.ts b/packages/extension/src/background/wallet/account/shared.service.ts index de5df252b..a3ae06cd2 100644 --- a/packages/extension/src/background/wallet/account/shared.service.ts +++ b/packages/extension/src/background/wallet/account/shared.service.ts @@ -7,7 +7,7 @@ import { } from "../../../shared/wallet.model" import { withHiddenSelector } from "../../../shared/account/selectors" import { PendingMultisig } from "../../../shared/multisig/types" -import { Network, defaultNetwork } from "../../../shared/network" +import { defaultNetwork } from "../../../shared/network" import { BaseWalletAccount, WalletAccount } from "../../../shared/wallet.model" import { MULTISIG_DERIVATION_PATH, @@ -28,6 +28,15 @@ export interface WalletSession { password: string } +interface GetAccountArgs + extends Pick< + WalletAccount, + "address" | "network" | "needsDeploy" | "classHash" + > { + index: number + name?: string +} + export class WalletAccountSharedService { constructor( public readonly store: IObjectStore, @@ -63,14 +72,16 @@ export class WalletAccountSharedService { return defaultAccountName } - public getDefaultStandardAccount( - index: number, - address: string, - network: Network, - needsDeploy: boolean, - ): WalletAccount { + public getDefaultStandardAccount({ + index, + address, + network, + needsDeploy, + name, + classHash, + }: GetAccountArgs): WalletAccount { return { - name: `Account ${index + 1}`, + name: name || `Account ${index + 1}`, address, network, networkId: network.id, @@ -79,18 +90,20 @@ export class WalletAccountSharedService { derivationPath: getPathForIndex(index, STANDARD_DERIVATION_PATH), type: "local_secret", }, + classHash, needsDeploy, } } - public getDefaultMultisigAccount( - index: number, - address: string, - network: Network, - needsDeploy: boolean, - ): WalletAccount { + public getDefaultMultisigAccount({ + index, + address, + network, + needsDeploy, + name, + }: GetAccountArgs): WalletAccount { return { - name: `Multisig ${index + 1}`, + name: name || `Multisig ${index + 1}`, address, networkId: network.id, network, diff --git a/packages/extension/src/background/wallet/account/starknet.service.test.ts b/packages/extension/src/background/wallet/account/starknet.service.test.ts index 3a98a82e5..6d398ffe8 100644 --- a/packages/extension/src/background/wallet/account/starknet.service.test.ts +++ b/packages/extension/src/background/wallet/account/starknet.service.test.ts @@ -9,7 +9,7 @@ import { cryptoStarknetServiceMock, sessionServiceMock, } from "../test.utils" -import { Account } from "starknet" +import { Account } from "starknet6" import { grindKey } from "../../keys/keyDerivation" import { MultisigSigner } from "../../../shared/multisig/signer" diff --git a/packages/extension/src/background/wallet/account/starknet.service.ts b/packages/extension/src/background/wallet/account/starknet.service.ts index bba3868cf..bfdff71bf 100644 --- a/packages/extension/src/background/wallet/account/starknet.service.ts +++ b/packages/extension/src/background/wallet/account/starknet.service.ts @@ -1,8 +1,8 @@ -import { Account } from "starknet" +import { getProvider6 } from "../../../shared/network" +import { Account } from "starknet6" import { MultisigAccount } from "../../../shared/multisig/account" import { PendingMultisig } from "../../../shared/multisig/types" -import { getProvider } from "../../../shared/network" import { INetworkService } from "../../../shared/network/service/interface" import { IRepository } from "../../../shared/storage/__new/interface" import { getAccountCairoVersion } from "../../../shared/utils/argentAccountVersion" @@ -40,7 +40,7 @@ export class WalletAccountStarknetService { throw new AccountError({ code: "NOT_FOUND" }) } - const provider = getProvider( + const provider = getProvider6( account.network && account.network.rpcUrl ? account.network : await this.networkService.getById(selector.networkId), @@ -59,7 +59,7 @@ export class WalletAccountStarknetService { const starknetAccount = new Account( provider, account.address, - pkOrSigner, + pkOrSigner as any, // TODO: migrate to snjsv6 completely cairoVersion, ) @@ -70,7 +70,7 @@ export class WalletAccountStarknetService { const starknetAccount = new Account( provider, account.address, - pkOrSigner, + pkOrSigner as any, // TODO: migrate to snjsv6 completely "1", ) @@ -96,7 +96,7 @@ export class WalletAccountStarknetService { const starknetAccount = new Account( provider, account.address, - pkOrSigner, + pkOrSigner as any, // TODO: migrate to snjsv6 completely accountCairoVersion, ) diff --git a/packages/extension/src/background/wallet/crypto/shared.service.ts b/packages/extension/src/background/wallet/crypto/shared.service.ts index e569c6cdc..f79d382cc 100644 --- a/packages/extension/src/background/wallet/crypto/shared.service.ts +++ b/packages/extension/src/background/wallet/crypto/shared.service.ts @@ -12,7 +12,6 @@ import { WalletSessionService } from "../session/session.service" import type { WalletSession } from "../session/walletSession.model" import { IWalletDeploymentService } from "../deployment/interface" import { IObjectStore } from "../../../shared/storage/__new/interface" -import { WalletError } from "../../../shared/errors/wallet" import { walletToKeystore } from "../utils" export class WalletCryptoSharedService { @@ -26,10 +25,6 @@ export class WalletCryptoSharedService { ) {} public async restoreSeedPhrase(seedPhrase: string, newPassword: string) { - const session = await this.sessionStore.get() - if ((await this.backupService.isInitialized()) || session) { - throw new WalletError({ code: "ALREADY_INITIALIZED" }) - } const ethersWallet = HDNodeWallet.fromPhrase(seedPhrase) const encryptedBackup = await encryptKeystoreJson( walletToKeystore(ethersWallet), diff --git a/packages/extension/src/background/wallet/crypto/starknet.service.ts b/packages/extension/src/background/wallet/crypto/starknet.service.ts index 353590254..89490b7b9 100644 --- a/packages/extension/src/background/wallet/crypto/starknet.service.ts +++ b/packages/extension/src/background/wallet/crypto/starknet.service.ts @@ -1,4 +1,4 @@ -import { isEqualAddress } from "@argent/shared" +import { Hex, isEqualAddress } from "@argent/shared" import { CairoVersion, CallData, hash } from "starknet" import { withHiddenSelector } from "../../../shared/account/selectors" import { MultisigSigner } from "../../../shared/multisig/signer" @@ -31,17 +31,20 @@ import { IObjectStore, IRepository, } from "../../../shared/storage/__new/interface" -import { - ARGENT_ACCOUNT_CONTRACT_CLASS_HASHES, - PROXY_CONTRACT_CLASS_HASHES, -} from "../starknet.constants" import { decodeBase58Array } from "@argent/shared" import { MULTISIG_DERIVATION_PATH } from "../../../shared/wallet.service" import { sortMultisigByDerivationPath } from "../../../shared/utils/accountsMultisigSort" import { SessionError } from "../../../shared/errors/session" import { getAccountCairoVersion } from "../../../shared/utils/argentAccountVersion" import { AccountError } from "../../../shared/errors/account" -import { STANDARD_CAIRO_0_ACCOUNT_CLASS_HASH } from "../../../shared/network/constants" +import { + ARGENT_ACCOUNT_CONTRACT_CLASS_HASHES, + C0_PROXY_CONTRACT_CLASS_HASHES, +} from "../../../shared/account/starknet.constants" +import { + Implementation, + findImplementationForAccount, +} from "../findImplementationForAddress" const { getSelectorFromName, calculateContractAddressFromHash } = hash export class WalletCryptoStarknetService { @@ -144,9 +147,7 @@ export class WalletCryptoStarknetService { account.signer.derivationPath, ) - const starkPub = starkPair.pubKey - - return { publicKey: starkPub, account } + return { publicKey: starkPair.pubKey, account } } /** @@ -226,12 +227,10 @@ export class WalletCryptoStarknetService { async getAccountClassHashForNetwork( network: Network, accountType: ArgentAccountType, - ): Promise { + ): Promise { if (network.accountClassHash && network.accountClassHash.standard) { - return ( - network.accountClassHash[accountType] ?? - network.accountClassHash.standard - ) + return (network.accountClassHash[accountType] ?? + network.accountClassHash.standard) as Hex } const deployerAccount = await getPreDeployedAccount(network) @@ -242,58 +241,10 @@ export class WalletCryptoStarknetService { this.loadContracts, ) - return accountClassHash - } - - return ARGENT_ACCOUNT_CONTRACT_CLASS_HASHES[0] - } - - public getCairo0AccountContractAddress( - accountClassHash: string, - pubKey: string, - ): string { - const constructorCallData = { - implementation: accountClassHash, - selector: getSelectorFromName("initialize"), - calldata: CallData.compile({ - signer: pubKey, - guardian: "0", - }), - } - - const deployAccountPayload = { - classHash: PROXY_CONTRACT_CLASS_HASHES[0], - constructorCalldata: CallData.compile(constructorCallData), - addressSalt: pubKey, - } - - return calculateContractAddressFromHash( - deployAccountPayload.addressSalt, - deployAccountPayload.classHash, - deployAccountPayload.constructorCalldata, - 0, - ) - } - - public getCairo1AccountContractAddress( - accountClassHash: string, - pubKey: string, - ) { - const deployAccountPayload = { - classHash: accountClassHash, - constructorCalldata: CallData.compile({ - signer: pubKey, - guardian: "0", - }), - addressSalt: pubKey, + return accountClassHash as Hex } - return calculateContractAddressFromHash( - deployAccountPayload.addressSalt, - deployAccountPayload.classHash, - deployAccountPayload.constructorCalldata, - 0, - ) + return ARGENT_ACCOUNT_CONTRACT_CLASS_HASHES.CAIRO_1[0] as Hex } public async getUndeployedAccountCairoVersion( @@ -317,37 +268,30 @@ export class WalletCryptoStarknetService { return "1" // multisig is always Cairo 1 } - const accountClassHash = - account.classHash ?? - (await this.getAccountClassHashForNetwork(account.network, account.type)) - const { publicKey } = await this.getPublicKey(account) - const cairo1Address = this.getCairo1AccountContractAddress( - accountClassHash, - publicKey, - ) - - if (isEqualAddress(account.address, cairo1Address)) { - console.log("Undeployed Account is a Cairo 1 account") - return "1" + // If no class hash is provided by the account, we want to add the network implementation to check + const networkImplementation: Implementation = { + cairoVersion: "1", + accountClassHash: await this.getAccountClassHashForNetwork( + account.network, + account.type, + ), } - const cairo0Address = this.getCairo0AccountContractAddress( - STANDARD_CAIRO_0_ACCOUNT_CLASS_HASH, // last Cairo 0 implementation - publicKey, - ) - - if (isEqualAddress(account.address, cairo0Address)) { - console.log("Undeployed Account is a Cairo 0 account") - return "0" + try { + const { cairoVersion } = findImplementationForAccount( + publicKey, + account, + [networkImplementation], + ) + return cairoVersion + } catch (error) { + throw new AccountError({ + code: "UNDEPLOYED_ACCOUNT_CAIRO_VERSION_NOT_FOUND", + options: { error }, + }) } - - // We don't check for bad class hash 0x01a7820094feaf82d53f53f214b81292d717e7bb9a92bb2488092cd306f3993f - // because it's deprecated, so we should not have any account with this class hash that should need this function - throw new AccountError({ - code: "UNDEPLOYED_ACCOUNT_CAIRO_VERSION_NOT_FOUND", - }) } public async getCalculatedMultisigAddress( @@ -386,7 +330,7 @@ export class WalletCryptoStarknetService { } const deployMultisigPayload = { - classHash: PROXY_CONTRACT_CLASS_HASHES[0], + classHash: C0_PROXY_CONTRACT_CLASS_HASHES[0], constructorCalldata: CallData.compile(constructorCallData), addressSalt: starkPub, } diff --git a/packages/extension/src/background/wallet/deployment/interface.ts b/packages/extension/src/background/wallet/deployment/interface.ts index 283bb1bf5..75064f7ef 100644 --- a/packages/extension/src/background/wallet/deployment/interface.ts +++ b/packages/extension/src/background/wallet/deployment/interface.ts @@ -1,4 +1,5 @@ import { + CairoVersion, DeployAccountContractPayload as StarknetDeployAccountContractPayload, InvocationsDetails as StarknetInvocationDetails, } from "starknet" @@ -13,7 +14,10 @@ import { Address } from "@argent/shared" // Extend to support multichain type InvocationsDetails = StarknetInvocationDetails -type DeployAccountContractPayload = StarknetDeployAccountContractPayload +export type DeployAccountContractPayload = + StarknetDeployAccountContractPayload & { + version: CairoVersion + } export interface IWalletDeploymentService { deployAccount( diff --git a/packages/extension/src/background/wallet/deployment/starknet.service.ts b/packages/extension/src/background/wallet/deployment/starknet.service.ts index 5402924ed..70fda3bbe 100644 --- a/packages/extension/src/background/wallet/deployment/starknet.service.ts +++ b/packages/extension/src/background/wallet/deployment/starknet.service.ts @@ -7,11 +7,10 @@ import { } from "@argent/shared" import { CallData, - DeployAccountContractPayload, DeployAccountContractTransaction, - InvocationsDetails, + EstimateFeeDetails, hash, -} from "starknet" +} from "starknet6" import { withHiddenSelector } from "../../../shared/account/selectors" import { PendingMultisig } from "../../../shared/multisig/types" @@ -47,20 +46,48 @@ import { WalletBackupService } from "../backup/backup.service" import { WalletCryptoStarknetService } from "../crypto/starknet.service" import { WalletSessionService } from "../session/session.service" import type { WalletSession } from "../session/walletSession.model" -import { PROXY_CONTRACT_CLASS_HASHES } from "../starknet.constants" -import { IWalletDeploymentService } from "./interface" +import { + IWalletDeploymentService, + DeployAccountContractPayload, +} from "./interface" import { SessionError } from "../../../shared/errors/session" import { WalletError } from "../../../shared/errors/wallet" import { ETH_TOKEN_ADDRESS, - STANDARD_CAIRO_0_ACCOUNT_CLASS_HASH, + STRK_TOKEN_ADDRESS, } from "../../../shared/network/constants" import { AccountError } from "../../../shared/errors/account" -import { modifySnjsFeeOverhead } from "../../../shared/utils/argentMaxFee" import { EstimatedFee } from "../../../shared/transactionSimulation/fees/fees.model" -import { estimatedFeeToMaxFeeTotal } from "../../../shared/transactionSimulation/utils" +import { estimatedFeeToMaxResourceBounds } from "../../../shared/transactionSimulation/utils" +import { BigNumberish, num, constants, CairoVersion } from "starknet" +import { getTxVersionFromFeeToken } from "../../../shared/utils/getTransactionVersion" +import { + Implementation, + findImplementationForAccount, + getAccountDeploymentPayload, +} from "../findImplementationForAddress" + +const { calculateContractAddressFromHash } = hash -const { getSelectorFromName, calculateContractAddressFromHash } = hash +// TODO: import from starknet6 when available +const BN_TRANSACTION_VERSION_3 = 3n +const BN_FEE_TRANSACTION_VERSION_3 = 2n ** 128n + BN_TRANSACTION_VERSION_3 + +function mapVersionToFeeToken(version: BigNumberish): Address { + if ( + num.toBigInt(version) === constants.BN_TRANSACTION_VERSION_1 || + num.toBigInt(version) === constants.BN_FEE_TRANSACTION_VERSION_1 + ) { + return ETH_TOKEN_ADDRESS + } + if ( + num.toBigInt(version) === BN_TRANSACTION_VERSION_3 || + num.toBigInt(version) === BN_FEE_TRANSACTION_VERSION_3 + ) { + return STRK_TOKEN_ADDRESS + } + throw new Error("Unsupported tx version for fee token mapping") +} export class WalletDeploymentStarknetService implements IWalletDeploymentService @@ -80,7 +107,7 @@ export class WalletDeploymentStarknetService public async deployAccount( walletAccount: WalletAccount, - transactionDetails?: InvocationsDetails | undefined, + transactionDetails?: EstimateFeeDetails | undefined, ): Promise<{ account: WalletAccount; txHash: string }> { const starknetAccount = await this.accountStarknetService.getStarknetAccount(walletAccount) @@ -95,18 +122,25 @@ export class WalletDeploymentStarknetService if (!isAccountV5(starknetAccount)) { throw new AccountError({ code: "CANNOT_DEPLOY_OLD_ACCOUNTS" }) } - const maxFee = transactionDetails?.maxFee - ? modifySnjsFeeOverhead({ - suggestedMaxFee: transactionDetails.maxFee, - }) - : estimatedFeeToMaxFeeTotal( - await this.getAccountDeploymentFee(walletAccount), - ) + + const maxFeeOrBounds = + transactionDetails?.maxFee || transactionDetails?.resourceBounds + ? { + maxFee: transactionDetails?.maxFee, + resourceBounds: transactionDetails?.resourceBounds, + } + : estimatedFeeToMaxResourceBounds( + await this.getAccountDeploymentFee( + walletAccount, + mapVersionToFeeToken(transactionDetails?.version ?? "0x1"), + ), + ) + const { transaction_hash } = await starknetAccount.deployAccount( deployAccountPayload, { ...transactionDetails, - maxFee, + ...maxFeeOrBounds, }, ) @@ -126,7 +160,7 @@ export class WalletDeploymentStarknetService public async getAccountDeploymentFee( walletAccount: WalletAccount, - feeTokenAddress: Address = ETH_TOKEN_ADDRESS, + feeTokenAddress: Address, ): Promise { const starknetAccount = await this.accountStarknetService.getStarknetAccount(walletAccount) @@ -143,9 +177,13 @@ export class WalletDeploymentStarknetService code: "CANNOT_ESTIMATE_FEE_OLD_ACCOUNTS_DEPLOYMENT", }) } + + const version = getTxVersionFromFeeToken(feeTokenAddress) + const { gas_consumed, gas_price } = await starknetAccount.estimateAccountDeployFee(deployAccountPayload, { skipValidate: true, + version, }) if (!gas_consumed || !gas_price) { @@ -193,107 +231,27 @@ export class WalletDeploymentStarknetService const starkPub = starkPair.pubKey - // Try to get the account class hash from walletAccount if it exists - // If it doesn't exist, get it from the network object - const accountClassHash = - walletAccount.classHash ?? - (await this.cryptoStarknetService.getAccountClassHashForNetwork( - walletAccount.network, - walletAccount.type, - )) - - const constructorCallData = { - implementation: accountClassHash, - selector: getSelectorFromName("initialize"), - calldata: CallData.compile({ signer: starkPub, guardian: "0" }), - } - - const deployAccountPayloadCairo0 = { - classHash: PROXY_CONTRACT_CLASS_HASHES[0], - contractAddress: walletAccount.address, - constructorCalldata: CallData.compile(constructorCallData), - addressSalt: starkPub, - } - - const deployAccountPayloadCairo1 = { - classHash: accountClassHash, - contractAddress: walletAccount.address, - constructorCalldata: CallData.compile({ - signer: starkPub, - guardian: "0", - }), - addressSalt: starkPub, - } - - let deployAccountPayload - - if (walletAccount.type === "standardCairo0") { - deployAccountPayload = deployAccountPayloadCairo0 - } else { - deployAccountPayload = deployAccountPayloadCairo1 - } - - const calculatedAccountAddress = calculateContractAddressFromHash( - deployAccountPayload.addressSalt, - deployAccountPayload.classHash, - deployAccountPayload.constructorCalldata, - 0, - ) - - if (isEqualAddress(walletAccount.address, calculatedAccountAddress)) { - return deployAccountPayload - } - - // Warn if the account was created using Cairo 0 implementation and the address does not match - console.warn( - "Calculated address does not match Cairo 1 account address. Trying Cairo 0 implementation", - ) - - const cairo0Calldata = CallData.compile({ - ...constructorCallData, - implementation: STANDARD_CAIRO_0_ACCOUNT_CLASS_HASH, // last Cairo 0 implementation - }) - - // Try to deploy using Cairo 0 implementation - const cairo0CalculatedAccountAddress = calculateContractAddressFromHash( - deployAccountPayloadCairo0.addressSalt, - deployAccountPayloadCairo0.classHash, - cairo0Calldata, - 0, - ) - - if (isEqualAddress(walletAccount.address, cairo0CalculatedAccountAddress)) { - console.warn("Address matches Cairo 0 implementation") - deployAccountPayloadCairo0.constructorCalldata = cairo0Calldata - return deployAccountPayloadCairo0 + // If no class hash is provided by the account, we want to add the network implementation to check + const networkImplementation: Implementation = { + cairoVersion: "1", + accountClassHash: + await this.cryptoStarknetService.getAccountClassHashForNetwork( + walletAccount.network, + walletAccount.type, + ), } - console.warn( - "Calculated address does not match Cairo 0 account address. Trying old implementation", - ) - - // In the end, try to deploy using the old implementation - const oldCalldata = CallData.compile({ - ...constructorCallData, - implementation: - "0x1a7820094feaf82d53f53f214b81292d717e7bb9a92bb2488092cd306f3993f", // old implementation, ask @janek why - }) - - const oldCalculatedAddress = calculateContractAddressFromHash( - deployAccountPayload.addressSalt, - deployAccountPayload.classHash, - oldCalldata, - 0, + const { accountClassHash, cairoVersion } = findImplementationForAccount( + starkPub, + walletAccount, + [networkImplementation], ) - if (isEqualAddress(oldCalculatedAddress, walletAccount.address)) { - console.warn("Address matches old implementation") - deployAccountPayload.constructorCalldata = oldCalldata - } else { - throw new AccountError({ code: "CALCULATED_ADDRESS_NO_MATCH" }) + return { + ...getAccountDeploymentPayload(cairoVersion, accountClassHash, starkPub), + version: cairoVersion as CairoVersion, + contractAddress: walletAccount.address, } - - return deployAccountPayload } public async getMultisigDeploymentPayload( @@ -346,7 +304,7 @@ export class WalletDeploymentStarknetService throw new AccountError({ code: "CALCULATED_ADDRESS_NO_MATCH" }) } - return deployMultisigPayload + return { ...deployMultisigPayload, version: "1" } } // TODO: remove this once testing of cairo 1 is done @@ -378,17 +336,7 @@ export class WalletDeploymentStarknetService "standardCairo0", ) - const payload = { - classHash: PROXY_CONTRACT_CLASS_HASHES[0], - constructorCalldata: CallData.compile({ - implementation: accountClassHash, - selector: getSelectorFromName("initialize"), - calldata: CallData.compile({ signer: pubKey, guardian: "0" }), - }), - addressSalt: pubKey, - } - - return payload + return getAccountDeploymentPayload("0", accountClassHash, pubKey) } public async getDeployContractPayloadForAccountIndex( @@ -419,16 +367,7 @@ export class WalletDeploymentStarknetService "standard", ) - const payload = { - classHash: accountClassHash, - constructorCalldata: CallData.compile({ - signer: pubKey, - guardian: "0", - }), - addressSalt: pubKey, - } - - return payload + return getAccountDeploymentPayload("1", accountClassHash, pubKey) } public async getDeployContractPayloadForMultisig({ @@ -556,7 +495,7 @@ export class WalletDeploymentStarknetService }, type, classHash: addressSchema.parse(payload.classHash), // This is only true for new Cairo 1 accounts. For Cairo 0, this is the proxy contract class hash - cairoVersion: "1", + cairoVersion: type === "standardCairo0" ? "0" : "1", needsDeploy: !isDeployed, } diff --git a/packages/extension/src/background/wallet/findImplementationForAddress.test.ts b/packages/extension/src/background/wallet/findImplementationForAddress.test.ts new file mode 100644 index 000000000..7a1d7906a --- /dev/null +++ b/packages/extension/src/background/wallet/findImplementationForAddress.test.ts @@ -0,0 +1,207 @@ +import { test } from "vitest" +import { + findImplementationForAccount, + getAccountDeploymentPayload, + getAccountContractAddress, + Implementation, +} from "./findImplementationForAddress" +import { WalletAccount } from "../../shared/wallet.model" +import { ARGENT_ACCOUNT_CONTRACT_CLASS_HASHES } from "../../shared/account/starknet.constants" +import { STANDARD_DEVNET_ACCOUNT_CLASS_HASH } from "../../shared/network/constants" + +const validCairoVersion = "1" +const validAccountClassHash = + "0x01a736d6ed154502257f02b1ccdf4d9d1089f80811cd6acad48e6b6a9d1f2003" +const validPubKey = + "0x79c1b964b5c2996ca1ba107616e0c3a9b671d488b696886606270dc5784e131" +const validAddress = // confirmed inside AX + "0xb9209b483a8f0b75ea6244827f66227f619e9dc055b18b16b26732c76dbd9d" + +describe("findImplementationForAccount", () => { + test("with invalid address", () => { + const invalidAddress = "0x456" + const account: Pick< + WalletAccount, + "address" | "classHash" | "cairoVersion" + > = { + address: invalidAddress, + classHash: validAccountClassHash, + cairoVersion: validCairoVersion, + } + const additionalImplementations: Implementation[] = [] + + expect(() => + findImplementationForAccount( + validPubKey, + account, + additionalImplementations, + ), + ).toThrowErrorMatchingInlineSnapshot( + `[AccountError: Calculated address does not match account address]`, + ) + }) + + test("with invalid pubkey", () => { + const invalidPubkey = "0xinvalid" + const account: Pick< + WalletAccount, + "address" | "classHash" | "cairoVersion" + > = { + address: "0x456", + classHash: validAccountClassHash, + cairoVersion: validCairoVersion, + } + const additionalImplementations: Implementation[] = [] + + expect(() => + findImplementationForAccount( + invalidPubkey, + account, + additionalImplementations, + ), + ).toThrowErrorMatchingInlineSnapshot( + `[SyntaxError: Cannot convert 0xinvalid to a BigInt]`, + ) + }) + + test("with no additional implementations", () => { + const account: Pick< + WalletAccount, + "address" | "classHash" | "cairoVersion" + > = { + address: validAddress, + classHash: validAccountClassHash, + cairoVersion: validCairoVersion, + } + + const result = findImplementationForAccount(validPubKey, account) + expect(result).toMatchInlineSnapshot(` + { + "accountClassHash": "0x01a736d6ed154502257f02b1ccdf4d9d1089f80811cd6acad48e6b6a9d1f2003", + "cairoVersion": "1", + } + `) + }) + + test("with additional implementations", () => { + const invalidCairoVersion = "0" + const invalidAddress = + "0x5663ac1c17abd6cb78c09b160e409f9c6acfeff368c3c3a61e458f1d5c6dfd4" + const invalidImplementation: Implementation = { + accountClassHash: validAccountClassHash, + cairoVersion: invalidCairoVersion, + } + const account: Pick< + WalletAccount, + "address" | "classHash" | "cairoVersion" + > = { + address: invalidAddress, + classHash: validAccountClassHash, + cairoVersion: invalidCairoVersion, + } + const additionalImplementations: Implementation[] = [invalidImplementation] + + const result = findImplementationForAccount( + validPubKey, + account, + additionalImplementations, + ) + expect(result).toEqual(additionalImplementations[0]) + }) +}) + +describe("getAccountDeploymentPayload", () => { + const pubKey = "0x123" + + test("when cairoVersion is 1 and accountClassHash is CAIRO_1[0]", () => { + const cairoVersion = "1" + const accountClassHash = ARGENT_ACCOUNT_CONTRACT_CLASS_HASHES.CAIRO_1[0] + + const result = getAccountDeploymentPayload( + cairoVersion, + accountClassHash, + pubKey, + ) + + expect(result).toMatchInlineSnapshot(` + { + "addressSalt": "0x123", + "classHash": "0x29927c8af6bccf3f6fda035981e765a7bdbf18a2dc0d630494f8758aa908e2b", + "constructorCalldata": [ + "291", + "0", + ], + } + `) + }) + + test("when cairoVersion is 0 and accountClassHash is CAIRO_0[0]", () => { + const cairoVersion = "0" + const accountClassHash = ARGENT_ACCOUNT_CONTRACT_CLASS_HASHES.CAIRO_0[0] + + const result = getAccountDeploymentPayload( + cairoVersion, + accountClassHash, + pubKey, + ) + + expect(result).toMatchInlineSnapshot(` + { + "addressSalt": "0x123", + "classHash": "0x25ec026985a3bf9d0cc1fe17326b245dfdc3ff89b8fde106542a3ea56c5a918", + "constructorCalldata": [ + "1449178161945088530446351771646113898511736767359683664273252560520029776866", + "215307247182100370520050591091822763712463273430149262739280891880522753123", + "2", + "291", + "0", + ], + } + `) + // Add your assertions here + }) + + test("when cairoVersion is 1 and accountClassHash is STANDARD_DEVNET_ACCOUNT_CLASS_HASH", () => { + const cairoVersion = "1" + const accountClassHash = STANDARD_DEVNET_ACCOUNT_CLASS_HASH + + const result = getAccountDeploymentPayload( + cairoVersion, + accountClassHash, + pubKey, + ) + + expect(result).toMatchInlineSnapshot(` + { + "addressSalt": "0x123", + "classHash": "0x4d07e40e93398ed3c76981e72dd1fd22557a78ce36c0515f679e27f0bb5bc5f", + "constructorCalldata": [ + "291", + ], + } + `) + }) +}) + +describe("getAccountContractAddress", () => { + test("valid AX account", () => { + const result = getAccountContractAddress( + validCairoVersion, + validAccountClassHash, + validPubKey, + ) + + expect(result).toEqual(validAddress) + }) + test("bugged account C0", () => { + const result = getAccountContractAddress( + "0", + validAccountClassHash, + validPubKey, + ) + + expect(result).toMatchInlineSnapshot( + `"0x5663ac1c17abd6cb78c09b160e409f9c6acfeff368c3c3a61e458f1d5c6dfd4"`, + ) + }) +}) diff --git a/packages/extension/src/background/wallet/findImplementationForAddress.ts b/packages/extension/src/background/wallet/findImplementationForAddress.ts new file mode 100644 index 000000000..8aec29aff --- /dev/null +++ b/packages/extension/src/background/wallet/findImplementationForAddress.ts @@ -0,0 +1,136 @@ +import { hexSchema, isEqualAddress } from "@argent/shared" +import { WalletAccount, cairoVersionSchema } from "../../shared/wallet.model" +import { z } from "zod" +import { + ARGENT_ACCOUNT_CONTRACT_CLASS_HASHES, + C0_PROXY_CONTRACT_CLASS_HASHES, +} from "../../shared/account/starknet.constants" +import { uniqWith } from "lodash-es" +import { CallData, Calldata, hash } from "starknet" +import { AccountError } from "../../shared/errors/account" +import { STANDARD_DEVNET_ACCOUNT_CLASS_HASH } from "../../shared/network/constants" + +export const implementationSchema = z.object({ + cairoVersion: cairoVersionSchema, + accountClassHash: hexSchema, +}) +export type Implementation = z.infer +export const isEqualImplementation = (a: Implementation, b: Implementation) => + a.cairoVersion === b.cairoVersion && + isEqualAddress(a.accountClassHash, b.accountClassHash) + +export function findImplementationForAccount( + pubkey: string, + account: Pick, + additionalImplementations: Implementation[] = [], +): Implementation { + const parsedAccountImplementation = implementationSchema.parse({ + cairoVersion: account.cairoVersion ?? "1", + accountClassHash: + account.classHash ?? ARGENT_ACCOUNT_CONTRACT_CLASS_HASHES.CAIRO_1[0], + }) + const parsedAdditionalImplementations = z + .array(implementationSchema) + .parse(additionalImplementations) + + const defaultImplementations = [ + ...ARGENT_ACCOUNT_CONTRACT_CLASS_HASHES.CAIRO_0.map( + (ch): Implementation => ({ cairoVersion: "0", accountClassHash: ch }), + ), + ...ARGENT_ACCOUNT_CONTRACT_CLASS_HASHES.CAIRO_1.map( + (ch): Implementation => ({ cairoVersion: "1", accountClassHash: ch }), + ), + ] + + const uniqueImplementations = uniqWith( + [ + parsedAccountImplementation, + ...parsedAdditionalImplementations, + ...defaultImplementations, + ], + isEqualImplementation, + ) + + // calculate addresses for each implementation + const implementationsWithAddresses = uniqueImplementations.map( + (implementation) => ({ + implementation, + address: getAccountContractAddress( + implementation.cairoVersion, + implementation.accountClassHash, + pubkey, + ), + }), + ) + + // find the implementation that matches the account address + const matchingImplementation = implementationsWithAddresses.find( + (implementation) => isEqualAddress(implementation.address, account.address), + ) + + if (!matchingImplementation) { + throw new AccountError({ code: "CALCULATED_ADDRESS_NO_MATCH" }) + } + + return matchingImplementation.implementation +} + +export function getAccountDeploymentPayload( + cairoVersion: string, + accountClassHash: string, + pubKey: string, + /** @deprecated This is only used for backwards compatibility with the old proxy contract, should not be used */ + c0ProxyClassHash: string = C0_PROXY_CONTRACT_CLASS_HASHES[0], +) { + const isDevnetImpl = + cairoVersion !== "0" && + isEqualAddress(STANDARD_DEVNET_ACCOUNT_CLASS_HASH, accountClassHash) + const implCalldata = { + signer: pubKey, + ...(isDevnetImpl ? {} : { guardian: "0" }), + } + const constructorCallData: + | { + signer: string + guardian?: string + } + | { + implementation: string + selector: string + calldata: Calldata + } = + cairoVersion === "0" + ? { + implementation: accountClassHash, + selector: hash.getSelectorFromName("initialize"), + calldata: CallData.compile(implCalldata), + } + : implCalldata + + const deployAccountPayload = { + classHash: cairoVersion === "0" ? c0ProxyClassHash : accountClassHash, + constructorCalldata: CallData.compile(constructorCallData), + addressSalt: pubKey, + } + + return deployAccountPayload +} + +export function getAccountContractAddress( + cairoVersion: string, + accountClassHash: string, + pubKey: string, +) { + const deployAccountPayload = getAccountDeploymentPayload( + cairoVersion, + accountClassHash, + pubKey, + ) + + return hash.calculateContractAddressFromHash( + deployAccountPayload.addressSalt, + deployAccountPayload.classHash, + deployAccountPayload.constructorCalldata, + 0, + ) +} diff --git a/packages/extension/src/background/wallet/index.ts b/packages/extension/src/background/wallet/index.ts index 0acd90511..160989f68 100644 --- a/packages/extension/src/background/wallet/index.ts +++ b/packages/extension/src/background/wallet/index.ts @@ -1,4 +1,4 @@ -import { Account, InvocationsDetails } from "starknet" +import { Account, InvocationsDetails } from "starknet6" import { ArgentAccountType, @@ -169,7 +169,7 @@ export class Wallet { } public async getAccountDeploymentFee( walletAccount: WalletAccount, - feeTokenAddress?: Address, + feeTokenAddress: Address, ) { return this.walletDeploymentStarknetService.getAccountDeploymentFee( walletAccount, diff --git a/packages/extension/src/background/wallet/recovery/starknet.service.ts b/packages/extension/src/background/wallet/recovery/starknet.service.ts index abd910693..37aa220ca 100644 --- a/packages/extension/src/background/wallet/recovery/starknet.service.ts +++ b/packages/extension/src/background/wallet/recovery/starknet.service.ts @@ -1,8 +1,5 @@ import { WalletAccountSharedService } from "./../account/shared.service" -import { - networkIdToStarknetNetwork, - networkToDiscoveryNetwork, -} from "./../../../shared/utils/starknetNetwork" +import { networkIdToStarknetNetwork } from "./../../../shared/utils/starknetNetwork" import { union, partition, memoize } from "lodash-es" import { num } from "starknet" @@ -30,7 +27,7 @@ import { generatePublicKeys, } from "../../keys/keyDerivation" import { WalletCryptoStarknetService } from "../crypto/starknet.service" -import { ARGENT_ACCOUNT_CONTRACT_CLASS_HASHES__NEW } from "../starknet.constants" +import { ARGENT_ACCOUNT_CONTRACT_CLASS_HASHES } from "../../../shared/account/starknet.constants" import { IWalletRecoveryService } from "./interface" import { IRepository } from "../../../shared/storage/__new/interface" import urlJoin from "url-join" @@ -44,7 +41,9 @@ import { ApiMultisigDataForSignerSchema, } from "../../../shared/multisig/multisig.model" import { RecoveryError } from "../../../shared/errors/recovery" -import { isContractDeployed } from "@argent/shared" +import { Address, isContractDeployed } from "@argent/shared" +import { getAccountContractAddress } from "../findImplementationForAddress" +import { argentApiNetworkForNetwork } from "../../../shared/api/headers" const INITIAL_PUB_KEY_COUNT = 5 @@ -62,12 +61,14 @@ export class WalletRecoveryStarknetService implements IWalletRecoveryService { ) {} private getCairo1AccountContractAddressMemoized = memoize( - this.cryptoStarknetService.getCairo1AccountContractAddress, + (classHash: string, publicKey: string) => + getAccountContractAddress("1", classHash, publicKey), (classHash, publicKey) => `${classHash}:${publicKey}`, ) private getCairo0AccountContractAddressMemoized = memoize( - this.cryptoStarknetService.getCairo0AccountContractAddress, + (classHash: string, publicKey: string) => + getAccountContractAddress("0", classHash, publicKey), (classHash, publicKey) => `${classHash}:${publicKey}`, ) @@ -114,7 +115,7 @@ export class WalletRecoveryStarknetService implements IWalletRecoveryService { } private getStandardAccountDiscoveryUrl(network: Network) { - const backendNetwork = networkToDiscoveryNetwork(network) + const backendNetwork = argentApiNetworkForNetwork(network.id) if ( process.env.NODE_ENV !== "production" && // be more strict in development @@ -183,7 +184,7 @@ export class WalletRecoveryStarknetService implements IWalletRecoveryService { ) { // Discover Cairo1 standard accounts const cairo1AccountClassHashes = union( - ARGENT_ACCOUNT_CONTRACT_CLASS_HASHES__NEW.CAIRO_1, + ARGENT_ACCOUNT_CONTRACT_CLASS_HASHES.CAIRO_1, [...(defaultClassHash ? [defaultClassHash] : [])], ) @@ -194,10 +195,14 @@ export class WalletRecoveryStarknetService implements IWalletRecoveryService { ) const account = this.walletAccountSharedService.getDefaultStandardAccount( - pubKeyWithIndex.index, - address, - network, - false, + { + index: pubKeyWithIndex.index, + address, + network, + needsDeploy: false, + name: `Account ${pubKeyWithIndex.index + 1}`, + classHash: accountClassHash as Address, + }, ) return { @@ -214,7 +219,7 @@ export class WalletRecoveryStarknetService implements IWalletRecoveryService { ) { // Discover Cairo0 standard accounts const cairo0AccountClassHashes = union( - ARGENT_ACCOUNT_CONTRACT_CLASS_HASHES__NEW.CAIRO_0, + ARGENT_ACCOUNT_CONTRACT_CLASS_HASHES.CAIRO_0, [...(defaultClassHash ? [defaultClassHash] : [])], ) @@ -225,10 +230,14 @@ export class WalletRecoveryStarknetService implements IWalletRecoveryService { ) const account = this.walletAccountSharedService.getDefaultStandardAccount( - pubKeyWithIndex.index, - address, - network, - false, + { + index: pubKeyWithIndex.index, + address, + network, + needsDeploy: false, + name: `Account ${pubKeyWithIndex.index + 1}`, + classHash: accountClassHash as Address, + }, ) return { @@ -360,12 +369,12 @@ export class WalletRecoveryStarknetService implements IWalletRecoveryService { }) return { - ...this.walletAccountSharedService.getDefaultMultisigAccount( - pubKeyWithIndex.index, - validMultisig.address, + ...this.walletAccountSharedService.getDefaultMultisigAccount({ + index: pubKeyWithIndex.index, + address: validMultisig.address, network, - false, - ), + needsDeploy: false, + }), type: "multisig", } }) diff --git a/packages/extension/src/inpage/ArgentXAccount.ts b/packages/extension/src/inpage/ArgentXAccount.ts index a232b54c8..04fb72df4 100644 --- a/packages/extension/src/inpage/ArgentXAccount.ts +++ b/packages/extension/src/inpage/ArgentXAccount.ts @@ -88,10 +88,12 @@ export class ArgentXAccount extends Account { sendMessage({ type: "REQUEST_DECLARE_CONTRACT", data: { - contract, - classHash, - casm, - compiledClassHash, + payload: { + contract, + classHash, + casm, + compiledClassHash, + }, }, }) const { actionHash } = await waitForMessage( diff --git a/packages/extension/src/inpage/ArgentXProvider4.ts b/packages/extension/src/inpage/ArgentXProvider4.ts index b4e3ce46c..8f877a602 100644 --- a/packages/extension/src/inpage/ArgentXProvider4.ts +++ b/packages/extension/src/inpage/ArgentXProvider4.ts @@ -1,22 +1,16 @@ import { Call, Provider, ProviderInterface } from "starknet4" import { Network } from "../shared/network" -import { - getRandomPublicRPCNode, - isArgentNetwork, -} from "../shared/network/utils" +import { getRandomPublicRPCNode } from "../shared/network/utils" import { getProviderv4 } from "../shared/network/provider" +import { argentApiNetworkForNetwork } from "../shared/api/headers" export class ArgentXProviderV4 extends Provider implements ProviderInterface { constructor(network: Network) { // Only expose sequencer provider for argent networks - if (isArgentNetwork(network)) { + const key = argentApiNetworkForNetwork(network.id) + if (key) { const publicRpcNode = getRandomPublicRPCNode(network) - - const nodeUrl = - network.id === "mainnet-alpha" - ? publicRpcNode.mainnet - : publicRpcNode.testnet - + const nodeUrl = publicRpcNode[key] super({ rpc: { nodeUrl } }) } else { // Otherwise, it's a custom network, so we expose the custom provider diff --git a/packages/extension/src/inpage/requestMessageHandlers.ts b/packages/extension/src/inpage/requestMessageHandlers.ts index 125dc9462..025eaa173 100644 --- a/packages/extension/src/inpage/requestMessageHandlers.ts +++ b/packages/extension/src/inpage/requestMessageHandlers.ts @@ -8,6 +8,7 @@ import type { Network } from "../shared/network/type" import { sendMessage, waitForMessage } from "./messageActions" import { ETH_TOKEN_ADDRESS } from "../shared/network/constants" import { inpageMessageClient } from "./trpcClient" +import { CairoVersion } from "starknet" export async function handleAddTokenRequest( callParams: WatchAssetParameters, @@ -179,6 +180,7 @@ interface GetDeploymentDataResult { class_hash: string // Represented as 'felt252' salt: string // Represented as 'felt252' calldata: string[] // Array of 'felt252', length := calldata_len + version: CairoVersion } const toHex = (x: bigint) => `0x${x.toString(16)}` @@ -186,12 +188,21 @@ const toHex = (x: bigint) => `0x${x.toString(16)}` const isStringArray = (x: any): x is string[] => x.every((y: any) => typeof y === "string") -export async function handleDeploymentData(): Promise { +export async function handleDeploymentData(): Promise { const deploymentData = await inpageMessageClient.accountMessaging.getAccountDeploymentPayload.query() - const { classHash, constructorCalldata, addressSalt, contractAddress } = - deploymentData + if (!deploymentData) { + return deploymentData + } + + const { + version, + classHash, + constructorCalldata, + addressSalt, + contractAddress, + } = deploymentData if (!classHash || !constructorCalldata || !addressSalt || !contractAddress) { throw new Error("Deployment data not found") @@ -209,5 +220,6 @@ export async function handleDeploymentData(): Promise { class_hash: classHash, salt: _addressSalt, calldata: _callData, + version, } } diff --git a/packages/extension/src/shared/account/details/getAccountCairoVersionFromChain.ts b/packages/extension/src/shared/account/details/getAccountCairoVersionFromChain.ts index 47501ed75..d95756483 100644 --- a/packages/extension/src/shared/account/details/getAccountCairoVersionFromChain.ts +++ b/packages/extension/src/shared/account/details/getAccountCairoVersionFromChain.ts @@ -28,7 +28,7 @@ export async function getAccountCairoVersionFromChain( return { address: accs[i].address, networkId, - cairoVersion: response, + cairoVersion: response || accs[i].cairoVersion, // If the onchain call fails, keep the cached one } }) return result diff --git a/packages/extension/src/shared/account/details/getAccountClassHashFromChain.test.ts b/packages/extension/src/shared/account/details/getAccountClassHashFromChain.test.ts index c87a7e4f0..fb5868749 100644 --- a/packages/extension/src/shared/account/details/getAccountClassHashFromChain.test.ts +++ b/packages/extension/src/shared/account/details/getAccountClassHashFromChain.test.ts @@ -7,7 +7,7 @@ import { getMulticallForNetwork } from "../../multicall" import { getMockWalletAccount } from "../../../../test/walletAccount.mock" import { MULTISIG_ACCOUNT_CLASS_HASH, - STANDARD_ACCOUNT_CLASS_HASH, + TXV1_ACCOUNT_CLASS_HASH, } from "../../network/constants" import { addressSchema } from "@argent/shared" import { @@ -40,7 +40,7 @@ describe("getAccountClassHashFromChain", () => { mockNetworkService.getById.mockResolvedValueOnce({ ...mockNetwork, accountClassHash: { - standard: STANDARD_ACCOUNT_CLASS_HASH, + standard: TXV1_ACCOUNT_CLASS_HASH, }, }) @@ -52,7 +52,7 @@ describe("getAccountClassHashFromChain", () => { }), ] - mockTryGetClassHash.mockResolvedValueOnce(STANDARD_ACCOUNT_CLASS_HASH) + mockTryGetClassHash.mockResolvedValueOnce(TXV1_ACCOUNT_CLASS_HASH) const call = { contractAddress: accounts[0].address, @@ -60,22 +60,18 @@ describe("getAccountClassHashFromChain", () => { } mockGetMulticallForNetwork.mockReturnValueOnce({ - callContract: vi - .fn() - .mockResolvedValueOnce([STANDARD_ACCOUNT_CLASS_HASH]), + callContract: vi.fn().mockResolvedValueOnce([TXV1_ACCOUNT_CLASS_HASH]), } as any) mockGetProvider.mockReturnValueOnce({ - getClassHashAt: vi - .fn() - .mockResolvedValueOnce(STANDARD_ACCOUNT_CLASS_HASH), + getClassHashAt: vi.fn().mockResolvedValueOnce(TXV1_ACCOUNT_CLASS_HASH), } as any) const results = await getAccountClassHashFromChain(accounts) expect(results[0].classHash).not.toBeUndefined() expect(results[0].classHash).toEqual( - addressSchema.parse(STANDARD_ACCOUNT_CLASS_HASH), + addressSchema.parse(TXV1_ACCOUNT_CLASS_HASH), ) expect(mockTryGetClassHash).toHaveBeenCalledWith( @@ -91,7 +87,7 @@ describe("getAccountClassHashFromChain", () => { address: accounts[0].address, networkId: accounts[0].networkId, type: "standard", - classHash: addressSchema.parse(STANDARD_ACCOUNT_CLASS_HASH), + classHash: addressSchema.parse(TXV1_ACCOUNT_CLASS_HASH), }) }) @@ -101,7 +97,7 @@ describe("getAccountClassHashFromChain", () => { mockNetworkService.getById.mockResolvedValueOnce({ ...mockNetwork, accountClassHash: { - standard: STANDARD_ACCOUNT_CLASS_HASH, + standard: TXV1_ACCOUNT_CLASS_HASH, multisig: MULTISIG_ACCOUNT_CLASS_HASH, }, }) @@ -111,7 +107,7 @@ describe("getAccountClassHashFromChain", () => { address: "0x01", networkId: mockNetwork.id, network: mockNetwork, - classHash: STANDARD_ACCOUNT_CLASS_HASH, + classHash: TXV1_ACCOUNT_CLASS_HASH, }), getMockWalletAccount({ address: "0x02", @@ -123,7 +119,7 @@ describe("getAccountClassHashFromChain", () => { ] mockTryGetClassHash - .mockResolvedValueOnce(STANDARD_ACCOUNT_CLASS_HASH) + .mockResolvedValueOnce(TXV1_ACCOUNT_CLASS_HASH) .mockResolvedValueOnce(MULTISIG_ACCOUNT_CLASS_HASH) const first_call = { @@ -137,22 +133,18 @@ describe("getAccountClassHashFromChain", () => { } mockGetMulticallForNetwork.mockReturnValueOnce({ - callContract: vi - .fn() - .mockResolvedValueOnce([STANDARD_ACCOUNT_CLASS_HASH]), + callContract: vi.fn().mockResolvedValueOnce([TXV1_ACCOUNT_CLASS_HASH]), } as any) mockGetProvider.mockReturnValueOnce({ - getClassHashAt: vi - .fn() - .mockResolvedValueOnce(STANDARD_ACCOUNT_CLASS_HASH), + getClassHashAt: vi.fn().mockResolvedValueOnce(TXV1_ACCOUNT_CLASS_HASH), } as any) const results = await getAccountClassHashFromChain(accounts) expect(results[0].classHash).not.toBeUndefined() expect(results[0].classHash).toEqual( - addressSchema.parse(STANDARD_ACCOUNT_CLASS_HASH), + addressSchema.parse(TXV1_ACCOUNT_CLASS_HASH), ) expect(results[1].classHash).not.toBeUndefined() @@ -167,7 +159,7 @@ describe("getAccountClassHashFromChain", () => { callContract: expect.any(Function), getClassHashAt: expect.any(Function), }), - STANDARD_ACCOUNT_CLASS_HASH, + TXV1_ACCOUNT_CLASS_HASH, ) expect(mockTryGetClassHash).toHaveBeenNthCalledWith( @@ -184,7 +176,7 @@ describe("getAccountClassHashFromChain", () => { address: accounts[0].address, networkId: accounts[0].networkId, type: "standard", - classHash: addressSchema.parse(STANDARD_ACCOUNT_CLASS_HASH), + classHash: addressSchema.parse(TXV1_ACCOUNT_CLASS_HASH), }) expect(results[1]).toEqual({ @@ -201,7 +193,7 @@ describe("getAccountClassHashFromChain", () => { mockNetworkService.getById.mockResolvedValueOnce({ ...mockNetwork, accountClassHash: { - standard: STANDARD_ACCOUNT_CLASS_HASH, + standard: TXV1_ACCOUNT_CLASS_HASH, }, }) @@ -216,25 +208,23 @@ describe("getAccountClassHashFromChain", () => { mockGetProvider.mockReturnValueOnce({ callContract: vi .fn() - .mockResolvedValueOnce({ result: [STANDARD_ACCOUNT_CLASS_HASH] }), - getClassHashAt: vi - .fn() - .mockResolvedValueOnce(STANDARD_ACCOUNT_CLASS_HASH), + .mockResolvedValueOnce({ result: [TXV1_ACCOUNT_CLASS_HASH] }), + getClassHashAt: vi.fn().mockResolvedValueOnce(TXV1_ACCOUNT_CLASS_HASH), } as any) - mockTryGetClassHash.mockResolvedValueOnce(STANDARD_ACCOUNT_CLASS_HASH) + mockTryGetClassHash.mockResolvedValueOnce(TXV1_ACCOUNT_CLASS_HASH) const results = await getAccountClassHashFromChain(accounts) expect(results[0].classHash).not.toBeUndefined() expect(results[0].classHash).toEqual( - addressSchema.parse(STANDARD_ACCOUNT_CLASS_HASH), + addressSchema.parse(TXV1_ACCOUNT_CLASS_HASH), ) expect(mockTryGetClassHash).toHaveBeenCalledTimes(1) expect(results[0]).toEqual({ address: accounts[0].address, networkId: accounts[0].networkId, type: "standard", - classHash: addressSchema.parse(STANDARD_ACCOUNT_CLASS_HASH), + classHash: addressSchema.parse(TXV1_ACCOUNT_CLASS_HASH), }) }) }) diff --git a/packages/extension/src/shared/account/details/getAccountClassHashFromChain.ts b/packages/extension/src/shared/account/details/getAccountClassHashFromChain.ts index fe8905ff9..f986772b6 100644 --- a/packages/extension/src/shared/account/details/getAccountClassHashFromChain.ts +++ b/packages/extension/src/shared/account/details/getAccountClassHashFromChain.ts @@ -11,7 +11,7 @@ import { addressSchema } from "@argent/shared" import { tryGetClassHash } from "./tryGetClassHash" import { MULTISIG_ACCOUNT_CLASS_HASH, - STANDARD_ACCOUNT_CLASS_HASH, + TXV1_ACCOUNT_CLASS_HASH, } from "../../network/constants" export type AccountClassHashFromChain = Pick< @@ -26,7 +26,7 @@ const getDefaultClassHash = (account: WalletAccount) => { if (account.type === "multisig") { return MULTISIG_ACCOUNT_CLASS_HASH } - return STANDARD_ACCOUNT_CLASS_HASH + return TXV1_ACCOUNT_CLASS_HASH } /** diff --git a/packages/extension/src/shared/account/details/getEscape.ts b/packages/extension/src/shared/account/details/getEscape.ts index 79d44bb7d..4d8b20ec9 100644 --- a/packages/extension/src/shared/account/details/getEscape.ts +++ b/packages/extension/src/shared/account/details/getEscape.ts @@ -9,6 +9,7 @@ import { ESCAPE_TYPE_SIGNER, Escape, } from "./escape.model" +import { multicallWithCairo0Fallback } from "./multicallWithCairo0Fallback" /** * Get escape state from account @@ -17,20 +18,12 @@ import { export const getEscapeForAccount = async (account: BaseWalletAccount) => { const network = await networkService.getById(account.networkId) - // Prioritize Cairo 1 get_escape over cairo 0 getEscape const call: Call = { contractAddress: account.address, entrypoint: "get_escape", } const multicall = getMulticallForNetwork(network) - let response: { result: string[] } = { result: [] } - - try { - response = await multicall.callContract(call) - } catch { - call.entrypoint = "getEscape" - response = await multicall.callContract(call) - } + const response = await multicallWithCairo0Fallback(call, multicall) return shapeResponse(response.result) } diff --git a/packages/extension/src/shared/account/details/getGuardian.ts b/packages/extension/src/shared/account/details/getGuardian.ts index 75624fd9e..ff616f113 100644 --- a/packages/extension/src/shared/account/details/getGuardian.ts +++ b/packages/extension/src/shared/account/details/getGuardian.ts @@ -3,30 +3,18 @@ import { Call, constants, num } from "starknet" import { getMulticallForNetwork } from "../../multicall" import { networkService } from "../../network/service" import { BaseWalletAccount } from "../../wallet.model" - -/** - * Get guardian address of account, or undefined if getGuardian returns `0x0` or account is not current implementation - */ +import { multicallWithCairo0Fallback } from "./multicallWithCairo0Fallback" export const getGuardianForAccount = async ( account: BaseWalletAccount, ): Promise => { const network = await networkService.getById(account.networkId) - // Prioritize Cairo 1 get_guardian over cairo 0 getGuardian const call: Call = { contractAddress: account.address, entrypoint: "get_guardian", } const multicall = getMulticallForNetwork(network) - let response: { result: string[] } = { result: [] } - - try { - response = await multicall.callContract(call) - } catch { - call.entrypoint = "getGuardian" - response = await multicall.callContract(call) - } - // if guardian is 0, return undefined + const response = await multicallWithCairo0Fallback(call, multicall) return num.toHex(response.result[0]) === num.toHex(constants.ZERO) ? undefined : response.result[0] diff --git a/packages/extension/src/shared/account/details/getImplementation.ts b/packages/extension/src/shared/account/details/getImplementation.ts index 1eac4bc75..dbd8a8a36 100644 --- a/packages/extension/src/shared/account/details/getImplementation.ts +++ b/packages/extension/src/shared/account/details/getImplementation.ts @@ -3,7 +3,7 @@ import { accountsEqual } from "../../utils/accountsEqual" import { getMulticallForNetwork } from "../../multicall" import { getProvider } from "../../network" -import { STANDARD_ACCOUNT_CLASS_HASH } from "../../network/constants" +import { TXV1_ACCOUNT_CLASS_HASH } from "../../network/constants" import { networkService } from "../../network/service" import { BaseWalletAccount } from "../../wallet.model" import { IAccountService } from "../service/interface" @@ -40,7 +40,7 @@ export const getImplementationForAccount = async ( return ( walletAccount.network.accountClassHash?.[walletAccount.type] ?? - STANDARD_ACCOUNT_CLASS_HASH + TXV1_ACCOUNT_CLASS_HASH ) } } diff --git a/packages/extension/src/shared/account/details/getOwner.ts b/packages/extension/src/shared/account/details/getOwner.ts index 923aa7baa..510515736 100644 --- a/packages/extension/src/shared/account/details/getOwner.ts +++ b/packages/extension/src/shared/account/details/getOwner.ts @@ -3,6 +3,7 @@ import { Call } from "starknet" import { getMulticallForNetwork } from "../../multicall" import { networkService } from "../../network/service" import { BaseWalletAccount } from "../../wallet.model" +import { multicallWithCairo0Fallback } from "./multicallWithCairo0Fallback" /** * Get owner public key of account @@ -12,19 +13,12 @@ export const getOwnerForAccount = async ( account: BaseWalletAccount, ): Promise => { const network = await networkService.getById(account.networkId) - // Prioritize Cairo 1 get_owner over cairo 0 getOwner const call: Call = { contractAddress: account.address, entrypoint: "get_owner", } const multicall = getMulticallForNetwork(network) - let response: { result: string[] } = { result: [] } + const response = await multicallWithCairo0Fallback(call, multicall) - try { - response = await multicall.callContract(call) - } catch { - call.entrypoint = "getOwner" - response = await multicall.callContract(call) - } return response.result[0] } diff --git a/packages/extension/src/shared/account/details/multicallWithCairo0Fallback.test.ts b/packages/extension/src/shared/account/details/multicallWithCairo0Fallback.test.ts new file mode 100644 index 000000000..970085045 --- /dev/null +++ b/packages/extension/src/shared/account/details/multicallWithCairo0Fallback.test.ts @@ -0,0 +1,59 @@ +import { MinimalProviderInterface } from "@argent/x-multicall" +import { Call } from "starknet" +import { Mocked, describe, expect, test } from "vitest" + +import { multicallWithCairo0Fallback } from "./multicallWithCairo0Fallback" + +describe("shared/account/details", () => { + describe("multicallWithCairo0Fallback", () => { + describe("when the call is successful", () => { + test("makes a single Cairo 1 call and returns the result", async () => { + const multicall = { + callContract: vi.fn(), + } as Mocked + const call: Call = { + entrypoint: "fooBar", + contractAddress: "0x0", + } + multicall.callContract.mockResolvedValueOnce({ result: ["baz"] }) + const result = await multicallWithCairo0Fallback(call, multicall) + expect(multicall.callContract).toHaveBeenCalledOnce() + expect(multicall.callContract).toHaveBeenLastCalledWith( + expect.objectContaining({ entrypoint: "foo_bar" }), + ) + expect(result).toEqual({ + result: ["baz"], + }) + }) + }) + describe("when the call fails", () => { + test("makes a Cairo 1 then a Cairo 0 call and returns the result", async () => { + const multicall = { + callContract: vi.fn(), + } as Mocked + const call: Call = { + entrypoint: "fooBar", + contractAddress: "0x0", + } + // { result: undefined } is not valid, but is returned if the first call fails + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + multicall.callContract.mockResolvedValueOnce({ result: undefined }) + multicall.callContract.mockResolvedValueOnce({ result: ["baz"] }) + const result = await multicallWithCairo0Fallback(call, multicall) + expect(multicall.callContract).toHaveBeenCalledTimes(2) + expect(multicall.callContract).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ entrypoint: "foo_bar" }), + ) + expect(multicall.callContract).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ entrypoint: "fooBar" }), + ) + expect(result).toEqual({ + result: ["baz"], + }) + }) + }) + }) +}) diff --git a/packages/extension/src/shared/account/details/multicallWithCairo0Fallback.ts b/packages/extension/src/shared/account/details/multicallWithCairo0Fallback.ts new file mode 100644 index 000000000..f64696898 --- /dev/null +++ b/packages/extension/src/shared/account/details/multicallWithCairo0Fallback.ts @@ -0,0 +1,29 @@ +import { Call } from "starknet" +import { MinimalProviderInterface } from "@argent/x-multicall" + +import { getEntryPointSafe } from "../../utils/transactions" + +export async function multicallWithCairo0Fallback( + call: Call, + multicall: MinimalProviderInterface, +) { + // Prioritize Cairo 1 + try { + const callCairo1 = { + ...call, + entrypoint: getEntryPointSafe(call.entrypoint, "1"), + } + const response = await multicall.callContract(callCairo1) + if (response.result !== undefined) { + return response + } + } catch { + // ignore multicall error + } + // Fallback to Cairo 0 + const callCairo0 = { + ...call, + entrypoint: getEntryPointSafe(call.entrypoint, "0"), + } + return await multicall.callContract(callCairo0) +} diff --git a/packages/extension/src/shared/account/details/tryGetClassHash.ts b/packages/extension/src/shared/account/details/tryGetClassHash.ts index 7e356ff18..98c5d48e1 100644 --- a/packages/extension/src/shared/account/details/tryGetClassHash.ts +++ b/packages/extension/src/shared/account/details/tryGetClassHash.ts @@ -1,5 +1,5 @@ import { Call, ProviderInterface } from "starknet" -import { STANDARD_ACCOUNT_CLASS_HASH } from "../../network/constants" +import { TXV1_ACCOUNT_CLASS_HASH } from "../../network/constants" import { addressSchema } from "@argent/shared" export async function tryGetClassHash( @@ -18,7 +18,7 @@ export async function tryGetClassHash( if (fallbackClassHash) { return fallbackClassHash } - return STANDARD_ACCOUNT_CLASS_HASH + return TXV1_ACCOUNT_CLASS_HASH } } } diff --git a/packages/extension/src/shared/account/optimisticImplUpdate.test.ts b/packages/extension/src/shared/account/optimisticImplUpdate.test.ts new file mode 100644 index 000000000..a541d35e7 --- /dev/null +++ b/packages/extension/src/shared/account/optimisticImplUpdate.test.ts @@ -0,0 +1,51 @@ +import { getMockWalletAccount } from "../../../test/walletAccount.mock" +import { optimisticImplUpdate } from "./optimisticImplUpdate" +import { ARGENT_ACCOUNT_CONTRACT_CLASS_HASHES } from "./starknet.constants" + +describe("optimisticImplUpdate", () => { + it("should return the account if newClassHash is not defined", () => { + const account = getMockWalletAccount() + const newClassHash = undefined + const result = optimisticImplUpdate(account, newClassHash) + expect(result).toEqual(account) + }) + it("should return the account with the new class hash and the cairo version 1 if newClassHash is in CAIRO_1", () => { + const account = getMockWalletAccount({ + classHash: ARGENT_ACCOUNT_CONTRACT_CLASS_HASHES.CAIRO_0[0], + cairoVersion: "0", + }) + const newClassHash = ARGENT_ACCOUNT_CONTRACT_CLASS_HASHES.CAIRO_1[0] + const result = optimisticImplUpdate(account, newClassHash) + expect(result).toEqual({ + ...account, + classHash: newClassHash, + cairoVersion: "1", + }) + }) + it("should return the account with the new class hash and the cairo version 0 if newClassHash is in CAIRO_0", () => { + const account = getMockWalletAccount({ + classHash: ARGENT_ACCOUNT_CONTRACT_CLASS_HASHES.CAIRO_1[0], + cairoVersion: "1", + }) + const newClassHash = ARGENT_ACCOUNT_CONTRACT_CLASS_HASHES.CAIRO_0[0] + const result = optimisticImplUpdate(account, newClassHash) + expect(result).toEqual({ + ...account, + classHash: newClassHash, + cairoVersion: "0", + }) + }) + it("should return the account with the new class hash and the current cairo version if newClassHash is not in CAIRO_1 or CAIRO_0", () => { + const account = getMockWalletAccount({ + classHash: "0x123", + cairoVersion: "1", + }) + const newClassHash = "0xabc" + const result = optimisticImplUpdate(account, newClassHash) + expect(result).toEqual({ + ...account, + classHash: newClassHash, + cairoVersion: "1", + }) + }) +}) diff --git a/packages/extension/src/shared/account/optimisticImplUpdate.ts b/packages/extension/src/shared/account/optimisticImplUpdate.ts new file mode 100644 index 000000000..8921d5b51 --- /dev/null +++ b/packages/extension/src/shared/account/optimisticImplUpdate.ts @@ -0,0 +1,22 @@ +import { Address, isEqualAddress } from "@argent/shared" +import { WalletAccount } from "../wallet.model" +import { ARGENT_ACCOUNT_CONTRACT_CLASS_HASHES } from "./starknet.constants" + +// Use this with caution, as it might not reflect the onchain state, +// but just an optimistic update +export const optimisticImplUpdate = ( + account: WalletAccount, + newClassHash?: Address, +): WalletAccount => { + if (!newClassHash) return account + + const { CAIRO_0, CAIRO_1 } = ARGENT_ACCOUNT_CONTRACT_CLASS_HASHES + + const cairoVersion = CAIRO_1.some((c1) => isEqualAddress(c1, newClassHash)) + ? "1" + : CAIRO_0.some((c0) => isEqualAddress(c0, newClassHash)) + ? "0" + : account.cairoVersion // fallback to the current account's cairo version + + return { ...account, classHash: newClassHash, cairoVersion } +} diff --git a/packages/extension/src/shared/account/service/implementation.ts b/packages/extension/src/shared/account/service/implementation.ts index b2548d6b6..29eacb297 100644 --- a/packages/extension/src/shared/account/service/implementation.ts +++ b/packages/extension/src/shared/account/service/implementation.ts @@ -5,7 +5,7 @@ import { accountsEqual } from "../../utils/accountsEqual" import { withoutHiddenSelector } from "../selectors" import type { IAccountRepo } from "../store" import type { IAccountService } from "./interface" - +import { ProvisionActivityPayload } from "../../activity/types" export class AccountService implements IAccountService { constructor( private readonly chainService: IChainService, @@ -72,4 +72,15 @@ export class AccountService implements IAccountService { async getDeployed(baseAccount: BaseWalletAccount): Promise { return this.chainService.getDeployed(baseAccount) } + + async handleProvisionedAccount(payload: ProvisionActivityPayload) { + await this.update( + (account) => accountsEqual(account, payload.account), + (account) => ({ + ...account, + provisionAmount: payload.activity.transfers[0].asset.amount, + provisionDate: payload.activity.lastModified, + }), + ) + } } diff --git a/packages/extension/src/shared/account/service/interface.ts b/packages/extension/src/shared/account/service/interface.ts index 6f18506ba..4f2a761db 100644 --- a/packages/extension/src/shared/account/service/interface.ts +++ b/packages/extension/src/shared/account/service/interface.ts @@ -1,3 +1,4 @@ +import { ProvisionActivityPayload } from "../../activity/types" import { AllowArray, SelectorFn } from "../../storage/__new/interface" import { BaseWalletAccount, WalletAccount } from "../../wallet.model" @@ -16,4 +17,7 @@ export interface IAccountService { // getters getDeployed(baseAccount: BaseWalletAccount): Promise + + // handlers + handleProvisionedAccount(payload: ProvisionActivityPayload): Promise } diff --git a/packages/extension/src/background/wallet/starknet.constants.ts b/packages/extension/src/shared/account/starknet.constants.ts similarity index 51% rename from packages/extension/src/background/wallet/starknet.constants.ts rename to packages/extension/src/shared/account/starknet.constants.ts index 1298e7f6f..7770ed3fe 100644 --- a/packages/extension/src/background/wallet/starknet.constants.ts +++ b/packages/extension/src/shared/account/starknet.constants.ts @@ -1,14 +1,10 @@ -export const PROXY_CONTRACT_CLASS_HASHES = [ +export const C0_PROXY_CONTRACT_CLASS_HASHES = [ "0x25ec026985a3bf9d0cc1fe17326b245dfdc3ff89b8fde106542a3ea56c5a918", -] -export const ARGENT_ACCOUNT_CONTRACT_CLASS_HASHES = [ - "0x033434ad846cdd5f23eb73ff09fe6fddd568284a0fb7d1be20ee482f044dabe2", - "0x1a7820094feaf82d53f53f214b81292d717e7bb9a92bb2488092cd306f3993f", - "0x3e327de1c40540b98d05cbcb13552008e36f0ec8d61d46956d2f9752c294328", - "0x7e28fb0161d10d1cf7fe1f13e7ca57bce062731a3bd04494dfd2d0412699727", -] +] as const -export const ARGENT_ACCOUNT_CONTRACT_CLASS_HASHES__NEW = { +// ClassHashes are arranged from latest to oldest +// For example, CAIRO_0[0] is the latest version of CAIRO_0 +export const ARGENT_ACCOUNT_CONTRACT_CLASS_HASHES = { CAIRO_0: [ "0x033434ad846cdd5f23eb73ff09fe6fddd568284a0fb7d1be20ee482f044dabe2", "0x1a7820094feaf82d53f53f214b81292d717e7bb9a92bb2488092cd306f3993f", @@ -17,6 +13,8 @@ export const ARGENT_ACCOUNT_CONTRACT_CLASS_HASHES__NEW = { ], CAIRO_1: [ + "0x29927c8af6bccf3f6fda035981e765a7bdbf18a2dc0d630494f8758aa908e2b", + "0x02fadbf77a721b94bdcc3032d86a8921661717fa55145bccf88160ee2a5efcd1", "0x1a736d6ed154502257f02b1ccdf4d9d1089f80811cd6acad48e6b6a9d1f2003", ], -} +} as const diff --git a/packages/extension/src/shared/actionQueue/queue/interface.ts b/packages/extension/src/shared/actionQueue/queue/interface.ts index aafb92b6f..7adbd381d 100644 --- a/packages/extension/src/shared/actionQueue/queue/interface.ts +++ b/packages/extension/src/shared/actionQueue/queue/interface.ts @@ -8,6 +8,17 @@ export interface IActionQueue { item: U, meta?: Partial, ) => Promise> + addFront: ( + item: U, + meta?: Partial, + ) => Promise> + update: ( + hash: string, + updatedItem: Partial<{ + meta: Partial> + }> & + Partial, + ) => Promise | null> updateMeta: ( hash: string, meta: Partial>, diff --git a/packages/extension/src/shared/actionQueue/queue/queue.test.ts b/packages/extension/src/shared/actionQueue/queue/queue.test.ts index 2c7b04ebc..6aa95c0a6 100644 --- a/packages/extension/src/shared/actionQueue/queue/queue.test.ts +++ b/packages/extension/src/shared/actionQueue/queue/queue.test.ts @@ -19,7 +19,20 @@ const txFixture: ActionItem = { }, } +const txFixture2: ActionItem = { + type: "TRANSACTION", + payload: { + transactions: { + contractAddress: "0x456", + entrypoint: "fooBar", + calldata: [], + }, + createdAt: 456, + }, +} + const txFixtureHash = objectHash(txFixture) +const txFixtureHash2 = objectHash(txFixture2) describe("actionQueue", () => { const actionQueueRepo = new InMemoryRepository({ @@ -44,6 +57,19 @@ describe("actionQueue", () => { expect(item.meta.hash).toEqual(txFixtureHash) }) + it("automatically adds an item to the front of the queue", async () => { + await actionQueue.add(txFixture) + await actionQueue.addFront(txFixture2) + const [item] = await actionQueue.getAll() + expect(item.meta.hash).toEqual(txFixtureHash2) + }) + + it("automatically adds an item to the front even the queue is empty", async () => { + await actionQueue.addFront(txFixture) + const [item] = await actionQueue.getAll() + expect(item.meta.hash).toEqual(txFixtureHash) + }) + it("automatically removes expired items on getAll", async () => { await actionQueue.add(txFixture, { expires: 100 }) const [item] = await actionQueue.getAll() @@ -66,6 +92,10 @@ describe("actionQueue", () => { await actionQueue.add(txFixture) const items = await actionQueue.getAll() expect(items.length).toEqual(1) + + await actionQueue.addFront(txFixture) + const itemsFront = await actionQueue.getAll() + expect(itemsFront.length).toEqual(1) }) it("updates meta", async () => { diff --git a/packages/extension/src/shared/actionQueue/queue/queue.ts b/packages/extension/src/shared/actionQueue/queue/queue.ts index 15489c238..300c8cf81 100644 --- a/packages/extension/src/shared/actionQueue/queue/queue.ts +++ b/packages/extension/src/shared/actionQueue/queue/queue.ts @@ -59,6 +59,7 @@ export function getActionQueue( async function add( item: U, meta?: Partial, + front = false, ): Promise> { if (isTransactionActionItem(item) && !item.payload.createdAt) { /** @@ -78,14 +79,24 @@ export function getActionQueue( }, } - await storage.upsert(newItem) + await storage.upsert(newItem, front ? "unshift" : "push") return newItem } - async function updateMeta( + async function addFront( + item: U, + meta?: Partial, + ): Promise> { + return add(item, meta, true) + } + + async function update( hash: string, - meta: Partial>, + updatedItem: Partial<{ + meta: Partial> + }> & + Partial, ): Promise | null> { const item = await get(hash) @@ -95,9 +106,10 @@ export function getActionQueue( const newItem = { ...item, + ...updatedItem, meta: { ...item.meta, - ...meta, + ...updatedItem.meta, }, } @@ -106,6 +118,16 @@ export function getActionQueue( return newItem } + async function updateMeta( + hash: string, + meta: Partial>, + ): Promise | null> { + return update(hash, { meta } as Partial<{ + meta: Partial> + }> & + Partial) + } + async function remove(hash: string): Promise | null> { const [item] = await storage.remove((item) => item.meta.hash === hash) return item ?? null @@ -119,6 +141,8 @@ export function getActionQueue( get, getAll, add, + addFront, + update, updateMeta, remove, removeAll, diff --git a/packages/extension/src/shared/activity/__fixtures__/activities-deploy.json b/packages/extension/src/shared/activity/__fixtures__/activities-deploy.json new file mode 100644 index 000000000..3d243be5a --- /dev/null +++ b/packages/extension/src/shared/activity/__fixtures__/activities-deploy.json @@ -0,0 +1,219 @@ +[ + { + "compositeId": "be7ca513de1bff27d342073c21f7290e9f2991611a7a3cb85c6796f396399ef1", + "id": "9bc71a4c-1da1-4f4f-9168-d06112ea6be2", + "status": "success", + "wallet": "0x07df7c3ed69cbacf5c4e39f0e3270699f53160b29ee14a8691057fa68bf78c42", + "txSender": "0x074d0d03c4333fe1979cb2523ae35763bb9af7e57d7edce2ce9a581618e9203b", + "source": "transaction-monitor", + "type": "multicall", + "group": "finance", + "submitted": 1707156314000, + "lastModified": 1707156503458, + "transaction": { + "network": "starknet", + "hash": "0x031d5c57c49081855a8e472a6383b78a50a9408afadc4e1c04e310e8352f5fdf", + "status": "success", + "blockNumber": 945585, + "transactionIndex": 22 + }, + "transferSummary": [ + { + "asset": { + "type": "token", + "tokenAddress": "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + "amount": "12500000000000000000000", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 3474257.2 + } + }, + "sent": true + }, + { + "asset": { + "type": "token", + "tokenAddress": "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + "amount": "25000000000000000000000", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 6948514.4 + } + }, + "sent": false + }, + { + "asset": { + "type": "token", + "tokenAddress": "0x01a881a75bb478cedfd4d3ea19d2a4564350d78ea463a5287833526a416d5e31", + "amount": "12500000000000000000000" + }, + "sent": false + } + ], + "transfers": [ + { + "type": "payment", + "leg": "credit", + "counterparty": "0x2b2e8b8eb3429540c58c0dc69ebb2981267196fe0ca2e361056b852445ee766", + "asset": { + "type": "token", + "tokenAddress": "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + "amount": "25000000000000000000000", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 6948514.4 + } + }, + "counterpartyNetwork": "starknet" + }, + { + "type": "payment", + "leg": "debit", + "counterparty": "0x1a881a75bb478cedfd4d3ea19d2a4564350d78ea463a5287833526a416d5e31", + "asset": { + "type": "token", + "tokenAddress": "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + "amount": "12500000000000000000000", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 3474257.2 + } + }, + "counterpartyNetwork": "starknet" + }, + { + "type": "payment", + "leg": "credit", + "counterparty": "0x0", + "asset": { + "type": "token", + "tokenAddress": "0x01a881a75bb478cedfd4d3ea19d2a4564350d78ea463a5287833526a416d5e31", + "amount": "12500000000000000000000" + }, + "counterpartyNetwork": "starknet" + } + ], + "fees": [], + "relatedAddresses": [ + { + "address": "0x02b2e8b8eb3429540c58c0dc69ebb2981267196fe0ca2e361056b852445ee766", + "network": "starknet", + "type": "wallet" + }, + { + "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "network": "starknet", + "type": "token" + }, + { + "address": "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + "network": "starknet", + "type": "token" + }, + { + "address": "0x074d0d03c4333fe1979cb2523ae35763bb9af7e57d7edce2ce9a581618e9203b", + "network": "starknet", + "type": "wallet" + }, + { + "address": "0x01a881a75bb478cedfd4d3ea19d2a4564350d78ea463a5287833526a416d5e31", + "network": "starknet", + "type": "token" + }, + { + "address": "0x05f26b643443257ba3477bcf79d0b9c08168442d88d0bf8bf3ac739199c260f9", + "network": "starknet", + "type": "wallet" + }, + { + "address": "0x01a881a75bb478cedfd4d3ea19d2a4564350d78ea463a5287833526a416d5e31", + "network": "starknet", + "type": "wallet" + }, + { + "address": "0x0000000000000000000000000000000000000000000000000000000000000000", + "network": "starknet", + "type": "wallet" + } + ], + "networkDetails": { + "ethereumNetwork": "goerli", + "chainId": "TESTNET" + }, + "details": { + "calls": [ + { + "details": { + "deployer": "0x74d0d03c4333fe1979cb2523ae35763bb9af7e57d7edce2ce9a581618e9203b", + "contractAddress": "0x7df7c3ed69cbacf5c4e39f0e3270699f53160b29ee14a8691057fa68bf78c42", + "type": "deploy" + } + }, + { + "details": { + "counterparty": "0x2b2e8b8eb3429540c58c0dc69ebb2981267196fe0ca2e361056b852445ee766", + "leg": "credit", + "asset": { + "type": "token", + "tokenAddress": "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + "amount": "25000000000000000000000", + "fiatAmount": { + "currency": "USD", + "currencyAmount": 6948514.4 + } + }, + "context": { + "isProvisionAirdrop": true + }, + "type": "payment", + "counterpartyNetwork": "starknet" + } + }, + { + "details": { + "dappAddress": "0x4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + "function": { + "name": "lock_and_delegate_by_sig", + "parameters": [ + { + "type": "core::starknet::contract_address::ContractAddress", + "name": "account", + "value": "0x7df7c3ed69cbacf5c4e39f0e3270699f53160b29ee14a8691057fa68bf78c42" + }, + { + "type": "core::starknet::contract_address::ContractAddress", + "name": "delegatee", + "value": "0x7df7c3ed69cbacf5c4e39f0e3270699f53160b29ee14a8691057fa68bf78c42" + }, + { + "type": "core::integer::u256", + "name": "amount", + "value": "12500000000000000000000" + }, + { + "type": "core::felt252", + "name": "nonce", + "value": "0x0" + }, + { + "type": "core::integer::u64", + "name": "expiry", + "value": "1711649205508" + }, + { + "type": "core::array::Array::", + "name": "signature", + "value": "[\"0x2a9dbfab9eaa43bf8e6899de306b6e53561a4b4aaaa04dcf1285672fbad0540\",\"0x35257a31f674998de10cde06eeee28236aca62cc8830ed0b65ac7180f6f8529\"]" + } + ] + }, + "type": "dappInteraction" + } + } + ], + "type": "multicall" + }, + "network": "starknet" + } +] diff --git a/packages/extension/src/shared/activity/__fixtures__/activities-handle-deposit.json b/packages/extension/src/shared/activity/__fixtures__/activities-handle-deposit.json new file mode 100644 index 000000000..ee76aa699 --- /dev/null +++ b/packages/extension/src/shared/activity/__fixtures__/activities-handle-deposit.json @@ -0,0 +1,78 @@ +[ + { + "compositeId": "af005bb92402c942ae2f0479d5b87f5c5cd8aebcccc01ecfd3013bc905a7e25d", + "id": "e0ce64d5-68cb-4a77-9f22-02e1945eb6b6", + "status": "success", + "wallet": "0x05a69dcffb37d127801e5422377b3eb31861568a26ad35870e23e7069fb8a6cf", + "txSender": "0x04c5772d1914fe6ce891b64eb35bf3522aeae1315647314aac58b01137607f3f", + "source": "transaction-monitor", + "type": "payment", + "group": "finance", + "submitted": 1706282918000, + "lastModified": 1706283105994, + "transaction": { + "network": "starknet", + "hash": "0x037eb609980b5cd53a19bc618dc99652fa8fcb3dc18e83bc756969c2b663741f", + "status": "success", + "blockNumber": 25822, + "transactionIndex": 2 + }, + "transferSummary": [ + { + "asset": { + "type": "token", + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "amount": "30000000000000000", + "fiatAmount": { "currency": "USD", "currencyAmount": 67.67 } + }, + "sent": false + } + ], + "transfers": [ + { + "type": "payment", + "leg": "credit", + "counterparty": "0x8453fc6cd1bcfe8d4dfc069c400b433054d47bdc", + "asset": { + "type": "token", + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "amount": "30000000000000000", + "fiatAmount": { "currency": "USD", "currencyAmount": 67.67 } + }, + "counterpartyNetwork": "ethereum" + } + ], + "fees": [], + "relatedAddresses": [ + { + "address": "0x8453fc6cd1bcfe8d4dfc069c400b433054d47bdc", + "network": "ethereum", + "type": "wallet" + }, + { + "address": "0x05a69dcffb37d127801e5422377b3eb31861568a26ad35870e23e7069fb8a6cf", + "network": "starknet", + "type": "wallet" + }, + { + "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "network": "starknet", + "type": "token" + } + ], + "networkDetails": { "ethereumNetwork": "sepolia", "chainId": "SEPOLIA" }, + "details": { + "counterparty": "0x8453fc6cd1bcfe8d4dfc069c400b433054d47bdc", + "leg": "credit", + "asset": { + "type": "token", + "tokenAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "amount": "30000000000000000", + "fiatAmount": { "currency": "USD", "currencyAmount": 67.67 } + }, + "type": "payment", + "counterpartyNetwork": "ethereum" + }, + "network": "starknet" + } +] diff --git a/packages/extension/src/background/__new/services/activity/__fixtures__/activities-many-escapes.json b/packages/extension/src/shared/activity/__fixtures__/activities-many-escapes.json similarity index 100% rename from packages/extension/src/background/__new/services/activity/__fixtures__/activities-many-escapes.json rename to packages/extension/src/shared/activity/__fixtures__/activities-many-escapes.json diff --git a/packages/extension/src/background/__new/services/activity/__fixtures__/activities-signer-changed.json b/packages/extension/src/shared/activity/__fixtures__/activities-signer-changed.json similarity index 100% rename from packages/extension/src/background/__new/services/activity/__fixtures__/activities-signer-changed.json rename to packages/extension/src/shared/activity/__fixtures__/activities-signer-changed.json diff --git a/packages/extension/src/background/__new/services/activity/__fixtures__/activities.json b/packages/extension/src/shared/activity/__fixtures__/activities.json similarity index 100% rename from packages/extension/src/background/__new/services/activity/__fixtures__/activities.json rename to packages/extension/src/shared/activity/__fixtures__/activities.json diff --git a/packages/extension/src/background/__new/services/activity/__fixtures__/state.json b/packages/extension/src/shared/activity/__fixtures__/state.json similarity index 100% rename from packages/extension/src/background/__new/services/activity/__fixtures__/state.json rename to packages/extension/src/shared/activity/__fixtures__/state.json diff --git a/packages/extension/src/background/__new/services/activity/schema.test.ts b/packages/extension/src/shared/activity/schema.test.ts similarity index 99% rename from packages/extension/src/background/__new/services/activity/schema.test.ts rename to packages/extension/src/shared/activity/schema.test.ts index 0a30c854b..3aca5562c 100644 --- a/packages/extension/src/background/__new/services/activity/schema.test.ts +++ b/packages/extension/src/shared/activity/schema.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from "vitest" import activities from "./__fixtures__/activities.json" import activitiesManyEscapes from "./__fixtures__/activities-many-escapes.json" import activitiesSignerChanged from "./__fixtures__/activities-signer-changed.json" + import { activitiesSchema } from "./schema" describe("background/services/activity", () => { diff --git a/packages/extension/src/background/__new/services/activity/schema.ts b/packages/extension/src/shared/activity/schema.ts similarity index 83% rename from packages/extension/src/background/__new/services/activity/schema.ts rename to packages/extension/src/shared/activity/schema.ts index f56877deb..86ab8cbc1 100644 --- a/packages/extension/src/background/__new/services/activity/schema.ts +++ b/packages/extension/src/shared/activity/schema.ts @@ -67,8 +67,11 @@ const detailsActionSchema = z.enum([ "multisigConfigurationUpdated", ]) -export const activityDetailsSchema = z.object({ - type: typeSchema, +const functionSchema = z.object({ + name: z.string(), + parameters: z.array(z.unknown()).optional(), +}) +const baseActivityDetailsSchema = z.object({ action: detailsActionSchema.optional(), context: z .object({ @@ -76,11 +79,25 @@ export const activityDetailsSchema = z.object({ newGuardian: z.string().optional(), newImplementation: z.string().optional(), newVersion: z.string().optional(), + + /// SUBJECT TO CHANGE + isProvisionAirdrop: z.boolean().optional(), }) .optional(), srcAsset: assetSchema.optional(), destAsset: assetSchema.optional(), + type: z.string().optional(), + contractAddress: addressSchema.optional(), + function: functionSchema.optional(), }) +export const activityDetailsSchema = z + .object({ + type: typeSchema, + calls: z + .array(z.object({ details: baseActivityDetailsSchema.optional() })) + .optional(), + }) + .merge(baseActivityDetailsSchema) export const activitySchema = z.object({ compositeId: z.string(), @@ -111,6 +128,7 @@ export const activityResponseSchema = z.object({ export type ActivityResponse = z.infer export type Activity = z.infer + export type ActivityDetailsAction = z.infer export function isActivityDetailsAction( diff --git a/packages/extension/src/shared/activity/types.ts b/packages/extension/src/shared/activity/types.ts index 188aab1f9..53a4b26a2 100644 --- a/packages/extension/src/shared/activity/types.ts +++ b/packages/extension/src/shared/activity/types.ts @@ -1,3 +1,15 @@ +import { Activity } from "./schema" +import { BaseWalletAccount } from "../wallet.model" + export interface IActivityStorage { modifiedAfter: Record } +export type ActivitiesPayload = { + account: BaseWalletAccount + activities: Activity[] +} + +export type ProvisionActivityPayload = { + account: BaseWalletAccount + activity: Activity +} diff --git a/packages/extension/src/background/__new/services/activity/utils/getOverallLastModified.test.ts b/packages/extension/src/shared/activity/utils/getOverallLastModified.test.ts similarity index 100% rename from packages/extension/src/background/__new/services/activity/utils/getOverallLastModified.test.ts rename to packages/extension/src/shared/activity/utils/getOverallLastModified.test.ts diff --git a/packages/extension/src/background/__new/services/activity/utils/getOverallLastModified.ts b/packages/extension/src/shared/activity/utils/getOverallLastModified.ts similarity index 100% rename from packages/extension/src/background/__new/services/activity/utils/getOverallLastModified.ts rename to packages/extension/src/shared/activity/utils/getOverallLastModified.ts diff --git a/packages/extension/src/shared/activity/utils/hasDelegationActivity.test.ts b/packages/extension/src/shared/activity/utils/hasDelegationActivity.test.ts new file mode 100644 index 000000000..d4d7c3147 --- /dev/null +++ b/packages/extension/src/shared/activity/utils/hasDelegationActivity.test.ts @@ -0,0 +1,46 @@ +import { Activity } from "../schema" +import { hasDelegationActivity } from "./hasDelegationActivity" + +describe("hasDelegationActivity", () => { + const createBaseActivity = (calls: Activity["details"]["calls"]) => + ({ + details: { calls }, + } as unknown as Activity) + + it("returns false when activity has no details", () => { + const activity = {} as unknown as Activity + expect(hasDelegationActivity(activity)).toBe(false) + }) + + it("returns false when details are present but no calls", () => { + const activity = createBaseActivity([]) + expect(hasDelegationActivity(activity)).toBe(false) + }) + + it("returns false when calls do not match the function name", () => { + const activity = createBaseActivity([ + { details: { function: { name: "otherFunction" } } }, + ]) + expect(hasDelegationActivity(activity)).toBe(false) + }) + + it("returns true when a call matches the function name", () => { + const activity = createBaseActivity([ + { details: { function: { name: "lock_and_delegate_by_sig" } } }, + ]) + expect(hasDelegationActivity(activity)).toBe(true) + }) + + it("returns false when calls have null or undefined details", () => { + const activity = createBaseActivity([{}, { details: undefined }]) + expect(hasDelegationActivity(activity)).toBe(false) + }) + + it("returns true when mixed calls contain at least one matching function name", () => { + const activity = createBaseActivity([ + { details: { function: { name: "otherFunction" } } }, + { details: { function: { name: "lock_and_delegate_by_sig" } } }, + ]) + expect(hasDelegationActivity(activity)).toBe(true) + }) +}) diff --git a/packages/extension/src/shared/activity/utils/hasDelegationActivity.ts b/packages/extension/src/shared/activity/utils/hasDelegationActivity.ts new file mode 100644 index 000000000..0939b9a7a --- /dev/null +++ b/packages/extension/src/shared/activity/utils/hasDelegationActivity.ts @@ -0,0 +1,11 @@ +import { Activity } from "../schema" + +export const hasDelegationActivity = (activity: Activity) => { + const hasDelegation = activity.details?.calls?.some((call) => { + if (call?.details?.function?.name) { + return call?.details?.function.name === "lock_and_delegate_by_sig" + } + return false + }) + return Boolean(hasDelegation) +} diff --git a/packages/extension/src/shared/activity/utils/isProvisionWithDeploymentActivity.test.ts b/packages/extension/src/shared/activity/utils/isProvisionWithDeploymentActivity.test.ts new file mode 100644 index 000000000..1de3c310d --- /dev/null +++ b/packages/extension/src/shared/activity/utils/isProvisionWithDeploymentActivity.test.ts @@ -0,0 +1,61 @@ +import { Activity } from "../schema" +import { isProvisionWithDeploymentActivity } from "./isProvisionWithDeploymentActivity" + +describe("isProvisionWithDeploymentActivity", () => { + const createBaseActivity = () => + ({ + type: "multicall", + details: { + calls: [], + }, + wallet: "0x1", + } as unknown as Activity) + + it("returns false when there are no calls in details", () => { + const activity = createBaseActivity() + expect(isProvisionWithDeploymentActivity(activity)).toBe(false) + }) + + it("returns false when calls are present but none match", () => { + const activity = createBaseActivity() + activity?.details?.calls?.push({ + details: { + type: "non-deploy-type", + contractAddress: "0x0", + }, + }) + + expect(isProvisionWithDeploymentActivity(activity)).toBe(false) + }) + + it("returns false when matching call has a different wallet address", () => { + const activity = createBaseActivity() + activity?.details?.calls?.push({ + details: { + type: "deploy", + contractAddress: "0x2", + }, + }) + + expect(isProvisionWithDeploymentActivity(activity)).toBe(false) + }) + + it("returns true when matching call has the same wallet address", () => { + const activity = createBaseActivity() + activity?.details?.calls?.push({ + details: { + type: "deploy", + contractAddress: "0x1", + }, + }) + + expect(isProvisionWithDeploymentActivity(activity)).toBe(true) + }) + + it("returns false when activity type is not multicall", () => { + const activity = createBaseActivity() + activity.type = "approval" + + expect(isProvisionWithDeploymentActivity(activity)).toBe(false) + }) +}) diff --git a/packages/extension/src/shared/activity/utils/isProvisionWithDeploymentActivity.ts b/packages/extension/src/shared/activity/utils/isProvisionWithDeploymentActivity.ts new file mode 100644 index 000000000..a5d8a5d99 --- /dev/null +++ b/packages/extension/src/shared/activity/utils/isProvisionWithDeploymentActivity.ts @@ -0,0 +1,13 @@ +import { isEqualAddress } from "@argent/shared" +import { Activity } from "../schema" + +export const isProvisionWithDeploymentActivity = (activity: Activity) => { + return ( + activity.type === "multicall" && + activity.details.calls?.some( + (call) => + call?.details?.type === "deploy" && + isEqualAddress(call?.details.contractAddress || "", activity.wallet), + ) + ) +} diff --git a/packages/extension/src/shared/activity/utils/parseAccountActivities.test.ts b/packages/extension/src/shared/activity/utils/parseAccountActivities.test.ts new file mode 100644 index 000000000..7171aa0e3 --- /dev/null +++ b/packages/extension/src/shared/activity/utils/parseAccountActivities.test.ts @@ -0,0 +1,100 @@ +import type { Address } from "@argent/shared" +import { describe, expect, test } from "vitest" + +import activities from "../__fixtures__/activities.json" +import activitiesManyEscapes from "../__fixtures__/activities-many-escapes.json" +import activitiesSignerChanged from "../__fixtures__/activities-signer-changed.json" +import activitiesDeploy from "../__fixtures__/activities-deploy.json" +import state from "../__fixtures__/state.json" +import type { Activity } from "../schema" +import { parseAccountActivities } from "./parseAccountActivities" + +describe("background/services/activity/utils", () => { + describe("parseAccountActivities", () => { + test("returns a map of actions to account addresses for activities", () => { + expect( + parseAccountActivities({ + activities: activities as Activity[], + accountAddressesOnNetwork: + state.accountAddressesOnNetwork as Address[], + }), + ).toMatchInlineSnapshot(` + { + "guardianChanged": [ + "0x05f1f0a38429dcab9ffd8a786c0d827e84c1cbd8f60243e6d25d066a13af4a25", + ], + } + `) + }) + test("returns a map of actions to account addresses for activitiesManyEscapes", () => { + expect( + parseAccountActivities({ + activities: activitiesManyEscapes as Activity[], + accountAddressesOnNetwork: [ + "0x00c90c89d339d1611f971e9211bc6a8efafc82541a61703a702c17d291afe9bb", + ] as Address[], + }), + ).toMatchInlineSnapshot(` + { + "cancelEscape": [ + "0x00c90c89d339d1611f971e9211bc6a8efafc82541a61703a702c17d291afe9bb", + ], + "guardianChanged": [ + "0x00c90c89d339d1611f971e9211bc6a8efafc82541a61703a702c17d291afe9bb", + ], + "triggerEscapeGuardian": [ + "0x00c90c89d339d1611f971e9211bc6a8efafc82541a61703a702c17d291afe9bb", + ], + } + `) + }) + test("returns a map of actions to account addresses for activitiesSignerChanged", () => { + expect( + parseAccountActivities({ + activities: activitiesSignerChanged as Activity[], + accountAddressesOnNetwork: [ + "0x02470ea294aa4b28ee4a473aaa8a1edc6c810c11684d1f29f1f3edd336fd0f34", + ] as Address[], + }), + ).toMatchInlineSnapshot(` + { + "signerChanged": [ + "0x02470ea294aa4b28ee4a473aaa8a1edc6c810c11684d1f29f1f3edd336fd0f34", + ], + } + `) + }) + test("returns a map of actions to account addresses for activitiesDeploy", () => { + expect( + parseAccountActivities({ + activities: activitiesDeploy as Activity[], + accountAddressesOnNetwork: [ + "0x07df7c3ed69cbacf5c4e39f0e3270699f53160b29ee14a8691057fa68bf78c42", + ] as Address[], + }), + ).toMatchInlineSnapshot(` + { + "deploy": [ + "0x07df7c3ed69cbacf5c4e39f0e3270699f53160b29ee14a8691057fa68bf78c42", + ], + } + `) + }) + test("returns a map of actions to account addresses for activitiesDeploy using non 0 based address", () => { + expect( + parseAccountActivities({ + activities: activitiesDeploy as Activity[], + accountAddressesOnNetwork: [ + "0x7df7c3ed69cbacf5c4e39f0e3270699f53160b29ee14a8691057fa68bf78c42", + ] as Address[], + }), + ).toMatchInlineSnapshot(` + { + "deploy": [ + "0x07df7c3ed69cbacf5c4e39f0e3270699f53160b29ee14a8691057fa68bf78c42", + ], + } + `) + }) + }) +}) diff --git a/packages/extension/src/shared/activity/utils/parseAccountActivities.ts b/packages/extension/src/shared/activity/utils/parseAccountActivities.ts new file mode 100644 index 000000000..2f1494d97 --- /dev/null +++ b/packages/extension/src/shared/activity/utils/parseAccountActivities.ts @@ -0,0 +1,56 @@ +import { type Address, ensureArray, includesAddress } from "@argent/shared" + +import type { Activity, ActivityDetailsAction } from "../schema" +import { isProvisionWithDeploymentActivity } from "./isProvisionWithDeploymentActivity" + +interface ParseAccountActivitiesProps { + activities: Activity[] + accountAddressesOnNetwork: Address[] +} + +/** + * Parses security-related activities from the provided list of activities, grouping them by action + * and returning a map of addresses associated with each action. + * + * @param activities: Array of activities to parse. + * @param accountAddressesOnNetwork: Array of account addresses that are known to be active on the + * network. + * @returns A map of actions to their associated addresses, where the addresses represent the + * accounts involved in the respective actions. + */ + +export function parseAccountActivities({ + activities, + accountAddressesOnNetwork, +}: ParseAccountActivitiesProps) { + const accountAddressesByAction: Partial< + Record + > = {} + + activities.forEach((activity) => { + const address = activity.wallet + const action = + activity.type === "deploy" ? activity.type : activity.details.action + + if ( + activity.group === "security" && + includesAddress(address, accountAddressesOnNetwork) && + action + ) { + accountAddressesByAction[action] = accountAddressesByAction[action] || [] + if (!includesAddress(address, accountAddressesByAction[action])) { + accountAddressesByAction[action] = ensureArray( + accountAddressesByAction[action], + ).concat(address) + } + } + // This is to cover the case where starknet uses a multicall to deploy an account and provision it in the same transaction + if (isProvisionWithDeploymentActivity(activity)) { + accountAddressesByAction["deploy"] = ensureArray( + accountAddressesByAction["deploy"], + ).concat(address) + } + }) + + return accountAddressesByAction +} diff --git a/packages/extension/src/background/__new/services/activity/utils/parseFinanceActivities.test.ts b/packages/extension/src/shared/activity/utils/parseFinanceActivities.test.ts similarity index 69% rename from packages/extension/src/background/__new/services/activity/utils/parseFinanceActivities.test.ts rename to packages/extension/src/shared/activity/utils/parseFinanceActivities.test.ts index 772fa25eb..5951e8914 100644 --- a/packages/extension/src/background/__new/services/activity/utils/parseFinanceActivities.test.ts +++ b/packages/extension/src/shared/activity/utils/parseFinanceActivities.test.ts @@ -1,11 +1,13 @@ import type { Address } from "@argent/shared" import { describe, expect, test } from "vitest" -import activities from "../__fixtures__/activities.json" -import state from "../__fixtures__/state.json" import type { Activity } from "../schema" import { parseFinanceActivities } from "./parseFinanceActivities" +import activities from "../__fixtures__/activities.json" +import depositActivities from "../__fixtures__/activities-handle-deposit.json" +import state from "../__fixtures__/state.json" + describe("background/services/activity/utils", () => { describe("parseSecurityActivities", () => { describe("when valid", () => { @@ -46,6 +48,30 @@ describe("background/services/activity/utils", () => { } `) }) + test("returns a map of actions to account addresses for deposit activity", () => { + expect( + parseFinanceActivities({ + activities: depositActivities as Activity[], + accountAddressesOnNetwork: + state.accountAddressesOnNetwork as Address[], + tokenAddressesOnNetwork: state.tokenAddressesOnNetwork as Address[], + nftAddressesOnNetwork: state.nftAddressesOnNetwork as Address[], + }), + ).toMatchInlineSnapshot(` + { + "nftActivity": { + "accountAddresses": [], + "tokenAddresses": [], + }, + "tokenActivity": { + "accountAddresses": [], + "tokenAddresses": [ + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + ], + }, + } + `) + }) }) }) }) diff --git a/packages/extension/src/background/__new/services/activity/utils/parseFinanceActivities.ts b/packages/extension/src/shared/activity/utils/parseFinanceActivities.ts similarity index 100% rename from packages/extension/src/background/__new/services/activity/utils/parseFinanceActivities.ts rename to packages/extension/src/shared/activity/utils/parseFinanceActivities.ts diff --git a/packages/extension/src/shared/activity/utils/parseProvisionActivity.test.ts b/packages/extension/src/shared/activity/utils/parseProvisionActivity.test.ts new file mode 100644 index 000000000..6492acb40 --- /dev/null +++ b/packages/extension/src/shared/activity/utils/parseProvisionActivity.test.ts @@ -0,0 +1,64 @@ +import { Activity } from "../schema" +import { parseProvisionActivity } from "./parseProvisionActivity" + +describe("parseProvisionActivity", () => { + const createBaseActivity = ( + type: Activity["type"], + status: Activity["status"], + hasProvision = false, + ) => + ({ + type, + status, + details: { + calls: hasProvision + ? [{ details: { context: { isProvisionAirdrop: true } } }] + : [], + context: hasProvision ? { isProvisionAirdrop: true } : undefined, + }, + } as unknown as Activity) + + it("returns undefined when no activities are provided", () => { + expect(parseProvisionActivity([])).toBeUndefined() + }) + + it("returns undefined when no activities are successful", () => { + const activities = [createBaseActivity("multicall", "pending")] + expect(parseProvisionActivity(activities)).toBeUndefined() + }) + + it("returns a successful multicall activity with provision", () => { + const provisionActivity = createBaseActivity("multicall", "success", true) + const activities = [provisionActivity] + expect(parseProvisionActivity(activities)).toEqual(provisionActivity) + }) + + it("returns a successful non-multicall activity with provision", () => { + const provisionActivity = createBaseActivity("approval", "success", true) + const activities = [provisionActivity] + expect(parseProvisionActivity(activities)).toEqual(provisionActivity) + }) + + it("returns undefined when successful activities do not have provision", () => { + const activities = [ + createBaseActivity("multicall", "success"), + createBaseActivity("approval", "success"), + ] + expect(parseProvisionActivity(activities)).toBeUndefined() + }) + + it("returns the last multicall activity with provision when multiple are present", () => { + const firstProvisionActivity = createBaseActivity( + "multicall", + "success", + true, + ) + const secondProvisionActivity = createBaseActivity( + "multicall", + "success", + true, + ) + const activities = [firstProvisionActivity, secondProvisionActivity] + expect(parseProvisionActivity(activities)).toEqual(secondProvisionActivity) + }) +}) diff --git a/packages/extension/src/shared/activity/utils/parseProvisionActivity.ts b/packages/extension/src/shared/activity/utils/parseProvisionActivity.ts new file mode 100644 index 000000000..62888897f --- /dev/null +++ b/packages/extension/src/shared/activity/utils/parseProvisionActivity.ts @@ -0,0 +1,32 @@ +import { Activity } from "../schema" + +export const parseProvisionActivity = ( + activities: Activity[], +): Activity | undefined => { + let activityWithProvision: Activity | undefined + + const successfulActivities = activities.filter( + (activity) => activity.status === "success", + ) + const multicallActivities = successfulActivities.filter( + (activity) => activity.type === "multicall", + ) + + const nonMulticallActivities = successfulActivities.filter( + (activity) => activity.type !== "multicall", + ) + multicallActivities.forEach((multicallActivity) => { + const callWithProvision = multicallActivity.details.calls?.find( + (call) => call.details?.context?.isProvisionAirdrop, + ) + if (callWithProvision) { + activityWithProvision = multicallActivity + } + }) + nonMulticallActivities.forEach((nonMulticallActivity) => { + if (nonMulticallActivity.details?.context?.isProvisionAirdrop) { + activityWithProvision = nonMulticallActivity + } + }) + return activityWithProvision +} diff --git a/packages/extension/src/shared/analytics.ts b/packages/extension/src/shared/analytics.ts index 4462e1101..08b3ca00c 100644 --- a/packages/extension/src/shared/analytics.ts +++ b/packages/extension/src/shared/analytics.ts @@ -3,6 +3,8 @@ import { encode } from "starknet" import { CreateAccountType } from "./wallet.model" import { KeyValueStorage } from "./storage" +import { isEmpty } from "lodash-es" +import { settingsStore } from "./settings" const SEGMENT_TRACK_URL = "https://api.segment.io/v1/track" @@ -270,7 +272,13 @@ export function getAnalytics( } return { track: async (event, ...[data]) => { - if (!SEGMENT_WRITE_KEY) { + const privacyShareAnalyticsData = await settingsStore.get( + "privacyShareAnalyticsData", + ) + if (!privacyShareAnalyticsData) { + return + } + if (isEmpty(SEGMENT_WRITE_KEY)) { console.groupCollapsed(`Analytics: ${event}`) console.log("You see this log because no SEGMENT_WRITE_KEY is set") console.log(data) diff --git a/packages/extension/src/shared/api/constants.ts b/packages/extension/src/shared/api/constants.ts index 09ff6a82b..3ffed45ee 100644 --- a/packages/extension/src/shared/api/constants.ts +++ b/packages/extension/src/shared/api/constants.ts @@ -38,6 +38,8 @@ export const ARGENT_X_STATUS_URL = process.env.ARGENT_X_STATUS_URL export const ARGENT_X_STATUS_ENABLED = isValidString(ARGENT_X_STATUS_URL) +export const ARGENT_X_NEWS_URL = process.env.ARGENT_X_NEWS_URL + export const ARGENT_EXPLORER_BASE_URL = ARGENT_API_ENABLED ? urlJoin(ARGENT_API_BASE_URL, "explorer/starknet") : undefined @@ -83,3 +85,38 @@ export const ARGENT_MULTISIG_DISCOVERY_URL = ARGENT_MULTISIG_URL export const ARGENT_SWAP_BASE_URL = ARGENT_API_ENABLED ? urlJoin(ARGENT_API_BASE_URL, "tokens/swap") : undefined + +export const ARGENT_X_LEGAL_PRIVACY_POLICY_URL = + "https://www.argent.xyz/legal/privacy/argent-x/" + +export const ARGENT_X_LEGAL_TERMS_OF_SERVICE_URL = + "https://www.argent.xyz/legal/argent-extension-terms-of-service/" + +const ARGENT_HEALTHCHECK_BASE_URL = process.env + .ARGENT_HEALTHCHECK_BASE_URL as any // we validate it with isValidString + +const ARGENT_HEALTHCHECK_ENABLED = isValidString(ARGENT_HEALTHCHECK_BASE_URL) + +export const PROVISION_STATUS_ENDPOINT = ARGENT_HEALTHCHECK_ENABLED + ? urlJoin(ARGENT_HEALTHCHECK_BASE_URL, "provision-status.json") + : undefined + +// GOERLI only, mainnet ones are still unknown +export const PROVISION_CONTRACT_ADDRESSES = + process.env.ARGENT_X_ENVIRONMENT === "prod" + ? [ + "0x03ce54e2104cd65bb2117c8d401b0ce30139fafffb2ebf8811f70b362d8fac6e", + "0x0128492AB86D97475CDC074A06A827014E6AA10DA9BD745B26CCAFB8C1A54A9A", + "0x06793D9E6ED7182978454C79270E5B14D2655204BA6565CE9B0AA8A3C3121025", + "0x0517daba3622259ae4fffab72bb716d89c30df0994c6ab25ede61bd139639724", + ] + : [ + "0x02b2e8b8eb3429540c58c0dc69ebb2981267196fe0ca2e361056b852445ee766", + "0x0512e19eb3daa35c94592a251f939c8bb7e81795b6eca6148964b5778bf7dd6d", + "0x0761357121b07055dae758496c210da9ab7b422a831a6b90efa3704d85d128d0", + "0x0524983b9b9322fa94d94758d9d8cdd94c936479c77775babcc921bf1e1ad2b6", + ] + +export const ARGENT_NETWORK_STATUS = ARGENT_API_ENABLED + ? urlJoin(ARGENT_API_BASE_URL, "status/starknet") + : undefined diff --git a/packages/extension/src/shared/api/headers.ts b/packages/extension/src/shared/api/headers.ts index 10ebd13b9..51fb24833 100644 --- a/packages/extension/src/shared/api/headers.ts +++ b/packages/extension/src/shared/api/headers.ts @@ -1,3 +1,5 @@ +import type { ArgentBackendNetworkId, ArgentNetworkId } from "@argent/shared" + const makeArgentXHeaders = () => ({ "argent-version": process.env.VERSION || "Unknown version", "argent-client": "argent-x", @@ -7,12 +9,18 @@ export const argentXHeaders = makeArgentXHeaders() /** convert KnownNetworksType to 'goerli' or 'mainnet' expected by API */ -export const argentApiNetworkForNetwork = (network: string) => { - return network === "goerli-alpha" - ? "goerli" - : network === "mainnet-alpha" - ? "mainnet" - : null +export const argentApiNetworkForNetwork = ( + network: ArgentNetworkId | string, +): ArgentBackendNetworkId | null => { + switch (network) { + case "goerli-alpha": + return "goerli" + case "sepolia-alpha": + return "sepolia" + case "mainnet-alpha": + return "mainnet" + } + return null } export const argentApiHeadersForNetwork = (network: string) => { diff --git a/packages/extension/src/shared/chain/service/implementation.test.ts b/packages/extension/src/shared/chain/service/implementation.test.ts index 395b62287..05327fc4d 100644 --- a/packages/extension/src/shared/chain/service/implementation.test.ts +++ b/packages/extension/src/shared/chain/service/implementation.test.ts @@ -52,7 +52,7 @@ describe("StarknetChainService", () => { getTransactionStatus: () => ({ finality_status: status, }), - getTransactionReceipt, + getTransactionReceipt: () => ({ finality_status: status }), }) const txWithStatus = await starknetChainService.getTransactionStatus( diff --git a/packages/extension/src/shared/chain/service/implementation.ts b/packages/extension/src/shared/chain/service/implementation.ts index d4d289357..27a700be4 100644 --- a/packages/extension/src/shared/chain/service/implementation.ts +++ b/packages/extension/src/shared/chain/service/implementation.ts @@ -1,5 +1,3 @@ -import { RpcProvider, TransactionStatus as StarknetTxStatus } from "starknet" - import { getProvider } from "../../network" import { INetworkService } from "../../network/service/interface" import { @@ -9,24 +7,7 @@ import { } from "../../transactions/interface" import { BaseContract, IChainService } from "./interface" import { isContractDeployed } from "@argent/shared" - -function starknetStatusToTransactionStatus( - status: T, - error: T extends "REJECTED" | "REVERTED" ? () => Error : never, -): TransactionStatus { - switch (status) { - case StarknetTxStatus.RECEIVED: - return { status: "pending" } - case StarknetTxStatus.ACCEPTED_ON_L2: - case StarknetTxStatus.ACCEPTED_ON_L1: - return { status: "confirmed" } - case StarknetTxStatus.REJECTED: - case StarknetTxStatus.REVERTED: - return { status: "failed", reason: error() } - default: - throw new Error(`Unknown status: ${status}`) - } -} +import { SUCCESS_STATUSES } from "../../transactions" export class StarknetChainService implements IChainService { constructor(private networkService: Pick) {} @@ -50,15 +31,23 @@ export class StarknetChainService implements IChainService { // TODO: Use constants const isFailed = execution_status === "REVERTED" || finality_status === "REJECTED" - const isSuccessful = - finality_status === "ACCEPTED_ON_L2" || - finality_status === "ACCEPTED_ON_L1" + let isSuccessful = false + // getTransactionStatus goes straight to the sequencer, hence it's much faster than the RPC nodes + // because of that we need to wait for the RPC nodes to have a receipt as well try { - if (execution_status === "REVERTED") { + if ( + execution_status === "REVERTED" || + SUCCESS_STATUSES.includes(finality_status) + ) { // Only get the receipt if the transaction reverted const receipt = await provider.getTransactionReceipt(transaction.hash) - error_reason = receipt.revert_reason + + if ("revert_reason" in receipt) { + error_reason = receipt.revert_reason + } + + isSuccessful = SUCCESS_STATUSES.includes(receipt.finality_status) } } catch (e) { console.warn( diff --git a/packages/extension/src/shared/discover/interface.ts b/packages/extension/src/shared/discover/interface.ts new file mode 100644 index 000000000..793991ed2 --- /dev/null +++ b/packages/extension/src/shared/discover/interface.ts @@ -0,0 +1,10 @@ +import { NewsApiReponse } from "./schema" + +export interface IDiscoverService { + setViewedAt(viewedAt: number): Promise +} + +export type IDiscoverStorage = { + data: NewsApiReponse | null + viewedAt: number +} diff --git a/packages/extension/src/shared/discover/schema.ts b/packages/extension/src/shared/discover/schema.ts new file mode 100644 index 000000000..9316eb23a --- /dev/null +++ b/packages/extension/src/shared/discover/schema.ts @@ -0,0 +1,23 @@ +import { z } from "zod" + +export const newsItemSchema = z.object({ + title: z.string().optional(), + description: z.string().optional(), + created: z.string().optional(), + modified: z.string().optional(), + startTime: z.string().optional(), + endTime: z.string().optional(), + badgeText: z.string().optional(), + backgroundImageUrl: z.string().optional(), + linkUrl: z.string().optional(), + dappId: z.string().optional(), +}) + +export const newsApiReponseSchema = z.object({ + lastModified: z.string(), + news: z.array(newsItemSchema), +}) + +export type NewsItem = z.infer + +export type NewsApiReponse = z.infer diff --git a/packages/extension/src/shared/discover/storage.ts b/packages/extension/src/shared/discover/storage.ts new file mode 100644 index 000000000..c29633e01 --- /dev/null +++ b/packages/extension/src/shared/discover/storage.ts @@ -0,0 +1,15 @@ +import { KeyValueStorage } from "../storage" +import { adaptKeyValue } from "../storage/__new/keyvalue" +import type { IDiscoverStorage } from "./interface" + +const keyValueStorage = new KeyValueStorage( + { + viewedAt: 0, + data: null, + }, + { + namespace: "service:discover", + }, +) + +export const discoverStore = adaptKeyValue(keyValueStorage) diff --git a/packages/extension/src/shared/errors/accountMessaging.ts b/packages/extension/src/shared/errors/accountMessaging.ts index d05a15dd0..3958d8a33 100644 --- a/packages/extension/src/shared/errors/accountMessaging.ts +++ b/packages/extension/src/shared/errors/accountMessaging.ts @@ -9,6 +9,7 @@ export enum ACCOUNT_MESSAGING_ERROR_MESSAGES { GET_NEXT_PUBLIC_KEY_FAILED = "Get next public key failed", GET_PUBLIC_KEY_FAILED = "Get public key failed", TRIGGER_ESCAPE_FAILED = "Trigger escape failed", + ACCOUNT_DEPLOYMENT_PAYLOAD_FAILED = "Account deployment payload failed", } export type AccountMessagingErrorMessage = keyof typeof ACCOUNT_MESSAGING_ERROR_MESSAGES diff --git a/packages/extension/src/shared/errors/network.ts b/packages/extension/src/shared/errors/network.ts index a480d923e..a3fe391c5 100644 --- a/packages/extension/src/shared/errors/network.ts +++ b/packages/extension/src/shared/errors/network.ts @@ -4,6 +4,7 @@ export enum NETWORK_ERROR_MESSAGES { NOT_FOUND = "Network not found", NETWORK_STATUS_RESPONSE_PARSING_FAILED = "Failed to parse checkly response", NETWORK_STATUS_REQUEST_FAILED = "Failed to request network status", + ARGENT_NETWORK_STATUS_NOT_DEFINED = "ARGENT_NETWORK_STATUS is not defined", } export type NetworkValidationErrorMessage = keyof typeof NETWORK_ERROR_MESSAGES diff --git a/packages/extension/src/shared/errors/review.ts b/packages/extension/src/shared/errors/review.ts index a038bbf5f..f9023a4c7 100644 --- a/packages/extension/src/shared/errors/review.ts +++ b/packages/extension/src/shared/errors/review.ts @@ -4,6 +4,7 @@ export enum REVIEW_ERROR_MESSAGE { SIMULATE_AND_REVIEW_FAILED = "Something went wrong fetching review", NO_CALLS_FOUND = "No calls found", ONCHAIN_FEE_ESTIMATION_FAILED = "Failed to estimate fees onchain", + BACKEND_SIMULATION_ERROR = "There was an error in the backend simulation response", } export type ReviewErrorMessage = keyof typeof REVIEW_ERROR_MESSAGE diff --git a/packages/extension/src/shared/errors/riskAssessment.ts b/packages/extension/src/shared/errors/riskAssessment.ts new file mode 100644 index 000000000..e81f050f0 --- /dev/null +++ b/packages/extension/src/shared/errors/riskAssessment.ts @@ -0,0 +1,15 @@ +import { BaseError, BaseErrorPayload } from "./baseError" + +export enum RISK_ASSESSMENT_ERROR_MESSAGE { + ERROR_FETCHING = "Encountered an error while fetching risk assessment", +} + +export type RiskAssessmentErrorMessage = + keyof typeof RISK_ASSESSMENT_ERROR_MESSAGE + +export class RiskAssessmentError extends BaseError { + constructor(payload: BaseErrorPayload) { + super(payload, RISK_ASSESSMENT_ERROR_MESSAGE) + this.name = "RiskAssessmentError" + } +} diff --git a/packages/extension/src/shared/errors/transaction.ts b/packages/extension/src/shared/errors/transaction.ts index dd491e3c3..2a1df5d23 100644 --- a/packages/extension/src/shared/errors/transaction.ts +++ b/packages/extension/src/shared/errors/transaction.ts @@ -6,6 +6,7 @@ export enum TRANSACTION_ERROR_MESSAGE { SIMULATION_DISABLED = "Transaction simulation is disabled", SIMULATION_ERROR = "Transaction simulation failed", DEPRECATED_ACCOUNT = "Deprecated account", + NO_PRE_COMPUTED_FEES = "There was an issue computing fees - please reject this transaction and try again", } export type TransactionErrorMessage = keyof typeof TRANSACTION_ERROR_MESSAGE diff --git a/packages/extension/src/shared/errors/udc.ts b/packages/extension/src/shared/errors/udc.ts index e344519c2..14577c6c2 100644 --- a/packages/extension/src/shared/errors/udc.ts +++ b/packages/extension/src/shared/errors/udc.ts @@ -3,7 +3,9 @@ import { BaseError, BaseErrorPayload } from "./baseError" export enum UDC_ERROR_MESSAGES { FETCH_CONTRACT_CONTRUCTOR_PARAMS = "Error while fetching contract constructor params", CAIRO_1_NOT_SUPPORTED = "Cairo1 contracts are not supported", + CAIRO_0_DECLARE_NOT_SUPPORTED = "Declaring Cairo0 contracts is no longer supported", DEPLOY_TX_NOT_ADDED = "Deploy Account Transaction could not get added to the sequencer", + CONTRACT_ALREADY_DECLARED = "Contract is already declared", NO_STARKNET_DECLARE = "Account does not support Starknet declare", NO_STARKNET_DECLARE_FEE = "Account does not support Starknet Declare Fee", NO_DECLARE_CONTRACT = "Could not declare contract", diff --git a/packages/extension/src/shared/feeToken/constants.ts b/packages/extension/src/shared/feeToken/constants.ts new file mode 100644 index 000000000..a9de7ebbe --- /dev/null +++ b/packages/extension/src/shared/feeToken/constants.ts @@ -0,0 +1,8 @@ +import { ETH_TOKEN_ADDRESS, STRK_TOKEN_ADDRESS } from "../network/constants" + +export const FEE_TOKEN_PREFERENCE_BY_ADDRESS = [ + STRK_TOKEN_ADDRESS, + ETH_TOKEN_ADDRESS, +] + +export const FEE_TOKEN_PREFERENCE_BY_SYMBOL = ["STRK", "ETH"] diff --git a/packages/extension/src/shared/feeToken/repository/preference.ts b/packages/extension/src/shared/feeToken/repository/preference.ts new file mode 100644 index 000000000..c9b979fd1 --- /dev/null +++ b/packages/extension/src/shared/feeToken/repository/preference.ts @@ -0,0 +1,19 @@ +import { STRK_TOKEN_ADDRESS } from "../../network/constants" +import { KeyValueStorage } from "../../storage" +import { adaptKeyValue } from "../../storage/__new/keyvalue" +import { FeeTokenPreference } from "../types/preference.model" + +export const feeTokenPreferenceKeyValueStore = + new KeyValueStorage( + { + prefer: STRK_TOKEN_ADDRESS, + }, + { + namespace: "core:feeTokensPreference", + areaName: "local", + }, + ) + +export const feeTokenPreferenceStore = adaptKeyValue( + feeTokenPreferenceKeyValueStore, +) diff --git a/packages/extension/src/shared/feeToken/service/implementation.test.ts b/packages/extension/src/shared/feeToken/service/implementation.test.ts new file mode 100644 index 000000000..81e9e3e7c --- /dev/null +++ b/packages/extension/src/shared/feeToken/service/implementation.test.ts @@ -0,0 +1,258 @@ +import { Mocked } from "vitest" +import { NetworkService } from "../../network/service/implementation" +import { INetworkService } from "../../network/service/interface" +import { INetworkRepo } from "../../network/store" +import { + MockFnObjectStore, + MockFnRepository, +} from "../../storage/__new/__test__/mockFunctionImplementation" +import { ITokenBalanceRepository } from "../../token/__new/repository/tokenBalance" +import { FeeTokenService } from "./implementation" +import { TokenService } from "../../token/__new/service/implementation" +import { ITokenRepository } from "../../token/__new/repository/token" +import { StarknetChainService } from "../../chain/service/implementation" +import { IAccountService } from "../../account/service/interface" +import { AccountService } from "../../account/service/implementation" +import { + ETH_TOKEN_ADDRESS, + STRK_TOKEN_ADDRESS, + TXV3_ACCOUNT_CLASS_HASH, +} from "../../network/constants" +import { Address, IHttpService, addressSchema } from "@argent/shared" +import { stark } from "starknet" +import { defaultNetwork } from "../../network" +import { getMockNetwork } from "../../../../test/network.mock" +import { + getMockBaseToken, + getMockTokenWithBalance, +} from "../../../../test/token.mock" +import { ITransactionsRepository } from "../../transactions/store" +import { FeeTokenPreference } from "../types/preference.model" +import { ITokenPriceRepository } from "../../token/__new/repository/tokenPrice" + +const randomAddress1 = addressSchema.parse(stark.randomAddress()) + +const BASE_INFO_ENDPOINT = "https://token.info.argent47.net/v1" +const BASE_PRICES_ENDPOINT = "https://token.prices.argent47.net/v1" + +describe("FeeTokenService", () => { + let tokenService: TokenService + let feeTokenService: FeeTokenService + let mockNetworkService: Mocked + let mockTokenRepo: MockFnRepository + let mockTokenBalanceRepo: MockFnRepository + let mockTokenPriceRepo: MockFnRepository + let mockTransactionsRepo: MockFnRepository + let mockNetworkRepo: MockFnRepository + let mockAccountService: Mocked + let mockFeeTokenPreferenceStore: MockFnObjectStore + + beforeEach(() => { + mockTokenRepo = new MockFnRepository() + mockTokenBalanceRepo = new MockFnRepository() + mockTokenPriceRepo = new MockFnRepository() + mockNetworkRepo = new MockFnRepository() + mockTransactionsRepo = new MockFnRepository() + + mockNetworkService = vi.mocked( + new NetworkService(mockNetworkRepo), + ) + const mockAccountRepo = new MockFnRepository() + const chainService = new StarknetChainService(mockNetworkService) + mockAccountService = vi.mocked( + new AccountService(chainService, mockAccountRepo), + ) + mockFeeTokenPreferenceStore = new MockFnObjectStore() + mockFeeTokenPreferenceStore.get = vi.fn().mockResolvedValue({ + prefer: STRK_TOKEN_ADDRESS, + }) + + const mockHttpService = { + get: vi.fn(), + } as unknown as Mocked + + const mockTokenInfoStore = { + namespace: "core:tokenInfo", + get: vi.fn(), + set: vi.fn(), + subscribe: vi.fn(), + } + + tokenService = new TokenService( + mockNetworkService, + mockTokenRepo, + mockTokenBalanceRepo, + mockTokenPriceRepo, + mockTokenInfoStore, + mockHttpService, + BASE_INFO_ENDPOINT, + BASE_PRICES_ENDPOINT, + ) + + feeTokenService = new FeeTokenService( + tokenService, + mockAccountService, + mockNetworkService, + mockFeeTokenPreferenceStore, + ) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + test("getFeeTokens returns the correct fee tokens and respects order preference", async () => { + const mockAccount = { + classHash: TXV3_ACCOUNT_CLASS_HASH as Address, + address: randomAddress1, + networkId: defaultNetwork.id, + } + const mockNetwork = getMockNetwork() + const mockBaseTokens = [ + getMockBaseToken({ networkId: mockNetwork.id }), + getMockBaseToken({ address: "0x456", networkId: mockNetwork.id }), + ] + + const mockTokens = [ + getMockTokenWithBalance({ + ...mockBaseTokens[0], + symbol: "ETH", + balance: BigInt(10e17).toString(), + account: mockAccount, + address: ETH_TOKEN_ADDRESS, + }), + getMockTokenWithBalance({ + ...mockBaseTokens[1], + balance: BigInt(10e16).toString(), + account: mockAccount, + }), + + getMockTokenWithBalance({ + ...mockBaseTokens[1], + balance: BigInt(20e18).toString(), + symbol: "STRK", + account: mockAccount, + address: STRK_TOKEN_ADDRESS, + }), + ] + + mockNetworkService.getById = vi.fn().mockResolvedValueOnce(mockNetwork) + mockTokenBalanceRepo.get.mockResolvedValueOnce(mockTokens) + mockTokenRepo.get.mockResolvedValueOnce(mockTokens) + mockTransactionsRepo.get.mockResolvedValueOnce([]) + + const result = await feeTokenService.getFeeTokens(mockAccount) + expect(result).toEqual([mockTokens[2], mockTokens[0]]) + }) + + test("should return the correct fee tokens given a mock account", async () => { + const mockAccount = { + classHash: TXV3_ACCOUNT_CLASS_HASH as Address, + address: randomAddress1, + networkId: defaultNetwork.id, + } + const mockNetwork = getMockNetwork() + const mockBaseTokens = [ + getMockBaseToken({ networkId: mockNetwork.id }), + getMockBaseToken({ address: "0x456", networkId: mockNetwork.id }), + ] + + const mockTokens = [ + getMockTokenWithBalance({ + ...mockBaseTokens[1], + balance: BigInt(20e18).toString(), + symbol: "STRK", + account: mockAccount, + address: STRK_TOKEN_ADDRESS, + }), + getMockTokenWithBalance({ + ...mockBaseTokens[0], + balance: BigInt(10e17).toString(), + account: mockAccount, + symbol: "ETH", + address: ETH_TOKEN_ADDRESS, + }), + ] + + mockNetworkService.getById = vi.fn().mockResolvedValueOnce(mockNetwork) + mockTokenBalanceRepo.get.mockResolvedValueOnce(mockTokens) + mockTokenRepo.get.mockResolvedValueOnce(mockTokens) + mockTransactionsRepo.get.mockResolvedValueOnce([]) + + const result = await feeTokenService.getFeeTokens(mockAccount) + expect(result).toEqual(mockTokens) + }) + + test("getBestFeeToken returns the correct fee token", async () => { + const mockAccount = { + classHash: "0x123" as Address, + address: randomAddress1, + networkId: defaultNetwork.id, + } + const mockNetwork = getMockNetwork() + const mockBaseTokens = [ + getMockBaseToken({ networkId: mockNetwork.id }), + getMockBaseToken({ address: "0x456", networkId: mockNetwork.id }), + ] + + const mockTokens = [ + getMockTokenWithBalance({ + ...mockBaseTokens[0], + balance: BigInt(10e17).toString(), + account: mockAccount, + address: ETH_TOKEN_ADDRESS, + }), + getMockTokenWithBalance({ + ...mockBaseTokens[1], + balance: BigInt(10e16).toString(), + account: mockAccount, + }), + ] + + mockNetworkService.getById = vi.fn().mockResolvedValueOnce(mockNetwork) + mockTokenBalanceRepo.get.mockResolvedValueOnce(mockTokens) + mockTokenRepo.get.mockResolvedValueOnce(mockTokens) + mockTransactionsRepo.get.mockResolvedValueOnce([]) + + const result = await feeTokenService.getBestFeeToken(mockAccount) + expect(result).toEqual(mockTokens[0]) + }) + + test("getBestFeeToken returns the token with the highest balance", async () => { + const mockAccount = { + classHash: TXV3_ACCOUNT_CLASS_HASH as Address, + address: randomAddress1, + networkId: defaultNetwork.id, + } + const mockNetwork = getMockNetwork() + const mockBaseTokens = [ + getMockBaseToken({ networkId: mockNetwork.id }), + getMockBaseToken({ address: "0x456", networkId: mockNetwork.id }), + ] + + const mockTokens = [ + getMockTokenWithBalance({ + ...mockBaseTokens[0], + balance: BigInt(10e17).toString(), + account: mockAccount, + symbol: "ETH", + address: ETH_TOKEN_ADDRESS, + }), + getMockTokenWithBalance({ + ...mockBaseTokens[1], + balance: BigInt(10e18).toString(), + symbol: "STRK", + account: mockAccount, + address: STRK_TOKEN_ADDRESS, + }), + ] + + mockNetworkService.getById = vi.fn().mockResolvedValueOnce(mockNetwork) + mockTokenBalanceRepo.get.mockResolvedValueOnce(mockTokens) + mockTokenRepo.get.mockResolvedValueOnce(mockTokens) + mockTransactionsRepo.get.mockResolvedValueOnce([]) + + const result = await feeTokenService.getBestFeeToken(mockAccount) + expect(result).toEqual(mockTokens[1]) + }) +}) diff --git a/packages/extension/src/shared/feeToken/service/implementation.ts b/packages/extension/src/shared/feeToken/service/implementation.ts new file mode 100644 index 000000000..7d79661ad --- /dev/null +++ b/packages/extension/src/shared/feeToken/service/implementation.ts @@ -0,0 +1,96 @@ +import { accountsEqual } from "./../../utils/accountsEqual" +import { IAccountService } from "../../account/service/interface" +import { ITokenService } from "../../token/__new/service/interface" +import { TokenWithBalance } from "../../token/__new/types/tokenBalance.model" +import { BaseWalletAccount, WalletAccount } from "../../wallet.model" +import { IFeeTokenService } from "./interface" +import { INetworkService } from "../../network/service/interface" +import { Address, isEqualAddress } from "@argent/shared" +import { + classHashSupportsTxV3, + feeTokenNeedsTxV3Support, +} from "../../network/txv3" +import { equalToken } from "../../token/__new/utils" +import { FEE_TOKEN_PREFERENCE_BY_SYMBOL } from "../constants" +import { FeeTokenPreference } from "../types/preference.model" +import { pickBestFeeToken } from "../utils" +import { IObjectStore } from "../../storage/__new/interface" + +export class FeeTokenService implements IFeeTokenService { + constructor( + private readonly tokenService: ITokenService, + private readonly accountService: IAccountService, + private readonly networkService: INetworkService, + private readonly feeTokenPreferenceStore: IObjectStore, + ) {} + + async getFeeTokens( + account: BaseWalletAccount & Pick, + ): Promise { + const tokens = await this.tokenService.getTokens() + const classHash = + account.classHash ?? + (await this.accountService + .get((x) => accountsEqual(x, account)) + .then(([a]) => a.classHash)) + const network = await this.networkService.getById(account.networkId) + const networkFeeTokens = tokens.filter((token) => + network.possibleFeeTokenAddresses.some((ft) => + isEqualAddress(ft, token.address), + ), + ) + + const accountFeeTokens = networkFeeTokens.filter((token) => { + if (feeTokenNeedsTxV3Support(token)) { + return classHashSupportsTxV3(classHash) + } + return true + }) + const feeTokenBalances = await this.tokenService.getTokenBalancesForAccount( + account, + accountFeeTokens, + ) + const feeTokensWithBalances: TokenWithBalance[] = accountFeeTokens.map( + (token) => { + const tokenBalance = feeTokenBalances.find((tb) => + equalToken(tb, token), + ) ?? { + balance: "0", + account: { address: account.address, networkId: account.networkId }, + } + return { + ...token, + ...tokenBalance, + } + }, + ) + // sort by fee token preference defined in FEE_TOKEN_PREFERENCE_BY_SYMBOL + return feeTokensWithBalances.sort((a, b) => { + const [aIndex, bIndex] = [a, b].map((token) => + FEE_TOKEN_PREFERENCE_BY_SYMBOL.indexOf(token.symbol), + ) + return aIndex === -1 ? 1 : bIndex === -1 ? -1 : aIndex - bIndex + }) + } + + async getBestFeeToken( + account: BaseWalletAccount & Pick, + ): Promise { + const possibleFeeTokenWithBalances = await this.getFeeTokens(account) + const { prefer: preferredFeeToken } = await this.getFeeTokenPreference() + + return pickBestFeeToken(possibleFeeTokenWithBalances, { + prefer: [preferredFeeToken], + }) + } + + async getFeeTokenPreference(): Promise { + return await this.feeTokenPreferenceStore.get() + } + + async preferFeeToken(feeTokenAddress: Address): Promise { + await this.feeTokenPreferenceStore.set({ + prefer: feeTokenAddress, + }) + } +} diff --git a/packages/extension/src/shared/feeToken/service/index.ts b/packages/extension/src/shared/feeToken/service/index.ts new file mode 100644 index 000000000..c3e7a18d5 --- /dev/null +++ b/packages/extension/src/shared/feeToken/service/index.ts @@ -0,0 +1,12 @@ +import { accountService } from "../../account/service" +import { networkService } from "../../network/service" +import { tokenService } from "../../token/__new/service" +import { feeTokenPreferenceStore } from "../repository/preference" +import { FeeTokenService } from "./implementation" + +export const feeTokenService = new FeeTokenService( + tokenService, + accountService, + networkService, + feeTokenPreferenceStore, +) diff --git a/packages/extension/src/shared/feeToken/service/interface.ts b/packages/extension/src/shared/feeToken/service/interface.ts new file mode 100644 index 000000000..b4424e238 --- /dev/null +++ b/packages/extension/src/shared/feeToken/service/interface.ts @@ -0,0 +1,14 @@ +import { TokenWithBalance } from "../../token/__new/types/tokenBalance.model" +import { BaseWalletAccount, WalletAccount } from "../../wallet.model" +import { FeeTokenPreference } from "../types/preference.model" + +export interface IFeeTokenService { + getFeeTokens( + account: BaseWalletAccount & Pick, + ): Promise + getBestFeeToken( + account: BaseWalletAccount & Pick, + ): Promise + getFeeTokenPreference(): Promise + preferFeeToken(tokenAddress: string): Promise +} diff --git a/packages/extension/src/shared/feeToken/types/preference.model.ts b/packages/extension/src/shared/feeToken/types/preference.model.ts new file mode 100644 index 000000000..c3b45472a --- /dev/null +++ b/packages/extension/src/shared/feeToken/types/preference.model.ts @@ -0,0 +1,17 @@ +import { z } from "zod" +import { addressSchema } from "@argent/shared" + +export const FeeTokenPreferenceSchema = z.object({ + prefer: addressSchema, +}) + +export type FeeTokenPreference = z.infer + +export const FeeTokenPreferenceOptionSchema = z.object({ + avoid: z.array(addressSchema).optional(), + prefer: z.array(addressSchema).optional(), +}) + +export type FeeTokenPreferenceOption = z.infer< + typeof FeeTokenPreferenceOptionSchema +> diff --git a/packages/extension/src/ui/features/accountTokens/useFeeTokenBalance.test.ts b/packages/extension/src/shared/feeToken/utils.test.ts similarity index 80% rename from packages/extension/src/ui/features/accountTokens/useFeeTokenBalance.test.ts rename to packages/extension/src/shared/feeToken/utils.test.ts index aecaf914e..f7b9b7387 100644 --- a/packages/extension/src/ui/features/accountTokens/useFeeTokenBalance.test.ts +++ b/packages/extension/src/shared/feeToken/utils.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect } from "vitest" -import { pickBestFeeToken } from "./useFeeTokenBalance" // Adjust the import as necessary -import { BaseTokenWithBalance } from "../../../shared/token/__new/types/tokenBalance.model" import { Address } from "@argent/shared" +import { BaseTokenWithBalance } from "../token/__new/types/tokenBalance.model" +import { STRK_TOKEN_ADDRESS } from "../network/constants" +import { pickBestFeeToken } from "./utils" function getMockToken(options: { address: Address @@ -20,24 +21,24 @@ const mockTokens: BaseTokenWithBalance[] = [ getMockToken({ address: "0x3", balance: "0" }), getMockToken({ address: "0x4", balance: "0" }), getMockToken({ address: "0x5", balance: "0" }), - getMockToken({ address: "0x6", balance: "12" }), + getMockToken({ address: STRK_TOKEN_ADDRESS, balance: "12" }), getMockToken({ address: "0x7", balance: "10" }), getMockToken({ address: "0x8", balance: "0" }), ] describe("pickBestFeeToken", () => { - it("should return the token with the highest balance", () => { + it("should return the token from the default list", () => { const result = pickBestFeeToken(mockTokens) expect(result).toEqual(mockTokens[5]) }) - it("should return the first token if all balances are 0", () => { + it("should return token from default list if all balances are 0", () => { const zeroBalanceTokens = mockTokens.map((token) => ({ ...token, balance: "0", })) const result = pickBestFeeToken(zeroBalanceTokens) - expect(result).toEqual(zeroBalanceTokens[0]) + expect(result).toEqual(zeroBalanceTokens[5]) }) it("should prefer tokens in the 'prefer' list", () => { @@ -46,7 +47,7 @@ describe("pickBestFeeToken", () => { }) it("should avoid tokens in the 'avoid' list", () => { - const result = pickBestFeeToken(mockTokens, { avoid: ["0x6"] }) + const result = pickBestFeeToken(mockTokens, { avoid: [STRK_TOKEN_ADDRESS] }) expect(result).toEqual(mockTokens[1]) }) diff --git a/packages/extension/src/shared/feeToken/utils.ts b/packages/extension/src/shared/feeToken/utils.ts new file mode 100644 index 000000000..30069657c --- /dev/null +++ b/packages/extension/src/shared/feeToken/utils.ts @@ -0,0 +1,84 @@ +import { Address, isEqualAddress } from "@argent/shared" +import { FeeTokenPreferenceOption } from "./types/preference.model" +import { num } from "starknet" +import { arrayOrderWith } from "../utils/arrayOrderWith" +import { FEE_TOKEN_PREFERENCE_BY_ADDRESS } from "./constants" +import { ETH_TOKEN_ADDRESS } from "../network/constants" + +export const pickBestFeeToken = < + BS extends { address: Address; balance: string | bigint }, +>( + balances: BS[], + { avoid = [], prefer = [] }: FeeTokenPreferenceOption = {}, +): BS => { + // sort by prefered tokens, neutral tokens, then avoid tokens + // sort each group by the provided array order and secondarily by balance + const sortedBalances = balances + .map( + (balance) => + [ + balance, + { + balance: num.toBigInt(balance.balance), + prefer: prefer.includes(balance.address), + avoid: avoid.includes(balance.address), + }, + ] as const, + ) + + .sort(([aa, a], [bb, b]) => { + // if one of them has no balance, it should be last + if (!a.balance && b.balance) return 1 + if (a.balance && !b.balance) return -1 + + // otherwise, sort by prefer, then avoid, then by prefer/avoid array order, then by fee token preference + if (a.prefer !== b.prefer) { + return b.prefer ? 1 : -1 + } + if (a.avoid !== b.avoid) { + return a.avoid ? 1 : -1 + } + + if (a.prefer && b.prefer) { + const preferArrayPrio = arrayOrderWith( + prefer, + aa.address, + bb.address, + isEqualAddress, + ) + if (preferArrayPrio !== 0) { + return preferArrayPrio + } + } + if (a.avoid && b.avoid) { + const avoidArrayPrio = arrayOrderWith( + prefer, + bb.address, + aa.address, + isEqualAddress, + ) + if (avoidArrayPrio !== 0) { + return avoidArrayPrio + } + } + + return arrayOrderWith( + FEE_TOKEN_PREFERENCE_BY_ADDRESS, + aa.address, + bb.address, + isEqualAddress, + ) + }) + .map(([balance]) => balance) + + // filter tokens with 0 balance out + const filteredBalances = sortedBalances.filter( + (balance) => num.toBigInt(balance.balance) > 0n, + ) + + const result: BS = filteredBalances[0] ?? + sortedBalances[0] ?? { address: ETH_TOKEN_ADDRESS, balance: "0" } // fallback to ETH to prevent errors + + // return the first token with a balance or the first token if all balances are 0, or a default object if no tokens are found + return result +} diff --git a/packages/extension/src/shared/messages/TransactionMessage.ts b/packages/extension/src/shared/messages/TransactionMessage.ts index 64a83b683..a1ac8d23e 100644 --- a/packages/extension/src/shared/messages/TransactionMessage.ts +++ b/packages/extension/src/shared/messages/TransactionMessage.ts @@ -1,19 +1,14 @@ -import type { - Abi, - AllowArray, - Call, - InvocationsDetails, - UniversalDeployerContractPayload, -} from "starknet" +import type { Abi, AllowArray, Call, InvocationsDetails } from "starknet" import { Transaction } from "../transactions" import { SimulateTransactionsRequest, TransactionSimulationWithFees, } from "../transactionSimulation/types" -import { DeclareContract } from "../udc/schema" +import { DeclareContract, DeployContract } from "../udc/schema" import { TransactionError } from "../errors/transaction" import { EstimatedFees } from "../transactionSimulation/fees/fees.model" +import { Address } from "@argent/shared" export interface ExecuteTransactionRequest { transactions: Call | Call[] @@ -44,7 +39,7 @@ export type TransactionMessage = } | { type: "ESTIMATE_DEPLOY_CONTRACT_FEE" - data: UniversalDeployerContractPayload + data: DeployContract } | { type: "ESTIMATE_DEPLOY_CONTRACT_FEE_REJ"; data: { error: string } } | { @@ -68,7 +63,7 @@ export type TransactionMessage = } | { type: "SIMULATE_TRANSACTIONS" - data: AllowArray + data: { call: AllowArray; feeTokenAddress: Address } } | { type: "SIMULATE_TRANSACTIONS_RES" diff --git a/packages/extension/src/shared/messages/UdcMessage.ts b/packages/extension/src/shared/messages/UdcMessage.ts index 0cae45ce6..1e4d19d25 100644 --- a/packages/extension/src/shared/messages/UdcMessage.ts +++ b/packages/extension/src/shared/messages/UdcMessage.ts @@ -1,7 +1,10 @@ import { DeclareContract } from "../udc/schema" export type UdcMessage = - | { type: "REQUEST_DECLARE_CONTRACT"; data: DeclareContract } + | { + type: "REQUEST_DECLARE_CONTRACT" + data: Omit + } | { type: "REQUEST_DECLARE_CONTRACT_RES"; data: { actionHash: string } } | { type: "REQUEST_DECLARE_CONTRACT_REJ" diff --git a/packages/extension/src/shared/multisig/account.ts b/packages/extension/src/shared/multisig/account.ts index fd41e32af..54d59758f 100644 --- a/packages/extension/src/shared/multisig/account.ts +++ b/packages/extension/src/shared/multisig/account.ts @@ -10,13 +10,27 @@ import { ProviderInterface, ProviderOptions, TransactionType, - hash, num, -} from "starknet" +} from "starknet6" import { MultisigPendingTransaction } from "./pendingTransactionsStore" import { MultisigSigner } from "./signer" import { IMultisigBackendService } from "./service/backend/interface" import { isAccountV5 } from "@argent/shared" +import { + txVersionSchema, + TransactionVersion, +} from "../utils/transactionVersion" + +function denyTxV3( + version: TransactionVersion, +): asserts version is Exclude< + TransactionVersion, + "0x3" | "0x100000000000000000000000000000003" +> { + if (version === "0x3" || version === "0x100000000000000000000000000000003") { + throw Error("Only txv1 is supported") + } +} export class MultisigAccount extends Account { public readonly multisigBackendService: IMultisigBackendService @@ -71,29 +85,67 @@ export class MultisigAccount extends Account { ): Promise { const transactions = Array.isArray(calls) ? calls : [calls] const nonce = num.toHex(transactionsDetail.nonce ?? (await this.getNonce())) - const version = num.toBigInt(hash.transactionVersion).toString() + const version = txVersionSchema.parse(transactionsDetail.version) const chainId = await this.getChainId() const maxFee = transactionsDetail.maxFee ?? - (await this.getSuggestedMaxFee( - { - type: TransactionType.INVOKE, - payload: calls, - }, - { - skipValidate: true, - nonce, - }, - )) - - const signerDetails: InvocationsSignerDetails = { + ( + await this.getSuggestedFee( + { + type: TransactionType.INVOKE, + payload: calls, + }, + { + version: "0x1", // TODO: this should be "0x3 + skipValidate: true, + nonce, + }, + ) + ).suggestedMaxFee + + // TODO: enable once TXV3 is supported + // const signerDetails: InvocationsSignerDetails = + // version !== "0x3" && version !== "0x100000000000000000000000000000003" + // ? ({ + // // txv2 and below + // ...transactionsDetail, + // walletAddress: this.address, + // chainId, + // nonce, + // version, + // cairoVersion: this.cairoVersion, + // maxFee, + // } satisfies V2InvocationsSignerDetails) + // : ({ + // // txv3 + // ...transactionsDetail, + // walletAddress: this.address, + // chainId, + // nonce, + // version, + // cairoVersion: this.cairoVersion, + // accountDeploymentData: [], + // feeDataAvailabilityMode: RPC.EDataAvailabilityMode.L1, + // nonceDataAvailabilityMode: RPC.EDataAvailabilityMode.L1, + // paymasterData: [], + // resourceBounds: { + // l1_gas: { max_amount: "0x0", max_price_per_unit: "0x0" }, + // l2_gas: { max_amount: "0x0", max_price_per_unit: "0x0" }, + // }, + // tip: 0, + // } satisfies V3InvocationsSignerDetails) + + denyTxV3(version) + + const signerDetails = { + ...transactionsDetail, walletAddress: this.address, - chainId, + chainId: chainId as any, // TODO: migrate to snjsv6 completely nonce, version, - maxFee, cairoVersion: this.cairoVersion, + maxFee, } const signature = await this.signer.signTransaction( @@ -115,7 +167,15 @@ export class MultisigAccount extends Account { ) { const chainId = await this.getChainId() - const { calls, maxFee, nonce, version } = transactionToSign.transaction + const { + calls, + maxFee, + nonce, + version: transactionsDetailVersion, + } = transactionToSign.transaction + const version = txVersionSchema.parse(transactionsDetailVersion) + + denyTxV3(version) const signerDetails: InvocationsSignerDetails = { walletAddress: this.address, @@ -131,7 +191,7 @@ export class MultisigAccount extends Account { return this.multisigBackendService.addRequestSignature({ address: this.address, transactionToSign, - chainId, + chainId: chainId as any, // TODO: migrate to snjsv6 completely signature, }) } diff --git a/packages/extension/src/shared/multisig/signer.ts b/packages/extension/src/shared/multisig/signer.ts index a4a0bfff5..7febc9879 100644 --- a/packages/extension/src/shared/multisig/signer.ts +++ b/packages/extension/src/shared/multisig/signer.ts @@ -1,12 +1,11 @@ import { - Abi, Call, DeployAccountSignerDetails, InvocationsSignerDetails, Signature, Signer, stark, -} from "starknet" +} from "starknet6" export class MultisigSigner extends Signer { constructor(pk: Uint8Array | string) { @@ -30,12 +29,10 @@ export class MultisigSigner extends Signer { public async signTransaction( transactions: Call[], transactionsDetail: InvocationsSignerDetails, - abis?: Abi[] | undefined, ): Promise { const signatures = await super.signTransaction( transactions, transactionsDetail, - abis, ) const formattedSignatures = stark.signatureToDecimalArray(signatures) diff --git a/packages/extension/src/shared/network/constants.ts b/packages/extension/src/shared/network/constants.ts index 2765b9e9f..ca903b39c 100644 --- a/packages/extension/src/shared/network/constants.ts +++ b/packages/extension/src/shared/network/constants.ts @@ -6,15 +6,18 @@ export const STRK_TOKEN_ADDRESS = "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d" // This should always be the latest. If you need to use the old or custom one, don't use this constant. -export const STANDARD_ACCOUNT_CLASS_HASH = +export const TXV1_ACCOUNT_CLASS_HASH = "0x1a736d6ed154502257f02b1ccdf4d9d1089f80811cd6acad48e6b6a9d1f2003" export const TXV3_ACCOUNT_CLASS_HASH = - "0x028463df0e5e765507ae51f9e67d6ae36c7e5af793424eccc9bc22ad705fc09d" + "0x29927c8af6bccf3f6fda035981e765a7bdbf18a2dc0d630494f8758aa908e2b" export const STANDARD_CAIRO_0_ACCOUNT_CLASS_HASH = "0x033434ad846cdd5f23eb73ff09fe6fddd568284a0fb7d1be20ee482f044dabe2" +export const STANDARD_DEVNET_ACCOUNT_CLASS_HASH = + "0x4d07e40e93398ed3c76981e72dd1fd22557a78ce36c0515f679e27f0bb5bc5f" + export const PLUGIN_ACCOUNT_CLASS_HASH = "0x4ee23ad83fb55c1e3fac26e2cd951c60abf3ddc851caa9a7fbb9f5eddb2091" @@ -34,12 +37,14 @@ export const MULTICALL_CONTRACT_ADDRESS = // Public RPC nodes export const BLAST_RPC_NODE: PublicRpcNode = { mainnet: "https://starknet-mainnet.public.blastapi.io", - testnet: "https://starknet-testnet.public.blastapi.io", + goerli: "https://starknet-testnet.public.blastapi.io", + sepolia: "https://starknet-sepolia.public.blastapi.io", } as const export const LAVA_RPC_NODE: PublicRpcNode = { mainnet: "https://rpc.starknet.lava.build", - testnet: "https://rpc.starknet-testnet.lava.build", + goerli: "https://rpc.starknet-testnet.lava.build", + sepolia: "https://rpc.starknet-sepolia.lava.build", } as const export const PUBLIC_RPC_NODES = [BLAST_RPC_NODE, LAVA_RPC_NODE] as const diff --git a/packages/extension/src/shared/network/defaults.ts b/packages/extension/src/shared/network/defaults.ts index 8c3b24961..09b99ff63 100644 --- a/packages/extension/src/shared/network/defaults.ts +++ b/packages/extension/src/shared/network/defaults.ts @@ -1,3 +1,4 @@ +import urlJoin from "url-join" import { ARGENT_5_MINUTE_ESCAPE_TESTING_ACCOUNT_CLASS_HASH, BETTER_MULTICAL_ACCOUNT_CLASS_HASH, @@ -6,14 +7,39 @@ import { MULTICALL_CONTRACT_ADDRESS, MULTISIG_ACCOUNT_CLASS_HASH, PLUGIN_ACCOUNT_CLASS_HASH, - STANDARD_ACCOUNT_CLASS_HASH, + TXV1_ACCOUNT_CLASS_HASH, STANDARD_CAIRO_0_ACCOUNT_CLASS_HASH, TXV3_ACCOUNT_CLASS_HASH, + STANDARD_DEVNET_ACCOUNT_CLASS_HASH, } from "./constants" import type { Network, NetworkWithStatus } from "./type" import { getDefaultNetwork } from "./utils" -const DEV_ONLY_NETWORKS: Network[] = [ +const argentXEnv = process.env.ARGENT_X_ENVIRONMENT || "" + +const ARGENT_X_ENV_PROD_ONLY_NETWORKS: Network[] = [ + { + id: "sepolia-alpha", + name: "Sepolia", + chainId: "SN_SEPOLIA", + rpcUrl: urlJoin( + process.env.ARGENT_API_BASE_URL || "", + "starknet/sepolia/rpc/v0.6", + ), + explorerUrl: "https://sepolia.voyager.online", + l1ExplorerUrl: "https://sepolia.etherscan.io", + accountClassHash: { + standard: TXV3_ACCOUNT_CLASS_HASH, + txv1Standard: TXV1_ACCOUNT_CLASS_HASH, + /** NOTE: multisig currently not supported on Sepolia */ + }, + multicallAddress: MULTICALL_CONTRACT_ADDRESS, + possibleFeeTokenAddresses: [ETH_TOKEN_ADDRESS], + readonly: true, + }, +] + +const NODE_ENV_DEV_ONLY_NETWORKS: Network[] = [ { id: "integration", name: "Integration", @@ -24,7 +50,7 @@ const DEV_ONLY_NETWORKS: Network[] = [ }, // multicallAddress: MULTICALL_CONTRACT_ADDRESS, // not defined on integration possibleFeeTokenAddresses: [ETH_TOKEN_ADDRESS, STRK_TOKEN_ADDRESS], - explorerUrl: "https://integration.voyager.online", + explorerUrl: "https://integration.starkscan.co", readonly: true, }, ] @@ -38,6 +64,10 @@ export const defaultNetworksStatuses: NetworkWithStatus[] = [ id: "goerli-alpha", status: "unknown", }, + { + id: "sepolia-alpha", + status: "unknown", + }, { id: "localhost", status: "unknown", @@ -49,47 +79,55 @@ export const defaultNetworks: Network[] = [ id: "mainnet-alpha", name: "Mainnet", chainId: "SN_MAIN", - rpcUrl: "https://cloud.argent-api.com/v1/starknet/mainnet/rpc/v0.5", + rpcUrl: "https://cloud.argent-api.com/v1/starknet/mainnet/rpc/v0.6", explorerUrl: "https://voyager.online", l1ExplorerUrl: "https://etherscan.io", accountClassHash: { - standard: STANDARD_ACCOUNT_CLASS_HASH, + standard: TXV3_ACCOUNT_CLASS_HASH, + txv1Standard: TXV1_ACCOUNT_CLASS_HASH, multisig: MULTISIG_ACCOUNT_CLASS_HASH, + standardCairo0: STANDARD_CAIRO_0_ACCOUNT_CLASS_HASH, }, multicallAddress: MULTICALL_CONTRACT_ADDRESS, - possibleFeeTokenAddresses: [ETH_TOKEN_ADDRESS], + possibleFeeTokenAddresses: [ETH_TOKEN_ADDRESS, STRK_TOKEN_ADDRESS], readonly: true, }, { id: "goerli-alpha", - name: "Testnet", + name: "Goerli", chainId: "SN_GOERLI", - rpcUrl: process.env.ARGENT_TESTNET_RPC_URL ?? "", + rpcUrl: urlJoin( + process.env.ARGENT_API_BASE_URL || "", + "starknet/goerli/rpc/v0.6", + ), explorerUrl: "https://goerli.voyager.online", faucetUrl: "https://faucet.goerli.starknet.io", l1ExplorerUrl: "https://goerli.etherscan.io", accountClassHash: { - standard: STANDARD_ACCOUNT_CLASS_HASH, + standard: TXV3_ACCOUNT_CLASS_HASH, + txv1Standard: TXV1_ACCOUNT_CLASS_HASH, plugin: PLUGIN_ACCOUNT_CLASS_HASH, betterMulticall: BETTER_MULTICAL_ACCOUNT_CLASS_HASH, argent5MinuteEscapeTestingAccount: ARGENT_5_MINUTE_ESCAPE_TESTING_ACCOUNT_CLASS_HASH, multisig: MULTISIG_ACCOUNT_CLASS_HASH, + standardCairo0: STANDARD_CAIRO_0_ACCOUNT_CLASS_HASH, }, multicallAddress: MULTICALL_CONTRACT_ADDRESS, - possibleFeeTokenAddresses: [ETH_TOKEN_ADDRESS], + possibleFeeTokenAddresses: [ETH_TOKEN_ADDRESS, STRK_TOKEN_ADDRESS], readonly: true, }, - ...(process.env.NODE_ENV === "development" ? DEV_ONLY_NETWORKS : []), + ...(argentXEnv === "prod" ? ARGENT_X_ENV_PROD_ONLY_NETWORKS : []), + ...(process.env.NODE_ENV === "development" ? NODE_ENV_DEV_ONLY_NETWORKS : []), { id: "localhost", chainId: "SN_GOERLI", - rpcUrl: "http://localhost:5050/rpc", - explorerUrl: "https://devnet.starkscan.co", - name: "Localhost 5050", + rpcUrl: "http://localhost:5050", + explorerUrl: "http://localhost:4000/testnet/", + name: "Devnet", possibleFeeTokenAddresses: [ETH_TOKEN_ADDRESS], accountClassHash: { - standard: STANDARD_CAIRO_0_ACCOUNT_CLASS_HASH, + standard: STANDARD_DEVNET_ACCOUNT_CLASS_HASH, }, }, ] diff --git a/packages/extension/src/shared/network/index.ts b/packages/extension/src/shared/network/index.ts index 49864ff16..42c2320a7 100644 --- a/packages/extension/src/shared/network/index.ts +++ b/packages/extension/src/shared/network/index.ts @@ -3,6 +3,6 @@ export { defaultNetworks, defaultCustomNetworks, } from "./defaults" -export { getProvider } from "./provider" +export { getProvider, getProvider6 } from "./provider" export { networkSchema } from "./schema" export type { Network, NetworkStatus } from "./type" diff --git a/packages/extension/src/shared/network/makeSafeNetworks.test.ts b/packages/extension/src/shared/network/makeSafeNetworks.test.ts new file mode 100644 index 000000000..187d53b04 --- /dev/null +++ b/packages/extension/src/shared/network/makeSafeNetworks.test.ts @@ -0,0 +1,88 @@ +import {} from "vitest" +import { makeSafeNetworks } from "./makeSafeNetworks" + +import { defaultNetworks } from "./defaults" +import { Network } from "." +import { ETH_TOKEN_ADDRESS } from "./constants" + +const legacyNetwork = { + accountClassHash: { + standard: + "0x1a736d6ed154502257f02b1ccdf4d9d1089f80811cd6acad48e6b6a9d1f2003", + }, + chainId: "SN_SEPOLIA", + feeTokenAddress: + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + id: "sepolia", + multicallAddress: + "0x05754af3760f3356da99aea5c3ec39ccac7783d925a19666ebbeca58ff0087f4", + name: "Sepolia", + rpcUrl: "https://foo.bar", +} as unknown as Network + +const legacyNetworkNoFeeToken = { + accountClassHash: { + standard: + "0x1a736d6ed154502257f02b1ccdf4d9d1089f80811cd6acad48e6b6a9d1f2003", + }, + chainId: "SN_SEPOLIA", + id: "sepolia2", + multicallAddress: + "0x05754af3760f3356da99aea5c3ec39ccac7783d925a19666ebbeca58ff0087f4", + name: "Sepolia 2", + rpcUrl: "https://foo.bar", +} as unknown as Network + +const invalidNetwork = { + chainId: "SN_SEPOLIA", + id: "sepolia3", + name: "Sepolia 3", +} as unknown as Network + +describe("shared/network/makeSafeNetworks", () => { + describe("when valid", () => { + test("returns unmodified networks", () => { + expect(makeSafeNetworks(defaultNetworks)).toEqual(defaultNetworks) + }) + }) + describe("when invalid", () => { + test("returns modified, valid networks", () => { + expect( + makeSafeNetworks([ + legacyNetwork, + legacyNetworkNoFeeToken, + invalidNetwork, + ]), + ).toEqual([ + { + accountClassHash: { + standard: + "0x1a736d6ed154502257f02b1ccdf4d9d1089f80811cd6acad48e6b6a9d1f2003", + }, + chainId: "SN_SEPOLIA", + id: "sepolia", + multicallAddress: + "0x05754af3760f3356da99aea5c3ec39ccac7783d925a19666ebbeca58ff0087f4", + name: "Sepolia", + possibleFeeTokenAddresses: [ + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + ], + rpcUrl: "https://foo.bar", + }, + { + accountClassHash: { + standard: + "0x1a736d6ed154502257f02b1ccdf4d9d1089f80811cd6acad48e6b6a9d1f2003", + }, + chainId: "SN_SEPOLIA", + id: "sepolia2", + multicallAddress: + "0x05754af3760f3356da99aea5c3ec39ccac7783d925a19666ebbeca58ff0087f4", + name: "Sepolia 2", + possibleFeeTokenAddresses: [ETH_TOKEN_ADDRESS], + rpcUrl: "https://foo.bar", + }, + ]) + }) + }) +}) diff --git a/packages/extension/src/shared/network/makeSafeNetworks.ts b/packages/extension/src/shared/network/makeSafeNetworks.ts new file mode 100644 index 000000000..e271f56cd --- /dev/null +++ b/packages/extension/src/shared/network/makeSafeNetworks.ts @@ -0,0 +1,36 @@ +import { Address } from "@argent/shared" +import type { Network } from "./type" +import { ETH_TOKEN_ADDRESS } from "./constants" +import { networkSchema } from "." +import { isEmpty } from "lodash-es" + +export const makeSafeNetworks = (unsafeNetworks: Network[]): Network[] => { + return unsafeNetworks.flatMap((unsafeNetwork) => { + if (networkSchema.safeParse(unsafeNetwork).success) { + return [unsafeNetwork] + } + // try to fix the network if it is using old `feeTokenAddress` shape + if (isEmpty(unsafeNetwork.possibleFeeTokenAddresses)) { + // move feeTokenAddress -> possibleFeeTokenAddresses[feeTokenAddress] + if ( + "feeTokenAddress" in unsafeNetwork && + !isEmpty(unsafeNetwork.feeTokenAddress) + ) { + unsafeNetwork.possibleFeeTokenAddresses = [ + unsafeNetwork.feeTokenAddress as Address, + ] + delete unsafeNetwork.feeTokenAddress + if (networkSchema.safeParse(unsafeNetwork).success) { + return [unsafeNetwork] + } + } + // try possibleFeeTokenAddresses[ETH_TOKEN_ADDRESS] + unsafeNetwork.possibleFeeTokenAddresses = [ETH_TOKEN_ADDRESS] + if (networkSchema.safeParse(unsafeNetwork).success) { + return [unsafeNetwork] + } + } + // omit network that failed schema + return [] + }) +} diff --git a/packages/extension/src/shared/network/provider.ts b/packages/extension/src/shared/network/provider.ts index b0da1aa1c..47d8962b4 100644 --- a/packages/extension/src/shared/network/provider.ts +++ b/packages/extension/src/shared/network/provider.ts @@ -1,19 +1,30 @@ import { memoize } from "lodash-es" -import { RpcProvider, constants, shortString } from "starknet" +import { RpcProvider, constants } from "starknet" +import { RpcProvider as RpcProvider6, shortString } from "starknet6" import { RpcProvider as RpcProviderV4 } from "starknet4" import { Network } from "./type" import { argentXHeaders } from "../api/headers" -export const getProviderForRpcUrlAndChainId = memoize( - (rpcUrl: string, chainId: constants.StarknetChainId): RpcProvider => { +export const getProviderForRpcUrl = memoize( + (rpcUrl: string, chainId?: constants.StarknetChainId): RpcProvider => { return new RpcProvider({ nodeUrl: rpcUrl, chainId, headers: argentXHeaders, }) }, - (a: string, b: string) => `${a}::${b}`, + (a: string, b: string = "") => `${a}::${b}`, +) +export const getProviderForRpcUrl6 = memoize( + (rpcUrl: string, chainId?: constants.StarknetChainId): RpcProvider6 => { + return new RpcProvider6({ + nodeUrl: rpcUrl, + chainId, + headers: argentXHeaders, + }) + }, + (a: string, b: string = "") => `${a}::${b}`, ) /** @@ -22,11 +33,17 @@ export const getProviderForRpcUrlAndChainId = memoize( * @returns */ export function getProvider(network: Network): RpcProvider { - // Initialising RpcProvider with chainId removes the need for initial RPC calls to `starknet_chainId` const chainId = shortString.encodeShortString( network.chainId, ) as constants.StarknetChainId - return getProviderForRpcUrlAndChainId(network.rpcUrl, chainId) + return getProviderForRpcUrl(network.rpcUrl, chainId) +} + +export function getProvider6(network: Network): RpcProvider6 { + const chainId = shortString.encodeShortString( + network.chainId, + ) as constants.StarknetChainId + return getProviderForRpcUrl6(network.rpcUrl, chainId) } /** ======================================================================== */ diff --git a/packages/extension/src/shared/network/schema.ts b/packages/extension/src/shared/network/schema.ts index 86f9a170d..2f8e86d9c 100644 --- a/packages/extension/src/shared/network/schema.ts +++ b/packages/extension/src/shared/network/schema.ts @@ -7,12 +7,8 @@ export const baseNetworkSchema = z.object({ id: z.string().min(2).max(31), }) -export const networkStatusSchema = z.enum([ - "ok", - "degraded", - "error", - "unknown", -]) +export const networkStatusSchema = z.enum(["red", "amber", "green", "unknown"]) + export const networkSchema = baseNetworkSchema.extend({ name: z.string().min(2).max(128), chainId: z @@ -38,6 +34,12 @@ export const networkSchema = baseNetworkSchema.extend({ message: `Account class hash must match the following: /^0x[a-f0-9]+$/i`, }) .optional(), + txv1Standard: z + .string() + .regex(REGEX_HEXSTRING, { + message: `Account class hash must match the following: /^0x[a-f0-9]+$/i`, + }) + .optional(), standardCairo0: z .string() .regex(REGEX_HEXSTRING, { diff --git a/packages/extension/src/shared/network/store.ts b/packages/extension/src/shared/network/store.ts index 68d8d842e..449994244 100644 --- a/packages/extension/src/shared/network/store.ts +++ b/packages/extension/src/shared/network/store.ts @@ -4,6 +4,7 @@ import type { IRepository } from "../storage/__new/interface" import { adaptArrayStorage } from "../storage/__new/repository" import { defaultNetworks, defaultReadonlyNetworks } from "./defaults" import type { BaseNetwork, Network } from "./type" +import { makeSafeNetworks } from "./makeSafeNetworks" export type INetworkRepo = IRepository @@ -15,9 +16,10 @@ export const networksEqual = (a: BaseNetwork, b: BaseNetwork) => a.id === b.id export const allNetworksStore = new ArrayStorage(defaultNetworks, { namespace: "core:allNetworks", compare: networksEqual, - deserialize(value: Network[]): Network[] { + deserialize(unsafeNetworks: Network[]): Network[] { + const safeNetworks = makeSafeNetworks(unsafeNetworks) // overwrite the stored values for the default networks with the default values - return mergeArrayStableWith(value, defaultReadonlyNetworks, { + return mergeArrayStableWith(safeNetworks, defaultReadonlyNetworks, { compareFn: networksEqual, insertMode: "unshift", }) diff --git a/packages/extension/src/shared/network/txv3.ts b/packages/extension/src/shared/network/txv3.ts index ccfb94749..f274452a3 100644 --- a/packages/extension/src/shared/network/txv3.ts +++ b/packages/extension/src/shared/network/txv3.ts @@ -4,7 +4,7 @@ import { BaseToken } from "../token/__new/types/token.model" const tokensRequireTxV3Support = [STRK_TOKEN_ADDRESS] -export function feeTokenNeedsTxV3Support(token: BaseToken) { +export function feeTokenNeedsTxV3Support(token: Pick) { return tokensRequireTxV3Support.some((address) => isEqualAddress(address, token.address), ) diff --git a/packages/extension/src/shared/network/type.ts b/packages/extension/src/shared/network/type.ts index aed62724a..50ba049be 100644 --- a/packages/extension/src/shared/network/type.ts +++ b/packages/extension/src/shared/network/type.ts @@ -1,3 +1,4 @@ +import type { ArgentNetworkId, ArgentBackendNetworkId } from "@argent/shared" import { z } from "zod" import { @@ -15,13 +16,6 @@ export type NetworkWithStatus = z.infer export type NetworkStatus = z.infer -export type DefaultNetworkId = - | "mainnet-alpha" - | "goerli-alpha" - | "localhost" - | "integration" +export type DefaultNetworkId = ArgentNetworkId | "localhost" | "integration" -export type PublicRpcNode = { - mainnet: string - testnet: string -} +export type PublicRpcNode = Record diff --git a/packages/extension/src/shared/network/utils.ts b/packages/extension/src/shared/network/utils.ts index 30f437e0a..b9adb9e03 100644 --- a/packages/extension/src/shared/network/utils.ts +++ b/packages/extension/src/shared/network/utils.ts @@ -1,9 +1,14 @@ import { constants } from "starknet" -import { isEqualAddress } from "@argent/shared" +import { + isEqualAddress, + type ArgentNetworkId, + isArgentNetworkId, +} from "@argent/shared" import type { ArgentAccountType } from "../wallet.model" -import type { Network, PublicRpcNode } from "./type" +import type { DefaultNetworkId, Network, PublicRpcNode } from "./type" import { PUBLIC_RPC_NODES } from "./constants" +import { argentApiNetworkForNetwork } from "../api/headers" // LEGACY ⬇️ export function mapImplementationToArgentAccountType( @@ -29,7 +34,7 @@ export function mapImplementationToArgentAccountType( export function getNetworkIdFromChainId( encodedChainId: string, -): "mainnet-alpha" | "goerli-alpha" { +): ArgentNetworkId { switch (encodedChainId) { case constants.StarknetChainId.SN_MAIN: return "mainnet-alpha" @@ -37,12 +42,15 @@ export function getNetworkIdFromChainId( case constants.StarknetChainId.SN_GOERLI: return "goerli-alpha" + case constants.StarknetChainId.SN_SEPOLIA: + return "sepolia-alpha" + default: throw new Error(`Unknown chainId: ${encodedChainId}`) } } -export function getDefaultNetworkId() { +export function getDefaultNetworkId(): DefaultNetworkId { const argentXEnv = process.env.ARGENT_X_ENVIRONMENT if (!argentXEnv) { @@ -82,7 +90,7 @@ export function getDefaultNetwork(defaultNetworks: Network[]): Network { } export function isArgentNetwork(network: Network) { - return network.id === "mainnet-alpha" || network.id === "goerli-alpha" + return isArgentNetworkId(network.id) } export function getRandomPublicRPCNode(network: Network) { @@ -101,13 +109,11 @@ export function getPublicRPCNodeUrls(network: Network) { if (!isArgentNetwork) { throw new Error(`Not an Argent network: ${network.id}`) } - const key: keyof PublicRpcNode = - network.id === "mainnet-alpha" ? "mainnet" : "testnet" - const nodeUrls = PUBLIC_RPC_NODES.map((node) => node[key]) - - if (!nodeUrls) { + const key = argentApiNetworkForNetwork(network.id) + if (!key) { throw new Error(`No nodes found for network: ${network.id}`) } + const nodeUrls = PUBLIC_RPC_NODES.map((node) => node[key]) return nodeUrls } diff --git a/packages/extension/src/shared/nft/implementation.ts b/packages/extension/src/shared/nft/implementation.ts index 91067bbf6..545ff44e0 100644 --- a/packages/extension/src/shared/nft/implementation.ts +++ b/packages/extension/src/shared/nft/implementation.ts @@ -1,10 +1,12 @@ import { - Address, + type Address, + type ArgentBackendNetworkId, ArgentBackendNftService, - Collection, - NftItem, - PaginatedItems, + type Collection, + type NftItem, + type PaginatedItems, isEqualAddress, + PaginatedCollections, } from "@argent/shared" import { differenceWith, groupBy, isEqual } from "lodash-es" import { AllowArray, constants, num, shortString } from "starknet" @@ -26,7 +28,7 @@ import { type NftMarketplace, } from "./marketplaces" -const chainIdToPandoraNetwork = (chainId: string): "mainnet" | "goerli" => { +const chainIdToPandoraNetwork = (chainId: string): ArgentBackendNetworkId => { const encodedChainId = num.isHex(chainId) ? chainId : shortString.encodeShortString(chainId) @@ -36,6 +38,8 @@ const chainIdToPandoraNetwork = (chainId: string): "mainnet" | "goerli" => { return "mainnet" case constants.StarknetChainId.SN_GOERLI: return "goerli" + case constants.StarknetChainId.SN_SEPOLIA: + return "sepolia" } throw new Error(`Unsupported network ${chainId}`) } @@ -102,7 +106,7 @@ export class NFTService implements INFTService { private async fetchNftsUrl( chain: string, - network: "mainnet" | "goerli", + network: ArgentBackendNetworkId, accountAddress: string, page = 1, ): Promise { @@ -128,6 +132,35 @@ export class NFTService implements INFTService { return paginateditems } + private async fetchCollectionsUrl( + chain: string, + network: ArgentBackendNetworkId, + accountAddress: string, + page = 1, + ): Promise { + const paginateditems: PaginatedCollections = + await this.argentNftService.getProfileCollections( + chain, + network, + accountAddress, + page, + ) + if (page < paginateditems.totalPages) { + const nextPage: PaginatedCollections = await this.fetchCollectionsUrl( + chain, + network, + accountAddress, + paginateditems.page + 1, + ) + + return { + ...paginateditems, + collections: paginateditems.collections.concat(nextPage.collections), + } + } + return paginateditems + } + async getCollection( chain: string, networkId: string, @@ -148,26 +181,32 @@ export class NFTService implements INFTService { chain: string, networkId: string, contractsAddresses: ContractAddress[], + accountAddress: string, ) { const axNetwork = await this.networkService.getById(networkId) const pandoraNetwork = chainIdToPandoraNetwork(axNetwork.chainId) await this.nftsContractsRepository.upsert(contractsAddresses) - const collections = groupBy( + const collectionsRepository = groupBy( await this.nftsCollectionsRepository.get(), "contractAddress", ) const toPush: Collection[] = [] - for (const contract of contractsAddresses) { - if (!collections[contract.contractAddress]) { - const { nfts, ...rest } = await this.argentNftService.getCollection( - chain, - pandoraNetwork, - contract.contractAddress, - ) - toPush.push({ ...rest, networkId }) + + const { collections } = await this.fetchCollectionsUrl( + chain, + pandoraNetwork, + accountAddress, + ) + + for (const collection of collections) { + // already stored + if (collectionsRepository[collection.contractAddress]) { + continue } + + toPush.push({ ...collection, networkId }) } if (toPush.length > 0) { diff --git a/packages/extension/src/shared/nft/interface.ts b/packages/extension/src/shared/nft/interface.ts index 06fabd3bb..590edd5e1 100644 --- a/packages/extension/src/shared/nft/interface.ts +++ b/packages/extension/src/shared/nft/interface.ts @@ -25,6 +25,7 @@ export interface INFTService { chain: string, networkId: string, contractsAddresses: ContractAddress[], + accountAddress: string, ) => Promise upsert: ( nfts: AllowArray, diff --git a/packages/extension/src/shared/provision/interface.ts b/packages/extension/src/shared/provision/interface.ts new file mode 100644 index 000000000..ea030d10b --- /dev/null +++ b/packages/extension/src/shared/provision/interface.ts @@ -0,0 +1,5 @@ +import { ProvisionStatus } from "./types" + +export interface IProvisionService { + getStatus: () => Promise +} diff --git a/packages/extension/src/shared/provision/types.ts b/packages/extension/src/shared/provision/types.ts new file mode 100644 index 000000000..83672c2d5 --- /dev/null +++ b/packages/extension/src/shared/provision/types.ts @@ -0,0 +1,19 @@ +import { z } from "zod" + +export const statusSchema = z.union([ + z.literal("notActive"), + z.literal("eligibilityCheck"), + z.literal("active"), + z.literal("paused"), + z.literal("disabled"), +]) + +export const provisionStatusSchema = z.object({ + status: statusSchema, + link: z.string().optional(), + bannerTitle: z.string(), + bannerDescription: z.string(), +}) + +export type ProvisionStatus = z.infer +export type Status = z.infer diff --git a/packages/extension/src/shared/recovery/service/interface.ts b/packages/extension/src/shared/recovery/service/interface.ts index 48568c2b7..69c88c481 100644 --- a/packages/extension/src/shared/recovery/service/interface.ts +++ b/packages/extension/src/shared/recovery/service/interface.ts @@ -1,4 +1,5 @@ export interface IRecoveryService { - byBackup: (backup: string) => Promise - bySeedPhrase: (seedPhrase: string, newPassword: string) => Promise + byBackup(backup: string): Promise + bySeedPhrase(seedPhrase: string, newPassword: string): Promise + clearErrorRecovering(): Promise } diff --git a/packages/extension/src/shared/recovery/storage.ts b/packages/extension/src/shared/recovery/storage.ts index f2ef60f56..edcda4541 100644 --- a/packages/extension/src/shared/recovery/storage.ts +++ b/packages/extension/src/shared/recovery/storage.ts @@ -5,6 +5,8 @@ import { IRecoveryStorage } from "./types" const keyValueStorage = new KeyValueStorage( { isRecovering: false, + errorRecovering: false, + isClearingStorage: false, }, { namespace: "service:recovery", @@ -12,3 +14,14 @@ const keyValueStorage = new KeyValueStorage( ) export const recoveryStore = adaptKeyValue(keyValueStorage) + +export const recoveredAtKeyValueStore = new KeyValueStorage<{ + lastRecoveredAt: number | null +}>( + { lastRecoveredAt: null }, + { + namespace: "core:recoveredAt", + areaName: "local", + }, +) +export const recoveredAtStore = adaptKeyValue(recoveredAtKeyValueStore) diff --git a/packages/extension/src/shared/recovery/types.ts b/packages/extension/src/shared/recovery/types.ts index 852d09e59..8c938e15a 100644 --- a/packages/extension/src/shared/recovery/types.ts +++ b/packages/extension/src/shared/recovery/types.ts @@ -1,3 +1,5 @@ export interface IRecoveryStorage { isRecovering: boolean + errorRecovering: string | false + isClearingStorage: boolean } diff --git a/packages/extension/src/shared/riskAssessment/interface.ts b/packages/extension/src/shared/riskAssessment/interface.ts new file mode 100644 index 000000000..605f55cbf --- /dev/null +++ b/packages/extension/src/shared/riskAssessment/interface.ts @@ -0,0 +1,17 @@ +import { z } from "zod" +import { RiskAssessment } from "./schema" + +export const dappContextSchema = z.object({ + dappDomain: z.string(), + network: z.string(), +}) + +export type DappContext = z.infer + +export interface IRiskAssessmentService { + assessRisk({ + dappContext, + }: { + dappContext: DappContext + }): Promise +} diff --git a/packages/extension/src/shared/riskAssessment/schema.ts b/packages/extension/src/shared/riskAssessment/schema.ts new file mode 100644 index 000000000..de39ed47b --- /dev/null +++ b/packages/extension/src/shared/riskAssessment/schema.ts @@ -0,0 +1,38 @@ +import { z } from "zod" +import { warningSchema } from "../transactionReview/schema" + +const linkSchema = z.object({ + name: z.string(), + url: z.string(), + position: z.number(), +}) + +const contractSchema = z.object({ + address: z.string(), + chain: z.string(), +}) + +const dappSchema = z.object({ + dappId: z.string().optional(), + name: z.string().optional(), + description: z.string().optional(), + logoUrl: z.string().optional(), + iconUrl: z.string().optional(), + argentVerified: z.boolean().default(false), + dappDomain: z.string().optional(), + isUnknown: z.boolean().default(true), + links: z.array(linkSchema), + contracts: z.array(contractSchema), + urlSoundex: z.array(z.string()), + unknown: z.boolean().default(true), +}) + +export const riskAssessmentSchema = z.object({ + dapp: dappSchema.optional(), + warning: warningSchema.optional(), + status: z.number().optional(), + error: z.string().optional(), +}) + +export type Dapp = z.infer +export type RiskAssessment = z.infer diff --git a/packages/extension/src/shared/sentry/options.ts b/packages/extension/src/shared/sentry/options.ts new file mode 100644 index 000000000..619c1ed88 --- /dev/null +++ b/packages/extension/src/shared/sentry/options.ts @@ -0,0 +1,21 @@ +import * as Sentry from "@sentry/browser" + +const environment = process.env.SENTRY_ENVIRONMENT ?? process.env.NODE_ENV + +const release = getRelease() + +export const baseSentryOptions: Sentry.BrowserOptions = { + dsn: process.env.SENTRY_DSN, + environment, + release, + autoSessionTracking: false, // don't want to track user sessions. +} + +function getRelease() { + const commitHash = process.env.COMMIT_HASH + const release = process.env.npm_package_version + if (environment === "staging" && commitHash) { + return `${release}-rc__${commitHash}` + } + return release +} diff --git a/packages/extension/src/shared/settings/defaultBlockExplorers.ts b/packages/extension/src/shared/settings/defaultBlockExplorers.ts index efb205957..6c69e5613 100644 --- a/packages/extension/src/shared/settings/defaultBlockExplorers.ts +++ b/packages/extension/src/shared/settings/defaultBlockExplorers.ts @@ -14,6 +14,7 @@ export const defaultBlockExplorers: BlockExplorers = { logo: "StarknetLogo", url: { "mainnet-alpha": "https://starkscan.co", + "sepolia-alpha": "https://sepolia.starkscan.co", "goerli-alpha": "https://testnet.starkscan.co", localhost: "https://devnet.starkscan.co", }, @@ -23,6 +24,7 @@ export const defaultBlockExplorers: BlockExplorers = { logo: "VoyagerLogo", url: { "mainnet-alpha": "https://voyager.online", + "sepolia-alpha": "https://sepolia.voyager.online", "goerli-alpha": "https://goerli.voyager.online", localhost: "https://goerli.voyager.online/local-version", }, diff --git a/packages/extension/src/shared/settings/store.ts b/packages/extension/src/shared/settings/store.ts index 8bd0c66bd..80af92491 100644 --- a/packages/extension/src/shared/settings/store.ts +++ b/packages/extension/src/shared/settings/store.ts @@ -8,7 +8,7 @@ export const settingsStore = new KeyValueStorage( privacyUseArgentServices: true, privacyShareAnalyticsData: true, privacyErrorReporting: Boolean(process.env.SENTRY_DSN), // use SENRY_DSN to enable error reporting - privacyAutomaticErrorReporting: false, + privacyAutomaticErrorReporting: true, experimentalAllowChooseAccount: false, blockExplorerKey: defaultBlockExplorerKey, nftMarketplaceKey: "unframed", diff --git a/packages/extension/src/shared/shield/GuardianSelfSigner.ts b/packages/extension/src/shared/shield/GuardianSelfSigner.ts index b88727fe5..c59ea5ac6 100644 --- a/packages/extension/src/shared/shield/GuardianSelfSigner.ts +++ b/packages/extension/src/shared/shield/GuardianSelfSigner.ts @@ -1,13 +1,12 @@ import { - Abi, Call, DeclareSignerDetails, DeployAccountSignerDetails, InvocationsSignerDetails, Signature, stark, -} from "starknet" -import { Signer, typedData } from "starknet" +} from "starknet6" +import { Signer, typedData } from "starknet6" /** * Use case: `escapeGuardian` cannot be used to remove or set guardian to ZERO @@ -34,57 +33,25 @@ export class GuardianSelfSigner extends Signer { public async signTransaction( transactions: Call[], transactionsDetail: InvocationsSignerDetails, - abis?: Abi[], ): Promise { const signatures = await super.signTransaction( transactions, transactionsDetail, - abis, ) const formattedSignatures = stark.signatureToDecimalArray(signatures) return [...formattedSignatures, ...formattedSignatures] } - public async signDeployAccountTransaction({ - classHash, - contractAddress, - constructorCalldata, - addressSalt, - maxFee, - version, - chainId, - nonce, - }: DeployAccountSignerDetails) { - const signatures = await super.signDeployAccountTransaction({ - classHash, - contractAddress, - constructorCalldata, - addressSalt, - maxFee, - version, - chainId, - nonce, - }) + public async signDeployAccountTransaction( + details: DeployAccountSignerDetails, + ) { + const signatures = await super.signDeployAccountTransaction(details) const formattedSignatures = stark.signatureToDecimalArray(signatures) return [...formattedSignatures, ...formattedSignatures] } - public async signDeclareTransaction({ - classHash, - senderAddress, - chainId, - maxFee, - version, - nonce, - }: DeclareSignerDetails) { - const signatures = await super.signDeclareTransaction({ - classHash, - senderAddress, - chainId, - maxFee, - version, - nonce, - }) + public async signDeclareTransaction(details: DeclareSignerDetails) { + const signatures = await super.signDeclareTransaction(details) const formattedSignatures = stark.signatureToDecimalArray(signatures) return [...formattedSignatures, ...formattedSignatures] } diff --git a/packages/extension/src/shared/shield/GuardianSignerArgentX.ts b/packages/extension/src/shared/shield/GuardianSignerArgentX.ts index 9eccc8427..6a146e2f8 100644 --- a/packages/extension/src/shared/shield/GuardianSignerArgentX.ts +++ b/packages/extension/src/shared/shield/GuardianSignerArgentX.ts @@ -1,6 +1,6 @@ -import { CosignerOffchainMessage, GuardianSigner } from "@argent/guardian" -import type { CosignerMessage } from "@argent/guardian" -import { Signature, hash, num } from "starknet" +import { GuardianSigner } from "@argent/guardian" +import type { CosignerMessage, CosignerOffchainMessage } from "@argent/guardian" +import { Signature, hash, num } from "starknet6" import { isEqualAddress } from "@argent/shared" import { isTokenExpired } from "./backend/account" @@ -25,11 +25,15 @@ export class GuardianSignerArgentX extends GuardianSigner { isOffchainMessage = false, ): Promise { /** special case - check guardianSignerNotRequired */ - const selector = cosignerMessage.message?.calldata?.[2] - if ( + "type" in cosignerMessage && + (cosignerMessage.type === "starknet" || + cosignerMessage.type === "starknetV3") && guardianSignerNotRequiredSelectors.find((notRequiredSelector) => - isEqualAddress(notRequiredSelector, selector), + isEqualAddress( + notRequiredSelector, + cosignerMessage.message.calldata[2], // calldata[2] is the selector + ), ) ) { return [] diff --git a/packages/extension/src/shared/storage/__new/__test__/inmemoryImplementations.ts b/packages/extension/src/shared/storage/__new/__test__/inmemoryImplementations.ts index c84b232f9..2440fceef 100644 --- a/packages/extension/src/shared/storage/__new/__test__/inmemoryImplementations.ts +++ b/packages/extension/src/shared/storage/__new/__test__/inmemoryImplementations.ts @@ -80,7 +80,10 @@ export class InMemoryRepository implements IRepository { return selector ? this._data.filter(selector) : this._data } - async upsert(value: AllowArray | SetterFn): Promise { + async upsert( + value: AllowArray | SetterFn, + insertMode: "push" | "unshift" = "push", + ): Promise { const oldValue = [...this._data] const items = isFunction(value) ? value(oldValue) @@ -100,7 +103,7 @@ export class InMemoryRepository implements IRepository { this._data[index] = item updated++ } else { - this._data.push(item) + this._data[insertMode](item) created++ } } diff --git a/packages/extension/src/shared/storage/__new/chrome.ts b/packages/extension/src/shared/storage/__new/chrome.ts index 93b4f234d..9c07ab2ef 100644 --- a/packages/extension/src/shared/storage/__new/chrome.ts +++ b/packages/extension/src/shared/storage/__new/chrome.ts @@ -81,7 +81,10 @@ export class ChromeRepository implements IRepository { return removedValues } - async upsert(value: AllowArray | SetterFn): Promise { + async upsert( + value: AllowArray | SetterFn, + insertMode: "push" | "unshift" = "push", + ): Promise { // use mergeArrayStableWith to merge the new values with the existing values const items = await this.getStorage() @@ -93,10 +96,10 @@ export class ChromeRepository implements IRepository { } else { newValues = [value] } - const mergedValues = mergeArrayStableWith(items, newValues, { compareFn: this.options.compare.bind(this), mergeFn: this.options.merge.bind(this), + insertMode, }) await this.set(mergedValues) diff --git a/packages/extension/src/shared/storage/__new/interface.ts b/packages/extension/src/shared/storage/__new/interface.ts index 906dbbc15..21a774c44 100644 --- a/packages/extension/src/shared/storage/__new/interface.ts +++ b/packages/extension/src/shared/storage/__new/interface.ts @@ -67,7 +67,10 @@ export interface IRepository { * @param value - An array of items, a single item, or a setter function that operates on an array of items. * @returns A Promise that resolves to a boolean indicating whether the operation succeeded. */ - upsert(value: AllowArray | SetterFn): Promise + upsert( + value: AllowArray | SetterFn, + insertMode?: "push" | "unshift", + ): Promise /** * Removes items from the repository based on the provided value or selector function. diff --git a/packages/extension/src/shared/storage/__new/prune.ts b/packages/extension/src/shared/storage/__new/prune.ts index 74152f61c..07b262be1 100644 --- a/packages/extension/src/shared/storage/__new/prune.ts +++ b/packages/extension/src/shared/storage/__new/prune.ts @@ -28,6 +28,7 @@ const localStoragePatterns: Pattern[] = [ /"feeTokenBalance"/, [/^core:transactions$/, pruneTransactions], /^dev:storage/, + /^core:tokenInfo$/, ] /** keep in-flight transactions as they can't be retreived from backend or on-chain */ diff --git a/packages/extension/src/shared/storage/__new/repository.ts b/packages/extension/src/shared/storage/__new/repository.ts index e04d28acd..9d87e7709 100644 --- a/packages/extension/src/shared/storage/__new/repository.ts +++ b/packages/extension/src/shared/storage/__new/repository.ts @@ -17,8 +17,11 @@ export function adaptArrayStorage(storage: ArrayStorage): IRepository { return storage.get(selector) }, - async upsert(value: AllowArray | SetterFn): Promise { - await storage.push(value) + async upsert( + value: AllowArray | SetterFn, + insertMode: "push" | "unshift" = "push", + ): Promise { + await storage[insertMode](value) return { created: Date.now(), updated: Date.now() } }, diff --git a/packages/extension/src/shared/token/__new/constants.ts b/packages/extension/src/shared/token/__new/constants.ts index 5909cfd49..89c657789 100644 --- a/packages/extension/src/shared/token/__new/constants.ts +++ b/packages/extension/src/shared/token/__new/constants.ts @@ -12,6 +12,11 @@ export const ETH: Record = { "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", networkId: "goerli-alpha", }, + [constants.StarknetChainId.SN_SEPOLIA]: { + address: + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + networkId: "sepolia-alpha", + }, } export const USDC: Record = { @@ -25,4 +30,9 @@ export const USDC: Record = { "0x005a643907b9a4bc6a55e9069c4fd5fd1f5c79a22470690f75556c4736e34426", networkId: "goerli-alpha", }, + [constants.StarknetChainId.SN_SEPOLIA]: { + address: + "0x03a909c1f2d1900d0c96626fac1bedf1e82b92110e5c529b05f9138951b93535", + networkId: "sepolia-alpha", + }, } diff --git a/packages/extension/src/shared/token/__new/repository/mergeTokens.test.ts b/packages/extension/src/shared/token/__new/repository/mergeTokens.test.ts new file mode 100644 index 000000000..24418d484 --- /dev/null +++ b/packages/extension/src/shared/token/__new/repository/mergeTokens.test.ts @@ -0,0 +1,87 @@ +import { describe, test } from "vitest" + +import type { Token } from "../types/token.model" +import { mergeTokens, mergeTokensWithDefaults } from "./mergeTokens" + +const MOCK_TOKEN_1: Token = { + symbol: "MOCK1", + address: "0x1", + networkId: "mainnet-alpha", + name: "Mock1", + decimals: 18, +} + +const MOCK_TOKEN_2: Token = { + symbol: "MOCK2", + address: "0x2", + networkId: "mainnet-alpha", + name: "Mock2", + decimals: 18, +} + +const MOCK_TOKEN_3: Token = { + symbol: "MOCK3", + address: "0x3", + networkId: "mainnet-alpha", + name: "Mock3", + decimals: 18, +} + +describe("shared/token/repository", () => { + describe("mergeTokens", () => { + test("merges tokens, keeping old values and updating new values", () => { + const firstToken: Token = { + ...MOCK_TOKEN_1, + pricingId: 1, + } + const secondToken: Token = { + ...MOCK_TOKEN_1, + name: "Mock1Updated", + } + expect(mergeTokens(firstToken, secondToken)).toEqual({ + address: "0x1", + decimals: 18, + name: "Mock1Updated", + networkId: "mainnet-alpha", + pricingId: 1, + symbol: "MOCK1", + }) + }) + }) + describe("mergeTokensWithDefaults", () => { + test("merges tokens, keeping old values and updating new values, inserting new tokens", () => { + const tokens: Token[] = [ + { + ...MOCK_TOKEN_1, + name: "Mock1Existing", + pricingId: 1, + }, + { + ...MOCK_TOKEN_2, + name: "Mock2Existing", + iconUrl: "https://foo.bar", + }, + ] + const defaultTokens = [MOCK_TOKEN_1, MOCK_TOKEN_2, MOCK_TOKEN_3] + expect(mergeTokensWithDefaults(tokens, defaultTokens)).toEqual([ + MOCK_TOKEN_3, + { + address: "0x1", + decimals: 18, + name: "Mock1", + networkId: "mainnet-alpha", + pricingId: 1, + symbol: "MOCK1", + }, + { + address: "0x2", + decimals: 18, + iconUrl: "https://foo.bar", + name: "Mock2", + networkId: "mainnet-alpha", + symbol: "MOCK2", + }, + ]) + }) + }) +}) diff --git a/packages/extension/src/shared/token/__new/repository/mergeTokens.ts b/packages/extension/src/shared/token/__new/repository/mergeTokens.ts new file mode 100644 index 000000000..19d6804b9 --- /dev/null +++ b/packages/extension/src/shared/token/__new/repository/mergeTokens.ts @@ -0,0 +1,20 @@ +import type { Token } from "../types/token.model" +import { equalToken, parsedDefaultTokens } from "../utils" +import { mergeArrayStableWith } from "../../../storage/__new/base" + +export const mergeTokens = (oldValue: Token, newValue: Token) => ({ + ...oldValue, + ...newValue, +}) + +export const mergeTokensWithDefaults = ( + tokens: Token[], + defaultTokens: Token[] = parsedDefaultTokens, +) => { + const repoTokensWithDefaults = mergeArrayStableWith(tokens, defaultTokens, { + compareFn: equalToken, + mergeFn: mergeTokens, + insertMode: "unshift", + }) + return repoTokensWithDefaults +} diff --git a/packages/extension/src/shared/token/__new/repository/token.ts b/packages/extension/src/shared/token/__new/repository/token.ts index 7a5bc49ca..8db5badd0 100644 --- a/packages/extension/src/shared/token/__new/repository/token.ts +++ b/packages/extension/src/shared/token/__new/repository/token.ts @@ -1,8 +1,10 @@ import browser from "webextension-polyfill" + import { ChromeRepository } from "../../../storage/__new/chrome" -import { IRepository } from "../../../storage/__new/interface" -import { BaseToken, Token } from "../types/token.model" +import type { IRepository } from "../../../storage/__new/interface" +import type { Token } from "../types/token.model" import { equalToken, parsedDefaultTokens } from "../utils" +import { mergeTokens, mergeTokensWithDefaults } from "./mergeTokens" export type ITokenRepository = IRepository @@ -11,11 +13,12 @@ export const tokenRepo: ITokenRepository = new ChromeRepository( { areaName: "local", namespace: "core:tokens", - compare: (a: BaseToken, b: BaseToken) => equalToken(a, b), - merge: (oldValue: Token, newValue: Token) => ({ - ...oldValue, - ...newValue, - }), + compare: equalToken, + merge: mergeTokens, defaults: parsedDefaultTokens, + deserialize(repoTokens: Token[]): Token[] { + // overwrite the stored values for the default tokens with the default values + return mergeTokensWithDefaults(repoTokens, parsedDefaultTokens) + }, }, ) diff --git a/packages/extension/src/shared/token/__new/repository/tokenInfo.ts b/packages/extension/src/shared/token/__new/repository/tokenInfo.ts new file mode 100644 index 000000000..7fef433e2 --- /dev/null +++ b/packages/extension/src/shared/token/__new/repository/tokenInfo.ts @@ -0,0 +1,12 @@ +import { KeyValueStorage } from "../../../storage" +import { adaptKeyValue } from "../../../storage/__new/keyvalue" +import { TokenInfoByNetwork } from "../types/tokenInfo.model" + +const keyValueStorage = new KeyValueStorage( + {}, + { + namespace: "core:tokenInfo", + }, +) + +export const tokenInfoStore = adaptKeyValue(keyValueStorage) diff --git a/packages/extension/src/shared/token/__new/service/implementation.test.ts b/packages/extension/src/shared/token/__new/service/implementation.test.ts index 45c3243ed..a7d013cef 100644 --- a/packages/extension/src/shared/token/__new/service/implementation.test.ts +++ b/packages/extension/src/shared/token/__new/service/implementation.test.ts @@ -19,71 +19,29 @@ import { getMockNetworkWithoutMulticall, } from "../../../../../test/network.mock" import { INetworkRepo } from "../../../network/store" -import { rest } from "msw" -import { setupServer } from "msw/node" import { GatewayError, shortString, stark } from "starknet" -import { Address, addressSchema } from "@argent/shared" +import { IHttpService, addressSchema } from "@argent/shared" import { TokenError } from "../../../errors/token" -import { - ETH_TOKEN_ADDRESS, - STRK_TOKEN_ADDRESS, - TXV3_ACCOUNT_CLASS_HASH, -} from "../../../network/constants" +import { IObjectStore } from "../../../storage/__new/interface" +import { TokenInfoByNetwork } from "../types/tokenInfo.model" const BASE_INFO_ENDPOINT = "https://token.info.argent47.net/v1" -const BASE_INFO_ENDPOINT_INVALID = "https://token.info.argent47.net/v2" const BASE_PRICES_ENDPOINT = "https://token.prices.argent47.net/v1" -// const BASE_URL_WITH_WILDCARD = BASE_URL_ENDPOINT + "*" - const randomAddress1 = addressSchema.parse(stark.randomAddress()) const randomAddress2 = addressSchema.parse(stark.randomAddress()) -const server = setupServer( - rest.get(BASE_INFO_ENDPOINT, (req, res, ctx) => { - return res( - ctx.json({ - tokens: [ - getMockApiTokenDetails({ id: 1, address: randomAddress1 }), - getMockApiTokenDetails({ - id: 2, - address: randomAddress2, - pricingId: 2, - }), - ], - }), - ) - }), - rest.get(BASE_INFO_ENDPOINT_INVALID, (req, res, ctx) => { - return res( - ctx.json([ - getMockApiTokenDetails({ address: randomAddress1 }), - getMockApiTokenDetails({ address: randomAddress2, pricingId: 2 }), - ]), - ) - }), - rest.get(BASE_PRICES_ENDPOINT, (req, res, ctx) => { - return res( - ctx.json({ - prices: [ - getMockTokenPriceDetails({ pricingId: 1, ethValue: "0.32" }), - getMockTokenPriceDetails({ pricingId: 2, ethValue: "0.64" }), - ], - }), - ) - }), -) - describe("TokenService", () => { let tokenService: TokenService let mockNetworkService: Mocked + let mockNetworkRepo: MockFnRepository let mockTokenRepo: MockFnRepository let mockTokenBalanceRepo: MockFnRepository let mockTokenPriceRepo: MockFnRepository - beforeAll(() => { - server.listen() - }) + let mockHttpService: Mocked + let mockTokenInfoStore: Mocked> + beforeEach(() => { mockTokenRepo = new MockFnRepository() mockTokenBalanceRepo = new MockFnRepository() @@ -94,11 +52,24 @@ describe("TokenService", () => { new NetworkService(mockNetworkRepo), ) + mockHttpService = { + get: vi.fn(), + } as unknown as Mocked + + mockTokenInfoStore = { + namespace: "core:tokenInfo", + get: vi.fn(), + set: vi.fn(), + subscribe: vi.fn(), + } + tokenService = new TokenService( mockNetworkService, mockTokenRepo, mockTokenBalanceRepo, mockTokenPriceRepo, + mockTokenInfoStore, + mockHttpService, BASE_INFO_ENDPOINT, BASE_PRICES_ENDPOINT, ) @@ -152,53 +123,203 @@ describe("TokenService", () => { expect(mockTokenPriceRepo.upsert).toHaveBeenCalledWith(mockTokenPrices) }) - describe("fetch tokens from backend", () => { - it("should fetch tokens from backend", async () => { - const mockNetworkId = defaultNetwork.id - const defaultMockApiTokeDetails = getMockApiTokenDetails() - const mockToken1 = getMockToken({ - id: 1, - address: randomAddress1, - networkId: mockNetworkId, - iconUrl: defaultMockApiTokeDetails.iconUrl, - showAlways: undefined, - custom: undefined, - popular: defaultMockApiTokeDetails.popular, - pricingId: defaultMockApiTokeDetails.pricingId, - tradable: true, + describe("get tokens info from backend", () => { + const mockNetworkId = defaultNetwork.id + const defaultMockApiTokeDetails = getMockApiTokenDetails() + const mockToken1 = getMockApiTokenDetails({ + id: 1, + address: randomAddress1, + iconUrl: defaultMockApiTokeDetails.iconUrl, + popular: defaultMockApiTokeDetails.popular, + pricingId: defaultMockApiTokeDetails.pricingId, + tradable: true, + }) + const mockToken2 = getMockApiTokenDetails({ + id: 2, + address: randomAddress2, + iconUrl: defaultMockApiTokeDetails.iconUrl, + popular: defaultMockApiTokeDetails.popular, + pricingId: 2, + tradable: true, + }) + const mockTokens = [mockToken1, mockToken2] + + describe("when storage is empty", () => { + it("should fetch tokens from backend", async () => { + mockTokenInfoStore.get.mockResolvedValueOnce({}) + mockHttpService.get.mockResolvedValueOnce({ tokens: mockTokens }) + const result = await tokenService.getTokensInfoFromBackendForNetwork( + mockNetworkId, + ) + expect(mockTokenInfoStore.get).toHaveBeenCalledOnce() + expect(mockHttpService.get).toHaveBeenCalledOnce() + expect(mockTokenInfoStore.set).toHaveBeenCalledOnce() + expect(result).toEqual(mockTokens) }) - const mockToken2 = getMockToken({ - id: 2, - address: randomAddress2, - networkId: mockNetworkId, - showAlways: undefined, - iconUrl: defaultMockApiTokeDetails.iconUrl, - custom: undefined, - popular: defaultMockApiTokeDetails.popular, - pricingId: 2, - tradable: true, + }) + + describe("when storage has recent tokens", () => { + it("should return tokens from storage and not fetch from backend", async () => { + mockTokenInfoStore.get.mockResolvedValueOnce({ + [mockNetworkId]: { + updatedAt: Date.now(), + data: mockTokens, + }, + }) + mockHttpService.get.mockResolvedValueOnce({ tokens: mockTokens }) + const result = await tokenService.getTokensInfoFromBackendForNetwork( + mockNetworkId, + ) + expect(mockTokenInfoStore.get).toHaveBeenCalledOnce() + expect(mockHttpService.get).not.toHaveBeenCalled() + expect(mockTokenInfoStore.set).not.toHaveBeenCalled() + expect(result).toEqual(mockTokens) }) - const mockTokens = [mockToken1, mockToken2] - mockTokenRepo.get.mockResolvedValueOnce(mockTokens) - const result = await tokenService.fetchTokensFromBackend(mockNetworkId) - expect(result).toEqual(mockTokens) }) + describe("when storage has old tokens", () => { + it("should fetch tokens from backend", async () => { + mockTokenInfoStore.get.mockResolvedValueOnce({ + [mockNetworkId]: { + updatedAt: 0, + data: mockTokens, + }, + }) + mockHttpService.get.mockResolvedValueOnce({ tokens: mockTokens }) + const result = await tokenService.getTokensInfoFromBackendForNetwork( + mockNetworkId, + ) + expect(mockTokenInfoStore.get).toHaveBeenCalledOnce() + expect(mockHttpService.get).toHaveBeenCalledOnce() + expect(mockTokenInfoStore.set).toHaveBeenCalledOnce() + expect(result).toEqual(mockTokens) + }) + }) + }) + + describe("fetch account token balances from backend", () => { + describe("when default network", async () => { + describe("and the response is not initialised", async () => { + it("should retry until initialised", async () => { + const networkId = defaultNetwork.id + mockHttpService.get + .mockResolvedValueOnce({ status: "initialising" }) + .mockResolvedValueOnce({ status: "initialising" }) + .mockResolvedValueOnce({ status: "initialising" }) + .mockResolvedValueOnce({ + status: "initialised", + balances: [ + { + tokenAddress: "0x123", + tokenBalance: "123", + }, + ], + }) + const result = + await tokenService.fetchAccountTokenBalancesFromBackend( + { + address: "0x123", + networkId, + }, + { + minTimeout: 0, + maxTimeout: 0, + }, + ) + expect(mockHttpService.get).toHaveBeenCalledTimes(4) + expect(mockHttpService.get).toHaveBeenCalledWith( + expect.stringMatching( + /activity\/starknet\/(goerli|sepolia|mainnet)\/account\/0x123\/balance$/, + ), + ) + expect(result).toEqual([ + { + account: { + address: "0x123", + networkId, + }, + address: + "0x0000000000000000000000000000000000000000000000000000000000000123", + balance: "123", + networkId, + }, + ]) + }) + }) + describe("and the response is initialised", async () => { + it("should return first response", async () => { + const networkId = defaultNetwork.id + mockHttpService.get.mockResolvedValueOnce({ + status: "initialised", + balances: [ + { + tokenAddress: "0x123", + tokenBalance: "123", + }, + ], + }) + const result = + await tokenService.fetchAccountTokenBalancesFromBackend( + { + address: "0x123", + networkId, + }, + { + minTimeout: 0, + maxTimeout: 0, + }, + ) + expect(mockHttpService.get).toHaveBeenCalledTimes(1) + expect(mockHttpService.get).toHaveBeenCalledWith( + expect.stringMatching( + /activity\/starknet\/(goerli|sepolia|mainnet)\/account\/0x123\/balance$/, + ), + ) + expect(result).toEqual([ + { + account: { + address: "0x123", + networkId, + }, + address: + "0x0000000000000000000000000000000000000000000000000000000000000123", + balance: "123", + networkId, + }, + ]) + }) + }) + }) + describe("when not default network", () => { + it("should return empty array", async () => { + const result = await tokenService.fetchAccountTokenBalancesFromBackend({ + address: "0x123", + networkId: "invalid-network", + }) + expect(result).toEqual([]) + }) + }) + }) + + describe("fetch tokens from backend", () => { it("should return without fetching tokens if it is not a default network", async () => { const mockNetworkId = "mockNetworkId" - const mockTokens = [getMockToken({ networkId: mockNetworkId })] - mockTokenRepo.get.mockResolvedValueOnce(mockTokens) - const result = await tokenService.fetchTokensFromBackend(mockNetworkId) - expect(result).toEqual(mockTokens) + const result = await tokenService.getTokensInfoFromBackendForNetwork( + mockNetworkId, + ) + expect(mockTokenInfoStore.get).not.toHaveBeenCalled() + expect(result).toBeUndefined() }) - it("should throw parsing error if token info response is not valid", async () => { + it("should return undefined if token info response is not valid", async () => { const invalidTokenService = new TokenService( mockNetworkService, mockTokenRepo, mockTokenBalanceRepo, mockTokenPriceRepo, - BASE_INFO_ENDPOINT_INVALID, + mockTokenInfoStore, + mockHttpService, + BASE_INFO_ENDPOINT, BASE_PRICES_ENDPOINT, ) @@ -223,10 +344,13 @@ describe("TokenService", () => { pricingId: 2, }) const mockTokens = [mockToken1, mockToken2] + mockTokenInfoStore.get.mockResolvedValueOnce({}) mockTokenRepo.get.mockResolvedValueOnce(mockTokens) - await expect( - invalidTokenService.fetchTokensFromBackend(mockNetworkId), - ).rejects.toThrowError(new TokenError({ code: "TOKEN_PARSING_ERROR" })) + const result = + await invalidTokenService.getTokensInfoFromBackendForNetwork( + mockNetworkId, + ) + expect(result).toBeUndefined() }) }) @@ -360,6 +484,12 @@ describe("TokenService", () => { pricingId: i + 1, }), ) + mockHttpService.get.mockResolvedValueOnce({ + prices: [ + getMockTokenPriceDetails({ pricingId: 1, ethValue: "0.32" }), + getMockTokenPriceDetails({ pricingId: 2, ethValue: "0.64" }), + ], + }) mockTokenPriceRepo.get.mockResolvedValueOnce([]) const result = await tokenService.fetchTokenPricesFromBackend( mockTokens, @@ -386,6 +516,12 @@ describe("TokenService", () => { pricingId: i + 1, }), ) + mockHttpService.get.mockResolvedValueOnce({ + prices: [ + getMockTokenPriceDetails({ pricingId: 1, ethValue: "0.32" }), + getMockTokenPriceDetails({ pricingId: 2, ethValue: "0.64" }), + ], + }) mockTokenPriceRepo.get.mockResolvedValueOnce(mockTokenPrices) const result = await tokenService.fetchTokenPricesFromBackend( mockTokens, @@ -726,155 +862,4 @@ describe("TokenService", () => { [`${mockAccount.address}:${mockAccount.networkId}`]: "2200", }) }) - - test("getFeeTokens returns the correct fee tokens and respects order preference", async () => { - const mockAccount = { - classHash: TXV3_ACCOUNT_CLASS_HASH as Address, - address: randomAddress1, - networkId: defaultNetwork.id, - } - const mockNetwork = getMockNetwork() - const mockBaseTokens = [ - getMockBaseToken({ networkId: mockNetwork.id }), - getMockBaseToken({ address: "0x456", networkId: mockNetwork.id }), - ] - - const mockTokens = [ - getMockTokenWithBalance({ - ...mockBaseTokens[0], - symbol: "ETH", - balance: BigInt(10e17).toString(), - account: mockAccount, - address: ETH_TOKEN_ADDRESS, - }), - getMockTokenWithBalance({ - ...mockBaseTokens[1], - balance: BigInt(10e16).toString(), - account: mockAccount, - }), - - getMockTokenWithBalance({ - ...mockBaseTokens[1], - balance: BigInt(20e18).toString(), - symbol: "STRK", - account: mockAccount, - address: STRK_TOKEN_ADDRESS, - }), - ] - - mockNetworkService.getById = vi.fn().mockResolvedValueOnce(mockNetwork) - mockTokenBalanceRepo.get.mockResolvedValueOnce(mockTokens) - mockTokenRepo.get.mockResolvedValueOnce(mockTokens) - - const result = await tokenService.getFeeTokens(mockAccount) - expect(result).toEqual([mockTokens[2], mockTokens[0]]) - }) - - test("should return the correct fee tokens given a mock account", async () => { - const mockAccount = { - classHash: TXV3_ACCOUNT_CLASS_HASH as Address, - address: randomAddress1, - networkId: defaultNetwork.id, - } - const mockNetwork = getMockNetwork() - const mockBaseTokens = [ - getMockBaseToken({ networkId: mockNetwork.id }), - getMockBaseToken({ address: "0x456", networkId: mockNetwork.id }), - ] - - const mockTokens = [ - getMockTokenWithBalance({ - ...mockBaseTokens[1], - balance: BigInt(20e18).toString(), - symbol: "STRK", - account: mockAccount, - address: STRK_TOKEN_ADDRESS, - }), - getMockTokenWithBalance({ - ...mockBaseTokens[0], - balance: BigInt(10e17).toString(), - account: mockAccount, - symbol: "ETH", - address: ETH_TOKEN_ADDRESS, - }), - ] - - mockNetworkService.getById = vi.fn().mockResolvedValueOnce(mockNetwork) - mockTokenBalanceRepo.get.mockResolvedValueOnce(mockTokens) - mockTokenRepo.get.mockResolvedValueOnce(mockTokens) - - const result = await tokenService.getFeeTokens(mockAccount) - expect(result).toEqual(mockTokens) - }) - - test("getBestFeeToken returns the correct fee token", async () => { - const mockAccount = { - classHash: "0x123" as Address, - address: randomAddress1, - networkId: defaultNetwork.id, - } - const mockNetwork = getMockNetwork() - const mockBaseTokens = [ - getMockBaseToken({ networkId: mockNetwork.id }), - getMockBaseToken({ address: "0x456", networkId: mockNetwork.id }), - ] - - const mockTokens = [ - getMockTokenWithBalance({ - ...mockBaseTokens[0], - balance: BigInt(10e17).toString(), - account: mockAccount, - address: ETH_TOKEN_ADDRESS, - }), - getMockTokenWithBalance({ - ...mockBaseTokens[1], - balance: BigInt(10e16).toString(), - account: mockAccount, - }), - ] - - mockNetworkService.getById = vi.fn().mockResolvedValueOnce(mockNetwork) - mockTokenBalanceRepo.get.mockResolvedValueOnce(mockTokens) - mockTokenRepo.get.mockResolvedValueOnce(mockTokens) - - const result = await tokenService.getBestFeeToken(mockAccount) - expect(result).toEqual(mockTokens[0]) - }) - - test("getBestFeeToken returns the token with the highest balance", async () => { - const mockAccount = { - classHash: TXV3_ACCOUNT_CLASS_HASH as Address, - address: randomAddress1, - networkId: defaultNetwork.id, - } - const mockNetwork = getMockNetwork() - const mockBaseTokens = [ - getMockBaseToken({ networkId: mockNetwork.id }), - getMockBaseToken({ address: "0x456", networkId: mockNetwork.id }), - ] - - const mockTokens = [ - getMockTokenWithBalance({ - ...mockBaseTokens[0], - balance: BigInt(10e17).toString(), - account: mockAccount, - symbol: "ETH", - address: ETH_TOKEN_ADDRESS, - }), - getMockTokenWithBalance({ - ...mockBaseTokens[1], - balance: BigInt(10e18).toString(), - symbol: "STRK", - account: mockAccount, - address: STRK_TOKEN_ADDRESS, - }), - ] - - mockNetworkService.getById = vi.fn().mockResolvedValueOnce(mockNetwork) - mockTokenBalanceRepo.get.mockResolvedValueOnce(mockTokens) - mockTokenRepo.get.mockResolvedValueOnce(mockTokens) - - const result = await tokenService.getBestFeeToken(mockAccount) - expect(result).toEqual(mockTokens[1]) - }) }) diff --git a/packages/extension/src/shared/token/__new/service/implementation.ts b/packages/extension/src/shared/token/__new/service/implementation.ts index 1f577f1d8..30c42f9a5 100644 --- a/packages/extension/src/shared/token/__new/service/implementation.ts +++ b/packages/extension/src/shared/token/__new/service/implementation.ts @@ -4,17 +4,15 @@ import { ITokenBalanceRepository } from "../repository/tokenBalance" import { ITokenPriceRepository } from "../repository/tokenPrice" import { ITokenService } from "./interface" import { - ApiTokenDataResponseSchema, + ApiAccountTokenBalances, BaseToken, BaseTokenSchema, Token, + apiAccountTokenBalancesSchema, } from "../types/token.model" import { convertTokenAmountToCurrencyValue, equalToken } from "../utils" -import { - BaseTokenWithBalance, - TokenWithBalance, -} from "../types/tokenBalance.model" -import { BaseWalletAccount, WalletAccount } from "../../../wallet.model" +import { BaseTokenWithBalance } from "../types/tokenBalance.model" +import { BaseWalletAccount } from "../../../wallet.model" import { ApiPriceDataResponseSchema, TokenPriceDetails, @@ -22,21 +20,34 @@ import { } from "../types/tokenPrice.model" import { accountsEqual } from "../../../utils/accountsEqual" import { groupBy, uniq } from "lodash-es" -import { SelectorFn } from "../../../storage/__new/interface" -import { bigDecimal, ensureArray, isEqualAddress } from "@argent/shared" +import { IObjectStore, SelectorFn } from "../../../storage/__new/interface" +import { + IHttpService, + addressSchema, + bigDecimal, + ensureArray, + stripAddressZeroPadding, +} from "@argent/shared" import { INetworkService } from "../../../network/service/interface" import { getMulticallForNetwork } from "../../../multicall" import { getProvider } from "../../../network/provider" import { Network, defaultNetwork } from "../../../network" -import { fetcherWithArgentApiHeadersForNetwork } from "../../../api/fetcher" -import { getAccountIdentifier } from "../../../wallet.service" import { TokenError } from "../../../errors/token" import { - classHashSupportsTxV3, - feeTokenNeedsTxV3Support, -} from "../../../network/txv3" - -const FEE_TOKEN_PREFERENCE_BY_SYMBOL = ["STRK", "ETH"] + ApiTokenInfo, + ApiTokensInfoResponse, + TokenInfoByNetwork, + apiTokensInfoResponseSchema, +} from "../types/tokenInfo.model" +import { RefreshInterval } from "../../../config" +import retry from "async-retry" +import { getDefaultNetworkId } from "../../../network/utils" +import { ARGENT_API_BASE_URL } from "../../../api/constants" +import { argentApiNetworkForNetwork } from "../../../api/headers" +import urlJoin from "url-join" +import { ProvisionActivityPayload } from "../../../activity/types" +import vSTRK from "../../../../assets/vSTRK.json" +import { hasDelegationActivity } from "../../../activity/utils/hasDelegationActivity" /** * TokenService class implements ITokenService interface. @@ -58,6 +69,8 @@ export class TokenService implements ITokenService { private readonly tokenRepo: ITokenRepository, private readonly tokenBalanceRepo: ITokenBalanceRepository, private readonly tokenPriceRepo: ITokenPriceRepository, + private readonly tokenInfoStore: IObjectStore, + private readonly httpService: IHttpService, TOKENS_INFO_URL: string | undefined, TOKENS_PRICES_URL: string | undefined, ) { @@ -120,60 +133,52 @@ export class TokenService implements ITokenService { } /** - * Fetch tokens from the backend. + * Lazy fetch tokens info from local storage or backend max RefreshInterval.VERY_SLOW * @param {string} networkId - The network id. - * @returns {Promise} - The fetched tokens. + * @returns {Promise} - The fetched tokens or undefined if there was an error or not default network */ - async fetchTokensFromBackend(networkId: string): Promise { + async getTokensInfoFromBackendForNetwork( + networkId: string, + ): Promise { + /** the backend currently only returns token info for its specific network */ const isDefaultNetwork = defaultNetwork.id === networkId - - const tokensOnNetwork = await this.tokenRepo.get( - (t) => t.networkId === networkId, - ) if (!isDefaultNetwork) { - return tokensOnNetwork + return + } + const tokenInfoByNetwork = await this.tokenInfoStore.get() + + /** if we have data and updatedAt within RefreshInterval.VERY_SLOW, then return that data */ + if ( + tokenInfoByNetwork && + tokenInfoByNetwork[networkId] && + tokenInfoByNetwork[networkId].data + ) { + if ( + Date.now() - tokenInfoByNetwork[networkId].updatedAt < + RefreshInterval.SLOW * 1000 + ) { + return tokenInfoByNetwork[networkId].data + } } - // Prepare a map to avoid find operation - const tokenMap = new Map( - tokensOnNetwork.map((t) => [getAccountIdentifier(t), t]), + /** fetch data and check it's valid format */ + const response = await this.httpService.get( + this.TOKENS_INFO_URL, ) - - const fetcher = fetcherWithArgentApiHeadersForNetwork(networkId) - const response = await fetcher(this.TOKENS_INFO_URL) - const parsedResponse = ApiTokenDataResponseSchema.safeParse(response) - + const parsedResponse = apiTokensInfoResponseSchema.safeParse(response) if (!parsedResponse.success) { - throw new TokenError({ code: "TOKEN_PARSING_ERROR" }) + return } - const tokens = parsedResponse.data.tokens - .filter( - (t) => - t.popular || - tokensOnNetwork.some((tn) => equalToken(tn, { ...t, networkId })), - ) - .map((token) => { - const cached = tokenMap.get( - getAccountIdentifier({ address: token.address, networkId }), - ) - return { - id: token.id, - address: token.address, - decimals: token.decimals || cached?.decimals || 18, - name: token.name, - symbol: token.symbol, - iconUrl: token.iconUrl || cached?.iconUrl, - pricingId: token.pricingId || cached?.pricingId, - showAlways: cached?.showAlways, - networkId, - custom: cached?.custom, - popular: token.popular || cached?.popular, - tradable: token.tradable || cached?.tradable, - } - }) - - return tokens + /** store and update the updatedAt timestamp */ + const data = parsedResponse.data.tokens + await this.tokenInfoStore.set({ + [networkId]: { + updatedAt: Date.now(), + data, + }, + }) + return data } /** @@ -203,22 +208,27 @@ export class TokenService implements ITokenService { const tokenBalances: BaseTokenWithBalance[] = [] for (const networkId in accountsGroupedByNetwork) { - const tokensOnCurrentNetwork = tokensGroupedByNetwork[networkId] // filter tokens based on networkId - const network = await this.networkService.getById(networkId) - if (network.multicallAddress) { - const balances = await this.fetchTokenBalancesWithMulticall( - network, - accountsGroupedByNetwork, - tokensOnCurrentNetwork, - ) - tokenBalances.push(...balances) - } else { - const balances = await this.fetchTokenBalancesWithoutMulticall( - network, - accountsArray, - tokensOnCurrentNetwork, - ) - tokenBalances.push(...balances) + try { + const tokensOnCurrentNetwork = tokensGroupedByNetwork[networkId] // filter tokens based on networkId + const network = await this.networkService.getById(networkId) + if (network.multicallAddress) { + const balances = await this.fetchTokenBalancesWithMulticall( + network, + accountsGroupedByNetwork, + tokensOnCurrentNetwork, + ) + tokenBalances.push(...balances) + } else { + const balances = await this.fetchTokenBalancesWithoutMulticall( + network, + accountsArray, + tokensOnCurrentNetwork, + ) + tokenBalances.push(...balances) + } + } catch (e) { + /** Catch error to be resilient to individual network failure */ + console.error(`fetchTokenBalancesFromOnChain error on ${networkId}`, e) } } return tokenBalances @@ -311,8 +321,7 @@ export class TokenService implements ITokenService { return tokenPrices } - const fetcher = fetcherWithArgentApiHeadersForNetwork(defaultNetwork.id) - const response = await fetcher(this.TOKENS_PRICES_URL) + const response = await this.httpService.get(this.TOKENS_PRICES_URL) const parsedResponse = ApiPriceDataResponseSchema.safeParse(response) if (!parsedResponse.success) { @@ -548,65 +557,83 @@ export class TokenService implements ITokenService { return totalCurrencyBalanceForAccounts } - async getFeeTokens( - account: BaseWalletAccount & Required>, - ): Promise { - const tokens = await this.getTokens() - const network = await this.networkService.getById(account.networkId) - const networkFeeTokens = tokens.filter((token) => - network.possibleFeeTokenAddresses.some((ft) => - isEqualAddress(ft, token.address), - ), - ) - const accountFeeTokens = networkFeeTokens.filter((token) => { - if (feeTokenNeedsTxV3Support(token)) { - return classHashSupportsTxV3(account.classHash) - } - return true - }) - const feeTokenBalances = await this.getTokenBalancesForAccount( - account, - accountFeeTokens, + async fetchAccountTokenBalancesFromBackend( + account: BaseWalletAccount, + opts?: retry.Options, + ): Promise { + const defaultNetworkId = getDefaultNetworkId() + /** This service only works for the default network */ + if (account.networkId !== defaultNetworkId) { + return [] + } + const apiBaseUrl = ARGENT_API_BASE_URL + const argentApiNetwork = argentApiNetworkForNetwork(account.networkId) + if (!argentApiNetwork) { + return [] + } + + const url = urlJoin( + apiBaseUrl, + "activity", + "starknet", + argentApiNetwork, + "account", + stripAddressZeroPadding(account.address), + "balance", ) - const feeTokensWithBalances: TokenWithBalance[] = accountFeeTokens.map( - (token) => { - const tokenBalance = feeTokenBalances.find((tb) => - equalToken(tb, token), - ) ?? { - balance: "0", - account: { address: account.address, networkId: account.networkId }, + + /** retry until status is "initialised" */ + const accountTokenBalances = await retry( + async (bail) => { + let response + try { + response = await this.httpService.get(url) + } catch (e) { + /** bail without retry if there is any fetching error */ + bail(new Error("Error fetching")) + return [] } - return { - ...token, - ...tokenBalance, + const parsedRespose = apiAccountTokenBalancesSchema.safeParse(response) + if (!parsedRespose.success) { + bail(new Error("Error parsing response")) + return [] } + if (parsedRespose.data.status !== "initialised") { + /** causes a retry */ + throw new Error("Not initialised yet") + } + return parsedRespose.data.balances + }, + { + /** seems to take 5-10 sec for initialised state */ + retries: 5, + minTimeout: 5000, + ...opts, }, ) - // sort by fee token preference defined in FEE_TOKEN_PREFERENCE_BY_SYMBOL - return feeTokensWithBalances.sort((a, b) => { - const [aIndex, bIndex] = [a, b].map((token) => - FEE_TOKEN_PREFERENCE_BY_SYMBOL.indexOf(token.symbol), - ) - return aIndex === -1 ? 1 : bIndex === -1 ? -1 : aIndex - bIndex - }) + + const baseTokenWithBalances: BaseTokenWithBalance[] = + accountTokenBalances.map((accountTokenBalance) => { + return { + address: accountTokenBalance.tokenAddress, + balance: accountTokenBalance.tokenBalance, + networkId: account.networkId, + account, + } + }) + + return baseTokenWithBalances } - async getBestFeeToken( - account: BaseWalletAccount & Required>, - ): Promise { - const possibleFeeTokenWithBalances = await this.getFeeTokens(account) + async handleProvisionTokens(payload: ProvisionActivityPayload) { + const hasDelegation = hasDelegationActivity(payload.activity) - if (possibleFeeTokenWithBalances.length === 1) { - return possibleFeeTokenWithBalances[0] + if (hasDelegation) { + void this.addToken({ + ...vSTRK, + networkId: payload.account.networkId, + address: addressSchema.parse(vSTRK.address), + }) } - - // we expect the fee tokens to be sorted by preference - // so we return the first one that has a balance - // if none have a balance, we return the first one (prefered one) - return ( - possibleFeeTokenWithBalances.find( - (token) => bigDecimal.parseCurrency(token.balance).value > 0n, - ) ?? possibleFeeTokenWithBalances[0] - ) } } diff --git a/packages/extension/src/shared/token/__new/service/index.ts b/packages/extension/src/shared/token/__new/service/index.ts index 4765893c1..8c318db89 100644 --- a/packages/extension/src/shared/token/__new/service/index.ts +++ b/packages/extension/src/shared/token/__new/service/index.ts @@ -2,9 +2,11 @@ import { ARGENT_API_TOKENS_INFO_URL, ARGENT_API_TOKENS_PRICES_URL, } from "../../../api/constants" +import { httpService } from "../../../http/singleton" import { networkService } from "../../../network/service" import { tokenRepo } from "../repository/token" import { tokenBalanceRepo } from "../repository/tokenBalance" +import { tokenInfoStore } from "../repository/tokenInfo" import { tokenPriceRepo } from "../repository/tokenPrice" import { TokenService } from "./implementation" @@ -13,6 +15,8 @@ export const tokenService = new TokenService( tokenRepo, tokenBalanceRepo, tokenPriceRepo, + tokenInfoStore, + httpService, ARGENT_API_TOKENS_INFO_URL, ARGENT_API_TOKENS_PRICES_URL, ) diff --git a/packages/extension/src/shared/token/__new/service/interface.ts b/packages/extension/src/shared/token/__new/service/interface.ts index b2d69430a..5eeda84e2 100644 --- a/packages/extension/src/shared/token/__new/service/interface.ts +++ b/packages/extension/src/shared/token/__new/service/interface.ts @@ -1,7 +1,9 @@ +import { ProvisionActivityPayload } from "../../../activity/types" import { AllowArray, SelectorFn } from "../../../storage/__new/interface" -import { BaseWalletAccount, WalletAccount } from "../../../wallet.model" +import { BaseWalletAccount } from "../../../wallet.model" import { BaseToken, Token } from "../types/token.model" import { BaseTokenWithBalance } from "../types/tokenBalance.model" +import { ApiTokenInfo } from "../types/tokenInfo.model" import { TokenPriceDetails, TokenWithBalanceAndPrice, @@ -26,15 +28,15 @@ export interface ITokenService { tokensWithBalance: AllowArray, ): Promise updateTokenPrices(tokenPrices: AllowArray): Promise - + handleProvisionTokens: (payload: ProvisionActivityPayload) => Promise /** * Fetch methods - These methods fetch data from backend or chain - * fetchTokensFromBackend: Fetch a list of tokens from the backend using networkId * fetchTokenBalancesFromOnChain: Fetch balances of specified tokens from on-chain for given accounts * fetchTokenPricesFromBackend: Fetch prices of specified tokens from the backend * fetchTokenDetails: Fetch details of specified tokens from on-chain + * fetchAccountTokenBalancesFromBackend: Fetch list of tokens and balances for given account from backend + * getTokensInfoFromBackendForNetwork: Lazy fetch tokens info from local storage or backend max RefreshInterval.VERY_SLOW */ - fetchTokensFromBackend: (networkId: string) => Promise fetchTokenBalancesFromOnChain: ( accounts: AllowArray, tokens?: AllowArray, @@ -44,6 +46,12 @@ export interface ITokenService { networkId: string, ) => Promise fetchTokenDetails: (baseToken: BaseToken) => Promise + fetchAccountTokenBalancesFromBackend: ( + account: BaseWalletAccount, + ) => Promise + getTokensInfoFromBackendForNetwork( + networkId: string, + ): Promise /** * Get methods - These methods retrieve data from local storage or perform calculations @@ -66,11 +74,4 @@ export interface ITokenService { getTotalCurrencyBalanceForAccounts: ( accounts: BaseWalletAccount[], ) => Promise<{ [key: string]: string }> - - getFeeTokens: ( - account: BaseWalletAccount & Required>, - ) => Promise - getBestFeeToken: ( - account: BaseWalletAccount & Required>, - ) => Promise } diff --git a/packages/extension/src/shared/token/__new/types/token.model.ts b/packages/extension/src/shared/token/__new/types/token.model.ts index 8408e2c78..414c38203 100644 --- a/packages/extension/src/shared/token/__new/types/token.model.ts +++ b/packages/extension/src/shared/token/__new/types/token.model.ts @@ -1,4 +1,4 @@ -import { addressSchema } from "@argent/shared" +import { addressSchema, addressSchemaArgentBackend } from "@argent/shared" import { z } from "zod" export const BaseTokenSchema = z.object( @@ -33,28 +33,22 @@ export const TokenSchema = RequestTokenSchema.required().extend({ export type Token = z.infer -export const ApiTokenDetailsSchema = z.object({ - id: z.number(), - address: addressSchema, - name: z.string(), - symbol: z.string(), - decimals: z.number(), - iconUrl: z.string().optional(), - sendable: z.boolean(), - popular: z.boolean(), - refundable: z.boolean(), - listed: z.boolean(), - tradable: z.boolean(), - category: z.union([ - z.literal("tokens"), - z.literal("currencies"), - z.literal("savings"), - ]), - pricingId: z.number().optional(), -}) - -export type ApiTokenDetails = z.infer -export const ApiTokenDataResponseSchema = z.object({ - tokens: z.array(ApiTokenDetailsSchema), -}) -export type ApiTokenDataResponse = z.infer +export const apiAccountTokenBalancesSchema = z + .object({ + status: z.literal("initialising"), + }) + .or( + z.object({ + status: z.literal("initialised"), + balances: z.array( + z.object({ + tokenAddress: addressSchemaArgentBackend, + tokenBalance: z.string(), + }), + ), + }), + ) + +export type ApiAccountTokenBalances = z.infer< + typeof apiAccountTokenBalancesSchema +> diff --git a/packages/extension/src/shared/token/__new/types/tokenInfo.model.ts b/packages/extension/src/shared/token/__new/types/tokenInfo.model.ts new file mode 100644 index 000000000..fd17c369e --- /dev/null +++ b/packages/extension/src/shared/token/__new/types/tokenInfo.model.ts @@ -0,0 +1,37 @@ +import { addressSchema } from "@argent/shared" +import { z } from "zod" + +export const apiTokenInfoSchema = z.object({ + id: z.number(), + address: addressSchema, + name: z.string(), + symbol: z.string(), + decimals: z.number(), + iconUrl: z.string().optional(), + sendable: z.boolean(), + popular: z.boolean(), + refundable: z.boolean(), + listed: z.boolean(), + tradable: z.boolean(), + category: z.union([ + z.literal("tokens"), + z.literal("currencies"), + z.literal("savings"), + ]), + pricingId: z.number().optional(), +}) + +export type ApiTokenInfo = z.infer + +export const apiTokensInfoResponseSchema = z.object({ + tokens: z.array(apiTokenInfoSchema), +}) + +export type ApiTokensInfoResponse = z.infer + +export const tokenInfoByNetworkSchema = z.record( + z.string(), + z.object({ updatedAt: z.number(), data: z.array(apiTokenInfoSchema) }), +) + +export type TokenInfoByNetwork = z.infer diff --git a/packages/extension/src/shared/token/__new/utils/index.ts b/packages/extension/src/shared/token/__new/utils/index.ts index b1cf781e0..256cce638 100644 --- a/packages/extension/src/shared/token/__new/utils/index.ts +++ b/packages/extension/src/shared/token/__new/utils/index.ts @@ -3,13 +3,18 @@ import { bigDecimal, isEqualAddress, isNumeric, + DEFAULT_TOKEN_DECIMALS, } from "@argent/shared" import { BaseToken, Token } from "../types/token.model" import { BigNumberish } from "starknet" import defaultTokens from "../../../../assets/default-tokens.json" -export const equalToken = (a: BaseToken, b: BaseToken) => - a.networkId === b.networkId && isEqualAddress(a.address, b.address) +export const equalToken = (a?: BaseToken, b?: BaseToken) => { + if (!a || !b) { + return false + } + return a.networkId === b.networkId && isEqualAddress(a.address, b.address) +} export interface IConvertTokenAmountToCurrencyValue { /** the token decimal amount */ @@ -43,12 +48,13 @@ export const convertTokenAmountToCurrencyValue = ({ /** multiply to convert to currency */ const currencyValue = BigInt(amount) * - bigDecimal.parseUnits(unitCurrencyValue.toString(), 6).value + bigDecimal.parseUnits(unitCurrencyValue.toString(), DEFAULT_TOKEN_DECIMALS) + .value /** keep as string to avoid loss of precision elsewhere */ return bigDecimal.formatUnits({ value: currencyValue, - decimals: decimalsNumber + 6, + decimals: decimalsNumber + DEFAULT_TOKEN_DECIMALS, }) } diff --git a/packages/extension/src/shared/token/price.ts b/packages/extension/src/shared/token/price.ts index 9e1d6f456..cd0e5b9d4 100644 --- a/packages/extension/src/shared/token/price.ts +++ b/packages/extension/src/shared/token/price.ts @@ -6,7 +6,7 @@ import { prettifyCurrencyNumber, prettifyTokenNumber, } from "../utils/number" -import { bigDecimal } from "@argent/shared" +import { bigDecimal, DEFAULT_TOKEN_DECIMALS } from "@argent/shared" import { TokenPriceDetails } from "./__new/types/tokenPrice.model" import { BaseToken, Token } from "./__new/types/token.model" import { equalToken } from "./__new/utils" @@ -163,7 +163,7 @@ export const convertTokenAmountToCurrencyValue = ({ /** keep as string to avoid loss of precision elsewhere */ return bigDecimal.formatUnits({ value: currencyValue, - decimals: decimalsNumber + 6, + decimals: decimalsNumber + DEFAULT_TOKEN_DECIMALS, }) } diff --git a/packages/extension/src/shared/transactionReview.service.ts b/packages/extension/src/shared/transactionReview.service.ts index e54d35c15..dd93c3e1f 100644 --- a/packages/extension/src/shared/transactionReview.service.ts +++ b/packages/extension/src/shared/transactionReview.service.ts @@ -1,4 +1,4 @@ -import { Address } from "@argent/shared" +import { Address, ArgentBackendNetworkId } from "@argent/shared" import { isArray, lowerCase } from "lodash-es" import { Call } from "starknet" @@ -112,11 +112,10 @@ export interface ApiTransactionReview { } export type ApiTransactionReviewNetwork = - | "mainnet" + | ArgentBackendNetworkId | "morden" | "ropsten" | "rinkeby" - | "goerli" | "kovan" export interface ApiTransactionReviewRequestBody { diff --git a/packages/storybook/src/features/actions/transactionV2/__fixtures__/fixtures.sh b/packages/extension/src/shared/transactionReview/__fixtures__/fixtures.sh similarity index 90% rename from packages/storybook/src/features/actions/transactionV2/__fixtures__/fixtures.sh rename to packages/extension/src/shared/transactionReview/__fixtures__/fixtures.sh index a9b0fdcdb..60ff333b1 100755 --- a/packages/storybook/src/features/actions/transactionV2/__fixtures__/fixtures.sh +++ b/packages/extension/src/shared/transactionReview/__fixtures__/fixtures.sh @@ -2,7 +2,7 @@ # Send -curl 'http://fraud-monitor-v2-hydrogen.eu-west-1.elasticbeanstalk.com/transactions/v2/review/starknet' \ +curl 'https://cloud.argent-api.com/v1/reviewer/transactions/v2/review/starknet' \ -H 'Accept: application/json' \ -H 'Accept-Language: en-GB,en-US;q=0.9,en;q=0.8' \ -H 'Connection: keep-alive' \ @@ -16,7 +16,7 @@ curl 'http://fraud-monitor-v2-hydrogen.eu-west-1.elasticbeanstalk.com/transactio # Send NFT -curl 'http://fraud-monitor-v2-hydrogen.eu-west-1.elasticbeanstalk.com/transactions/v2/review/starknet' \ +curl 'https://cloud.argent-api.com/v1/reviewer/transactions/v2/review/starknet' \ -H 'Accept: application/json' \ -H 'Accept-Language: en-GB,en-US;q=0.9,en;q=0.8' \ -H 'Connection: keep-alive' \ @@ -30,7 +30,7 @@ curl 'http://fraud-monitor-v2-hydrogen.eu-west-1.elasticbeanstalk.com/transactio # Send NFT to Self -curl 'http://fraud-monitor-v2-hydrogen.eu-west-1.elasticbeanstalk.com/transactions/v2/review/starknet' \ +curl 'https://cloud.argent-api.com/v1/reviewer/transactions/v2/review/starknet' \ -H 'Accept: application/json' \ -H 'Accept-Language: en-GB,en-US;q=0.9,en;q=0.8' \ -H 'Connection: keep-alive' \ @@ -44,7 +44,7 @@ curl 'http://fraud-monitor-v2-hydrogen.eu-west-1.elasticbeanstalk.com/transactio # Mint NFT -curl 'http://fraud-monitor-v2-hydrogen.eu-west-1.elasticbeanstalk.com/transactions/v2/review/starknet' \ +curl 'https://cloud.argent-api.com/v1/reviewer/transactions/v2/review/starknet' \ -H 'Accept: application/json' \ -H 'Accept-Language: en-GB,en-US;q=0.9,en;q=0.8' \ -H 'Connection: keep-alive' \ @@ -58,7 +58,7 @@ curl 'http://fraud-monitor-v2-hydrogen.eu-west-1.elasticbeanstalk.com/transactio # Swap -curl 'http://fraud-monitor-v2-hydrogen.eu-west-1.elasticbeanstalk.com/transactions/v2/review/starknet' \ +curl 'https://cloud.argent-api.com/v1/reviewer/transactions/v2/review/starknet' \ -H 'Accept: application/json' \ -H 'Accept-Language: en-GB,en-US;q=0.9,en;q=0.8' \ -H 'Connection: keep-alive' \ @@ -72,7 +72,7 @@ curl 'http://fraud-monitor-v2-hydrogen.eu-west-1.elasticbeanstalk.com/transactio # Upgrade -curl 'http://fraud-monitor-v2-hydrogen.eu-west-1.elasticbeanstalk.com/transactions/v2/review/starknet' \ +curl 'https://cloud.argent-api.com/v1/reviewer/transactions/v2/review/starknet' \ -H 'Accept: application/json' \ -H 'Referer;' \ -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36' \ @@ -83,7 +83,7 @@ curl 'http://fraud-monitor-v2-hydrogen.eu-west-1.elasticbeanstalk.com/transactio # Add Shield -curl 'http://fraud-monitor-v2-hydrogen.eu-west-1.elasticbeanstalk.com/transactions/v2/review/starknet' \ +curl 'https://cloud.argent-api.com/v1/reviewer/transactions/v2/review/starknet' \ -H 'Accept: application/json' \ -H 'Accept-Language: en-GB,en-US;q=0.9,en;q=0.8' \ -H 'Connection: keep-alive' \ @@ -97,7 +97,7 @@ curl 'http://fraud-monitor-v2-hydrogen.eu-west-1.elasticbeanstalk.com/transactio # Remove Shield -curl 'http://fraud-monitor-v2-hydrogen.eu-west-1.elasticbeanstalk.com/transactions/v2/review/starknet' \ +curl 'https://cloud.argent-api.com/v1/reviewer/transactions/v2/review/starknet' \ -H 'Accept: application/json' \ -H 'Referer;' \ -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36' \ @@ -108,7 +108,7 @@ curl 'http://fraud-monitor-v2-hydrogen.eu-west-1.elasticbeanstalk.com/transactio # Keep Shield -curl 'http://fraud-monitor-v2-hydrogen.eu-west-1.elasticbeanstalk.com/transactions/v2/review/starknet' \ +curl 'https://cloud.argent-api.com/v1/reviewer/transactions/v2/review/starknet' \ -H 'Accept: application/json' \ -H 'Referer;' \ -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36' \ @@ -119,7 +119,7 @@ curl 'http://fraud-monitor-v2-hydrogen.eu-west-1.elasticbeanstalk.com/transactio # Multisig add signer -curl 'http://fraud-monitor-v2-hydrogen.eu-west-1.elasticbeanstalk.com/transactions/v2/review/starknet' \ +curl 'https://cloud.argent-api.com/v1/reviewer/transactions/v2/review/starknet' \ -H 'Accept: application/json' \ -H 'Referer;' \ -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36' \ @@ -130,7 +130,7 @@ curl 'http://fraud-monitor-v2-hydrogen.eu-west-1.elasticbeanstalk.com/transactio # Multisig change threshold -curl 'http://fraud-monitor-v2-hydrogen.eu-west-1.elasticbeanstalk.com/transactions/v2/review/starknet' \ +curl 'https://cloud.argent-api.com/v1/reviewer/transactions/v2/review/starknet' \ -H 'Accept: application/json' \ -H 'Referer;' \ -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36' \ @@ -141,7 +141,7 @@ curl 'http://fraud-monitor-v2-hydrogen.eu-west-1.elasticbeanstalk.com/transactio # JediSwap non-native swap -curl 'http://fraud-monitor-v2-hydrogen.eu-west-1.elasticbeanstalk.com/transactions/v2/review/starknet' \ +curl 'https://cloud.argent-api.com/v1/reviewer/transactions/v2/review/starknet' \ -H 'Accept: application/json' \ -H 'Accept-Language: en-GB,en-US;q=0.9,en;q=0.8' \ -H 'Connection: keep-alive' \ diff --git a/packages/storybook/src/features/actions/transactionV2/__fixtures__/mint-nft.json b/packages/extension/src/shared/transactionReview/__fixtures__/mint-nft.json similarity index 92% rename from packages/storybook/src/features/actions/transactionV2/__fixtures__/mint-nft.json rename to packages/extension/src/shared/transactionReview/__fixtures__/mint-nft.json index b46725b1c..dda6431aa 100644 --- a/packages/storybook/src/features/actions/transactionV2/__fixtures__/mint-nft.json +++ b/packages/extension/src/shared/transactionReview/__fixtures__/mint-nft.json @@ -73,12 +73,13 @@ "sent": false } ], - "calculatedNonce": "0xa", + "calculatedNonce": "0xb", "feeEstimation": { - "overallFee": "7420000089040", - "gasPrice": "1000000012", - "gasUsage": "7420", - "unit": "wei" + "overallFee": "5786000052074", + "gasPrice": "1000000009", + "gasUsage": "5786", + "unit": "WEI", + "maxFee": "11571998054700" } } } diff --git a/packages/storybook/src/features/actions/transactionV2/__fixtures__/multisig-add.json b/packages/extension/src/shared/transactionReview/__fixtures__/multisig-add.json similarity index 82% rename from packages/storybook/src/features/actions/transactionV2/__fixtures__/multisig-add.json rename to packages/extension/src/shared/transactionReview/__fixtures__/multisig-add.json index c236d72eb..3e9ec8ad4 100644 --- a/packages/storybook/src/features/actions/transactionV2/__fixtures__/multisig-add.json +++ b/packages/extension/src/shared/transactionReview/__fixtures__/multisig-add.json @@ -49,6 +49,19 @@ } } ] + }, + "simulation": { + "approvals": [], + "transfers": [], + "summary": [], + "calculatedNonce": "0x1", + "feeEstimation": { + "overallFee": "2788000025092", + "gasPrice": "1000000009", + "gasUsage": "2788", + "unit": "WEI", + "maxFee": "5575993939010" + } } } ] diff --git a/packages/storybook/src/features/actions/transactionV2/__fixtures__/multisig-change.json b/packages/extension/src/shared/transactionReview/__fixtures__/multisig-change.json similarity index 75% rename from packages/storybook/src/features/actions/transactionV2/__fixtures__/multisig-change.json rename to packages/extension/src/shared/transactionReview/__fixtures__/multisig-change.json index e7fae1df4..8d2dffb2f 100644 --- a/packages/storybook/src/features/actions/transactionV2/__fixtures__/multisig-change.json +++ b/packages/extension/src/shared/transactionReview/__fixtures__/multisig-change.json @@ -34,6 +34,19 @@ } } ] + }, + "simulation": { + "approvals": [], + "transfers": [], + "summary": [], + "calculatedNonce": "0x1", + "feeEstimation": { + "overallFee": "1674000015066", + "gasPrice": "1000000009", + "gasUsage": "1674", + "unit": "WEI", + "maxFee": "3348001389580" + } } } ] diff --git a/packages/storybook/src/features/actions/transactionV2/__fixtures__/non-native-jediswap.json b/packages/extension/src/shared/transactionReview/__fixtures__/non-native-jediswap.json similarity index 61% rename from packages/storybook/src/features/actions/transactionV2/__fixtures__/non-native-jediswap.json rename to packages/extension/src/shared/transactionReview/__fixtures__/non-native-jediswap.json index 99a6ee863..8b76f0c59 100644 --- a/packages/storybook/src/features/actions/transactionV2/__fixtures__/non-native-jediswap.json +++ b/packages/extension/src/shared/transactionReview/__fixtures__/non-native-jediswap.json @@ -2,8 +2,14 @@ "transactions": [ { "reviewOfTransaction": { - "assessment": "neutral", - "warnings": [], + "assessment": "warn", + "warnings": [ + { + "reason": "amount_mismatch_too_low", + "details": {}, + "severity": "caution" + } + ], "targetedDapp": { "name": "JediSwap", "description": "A community-led fully permissionless and composable AMM on Starknet.", @@ -48,7 +54,7 @@ "type": "ERC20" }, "amount": "10771066479645102", - "usd": "19.20", + "usd": "24.08", "editable": true }, { @@ -87,8 +93,14 @@ } }, { - "assessment": "neutral", - "warnings": [], + "assessment": "warn", + "warnings": [ + { + "reason": "amount_mismatch_too_low", + "details": {}, + "severity": "caution" + } + ], "action": { "name": "Jediswap_swap", "properties": [ @@ -105,7 +117,7 @@ "type": "ERC20" }, "amount": "10771066479645102", - "usd": "19.20", + "usd": "24.08", "editable": false }, { @@ -166,98 +178,16 @@ ] }, "simulation": { - "approvals": [ - { - "tokenAddress": "0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", - "owner": "0x5f1f0a38429dcab9ffd8a786c0d827e84c1cbd8f60243e6d25d066a13af4a25", - "spender": "0x2bcc885342ebbcbcd170ae6cafa8a4bed22bb993479f49806e72d96af94c965", - "value": "10771066479645102", - "usdValue": "19.20", - "approvalForAll": false, - "details": { - "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", - "name": "Ether", - "symbol": "ETH", - "decimals": 18, - "unknown": false, - "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", - "type": "ERC20" - } - } - ], - "transfers": [ - { - "tokenAddress": "0x3e85bfbb8e2a42b7bead9e88e9a1b19dbccf661471061807292120462396ec9", - "from": "0xaa77901620bdae04ffe72235a50f53c02c4bdfccbe00e86f99ae6373064d3c", - "to": "0x5f1f0a38429dcab9ffd8a786c0d827e84c1cbd8f60243e6d25d066a13af4a25", - "value": "11529021482052226945", - "usdValue": "11.53", - "details": { - "address": "0x03e85bfbb8e2a42b7bead9e88e9a1b19dbccf661471061807292120462396ec9", - "name": "Dai Stablecoin", - "symbol": "DAI", - "decimals": 18, - "unknown": false, - "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/dai.png", - "type": "ERC20" - } - }, - { - "tokenAddress": "0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", - "from": "0x5f1f0a38429dcab9ffd8a786c0d827e84c1cbd8f60243e6d25d066a13af4a25", - "to": "0xaa77901620bdae04ffe72235a50f53c02c4bdfccbe00e86f99ae6373064d3c", - "value": "10771066479645102", - "usdValue": "19.20", - "details": { - "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", - "name": "Ether", - "symbol": "ETH", - "decimals": 18, - "unknown": false, - "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", - "type": "ERC20" - } - } - ], - "summary": [ - { - "type": "transfer", - "label": "simulation_summary_send", - "value": "10771066479645102", - "usdValue": "19.20", - "token": { - "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", - "name": "Ether", - "symbol": "ETH", - "decimals": 18, - "unknown": false, - "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", - "type": "ERC20" - }, - "sent": true - }, - { - "type": "transfer", - "label": "simulation_summary_receive", - "value": "11529021482052226945", - "usdValue": "11.53", - "token": { - "address": "0x03e85bfbb8e2a42b7bead9e88e9a1b19dbccf661471061807292120462396ec9", - "name": "Dai Stablecoin", - "symbol": "DAI", - "decimals": 18, - "unknown": false, - "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/dai.png", - "type": "ERC20" - }, - "sent": false - } - ], + "approvals": [], + "transfers": [], + "summary": [], + "calculatedNonce": "0x34", "feeEstimation": { - "overallFee": 13803004140900, - "gasPrice": 1000000300, - "gasUsage": 13803, - "unit": "wei" + "overallFee": "1678000015102", + "gasPrice": "1000000009", + "gasUsage": "1678", + "unit": "WEI", + "maxFee": "3355997170495" } } } diff --git a/packages/storybook/src/features/actions/transactionV2/__fixtures__/send-nft-self.json b/packages/extension/src/shared/transactionReview/__fixtures__/send-nft-self.json similarity index 89% rename from packages/storybook/src/features/actions/transactionV2/__fixtures__/send-nft-self.json rename to packages/extension/src/shared/transactionReview/__fixtures__/send-nft-self.json index 45101df7f..9e7a828a2 100644 --- a/packages/storybook/src/features/actions/transactionV2/__fixtures__/send-nft-self.json +++ b/packages/extension/src/shared/transactionReview/__fixtures__/send-nft-self.json @@ -2,24 +2,12 @@ "transactions": [ { "reviewOfTransaction": { - "assessment": "warn", - "warnings": [ - { - "reason": "undeployed_account", - "details": {}, - "severity": "info" - } - ], + "assessment": "neutral", + "warnings": [], "reviews": [ { - "assessment": "warn", - "warnings": [ - { - "reason": "undeployed_account", - "details": {}, - "severity": "info" - } - ], + "assessment": "neutral", + "warnings": [], "action": { "name": "ERC721_transferFrom", "properties": [ @@ -144,12 +132,13 @@ "sent": false } ], - "calculatedNonce": "0x11", + "calculatedNonce": "0x34", "feeEstimation": { - "overallFee": "2529000030348", - "gasPrice": "1000000012", - "gasUsage": "2529", - "unit": "wei" + "overallFee": "1693000015237", + "gasPrice": "1000000009", + "gasUsage": "1693", + "unit": "WEI", + "maxFee": "3386011280760" } } } diff --git a/packages/storybook/src/features/actions/transactionV2/__fixtures__/send-nft.json b/packages/extension/src/shared/transactionReview/__fixtures__/send-nft.json similarity index 87% rename from packages/storybook/src/features/actions/transactionV2/__fixtures__/send-nft.json rename to packages/extension/src/shared/transactionReview/__fixtures__/send-nft.json index 5f44a1a4e..47b50194b 100644 --- a/packages/storybook/src/features/actions/transactionV2/__fixtures__/send-nft.json +++ b/packages/extension/src/shared/transactionReview/__fixtures__/send-nft.json @@ -2,24 +2,12 @@ "transactions": [ { "reviewOfTransaction": { - "assessment": "warn", - "warnings": [ - { - "reason": "undeployed_account", - "details": {}, - "severity": "info" - } - ], + "assessment": "neutral", + "warnings": [], "reviews": [ { - "assessment": "warn", - "warnings": [ - { - "reason": "undeployed_account", - "details": {}, - "severity": "info" - } - ], + "assessment": "neutral", + "warnings": [], "action": { "name": "ERC721_transferFrom", "properties": [ @@ -120,12 +108,13 @@ "sent": true } ], - "calculatedNonce": "0x11", + "calculatedNonce": "0x34", "feeEstimation": { - "overallFee": "7425000089100", - "gasPrice": "1000000012", - "gasUsage": "7425", - "unit": "wei" + "overallFee": "5789000052101", + "gasPrice": "1000000009", + "gasUsage": "5789", + "unit": "WEI", + "maxFee": "11578007007733" } } } diff --git a/packages/extension/src/shared/transactionReview/__fixtures__/send.json b/packages/extension/src/shared/transactionReview/__fixtures__/send.json new file mode 100644 index 000000000..aee15e499 --- /dev/null +++ b/packages/extension/src/shared/transactionReview/__fixtures__/send.json @@ -0,0 +1,81 @@ +{ + "transactions": [ + { + "reviewOfTransaction": { + "assessment": "neutral", + "warnings": [], + "reviews": [ + { + "assessment": "neutral", + "warnings": [], + "action": { + "name": "ERC20_transfer", + "properties": [ + { + "type": "amount", + "label": "ERC20_transfer_amount", + "token": { + "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "name": "Ether", + "symbol": "ETH", + "decimals": 18, + "unknown": false, + "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", + "type": "ERC20" + }, + "amount": "1882970796924913", + "usd": "4.21", + "editable": false + }, + { + "type": "address", + "label": "ERC20_transfer_recipient", + "address": "0x00575b2948cbaa8ef2d24c62c5e5c3848a5950c0bbac4d260801b159d7806633", + "verified": false + } + ], + "defaultProperties": [ + { + "type": "token_address", + "label": "default_contract", + "token": { + "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "name": "Ether", + "symbol": "ETH", + "decimals": 18, + "unknown": false, + "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", + "type": "ERC20" + } + }, + { + "type": "calldata", + "label": "default_call", + "entrypoint": "transfer", + "calldata": [ + "154344866577359164344739446380090869279696317484211928595226592103047915059", + "1882970796924913", + "0" + ] + } + ] + } + } + ] + }, + "simulation": { + "approvals": [], + "transfers": [], + "summary": [], + "calculatedNonce": "0x12", + "feeEstimation": { + "overallFee": "1674000015066", + "gasPrice": "1000000009", + "gasUsage": "1674", + "unit": "WEI", + "maxFee": "3348008526928" + } + } + } + ] +} diff --git a/packages/storybook/src/features/actions/transactionV2/__fixtures__/shield-add.json b/packages/extension/src/shared/transactionReview/__fixtures__/shield-add.json similarity index 87% rename from packages/storybook/src/features/actions/transactionV2/__fixtures__/shield-add.json rename to packages/extension/src/shared/transactionReview/__fixtures__/shield-add.json index 3cfff2358..248327549 100644 --- a/packages/storybook/src/features/actions/transactionV2/__fixtures__/shield-add.json +++ b/packages/extension/src/shared/transactionReview/__fixtures__/shield-add.json @@ -42,12 +42,13 @@ "approvals": [], "transfers": [], "summary": [], - "calculatedNonce": "0x11", + "calculatedNonce": "0x34", "feeEstimation": { - "overallFee": "3733000044796", - "gasPrice": "1000000012", - "gasUsage": "3733", - "unit": "wei" + "overallFee": "2785000025065", + "gasPrice": "1000000009", + "gasUsage": "2785", + "unit": "WEI", + "maxFee": "5569996282936" } } } diff --git a/packages/storybook/src/features/actions/transactionV2/__fixtures__/shield-keep.json b/packages/extension/src/shared/transactionReview/__fixtures__/shield-keep.json similarity index 71% rename from packages/storybook/src/features/actions/transactionV2/__fixtures__/shield-keep.json rename to packages/extension/src/shared/transactionReview/__fixtures__/shield-keep.json index 30fa4ab43..cc5aeb400 100644 --- a/packages/storybook/src/features/actions/transactionV2/__fixtures__/shield-keep.json +++ b/packages/extension/src/shared/transactionReview/__fixtures__/shield-keep.json @@ -28,6 +28,19 @@ } } ] + }, + "simulation": { + "approvals": [], + "transfers": [], + "summary": [], + "calculatedNonce": "0x34", + "feeEstimation": { + "overallFee": "1673000015057", + "gasPrice": "1000000009", + "gasUsage": "1673", + "unit": "WEI", + "maxFee": "3346012090209" + } } } ] diff --git a/packages/storybook/src/features/actions/transactionV2/__fixtures__/shield-remove.json b/packages/extension/src/shared/transactionReview/__fixtures__/shield-remove.json similarity index 85% rename from packages/storybook/src/features/actions/transactionV2/__fixtures__/shield-remove.json rename to packages/extension/src/shared/transactionReview/__fixtures__/shield-remove.json index be86002c3..4e3a971ec 100644 --- a/packages/storybook/src/features/actions/transactionV2/__fixtures__/shield-remove.json +++ b/packages/extension/src/shared/transactionReview/__fixtures__/shield-remove.json @@ -39,12 +39,13 @@ "approvals": [], "transfers": [], "summary": [], - "calculatedNonce": "0x11", + "calculatedNonce": "0x34", "feeEstimation": { - "overallFee": "2510000030120", - "gasPrice": "1000000012", - "gasUsage": "2510", - "unit": "wei" + "overallFee": "1683000015147", + "gasPrice": "1000000009", + "gasUsage": "1683", + "unit": "WEI", + "maxFee": "3366002638612" } } } diff --git a/packages/extension/src/shared/transactionReview/__fixtures__/simulation-error-unexpected.json b/packages/extension/src/shared/transactionReview/__fixtures__/simulation-error-unexpected.json new file mode 100644 index 000000000..e3beb3a4e --- /dev/null +++ b/packages/extension/src/shared/transactionReview/__fixtures__/simulation-error-unexpected.json @@ -0,0 +1,44 @@ +{ + "transactions": [ + { + "reviewOfTransaction": { + "assessment": "neutral", + "warnings": [], + "reviews": [ + { + "assessment": "neutral", + "warnings": [], + "action": { + "name": "initiate_withdraw", + "properties": [], + "defaultProperties": [ + { + "type": "address", + "label": "default_contract", + "address": "0x073314940630fd6dcda0d772d4c972c4e0a9946bef9dabf4ef84eda8ef542b82", + "addressName": "StarkGate: ETH Bridge", + "verified": false + }, + { + "type": "calldata", + "label": "default_call", + "entrypoint": "initiate_withdraw", + "calldata": [ + "672615209863731673604883528732489532497378066027", + "1000000000000000", + "0" + ] + } + ] + } + } + ] + }, + "simulationError": { + "label": "transaction_unknown_error", + "code": -1, + "message": "Encountered an unknown key 'order'.\nUse 'ignoreUnknownKeys = true' in 'Json {}' builder to ignore unknown keys.\nCurrent input: .....],\"to_address\":\"0xae0ee0a63a2ce6baeeffe56e7714fb4efe48d419\"}" + } + } + ] +} diff --git a/packages/extension/src/shared/transactionReview/__fixtures__/simulation.json b/packages/extension/src/shared/transactionReview/__fixtures__/simulation.json index 117deb933..00b35ea5f 100644 --- a/packages/extension/src/shared/transactionReview/__fixtures__/simulation.json +++ b/packages/extension/src/shared/transactionReview/__fixtures__/simulation.json @@ -130,7 +130,7 @@ "overallFee": "2520000100800", "gasPrice": "1000000040", "gasUsage": "2520", - "unit": "wei", + "unit": "WEI", "maxFee": "5040003826577" } } diff --git a/packages/storybook/src/features/actions/transactionV2/__fixtures__/swap.json b/packages/extension/src/shared/transactionReview/__fixtures__/swap.json similarity index 71% rename from packages/storybook/src/features/actions/transactionV2/__fixtures__/swap.json rename to packages/extension/src/shared/transactionReview/__fixtures__/swap.json index 45aa3b408..c540b566f 100644 --- a/packages/storybook/src/features/actions/transactionV2/__fixtures__/swap.json +++ b/packages/extension/src/shared/transactionReview/__fixtures__/swap.json @@ -7,7 +7,7 @@ { "reason": "amount_mismatch_too_high", "details": {}, - "severity": "caution" + "severity": "info" } ], "targetedDapp": { @@ -54,7 +54,7 @@ "type": "ERC20" }, "amount": "9771066479645102", - "usd": "17.42", + "usd": "21.85", "editable": true }, { @@ -98,7 +98,7 @@ { "reason": "amount_mismatch_too_high", "details": {}, - "severity": "caution" + "severity": "info" } ], "action": { @@ -117,7 +117,7 @@ "type": "ERC20" }, "amount": "9771066479645102", - "usd": "17.42", + "usd": "21.85", "editable": false }, { @@ -133,7 +133,7 @@ "type": "ERC20" }, "amount": "329694585016", - "usd": "329754.59", + "usd": "329724.92", "editable": false }, { @@ -178,66 +178,16 @@ ] }, "simulation": { - "approvals": [ - { - "tokenAddress": "0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", - "owner": "0x5f1f0a38429dcab9ffd8a786c0d827e84c1cbd8f60243e6d25d066a13af4a25", - "spender": "0x2bcc885342ebbcbcd170ae6cafa8a4bed22bb993479f49806e72d96af94c965", - "value": "9771066479645102", - "usdValue": "17.42", - "approvalForAll": false, - "details": { - "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", - "name": "Ether", - "symbol": "ETH", - "decimals": 18, - "unknown": false, - "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", - "type": "ERC20" - } - } - ], - "transfers": [ - { - "tokenAddress": "0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", - "from": "0x5f1f0a38429dcab9ffd8a786c0d827e84c1cbd8f60243e6d25d066a13af4a25", - "to": "0x5a2b2b37f66157f767ea711cb4e034c40d41f2f5acf9ff4a19049fa11c1a884", - "value": "9771066479645102", - "usdValue": "17.42", - "details": { - "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", - "name": "Ether", - "symbol": "ETH", - "decimals": 18, - "unknown": false, - "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", - "type": "ERC20" - } - } - ], - "summary": [ - { - "type": "transfer", - "label": "simulation_summary_send", - "value": "9771066479645102", - "usdValue": "17.42", - "token": { - "address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", - "name": "Ether", - "symbol": "ETH", - "decimals": 18, - "unknown": false, - "iconUrl": "https://dv3jj1unlp2jl.cloudfront.net/128/color/eth.png", - "type": "ERC20" - }, - "sent": true - } - ], + "approvals": [], + "transfers": [], + "summary": [], + "calculatedNonce": "0x34", "feeEstimation": { - "overallFee": 13802004085392, - "gasPrice": 1000000296, - "gasUsage": 13802, - "unit": "wei" + "overallFee": "1678000015102", + "gasPrice": "1000000009", + "gasUsage": "1678", + "unit": "WEI", + "maxFee": "3356011345043" } } } diff --git a/packages/storybook/src/features/actions/transactionV2/__fixtures__/upgrade.json b/packages/extension/src/shared/transactionReview/__fixtures__/upgrade.json similarity index 87% rename from packages/storybook/src/features/actions/transactionV2/__fixtures__/upgrade.json rename to packages/extension/src/shared/transactionReview/__fixtures__/upgrade.json index ee3e44b08..79954ef0e 100644 --- a/packages/storybook/src/features/actions/transactionV2/__fixtures__/upgrade.json +++ b/packages/extension/src/shared/transactionReview/__fixtures__/upgrade.json @@ -31,6 +31,11 @@ } } ] + }, + "simulationError": { + "label": "transaction_unknown_error", + "code": -1, + "message": "Contract not found" } } ] diff --git a/packages/extension/src/shared/transactionReview/interface.ts b/packages/extension/src/shared/transactionReview/interface.ts index 6bb50dc32..63f543247 100644 --- a/packages/extension/src/shared/transactionReview/interface.ts +++ b/packages/extension/src/shared/transactionReview/interface.ts @@ -1,7 +1,7 @@ import { z } from "zod" import { EnrichedSimulateAndReview } from "./schema" -import { callSchema, hexSchema } from "@argent/shared" +import { Address, callSchema, hexSchema } from "@argent/shared" export const transactionReviewTransactionsSchema = z.object({ type: z @@ -21,8 +21,10 @@ export type TransactionReviewTransactions = z.infer< export interface ITransactionReviewService { simulateAndReview({ transactions, + feeTokenAddress, }: { transactions: TransactionReviewTransactions[] + feeTokenAddress: Address }): Promise getLabels(): Promise } diff --git a/packages/extension/src/shared/transactionReview/schema.test.ts b/packages/extension/src/shared/transactionReview/schema.test.ts index e86946994..a00cbb946 100644 --- a/packages/extension/src/shared/transactionReview/schema.test.ts +++ b/packages/extension/src/shared/transactionReview/schema.test.ts @@ -66,7 +66,8 @@ describe("transactionReview/schema", () => { test("returns true", () => { expect( isNotTransactionSimulationError( - simulationResponse.transactions[0] as TransactionReviewTransaction, + simulationResponse + .transactions[0] as unknown as TransactionReviewTransaction, ), ).toBeTruthy() }) @@ -97,7 +98,8 @@ describe("transactionReview/schema", () => { test("returns false", () => { expect( isTransactionSimulationError( - simulationResponse.transactions[0] as TransactionReviewTransaction, + simulationResponse + .transactions[0] as unknown as TransactionReviewTransaction, ), ).toBeFalsy() }) diff --git a/packages/extension/src/shared/transactionReview/schema.ts b/packages/extension/src/shared/transactionReview/schema.ts index 61ee55678..0a00760c4 100644 --- a/packages/extension/src/shared/transactionReview/schema.ts +++ b/packages/extension/src/shared/transactionReview/schema.ts @@ -1,6 +1,7 @@ -import { addressSchemaArgentBackend } from "@argent/shared" +import { addressSchema, addressSchemaArgentBackend } from "@argent/shared" import { z } from "zod" import { estimatedFeesSchema } from "../transactionSimulation/fees/fees.model" +import { reasonsSchema, severitySchema } from "../warning/schema" const linkSchema = z.object({ name: z.string(), @@ -64,31 +65,6 @@ export const actionSchema = z.object({ defaultProperties: z.array(propertySchema).optional(), }) -export const reasonsSchema = z.union([ - z.literal("account_upgrade_to_unknown_implementation"), - z.literal("account_state_change"), - z.literal("contract_is_black_listed"), - z.literal("amount_mismatch_too_low"), - z.literal("amount_mismatch_too_high"), - z.literal("dst_token_black_listed"), - z.literal("internal_service_issue"), - z.literal("recipient_is_not_current_account"), - z.literal("recipient_is_token_address"), - z.literal("recipient_is_black_listed"), - z.literal("spender_is_black_listed"), - z.literal("operator_is_black_listed"), - z.literal("src_token_black_listed"), - z.literal("unknown_token"), - z.literal("undeployed_account"), - z.literal("contract_is_not_verified"), - z.literal("token_a_black_listed"), - z.literal("token_b_black_listed"), - z.literal("approval_too_high"), - // these exist in the backend but should never occur - // z.literal("multi_calls_on_account"), - // z.literal("unknown_selector"), -]) - export const assessmentSchema = z.union([ z.literal("verified"), z.literal("neutral"), @@ -96,16 +72,17 @@ export const assessmentSchema = z.union([ z.literal("warn"), ]) -export const severitySchema = z.union([ - z.literal("critical"), - z.literal("high"), - z.literal("caution"), - z.literal("info"), -]) +export const warningDetailsSchema = z.object({ + unknown_token: z.unknown().optional(), + date_of_addition: z.string().optional(), + contract_address: addressSchema.optional(), + reason: z.string().optional(), + value: z.string().or(z.number()).optional(), +}) export const warningSchema = z.object({ reason: reasonsSchema, - details: z.record(z.string().or(z.number())).optional(), + details: warningDetailsSchema.optional(), severity: severitySchema, }) @@ -193,14 +170,38 @@ const transferSchema = z.object({ value: z.string().optional(), details: tokenDetailsSchema.optional(), }) +// Not great but this is to deal with backend inconsistencies +const stringOrNumberAsNumberSchema = z + .union([z.string(), z.number()]) + .transform((val) => parseInt(val.toString(), 10)) +const feeEstimationCommonFields = { + overallFee: stringOrNumberAsNumberSchema, + gasPrice: stringOrNumberAsNumberSchema, + gasUsage: stringOrNumberAsNumberSchema, +} -export const feeEstimationSchema = z.object({ - overallFee: z.string(), - gasPrice: z.string(), - gasUsage: z.string(), - unit: z.string(), - maxFee: z.string(), -}) +export const feeEstimationSchema = z + .object({ + ...feeEstimationCommonFields, + unit: z + .string() + .transform((t) => t.toUpperCase()) + .pipe(z.literal("WEI")), + + maxFee: stringOrNumberAsNumberSchema, + }) + .or( + z.object({ + ...feeEstimationCommonFields, + unit: z + .string() + .transform((t) => t.toUpperCase()) + .pipe(z.literal("FRI")), + + maxAmount: stringOrNumberAsNumberSchema, + maxPricePerUnit: stringOrNumberAsNumberSchema, + }), + ) const summarySchema = z.object({ type: z.string(), @@ -265,8 +266,7 @@ export type EnrichedSimulateAndReview = z.infer< > export type SimulateAndReview = z.infer -export type AssessmentReason = z.infer -export type AssessmentSeverity = z.infer + export type Assessment = z.infer export type FeeEstimation = z.infer export type ReviewOfTransaction = z.infer diff --git a/packages/extension/src/shared/transactionSimulation/fees/estimatedFeesRepository.ts b/packages/extension/src/shared/transactionSimulation/fees/estimatedFeesRepository.ts index ea67dae29..a26828b38 100644 --- a/packages/extension/src/shared/transactionSimulation/fees/estimatedFeesRepository.ts +++ b/packages/extension/src/shared/transactionSimulation/fees/estimatedFeesRepository.ts @@ -1,7 +1,7 @@ import browser from "webextension-polyfill" -import { ensureArray } from "@argent/shared" +import { TransactionAction, ensureArray } from "@argent/shared" import { deserialize, serialize } from "superjson" -import type { AllowArray, Call } from "starknet" +import { TransactionType } from "starknet" import { ChromeRepository } from "../../storage/__new/chrome" import { @@ -10,29 +10,40 @@ import { IEstimatedFeesRepository, } from "./fees.model" import { objectHash } from "../../objectHash" +import { assertNever } from "../../utils/assertNever" export const estimatedFeesRepo: IEstimatedFeesRepository = new ChromeRepository(browser, { - namespace: "core:estimatedFees4", - areaName: "local", + namespace: "core:estimatedFees5", + areaName: "session", // we want to clear it on session end, no need to keep this around compare: (a, b) => a.id === b.id, // we need to serialize/deserialize as we store bigints serialize, deserialize, }) -// always use array as it is easier to compare -export function getIdForTransactions(transactions: AllowArray) { - const transactionsArray = ensureArray(transactions) - const id = objectHash(transactionsArray) - return id +export function getIdForTransactions(action: TransactionAction) { + if (action.type === TransactionType.INVOKE) { + // For INVOKE, ensure array for consistent hashing + return objectHash(ensureArray(action.payload)) + } else if ( + action.type === TransactionType.DECLARE || + action.type === TransactionType.DEPLOY || + action.type === TransactionType.DEPLOY_ACCOUNT + ) { + // For DECLARE, DEPLOY, and DEPLOY_ACCOUNT, payload is already in the correct form + return objectHash(action.payload) + } else { + assertNever(action) + throw new Error(`Unknown transaction type: ${action}`) + } } export const addEstimatedFee = async ( estimatedFee: EstimatedFees, - transactions: AllowArray, + action: TransactionAction, ) => { - const id = getIdForTransactions(transactions) + const id = getIdForTransactions(action) // If a transaction is already in the store, we update it with the new fees and timestamp // Otherwise, we add it to the store @@ -48,15 +59,18 @@ export const addEstimatedFee = async ( } export const getEstimatedFees = async ( - transactions: AllowArray, + action: TransactionAction, ): Promise => { - const id = getIdForTransactions(transactions) + const id = getIdForTransactions(action) const [fee] = await estimatedFeesRepo.get( (estimatedFee) => estimatedFee.id === id, ) if (!fee) { - console.error(`No fees found for transactions: `, transactions) + console.error( + `No fees found for ${action.type} transaction: `, + action.payload, + ) return null } diff --git a/packages/extension/src/shared/transactionSimulation/fees/fees.model.ts b/packages/extension/src/shared/transactionSimulation/fees/fees.model.ts index 1e1eedf47..0487af683 100644 --- a/packages/extension/src/shared/transactionSimulation/fees/fees.model.ts +++ b/packages/extension/src/shared/transactionSimulation/fees/fees.model.ts @@ -2,11 +2,18 @@ import { z } from "zod" import { ChromeRepository } from "../../storage/__new/chrome" import { addressSchema } from "@argent/shared" +const maxFeeSchema = z + .object({ + amount: z.bigint(), + pricePerUnit: z.bigint(), + }) + .or(z.object({ maxFee: z.bigint() })) + export const estimatedFeeSchema = z.object({ feeTokenAddress: addressSchema, amount: z.bigint(), pricePerUnit: z.bigint(), - watermarkedMaxFee: z.bigint().optional(), // TODO: Remove this once we have the watermark fee in the amount*pricePerUnit product + max: maxFeeSchema.optional(), }) export type EstimatedFee = z.infer diff --git a/packages/extension/src/shared/transactionSimulation/types.ts b/packages/extension/src/shared/transactionSimulation/types.ts index c923d2675..f07ad8721 100644 --- a/packages/extension/src/shared/transactionSimulation/types.ts +++ b/packages/extension/src/shared/transactionSimulation/types.ts @@ -9,6 +9,9 @@ import { import { Fetcher } from "../api/fetcher" import { EstimatedFees } from "./fees/fees.model" +export type WEI = "WEI" | "wei" +export type FRI = "FRI" | "fri" + export interface SimulationError extends Error { name: string responseJson: { status: string } @@ -74,13 +77,22 @@ export interface TokenDetails { usdValue: string | null } -export type TransactionSimulationFeesEstimation = { - gasPrice: number - gasUsage: number - overallFee: number - unit: string - maxFee: number -} +export type TransactionSimulationFeesEstimation = + | { + gasPrice: number + gasUsage: number + overallFee: number + unit: WEI + maxFee: number + } + | { + gasPrice: number + gasUsage: number + overallFee: number + unit: FRI + maxAmount: number + maxPricePerUnit: number + } export type ApiTransactionSimulationResponse = { approvals: TransactionSimulationApproval[] diff --git a/packages/extension/src/shared/transactionSimulation/utils.test.ts b/packages/extension/src/shared/transactionSimulation/utils.test.ts new file mode 100644 index 000000000..7912109fc --- /dev/null +++ b/packages/extension/src/shared/transactionSimulation/utils.test.ts @@ -0,0 +1,77 @@ +import { estimatedFeeToMaxFeeTotal } from "./utils" +import { EstimatedFee } from "./fees/fees.model" + +describe("estimatedFeeToMaxFeeTotal", () => { + it("should return the correct max fee total", () => { + const estimatedFee: EstimatedFee = { + feeTokenAddress: "0x123", + amount: 100n, + pricePerUnit: 10n, + max: { + maxFee: 200n, + }, + } + + const result = estimatedFeeToMaxFeeTotal(estimatedFee) + expect(result).toBe(200n) + }) + + it("should return the product of amount and pricePerUnit if watermarkedMaxFee is not provided", () => { + const estimatedFee: EstimatedFee = { + feeTokenAddress: "0x123", + amount: 100n, + pricePerUnit: 10n, + } + + const result = estimatedFeeToMaxFeeTotal(estimatedFee) + expect(result).toBeGreaterThan(1950) + expect(result).toBeLessThan(2050) + }) + + it("should handle edge case where amount and pricePerUnit are zero", () => { + const estimatedFee: EstimatedFee = { + feeTokenAddress: "0x123", + amount: 0n, + pricePerUnit: 0n, + } + + const result = estimatedFeeToMaxFeeTotal(estimatedFee) + expect(result).toBe(0n) + }) + + it("should handle edge case where amount is zero and pricePerUnit is not", () => { + const estimatedFee: EstimatedFee = { + feeTokenAddress: "0x123", + amount: 0n, + pricePerUnit: 10n, + } + + const result = estimatedFeeToMaxFeeTotal(estimatedFee) + expect(result).toBe(0n) + }) + + it("should handle edge case where amount is not zero and pricePerUnit is zero", () => { + const estimatedFee: EstimatedFee = { + feeTokenAddress: "0x123", + amount: 100n, + pricePerUnit: 0n, + } + + const result = estimatedFeeToMaxFeeTotal(estimatedFee) + expect(result).toBe(0n) + }) + + it("should handle edge case where amount is negative", () => { + const estimatedFee: EstimatedFee = { + feeTokenAddress: "0x123", + amount: -100n, + pricePerUnit: 10n, + } + + expect(() => + estimatedFeeToMaxFeeTotal(estimatedFee), + ).toThrowErrorMatchingInlineSnapshot( + `[Error: Cannot calculate max fee for negative fee]`, + ) + }) +}) diff --git a/packages/extension/src/shared/transactionSimulation/utils.ts b/packages/extension/src/shared/transactionSimulation/utils.ts index 90e711cce..04cdffe64 100644 --- a/packages/extension/src/shared/transactionSimulation/utils.ts +++ b/packages/extension/src/shared/transactionSimulation/utils.ts @@ -1,18 +1,89 @@ import { num } from "starknet" import { EstimatedFee, EstimatedFees } from "./fees/fees.model" -import { ApiTransactionSimulationResponse } from "./types" -import { ETH_TOKEN_ADDRESS } from "../network/constants" -import { isEqualAddress } from "@argent/shared" -import { SimulateAndReview } from "../transactionReview/schema" +import { ApiTransactionSimulationResponse, FRI, WEI } from "./types" +import { ETH_TOKEN_ADDRESS, STRK_TOKEN_ADDRESS } from "../network/constants" +import { Address, isEqualAddress } from "@argent/shared" +import { + SimulateAndReview, + feeEstimationSchema, +} from "../transactionReview/schema" import { ReviewError } from "../errors/review" import { argentMaxFee } from "../utils/argentMaxFee" +import { upperCase } from "lodash-es" + +type FeeEstimationV1 = { + unit: WEI + maxFee: T + overallFee: T + gasPrice: T + gasUsage: T +} + +type FeeEstimationV3 = { + unit: FRI + overallFee: T + gasPrice: T + gasUsage: T + maxAmount: T + maxPricePerUnit: T +} + +type CastFeeEstimation = FeeEstimationV1 | FeeEstimationV3 + +function toMax>( + value: T, +): T["unit"] extends WEI ? { maxFee: S } : { amount: S; pricePerUnit: S } { + if (isWEI(value)) { + return { + maxFee: value.maxFee, + } as T["unit"] extends WEI ? { maxFee: S } : { amount: S; pricePerUnit: S } + } + return { + amount: value.maxAmount, + pricePerUnit: value.maxPricePerUnit, + } as T["unit"] extends WEI ? { maxFee: S } : { amount: S; pricePerUnit: S } +} + +function isWEI( + value: Pick, "unit">, +): value is FeeEstimationV1 { + return upperCase(value.unit) === "WEI" +} + +function isFRI( + value: Pick, "unit">, +): value is FeeEstimationV3 { + return upperCase(value.unit) === "FRI" +} + +function castFeeEstimation, C>( + feeEstimation: T, + cast: (value: S) => C, +): CastFeeEstimation { + if (isWEI(feeEstimation)) { + return { + ...feeEstimation, + maxFee: cast(feeEstimation.maxFee), + overallFee: cast(feeEstimation.overallFee), + gasPrice: cast(feeEstimation.gasPrice), + gasUsage: cast(feeEstimation.gasUsage), + } + } + return { + ...feeEstimation, + overallFee: cast(feeEstimation.overallFee), + gasPrice: cast(feeEstimation.gasPrice), + gasUsage: cast(feeEstimation.gasUsage), + maxAmount: cast(feeEstimation.maxAmount), + maxPricePerUnit: cast(feeEstimation.maxPricePerUnit), + } +} -// TODO: Remove this once we have the watermark fee in the amount*pricePerUnit product export const getEstimatedFeeFromSimulationAndRespectWatermarkFee = ( simulateAndReviewResult: Pick, ): EstimatedFees & { - transactions: EstimatedFee & { watermarkedMaxFee: bigint } - deployment?: EstimatedFee & { watermarkedMaxFee: bigint } + transactions: Required + deployment?: Required } => { const { transactions: _transactions } = simulateAndReviewResult @@ -30,13 +101,7 @@ export const getEstimatedFeeFromSimulationAndRespectWatermarkFee = ( (tx): Pick => { return { ...tx.simulation, - feeEstimation: { - ...tx.simulation.feeEstimation, - gasPrice: Number(tx.simulation.feeEstimation.gasPrice), - gasUsage: Number(tx.simulation.feeEstimation.gasUsage), - overallFee: Number(tx.simulation.feeEstimation.overallFee), - maxFee: Number(tx.simulation.feeEstimation.maxFee), - }, + feeEstimation: castFeeEstimation(tx.simulation.feeEstimation, Number), } }, ) @@ -52,22 +117,32 @@ export const getEstimatedFeeFromSimulationAndRespectWatermarkFee = ( ...estimatedFee, transactions: { ...estimatedFee.transactions, - watermarkedMaxFee: num.toBigInt( - invokeTransaction.simulation.feeEstimation.maxFee, + max: toMax( + castFeeEstimation( + invokeTransaction.simulation.feeEstimation, + num.toBigInt, + ), ), }, deployment: estimatedFee.deployment && deployTransactionOrUndefined ? { ...estimatedFee.deployment, - watermarkedMaxFee: num.toBigInt( - deployTransactionOrUndefined.simulation.feeEstimation.maxFee, + max: toMax( + castFeeEstimation( + deployTransactionOrUndefined.simulation.feeEstimation, + num.toBigInt, + ), ), } : undefined, } } +export function unitToFeeTokenAddress(unit: WEI | FRI): Address { + return isFRI({ unit }) ? STRK_TOKEN_ADDRESS : ETH_TOKEN_ADDRESS +} + export const getEstimatedFeeFromBulkSimulation = ( simulation: | Pick[] @@ -91,13 +166,14 @@ export const getEstimatedFeeFromBulkSimulation = ( } if (simulation.length === 1) { + const feeEstimation = feeEstimationSchema.parse(simulation[0].feeEstimation) // No account deployment return { transactions: { - feeTokenAddress: ETH_TOKEN_ADDRESS, - amount: num.toBigInt(simulation[0].feeEstimation.gasUsage), - pricePerUnit: num.toBigInt(simulation[0].feeEstimation.gasPrice), - watermarkedMaxFee: num.toBigInt(simulation[0].feeEstimation.maxFee), + feeTokenAddress: unitToFeeTokenAddress(feeEstimation.unit), + amount: num.toBigInt(feeEstimation.gasUsage), + pricePerUnit: num.toBigInt(feeEstimation.gasPrice), + max: toMax(castFeeEstimation(feeEstimation, num.toBigInt)), }, } } @@ -106,16 +182,24 @@ export const getEstimatedFeeFromBulkSimulation = ( // Simulation includes account deployment return { deployment: { - feeTokenAddress: ETH_TOKEN_ADDRESS, + feeTokenAddress: unitToFeeTokenAddress( + simulation[0].feeEstimation.unit, + ), amount: num.toBigInt(simulation[0].feeEstimation.gasUsage), pricePerUnit: num.toBigInt(simulation[0].feeEstimation.gasPrice), - watermarkedMaxFee: num.toBigInt(simulation[0].feeEstimation.maxFee), + max: toMax( + castFeeEstimation(simulation[0].feeEstimation, num.toBigInt), + ), }, transactions: { - feeTokenAddress: ETH_TOKEN_ADDRESS, + feeTokenAddress: unitToFeeTokenAddress( + simulation[1].feeEstimation.unit, + ), amount: num.toBigInt(simulation[1].feeEstimation.gasUsage), pricePerUnit: num.toBigInt(simulation[1].feeEstimation.gasPrice), - watermarkedMaxFee: num.toBigInt(simulation[1].feeEstimation.maxFee), + max: toMax( + castFeeEstimation(simulation[1].feeEstimation, num.toBigInt), + ), }, } } @@ -123,7 +207,9 @@ export const getEstimatedFeeFromBulkSimulation = ( throw Error("Unexpected simulation response length") } -export const estimatedFeeToTotal = (estimatedFee: EstimatedFee): bigint => { +export const estimatedFeeToTotal = ( + estimatedFee: Pick, +): bigint => { return estimatedFee.amount * estimatedFee.pricePerUnit } @@ -147,20 +233,62 @@ export const estimatedFeesToTotal = (estimatedFees: EstimatedFees): bigint => { } const estimatedFeeToMaxFee = (estimatedFee: EstimatedFee): EstimatedFee => { + // Respect the watermark fee if it exists + if (estimatedFee.max && "amount" in estimatedFee.max) { + return { + ...estimatedFee, + ...estimatedFee.max, + } + } + + // Otherwise, calculate the max fee with the fallback overhead + const scale = 10000n + const totalFee = estimatedFee.amount * estimatedFee.pricePerUnit + + if (totalFee < 0) { + throw Error("Cannot calculate max fee for negative fee") + } + + const adjustedTotalFee = num.toBigInt( + argentMaxFee({ estimatedFee: totalFee }), + ) + + const factor = Math.sqrt(Number(adjustedTotalFee) / Number(totalFee)) + const adjustmentFactor = isNaN(factor) + ? Math.sqrt(2) // fallback multiplier if ratio is NaN, square root of 2 + : factor + + const scaledAdjustmentFactor = BigInt( + Math.trunc(adjustmentFactor * Number(scale)), + ) + return { ...estimatedFee, - amount: num.toBigInt(argentMaxFee({ estimatedFee: estimatedFee.amount })), - pricePerUnit: num.toBigInt( - argentMaxFee({ estimatedFee: estimatedFee.pricePerUnit }), - ), + amount: (estimatedFee.amount * scaledAdjustmentFactor) / scale, + pricePerUnit: (estimatedFee.pricePerUnit * scaledAdjustmentFactor) / scale, + } +} + +export const getWatermarkedMaxFeeTotal = ( + estimatedFee: EstimatedFee, +): bigint | undefined => { + if (!estimatedFee.max) { + return undefined } + if ("maxFee" in estimatedFee.max) { + return estimatedFee.max.maxFee + } + return estimatedFeeToTotal(estimatedFee.max) } export const estimatedFeeToMaxFeeTotal = ( estimatedFee: EstimatedFee, ): bigint => { - const maxFee = estimatedFeeToMaxFee(estimatedFee) - return maxFee.watermarkedMaxFee ?? estimatedFeeToTotal(maxFee) + const watermarkedMaxFee = getWatermarkedMaxFeeTotal(estimatedFee) + if (watermarkedMaxFee) { + return watermarkedMaxFee + } + return estimatedFeeToTotal(estimatedFeeToMaxFee(estimatedFee)) } export const estimatedFeesToMaxFeeTotal = ( @@ -178,34 +306,28 @@ export const estimatedFeesToMaxFeeTotal = ( const deployment = !estimatedFees.deployment ? 0n - : estimatedFees.deployment.watermarkedMaxFee ?? - estimatedFeeToMaxFeeTotal(estimatedFees.deployment) + : estimatedFeeToMaxFeeTotal(estimatedFees.deployment) - const transactions = - estimatedFees.transactions.watermarkedMaxFee ?? - estimatedFeeToMaxFeeTotal(estimatedFees.transactions) + const transactions = estimatedFeeToMaxFeeTotal(estimatedFees.transactions) return deployment + transactions } -export const estimatedFeeToResourceBounds = (estimatedFee: EstimatedFee) => { +export const estimatedFeeToMaxResourceBounds = (estimatedFee: EstimatedFee) => { + const maxFee = estimatedFeeToMaxFee(estimatedFee) return { // for v1 transactions - maxFee: { - suggestedMaxFee: estimatedFeeToTotal(estimatedFee), - }, + maxFee: estimatedFeeToMaxFeeTotal(estimatedFee), // for v3 transactions - l1_gas: { - max_amount: { - suggestedMaxFee: estimatedFee.amount, + resourceBounds: { + l1_gas: { + max_amount: num.toHex(maxFee.amount), + max_price_per_unit: num.toHex(maxFee.pricePerUnit), }, - max_price_per_unit: { - suggestedMaxFee: estimatedFee.pricePerUnit, + l2_gas: { + max_amount: "0x0", + max_price_per_unit: "0x0", }, }, - l2_gas: { - max_amount: "0x0", - max_price_per_unit: "0x0", - }, } } diff --git a/packages/extension/src/shared/transactions.ts b/packages/extension/src/shared/transactions.ts index a10f9067a..1c7d12367 100644 --- a/packages/extension/src/shared/transactions.ts +++ b/packages/extension/src/shared/transactions.ts @@ -7,6 +7,7 @@ import { MultisigTransactionType, } from "./multisig/types" import { getTransactionStatus } from "./transactions/utils" +import { Address } from "@argent/shared" export type FinaliyStatus = RPC.SPEC.TXN_STATUS export type ExecutionStatus = RPC.SPEC.TXN_EXECUTION_STATUS @@ -25,7 +26,6 @@ export type ExtendedTransactionStatus = { // Global Constants for Transactions export const SUCCESS_STATUSES: ExtendedFinalityStatus[] = [ - "PENDING", // For backward compatibility on mainnet "ACCEPTED_ON_L2", "ACCEPTED_ON_L1", ] @@ -51,10 +51,11 @@ export type ExtendedTransactionType = export interface TransactionMeta { title?: string subTitle?: string - isUpgrade?: boolean + newClassHash?: Address isChangeGuardian?: boolean isDeployAccount?: boolean isCancelEscape?: boolean + isMaxSend?: boolean transactions?: Call | Call[] type?: ExtendedTransactionType } diff --git a/packages/extension/src/shared/transactions/store.ts b/packages/extension/src/shared/transactions/store.ts index 6fec8e02e..6fa376329 100644 --- a/packages/extension/src/shared/transactions/store.ts +++ b/packages/extension/src/shared/transactions/store.ts @@ -21,7 +21,9 @@ export const transactionsStore = new ArrayStorage([], { compare: compareTransactions, }) -export const transactionsRepo: IRepository = +export type ITransactionsRepository = IRepository + +export const transactionsRepo: ITransactionsRepository = adaptArrayStorage(transactionsStore) const timestampInSeconds = (): number => Math.floor(Date.now() / 1000) diff --git a/packages/extension/src/shared/transactions/utils.ts b/packages/extension/src/shared/transactions/utils.ts index a4c8d8e7b..9b36fc8c8 100644 --- a/packages/extension/src/shared/transactions/utils.ts +++ b/packages/extension/src/shared/transactions/utils.ts @@ -9,8 +9,10 @@ import { ExtendedTransactionStatus, Transaction, ExecutionStatus, + SUCCESS_STATUSES, } from "../transactions" import { z } from "zod" +import { isSafeUpgradeTransaction } from "../utils/isUpgradeTransaction" export function getTransactionIdentifier(transaction: BaseTransaction): string { return `${transaction.networkId}::${hexSchema.parse(transaction.hash)}` @@ -95,3 +97,23 @@ export function getTransactionStatus( return { finality_status, execution_status } } + +export function getPendingTransactions( + transactions: Transaction[], +): Transaction[] { + return transactions.filter((transaction) => { + const { finality_status } = getTransactionStatus(transaction) + return finality_status === "RECEIVED" + }) +} + +export function getPendingUpgradeTransactions( + transactions: Transaction[], +): Transaction[] { + return getPendingTransactions(transactions).filter(isSafeUpgradeTransaction) +} + +export const isSuccessfulTransaction = (tx: Transaction) => { + const { finality_status } = getTransactionStatus(tx) + return finality_status && SUCCESS_STATUSES.includes(finality_status) +} diff --git a/packages/extension/src/shared/udc/schema.ts b/packages/extension/src/shared/udc/schema.ts index b16e6b808..a940fabdd 100644 --- a/packages/extension/src/shared/udc/schema.ts +++ b/packages/extension/src/shared/udc/schema.ts @@ -1,4 +1,5 @@ import { + Address, cairoAssemblySchema, compiledContractClassSchema, } from "@argent/shared" @@ -8,6 +9,7 @@ import { UniversalDeployerContractPayload, } from "starknet" import { z } from "zod" +import { BaseWalletAccount } from "../wallet.model" export const getConstructorParamsSchema = z.object({ networkId: z.string(), @@ -23,15 +25,17 @@ export const basicContractClassSchema = z.object({ export type BasicContractClass = z.infer -export type DeclareContract = { - address?: string - networkId?: string -} & DeclareContractPayload +export interface DeclareContract { + payload: DeclareContractPayload + feeTokenAddress: Address + account?: BaseWalletAccount +} -export type DeployContract = { - address: string - networkId: string -} & UniversalDeployerContractPayload +export interface DeployContract { + payload: UniversalDeployerContractPayload + feeTokenAddress: Address + account?: BaseWalletAccount +} export const declareContractSchema = z.object({ address: z.string().optional(), diff --git a/packages/extension/src/shared/utils/arrayOrderWith.ts b/packages/extension/src/shared/utils/arrayOrderWith.ts new file mode 100644 index 000000000..db1547ee8 --- /dev/null +++ b/packages/extension/src/shared/utils/arrayOrderWith.ts @@ -0,0 +1,13 @@ +// Utility function to determine order of values in an array +export const arrayOrderWith = ( + array: T[], + valueA: T, + valueB: T, + compareFn: (a: T, b: T) => boolean, +): number => { + const indexA = array.findIndex((v) => compareFn(v, valueA)) + const indexB = array.findIndex((v) => compareFn(v, valueB)) + if (indexA === -1) return 1 + if (indexB === -1) return -1 + return indexA - indexB +} diff --git a/packages/extension/src/shared/utils/getTransactionVersion.ts b/packages/extension/src/shared/utils/getTransactionVersion.ts new file mode 100644 index 000000000..077070fb0 --- /dev/null +++ b/packages/extension/src/shared/utils/getTransactionVersion.ts @@ -0,0 +1,37 @@ +import { Address } from "@argent/shared" +import { feeTokenNeedsTxV3Support } from "../network/txv3" +import { + TransactionInvokeVersion, + TransactionSimulationVersion, +} from "./transactionVersion" +import { DeclareContractPayload, isSierra } from "starknet" + +export function getTxVersionFromFeeToken( + feeTokenAddress: Address, +): TransactionInvokeVersion { + return feeTokenNeedsTxV3Support({ + address: feeTokenAddress, + }) + ? "0x3" + : "0x1" +} + +export function getSimulationTxVersionFromFeeToken( + feeTokenAddress: Address, +): TransactionSimulationVersion { + return feeTokenNeedsTxV3Support({ address: feeTokenAddress }) + ? "0x100000000000000000000000000000003" + : "0x100000000000000000000000000000001" +} + +// Declare contract specifics +export function getTxVersionFromFeeTokenForDeclareContract( + feeTokenAddress: Address, + payload: DeclareContractPayload, +): TransactionInvokeVersion { + if (!isSierra(payload.contract)) { + return "0x1" + } + + return feeTokenNeedsTxV3Support({ address: feeTokenAddress }) ? "0x3" : "0x2" +} diff --git a/packages/extension/src/shared/utils/isUpgradeTransaction.ts b/packages/extension/src/shared/utils/isUpgradeTransaction.ts new file mode 100644 index 000000000..05523f39b --- /dev/null +++ b/packages/extension/src/shared/utils/isUpgradeTransaction.ts @@ -0,0 +1,15 @@ +import { addressSchema } from "@argent/shared" +import { Transaction } from "../transactions" + +// This function checks if a transaction is a safe upgrade transaction +// with backwards compatibility for old upgrade transactions +export const isSafeUpgradeTransaction = ({ + meta, +}: Pick) => { + if (!meta) { + return false + } + const isNewUpgradeTxn = addressSchema.safeParse(meta.newClassHash).success + const isOldUpgradeTxn = "isUpgrade" in meta && Boolean(meta.isUpgrade) + return isNewUpgradeTxn || isOldUpgradeTxn +} diff --git a/packages/extension/src/shared/utils/starknetNetwork.ts b/packages/extension/src/shared/utils/starknetNetwork.ts index 2f352a9d3..db36ca577 100644 --- a/packages/extension/src/shared/utils/starknetNetwork.ts +++ b/packages/extension/src/shared/utils/starknetNetwork.ts @@ -2,6 +2,12 @@ import { constants } from "starknet" import { Network } from "../network" +/** + * NOTE: Sepolia - Currently Multisig only distinguishes between 'testnet' and 'mainnet' + * + * Sepolia is therefore not added here. + */ + export const networkToStarknetNetwork = (network: Network) => { switch (network.chainId) { case "SN_MAIN": @@ -13,18 +19,6 @@ export const networkToStarknetNetwork = (network: Network) => { } } -export const networkToDiscoveryNetwork = (network: Network) => { - // Prioritize network.id over network.chainId - switch (network.id) { - case "mainnet-alpha": - return "mainnet" - case "goerli-alpha": - return "goerli" - default: - return - } -} - export const networkIdToStarknetNetwork = (networkId: string) => { switch (networkId) { case "mainnet-alpha": diff --git a/packages/extension/src/shared/utils/transactionVersion.ts b/packages/extension/src/shared/utils/transactionVersion.ts new file mode 100644 index 000000000..7475c99c0 --- /dev/null +++ b/packages/extension/src/shared/utils/transactionVersion.ts @@ -0,0 +1,44 @@ +import { num } from "starknet" +import { z } from "zod" + +// TransactionInvokeVersion is designated for the actual execution of transactions. +export type TransactionInvokeVersion = "0x0" | "0x1" | "0x2" | "0x3" + +// TransactionSimulationVersion is utilized during fee estimation and simulation. +// It is intentionally kept distinct from TransactionInvokeVersion to prevent +// the possibility of replaying signatures from simulations in actual transactions. +export type TransactionSimulationVersion = + | "0x100000000000000000000000000000000" + | "0x100000000000000000000000000000001" + | "0x100000000000000000000000000000002" + | "0x100000000000000000000000000000003" + +// TransactionVersion - union of both the invoke and simulation versions. +export type TransactionVersion = + | TransactionInvokeVersion + | TransactionSimulationVersion + +const validTxVersions: TransactionVersion[] = [ + "0x0", + "0x1", + "0x2", + "0x3", + "0x100000000000000000000000000000000", + "0x100000000000000000000000000000001", + "0x100000000000000000000000000000002", + "0x100000000000000000000000000000003", +] + +export const txVersionSchema = z + .string() + .default("0x3") + .refine((v) => { + const n = num.toBigInt(v) + + // Ensure that the provided version is a valid transaction version. + return validTxVersions.map((v) => num.toBigInt(v)).includes(n) + }) + .transform((v) => { + // Convert the valid transaction version to hexadecimal format. + return num.toHex(v) as TransactionVersion + }) diff --git a/packages/extension/src/shared/wallet.model.ts b/packages/extension/src/shared/wallet.model.ts index a905afd26..f9254c4d2 100644 --- a/packages/extension/src/shared/wallet.model.ts +++ b/packages/extension/src/shared/wallet.model.ts @@ -29,19 +29,22 @@ export const withSignerSchema = z.object({ signer: walletAccountSignerSchema, }) +export const cairoVersionSchema = z.union([z.literal("0"), z.literal("1")]) export const walletAccountSchema = z .object({ name: z.string(), network: networkSchema, type: argentAccountTypeSchema, classHash: addressSchema.optional(), - cairoVersion: z.union([z.literal("0"), z.literal("1")]).optional(), + cairoVersion: cairoVersionSchema.optional(), hidden: z.boolean().optional(), needsDeploy: z.boolean().optional(), showBlockingDeprecated: z.boolean().optional(), guardian: z.string().optional(), escape: escapeSchema.optional(), owner: z.string().optional(), + provisionAmount: z.string().optional(), + provisionDate: z.number().optional(), }) .merge(withSignerSchema) .merge(baseWalletAccountSchema) diff --git a/packages/extension/src/shared/warning/schema.ts b/packages/extension/src/shared/warning/schema.ts new file mode 100644 index 000000000..5f5d9438a --- /dev/null +++ b/packages/extension/src/shared/warning/schema.ts @@ -0,0 +1,33 @@ +import { z } from "zod" + +export const severitySchema = z.union([ + z.literal("critical"), + z.literal("high"), + z.literal("caution"), + z.literal("info"), +]) +export const reasonsSchema = z.union([ + z.literal("account_upgrade_to_unknown_implementation"), + z.literal("account_state_change"), + z.literal("contract_is_black_listed"), + z.literal("amount_mismatch_too_low"), + z.literal("amount_mismatch_too_high"), + z.literal("dst_token_black_listed"), + z.literal("internal_service_issue"), + z.literal("recipient_is_not_current_account"), + z.literal("recipient_is_token_address"), + z.literal("recipient_is_black_listed"), + z.literal("spender_is_black_listed"), + z.literal("operator_is_black_listed"), + z.literal("src_token_black_listed"), + z.literal("unknown_token"), + z.literal("undeployed_account"), + z.literal("contract_is_not_verified"), + z.literal("token_a_black_listed"), + z.literal("token_b_black_listed"), + z.literal("approval_too_high"), + z.literal("domain_is_black_listed"), + z.literal("similar_to_existing_dapp_url"), +]) +export type Reason = z.infer +export type Severity = z.infer diff --git a/packages/extension/src/ui/AppBackgroundError.tsx b/packages/extension/src/ui/AppBackgroundError.tsx index 95ab4d8ce..a24ff9b04 100644 --- a/packages/extension/src/ui/AppBackgroundError.tsx +++ b/packages/extension/src/ui/AppBackgroundError.tsx @@ -1,10 +1,27 @@ -import { Center, Flex } from "@chakra-ui/react" +import { Button, Center, Flex, useDisclosure } from "@chakra-ui/react" import { FC } from "react" import { H4, P3 } from "@argent/ui" import { SupportFooter } from "./features/settings/ui/SupportFooter" +import { useClearLocalStorage } from "./features/settings/developerSettings/clearLocalStorage/useClearLocalStorage" +import { useHardResetAndReload } from "./services/resetAndReload" +import { ClearStorageModal } from "./components/ClearStorageModal" export const AppBackgroundError: FC = () => { + const { + isOpen: isClearStorageModalOpen, + onOpen: onClearStorageModalOpen, + onClose: onClearStorageModalClose, + } = useDisclosure() + + const hardResetAndReload = useHardResetAndReload() + const onClearStorageSuccess = async () => { + await hardResetAndReload() + onClearStorageModalClose() + } + const { verifyPasswordAndClearStorage, isClearingStorage } = + useClearLocalStorage(onClearStorageSuccess) + return (
@@ -14,8 +31,17 @@ export const AppBackgroundError: FC = () => { process. Accounts are not affected. Please contact support for further instructions. +
+
) } diff --git a/packages/extension/src/ui/AppRoutes.tsx b/packages/extension/src/ui/AppRoutes.tsx index f2c3ed56e..ecfcd303a 100644 --- a/packages/extension/src/ui/AppRoutes.tsx +++ b/packages/extension/src/ui/AppRoutes.tsx @@ -7,7 +7,7 @@ import { Location, Outlet, useLocation } from "react-router-dom" import { useAppState, useMessageStreamHandler } from "./app.state" import { ResponsiveBox } from "./components/Responsive" import { TransactionDetailScreen } from "./features/accountActivity/TransactionDetailScreen" -import { AccountEditScreen } from "./features/accountEdit/AccountEditScreen" +import { AccountSettingsScreen } from "./features/settings/account/AccountSettingsScreen" import { CollectionNftsContainer } from "./features/accountNfts/CollectionNftsContainer" import { NftScreenContainer } from "./features/accountNfts/NftScreenContainer" import { AddPluginScreen } from "./features/accountPlugins.tsx/AddPluginScreen" @@ -17,7 +17,6 @@ import { AccountListScreenContainer } from "./features/accounts/AccountListScree import { AccountScreen } from "./features/accounts/AccountScreen" import { AddNewAccountScreenContainer } from "./features/accounts/AddNewAccountScreenContainer" import { HideOrDeleteAccountConfirmScreenContainer } from "./features/accounts/HideOrDeleteAccountConfirmScreenContainer" -import { ExportPrivateKeyScreen } from "./features/accountTokens/ExportPrivateKeyScreen" import { HideTokenScreenContainer } from "./features/accountTokens/HideTokenScreenContainer" import { ActionScreenContainer } from "./features/actions/ActionScreen" import { AddTokenScreenContainer } from "./features/actions/AddTokenScreenContainer" @@ -40,10 +39,8 @@ import { MultisigTransactionConfirmationsScreen } from "./features/multisig/Mult import { NewMultisigScreen } from "./features/multisig/NewMultisigScreen" import { RemovedMultisigSettingsScreenContainer } from "./features/multisig/RemovedMultisigSettingsScreenContainer" import { NetworkWarningScreenContainer } from "./features/networks/NetworkWarningScreen/NetworkWarningScreenContainer" -import { OnboardingDisclaimerScreenContainer } from "./features/onboarding/OnboardingDisclaimerScreenContainer" import { OnboardingFinishScreenContainer } from "./features/onboarding/OnboardingFinishScreenContainer" import { OnboardingPasswordScreenContainer } from "./features/onboarding/OnboardingPasswordScreenContainer" -import { OnboardingPrivacyStatementScreenContainer } from "./features/onboarding/OnboardingPrivacyStatementScreenContainer" import { OnboardingRestoreBackupScreenContainer } from "./features/onboarding/OnboardingRestoreBackupScreenContainer" import { OnboardingRestorePasswordScreenContainer } from "./features/onboarding/OnboardingRestorePasswordScreenContainer" import { OnboardingRestoreSeedScreenContainer } from "./features/onboarding/OnboardingRestoreSeedScreenContainer" @@ -64,7 +61,6 @@ import { DeploySmartContractScreen } from "./features/settings/developerSettings import { NetworkSettingsEditScreen } from "./features/settings/developerSettings/manageNetworks/NetworkSettingsEditScreen" import { NetworkSettingsFormScreenContainer } from "./features/settings/developerSettings/manageNetworks/NetworkSettingsFormScreenContainer" import { SeedSettingsScreenContainer } from "./features/settings/securityAndPrivacy/SeedSettingsScreenContainer" -import { SettingsPrivacyStatementScreen } from "./features/settings/SettingsPrivacyStatementScreen" import { SmartContractDevelopmentScreen } from "./features/settings/developerSettings/smartContractDevelopment/SmartContractDevelopmentScreen" import { EscapeWarningScreen } from "./features/shield/escape/EscapeWarningScreen" import { ShieldAccountActionScreen } from "./features/shield/ShieldAccountActionScreen" @@ -85,11 +81,11 @@ import { EmailNotificationsSettingsScreenContainer } from "./features/settings/p import { MultisigPendingTransactionDetailsScreen } from "./features/multisig/MultisigPendingTransactionDetailsScreen" import { SuspenseScreen } from "./components/SuspenseScreen" import { BetaFeaturesSettingsScreenContainer } from "./features/settings/developerSettings/betaFeatures/BetaFeaturesSettingsScreenContainer" -import { ChangeAccountImplementationScreen } from "./features/accountEdit/ChangeAccountImplementationScreen" +import { ChangeAccountImplementationScreen } from "./features/settings/account/ChangeAccountImplementationScreen" import { MultisigReplaceOwnerScreen } from "./features/multisig/MultisigReplaceOwnerScreen" import { FundingQrCodeScreenContainer } from "./features/funding/FundingQrCodeScreenContainer" import { AppBackgroundError } from "./AppBackgroundError" -import { isRecoveringView } from "./views/recovery" +import { isClearingStorageView, isRecoveringView } from "./views/recovery" import { SettingsScreenContainer } from "./features/settings/SettingsScreenContainer" import { PreferencesSettingsContainer } from "./features/settings/preferences/PreferencesSettingsContainer" import { BlockExplorerSettingsScreenContainer } from "./features/settings/preferences/BlockExplorerSettingsScreenContainer" @@ -101,6 +97,11 @@ import { DeveloperSettingsScreenContainer } from "./features/settings/developerS import { ExperimentalSettingsScreenContainer } from "./features/settings/developerSettings/experimental/ExperimentalSettingsScreenContainer" import { NetworkSettingsScreenContainer } from "./features/settings/developerSettings/manageNetworks/NetworkSettingsScreenContainer" import { AccountOwnerWarningScreen } from "./features/accountTokens/warning/AccountOwnerWarningScreen" +import { ExportPrivateKeyScreenContainer } from "./features/settings/account/ExportPrivateKeyScreenContainer" +import { ClearLocalStorageScreen } from "./features/settings/developerSettings/clearLocalStorage/ClearLocalStorageScreen" +import { useProvisionAnnouncement } from "./services/provision/useProvisionAnnouncement" +import { ProvisionAnnouncement } from "./features/provision/ProvisionAnnouncement" +import { DeploymentDataScreen } from "./features/settings/developerSettings/deploymentData/DeploymentDataScreen" interface LocationWithState extends Location { state: { @@ -192,6 +193,11 @@ const walletRoutes = ( path={routes.accountActivity.path} element={} /> + } + /> } /> - } - /> } /> + + + + } + /> } /> + } /> + } + /> + } + /> } /> - } - /> } /> } + element={} /> {/* Multisig */} } /> @@ -583,14 +600,6 @@ const fullscreenRoutes = ( path={routes.onboardingStart.path} element={} /> - } - /> - } - /> } @@ -640,6 +649,8 @@ export const AppRoutes: FC = () => { const { isLoading } = useAppState() const hasActions = useView(hasActionsView) const isRecovering = useView(isRecoveringView) + const isClearingStorage = useView(isClearingStorageView) + const provisionAnnouncement = useProvisionAnnouncement() /** TODO: refactor: this should maybe be invoked by service + worker pattern */ const showActions = useMemo(() => { @@ -648,7 +659,10 @@ export const AppRoutes: FC = () => { return hasActions && !isNonWalletRoute }, [hasActions, pathname, state]) - if (isRecovering) { + if (isClearingStorage) { + return + } + if (isRecovering && !isClearingStorage) { return } if (isLoading) { @@ -663,6 +677,14 @@ export const AppRoutes: FC = () => { ) } + if (provisionAnnouncement) { + return ( + + ) + } + return ( void + onConfirm: (password: string) => Promise + isClearingStorage: boolean +} + +export const ClearStorageModal: FC = ({ + isOpen, + onClose, + onConfirm, + isClearingStorage, +}) => { + return ( + <> + + + + +
+ Enter your password to clear storage +
+
+ + + + {(isDirty) => ( + + + + + )} + + +
+
+ + ) +} diff --git a/packages/extension/src/ui/components/ErrorBoundary.tsx b/packages/extension/src/ui/components/ErrorBoundary.tsx index 23579994f..1a4a29929 100644 --- a/packages/extension/src/ui/components/ErrorBoundary.tsx +++ b/packages/extension/src/ui/components/ErrorBoundary.tsx @@ -1,7 +1,6 @@ import { Component, cloneElement } from "react" -import { Location, RouterProps } from "react-router-dom" +import { RouterProps } from "react-router-dom" -import { routes } from "../routes" import { withRouter } from "../services/withRouter" interface ErrorBoundaryProps { @@ -28,18 +27,6 @@ class ErrorBoundaryComponent extends Component< } } - componentDidUpdate() { - const { router } = this.props - if ( - router && - (router.location as Location).pathname === - routes.settingsPrivacyStatement.path && - this.state.error - ) { - this.setState({ error: null }) - } - } - /** client-side error with info */ componentDidCatch(error: any, errorInfo: any) { this.setState({ diff --git a/packages/extension/src/ui/components/ErrorBoundaryFallbackWithCopyError.tsx b/packages/extension/src/ui/components/ErrorBoundaryFallbackWithCopyError.tsx index afa82e1d3..f7cc2b7ec 100644 --- a/packages/extension/src/ui/components/ErrorBoundaryFallbackWithCopyError.tsx +++ b/packages/extension/src/ui/components/ErrorBoundaryFallbackWithCopyError.tsx @@ -1,4 +1,4 @@ -import { useToast } from "@argent/ui" +import { useToast, icons } from "@argent/ui" import { Collapse } from "@mui/material" import * as Sentry from "@sentry/react" import { FC, useCallback, useEffect, useMemo, useState } from "react" @@ -21,6 +21,9 @@ import { } from "./Icons/MuiIcons" import { WarningIcon } from "./Icons/WarningIcon" import IOSSwitch from "./IOSSwitch" +import { useClearLocalStorage } from "../features/settings/developerSettings/clearLocalStorage/useClearLocalStorage" +import { useDisclosure } from "@chakra-ui/react" +import { ClearStorageModal } from "./ClearStorageModal" const Title = styled.h3` font-weight: 600; @@ -126,6 +129,7 @@ const fallbackErrorPayload = `v${version} Unable to parse error ` +const { BroomIcon } = icons export interface IErrorBoundaryFallbackWithCopyError extends ErrorBoundaryState { message?: string @@ -144,8 +148,19 @@ const ErrorBoundaryFallbackWithCopyError: FC< const [viewLogs, setViewLogs] = useState(false) const toast = useToast() + const { + isOpen: isClearStorageModalOpen, + onOpen: onClearStorageModalOpen, + onClose: onClearStorageModalClose, + } = useDisclosure() const hardResetAndReload = useHardResetAndReload() + const onClearStorageSuccess = async () => { + await hardResetAndReload() + onClearStorageModalClose() + } + const { verifyPasswordAndClearStorage, isClearingStorage } = + useClearLocalStorage(onClearStorageSuccess) const errorPayload = useMemo(() => { try { const displayError = coerceErrorToString(error) @@ -263,7 +278,17 @@ ${displayStack} Report error )} + + + Clear storage + + {privacyErrorReporting && ( diff --git a/packages/extension/src/ui/components/PrivacyStatementText.tsx b/packages/extension/src/ui/components/PrivacyStatementText.tsx deleted file mode 100644 index af28cc51d..000000000 --- a/packages/extension/src/ui/components/PrivacyStatementText.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { FC } from "react" -import styled from "styled-components" - -import { A } from "../theme/Typography" - -const Container = styled.span` - font-size: 16px; - line-height: 21px; - > ${A} { - padding: 0; - } -` - -export const PrivacyStatementText: FC = () => { - return ( - - GDPR statement for browser extension wallet: Argent takes the privacy and - security of individuals very seriously and takes every reasonable measure - and precaution to protect and secure the personal data that we process. - The browser extension wallet  - - does not collect any personal information  - - nor does it correlate any of your personal information with anonymous data - processed as part of its services. On top of this Argent has robust - information security policies and procedures in place to make sure any - processing complies with applicable laws. If you would like to know more - or have any questions then please visit our website at{" "} -
- https://www.argent.xyz/ - - - ) -} diff --git a/packages/extension/src/ui/components/QrCode.tsx b/packages/extension/src/ui/components/QrCode.tsx new file mode 100644 index 000000000..be9fc1b1b --- /dev/null +++ b/packages/extension/src/ui/components/QrCode.tsx @@ -0,0 +1,52 @@ +import { Center, CenterProps } from "@chakra-ui/react" +import QRCodeStyling from "qr-code-styling" +import { FC, useCallback, useEffect, useMemo, useRef } from "react" + +interface QrCodeProps extends CenterProps { + size: number + data: string +} + +export const QrCode: FC = ({ size, data, ...rest }) => { + const ref = useRef(null) + const qrCode = useMemo( + () => + new QRCodeStyling({ + width: size, + height: size, + type: "svg", + dotsOptions: { type: "dots", color: "#000000" }, + cornersSquareOptions: { type: "dot", color: "#000000" }, + cornersDotOptions: { type: "dot", color: "#000000" }, + imageOptions: { + crossOrigin: "anonymous", + }, + }), + [size], + ) + + const setRef = useCallback( + (nextRef: HTMLDivElement | null) => { + ref.current = nextRef + if (ref?.current) { + qrCode.append(ref.current) + } + }, + [qrCode], + ) + + useEffect(() => { + qrCode.update({ data }) + }, [data, qrCode]) + + return ( +
+ ) +} diff --git a/packages/extension/src/ui/features/accountTokens/StarknetIdCopyButton.tsx b/packages/extension/src/ui/components/StarknetIdCopyButton.tsx similarity index 100% rename from packages/extension/src/ui/features/accountTokens/StarknetIdCopyButton.tsx rename to packages/extension/src/ui/components/StarknetIdCopyButton.tsx diff --git a/packages/extension/src/ui/components/StarknetIdOrAddressCopyButton.tsx b/packages/extension/src/ui/components/StarknetIdOrAddressCopyButton.tsx new file mode 100644 index 000000000..afc53984d --- /dev/null +++ b/packages/extension/src/ui/components/StarknetIdOrAddressCopyButton.tsx @@ -0,0 +1,29 @@ +import { ButtonProps } from "@chakra-ui/react" +import { FC } from "react" +import { BaseWalletAccount } from "../../shared/wallet.model" +import { AddressCopyButton } from "./AddressCopyButton" +import { useStarknetId } from "../services/useStarknetId" +import { StarknetIdCopyButton } from "./StarknetIdCopyButton" + +export interface StarknetIdOrAddressCopyButtonProps extends ButtonProps { + account?: BaseWalletAccount +} + +export const StarknetIdOrAddressCopyButton: FC< + StarknetIdOrAddressCopyButtonProps +> = ({ account, ...rest }) => { + const { data: starknetId } = useStarknetId(account) + if (!account) { + return null + } + if (starknetId) { + return ( + + ) + } + return +} diff --git a/packages/extension/src/ui/components/StatusIndicator.tsx b/packages/extension/src/ui/components/StatusIndicator.tsx index 1050a88b0..343a8843f 100644 --- a/packages/extension/src/ui/components/StatusIndicator.tsx +++ b/packages/extension/src/ui/components/StatusIndicator.tsx @@ -1,66 +1,52 @@ -import { Box } from "@chakra-ui/react" -import { FC } from "react" +import { Box, Tooltip } from "@chakra-ui/react" + import styled, { css, keyframes } from "styled-components" import { NetworkStatus } from "../../shared/network" -import { assertNever } from "../../shared/utils/assertNever" -import { NetworkWarningIcon } from "./Icons/NetworkWarningIcon" -export type StatusIndicatorColor = "green" | "orange" | "red" | "neutral" +export type StatusIndicatorColor = + | "green" + | "orange" + | "red" + | "neutral" + | "hidden" -interface StatusIndicatorProps { - color?: StatusIndicatorColor +interface NetworkStatusResponse { + color: StatusIndicatorColor + label?: string + hexColor: string } -export function mapNetworkStatusToColor( - status?: NetworkStatus, -): StatusIndicatorColor { - switch (status) { - case "error": - return "red" - case "degraded": - return "orange" - case "ok": - return "green" - case "unknown": - return "neutral" - case undefined: - return "neutral" - default: - assertNever(status) - return "neutral" +export const statusMapping: { [key in NetworkStatus]: NetworkStatusResponse } = + { + red: { color: "red", hexColor: "#FF675C", label: "Very busy" }, + amber: { color: "orange", hexColor: "#FFBF3D", label: "Busy" }, + green: { color: "green", hexColor: "#08A681", label: "Live" }, + unknown: { color: "hidden", hexColor: "#BFBFBF" }, + } + +function mapNetworkStatus(status: NetworkStatus): NetworkStatusResponse { + const response = statusMapping[status] + if (!response) { + throw new Error(`Unexpected status: ${status}`) } + return response } -export const StatusIndicator = ({ - color = "neutral", -}: { - color: StatusIndicatorColor -}) => ( - -) +export const StatusIndicator = ({ status }: { status: NetworkStatus }) => { + const { color, label, hexColor } = mapNetworkStatus(status) -export const NetworkStatusIndicator: FC = ({ - color = "neutral", -}) => { - if (color === "orange") { - return - } - return + return ( + + + + ) } const PulseAnimation = keyframes` @@ -83,8 +69,8 @@ const PulseAnimation = keyframes` export const TransactionStatusIndicator = styled(StatusIndicator)` margin-right: 8px; - ${({ color }) => - color === "orange" && + ${({ status }) => + status === "amber" && css` box-shadow: 0 0 0 0 rgba(255, 168, 92, 1); transform: scale(1); diff --git a/packages/extension/src/ui/components/TokenOption.tsx b/packages/extension/src/ui/components/TokenOption.tsx index bbd56ecb5..9c8384c6b 100644 --- a/packages/extension/src/ui/components/TokenOption.tsx +++ b/packages/extension/src/ui/components/TokenOption.tsx @@ -1,17 +1,21 @@ import { FC, ReactNode } from "react" -import { H6, P4 } from "@argent/ui" -import { Flex, Img } from "@chakra-ui/react" +import { Button, H6, L2, P4 } from "@argent/ui" +import { Flex, Img, Spinner } from "@chakra-ui/react" interface TokenOptionProps { name: string symbol: string imageSrc: string balance: ReactNode - ccyBalance: ReactNode - - onClick?: () => void + ccyBalance?: string + onTokenSelect?: () => void disabled?: boolean + errorText?: string + requiresTxV3Upgrade?: boolean + onEnableTxV3?: () => void + ref?: React.Ref + upgradeLoading?: boolean } export const TokenOption: FC = ({ @@ -20,8 +24,13 @@ export const TokenOption: FC = ({ imageSrc, name, symbol, - onClick, + onTokenSelect, disabled, + errorText, + requiresTxV3Upgrade = false, + onEnableTxV3, + ref, + upgradeLoading = false, }) => { return ( = ({ alignItems="center" p={4} borderRadius={8} - onClick={onClick} + onClick={!disabled ? onTokenSelect : undefined} cursor={disabled ? "auto" : "pointer"} - pointerEvents={disabled ? "none" : "auto"} bg="neutrals.800" - opacity={disabled ? 0.4 : 1} boxShadow="menu" - _hover={{ bg: "neutrals.700" }} + _hover={!disabled ? { bg: "neutrals.700" } : undefined} + ref={ref} > = ({ height="32px" width="32px" /> - -
{name}
- - {symbol} - -
- -
{balance}
- - {ccyBalance} - + + +
+ {name === "Ether" ? "Ethereum" : name} +
+ + {symbol} + +
+ + {!requiresTxV3Upgrade ? ( + <> +
{balance}
+ {disabled ? ( + + {errorText} + + ) : ( + ccyBalance && ( + + {ccyBalance} + + ) + )} + + ) : ( + + )} +
) diff --git a/packages/extension/src/ui/features/accountActivity/AccountActivity.tsx b/packages/extension/src/ui/features/accountActivity/AccountActivity.tsx index 9bd484a6d..22bc72467 100644 --- a/packages/extension/src/ui/features/accountActivity/AccountActivity.tsx +++ b/packages/extension/src/ui/features/accountActivity/AccountActivity.tsx @@ -66,6 +66,7 @@ const AccountActivityItem: FC = ({ transaction, }) => { const navigate = useNavigate() + if (isActivityTransaction(transaction)) { const { hash, failureReason } = transaction const transactionTransformed = transformTransaction({ @@ -85,7 +86,7 @@ const AccountActivityItem: FC = ({ > {failureReason ? (
- +
) : null} diff --git a/packages/extension/src/ui/features/accountActivity/AccountActivityContainer.tsx b/packages/extension/src/ui/features/accountActivity/AccountActivityContainer.tsx index 78a497d43..d76c5f1ff 100644 --- a/packages/extension/src/ui/features/accountActivity/AccountActivityContainer.tsx +++ b/packages/extension/src/ui/features/accountActivity/AccountActivityContainer.tsx @@ -183,7 +183,9 @@ export const AccountActivityLoader: FC = ({ } if (isVoyagerTransaction(transaction)) { const { hash, meta } = transaction - const failureReason = getTransactionFailureReason(transaction.status) + const failureReason = getTransactionFailureReason( + getTransactionStatus(transaction), + ) const activityTransaction: ActivityTransaction = { hash, diff --git a/packages/extension/src/ui/features/accountActivity/TransactionDetail.tsx b/packages/extension/src/ui/features/accountActivity/TransactionDetail.tsx index eec7f5547..0def2ff7f 100644 --- a/packages/extension/src/ui/features/accountActivity/TransactionDetail.tsx +++ b/packages/extension/src/ui/features/accountActivity/TransactionDetail.tsx @@ -53,8 +53,8 @@ import { useTransactionFees } from "./useTransactionFees" import { useTransactionNonce } from "./useTransactionNonce" import { Token } from "../../../shared/token/__new/types/token.model" import { formatTruncatedAddress } from "@argent/shared" -import { ETH_TOKEN_ADDRESS } from "../../../shared/network/constants" import { getTransactionStatus } from "../../../shared/transactions/utils" +import { unitToFeeTokenAddress } from "../../../shared/transactionSimulation/utils" const { ActivityIcon } = icons @@ -428,8 +428,8 @@ export const TransactionDetail: FC = ({ {additionalFields} {txFee && ( )} diff --git a/packages/extension/src/ui/features/accountActivity/TransactionListItem.tsx b/packages/extension/src/ui/features/accountActivity/TransactionListItem.tsx index 3fbc805e4..8cb92e6ac 100644 --- a/packages/extension/src/ui/features/accountActivity/TransactionListItem.tsx +++ b/packages/extension/src/ui/features/accountActivity/TransactionListItem.tsx @@ -12,6 +12,7 @@ import { isDeployContractTransaction, isNFTTransaction, isNFTTransferTransaction, + isProvisionTransaction, isSwapTransaction, isTokenApproveTransaction, isTokenMintTransaction, @@ -56,9 +57,9 @@ export const TransactionListItem: FC = ({ const isTokenApprove = isTokenApproveTransaction(transactionTransformed) const isDeclareContract = isDeclareContractTransaction(transactionTransformed) const isDeployContract = isDeployContractTransaction(transactionTransformed) - + const isProvision = isProvisionTransaction(transactionTransformed) const subtitle = useMemo(() => { - if (isTransfer || isNFTTransfer) { + if (isTransfer || isNFTTransfer || isProvision) { const titleShowsTo = (isTransfer || isNFTTransfer) && (action === "SEND" || action === "TRANSFER") @@ -124,6 +125,7 @@ export const TransactionListItem: FC = ({ dapp, isDeclareContract, isDeployContract, + isProvision, action, transactionTransformed, network.id, @@ -185,7 +187,7 @@ export const TransactionListItem: FC = ({ }, [isNFT, isSwap, transactionTransformed, failureReason, network.id]) const accessory = useMemo(() => { - if (isTransfer || isTokenMint || isTokenApprove) { + if (isTransfer || isTokenMint || isTokenApprove || isProvision) { return ( = ({ isSwap, transactionTransformed, failureReason, + isProvision, ]) const isCancelled = failureReason === "CANCELLED" diff --git a/packages/extension/src/ui/features/accountActivity/transform/explorerTransaction/transformExplorerTransaction.ts b/packages/extension/src/ui/features/accountActivity/transform/explorerTransaction/transformExplorerTransaction.ts index a435b7124..97fc72f58 100644 --- a/packages/extension/src/ui/features/accountActivity/transform/explorerTransaction/transformExplorerTransaction.ts +++ b/packages/extension/src/ui/features/accountActivity/transform/explorerTransaction/transformExplorerTransaction.ts @@ -17,6 +17,7 @@ import knownDappTransformer from "./transformers/knownDappTransformer" import knownNftTransformer from "./transformers/knownNftTransformer" import postSwapTransformer from "./transformers/postSwapTransformer" import postTransferTransformer from "./transformers/postTransferTransformer" +import provisionTransformer from "./transformers/provisionTransformer" import tokenApproveTransformer from "./transformers/tokenApproveTransformer" import tokenMintTransformer from "./transformers/tokenMintTransformer" import tokenTransferTransformer from "./transformers/tokenTransferTransformer" @@ -45,6 +46,7 @@ const mainTransformers = [ tokenMintTransformer, tokenTransferTransformer, tokenApproveTransformer, + provisionTransformer, ] /** all are executed */ diff --git a/packages/extension/src/ui/features/accountActivity/transform/explorerTransaction/transformers/provisionTransformer.ts b/packages/extension/src/ui/features/accountActivity/transform/explorerTransaction/transformers/provisionTransformer.ts new file mode 100644 index 000000000..bb4763ff4 --- /dev/null +++ b/packages/extension/src/ui/features/accountActivity/transform/explorerTransaction/transformers/provisionTransformer.ts @@ -0,0 +1,54 @@ +import { IExplorerTransactionTransformer } from "./type" +import { ProvisionTransaction } from "../../type" +import { getParameter } from "../getParameter" +import { isEqualAddress } from "@argent/shared" +import { STRK_TOKEN_ADDRESS } from "../../../../../../shared/network/constants" +import { getTokenForContractAddress } from "../../getTokenForContractAddress" +import { PROVISION_CONTRACT_ADDRESSES } from "../../../../../../shared/api/constants" + +const PROVISION_EVENT = "ClaimServed" + +export default function ({ + tokensByNetwork, + explorerTransaction, + result, +}: IExplorerTransactionTransformer) { + const eventsNames = explorerTransaction.events.map((event) => event.name) + const { calls, events } = explorerTransaction + + const callWithClaim = calls?.find((call) => call.name === "claim") + if ( + eventsNames.includes(PROVISION_EVENT) && + callWithClaim && + callWithClaim.address && + PROVISION_CONTRACT_ADDRESSES.find((address) => + isEqualAddress(address, callWithClaim.address), + ) + ) { + const entity = "TOKEN" + const action = "PROVISION" + const displayName = "Receive airdrop" + const tokenAddress = STRK_TOKEN_ADDRESS + + const parameters = + events.find((evt) => evt.name === PROVISION_EVENT)?.parameters ?? + undefined + const fromAddress = callWithClaim.address + const toAddress = getParameter(parameters, "recipient") + const amount = getParameter(parameters, "amount") + const token = getTokenForContractAddress(tokenAddress, tokensByNetwork) + + result = { + ...result, + action, + entity, + displayName, + fromAddress, + toAddress, + amount, + tokenAddress, + token, + } as ProvisionTransaction + return result + } +} diff --git a/packages/extension/src/ui/features/accountActivity/transform/is.ts b/packages/extension/src/ui/features/accountActivity/transform/is.ts index c48fb05f1..426fe17cf 100644 --- a/packages/extension/src/ui/features/accountActivity/transform/is.ts +++ b/packages/extension/src/ui/features/accountActivity/transform/is.ts @@ -6,6 +6,7 @@ import { DeployContractTransaction, NFTTransaction, NFTTransferTransaction, + ProvisionTransaction, SwapTransaction, TokenApproveTransaction, TokenMintTransaction, @@ -92,3 +93,10 @@ export const isExplorerTransaction = ( ): transaction is IExplorerTransaction => { return !!(!isVoyagerTransaction(transaction) && transaction.transactionHash) } + +export const isProvisionTransaction = ( + transaction: TransformedTransaction, +): transaction is ProvisionTransaction => { + const { entity, action } = transaction + return entity === "TOKEN" && action === "PROVISION" +} diff --git a/packages/extension/src/ui/features/accountActivity/transform/type.ts b/packages/extension/src/ui/features/accountActivity/transform/type.ts index 990636ad2..e8ce7c386 100644 --- a/packages/extension/src/ui/features/accountActivity/transform/type.ts +++ b/packages/extension/src/ui/features/accountActivity/transform/type.ts @@ -18,6 +18,7 @@ export type TransformedTransactionAction = | "REMOVE" | "CHANGE" | "REPLACE" + | "PROVISION" export type TransformedTransactionEntity = | "UNKNOWN" @@ -51,6 +52,16 @@ export interface TokenTransferTransaction extends BaseTransformedTransaction { token: Token } +export interface ProvisionTransaction extends BaseTransformedTransaction { + action: "PROVISION" + entity: "TOKEN" + amount: string + fromAddress: string + toAddress: string + tokenAddress: string + token: Token +} + export interface TokenApproveTransaction extends BaseTransformedTransaction { action: "APPROVE" entity: "TOKEN" diff --git a/packages/extension/src/ui/features/accountActivity/ui/TransactionIcon.tsx b/packages/extension/src/ui/features/accountActivity/ui/TransactionIcon.tsx index bc4c6173e..b7a30ea86 100644 --- a/packages/extension/src/ui/features/accountActivity/ui/TransactionIcon.tsx +++ b/packages/extension/src/ui/features/accountActivity/ui/TransactionIcon.tsx @@ -4,6 +4,7 @@ import { FC } from "react" import { getTokenIconUrl } from "../../accountTokens/TokenIcon" import { + isProvisionTransaction, isSwapTransaction, isTokenApproveTransaction, isTokenMintTransaction, @@ -28,6 +29,7 @@ const { MultisigRemoveIcon, MultisigReplaceIcon, FailIcon, + ParachuteIcon, } = icons export interface TransactionIconProps extends Omit { @@ -91,6 +93,9 @@ export const TransactionIcon: FC = ({ case "APPROVE": iconComponent = break + case "PROVISION": + iconComponent = + break } if (entity === "CONTRACT" && (action === "DEPLOY" || action === "DECLARE")) { @@ -100,7 +105,8 @@ export const TransactionIcon: FC = ({ if ( isTokenTransferTransaction(transaction) || isTokenMintTransaction(transaction) || - isTokenApproveTransaction(transaction) + isTokenApproveTransaction(transaction) || + isProvisionTransaction(transaction) ) { const { token } = transaction if (token) { diff --git a/packages/extension/src/ui/features/accountActivity/ui/TransferAccessory.tsx b/packages/extension/src/ui/features/accountActivity/ui/TransferAccessory.tsx index 575f616f8..0c3445ded 100644 --- a/packages/extension/src/ui/features/accountActivity/ui/TransferAccessory.tsx +++ b/packages/extension/src/ui/features/accountActivity/ui/TransferAccessory.tsx @@ -4,6 +4,7 @@ import { FC } from "react" import { useDisplayTokenAmountAndCurrencyValue } from "../../accountTokens/useDisplayTokenAmountAndCurrencyValue" import { + ProvisionTransaction, TokenApproveTransaction, TokenMintTransaction, TokenTransferTransaction, @@ -14,6 +15,7 @@ export interface TransferAccessoryProps { | TokenTransferTransaction | TokenMintTransaction | TokenApproveTransaction + | ProvisionTransaction failed?: boolean } @@ -28,8 +30,8 @@ export const TransferAccessory: FC = ({ if (!displayAmount) { return null } - const prefix = - action === "SEND" ? <>− : action === "RECEIVE" ? <>+ : null + const isInflux = action === "RECEIVE" || action === "PROVISION" + const prefix = action === "SEND" ? <>− : isInflux ? <>+ : null return ( @@ -37,7 +39,7 @@ export const TransferAccessory: FC = ({ overflow="hidden" textOverflow={"ellipsis"} textAlign={"right"} - color={action === "RECEIVE" ? "secondary.500" : undefined} + color={isInflux ? "secondary.500" : undefined} textDecoration={failed ? "line-through" : undefined} > {prefix} diff --git a/packages/extension/src/ui/features/accountActivity/useActivity.ts b/packages/extension/src/ui/features/accountActivity/useActivity.ts index 89e463634..064234c1d 100644 --- a/packages/extension/src/ui/features/accountActivity/useActivity.ts +++ b/packages/extension/src/ui/features/accountActivity/useActivity.ts @@ -1,12 +1,5 @@ import { TransactionMeta } from "../../../shared/transactions" -import { BaseWalletAccount } from "../../../shared/wallet.model" -import { formatDate } from "../../services/dates" -import { useAccountTransactions } from "../accounts/accountTransactions.state" -import { - ActivityTransactionFailureReason, - getTransactionFailureReason, -} from "./getTransactionFailureReason" -import { getTransactionStatus } from "../../../shared/transactions/utils" +import { ActivityTransactionFailureReason } from "./getTransactionFailureReason" export interface ActivityTransaction { hash: string @@ -17,32 +10,3 @@ export interface ActivityTransaction { } export type DailyActivity = Record - -export function useActivity(account: BaseWalletAccount): DailyActivity { - const { transactions } = useAccountTransactions(account) - const activity: DailyActivity = {} - for (const transaction of transactions) { - // RECEIVED transactions are already shown as pending - const { timestamp, hash, meta } = transaction - const { finality_status, execution_status } = - getTransactionStatus(transaction) - - if (finality_status !== "RECEIVED") { - const date = new Date(timestamp * 1000).toISOString() - const dateLabel = formatDate(date) - const failureReason = getTransactionFailureReason({ - finality_status, - execution_status, - }) - - activity[dateLabel] ||= [] - activity[dateLabel].push({ - hash, - date, - meta, - failureReason, - }) - } - } - return activity -} diff --git a/packages/extension/src/ui/features/accountActivity/useTransactionFees.test.ts b/packages/extension/src/ui/features/accountActivity/useTransactionFees.test.ts index 37b5c69e4..0a158ce56 100644 --- a/packages/extension/src/ui/features/accountActivity/useTransactionFees.test.ts +++ b/packages/extension/src/ui/features/accountActivity/useTransactionFees.test.ts @@ -7,17 +7,21 @@ import { useTransactionFees } from "./useTransactionFees" describe("useTransactionFees", () => { vi.mock("../../../shared/network", () => ({ - getProvider: vi.fn(() => { + getProvider6: vi.fn(() => { return { getTransactionReceipt: vi.fn(() => { return { - actual_fee: "0", + actual_fee: { + amount: "0x1", + unit: "WEI", + }, } }), } }), })) - test("it should return backend enriched data when available ", async () => { + // TODO: reenable this test once we have the actual fee from the backend + test.skip("it should return backend enriched data when available ", async () => { const payload = { network: {} as Network, transactionTransformed: { @@ -37,6 +41,8 @@ describe("useTransactionFees", () => { hash: "0x123", } const { result } = renderHook(() => useTransactionFees(payload)) - await waitFor(() => expect(result?.current).toBe("0")) + await waitFor(() => + expect(result?.current).toMatchObject({ amount: "0x1", unit: "WEI" }), + ) }) }) diff --git a/packages/extension/src/ui/features/accountActivity/useTransactionFees.ts b/packages/extension/src/ui/features/accountActivity/useTransactionFees.ts index 339bed626..e54d5a2ee 100644 --- a/packages/extension/src/ui/features/accountActivity/useTransactionFees.ts +++ b/packages/extension/src/ui/features/accountActivity/useTransactionFees.ts @@ -1,6 +1,6 @@ import useSWR from "swr" -import { Network, getProvider } from "../../../shared/network" +import { Network, getProvider6 } from "../../../shared/network" import { TransformedTransaction } from "./transform/type" export const useTransactionFees = ({ @@ -16,12 +16,17 @@ export const useTransactionFees = ({ if (!hash) { return } - const receipt = await getProvider(network).getTransactionReceipt(hash) + // TODO: TXV3 - use actual fee from transactionTransformed as soon as backend supports the fee for both tokens + // if (transactionTransformed.actualFee) { + // return transactionTransformed.actualFee + // } + + const receipt = await getProvider6(network).getTransactionReceipt(hash) const transactionFees = "actual_fee" in receipt ? receipt.actual_fee : undefined - return transactionTransformed.actualFee ?? transactionFees + return transactionFees } const { data: txFee } = useSWR( diff --git a/packages/extension/src/ui/features/accountNfts/EmptyCollections.tsx b/packages/extension/src/ui/features/accountNfts/EmptyCollections.tsx index de3eecdbd..b1de37fd8 100644 --- a/packages/extension/src/ui/features/accountNfts/EmptyCollections.tsx +++ b/packages/extension/src/ui/features/accountNfts/EmptyCollections.tsx @@ -57,7 +57,7 @@ const EmptyCollections: FC<{ networkId: string }> = () => ( - Discover NFTs on StarkNet + Discover NFTs on Starknet diff --git a/packages/extension/src/ui/features/accountNfts/NftItem.tsx b/packages/extension/src/ui/features/accountNfts/NftItem.tsx index fdd32de6f..1734b1c5c 100644 --- a/packages/extension/src/ui/features/accountNfts/NftItem.tsx +++ b/packages/extension/src/ui/features/accountNfts/NftItem.tsx @@ -40,6 +40,7 @@ const NftItem: FC = ({ logoSrc, name, thumbnailSrc, total }) => (
= ({ onViewNft, onSendNft, }) => { - const description = nft.description ?? nft.collection?.description + const description = nft.description const hasDescription = description?.length > 0 return ( <> @@ -81,11 +81,7 @@ export const NftScreen: FC = ({ Description - - {nft.description.length > 0 - ? nft.description - : nft.collection?.description} - + {nft.description} diff --git a/packages/extension/src/ui/features/accountTokens/AccountTokens.test.tsx b/packages/extension/src/ui/features/accountTokens/AccountTokens.test.tsx index 074ab8df2..16e058829 100644 --- a/packages/extension/src/ui/features/accountTokens/AccountTokens.test.tsx +++ b/packages/extension/src/ui/features/accountTokens/AccountTokens.test.tsx @@ -1,19 +1,17 @@ import { render, screen } from "@testing-library/react" -import userEvent from "@testing-library/user-event" import { BrowserRouter } from "react-router-dom" import { describe, expect, it, vi } from "vitest" import { getMockAccount } from "../../../../test/account.mock" import { AccountTokens, AccountTokensProps } from "./AccountTokens" +import * as accountsState from "../accounts/accounts.state" const mockProps = { account: getMockAccount({}), status: { text: "active", code: "CONNECTED" }, onRedeploy: vi.fn(), showAvnuBanner: false, - showEkuboBanner: true, setAvnuBannerSeen: vi.fn(), - setEkuboBannerSeen: vi.fn(), showTokensAndBanners: true, setDappLandBannerSeen: vi.fn(), hasEscape: true, @@ -24,9 +22,20 @@ const mockProps = { tokenListVariant: "default", showSaveRecoverySeedphraseBanner: true, isDeprecated: false, + onProvisionBannerClose: vi.fn(), + shouldShowProvisionBanner: false, + provisionStatus: { + status: "disabled", + bannerUrl: "https://argent.xyz", + link: "https://argent.xyz", + bannerTitle: "Argent", + bannerDescription: "Braavos is the best starknet wallet", + }, } as AccountTokensProps describe("AccountTokens", () => { + vi.spyOn(accountsState, "useAccount").mockReturnValue(mockProps.account) + it('renders the component with the "empty" component when showTokensAndBanners is false', async () => { const props = { ...mockProps, @@ -43,23 +52,7 @@ describe("AccountTokens", () => { await screen.findByText("You can no longer use this account"), ).toBeInTheDocument() }) - it("calls the setEkuboBannerSeen function when the ekubo banner is closed", async () => { - const props = { - ...mockProps, - showTokensAndBanners: true, - } - - render( - - - , - ) - - const closeButton = await screen.findByTestId("close-banner") - await userEvent.click(closeButton) - expect(props.setEkuboBannerSeen).toHaveBeenCalled() - }) it("renders the component with the account deprecated banner, when isDeprecated is true", async () => { const props = { ...mockProps, diff --git a/packages/extension/src/ui/features/accountTokens/AccountTokens.tsx b/packages/extension/src/ui/features/accountTokens/AccountTokens.tsx index 06daf0f9d..92c55065e 100644 --- a/packages/extension/src/ui/features/accountTokens/AccountTokens.tsx +++ b/packages/extension/src/ui/features/accountTokens/AccountTokens.tsx @@ -1,7 +1,5 @@ import { CellStack, Banner, Empty, icons } from "@argent/ui" -import avnuBanner from "@argent/ui/assets/avnuBannerBackground.png" -import ekuboBanner from "@argent/ui/assets/ekuboBannerBackground.png" -import { Center, Flex, VStack } from "@chakra-ui/react" +import { Flex, VStack } from "@chakra-ui/react" import { FC } from "react" import { routes } from "../../routes" @@ -9,7 +7,7 @@ import { Account } from "../accounts/Account" import { Multisig } from "../multisig/Multisig" import { MultisigBanner } from "../multisig/MultisigBanner" import { EscapeBanner } from "../shield/escape/EscapeBanner" -import { StatusMessageBannerContainer } from "../statusMessage/StatusMessageBanner" +import { StatusMessageBannerContainer } from "../statusMessage/StatusMessageBannerContainer" import { AccountTokensButtonsContainer } from "./AccountTokensButtonsContainer" import { AccountTokensHeader } from "./AccountTokensHeader" import { SaveRecoverySeedphraseBanner } from "./SaveRecoverySeedphraseBanner" @@ -17,16 +15,15 @@ import { TokenList } from "./TokenList" import { TokenListItemVariant } from "./TokenListItem" import { UpgradeBanner } from "./UpgradeBanner" import { AccountDeprecatedBanner } from "./warning/AccountDeprecatedBanner" -import { classHashSupportsTxV3 } from "../../../shared/network/txv3" import { AccountOwnerBanner } from "./warning/AccountOwnerBanner" +import { ProvisionStatus } from "../../../shared/provision/types" +import { isEmpty } from "lodash-es" -const { MultisigIcon, WalletIcon } = icons +const { MultisigIcon } = icons export interface AccountTokensProps { account: Account showTokensAndBanners: boolean - showEkuboBanner: boolean - showAvnuBanner: boolean hasEscape: boolean accountGuardianIsSelf: boolean | null accountOwnerIsSelf?: boolean @@ -35,21 +32,23 @@ export interface AccountTokensProps { onUpgradeBannerClick?: () => void upgradeLoading?: boolean multisig?: Multisig - showAddFundsBackdrop?: boolean tokenListVariant?: TokenListItemVariant hasFeeTokenBalance?: boolean showSaveRecoverySeedphraseBanner: boolean isDeprecated?: boolean - setEkuboBannerSeen: () => void - setAvnuBannerSeen: () => void onAvnuClick?: () => void returnTo?: string + onProvisionBannerClose: () => void + shouldShowProvisionBanner: boolean + provisionStatus: + | (ProvisionStatus & { + bannerUrl: string + }) + | undefined } export const AccountTokens: FC = ({ account, - showEkuboBanner, - showAvnuBanner, showTokensAndBanners, hasEscape, accountGuardianIsSelf, @@ -59,46 +58,45 @@ export const AccountTokens: FC = ({ onUpgradeBannerClick, upgradeLoading, multisig, - showAddFundsBackdrop, tokenListVariant, hasFeeTokenBalance, showSaveRecoverySeedphraseBanner, isDeprecated = false, - setEkuboBannerSeen, - setAvnuBannerSeen, - onAvnuClick, returnTo, + onProvisionBannerClose, + provisionStatus, + shouldShowProvisionBanner, }) => { - const supportsStrkAsFeeToken = classHashSupportsTxV3(account.classHash) - const feeTokenCurrency = supportsStrkAsFeeToken ? "ETH or STRK" : "ETH" return ( - + - + {showTokensAndBanners ? ( - - {showEkuboBanner && ( + + {shouldShowProvisionBanner && provisionStatus && ( - )} - {showAvnuBanner && ( - )} {showSaveRecoverySeedphraseBanner && } @@ -134,25 +132,12 @@ export const AccountTokens: FC = ({ hasFeeTokenBalance={hasFeeTokenBalance} /> )} - {showAddFundsBackdrop && ( - } - title={"Add funds"} - > -
- {multisig - ? `You will need some ${feeTokenCurrency} to activate the multisig account` - : `You will need some ${feeTokenCurrency} to use the account`} -
-
- )} - {!showAddFundsBackdrop && ( - null : undefined} - /> - )} + + null : undefined} + />
) : ( = ({ const navigate = useNavigate() const returnTo = useCurrentPathnameWithQuery() const { pendingTransactions } = useAccountTransactions(account) - const [hasSeenEkuboBanner, setHasSeenEkuboBanner] = useAtom(hasSeenEkuboAtom) - const [hasSeenAvnuBanner, setHasSeenAvnuBanner] = useAtom(hasSeenAvnuAtom) const currencyDisplayEnabled = useCurrencyDisplayEnabled() const transactionsBeforeReview = useKeyValueStorage( userReviewStore, @@ -48,7 +45,8 @@ export const AccountTokensContainer: FC = ({ const isMainnet = useIsMainnet() const [upgradeLoading, setUpgradeLoading] = useState(false) const userHasReviewed = useKeyValueStorage(userReviewStore, "hasReviewed") - + const { provisionStatus, onProvisionBannerClose, shouldShowProvisionBanner } = + useProvisionBanner() const hasPendingTransactions = pendingTransactions.length > 0 useEffect(() => { @@ -77,10 +75,11 @@ export const AccountTokensContainer: FC = ({ ) const showNoBalanceForUpgrade = Boolean( - needsUpgrade && - !hasPendingTransactions && - !hasFeeTokenBalance && - !account.needsDeploy, + showUpgradeBanner && !hasFeeTokenBalance, + ) + + const showWithBalanceForUpgrade = Boolean( + showUpgradeBanner && hasFeeTokenBalance, ) const hasEscape = accountHasEscape(account) @@ -103,28 +102,15 @@ export const AccountTokensContainer: FC = ({ return !hasSavedRecoverySeedPhrase && isMainnet }, [hasSavedRecoverySeedPhrase, isMainnet]) - const showAddFundsBackdrop = useMemo(() => { - return !showSaveRecoverySeedphraseBanner && !hasFeeTokenBalance - }, [hasFeeTokenBalance, showSaveRecoverySeedphraseBanner]) - - const shouldShowDappBanner = - !showAddFundsBackdrop && + // If important banners are displayed we dont want to display secondary banners + const canShowSecondaryBanner = !showSaveRecoverySeedphraseBanner && !needsUpgrade && - !hasPendingTransactions && !hasEscape && !multisig?.needsDeploy - const showAvnuBanner = !hasSeenAvnuBanner && shouldShowDappBanner - // Show Ekubo banner only after Avnu banner has been dismissed - const showEkuboBanner = - !hasSeenEkuboBanner && shouldShowDappBanner && hasSeenAvnuBanner - const setAvnuBannerSeen = useCallback(() => { - setHasSeenAvnuBanner(true) - }, [setHasSeenAvnuBanner]) - const setEkuboBannerSeen = useCallback(() => { - setHasSeenEkuboBanner(true) - }, [setHasSeenEkuboBanner]) + const showProvisionBanner = + shouldShowProvisionBanner && canShowSecondaryBanner const hadPendingTransactions = useRef(false) @@ -149,31 +135,26 @@ export const AccountTokensContainer: FC = ({ setUpgradeLoading(false) }, [account, navigate, showNoBalanceForUpgrade]) - const onAvnuClick = () => navigate(routes.swap()) - return ( void onUpgradeBannerClick()} upgradeLoading={upgradeLoading} multisig={multisig} - showAddFundsBackdrop={showAddFundsBackdrop} tokenListVariant={tokenListVariant} hasFeeTokenBalance={hasFeeTokenBalance} showSaveRecoverySeedphraseBanner={showSaveRecoverySeedphraseBanner} isDeprecated={isDeprecated} - showEkuboBanner={showEkuboBanner} - showAvnuBanner={showAvnuBanner} - setAvnuBannerSeen={setAvnuBannerSeen} - setEkuboBannerSeen={setEkuboBannerSeen} - onAvnuClick={onAvnuClick} returnTo={returnTo} /> diff --git a/packages/extension/src/ui/features/accountTokens/AccountTokensHeader.tsx b/packages/extension/src/ui/features/accountTokens/AccountTokensHeader.tsx index ce18c06e4..ce0ac8c7c 100644 --- a/packages/extension/src/ui/features/accountTokens/AccountTokensHeader.tsx +++ b/packages/extension/src/ui/features/accountTokens/AccountTokensHeader.tsx @@ -3,11 +3,9 @@ import { Center, VStack } from "@chakra-ui/react" import { FC } from "react" import { BaseWalletAccount } from "../../../shared/wallet.model" -import { AddressCopyButton } from "../../components/AddressCopyButton" -import { useStarknetId } from "../../services/useStarknetId" import { useMultisig } from "../multisig/multisig.state" -import { StarknetIdCopyButton } from "./StarknetIdCopyButton" import { usePrettyAccountBalance } from "./usePrettyAccountBalance" +import { StarknetIdOrAddressCopyButton } from "../../components/StarknetIdOrAddressCopyButton" interface AccountSubheaderProps { account: BaseWalletAccount @@ -19,11 +17,8 @@ export const AccountTokensHeader: FC = ({ accountName, }) => { const prettyAccountBalance = usePrettyAccountBalance(account) - const accountAddress = account.address const multisig = useMultisig(account) // This will be undefined if the account is not a multisig - const { data: starknetId } = useStarknetId(account) - return ( {multisig && ( @@ -41,14 +36,7 @@ export const AccountTokensHeader: FC = ({
)}

{prettyAccountBalance || accountName}

- {starknetId ? ( - - ) : ( - - )} + ) } diff --git a/packages/extension/src/ui/features/accountTokens/ExportPrivateKeyScreen.tsx b/packages/extension/src/ui/features/accountTokens/ExportPrivateKeyScreen.tsx deleted file mode 100644 index 066118a0b..000000000 --- a/packages/extension/src/ui/features/accountTokens/ExportPrivateKeyScreen.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { BarBackButton, BarCloseButton, NavigationContainer } from "@argent/ui" -import { FC, ReactNode, useState } from "react" -import { useNavigate } from "react-router-dom" -import styled from "styled-components" - -import { Button } from "../../components/Button" -import { CopyTooltip } from "../../components/CopyTooltip" -import { Paragraph } from "../../components/Page" -import { routes, useRouteAccountAddress } from "../../routes" -import { H2 } from "../../theme/Typography" -import { StickyGroup } from "../actions/DeprecatedConfirmScreen" -import { PasswordForm } from "../lock/PasswordForm" -import { useCurrentNetwork } from "../networks/hooks/useCurrentNetwork" -import { StatusMessageBanner } from "../statusMessage/StatusMessageBanner" -import { usePrivateKey } from "./usePrivateKey" -import { sessionService } from "../../services/session" - -const Container = styled.div` - display: flex; - flex-direction: column; - padding: 16px 32px 0 32px; - - form { - padding-top: 16px; - - ${Button} { - margin-top: 16px; - } - } -` - -const KeyContainer = styled.div` - background: ${({ theme }) => theme.bg2}; - border: 1px solid ${({ theme }) => theme.bg1}; - border-radius: 4px; - padding: 9px 13px 8px; - overflow-wrap: break-word; - font-size: 16px; - line-height: 140%; - cursor: pointer; -` - -const WarningContainer = styled.div` - margin-top: 15px; - border: 1px solid ${({ theme }) => theme.bg2}; - padding: 9px 13px 8px; - overflow-wrap: break-word; - font-size: 14px; - line-height: 120%; - border-radius: 4px; -` - -const Wrapper: FC<{ children: ReactNode }> = ({ children }) => { - const navigate = useNavigate() - return ( - } - rightButton={ - navigate(routes.accountTokens())} /> - } - > - -

Export private key

- {children} -
-
- ) -} - -export const ExportPrivateKeyScreen: FC = () => { - const [isPasswordValid, setPasswordValid] = useState(false) - - const navigate = useNavigate() - const accountAddress = useRouteAccountAddress() - const network = useCurrentNetwork() - - const privateKey = usePrivateKey(accountAddress, network.id) - - const handleVerifyPassword = async (password: string) => { - const isValid = await sessionService.checkPassword(password) - setPasswordValid(isValid) - return isValid - } - - if (!isPasswordValid) { - return ( - - Enter your password to export your private key. - - - {() => ( - - - - )} - - - ) - } - - return ( - - This is your private key (click to copy) - - { - // not possible - }} - style={{ - marginBottom: "16px", - width: "100%", - }} - /> - - {privateKey && ( - - {privateKey} - - )} - - - Warning: Never disclose this key. Anyone with your private keys can - steal any assets held in your account. - - - - - - - ) -} diff --git a/packages/extension/src/ui/features/accountTokens/SaveRecoverySeedphraseBanner.tsx b/packages/extension/src/ui/features/accountTokens/SaveRecoverySeedphraseBanner.tsx index 8eb9bdc72..a0d351f96 100644 --- a/packages/extension/src/ui/features/accountTokens/SaveRecoverySeedphraseBanner.tsx +++ b/packages/extension/src/ui/features/accountTokens/SaveRecoverySeedphraseBanner.tsx @@ -1,7 +1,8 @@ import { H6, P4, icons } from "@argent/ui" -import { Circle, Flex, Button } from "@chakra-ui/react" +import { Button, Center, Circle } from "@chakra-ui/react" import { FC } from "react" import { useNavigate } from "react-router-dom" + import { routes } from "../../routes" const { PasswordIcon } = icons @@ -12,40 +13,34 @@ export const SaveRecoverySeedphraseBanner: FC = () => { navigate(routes.setupSeedRecovery()) } return ( - - - + + - -
Save your recovery phrase
- - - It is very important you save this as it’s the only way you can - recover your account{" "} - -
+
Save your recovery phrase
+ + It is very important you save this as it’s the only way you can recover + your account + -
+ ) } diff --git a/packages/extension/src/ui/features/accountTokens/TokenIcon.tsx b/packages/extension/src/ui/features/accountTokens/TokenIcon.tsx index e82d366d4..4c6ec7d66 100644 --- a/packages/extension/src/ui/features/accountTokens/TokenIcon.tsx +++ b/packages/extension/src/ui/features/accountTokens/TokenIcon.tsx @@ -7,21 +7,34 @@ import { getColor } from "../accounts/accounts.service" export interface TokenIconProps extends Pick, ImageProps { name: string url?: string + iconUrl?: string + image?: string } export const getTokenIconUrl = ({ - url, name, -}: Pick) => { - if (url && url.length) { - return url + url, + iconUrl, + image, +}: Pick) => { + const imgUrl = url || iconUrl || image + if (imgUrl && imgUrl.length) { + return imgUrl } + const background = getColor(name) return generateAvatarImage(name, { background }) } -export const TokenIcon: FC = ({ name, url, size, ...rest }) => { - const src = getTokenIconUrl({ url, name }) +export const TokenIcon: FC = ({ + name, + url, + iconUrl, + image, + size, + ...rest +}) => { + const src = getTokenIconUrl({ url, iconUrl, image, name }) return ( = ({ fontWeight={"semibold"} overflow="hidden" textOverflow={"ellipsis"} + data-testid={`${token.symbol}-balance`} > {displayBalance} diff --git a/packages/extension/src/ui/features/accountTokens/WarningRecoverySeedphraseBanner.tsx b/packages/extension/src/ui/features/accountTokens/WarningRecoverySeedphraseBanner.tsx deleted file mode 100644 index c8c898eb0..000000000 --- a/packages/extension/src/ui/features/accountTokens/WarningRecoverySeedphraseBanner.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { H6, icons } from "@argent/ui" -import { Box, Flex, HStack, Text } from "@chakra-ui/react" -import { FC } from "react" - -const { AlertFillIcon } = icons - -export const WarningRecoverySeedphraseBanner: FC = () => { - return ( - -
- Never share your recovery phrase! -
- - - - - - It's the only way to recover your wallet - - - - - - - - If someone else has access to your recovery phrase they can control - your wallet - - -
- ) -} diff --git a/packages/extension/src/ui/features/accountTokens/banner/banner.state.ts b/packages/extension/src/ui/features/accountTokens/banner/banner.state.ts deleted file mode 100644 index 5d8ab7c36..000000000 --- a/packages/extension/src/ui/features/accountTokens/banner/banner.state.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { atomWithStorage } from "jotai/utils" - -export const hasSeenEkuboAtom = atomWithStorage("hasSeenEkubo", false) - -export const hasSeenAvnuAtom = atomWithStorage("hasSeenAvnu", false) diff --git a/packages/extension/src/ui/features/accountTokens/banner/useAirdropBanner.tsx b/packages/extension/src/ui/features/accountTokens/banner/useAirdropBanner.tsx new file mode 100644 index 000000000..fcf87e7be --- /dev/null +++ b/packages/extension/src/ui/features/accountTokens/banner/useAirdropBanner.tsx @@ -0,0 +1,58 @@ +import useSWR from "swr" +import { provisionService } from "../../../services/provision" + +import airdropBanner from "@argent/ui/assets/airdrop.png" +import airdropHalted from "@argent/ui/assets/airdropHalted.png" +import { + provisionBannerAtom, + provisionBannerStore, +} from "../../../services/provision/provision.state" +import { RefreshInterval } from "../../../../shared/config" +import { useView } from "../../../views/implementation/react" + +export const useProvisionBanner = () => { + const { lastDismissedStatus } = useView(provisionBannerAtom) + + const { data: provisionStatus } = useSWR( + "provisionStatus", + async () => { + const provisionStatus = await provisionService.getStatus() + + if (provisionStatus.status === "disabled") { + return undefined + } + + let bannerUrl + if ( + provisionStatus.status === "notActive" || + provisionStatus.status === "paused" + ) { + bannerUrl = airdropHalted + } else { + bannerUrl = airdropBanner + } + return { + ...provisionStatus, + bannerUrl, + } + }, + { + refreshInterval: RefreshInterval.MEDIUM * 1000, + dedupingInterval: RefreshInterval.MEDIUM * 1000, + }, + ) + + const onProvisionBannerClose = () => { + void provisionBannerStore.set({ + lastDismissedStatus: provisionStatus?.status ?? null, + }) + } + + const shouldShowProvisionBanner = + provisionStatus && provisionStatus?.status !== lastDismissedStatus + return { + shouldShowProvisionBanner, + provisionStatus, + onProvisionBannerClose, + } +} diff --git a/packages/extension/src/ui/features/accountTokens/tokenPriceHooks.ts b/packages/extension/src/ui/features/accountTokens/tokenPriceHooks.ts index bc5121d08..76371b680 100644 --- a/packages/extension/src/ui/features/accountTokens/tokenPriceHooks.ts +++ b/packages/extension/src/ui/features/accountTokens/tokenPriceHooks.ts @@ -26,7 +26,10 @@ import { RefreshInterval } from "../../../shared/config" import { useView } from "../../views/implementation/react" import { tokenPricesView } from "../../views/tokenPrices" import { allTokensView } from "../../views/token" -import { TokenWithOptionalBigIntBalance } from "../../../shared/token/__new/types/tokenBalance.model" +import { + TokenWithBalance, + TokenWithOptionalBigIntBalance, +} from "../../../shared/token/__new/types/tokenBalance.model" import { BaseTokenSchema, Token, @@ -173,7 +176,7 @@ export const useTokenAmountToCurrencyValue = ( */ export const useTokenBalanceToCurrencyValue = ( - token?: TokenWithOptionalBigIntBalance, + token?: TokenWithBalance | TokenWithOptionalBigIntBalance, usePriceAndTokenDataImpl = usePriceAndTokenData, ) => { return useTokenAmountToCurrencyValue( diff --git a/packages/extension/src/ui/features/accountTokens/tokens.state.ts b/packages/extension/src/ui/features/accountTokens/tokens.state.ts index b441b3aaa..1beba602d 100644 --- a/packages/extension/src/ui/features/accountTokens/tokens.state.ts +++ b/packages/extension/src/ui/features/accountTokens/tokens.state.ts @@ -11,7 +11,7 @@ import { } from "../../views/token" import { useAccount } from "../accounts/accounts.state" import { BaseToken, Token } from "../../../shared/token/__new/types/token.model" -import { tokenBalanceForAccountView } from "../../views/tokenBalances" +import { tokenBalancesForAccountView } from "../../views/tokenBalances" import { Address, isEqualAddress } from "@argent/shared" import { useCurrentNetwork } from "../networks/hooks/useCurrentNetwork" import { useAppState } from "../../app.state" @@ -22,7 +22,7 @@ export const useNetworkFeeTokens = (networkId?: string) => { return feeTokens } -export const useTokensInNetwork = (networkId: string) => +export const useTokensInNetwork = (networkId?: string) => useView(allTokensOnNetworkFamily(networkId)) export const useTokensInCurrentNetwork = () => { @@ -74,7 +74,7 @@ export const useTokensWithBalance = (account?: BaseWalletAccount) => { return selectedAccount?.networkId ?? "" }, [selectedAccount?.networkId]) const tokensInNetwork = useTokensInNetwork(networkId) - const balances = useView(tokenBalanceForAccountView(account)) + const balances = useView(tokenBalancesForAccountView(account)) const accountBalances = useMemo( () => @@ -85,7 +85,7 @@ export const useTokensWithBalance = (account?: BaseWalletAccount) => { return useMemo(() => { return tokensInNetwork .map((token) => { - const balance = accountBalances?.find((balance) => + const balance = accountBalances.find((balance) => isEqualAddress(balance.address, token.address), )?.balance return { diff --git a/packages/extension/src/ui/features/accountTokens/useAddFundsDialog.tsx b/packages/extension/src/ui/features/accountTokens/useAddFundsDialog.tsx index 6e95f291b..34cd3f620 100644 --- a/packages/extension/src/ui/features/accountTokens/useAddFundsDialog.tsx +++ b/packages/extension/src/ui/features/accountTokens/useAddFundsDialog.tsx @@ -17,6 +17,7 @@ import { SendQuery, isSendQuery } from "../send/schema" import { useTokensWithBalance } from "./tokens.state" import { useAccountIsDeployed } from "./useAccountStatus" import { ETH_TOKEN_ADDRESS } from "../../../shared/network/constants" +import { useBestFeeToken } from "../actions/useBestFeeToken" interface AddFundsDialogContextProps { onSend: (queryOrTo?: SendQuery | To) => void @@ -44,6 +45,7 @@ export const AddFundsDialogProvider: FC = ({ const returnTo = useCurrentPathnameWithQuery() const { isOpen, onOpen, onClose } = useDisclosure() const tokenDetails = useTokensWithBalance(account) + const bestFeeToken = useBestFeeToken(account) const hasNonZeroBalance = useMemo(() => { return tokenDetails.some(({ balance }) => balance && balance > 0n) @@ -69,7 +71,7 @@ export const AddFundsDialogProvider: FC = ({ navigate( routes.sendRecipientScreen({ returnTo, - tokenAddress: ETH_TOKEN_ADDRESS, + tokenAddress: bestFeeToken?.address ?? ETH_TOKEN_ADDRESS, }), ) } @@ -77,7 +79,14 @@ export const AddFundsDialogProvider: FC = ({ onOpen() } }, - [accountIsDeployed, hasNonZeroBalance, navigate, onOpen, returnTo], + [ + accountIsDeployed, + bestFeeToken?.address, + hasNonZeroBalance, + navigate, + onOpen, + returnTo, + ], ) const { title, message, cancelTitle, onConfirm } = useMemo(() => { diff --git a/packages/extension/src/ui/features/accountTokens/useFeeTokenBalance.ts b/packages/extension/src/ui/features/accountTokens/useFeeTokenBalance.ts index df46050a4..a1c1affc4 100644 --- a/packages/extension/src/ui/features/accountTokens/useFeeTokenBalance.ts +++ b/packages/extension/src/ui/features/accountTokens/useFeeTokenBalance.ts @@ -1,79 +1,52 @@ import { feeTokenBalancesView } from "./../../views/tokenBalances" import { Account } from "../accounts/Account" import { useView } from "../../views/implementation/react" -import { Address } from "@argent/shared" -import { BaseTokenWithBalance } from "../../../shared/token/__new/types/tokenBalance.model" -import { num } from "starknet" +import { TokenWithBalance } from "@argent/shared" +import { + classHashSupportsTxV3, + feeTokenNeedsTxV3Support, +} from "../../../shared/network/txv3" export const useFeeTokenBalances = ( account?: Pick, ) => { const feeTokenBalances = useView(feeTokenBalancesView(account)) - const numberFeeTokenBalances = feeTokenBalances?.map((balance) => ({ - ...balance, - amount: BigInt(balance.balance), - })) - return numberFeeTokenBalances ?? [] + + return account && feeTokenBalances + ? feeTokenBalances.map((balance) => ({ + ...balance, + amount: BigInt(balance.balance), + account, + })) + : [] } -export const useHasFeeTokenBalance = ( - account?: Pick, +export const usePossibleFeeTokenBalances = ( + account?: Pick, ) => { const feeTokenBalances = useFeeTokenBalances(account) - return feeTokenBalances.some((balance) => balance.amount > 0n) -} -interface PickBestFeeTokenOptions { - avoid?: Address[] - prefer?: Address[] + return feeTokenBalances.filter( + (tk) => + !feeTokenNeedsTxV3Support(tk) || + classHashSupportsTxV3(account?.classHash), + ) } -export const pickBestFeeToken = ( - balances: BaseTokenWithBalance[], - { avoid = [], prefer = [] }: PickBestFeeTokenOptions = {}, -) => { - // sort by prefered tokens, neutral tokens, then avoid tokens - // sort each group by the provided array order and secondarily by balance - const sortedBalances = balances - .map( - (balance) => - [ - balance, - { - balance: num.toBigInt(balance.balance), - prefer: prefer.includes(balance.address), - avoid: avoid.includes(balance.address), - }, - ] as const, - ) - .sort(([aa, a], [bb, b]) => { - if (a.prefer && !b.prefer) { - return -1 - } - if (!a.prefer && b.prefer) { - return 1 - } - if (a.prefer && b.prefer) { - return Number(prefer.indexOf(aa.address) - prefer.indexOf(bb.address)) - } - if (a.avoid && !b.avoid) { - return 1 - } - if (!a.avoid && b.avoid) { - return -1 - } - if (a.avoid && b.avoid) { - return Number(avoid.indexOf(bb.address) - avoid.indexOf(aa.address)) - } - return Number(b.balance - a.balance) - }) - .map(([balance]) => balance) +export const useBigIntFeeTokenBalances = ( + account?: Pick, +): TokenWithBalance[] => { + const feeTokenBalances = useFeeTokenBalances(account) - // filter tokens with 0 balance out - const filteredBalances = sortedBalances.filter( - (balance) => num.toBigInt(balance.balance) > 0n, - ) + return feeTokenBalances.map((ftb) => ({ + ...ftb, + balance: ftb.amount, + })) +} - // return the first token with a balance or the first token if all balances are 0 - return filteredBalances[0] ?? sortedBalances[0] +export const useHasFeeTokenBalance = ( + account?: Pick, +) => { + const feeTokenBalances = useFeeTokenBalances(account) + return feeTokenBalances.some((balance) => balance.amount > 0n) } diff --git a/packages/extension/src/ui/features/accountTokens/useLiveTokenBalanceForAccount.ts b/packages/extension/src/ui/features/accountTokens/useLiveTokenBalanceForAccount.ts deleted file mode 100644 index 4534cbd93..000000000 --- a/packages/extension/src/ui/features/accountTokens/useLiveTokenBalanceForAccount.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { addressSchema, isEqualAddress } from "@argent/shared" -import { get } from "lodash-es" -import { useEffect, useMemo, useRef } from "react" -import useSWR, { SWRConfiguration } from "swr" - -import { IS_DEV } from "../../../shared/utils/dev" -import { coerceErrorToString } from "../../../shared/utils/error" -import { isNumeric } from "../../../shared/utils/number" -import { getAccountIdentifier } from "../../../shared/wallet.service" - -import { tokenService } from "../../services/tokens" -import { Account } from "../accounts/Account" -import { useAccountTransactions } from "../accounts/accountTransactions.state" -import { num } from "starknet" -import { TokenWithOptionalBigIntBalance } from "../../../shared/token/__new/types/tokenBalance.model" -import { Token } from "../../../shared/token/__new/types/token.model" - -interface UseTokenBalanceForAccountArgs { - /** Not passing valid `token` will return undefined `tokenWithBalance` with descrption in `errorMessage`, this allows for lazy loading */ - token?: Token - account?: Pick - /** Return `data` as {@link TokenBalanceErrorMessage} rather than throwing so the UI can choose if / how to display it to the user without `ErrorBoundary` */ - shouldReturnError?: boolean -} - -/** - * Get the individual token balance for the account, using Multicall if available - * This will automatically mutate when the number of pending transactions decreases - */ - -export const useLiveTokenBalanceForAccount = ( - { token, account, shouldReturnError = false }: UseTokenBalanceForAccountArgs, - config?: SWRConfiguration, -) => { - const { pendingTransactions } = useAccountTransactions(account) - - const pendingTransactionsLengthRef = useRef(pendingTransactions.length) - const key = - token && account - ? [ - getAccountIdentifier(account), - "balanceOf", - token.address, - token.networkId, - ] - : null - const { data, mutate, isValidating, ...rest } = useSWR< - string | TokenBalanceErrorMessage | undefined - >( - key, - async () => { - if (!token || !account) { - return - } - try { - const balance = await tokenService.fetchTokenBalance( - token.address, - addressSchema.parse(account.address), - account.networkId, - ) - return balance - } catch (error) { - if (shouldReturnError) { - return errorToMessage( - error, - token.address, - account.network.multicallAddress, - ) - } else { - throw error - } - } - }, - config, - ) - - // refetch when number of pending transactions goes down - useEffect(() => { - if (pendingTransactionsLengthRef.current > pendingTransactions.length) { - void mutate() - } - pendingTransactionsLengthRef.current = pendingTransactions.length - }, [mutate, pendingTransactions.length]) - - /** as a convenience, also return the token with balance and error message */ - const { tokenWithBalance, errorMessage } = useMemo(() => { - if (!token) { - return { - tokenWithBalance: undefined, - errorMessage: { - message: "Error", - description: "token is not defined", - }, - } - } - const tokenWithBalance: TokenWithOptionalBigIntBalance = { - ...token, - } - let errorMessage: TokenBalanceErrorMessage | undefined - // strict type checking as BigInt doesn't allow passing undefined or TokenBalanceErrorMessage type - if (data && isNumeric(data) && typeof data === "string") { - tokenWithBalance.balance = num.toBigInt(data) - } else { - // tokenWithBalance.balance = cachedBalance?.balance - errorMessage = data as TokenBalanceErrorMessage - } - return { - tokenWithBalance, - errorMessage, - } - }, [data, token]) - - return { - tokenWithBalance, - errorMessage, - data, - mutate, - tokenBalanceLoading: !data && isValidating, - ...rest, - } -} - -const isNetworkError = (errorCode: string | number) => { - if (!isNumeric(errorCode)) { - return false - } - const code = Number(errorCode) - return [429, 502].includes(code) -} - -export interface TokenBalanceErrorMessage { - message: string - description: string -} - -const errorToMessage = ( - error: unknown, - tokenAddress: string, - multicallAddress?: string, -): TokenBalanceErrorMessage => { - const errorCode = get(error, "errorCode") as any - const message = get(error, "message") as any - if (errorCode === "StarknetErrorCode.UNINITIALIZED_CONTRACT") { - /** tried to use a contract not found on this network */ - /** message like "Requested contract address 0x05754af3760f3356da99aea5c3ec39ccac7783d925a19666ebbeca58ff0087f4 is not deployed" */ - const contractAddressMatches = message.match(/(0x[0-9a-f]+)/gi) - const contractAddress = contractAddressMatches?.[0] ?? undefined - if (contractAddress) { - if (isEqualAddress(contractAddress, tokenAddress)) { - return { - message: "Token not found", - description: `Token with address ${tokenAddress} not deployed on this network`, - } - } else if ( - multicallAddress && - isEqualAddress(contractAddress, multicallAddress) - ) { - return { - message: "No Multicall", - description: `Multicall contract with address ${multicallAddress} not deployed on this network`, - } - } - return { - message: "Missing contract", - description: `Contract with address ${contractAddress} not deployed on this network`, - } - } - return { - message: "Missing contract", - description: message, - } - } else if ( - errorCode === "StarknetErrorCode.ENTRY_POINT_NOT_FOUND_IN_CONTRACT" - ) { - /** not a token */ - return { - message: "Invalid token", - description: `This is not a valid token contract`, - } - } else if (isNetworkError(errorCode)) { - /* some other network error */ - return { - message: "Network error", - description: message, - } - } else { - /* show a console message in dev for any unhandled errors that could be better handled here */ - IS_DEV && - console.warn( - `useTokenBalanceForAccount - ignoring errorCode ${errorCode} with error:`, - coerceErrorToString(error), - ) - } - return { - message: "Error", - description: message, - } -} diff --git a/packages/extension/src/ui/features/accountTokens/useMaxFeeForTransfer.ts b/packages/extension/src/ui/features/accountTokens/useMaxFeeForTransfer.ts index f832fc787..bb52591d6 100644 --- a/packages/extension/src/ui/features/accountTokens/useMaxFeeForTransfer.ts +++ b/packages/extension/src/ui/features/accountTokens/useMaxFeeForTransfer.ts @@ -1,4 +1,4 @@ -import { Call, CallData, num, uint256 } from "starknet" +import { Call, CallData, uint256 } from "starknet" import useSWR from "swr" import { getAccountIdentifier } from "../../../shared/wallet.service" @@ -8,59 +8,74 @@ import { } from "../../services/backgroundTransactions" import { Account } from "../accounts/Account" import { - Hex, + Address, isEqualAddress, swrRefetchDisabledConfig, transferCalldataSchema, } from "@argent/shared" import { estimatedFeesToMaxFeeTotal } from "../../../shared/transactionSimulation/utils" +export const maxFeeEstimateForTransfer = async ( + feeTokenAddress?: Address, + tokenAddress?: Address, + account?: Pick< + Account, + "address" | "networkId" | "needsDeploy" | "classHash" | "type" + >, +) => { + if (!account || !tokenAddress || !feeTokenAddress) { + return + } + if (!isEqualAddress(tokenAddress, feeTokenAddress)) { + return 0n + } + + const calls: Call[] = [ + { + contractAddress: tokenAddress, + entrypoint: "transfer", + calldata: CallData.compile( + transferCalldataSchema.parse({ + // We are using a dummy address (ETH here) as recipient to estimate the fee given we don't have a receipient yet + recipient: feeTokenAddress, + // We are using the smallest possible amount to make sure this doesn't throw an error + amount: uint256.bnToUint256(BigInt(1)), + }), + ), + }, + ] + + const estimatedFee = + (await getSimulationEstimatedFee(calls, feeTokenAddress)) ?? + (await getEstimatedFee(calls, account, feeTokenAddress)) + return estimatedFeesToMaxFeeTotal(estimatedFee) +} + export const useMaxFeeEstimateForTransfer = ( - feeTokenAddress?: string, - tokenAddress?: string, - balance?: bigint, - account?: Pick, + feeTokenAddress?: Address, + tokenAddress?: Address, + account?: Pick< + Account, + "address" | "networkId" | "needsDeploy" | "classHash" | "type" + >, + balance = 0n, ) => { const key = - account && balance !== undefined && tokenAddress - ? [getAccountIdentifier(account), "maxFeeEstimateForTransfer"] + account && tokenAddress + ? [ + getAccountIdentifier(account), + "maxFeeEstimateForTransferV2", + feeTokenAddress, + balance, + ] : null - return useSWR( + return useSWR( key, - async () => { - if ( - !account || - balance === undefined || - !tokenAddress || - !feeTokenAddress - ) { - return - } - if (!isEqualAddress(tokenAddress, feeTokenAddress)) { - return "0x0" as Hex - } - - const call: Call = { - contractAddress: tokenAddress, - entrypoint: "transfer", - calldata: CallData.compile( - transferCalldataSchema.parse({ - // We are using a dummy address (ETH here) as recipient to estimate the fee given we don't have a receipient yet - recipient: feeTokenAddress, - // We are using the smallest possible amount to make sure this doesn't throw an error - amount: uint256.bnToUint256(BigInt(1)), - }), - ), - } - - const estimatedFee = - (await getSimulationEstimatedFee(call)) ?? - (await getEstimatedFee(call, account, feeTokenAddress)) - - const maxFeeTotal = estimatedFeesToMaxFeeTotal(estimatedFee) - return num.toHex(maxFeeTotal) as Hex - }, + async () => + balance > 0n + ? maxFeeEstimateForTransfer(feeTokenAddress, tokenAddress, account) + : balance, swrRefetchDisabledConfig, ) } diff --git a/packages/extension/src/ui/features/accountTokens/useTokenBalanceForAccount.ts b/packages/extension/src/ui/features/accountTokens/useTokenBalanceForAccount.ts index 537b60df8..7e5d88fe1 100644 --- a/packages/extension/src/ui/features/accountTokens/useTokenBalanceForAccount.ts +++ b/packages/extension/src/ui/features/accountTokens/useTokenBalanceForAccount.ts @@ -1,7 +1,7 @@ import { useMemo } from "react" import { Account } from "../accounts/Account" -import { tokenBalanceForAccountView } from "../../views/tokenBalances" +import { tokenBalancesForAccountView } from "../../views/tokenBalances" import { useView } from "../../views/implementation/react" import { equalToken } from "../../../shared/token/__new/utils" import { Token } from "../../../shared/token/__new/types/token.model" @@ -25,7 +25,7 @@ export function useTokenBalanceForAccount({ token, account, }: UseTokenBalanceForAccountArgs): TokenWithOptionalBigIntBalance | undefined { - const tokenBalancesForAccount = useView(tokenBalanceForAccountView(account)) + const tokenBalancesForAccount = useView(tokenBalancesForAccountView(account)) const ethToken = useView(ethTokenOnNetworkView(account?.networkId)) return useMemo(() => { diff --git a/packages/extension/src/ui/features/accountTokens/warning/AccountDeprecatedModal.tsx b/packages/extension/src/ui/features/accountTokens/warning/AccountDeprecatedModal.tsx index 671306c76..ed01b1ea3 100644 --- a/packages/extension/src/ui/features/accountTokens/warning/AccountDeprecatedModal.tsx +++ b/packages/extension/src/ui/features/accountTokens/warning/AccountDeprecatedModal.tsx @@ -29,7 +29,7 @@ export const AccountDeprecatedModal = () => { diff --git a/packages/extension/src/ui/features/actions/AddNetworkScreen/AddNetworkScreenContainer.tsx b/packages/extension/src/ui/features/actions/AddNetworkScreenContainer.tsx similarity index 63% rename from packages/extension/src/ui/features/actions/AddNetworkScreen/AddNetworkScreenContainer.tsx rename to packages/extension/src/ui/features/actions/AddNetworkScreenContainer.tsx index 240b1cbfc..d6428eda4 100644 --- a/packages/extension/src/ui/features/actions/AddNetworkScreen/AddNetworkScreenContainer.tsx +++ b/packages/extension/src/ui/features/actions/AddNetworkScreenContainer.tsx @@ -1,22 +1,13 @@ import { FC, useState } from "react" -import { networkSchema } from "../../../../shared/network" -import { useActionScreen } from "../hooks/useActionScreen" -import { WithActionScreenErrorFooter } from "../transaction/ApproveTransactionScreen/WithActionScreenErrorFooter" +import { networkSchema } from "../../../shared/network" +import { useActionScreen } from "./hooks/useActionScreen" +import { WithActionScreenErrorFooter } from "./transaction/ApproveTransactionScreen/WithActionScreenErrorFooter" import { AddNetworkScreen } from "./AddNetworkScreen" -interface AddNetworkScreenProps { - mode?: "add" | "switch" -} - -export const AddNetworkScreenContainer: FC = ({ - mode, -}) => { +export const AddNetworkScreenContainer: FC = () => { const { action, approveAndClose, reject } = useActionScreen() - if ( - action?.type !== "REQUEST_ADD_CUSTOM_NETWORK" && - action?.type !== "REQUEST_SWITCH_CUSTOM_NETWORK" - ) { + if (action?.type !== "REQUEST_ADD_CUSTOM_NETWORK") { throw new Error( "AddNetworkScreenContainer used with incompatible action.type", ) @@ -42,7 +33,6 @@ export const AddNetworkScreenContainer: FC = ({ requestedNetwork={requestedNetwork} error={error} onReject={() => void reject()} - mode={mode} footer={} /> ) diff --git a/packages/extension/src/ui/features/actions/AddTokenScreenContainer.tsx b/packages/extension/src/ui/features/actions/AddTokenScreenContainer.tsx index b493c8527..306200899 100644 --- a/packages/extension/src/ui/features/actions/AddTokenScreenContainer.tsx +++ b/packages/extension/src/ui/features/actions/AddTokenScreenContainer.tsx @@ -106,7 +106,7 @@ export const AddTokenScreenContainer: FC = ({ }, [tokenDetails, tokensInNetwork]) const error = fetchTokenError - ? `Unable to validate token - please check that this is a valid ECR20 contract address for this network` + ? `Unable to validate token - please check that this is a valid ERC20 contract address for this network` : undefined return ( diff --git a/packages/extension/src/ui/features/actions/ApproveDeployAccount.tsx b/packages/extension/src/ui/features/actions/ApproveDeployAccount.tsx index e1c4ef56e..dfe75c937 100644 --- a/packages/extension/src/ui/features/actions/ApproveDeployAccount.tsx +++ b/packages/extension/src/ui/features/actions/ApproveDeployAccount.tsx @@ -17,6 +17,7 @@ import { import { TransactionReviewActions } from "./transactionV2/action/TransactionReviewActions" import { ReviewOfTransaction } from "../../../shared/transactionReview/schema" import { ETH_TOKEN_ADDRESS } from "../../../shared/network/constants" +import { useBestFeeToken } from "./useBestFeeToken" export interface ApproveDeployAccountScreenProps extends Omit, @@ -39,6 +40,7 @@ export const ApproveDeployAccountScreen: FC< ...rest }) => { const [disableConfirm, setDisableConfirm] = useState(false) + const bestFeeToken = useBestFeeToken(selectedAccount) if (!selectedAccount) { return @@ -93,7 +95,7 @@ export const ApproveDeployAccountScreen: FC< footer={ { const { @@ -44,10 +45,13 @@ export const DeclareContractActionScreenContainer: FC = () => { actionHash={action.meta.hash} actionIsApproving={Boolean(action.meta.startedApproving)} actionErrorApproving={action.meta.errorApproving} - transactions={[]} + transactionAction={{ + type: TransactionType.DECLARE, + payload: action.payload, + }} approveScreenType={ApproveScreenType.DECLARE} - onSubmit={() => void onSubmit()} - onReject={() => void rejectAllActions()} + onSubmit={onSubmit} + onReject={rejectAllActions} selectedAccount={selectedAccount} /> diff --git a/packages/extension/src/ui/features/actions/DeployContractActionScreenContainer.tsx b/packages/extension/src/ui/features/actions/DeployContractActionScreenContainer.tsx index 93faf1965..c119a9897 100644 --- a/packages/extension/src/ui/features/actions/DeployContractActionScreenContainer.tsx +++ b/packages/extension/src/ui/features/actions/DeployContractActionScreenContainer.tsx @@ -7,6 +7,7 @@ import { WithArgentShieldVerified } from "../shield/WithArgentShieldVerified" import { useActionScreen } from "./hooks/useActionScreen" import { ApproveTransactionScreenContainer } from "./transaction/ApproveTransactionScreen/ApproveTransactionScreenContainer" import { ApproveScreenType } from "./transaction/types" +import { TransactionType } from "starknet" export const DeployContractActionScreenContainer: FC = () => { const { @@ -46,7 +47,10 @@ export const DeployContractActionScreenContainer: FC = () => { actionIsApproving={Boolean(action.meta.startedApproving)} actionErrorApproving={action.meta.errorApproving} approveScreenType={ApproveScreenType.DEPLOY} - transactions={[]} + transactionAction={{ + type: TransactionType.DEPLOY, + payload: action.payload, + }} onSubmit={() => void onSubmit()} onReject={() => void rejectAllActions()} selectedAccount={selectedAccount} diff --git a/packages/extension/src/ui/features/actions/SwitchNetworkScreen.tsx b/packages/extension/src/ui/features/actions/SwitchNetworkScreen.tsx new file mode 100644 index 000000000..70f56acba --- /dev/null +++ b/packages/extension/src/ui/features/actions/SwitchNetworkScreen.tsx @@ -0,0 +1,79 @@ +import { B3, icons } from "@argent/ui" +import { Center, Flex, Text } from "@chakra-ui/react" +import { FC, PropsWithChildren } from "react" + +import { DappActionHeader } from "./connectDapp/DappActionHeader" +import { DappDisplayAttributes } from "./connectDapp/useDappDisplayAttributes" +import { + ConfirmScreen, + ConfirmScreenProps, +} from "./transaction/ApproveTransactionScreen/ConfirmScreen" + +const { ChevronRightIcon } = icons + +export interface SwitchNetworkScreenProps extends ConfirmScreenProps { + fromNetworkTitle?: string + toNetworkTitle?: string + host: string + dappDisplayAttributes?: DappDisplayAttributes +} + +export const SwitchNetworkScreen: FC = ({ + fromNetworkTitle = "From", + toNetworkTitle = "To", + host, + dappDisplayAttributes, + ...rest +}) => { + return ( + + + + {fromNetworkTitle} + + + + {toNetworkTitle} + + + ) +} + +function NetworkPill(props: PropsWithChildren) { + return ( +
+ +
+ ) +} diff --git a/packages/extension/src/ui/features/actions/SwitchNetworkScreenContainer.tsx b/packages/extension/src/ui/features/actions/SwitchNetworkScreenContainer.tsx new file mode 100644 index 000000000..bf1028858 --- /dev/null +++ b/packages/extension/src/ui/features/actions/SwitchNetworkScreenContainer.tsx @@ -0,0 +1,47 @@ +import { FC } from "react" +import { useActionScreen } from "./hooks/useActionScreen" +import { useAppState } from "../../app.state" +import { useDappDisplayAttributes } from "./connectDapp/useDappDisplayAttributes" +import { AccountNavigationBarContainer } from "../accounts/AccountNavigationBarContainer" +import { SwitchNetworkScreen } from "./SwitchNetworkScreen" +import { WithActionScreenErrorFooter } from "./transaction/ApproveTransactionScreen/WithActionScreenErrorFooter" + +export const SwitchNetworkScreenContainer: FC = () => { + const { action, selectedAccount, approveAndClose, reject } = useActionScreen() + if (action?.type !== "REQUEST_SWITCH_CUSTOM_NETWORK") { + throw new Error( + "SwitchNetworkScreenContainer used with incompatible action.type", + ) + } + const host = action.meta.origin || "" + const requestedNetwork = action.payload + const { switcherNetworkId } = useAppState() + const dappDisplayAttributes = useDappDisplayAttributes(host) + + const fromNetworkTitle = selectedAccount?.network.name + const toNetworkTitle = requestedNetwork.name + + const networkNavigationBar = ( + + ) + + return ( + void approveAndClose()} + onReject={() => void reject()} + host={host} + dappDisplayAttributes={dappDisplayAttributes} + navigationBar={networkNavigationBar} + footer={ + <> + + + } + > + ) +} diff --git a/packages/extension/src/ui/features/actions/TransactionActionScreenContainer.tsx b/packages/extension/src/ui/features/actions/TransactionActionScreenContainer.tsx index e71991ca1..8c9e37fb7 100644 --- a/packages/extension/src/ui/features/actions/TransactionActionScreenContainer.tsx +++ b/packages/extension/src/ui/features/actions/TransactionActionScreenContainer.tsx @@ -7,6 +7,7 @@ import { WithArgentShieldVerified } from "../shield/WithArgentShieldVerified" import { useActionScreen } from "./hooks/useActionScreen" import { ApproveTransactionScreenContainer } from "./transaction/ApproveTransactionScreen/ApproveTransactionScreenContainer" import { getApproveScreenTypeFromAction } from "./utils" +import { TransactionType } from "starknet" export const TransactionActionScreenContainer: FC = () => { const { @@ -38,7 +39,10 @@ export const TransactionActionScreenContainer: FC = () => { return ( { selectedAccount: accounts[0], host: "http://localhost:3000", onSelectedAccountChange, + isHighRisk: false, + hasAcceptedRisk: false, } beforeEach(async () => { diff --git a/packages/extension/src/ui/features/actions/connectDapp/ConnectDappScreen.tsx b/packages/extension/src/ui/features/actions/connectDapp/ConnectDappScreen.tsx index f10c51b07..5b53b8498 100644 --- a/packages/extension/src/ui/features/actions/connectDapp/ConnectDappScreen.tsx +++ b/packages/extension/src/ui/features/actions/connectDapp/ConnectDappScreen.tsx @@ -8,7 +8,7 @@ import { ListItem, Text, } from "@chakra-ui/react" -import { FC, ReactNode } from "react" +import { FC, PropsWithChildren, ReactNode } from "react" import { BaseWalletAccount, @@ -18,15 +18,15 @@ import { ConfirmScreen } from "../transaction/ApproveTransactionScreen/ConfirmSc import { ConnectDappAccountSelect } from "./ConnectDappAccountSelect" import { DappIcon } from "./DappIcon" import { DappDisplayAttributes } from "./useDappDisplayAttributes" +import { DappActionHeader } from "./DappActionHeader" -const { LinkIcon, TickIcon } = icons +const { TickIcon, LinkIcon } = icons -export interface ConnectDappScreenProps { +export interface ConnectDappScreenProps extends PropsWithChildren { isConnected: boolean onConnect: () => void onDisconnect: () => void onReject?: () => void - host: string accounts: WalletAccount[] selectedAccount?: BaseWalletAccount @@ -35,6 +35,8 @@ export interface ConnectDappScreenProps { footer?: ReactNode dappDisplayAttributes?: DappDisplayAttributes navigationBar?: ReactNode + isHighRisk: boolean + hasAcceptedRisk: boolean } export const ConnectDappScreen: FC = ({ @@ -50,9 +52,12 @@ export const ConnectDappScreen: FC = ({ actionIsApproving, navigationBar, footer, + children, + isHighRisk, + hasAcceptedRisk, }) => { const confirmButtonText = isConnected ? "Continue" : "Connect" - const rejectButtonText = isConnected ? "Disconnect" : "Reject" + const rejectButtonText = isConnected ? "Disconnect" : "Cancel" const hostName = new URL(host).hostname @@ -67,21 +72,16 @@ export const ConnectDappScreen: FC = ({ confirmButtonLoadingText={confirmButtonText} navigationBar={navigationBar} footer={footer} + destructive={isHighRisk} + confirmButtonDisabled={isHighRisk && !hasAcceptedRisk} > -
- -
Connect to {dappDisplayAttributes?.title ?? "dapp"}
- - - {hostName} - - {dappDisplayAttributes?.isKnown && ( - - )} - -
+ + {children} { + return { + useActionScreen: () => { + return { + action: { + type: "CONNECT_DAPP", + payload: { + host: "http://localhost:3000", + }, + meta: { + hash: "0x123", + }, + }, + approveAndClose: vi.fn(), + reject: vi.fn(), + } + }, + } +}) + +const cautionResponse = { + warning: { + reason: "similar_to_existing_dapp_url", + severity: "caution", + }, +} as RiskAssessment +const criticalResponse = { + warning: { + reason: "contract_is_black_listed", + details: { + reason: "Contract address is blacklisted", + }, + severity: "critical", + }, +} as RiskAssessment +const happyResponse = { + dapp: { + name: "ftx", + logoUrl: "https://ftx.com/favicon.ico", + }, +} as RiskAssessment +const highRiskResponse = { + warning: { + reason: "similar_to_existing_dapp_url", + severity: "high", + }, +} as RiskAssessment + +const useActionDefaultResponse = { + selectedAccount: { + type: "standard", + address: "0x1", + networkId: "goerli", + name: "account_1", + network: mockNetworks[1], + signer: { + type: "local_secret", + derivationPath: "123", + }, + }, + approveAndClose: vi.fn(), + approve: vi.fn(), + rejectAllActions: vi.fn(), + rejectWithoutClose: vi.fn(), + closePopupIfLastAction: vi.fn(), + reject: vi.fn(), +} as unknown as ReturnType +describe("ConnectDappScreenContainer", () => { + afterEach(() => { + vi.resetAllMocks() + }) + + it("should render dapp information for safe dapps", async () => { + vi.spyOn(useActionScreenParent, "useActionScreen").mockReturnValue({ + ...useActionDefaultResponse, + action: { + type: "CONNECT_DAPP", + payload: { host: "https://happy.dapp" }, + meta: { + expires: 1230, + + hash: "0x123", + }, + }, + }) + const screen = await act(() => { + return renderWithLegacyProviders() + }) + expect(screen.getByText(/^Connect to/)).toBeInTheDocument() + expect(screen.getByText("happy.dapp")).toBeInTheDocument() + vi.spyOn(useRiskAssessmentParent, "useRiskAssessment").mockReturnValue( + happyResponse, + ) + expect( + screen.queryByText(/^Please review warnings before continuing/), + ).toBeNull() + }) + it("should render dapp information with warning for critical dapps", async () => { + vi.spyOn(useActionScreenParent, "useActionScreen").mockReturnValue({ + ...useActionDefaultResponse, + action: { + type: "CONNECT_DAPP", + payload: { host: "https://unhappy.dapp" }, + meta: { + expires: 1230, + + hash: "0x123", + }, + }, + }) + vi.spyOn(useRiskAssessmentParent, "useRiskAssessment").mockReturnValue( + criticalResponse, + ) + const screen = await act(() => { + return render( + + + + + , + ) + }) + expect(screen.getByText(/^Connect to/)).toBeInTheDocument() + expect(screen.getByText("unhappy.dapp")).toBeInTheDocument() + expect( + screen.queryByText(/^Please review warnings before continuing/), + ).toBeInTheDocument() + expect(screen.getByText("Connect")).toBeDisabled() + const reviewButton = screen.getByText("Review") + expect(reviewButton).toBeInTheDocument() + expect(screen.getByText("Critical risk")).toBeInTheDocument() + }) + it("should render dapp information with warning for high risk dapps", async () => { + vi.spyOn(useActionScreenParent, "useActionScreen").mockReturnValue({ + ...useActionDefaultResponse, + action: { + type: "CONNECT_DAPP", + payload: { host: "https://high-risk.dapp" }, + meta: { + expires: 1230, + + hash: "0x123", + }, + }, + }) + vi.spyOn(useRiskAssessmentParent, "useRiskAssessment").mockReturnValue( + highRiskResponse, + ) + const screen = await act(() => { + return render( + + + + + , + ) + }) + expect(screen.getByText(/^Connect to/)).toBeInTheDocument() + expect(screen.getByText("high-risk.dapp")).toBeInTheDocument() + expect( + screen.queryByText(/^Please review warnings before continuing/), + ).toBeInTheDocument() + expect(screen.getByText("Connect")).toBeDisabled() + const reviewButton = screen.getByText("Review") + expect(reviewButton).toBeInTheDocument() + expect(screen.getByText("High risk")).toBeInTheDocument() + }) + it("should render dapp information with warning for dapps with caution", async () => { + vi.spyOn(useActionScreenParent, "useActionScreen").mockReturnValue({ + ...useActionDefaultResponse, + action: { + type: "CONNECT_DAPP", + payload: { host: "https://caution-risk.dapp" }, + meta: { + expires: 1230, + + hash: "0x123", + }, + }, + }) + vi.spyOn(useRiskAssessmentParent, "useRiskAssessment").mockReturnValue( + cautionResponse, + ) + const screen = await act(() => { + return render( + + + + + , + ) + }) + expect(screen.getByText(/^Connect to/)).toBeInTheDocument() + expect(screen.getByText("caution-risk.dapp")).toBeInTheDocument() + expect( + screen.queryByText(/^Please review warnings before continuing/), + ).not.toBeInTheDocument() + expect(screen.getByText("Connect")).not.toBeDisabled() + const reviewButton = screen.getByText("Review") + expect(reviewButton).toBeInTheDocument() + expect(screen.getByText("Caution")).toBeInTheDocument() + }) +}) diff --git a/packages/extension/src/ui/features/actions/connectDapp/ConnectDappScreenContainer.tsx b/packages/extension/src/ui/features/actions/connectDapp/ConnectDappScreenContainer.tsx index d5a373a9e..df0fe1962 100644 --- a/packages/extension/src/ui/features/actions/connectDapp/ConnectDappScreenContainer.tsx +++ b/packages/extension/src/ui/features/actions/connectDapp/ConnectDappScreenContainer.tsx @@ -1,4 +1,4 @@ -import { FC, useCallback, useMemo, useState } from "react" +import { FC, useCallback, useEffect, useMemo, useState } from "react" import { useIsPreauthorized } from "../../preAuthorizations/hooks" import { BaseWalletAccount } from "../../../../shared/wallet.model" @@ -10,10 +10,12 @@ import { useActionScreen } from "../hooks/useActionScreen" import { WithActionScreenErrorFooter } from "../transaction/ApproveTransactionScreen/WithActionScreenErrorFooter" import { ConnectDappScreen } from "./ConnectDappScreen" import { useDappDisplayAttributes } from "./useDappDisplayAttributes" -import { NavigationBar } from "@argent/ui" -import { NetworkSwitcherContainer } from "../../networks/NetworkSwitcher/NetworkSwitcherContainer" import { clientAccountService } from "../../../services/account" import { preAuthorizationService } from "../../../../shared/preAuthorization/service" +import { useRiskAssessment } from "./useRiskAssessment" +import { AccountNavigationBarContainer } from "../../accounts/AccountNavigationBarContainer" +import { WarningBanner } from "../warning/WarningBanner" +import { ReviewFooter } from "../warning/ReviewFooter" export const ConnectDappScreenContainer: FC = () => { const { @@ -28,6 +30,32 @@ export const ConnectDappScreenContainer: FC = () => { ) } const host = action.payload.host + const riskAssessment = useRiskAssessment({ + host, + }) + const [isHighRisk, setIsHighRisk] = useState(false) + const [hasAcceptedRisk, setHasAcceptedRisk] = useState(false) + useEffect(() => { + const isRiskyTransaction = + riskAssessment?.warning?.severity === "critical" || + riskAssessment?.warning?.severity === "high" + if (isRiskyTransaction) { + setIsHighRisk(true) + } + }, [riskAssessment?.warning?.severity]) + const transactionReviewWarnings = useMemo(() => { + if (!riskAssessment?.warning) { + return null + } + + return ( + void reject()} + onConfirm={() => setHasAcceptedRisk(true)} + /> + ) + }, [riskAssessment?.warning, reject]) const { switcherNetworkId } = useAppState() const visibleAccounts = useView( @@ -72,18 +100,9 @@ export const ConnectDappScreenContainer: FC = () => { }, [action.payload.host, reject, selectedAccount]) const networkNavigationBar = ( - - } - py="3" - px="4" + ) @@ -100,7 +119,16 @@ export const ConnectDappScreenContainer: FC = () => { onSelectedAccountChange={onSelectedAccountChange} actionIsApproving={Boolean(action.meta.startedApproving)} navigationBar={networkNavigationBar} - footer={} - /> + isHighRisk={isHighRisk} + hasAcceptedRisk={hasAcceptedRisk} + footer={ + <> + {isHighRisk && } + + + } + > + {transactionReviewWarnings} + ) } diff --git a/packages/extension/src/ui/features/actions/connectDapp/DappActionHeader.tsx b/packages/extension/src/ui/features/actions/connectDapp/DappActionHeader.tsx new file mode 100644 index 000000000..43726d34a --- /dev/null +++ b/packages/extension/src/ui/features/actions/connectDapp/DappActionHeader.tsx @@ -0,0 +1,36 @@ +import { H5, KnownDappButton, P4 } from "@argent/ui" +import { Center, CenterProps, Flex } from "@chakra-ui/react" +import { FC } from "react" + +import { DappIcon } from "./DappIcon" +import { DappDisplayAttributes } from "./useDappDisplayAttributes" + +export interface DappActionHeaderProps extends CenterProps { + host: string + title: string + dappDisplayAttributes?: DappDisplayAttributes +} + +export const DappActionHeader: FC = ({ + host, + dappDisplayAttributes, + title, + ...rest +}) => { + const hostName = new URL(host).hostname + + return ( +
+ +
{title}
+ + + {hostName} + + {dappDisplayAttributes?.verified && ( + + )} + +
+ ) +} diff --git a/packages/extension/src/ui/features/actions/connectDapp/DappIcon.tsx b/packages/extension/src/ui/features/actions/connectDapp/DappIcon.tsx index d84d56e93..7d35dd766 100644 --- a/packages/extension/src/ui/features/actions/connectDapp/DappIcon.tsx +++ b/packages/extension/src/ui/features/actions/connectDapp/DappIcon.tsx @@ -2,6 +2,7 @@ import { Box, BoxProps } from "@chakra-ui/react" import { FC } from "react" import { DappDisplayAttributes } from "./useDappDisplayAttributes" +import { UnknownDappIcon } from "../transactionV2/TransactionHeader/TransactionIcon/UnknownDappIcon" interface DappIconProps extends BoxProps { dappDisplayAttributes?: DappDisplayAttributes @@ -13,7 +14,7 @@ export const DappIcon: FC = ({ }) => { return ( = ({ dappDisplayAttributes?.iconUrl ? "white" : "rgba(255, 255, 255, 0.15)" } {...rest} - /> + > + {!dappDisplayAttributes?.iconUrl && ( + + )} + ) } diff --git a/packages/extension/src/ui/features/actions/connectDapp/useRiskAssessment.ts b/packages/extension/src/ui/features/actions/connectDapp/useRiskAssessment.ts new file mode 100644 index 000000000..18b0d6942 --- /dev/null +++ b/packages/extension/src/ui/features/actions/connectDapp/useRiskAssessment.ts @@ -0,0 +1,36 @@ +import { useCallback, useEffect, useState } from "react" + +import { clientRiskAssessmentService } from "../../../services/riskAssessment" +import { useCurrentNetwork } from "../../networks/hooks/useCurrentNetwork" +import { RiskAssessment } from "../../../../shared/riskAssessment/schema" + +export interface IUseRiskAssessment { + host: string +} + +export const useRiskAssessment = ({ host }: IUseRiskAssessment) => { + const currentNetwork = useCurrentNetwork() + const [riskAssessment, setRiskAssessment] = useState< + RiskAssessment | undefined + >() + const riskAssessmentFetcher = useCallback(async () => { + const result = await clientRiskAssessmentService.assessRisk({ + dappContext: { + dappDomain: host, + network: currentNetwork.id, + }, + }) + return result + }, [currentNetwork.id, host]) + + useEffect(() => { + const fetchRiskAssessment = async () => { + const data = await riskAssessmentFetcher() + setRiskAssessment(data) + } + fetchRiskAssessment().catch((e) => { + console.error("Error fetching risk assessment", e) + }) + }, [riskAssessmentFetcher]) + return riskAssessment +} diff --git a/packages/extension/src/ui/features/actions/feeEstimation/CombinedFeeEstimationContainer.tsx b/packages/extension/src/ui/features/actions/feeEstimation/CombinedFeeEstimationContainer.tsx index da69ef809..dff307b85 100644 --- a/packages/extension/src/ui/features/actions/feeEstimation/CombinedFeeEstimationContainer.tsx +++ b/packages/extension/src/ui/features/actions/feeEstimation/CombinedFeeEstimationContainer.tsx @@ -1,4 +1,4 @@ -import { isFunction, isUndefined } from "lodash-es" +import { isFunction } from "lodash-es" import { FC, useEffect, useMemo } from "react" import { useTokenAmountToCurrencyValue } from "../../accountTokens/tokenPriceHooks" @@ -8,14 +8,13 @@ import { CombinedFeeEstimation } from "./CombinedFeeEstimation" import { ParsedFeeError, getParsedFeeError } from "./feeError" import { TransactionsFeeEstimationProps } from "./types" import { useMaxFeeEstimation } from "./utils" -import { useTokenBalance } from "../../accountTokens/tokens.state" export const CombinedFeeEstimationContainer: FC< TransactionsFeeEstimationProps > = ({ - feeTokenAddress, + feeToken, accountAddress, - transactions, + transactionAction, actionHash, onErrorChange, onFeeErrorChange, @@ -24,18 +23,18 @@ export const CombinedFeeEstimationContainer: FC< transactionSimulation, transactionSimulationFeeError, transactionSimulationLoading, + allowFeeTokenSelection, }) => { const account = useAccount({ address: accountAddress, networkId }) if (!account) { throw new Error("Account not found") } - const feeToken = useTokenBalance(feeTokenAddress, account) - const { fee: feeSequencer, error } = useMaxFeeEstimation( actionHash, account, - transactions, + transactionAction, + feeToken.address, transactionSimulation, transactionSimulationLoading, ) @@ -92,12 +91,6 @@ export const CombinedFeeEstimationContainer: FC< totalMaxFee, ) - const hasTransactions = !isUndefined(transactions) - - if (!hasTransactions) { - return null - } - return ( ) } diff --git a/packages/extension/src/ui/features/actions/feeEstimation/DeployAccountFeeEstimation.tsx b/packages/extension/src/ui/features/actions/feeEstimation/DeployAccountFeeEstimation.tsx index 95310aa6e..68dbbde99 100644 --- a/packages/extension/src/ui/features/actions/feeEstimation/DeployAccountFeeEstimation.tsx +++ b/packages/extension/src/ui/features/actions/feeEstimation/DeployAccountFeeEstimation.tsx @@ -1,42 +1,40 @@ -import { FC, useEffect, useMemo } from "react" +import { FC, useCallback, useEffect, useMemo, useState } from "react" import { useAccount } from "../../accounts/accounts.state" -import { useTokenAmountToCurrencyValue } from "../../accountTokens/tokenPriceHooks" -import { getParsedFeeError } from "./feeError" -import { FeeEstimation } from "./FeeEstimation" import { TransactionsFeeEstimationProps } from "./types" import { useMaxAccountDeploymentFeeEstimation } from "./utils" -import { useTokenBalance } from "../../accountTokens/tokens.state" -import { - estimatedFeeToMaxFeeTotal, - estimatedFeeToTotal, -} from "../../../../shared/transactionSimulation/utils" +import { estimatedFeeToTotal } from "../../../../shared/transactionSimulation/utils" +import { FeeEstimationContainerV2 } from "../transactionV2/FeeEstimationContainerV2" +import { useBestFeeToken } from "../useBestFeeToken" +import { AccountError } from "../../../../shared/errors/account" +import { classHashSupportsTxV3 } from "../../../../shared/network/txv3" +import { FeeTokenPickerModal } from "./ui/FeeTokenPickerModal" +import { useFeeTokenBalances } from "../../accountTokens/useFeeTokenBalance" +import { feeTokenService } from "../../../services/feeToken" +import { BaseToken } from "../../../../shared/token/__new/types/token.model" type DeployAccountFeeEstimationProps = Omit< TransactionsFeeEstimationProps, - "transactions" + "transactionAction" > export const DeployAccountFeeEstimation: FC< DeployAccountFeeEstimationProps -> = ({ - feeTokenAddress, - accountAddress, - actionHash, - onErrorChange, - networkId, -}) => { +> = ({ accountAddress, actionHash, onErrorChange, networkId }) => { const account = useAccount({ address: accountAddress, networkId }) if (!account) { - throw new Error("Account not found") + throw new AccountError({ code: "NOT_FOUND" }) } - const feeToken = useTokenBalance(feeTokenAddress, account) - const { fee, error } = useMaxAccountDeploymentFeeEstimation( + const feeToken = useBestFeeToken(account) + const feeTokens = useFeeTokenBalances(account) + const { fee, error, loading } = useMaxAccountDeploymentFeeEstimation( { address: accountAddress, networkId }, actionHash, + feeToken.address, ) + const [isFeeTokenPickerOpen, setIsFeeTokenPickerOpen] = useState(false) const deployAccountTotal = useMemo(() => { if (!fee) { @@ -45,13 +43,6 @@ export const DeployAccountFeeEstimation: FC< return estimatedFeeToTotal(fee) }, [fee]) - const deployAccountMaxFee = useMemo(() => { - if (!fee) { - return undefined - } - return estimatedFeeToMaxFeeTotal(fee) - }, [fee]) - const enoughBalance = useMemo( () => Boolean( @@ -71,33 +62,39 @@ export const DeployAccountFeeEstimation: FC< onErrorChange?.(hasError) }, [hasError, onErrorChange]) - const parsedFeeEstimationError = showEstimateError - ? getParsedFeeError(error) - : undefined - const amountCurrencyValue = useTokenAmountToCurrencyValue( - feeToken || undefined, - deployAccountTotal, - ) + // For undeployed txV3 accounts, this will be true + // For undeployed txV1 accounts, this needs to be false, as we don't want the user to deploy + upgrade from this screen + const allowFeeTokenSelection = classHashSupportsTxV3(account.classHash) - const suggestedMaxFeeCurrencyValue = useTokenAmountToCurrencyValue( - feeToken || undefined, - deployAccountMaxFee, - ) + // Same as TransactionActionsContainerV2 + const setPreferredFeeToken = useCallback(async ({ address }: BaseToken) => { + await feeTokenService.preferFeeToken(address) + setIsFeeTokenPickerOpen(false) + }, []) return ( <> - {feeToken && ( - setIsFeeTokenPickerOpen(true)} /> )} + + { + setIsFeeTokenPickerOpen(false) + }} + tokens={feeTokens} + onFeeTokenSelect={setPreferredFeeToken} + /> ) } diff --git a/packages/extension/src/ui/features/actions/feeEstimation/FeeEstimation.test.tsx b/packages/extension/src/ui/features/actions/feeEstimation/FeeEstimation.test.tsx index d15b20ee8..14517c494 100644 --- a/packages/extension/src/ui/features/actions/feeEstimation/FeeEstimation.test.tsx +++ b/packages/extension/src/ui/features/actions/feeEstimation/FeeEstimation.test.tsx @@ -1,5 +1,4 @@ import { render, screen } from "@testing-library/react" -import { noop } from "lodash-es" import { feeEstimationFixture1, @@ -15,30 +14,26 @@ describe("FeeEstimation", () => { it("should render scenario 1 as expected", async () => { render() - expect(screen.getByText(/(Max 0.00084 ETH)/)).toBeInTheDocument() + expect(screen.getByText(/(Max 0.00042 ETH)/)).toBeInTheDocument() expect(screen.getByText(/0.00021 ETH/)).toBeInTheDocument() }) it("should render scenario 2 as expected", async () => { render() - expect(screen.getByText(/(Max 0.000000000000084 ETH)/)).toBeInTheDocument() + expect(screen.getByText(/(Max 0.000000000000042 ETH)/)).toBeInTheDocument() expect(screen.getByText(/0.000000000000021 ETH/)).toBeInTheDocument() }) it("should render scenario 3 as expected", async () => { - window.scrollTo = vi.fn(noop) - render() expect( - screen.getByText(/Insufficient funds to pay network fee/), + screen.getByText(/Insufficient funds to pay fee/), ).toBeInTheDocument() - expect(screen.getByText(/(Max 0.000000000000084 ETH)/)).toBeInTheDocument() + expect(screen.getByText(/(Max 0.000000000000042 ETH)/)).toBeInTheDocument() expect(screen.getByText(/0.000000000000021 ETH/)).toBeInTheDocument() - - expect(window.scrollTo).toHaveBeenCalled() }) it("should render scenario 4 as expected", async () => { diff --git a/packages/extension/src/ui/features/actions/feeEstimation/FeeEstimation.tsx b/packages/extension/src/ui/features/actions/feeEstimation/FeeEstimation.tsx index 0e088cfce..35211aea7 100644 --- a/packages/extension/src/ui/features/actions/feeEstimation/FeeEstimation.tsx +++ b/packages/extension/src/ui/features/actions/feeEstimation/FeeEstimation.tsx @@ -9,28 +9,30 @@ import { import { FeeEstimationBox, FeeEstimationBoxWithDeploy, + FeeEstimationBoxWithInsufficientFunds, } from "./ui/FeeEstimationBox" import { FeeEstimationText } from "./ui/FeeEstimationText" -import { InsufficientFundsAccordion } from "./ui/InsufficientFundsAccordion" import { TransactionFailureAccordion } from "./ui/TransactionFailureAccordion" -import { WaitingForFunds } from "./ui/WaitingForFunds" import { getTooltipText } from "./utils" import { FeeEstimationProps } from "./feeEstimation.model" import { estimatedFeesToMaxFeeTotal, estimatedFeesToTotal, } from "../../../../shared/transactionSimulation/utils" +import { getTokenIconUrl } from "../../accountTokens/TokenIcon" export const FeeEstimation: FC = ({ amountCurrencyValue, fee, feeToken, parsedFeeEstimationError, - showError, + showEstimateError, showFeeError, suggestedMaxFeeCurrencyValue, - userClickedAddFunds, + userClickedAddFunds = false, needsDeploy, + onOpenFeeTokenPicker, + allowFeeTokenSelection = true, }) => { const amount = fee && estimatedFeesToTotal(fee) const maxFee = fee && estimatedFeesToMaxFeeTotal(fee) @@ -41,27 +43,30 @@ export const FeeEstimation: FC = ({ } }, [feeToken.balance, maxFee]) const primaryText = useMemo(() => { + if (amountCurrencyValue) { + return prettifyCurrencyValue(amountCurrencyValue) + } + if (amount) { return ( <> - {feeToken ? ( - prettifyTokenAmount({ - amount, - decimals: feeToken.decimals, - symbol: feeToken.symbol, - }) - ) : ( - <>{amount} Unknown - )} - {amountCurrencyValue !== undefined && - ` (${prettifyCurrencyValue(amountCurrencyValue)})`} + {prettifyTokenAmount({ + amount, + decimals: feeToken.decimals, + symbol: feeToken.symbol, + })} ) } }, [amount, amountCurrencyValue, feeToken]) + const secondaryText = useMemo(() => { + if (suggestedMaxFeeCurrencyValue) { + return `Max ${prettifyCurrencyValue(suggestedMaxFeeCurrencyValue)}` + } + if (maxFee) { return ( @@ -76,16 +81,39 @@ export const FeeEstimation: FC = ({ ) : ( <>{maxFee} Unknown )} - {suggestedMaxFeeCurrencyValue !== undefined && - ` (Max ${prettifyCurrencyValue(suggestedMaxFeeCurrencyValue)})`} ) } }, [feeToken, maxFee, suggestedMaxFeeCurrencyValue]) + + const [feeTokenIcon, feeTokenSymbol] = useMemo( + () => [getTokenIconUrl(feeToken), feeToken.symbol], + [feeToken], + ) + const isLoading = !fee || isUndefined(feeToken.balance) // because 0n is a valid balance but falsy - if (!showError) { + if (showFeeError) { + return ( + + + + ) + } + + if (!showEstimateError) { return needsDeploy ? ( = ({ primaryText={primaryText} secondaryText={secondaryText} isLoading={isLoading} + onOpenFeeTokenPicker={onOpenFeeTokenPicker} + feeTokenIcon={feeTokenIcon} + feeTokenSymbol={feeTokenSymbol} + allowFeeTokenSelection={allowFeeTokenSelection} /> ) : ( @@ -102,22 +134,18 @@ export const FeeEstimation: FC = ({ primaryText={primaryText} secondaryText={secondaryText} isLoading={isLoading} + feeTokenIcon={feeTokenIcon} + feeTokenSymbol={feeTokenSymbol} + onOpenFeeTokenPicker={onOpenFeeTokenPicker} + allowFeeTokenSelection={allowFeeTokenSelection} /> ) } - if (userClickedAddFunds) { - return - } - if (showFeeError) { - return ( - - ) - } + // if (userClickedAddFunds) { + // return + // } + return ( = ({ - feeTokenAddress, + feeToken, accountAddress, networkId, onErrorChange, onFeeErrorChange, - transactions, + transactionAction, actionHash, userClickedAddFunds, transactionSimulation, transactionSimulationFeeError, transactionSimulationLoading, needsDeploy = false, + allowFeeTokenSelection, + onFeeTokenPickerOpen, }) => { const account = useAccount({ address: accountAddress, networkId }) if (!account) { throw new Error("Account not found") } - const feeToken = useTokenBalance(feeTokenAddress, account) const { fee: feeSequencer, error } = useMaxFeeEstimation( actionHash, account, - transactions, + transactionAction, + feeToken?.address, transactionSimulation, transactionSimulationLoading, ) @@ -106,6 +107,8 @@ export const FeeEstimationContainer: FC = ({ suggestedMaxFeeCurrencyValue={suggestedMaxFeeCurrencyValue} userClickedAddFunds={userClickedAddFunds} needsDeploy={needsDeploy} + onOpenFeeTokenPicker={onFeeTokenPickerOpen} + allowFeeTokenSelection={allowFeeTokenSelection} /> )} diff --git a/packages/extension/src/ui/features/actions/feeEstimation/feeEstimation.model.ts b/packages/extension/src/ui/features/actions/feeEstimation/feeEstimation.model.ts index 6d7c4e79b..306a8d4cb 100644 --- a/packages/extension/src/ui/features/actions/feeEstimation/feeEstimation.model.ts +++ b/packages/extension/src/ui/features/actions/feeEstimation/feeEstimation.model.ts @@ -13,4 +13,6 @@ export interface FeeEstimationProps { suggestedMaxFeeCurrencyValue?: string userClickedAddFunds?: boolean needsDeploy?: boolean + onOpenFeeTokenPicker?: () => void + allowFeeTokenSelection?: boolean } diff --git a/packages/extension/src/ui/features/actions/feeEstimation/types.ts b/packages/extension/src/ui/features/actions/feeEstimation/types.ts index bbeeff8d3..c0c2672c7 100644 --- a/packages/extension/src/ui/features/actions/feeEstimation/types.ts +++ b/packages/extension/src/ui/features/actions/feeEstimation/types.ts @@ -1,10 +1,9 @@ -import { Call } from "starknet" import { ApiTransactionBulkSimulationResponse } from "../../../../shared/transactionSimulation/types" import { EstimatedFees } from "../../../../shared/transactionSimulation/fees/fees.model" -import { Address } from "@argent/shared" +import { TokenWithBalance, TransactionAction } from "@argent/shared" export interface TransactionsFeeEstimationProps { - feeTokenAddress: Address - transactions: Call | Call[] + feeToken: TokenWithBalance + transactionAction: TransactionAction defaultMaxFee?: bigint onChange?: (fee: bigint) => void onErrorChange?: (error: boolean) => void @@ -18,4 +17,6 @@ export interface TransactionsFeeEstimationProps { transactionSimulationLoading: boolean transactionSimulationFeeError?: Error needsDeploy?: boolean + allowFeeTokenSelection?: boolean + onFeeTokenPickerOpen?: () => void } diff --git a/packages/extension/src/ui/features/actions/feeEstimation/ui/FeeEstimationBox.tsx b/packages/extension/src/ui/features/actions/feeEstimation/ui/FeeEstimationBox.tsx index eee7f84e5..5f05345fa 100644 --- a/packages/extension/src/ui/features/actions/feeEstimation/ui/FeeEstimationBox.tsx +++ b/packages/extension/src/ui/features/actions/feeEstimation/ui/FeeEstimationBox.tsx @@ -1,15 +1,16 @@ +import { L2 } from "@argent/ui" import { Flex } from "@chakra-ui/react" import { FC, PropsWithChildren } from "react" export const FeeEstimationBox: FC = (props) => { return ( = (props) => { ) } -export const FeeEstimationBoxWithDeploy: FC = (props) => ( +export const FeeEstimationBoxWithDeploy: FC = ({ + children, +}) => ( = (props) => ( boxShadow: "menu", }} > - - {props.children} + + {children} = (props) => ( ) + +interface FeeEstimationBoxWithInsufficientFundsProps extends PropsWithChildren { + userClickedAddFunds: boolean +} + +export const FeeEstimationBoxWithInsufficientFunds: FC< + FeeEstimationBoxWithInsufficientFundsProps +> = ({ children, userClickedAddFunds }) => { + return ( + + + {children} + + + + {userClickedAddFunds + ? "Waiting for funds..." + : "Insufficient funds to pay fee"} + + + + ) +} diff --git a/packages/extension/src/ui/features/actions/feeEstimation/ui/FeeEstimationText.tsx b/packages/extension/src/ui/features/actions/feeEstimation/ui/FeeEstimationText.tsx index 4f52af93c..c561bd601 100644 --- a/packages/extension/src/ui/features/actions/feeEstimation/ui/FeeEstimationText.tsx +++ b/packages/extension/src/ui/features/actions/feeEstimation/ui/FeeEstimationText.tsx @@ -1,16 +1,15 @@ -import { L2, P4, icons } from "@argent/ui" +import { B3, L2, P4, icons } from "@argent/ui" import { - // Center, Flex, + Img, Spinner, Text, ThemingProps, Tooltip, } from "@chakra-ui/react" import { FC, ReactNode } from "react" -// import { TokenPicker } from "./TokenPicker" -const { InfoIcon } = icons +const { InfoIcon, ChevronRightIcon } = icons export interface FeeEstimationTextProps extends ThemingProps<"Flex"> { allowFeeTokenSelection?: boolean @@ -20,10 +19,13 @@ export interface FeeEstimationTextProps extends ThemingProps<"Flex"> { primaryText?: ReactNode secondaryText?: ReactNode isLoading?: boolean + onOpenFeeTokenPicker?: () => void + feeTokenIcon?: string + feeTokenSymbol?: string } export const FeeEstimationText: FC = ({ - // allowFeeTokenSelection = true, + allowFeeTokenSelection = true, colorScheme = "neutrals", tooltipText, title = "Estimated fee", @@ -31,10 +33,17 @@ export const FeeEstimationText: FC = ({ isLoading = false, primaryText, secondaryText, + feeTokenIcon, + feeTokenSymbol, + onOpenFeeTokenPicker, }) => { return ( - + @@ -65,25 +74,32 @@ export const FeeEstimationText: FC = ({ {isLoading ? ( ) : ( - - {primaryText && ( - - {/* {!allowFeeTokenSelection && ( - setIsTokenPickerOpen(true)} + allowFeeTokenSelection && onOpenFeeTokenPicker?.()} + > + + {primaryText && ( + + {feeTokenSymbol} - )} */} - {primaryText} - - )} - {secondaryText && ( - {secondaryText} - )} + {primaryText} + + )} + {secondaryText && ( + {secondaryText} + )} + + {allowFeeTokenSelection && } )} diff --git a/packages/extension/src/ui/features/actions/feeEstimation/ui/FeeTokenPickerModal.tsx b/packages/extension/src/ui/features/actions/feeEstimation/ui/FeeTokenPickerModal.tsx new file mode 100644 index 000000000..a0cded091 --- /dev/null +++ b/packages/extension/src/ui/features/actions/feeEstimation/ui/FeeTokenPickerModal.tsx @@ -0,0 +1,60 @@ +import { FC } from "react" +import { BaseToken } from "../../../../../shared/token/__new/types/token.model" +import { + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalHeader, +} from "@chakra-ui/react" +import { H6 } from "@argent/ui" + +import { TokenWithBalance } from "../../../../../shared/token/__new/types/tokenBalance.model" +import { MinBalances, TokenOptionContainer } from "./TokenOptionContainer" + +export interface FeeTokenPickerModalProps { + onClose: () => void + onFeeTokenSelect: (token: BaseToken) => void + isOpen: boolean + tokens: TokenWithBalance[] + minBalances?: MinBalances + initialFocusRef?: React.RefObject +} + +export const FeeTokenPickerModal: FC = ({ + onClose, + onFeeTokenSelect, + isOpen, + tokens, + minBalances = {}, + initialFocusRef, +}) => { + return ( + + + +
+ Select fee token +
+
+ + + {tokens.map((token, i) => ( + + ))} + +
+
+ ) +} diff --git a/packages/extension/src/ui/features/actions/feeEstimation/ui/TokenOptionContainer.tsx b/packages/extension/src/ui/features/actions/feeEstimation/ui/TokenOptionContainer.tsx new file mode 100644 index 000000000..a361f8dbb --- /dev/null +++ b/packages/extension/src/ui/features/actions/feeEstimation/ui/TokenOptionContainer.tsx @@ -0,0 +1,108 @@ +import { TokenWithBalance } from "../../../../../shared/token/__new/types/tokenBalance.model" +import { TokenOption } from "../../../../components/TokenOption" +import { getTokenIconUrl } from "../../../accountTokens/TokenIcon" +import { num } from "starknet" +import { FC, useCallback, useMemo, useState } from "react" +import { + useCurrencyDisplayEnabled, + useTokenBalanceToCurrencyValue, +} from "../../../accountTokens/tokenPriceHooks" +import { Address, prettifyCurrencyValue } from "@argent/shared" +import { + classHashSupportsTxV3, + feeTokenNeedsTxV3Support, +} from "../../../../../shared/network/txv3" +import { useAccount } from "../../../accounts/accounts.state" +import { clientAccountService } from "../../../../services/account" +import { AccountError } from "../../../../../shared/errors/account" +import { isEmpty } from "lodash-es" +import { prettifyTokenAmount } from "../../../../../shared/token/price" +import { useUpgradeAccountTransactions } from "../../../accounts/accountTransactions.state" +import { accountService } from "../../../../../shared/account/service" +import { accountsEqual } from "../../../../../shared/utils/accountsEqual" +import { useRequiresTxV3Upgrade } from "../useRequiresTxV3Upgrade" + +function toTokenView(token: TokenWithBalance): { + address: Address + name: string + symbol: string + balance: string + iconUrl: string +} { + return { + ...token, + iconUrl: getTokenIconUrl({ ...token, url: token.iconUrl }), + balance: `${prettifyTokenAmount({ ...token, amount: token.balance })}`, + } +} + +export interface MinBalances { + [address: Address]: bigint +} + +interface TokenOptionContainerProps { + token: TokenWithBalance + minBalances: MinBalances + onFeeTokenSelect: (token: TokenWithBalance) => void + ref?: React.Ref +} + +export const TokenOptionContainer: FC = ({ + token, + minBalances, + onFeeTokenSelect, + ref, +}) => { + const account = useAccount(token.account) + const { name, iconUrl, symbol, address, balance } = toTokenView(token) + const showCurrencyValue = useCurrencyDisplayEnabled() + const currencyValue = useTokenBalanceToCurrencyValue(token) + const ccyBalance = prettifyCurrencyValue(currencyValue) + const minBalance = minBalances[token.address] ?? BigInt(1) + const { pendingTransactions: pendingUpgradeTransactions } = + useUpgradeAccountTransactions(account) + + const initLoading = useMemo(() => { + if (!account) { + return false + } + return ( + feeTokenNeedsTxV3Support(token) && !isEmpty(pendingUpgradeTransactions) + ) + }, [account, pendingUpgradeTransactions, token]) + + const [upgradeLoading, setUpgradeLoading] = useState(initLoading) + + const { data: requiresTxV3Upgrade } = useRequiresTxV3Upgrade(account, token) + + // disabled if the token balance is less than the min balance or the token requires a tx v3 upgrade + const disabled = + requiresTxV3Upgrade || num.toBigInt(token.balance) < minBalance + + const enableTxV3 = useCallback(async () => { + setUpgradeLoading(true) + if (!account) { + throw new AccountError({ code: "NOT_SELECTED" }) + } + await clientAccountService.upgrade(account) + setUpgradeLoading(false) + }, [account]) + + return ( + onFeeTokenSelect(token)} + onEnableTxV3={enableTxV3} + upgradeLoading={upgradeLoading} + /> + ) +} diff --git a/packages/extension/src/ui/features/actions/feeEstimation/ui/TokenPickerScreen.tsx b/packages/extension/src/ui/features/actions/feeEstimation/ui/TokenPickerScreen.tsx index 7eaab8fa1..d8a991ee1 100644 --- a/packages/extension/src/ui/features/actions/feeEstimation/ui/TokenPickerScreen.tsx +++ b/packages/extension/src/ui/features/actions/feeEstimation/ui/TokenPickerScreen.tsx @@ -2,7 +2,7 @@ import { TokenWithBalance } from "@argent/shared" import { NavigationContainer } from "@argent/ui" import { FC, ReactNode } from "react" import { PageWrapper } from "../../../../components/Page" -import { Grid, Text } from "@chakra-ui/react" +import { Grid } from "@chakra-ui/react" import { TokenOption } from "../../../../components/TokenOption" import { getTokenIconUrl } from "../../../accountTokens/TokenIcon" import { formatTokenBalance } from "../../../../services/tokens/utils" @@ -58,14 +58,7 @@ export const TokenPickerScreen: FC = ({ name={tokenView.name} symbol={tokenView.symbol} balance={tokenView.balance} - ccyBalance={ - disabled ? ( - Insufficient funds - ) : ( - tokenView.balance - ) - } - onClick={() => onSelect(token)} + onTokenSelect={() => onSelect(token)} /> ) })} diff --git a/packages/extension/src/ui/features/actions/feeEstimation/useEstimatedFees.ts b/packages/extension/src/ui/features/actions/feeEstimation/useEstimatedFees.ts index af6d2d472..79fdd4229 100644 --- a/packages/extension/src/ui/features/actions/feeEstimation/useEstimatedFees.ts +++ b/packages/extension/src/ui/features/actions/feeEstimation/useEstimatedFees.ts @@ -1,8 +1,8 @@ -import { Call } from "starknet" +import { TransactionAction } from "@argent/shared" import { estimatedFeesAtom } from "../../../views/estimatedFees" import { useView } from "../../../views/implementation/react" -export function useEstimatedFees(transactions: Call | Call[]) { - const estimatedFees = useView(estimatedFeesAtom(transactions)) +export function useEstimatedFees(transactionAction: TransactionAction) { + const estimatedFees = useView(estimatedFeesAtom(transactionAction)) return estimatedFees } diff --git a/packages/extension/src/ui/features/actions/feeEstimation/useRequiresTxV3Upgrade.ts b/packages/extension/src/ui/features/actions/feeEstimation/useRequiresTxV3Upgrade.ts new file mode 100644 index 000000000..5b43247e1 --- /dev/null +++ b/packages/extension/src/ui/features/actions/feeEstimation/useRequiresTxV3Upgrade.ts @@ -0,0 +1,37 @@ +import useSWR from "swr" +import { Account } from "../../accounts/Account" +import { accountService } from "../../../../shared/account/service" +import { accountsEqual } from "../../../../shared/utils/accountsEqual" +import { + classHashSupportsTxV3, + feeTokenNeedsTxV3Support, +} from "../../../../shared/network/txv3" +import { Token } from "../../../../shared/token/__new/types/token.model" +import { getAccountIdentifier } from "@argent/shared" + +export function useRequiresTxV3Upgrade( + account: Account | undefined, + token: Token, +) { + return useSWR( + [ + "requiresTxV3Upgrade", + getAccountIdentifier(account), + getAccountIdentifier(token), + ], + async () => { + const [selectedAccount] = await accountService.get((acc) => + accountsEqual(acc, account), + ) + + return ( + selectedAccount?.classHash && + feeTokenNeedsTxV3Support(token) && + !classHashSupportsTxV3(selectedAccount.classHash) + ) + }, + { + refreshInterval: 1000, + }, + ) +} diff --git a/packages/extension/src/ui/features/actions/feeEstimation/utils.tsx b/packages/extension/src/ui/features/actions/feeEstimation/utils.tsx index 3c6ee6bfa..6a32a70f7 100644 --- a/packages/extension/src/ui/features/actions/feeEstimation/utils.tsx +++ b/packages/extension/src/ui/features/actions/feeEstimation/utils.tsx @@ -1,5 +1,10 @@ -import { bigDecimal, useConditionallyEnabledSWR } from "@argent/shared" -import { Call, UniversalDeployerContractPayload } from "starknet" +import { + Address, + TransactionAction, + bigDecimal, + useConditionallyEnabledSWR, +} from "@argent/shared" +import { TransactionType, UniversalDeployerContractPayload } from "starknet" import useSWR from "swr" import { DeclareContract } from "../../../../shared/udc/schema" @@ -25,15 +30,42 @@ interface UseMaxFeeEstimationReturnProps { export const useMaxFeeEstimation = ( actionHash: string, account: BaseWalletAccount, - transactions: Call | Call[], + transactionAction: TransactionAction, + feeTokenAddress: Address, transactionSimulation?: ApiTransactionBulkSimulationResponse, isSimulationLoading?: boolean, ): UseMaxFeeEstimationReturnProps => { const { data: fee, error } = useConditionallyEnabledSWR( !isSimulationLoading && (!transactionSimulation || transactionSimulation.length === 0), - [actionHash, "feeEstimation"], - () => transactions && getEstimatedFee(transactions, account), + [actionHash, feeTokenAddress, "feeEstimation"], + () => { + switch (transactionAction.type) { + case TransactionType.INVOKE: + return getEstimatedFee( + transactionAction.payload, + account, + feeTokenAddress, + ) + + case TransactionType.DECLARE: + return getDeclareContractEstimatedFee({ + payload: transactionAction.payload, + feeTokenAddress, + account, + }) + + case TransactionType.DEPLOY: + return getDeployContractEstimatedFee({ + payload: transactionAction.payload, + feeTokenAddress, + account, + }) + + default: + return + } + }, { suspense: false, refreshInterval: RefreshInterval.FAST * 1000, // 20 seconds @@ -46,22 +78,28 @@ export const useMaxFeeEstimation = ( interface UseMaxAccountDeploymentFeeEstimationReturnProps { fee: EstimatedFee | undefined error: ErrorObject | undefined + loading: boolean } export const useMaxAccountDeploymentFeeEstimation = ( account: BaseWalletAccount | undefined, actionHash: string, + feeTokenAddress: string, ): UseMaxAccountDeploymentFeeEstimationReturnProps => { - const { data: fee, error } = useSWR( - [actionHash, "accountDeploymentFeeEstimation"], - () => getAccountDeploymentEstimatedFee(account), + const { + data: fee, + error, + isValidating, + } = useSWR( + [actionHash, "accountDeploymentFeeEstimation", feeTokenAddress], + () => getAccountDeploymentEstimatedFee(feeTokenAddress, account), { suspense: false, refreshInterval: RefreshInterval.FAST * 1000, // 20 seconds shouldRetryOnError: false, }, ) - return { fee, error } + return { fee, error, loading: !fee && isValidating } } export const useMaxDeclareContractFeeEstimation = ( @@ -69,7 +107,11 @@ export const useMaxDeclareContractFeeEstimation = ( actionHash: string, ) => { const { data: fee, error } = useSWR( - [actionHash, "declareContractFeeEstimation"], + [ + actionHash, + "declareContractFeeEstimation", + declareContractPayload.feeTokenAddress, + ], () => getDeclareContractEstimatedFee(declareContractPayload), { suspense: false, @@ -82,11 +124,18 @@ export const useMaxDeclareContractFeeEstimation = ( export const useMaxDeployContractFeeEstimation = ( declareContractPayload: UniversalDeployerContractPayload, + account: BaseWalletAccount, + feeTokenAddress: Address, actionHash: string, ) => { const { data: fee, error } = useSWR( [actionHash, "deployContractFeeEstimation"], - () => getDeployContractEstimatedFee(declareContractPayload), + () => + getDeployContractEstimatedFee({ + payload: declareContractPayload, + account, + feeTokenAddress, + }), { suspense: false, refreshInterval: RefreshInterval.FAST * 1000, // 20 seconds diff --git a/packages/extension/src/ui/features/actions/hooks/useActionScreen.ts b/packages/extension/src/ui/features/actions/hooks/useActionScreen.ts index 5ca42f7ff..b8ec647b1 100644 --- a/packages/extension/src/ui/features/actions/hooks/useActionScreen.ts +++ b/packages/extension/src/ui/features/actions/hooks/useActionScreen.ts @@ -58,6 +58,14 @@ export const useActionScreen = () => { void uiService.closeFloatingWindow() }, []) + const rejectActionWithHash = useCallback( + async (actionHash: string) => { + await clientActionService.reject(actionHash) + closePopupIfLastAction() + }, + [closePopupIfLastAction], + ) + /** Focus the extension if it is running in a tab */ useEffect(() => { const init = async () => { @@ -75,6 +83,7 @@ export const useActionScreen = () => { approveAndClose, reject: rejectAndClose, rejectWithoutClose: reject, + rejectActionWithHash, rejectAllActions, closePopupIfLastAction, } diff --git a/packages/extension/src/ui/features/actions/hooks/usePrettyError.ts b/packages/extension/src/ui/features/actions/hooks/usePrettyError.ts new file mode 100644 index 000000000..4d7d01f3a --- /dev/null +++ b/packages/extension/src/ui/features/actions/hooks/usePrettyError.ts @@ -0,0 +1,32 @@ +import { z } from "zod" + +const exceptionMappings = [ + { + original: "63: An unexpected error occurred", + replacement: + "Starknet is currently experiencing high traffic. Please try again in a few minutes.", + title: "Tx not executed: high traffic", + }, +] + +const transactionErrorMessageSchema = z + .string() + .transform((message) => { + const foundException = exceptionMappings.find((exception) => + message.includes(exception.original), + ) + return foundException ? foundException.replacement : message + }) + .optional() + +export const usePrettyError = (message?: string, isTransaction?: boolean) => { + const defaultTitle = isTransaction ? "Transaction failed" : "Action failed" + + const foundException = exceptionMappings.find((exception) => + message?.includes(exception.original), + ) + const title = foundException ? foundException.title : defaultTitle + const errorMessage = transactionErrorMessageSchema.parse(message) + + return { title, errorMessage } +} diff --git a/packages/extension/src/ui/features/actions/hooks/useRejectDeployAction.ts b/packages/extension/src/ui/features/actions/hooks/useRejectDeployAction.ts new file mode 100644 index 000000000..704571491 --- /dev/null +++ b/packages/extension/src/ui/features/actions/hooks/useRejectDeployAction.ts @@ -0,0 +1,18 @@ +import { useCallback } from "react" +import { allActionsView } from "../../../views/actions" +import { useView } from "../../../views/implementation/react" +import { useActionScreen } from "./useActionScreen" + +export function useRejectDeployIfPresent() { + const allActions = useView(allActionsView) + const { rejectActionWithHash } = useActionScreen() + + return useCallback(async () => { + const deployAction = allActions.find( + (action) => action.type === "DEPLOY_ACCOUNT", + ) + if (deployAction) { + await rejectActionWithHash(deployAction.meta.hash) + } + }, [allActions, rejectActionWithHash]) +} diff --git a/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/ApproveTransactionScreen.test.tsx b/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/ApproveTransactionScreen.test.tsx index 57679bdf2..04a8cff52 100644 --- a/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/ApproveTransactionScreen.test.tsx +++ b/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/ApproveTransactionScreen.test.tsx @@ -10,12 +10,14 @@ import { jediswap, jediswapUnsafe, transfer, + transferV3, } from "../../__fixtures__" import { TransactionActionFixture } from "../../__fixtures__/types" import { ApproveScreenType } from "../types" import { ApproveTransactionScreen } from "./ApproveTransactionScreen" import { getDisplayWarnAndReasonForTransactionReview } from "../../../../../shared/transactionReview.service" import { ApproveTransactionScreenProps } from "./approveTransactionScreen.model" +import { TransactionType } from "starknet" const renderWithProps = async ( props: TransactionActionFixture & @@ -52,6 +54,10 @@ const renderWithProps = async ( hasPendingMultisigTransactions={false} selectedAccount={accounts[0]} approveScreenType={ApproveScreenType.TRANSACTION} + transactionAction={{ + type: TransactionType.INVOKE, + payload: props.transactions, + }} setShowTxDetails={() => undefined} showTxDetails={false} {...props} @@ -139,6 +145,27 @@ describe("ApproveTransactionScreen", () => { ).toBeInTheDocument() }) + it("should render transfer v3 scenario as expected", async () => { + window.scrollTo = vi.fn(noop) + + const onReject = vi.fn() + const onSubmit = vi.fn() + + await renderWithProps({ + ...transferV3, + transactionActionsType: { + type: "INVOKE_FUNCTION", + payload: jediswap.transactions, + }, + onReject, + onSubmit, + }) + + expect( + screen.getByText(/This transaction has been flagged as dangerous/), + ).toBeInTheDocument() + }) + it("should render aspect scenario as expected", async () => { const onReject = vi.fn() const onSubmit = vi.fn() diff --git a/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/ApproveTransactionScreen.tsx b/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/ApproveTransactionScreen.tsx index eef8b2fbf..ee0489eb3 100644 --- a/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/ApproveTransactionScreen.tsx +++ b/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/ApproveTransactionScreen.tsx @@ -43,6 +43,7 @@ export const ApproveTransactionScreen: FC = ({ confirmButtonText = "Confirm", multisigBannerProps, onConfirmAnyway, + transactionAction, ...rest }) => { const showTxActions = @@ -60,7 +61,7 @@ export const ApproveTransactionScreen: FC = ({ if (hasPendingMultisigTransactions) { multisigModalDisclosure.onOpen() } else { - onSubmit(transactions) + onSubmit(transactionAction) } }} showHeader={true} @@ -114,8 +115,8 @@ export const ApproveTransactionScreen: FC = ({ isOpen={multisigModalDisclosure.isOpen} onConfirm={() => onConfirmAnyway - ? onConfirmAnyway(transactions) - : onSubmit(transactions) + ? onConfirmAnyway(transactionAction) + : onSubmit(transactionAction) } onClose={multisigModalDisclosure.onClose} noOfOwners={multisig.threshold} diff --git a/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/ApproveTransactionScreenContainer.tsx b/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/ApproveTransactionScreenContainer.tsx index 4afa3cd4e..96a3e9a94 100644 --- a/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/ApproveTransactionScreenContainer.tsx +++ b/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/ApproveTransactionScreenContainer.tsx @@ -1,9 +1,9 @@ import { useDisclosure } from "@chakra-ui/react" import { useAtom } from "jotai" -import { isArray, isEmpty, isFunction } from "lodash-es" +import { isEmpty, isFunction } from "lodash-es" import { FC, useCallback, useMemo, useState } from "react" import { Navigate, useNavigate } from "react-router-dom" -import { Call } from "starknet" +import { Call, TransactionType, isSierra } from "starknet" import { getDisplayWarnAndReasonForTransactionReview } from "../../../../../shared/transactionReview.service" import { routes } from "../../../../routes" @@ -21,11 +21,15 @@ import { useAggregatedSimData } from "../useTransactionSimulatedData" import { useTransactionSimulation } from "../useTransactionSimulation" import { ApproveTransactionScreen } from "./ApproveTransactionScreen" import { MultisigBannerProps } from "./MultisigBanner" -import { useEstimatedFees } from "../../feeEstimation/useEstimatedFees" import { WithActionScreenErrorFooter } from "./WithActionScreenErrorFooter" import { ApproveTransactionScreenContainerProps } from "./approveTransactionScreen.model" -import { ensureArray } from "@argent/shared" +import { Address, TransactionAction, ensureArray } from "@argent/shared" import { ETH_TOKEN_ADDRESS } from "../../../../../shared/network/constants" +import { useBestFeeToken } from "../../useBestFeeToken" +import { FeeTokenPickerModal } from "../../feeEstimation/ui/FeeTokenPickerModal" +import { feeTokenService } from "../../../../services/feeToken" +import { BaseToken } from "../../../../../shared/token/__new/types/token.model" +import { useFeeTokenBalances } from "../../../accountTokens/useFeeTokenBalance" export const ApproveTransactionScreenContainer: FC< ApproveTransactionScreenContainerProps @@ -34,7 +38,7 @@ export const ApproveTransactionScreenContainer: FC< actionIsApproving, actionErrorApproving, selectedAccount, - transactions, + transactionAction, onReject, onSubmit, onConfirmAnyway, @@ -58,21 +62,40 @@ export const ApproveTransactionScreenContainer: FC< const { data: transactionReview } = useTransactionReview({ account: selectedAccount, - transactions, + transactions: + transactionAction.type === "INVOKE_FUNCTION" + ? transactionAction.payload + : [], actionHash, }) + // This is required because if STRK is selected as the user preferred fee token + // It would throw an error as tx v1 doesn't support STRK + const preferFeeToken = useMemo(() => { + if ( + transactionAction.type === TransactionType.DECLARE && + !isSierra(transactionAction.payload.contract) + ) { + return [ETH_TOKEN_ADDRESS] as Address[] + } + }, [transactionAction.payload, transactionAction.type]) + + const feeToken = useBestFeeToken(selectedAccount, { + prefer: preferFeeToken, + }) + + const feeTokens = useFeeTokenBalances(selectedAccount) + const { data: transactionSimulation, isValidating: isSimulationValidating, error: transactionSimulationError, } = useTransactionSimulation({ - transactions, + transactionAction, + feeTokenAddress: feeToken.address, actionHash, }) - const simulationEstimatedFee = useEstimatedFees(transactions) - const multisigModalDisclosure = useDisclosure() const isSimulationLoading = isSimulationValidating && !transactionSimulation @@ -105,14 +128,14 @@ export const ApproveTransactionScreenContainer: FC< [setUserClickedAddFunds, userClickedAddFunds], ) - const onSubmitAction = (transactions: Call | Call[]) => { + const onSubmitAction = (transactionAction: TransactionAction) => { if (hasInsufficientFunds) { navigate(routes.funding(), { state: { showOnTop: true } }) setUserClickedAddFunds(true) setHasInsufficientFunds(false) setDisableConfirm(true) } else { - onSubmit(transactions) + onSubmit(transactionAction) } } @@ -124,8 +147,11 @@ export const ApproveTransactionScreenContainer: FC< } const transactionsArray: Call[] = useMemo( - () => (isArray(transactions) ? transactions : [transactions]), - [transactions], + () => + transactionAction.type === "INVOKE_FUNCTION" + ? ensureArray(transactionAction.payload) + : [], + [transactionAction.payload, transactionAction.type], ) const txnHasTransfers = useMemo( @@ -202,6 +228,26 @@ export const ApproveTransactionScreenContainer: FC< const showFraudMonitorBanner = Boolean(warn && !isChangeGuardianTx) + // Disable fee token selection if the transaction is a Cairo0 declare transaction + const declareSupportTokenSelection = useMemo( + () => + transactionAction.type !== TransactionType.DECLARE || + isSierra(transactionAction.payload.contract), + [transactionAction.payload, transactionAction.type], + ) + + // Disable fee token selection if the transaction is an upgrade transaction + // or if its a multisig account + const allowFeeTokenSelection = !multisig && declareSupportTokenSelection + + // Same as TransactionActionsContainerV2 + const setPreferredFeeToken = useCallback(async ({ address }: BaseToken) => { + await feeTokenService.preferFeeToken(address) + setIsFeeTokenPickerOpen(false) + }, []) + + const [isFeeTokenPickerOpen, setIsFeeTokenPickerOpen] = useState(false) + if (!selectedAccount) { return } @@ -216,73 +262,84 @@ export const ApproveTransactionScreenContainer: FC< } return ( - - {selectedAccount.needsDeploy ? ( - - ) : ( - - )} -
- } - {...rest} - /> + <> + + {selectedAccount.needsDeploy ? ( + setIsFeeTokenPickerOpen(true)} + /> + ) : ( + setIsFeeTokenPickerOpen(true)} + /> + )} +
+ } + {...rest} + /> + setIsFeeTokenPickerOpen(false)} + tokens={feeTokens} + onFeeTokenSelect={setPreferredFeeToken} + /> + ) } diff --git a/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/ConfirmScreen.tsx b/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/ConfirmScreen.tsx index 9434aabe5..f16b9dbb0 100644 --- a/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/ConfirmScreen.tsx +++ b/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/ConfirmScreen.tsx @@ -154,11 +154,6 @@ export const ConfirmScreen: FC = ({ type="submit" isLoading={confirmButtonIsLoading} loadingText={confirmButtonLoadingText} - leftIcon={ - destructive ? ( - - ) : undefined - } sx={{ pointerEvents: "auto !important", }} diff --git a/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/WithActionScreenErrorFooter.tsx b/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/WithActionScreenErrorFooter.tsx index b0bd3f4d0..5bcc47a3b 100644 --- a/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/WithActionScreenErrorFooter.tsx +++ b/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/WithActionScreenErrorFooter.tsx @@ -10,6 +10,7 @@ import { import React, { FC, PropsWithChildren } from "react" import { useActionScreen } from "../../hooks/useActionScreen" +import { usePrettyError } from "../../hooks/usePrettyError" const { AlertIcon } = icons @@ -21,10 +22,13 @@ export const WithActionScreenErrorFooter: FC< WithActionScreenErrorFooterProps > = ({ children, isTransaction }) => { const { action } = useActionScreen() - if (!action?.meta.errorApproving) { + const { errorMessage, title } = usePrettyError( + action?.meta.errorApproving, + isTransaction, + ) + if (!errorMessage) { return <>{children} } - const message = isTransaction ? "Transaction failed" : "Action failed" return ( <> {children} @@ -33,11 +37,11 @@ export const WithActionScreenErrorFooter: FC< {" "} - {message} + {title} - {action.meta.errorApproving} + {errorMessage} diff --git a/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/approveTransactionScreen.model.ts b/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/approveTransactionScreen.model.ts index 767dc857f..c0503e827 100644 --- a/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/approveTransactionScreen.model.ts +++ b/packages/extension/src/ui/features/actions/transaction/ApproveTransactionScreen/approveTransactionScreen.model.ts @@ -13,23 +13,24 @@ import { ApproveScreenType, TransactionActionsType } from "../types" import { AggregatedSimData } from "../useTransactionSimulatedData" import { ConfirmScreenProps } from "./ConfirmScreen" import { MultisigBannerProps } from "./MultisigBanner" +import { TransactionAction } from "@argent/shared" export interface ApproveTransactionScreenContainerProps extends Omit { actionHash: string actionIsApproving?: boolean actionErrorApproving?: string - onSubmit: (transactions: Call | Call[]) => void + onSubmit: (transactionAction: TransactionAction) => void approveScreenType: ApproveScreenType declareOrDeployType?: "declare" | "deploy" selectedAccount?: WalletAccount - transactions: Call | Call[] + transactionAction: TransactionAction onRejectWithoutClose?: () => void multisigBannerProps?: MultisigBannerProps hideFooter?: boolean multisigModalDisclosure?: UseDisclosureReturn transactionContext?: "STANDARD_EXECUTE" | "MULTISIG_ADD_SIGNATURE" - onConfirmAnyway?: (transactions: Call | Call[]) => void + onConfirmAnyway?: (transactionAction: TransactionAction) => void } export interface ApproveTransactionScreenProps diff --git a/packages/extension/src/ui/features/actions/transaction/executeFromOutside/model.ts b/packages/extension/src/ui/features/actions/transaction/executeFromOutside/model.ts index 5c762b626..0ac4f8fc4 100644 --- a/packages/extension/src/ui/features/actions/transaction/executeFromOutside/model.ts +++ b/packages/extension/src/ui/features/actions/transaction/executeFromOutside/model.ts @@ -20,7 +20,7 @@ export const outsideCallSchema = z.object({ * See https://github.com/starknet-io/SNIPs/blob/main/SNIPS/snip-9.md#1-build-the-outsideexecution-struct */ export const outsideExecutionMessageSchema = z.object({ - caller: addressSchema, // The StarkNet address of the caller + caller: addressSchema, // The Starknet address of the caller nonce: bigNumberishSchema, // Nonce for replay protection execute_after: bigNumberishSchema, // Timestamp after which the execution is valid execute_before: bigNumberishSchema, // Timestamp before which the execution must occur diff --git a/packages/extension/src/ui/features/actions/transaction/useTransactionSimulatedData.ts b/packages/extension/src/ui/features/actions/transaction/useTransactionSimulatedData.ts index 4cea3bb24..c4dbf8c76 100644 --- a/packages/extension/src/ui/features/actions/transaction/useTransactionSimulatedData.ts +++ b/packages/extension/src/ui/features/actions/transaction/useTransactionSimulatedData.ts @@ -172,7 +172,7 @@ const DEFAULT_TRANSACTION_SIMULATION = [ gasPrice: 0, gasUsage: 0, overallFee: 0, - unit: "wei", + unit: "WEI" as const, maxFee: 0, }, }, diff --git a/packages/extension/src/ui/features/actions/transaction/useTransactionSimulation.ts b/packages/extension/src/ui/features/actions/transaction/useTransactionSimulation.ts index 9dfcaa04e..185e7fef5 100644 --- a/packages/extension/src/ui/features/actions/transaction/useTransactionSimulation.ts +++ b/packages/extension/src/ui/features/actions/transaction/useTransactionSimulation.ts @@ -1,6 +1,10 @@ -import { swrRefetchDisabledConfig } from "@argent/shared" +import { + Address, + TransactionAction, + swrRefetchDisabledConfig, +} from "@argent/shared" import { useCallback } from "react" -import { Call } from "starknet" +import { TransactionType } from "starknet" import { sendMessage, waitForMessage } from "../../../../shared/messages" import { ApiTransactionBulkSimulationResponse } from "../../../../shared/transactionSimulation/types" @@ -8,7 +12,8 @@ import { useConditionallyEnabledSWR } from "../../../services/swr.service" import { ARGENT_TRANSACTION_SIMULATION_API_ENABLED } from "./../../../../shared/api/constants" export interface IUseTransactionSimulation { - transactions: Call | Call[] + transactionAction: TransactionAction + feeTokenAddress: Address actionHash?: string } @@ -17,12 +22,21 @@ export const useTransactionSimulationEnabled = () => { } export const useTransactionSimulation = ({ - transactions, + transactionAction, + feeTokenAddress, actionHash = "", }: IUseTransactionSimulation) => { const transactionSimulationEnabled = useTransactionSimulationEnabled() const transactionSimulationFetcher = useCallback(async () => { - void sendMessage({ type: "SIMULATE_TRANSACTIONS", data: transactions }) + if (transactionAction.type !== TransactionType.INVOKE) { + // Backend Tx simulation only supports INVOKE transactions + return undefined + } + + void sendMessage({ + type: "SIMULATE_TRANSACTIONS", + data: { call: transactionAction.payload, feeTokenAddress }, + }) const result = await Promise.race([ waitForMessage("SIMULATE_TRANSACTIONS_RES"), @@ -41,7 +55,7 @@ export const useTransactionSimulation = ({ } return result.simulation - }, [transactions]) // eslint-disable-line react-hooks/exhaustive-deps + }, [transactionAction]) // eslint-disable-line react-hooks/exhaustive-deps return useConditionallyEnabledSWR< ApiTransactionBulkSimulationResponse | undefined >( diff --git a/packages/extension/src/ui/features/actions/transactionV2/FeeEstimationContainerV2.tsx b/packages/extension/src/ui/features/actions/transactionV2/FeeEstimationContainerV2.tsx index 034b1e4c9..2ecfdb2e0 100644 --- a/packages/extension/src/ui/features/actions/transactionV2/FeeEstimationContainerV2.tsx +++ b/packages/extension/src/ui/features/actions/transactionV2/FeeEstimationContainerV2.tsx @@ -1,21 +1,20 @@ import { isFunction } from "lodash-es" import { FC, useEffect, useMemo } from "react" - +import { TokenWithBalance } from "@argent/shared" import { useAccount } from "../../accounts/accounts.state" -import { useTokenAmountToCurrencyValue } from "../../accountTokens/tokenPriceHooks" +import { + useCurrencyDisplayEnabled, + useTokenAmountToCurrencyValue, +} from "../../accountTokens/tokenPriceHooks" import { FeeEstimation } from "../feeEstimation/FeeEstimation" import { ParsedFeeError, getParsedFeeError } from "../feeEstimation/feeError" import { EstimatedFees } from "../../../../shared/transactionSimulation/fees/fees.model" - -import { useTokenBalance } from "../../accountTokens/tokens.state" -import { Address } from "@argent/shared" import { estimatedFeesToMaxFeeTotal, estimatedFeesToTotal, } from "../../../../shared/transactionSimulation/utils" export interface FeeEstimationContainerV2Props { - feeTokenAddress: Address onChange?: (fee: bigint) => void onErrorChange?: (error: boolean) => void onFeeErrorChange?: (error: boolean) => void @@ -24,13 +23,14 @@ export interface FeeEstimationContainerV2Props { transactionSimulationLoading: boolean transactionSimulationFeeError?: Error needsDeploy?: boolean - error?: any fee: EstimatedFees + feeToken: TokenWithBalance + onOpenFeeTokenPicker?: () => void + allowFeeTokenSelection?: boolean } export const FeeEstimationContainerV2: FC = ({ - feeTokenAddress, accountAddress, networkId, onErrorChange, @@ -39,19 +39,17 @@ export const FeeEstimationContainerV2: FC = ({ needsDeploy = false, error, fee, + feeToken, + onOpenFeeTokenPicker, + allowFeeTokenSelection = true, }) => { const account = useAccount({ address: accountAddress, networkId }) if (!account) { throw new Error("Account not found") } - const feeToken = useTokenBalance(feeTokenAddress, account) - const enoughBalance = useMemo( - () => - Boolean( - feeToken?.balance && feeToken?.balance >= estimatedFeesToTotal(fee), - ), + () => feeToken.balance >= estimatedFeesToMaxFeeTotal(fee), [feeToken?.balance, fee], ) @@ -85,12 +83,15 @@ export const FeeEstimationContainerV2: FC = ({ } } + const showCurrencyValue = useCurrencyDisplayEnabled() + const amountCurrencyValue = useTokenAmountToCurrencyValue( - feeToken || undefined, + showCurrencyValue && feeToken ? feeToken : undefined, estimatedFeesToTotal(fee), - ) + ) // will return undefined if no feeToken or showCurrencyValue is false + const suggestedMaxFeeCurrencyValue = useTokenAmountToCurrencyValue( - feeToken || undefined, + showCurrencyValue && feeToken ? feeToken : undefined, estimatedFeesToMaxFeeTotal(fee), ) @@ -107,6 +108,8 @@ export const FeeEstimationContainerV2: FC = ({ showFeeError={showFeeError} suggestedMaxFeeCurrencyValue={suggestedMaxFeeCurrencyValue} needsDeploy={needsDeploy} + onOpenFeeTokenPicker={onOpenFeeTokenPicker} + allowFeeTokenSelection={allowFeeTokenSelection} /> )} diff --git a/packages/extension/src/ui/features/actions/transactionV2/TransactionActionScreenContainerV2.tsx b/packages/extension/src/ui/features/actions/transactionV2/TransactionActionScreenContainerV2.tsx index 915f0183d..02f82b35a 100644 --- a/packages/extension/src/ui/features/actions/transactionV2/TransactionActionScreenContainerV2.tsx +++ b/packages/extension/src/ui/features/actions/transactionV2/TransactionActionScreenContainerV2.tsx @@ -1,4 +1,4 @@ -import { FC, useCallback, useEffect, useMemo, useState } from "react" +import { FC, useCallback, useEffect, useMemo, useRef, useState } from "react" import { useTransactionReviewV2 } from "./useTransactionReviewV2" import { useActionScreen } from "../hooks/useActionScreen" @@ -15,7 +15,7 @@ import { } from "@chakra-ui/react" import { isArray } from "lodash-es" import { TransactionHeader } from "./TransactionHeader" -import { WarningBanner } from "./warning/WarningBanner" +import { WarningBanner } from "../warning/WarningBanner" import { FeeEstimationContainerV2 } from "./FeeEstimationContainerV2" import { isEmpty, isObject } from "lodash-es" import { routes } from "../../../routes" @@ -45,11 +45,35 @@ import { import { TransactionReviewLabel } from "./TransactionReviewLabel" const { AlertIcon } = icons -import { ETH_TOKEN_ADDRESS } from "../../../../shared/network/constants" import { warningSchema } from "../../../../shared/transactionReview/schema" import { z } from "zod" -import { ConfirmationModal } from "./warning/ConfirmationModal" -import { getHighestSeverity } from "./warning/helper" +import { useBestFeeToken } from "../useBestFeeToken" +import { ConfirmationModal } from "../warning/ConfirmationModal" +import { getHighestSeverity } from "../warning/helper" +import { ReviewFooter } from "../warning/ReviewFooter" +import { useRejectDeployIfPresent } from "../hooks/useRejectDeployAction" +import { FeeTokenPickerModal } from "../feeEstimation/ui/FeeTokenPickerModal" +import { useFeeTokenBalances } from "../../accountTokens/useFeeTokenBalance" +import { useUpgradeAccountTransactions } from "../../accounts/accountTransactions.state" +import { useTxnsHasV3UpgradeCallback } from "./useTxnsHasV3Upgrade" +import { BaseToken } from "../../../../shared/token/__new/types/token.model" +import { feeTokenService } from "../../../services/feeToken" +import { isTransactionActionItem } from "../../../../shared/actionQueue/types" +import { useNetworkFeeTokens } from "../../accountTokens/tokens.state" +import { + Address, + formatAddress, + getUint256CalldataFromBN, + isEqualAddress, + nonNullable, + transferCalldataSchema, +} from "@argent/shared" +import { maxFeeEstimateForTransfer } from "../../accountTokens/useMaxFeeForTransfer" +import { tokenService } from "../../../services/tokens" +import { formatUnits } from "ethers" +import { parseTransferTokenCall } from "./utils" +import { prettifyTokenNumber } from "../../../../shared/utils/number" +import { isSafeUpgradeTransaction } from "../../../../shared/utils/isUpgradeTransaction" export interface TransactionActionScreenContainerV2Props extends ConfirmScreenProps { @@ -85,18 +109,28 @@ export const TransactionActionScreenContainerV2: FC< userClickedAddFundsAtom, ) const [askForConfirmation, setAskForConfirmation] = useState(false) + const [isHighRisk, setIsHighRisk] = useState(false) + const [hasAcceptedRisk, setHasAcceptedRisk] = useState(false) + const [isFeeTokenPickerOpen, setIsFeeTokenPickerOpen] = useState(false) + const rejectDeployIfPresent = useRejectDeployIfPresent() + const feeTokePickerRef = useRef(null) + + const feeTokens = useFeeTokenBalances(selectedAccount) const onSubmit = useCallback(async () => { const result = await approve() if (isObject(result) && "error" in result) { // stay on error screen } else { + await rejectDeployIfPresent() closePopupIfLastAction() if (location.pathname === routes.swap()) { navigate(routes.accountActivity()) } } - }, [closePopupIfLastAction, navigate, approve]) + }, [approve, rejectDeployIfPresent, closePopupIfLastAction, navigate]) + + const feeToken = useBestFeeToken(selectedAccount) const { data: transactionReview, @@ -105,6 +139,8 @@ export const TransactionActionScreenContainerV2: FC< } = useTransactionReviewV2({ calls: action.payload.transactions, actionHash: action.meta.hash, + feeTokenAddress: feeToken?.address, + selectedAccount, }) const loadingOrErrorState = useMemo(() => { @@ -224,6 +260,18 @@ export const TransactionActionScreenContainerV2: FC< warningsWithoutUndefined.success && getHighestSeverity(warningsWithoutUndefined.data) + const { pendingTransactions: pendingUpgradeTransactions } = + useUpgradeAccountTransactions(selectedAccount) + + const txnsHasV3UpgradeTxn = useTxnsHasV3UpgradeCallback() + + const isUpgradeTransaction = useMemo( + () => + isTransactionActionItem(action) && + isSafeUpgradeTransaction(action.payload), + [action], + ) + useEffect(() => { if ( highestSeverityWarning && @@ -231,6 +279,7 @@ export const TransactionActionScreenContainerV2: FC< highestSeverityWarning.severity === "high") ) { setAskForConfirmation(true) + setIsHighRisk(true) } }, [highestSeverityWarning]) @@ -242,6 +291,7 @@ export const TransactionActionScreenContainerV2: FC< void reject()} + onConfirm={() => setHasAcceptedRisk(true)} /> ) }, [warningsWithoutUndefined, reject]) @@ -277,6 +327,11 @@ export const TransactionActionScreenContainerV2: FC< multisigModalDisclosure, } = useMultisigActionScreen({ onSubmit, transactionContext }) + const networkFeeTokens = useNetworkFeeTokens(selectedAccount?.networkId) + // Disable fee token selection if the transaction is an upgrade transaction + // or if its a multisig account + const allowFeeTokenSelection = !isUpgradeTransaction && !multisig + const onShowAddFunds = useCallback( (hasInsufficientFunds: boolean) => { if (!hasInsufficientFunds) { @@ -290,6 +345,73 @@ export const TransactionActionScreenContainerV2: FC< [setUserClickedAddFunds, userClickedAddFunds], ) + const setPreferredFeeToken = useCallback( + async ({ address }: BaseToken) => { + await feeTokenService.preferFeeToken(address) + + const transferTokenCall = + action.payload.meta?.isMaxSend && + parseTransferTokenCall(action.payload.transactions) + const transferToken = + transferTokenCall && + networkFeeTokens?.find((networkFeeToken) => + isEqualAddress( + networkFeeToken.address, + transferTokenCall.tokenAddress, + ), + ) + setIsFeeTokenPickerOpen(false) + + if (transferTokenCall && transferToken && selectedAccount) { + // If the user has selected a different fee token, we need to recompute the max amount + console.warn( + "Max send detected, recreating transaction with new max amount", + ) + const { recipient, tokenAddress } = transferTokenCall + const maxFeeForTransfer = await maxFeeEstimateForTransfer( + address, + tokenAddress, + selectedAccount, + ) + const balance = await tokenService.fetchTokenBalance( + transferToken.address, + selectedAccount.address as Address, + selectedAccount.networkId, + ) + const maxAmount = BigInt(balance) - (maxFeeForTransfer ?? 0n) + if (!nonNullable(maxAmount)) { + throw new Error("maxAmount could not be determined") + } + const formattedMaxAmount = formatUnits( + maxAmount, + transferToken.decimals, + ) + + await tokenService.send({ + to: tokenAddress, + method: "transfer", + calldata: transferCalldataSchema.parse({ + recipient, + amount: getUint256CalldataFromBN(maxAmount), + }), + title: `Send ${prettifyTokenNumber(formattedMaxAmount)} ${ + transferToken.symbol + }`, + subtitle: `to ${formatAddress(recipient)}`, + isMaxSend: true, + }) + void reject() + } + }, + [ + action.payload.meta?.isMaxSend, + action.payload.transactions, + networkFeeTokens, + reject, + selectedAccount, + ], + ) + const onConfirm = () => { onConfirmationModalClose() void onSubmit() @@ -313,21 +435,28 @@ export const TransactionActionScreenContainerV2: FC< } void onSubmit() } + const onReject = useCallback(() => { + setUserClickedAddFunds(false) + void reject() + }, [reject, setUserClickedAddFunds]) const footer = userClickedAddFunds ? ( ) : ( + {isHighRisk && } {selectedAccount && transactionReview?.enrichedFeeEstimation && ( setIsFeeTokenPickerOpen(true)} + allowFeeTokenSelection={allowFeeTokenSelection} error={error} /> )} @@ -351,13 +480,15 @@ export const TransactionActionScreenContainerV2: FC< void reject()} + onReject={onReject} footer={footer} - destructive={askForConfirmation} + destructive={askForConfirmation || isHighRisk} {...rest} > {multisigModal} @@ -384,6 +515,15 @@ export const TransactionActionScreenContainerV2: FC< onClose={onConfirmationModalClose} onConfirm={onConfirm} /> + { + setIsFeeTokenPickerOpen(false) + }} + tokens={feeTokens} + initialFocusRef={feeTokePickerRef} + onFeeTokenSelect={setPreferredFeeToken} + />
) } diff --git a/packages/extension/src/ui/features/actions/transactionV2/simulation/summary/TransactionReviewSummary.tsx b/packages/extension/src/ui/features/actions/transactionV2/simulation/summary/TransactionReviewSummary.tsx index 3dbc5bed7..74018f660 100644 --- a/packages/extension/src/ui/features/actions/transactionV2/simulation/summary/TransactionReviewSummary.tsx +++ b/packages/extension/src/ui/features/actions/transactionV2/simulation/summary/TransactionReviewSummary.tsx @@ -1,5 +1,6 @@ import { B3, P4, icons } from "@argent/ui" import { Flex, Image, Square } from "@chakra-ui/react" +import { ensureDecimals } from "@argent/shared" import { SimulationSummary } from "../../../../../../shared/transactionReview/schema" import { TokenIcon } from "../../../../accountTokens/TokenIcon" @@ -61,7 +62,7 @@ function TokenSummary(summary: SimulationSummary) { const { value, usdValue, token } = summary const displayAmount = prettifyTokenAmount({ amount: value || 0, - decimals: token?.decimals || 18, + decimals: ensureDecimals(token?.decimals), symbol: token?.symbol || "Unknown token", }) const { color, prefix } = getAttributes(summary) @@ -72,7 +73,7 @@ function TokenSummary(summary: SimulationSummary) { {token.name} - + {prefix} {displayAmount} diff --git a/packages/extension/src/ui/features/actions/transactionV2/useTransactionReviewV2.ts b/packages/extension/src/ui/features/actions/transactionV2/useTransactionReviewV2.ts index 883d0fc59..37ff9eaca 100644 --- a/packages/extension/src/ui/features/actions/transactionV2/useTransactionReviewV2.ts +++ b/packages/extension/src/ui/features/actions/transactionV2/useTransactionReviewV2.ts @@ -3,29 +3,43 @@ import useSWRImmutable from "swr/immutable" import { TransactionReviewTransactions } from "../../../../shared/transactionReview/interface" import { clientTransactionReviewService } from "../../../services/transactionReview" -import { useView } from "../../../views/implementation/react" -import { selectedAccountView } from "../../../views/account" import { Call } from "starknet" -import { isArray } from "lodash-es" +import { isArray, isEmpty } from "lodash-es" import { clientAccountService } from "../../../services/account" +import { Address } from "@argent/shared" +import { accountService } from "../../../../shared/account/service" +import { BaseWalletAccount } from "../../../../shared/wallet.model" +import { accountsEqual } from "../../../../shared/utils/accountsEqual" export interface IUseTransactionReviewV2 { + feeTokenAddress: Address calls: Call | Call[] actionHash: string + selectedAccount: BaseWalletAccount | undefined } export const useTransactionReviewV2 = ({ + feeTokenAddress, calls, actionHash, + selectedAccount, }: IUseTransactionReviewV2) => { - const currentAccount = useView(selectedAccountView) - const transactionReviewFetcher = useCallback(async () => { const invokeTransactions: TransactionReviewTransactions = { type: "INVOKE", calls: isArray(calls) ? calls : [calls], } + const accountList = await accountService.get((account) => + accountsEqual(account, selectedAccount), + ) + + if (isEmpty(accountList)) { + return + } + + const currentAccount = accountList[0] + const accountDeployTransaction = currentAccount?.needsDeploy ? await clientAccountService.getAccountDeploymentPayload(currentAccount) : null @@ -36,14 +50,20 @@ export const useTransactionReviewV2 = ({ const result = await clientTransactionReviewService.simulateAndReview({ transactions, + feeTokenAddress, }) return result - }, [calls, currentAccount]) + }, [calls, feeTokenAddress, selectedAccount]) /** only fetch a tx simulate and review one time since e.g. a swap may expire */ return useSWRImmutable( - [actionHash, "useTransactionReviewV2", "simulateAndReview"], + [ + actionHash, + "useTransactionReviewV2", + "simulateAndReview", + feeTokenAddress, + ], transactionReviewFetcher, { shouldRetryOnError: false, diff --git a/packages/extension/src/ui/features/actions/transactionV2/useTxnsHasV3Upgrade.ts b/packages/extension/src/ui/features/actions/transactionV2/useTxnsHasV3Upgrade.ts new file mode 100644 index 000000000..c5f1d48f9 --- /dev/null +++ b/packages/extension/src/ui/features/actions/transactionV2/useTxnsHasV3Upgrade.ts @@ -0,0 +1,23 @@ +import { useCallback } from "react" +import { Transaction } from "../../../../shared/transactions" +import { nonNullable } from "@argent/shared" +import { getV3UpgradeCall } from "../utils" + +export function useV3UpgradeFromTxnsCallback() { + return useCallback((transactions: Transaction[]) => { + const txCallsArray = transactions + .flatMap((txn) => txn.meta?.transactions) + .filter(nonNullable) + return getV3UpgradeCall(txCallsArray) + }, []) +} + +export function useTxnsHasV3UpgradeCallback() { + const txnsHasV3Upgrade = useV3UpgradeFromTxnsCallback() + return useCallback( + (transactions: Transaction[]) => { + return Boolean(txnsHasV3Upgrade(transactions)) + }, + [txnsHasV3Upgrade], + ) +} diff --git a/packages/extension/src/ui/features/actions/transactionV2/utils.test.ts b/packages/extension/src/ui/features/actions/transactionV2/utils.test.ts new file mode 100644 index 000000000..278c01e6f --- /dev/null +++ b/packages/extension/src/ui/features/actions/transactionV2/utils.test.ts @@ -0,0 +1,26 @@ +import { test } from "vitest" +import { parseTransferTokenCall } from "./utils" +import { Call, uint256 } from "starknet" +import { + ETH_TOKEN_ADDRESS, + STRK_TOKEN_ADDRESS, +} from "../../../../shared/network/constants" + +test("parseTransferTokenCall", () => { + const mockCall: Call = { + entrypoint: "transfer", + contractAddress: ETH_TOKEN_ADDRESS, + calldata: [STRK_TOKEN_ADDRESS, "0x789", "0xabc"], + } + + const result = parseTransferTokenCall(mockCall) + + expect(result).toEqual({ + tokenAddress: ETH_TOKEN_ADDRESS, + recipient: STRK_TOKEN_ADDRESS, + amount: uint256.uint256ToBN({ + low: "0x789", + high: "0xabc", + }), + }) +}) diff --git a/packages/extension/src/ui/features/actions/transactionV2/utils.ts b/packages/extension/src/ui/features/actions/transactionV2/utils.ts new file mode 100644 index 000000000..eb7e05424 --- /dev/null +++ b/packages/extension/src/ui/features/actions/transactionV2/utils.ts @@ -0,0 +1,22 @@ +import { Address, addressSchema } from "@argent/shared" +import { isArray } from "lodash-es" +import { Call, CallData, num, uint256 } from "starknet" + +export function parseTransferTokenCall( + calls: Call | Call[], +): { tokenAddress: Address; recipient: Address; amount: bigint } | undefined { + const call = isArray(calls) ? calls[0] : calls + const calldata = CallData.toCalldata(call.calldata) + if (call.entrypoint !== "transfer" || calldata.length !== 3) { + // Transfer will always have 3 arguments: [receipient, amountLow, amountHigh] + return undefined + } + try { + const tokenAddress = addressSchema.parse(num.toHex(call.contractAddress)) + const recipient = addressSchema.parse(num.toHex(calldata[0])) + const amount = uint256.uint256ToBN({ low: calldata[1], high: calldata[2] }) + return { tokenAddress, recipient, amount } + } catch { + return undefined + } +} diff --git a/packages/extension/src/ui/features/actions/useBestFeeToken.ts b/packages/extension/src/ui/features/actions/useBestFeeToken.ts new file mode 100644 index 000000000..45dfa7e21 --- /dev/null +++ b/packages/extension/src/ui/features/actions/useBestFeeToken.ts @@ -0,0 +1,63 @@ +import { BaseWalletAccount } from "../../../shared/wallet.model" +import { useView } from "../../views/implementation/react" +import { useTokensInNetwork } from "../accountTokens/tokens.state" +import { useAccount } from "../accounts/accounts.state" +import { TokenWithBalance, isEqualAddress } from "@argent/shared" +import { + classHashSupportsTxV3, + feeTokenNeedsTxV3Support, +} from "../../../shared/network/txv3" +import { tokenBalancesForAccountView } from "../../views/tokenBalances" +import { equalToken } from "../../../shared/token/__new/utils" +import { AccountError } from "../../../shared/errors/account" +import { num } from "starknet" +import { FeeTokenPreferenceOption } from "../../../shared/feeToken/types/preference.model" +import { pickBestFeeToken } from "../../../shared/feeToken/utils" +import { useFeeTokenPreference } from "./useFeeTokenPreference" + +export const useBestFeeToken = ( + baseAccount: BaseWalletAccount | undefined, + overrides: FeeTokenPreferenceOption = {}, +) => { + const tokens = useTokensInNetwork(baseAccount?.networkId) + const account = useAccount(baseAccount) + + const { prefer: userPreference } = useFeeTokenPreference() + + if (!account) { + throw new AccountError({ code: "NOT_FOUND" }) + } + + const { classHash, network } = account + + const networkFeeTokens = tokens.filter((token) => + network.possibleFeeTokenAddresses.some((ft) => + isEqualAddress(ft, token.address), + ), + ) + const accountFeeTokens = networkFeeTokens.filter((token) => { + if (feeTokenNeedsTxV3Support(token)) { + return classHashSupportsTxV3(classHash) + } + return true + }) + + const tokenBalancesForAccount = useView(tokenBalancesForAccountView(account)) + + const feeTokensWithBalances: TokenWithBalance[] = accountFeeTokens.map( + (token) => { + const tokenBalance = tokenBalancesForAccount?.find((tb) => + equalToken(tb, token), + ) + return { + ...token, + balance: num.toBigInt(tokenBalance?.balance ?? 0), + } + }, + ) + // Optimized by ensuring overrides.prefer is always an array before spreading + return pickBestFeeToken(feeTokensWithBalances, { + prefer: [...(overrides.prefer || []), userPreference], + avoid: overrides.avoid, + }) +} diff --git a/packages/extension/src/ui/features/actions/useFeeTokenPreference.ts b/packages/extension/src/ui/features/actions/useFeeTokenPreference.ts new file mode 100644 index 000000000..822a24b09 --- /dev/null +++ b/packages/extension/src/ui/features/actions/useFeeTokenPreference.ts @@ -0,0 +1,6 @@ +import { feeTokenPreferenceAtom } from "../../views/feeTokenPreference" +import { useView } from "../../views/implementation/react" + +export function useFeeTokenPreference() { + return useView(feeTokenPreferenceAtom) +} diff --git a/packages/extension/src/ui/features/actions/utils.test.ts b/packages/extension/src/ui/features/actions/utils.test.ts new file mode 100644 index 000000000..3ba67c12a --- /dev/null +++ b/packages/extension/src/ui/features/actions/utils.test.ts @@ -0,0 +1,76 @@ +import { Call, num } from "starknet" +import { getV3UpgradeCall } from "./utils" +import { TXV3_ACCOUNT_CLASS_HASH } from "../../../shared/network/constants" + +describe("getV3UpgradeCall", () => { + it("should return the first call that matches the condition", () => { + const calls: Call[] = [ + { + contractAddress: "0x0000000000", + entrypoint: "deploy", + calldata: [1, 2, 3], + }, + { + contractAddress: "0x0000000000", + entrypoint: "upgrade", + calldata: [ + "0x29927c8af6bccf3f6fda035981e765a7bdbf18a2dc0d630494f8758aa908e2b", // TXV3_ACCOUNT_CLASS_HASH + ], + }, + ] + expect(getV3UpgradeCall(calls)).toEqual(calls[1]) + }) + it("should return undefined if its not v3 upgrade", () => { + const calls = [ + { + contractAddress: "0x0000000000", + entrypoint: "deploy", + calldata: [1, 2, 3], + }, + { + contractAddress: "0x0000000000", + entrypoint: "upgrade", + calldata: [ + "0x1a736d6ed154502257f02b1ccdf4d9d1089f80811cd6acad48e6b6a9d1f2003", // TXV1_ACCOUNT_CLASS_HASH + ], + }, + ] + expect(getV3UpgradeCall(calls)).toBeUndefined() + }) + + it("should return undefined if there is no upgrade call to v3 ClassHash", () => { + const calls = [ + { + contractAddress: "0x0000000000", + entrypoint: "deploy", + calldata: [1, 2, 3], + }, + { + contractAddress: "0x0000000000", + entrypoint: "set_implementation", + calldata: [ + "0x02fadbf77a721b94bdcc3032d86a8921661717fa55145bccf88160ee2a5efcd1", // TXV3_ACCOUNT_CLASS_HASH + ], + }, + ] + expect(getV3UpgradeCall(calls)).toBeUndefined() + }) + + it("should work with BigInt ClassHash", () => { + const calls = [ + { + contractAddress: "0x0000000000", + entrypoint: "deploy", + calldata: [1, 2, 3], + }, + { + contractAddress: "0x0000000000", + entrypoint: "upgrade", + calldata: [ + 1175227876648481476947357169641801659537058431979519277343402598445203099179n, // num.toBigInt(TXV3_ACCOUNT_CLASS_HASH) + ], + }, + ] + expect(getV3UpgradeCall(calls)).toEqual(calls[1]) + }) +}) diff --git a/packages/extension/src/ui/features/actions/utils.ts b/packages/extension/src/ui/features/actions/utils.ts index b8eef0209..aae434d7d 100644 --- a/packages/extension/src/ui/features/actions/utils.ts +++ b/packages/extension/src/ui/features/actions/utils.ts @@ -1,9 +1,10 @@ -import { RawArgs } from "starknet" +import { Call, CallData, RawArgs, num } from "starknet" import { ActionQueueItem } from "../../../shared/actionQueue/schema" import { TransactionActionPayload } from "../../../shared/actionQueue/types" import { MultisigPendingTransaction } from "../../../shared/multisig/pendingTransactionsStore" import { ApproveScreenType } from "./transaction/types" import { MultisigTransactionType } from "../../../shared/multisig/types" +import { TXV3_ACCOUNT_CLASS_HASH } from "../../../shared/network/constants" export const getApproveScreenTypeFromAction = ( action: ActionQueueItem & { @@ -60,3 +61,13 @@ export const formatCalldataSafe = (calldata?: RawArgs) => { ? calldata.map((cd) => cd.toString()) : calldata } + +export function getV3UpgradeCall(calls: Call[]) { + return calls + .filter((call) => call.entrypoint === "upgrade") + .find((call) => + CallData.toCalldata(call.calldata).some( + (cd) => num.toBigInt(cd) === num.toBigInt(TXV3_ACCOUNT_CLASS_HASH), + ), + ) +} diff --git a/packages/extension/src/ui/features/actions/transactionV2/warning/AlertFillIconWithHalo.tsx b/packages/extension/src/ui/features/actions/warning/AlertFillIconWithHalo.tsx similarity index 100% rename from packages/extension/src/ui/features/actions/transactionV2/warning/AlertFillIconWithHalo.tsx rename to packages/extension/src/ui/features/actions/warning/AlertFillIconWithHalo.tsx diff --git a/packages/extension/src/ui/features/actions/transactionV2/warning/ConfirmationModal.tsx b/packages/extension/src/ui/features/actions/warning/ConfirmationModal.tsx similarity index 100% rename from packages/extension/src/ui/features/actions/transactionV2/warning/ConfirmationModal.tsx rename to packages/extension/src/ui/features/actions/warning/ConfirmationModal.tsx diff --git a/packages/extension/src/ui/features/actions/warning/ReviewFooter.tsx b/packages/extension/src/ui/features/actions/warning/ReviewFooter.tsx new file mode 100644 index 000000000..c0a583873 --- /dev/null +++ b/packages/extension/src/ui/features/actions/warning/ReviewFooter.tsx @@ -0,0 +1,18 @@ +import { P4 } from "@argent/ui" +import { Flex } from "@chakra-ui/react" + +export const ReviewFooter = () => { + return ( + + + Please review warnings before continuing + + + ) +} diff --git a/packages/extension/src/ui/features/actions/transactionV2/warning/WarningBanner.tsx b/packages/extension/src/ui/features/actions/warning/WarningBanner.tsx similarity index 94% rename from packages/extension/src/ui/features/actions/transactionV2/warning/WarningBanner.tsx rename to packages/extension/src/ui/features/actions/warning/WarningBanner.tsx index 74be7ee71..bc0d16b47 100644 --- a/packages/extension/src/ui/features/actions/transactionV2/warning/WarningBanner.tsx +++ b/packages/extension/src/ui/features/actions/warning/WarningBanner.tsx @@ -1,5 +1,5 @@ import { Button, Flex, useDisclosure } from "@chakra-ui/react" -import { Warning } from "../../../../../shared/transactionReview/schema" +import { Warning } from "../../../../shared/transactionReview/schema" import { riskToColorMap, riskToHeaderMap } from "./warningMap" import { B2, L2 } from "@argent/ui" @@ -11,9 +11,11 @@ import { WarningModal } from "./WarningModal" export const WarningBanner = ({ warnings, onReject, + onConfirm, }: { warnings: Warning[] onReject: () => void + onConfirm?: () => void }) => { const highestSeverityWarning = getHighestSeverity(warnings) const { @@ -30,6 +32,7 @@ export const WarningBanner = ({ const color = riskToColorMap[highestSeverityWarning.severity] const header = riskToHeaderMap[highestSeverityWarning.severity] const onClose = () => { + onConfirm?.() onWarningModalClose() } diff --git a/packages/extension/src/ui/features/actions/transactionV2/warning/WarningModal.tsx b/packages/extension/src/ui/features/actions/warning/WarningModal.tsx similarity index 75% rename from packages/extension/src/ui/features/actions/transactionV2/warning/WarningModal.tsx rename to packages/extension/src/ui/features/actions/warning/WarningModal.tsx index c19fbe3ff..7ea41d32c 100644 --- a/packages/extension/src/ui/features/actions/transactionV2/warning/WarningModal.tsx +++ b/packages/extension/src/ui/features/actions/warning/WarningModal.tsx @@ -10,7 +10,7 @@ import { ModalOverlay, } from "@chakra-ui/react" import { FC } from "react" -import { Warning } from "../../../../../shared/transactionReview/schema" +import { Warning } from "../../../../shared/transactionReview/schema" import { riskToBadgeMap, riskToColorMap, @@ -19,7 +19,7 @@ import { } from "./warningMap" const { AlertFillIcon } = icons -interface MultisigHideModalProps { +interface WarningModalProps { isOpen: boolean onReject?: () => void onClose: () => void @@ -28,7 +28,7 @@ interface MultisigHideModalProps { highestSeverityWarning: Warning } -export const WarningModal: FC = ({ +export const WarningModal: FC = ({ isOpen, onReject, onClose, @@ -36,16 +36,26 @@ export const WarningModal: FC = ({ warnings, highestSeverityWarning, }) => { + const title = `${warnings.length} risk${ + warnings.length === 1 ? "" : "s" + } identified` return ( - + + - +
- {warnings.length} risk{warnings.length === 1 ? "" : "s"} identified + {title}
{highestSeverityWarning.severity === "critical" && ( = ({ {warningMap[warning.reason].title}
- {warningMap[warning.reason].description} + {warningMap[warning.reason].description( + warning?.details?.reason, + )}
))} + - diff --git a/packages/extension/src/ui/features/actions/transactionV2/warning/helper.test.ts b/packages/extension/src/ui/features/actions/warning/helper.test.ts similarity index 96% rename from packages/extension/src/ui/features/actions/transactionV2/warning/helper.test.ts rename to packages/extension/src/ui/features/actions/warning/helper.test.ts index 161687140..8242bbc66 100644 --- a/packages/extension/src/ui/features/actions/transactionV2/warning/helper.test.ts +++ b/packages/extension/src/ui/features/actions/warning/helper.test.ts @@ -1,4 +1,4 @@ -import { Warning } from "../../../../../shared/transactionReview/schema" +import { Warning } from "../../../../shared/transactionReview/schema" import { getHighestSeverity, getTitleForWarnings } from "./helper" describe("getHighestSeverity", () => { diff --git a/packages/extension/src/ui/features/actions/transactionV2/warning/helper.ts b/packages/extension/src/ui/features/actions/warning/helper.ts similarity index 90% rename from packages/extension/src/ui/features/actions/transactionV2/warning/helper.ts rename to packages/extension/src/ui/features/actions/warning/helper.ts index 5707f75ec..4fb9fb667 100644 --- a/packages/extension/src/ui/features/actions/transactionV2/warning/helper.ts +++ b/packages/extension/src/ui/features/actions/warning/helper.ts @@ -1,4 +1,4 @@ -import { Warning } from "../../../../../shared/transactionReview/schema" +import { Warning } from "../../../../shared/transactionReview/schema" import { warningMap } from "./warningMap" export const getHighestSeverity = (warnings: Warning[]): Warning | null => { diff --git a/packages/extension/src/ui/features/actions/transactionV2/warning/warningMap.ts b/packages/extension/src/ui/features/actions/warning/warningMap.ts similarity index 68% rename from packages/extension/src/ui/features/actions/transactionV2/warning/warningMap.ts rename to packages/extension/src/ui/features/actions/warning/warningMap.ts index 35ca9b31a..8bb78505c 100644 --- a/packages/extension/src/ui/features/actions/transactionV2/warning/warningMap.ts +++ b/packages/extension/src/ui/features/actions/warning/warningMap.ts @@ -2,7 +2,7 @@ import { z } from "zod" import { reasonsSchema, severitySchema, -} from "../../../../../shared/transactionReview/schema" +} from "../../../../shared/warning/schema" export const riskToColorMap: Record, string> = { info: "accent.500", @@ -24,8 +24,8 @@ export const riskToInvertedColorMap: Record< export const riskToHeaderMap: Record, string> = { info: "Double check", caution: "Caution", - high: "High risk transaction", - critical: "Critical risk transaction", + high: "High risk", + critical: "Critical risk", } export const riskToBadgeMap: Record, string> = { @@ -38,98 +38,110 @@ export const warningMap: Record< z.infer, { title: string - description: string + description: (reason?: string) => string } > = { undeployed_account: { title: "Sending to the correct account?", - description: + description: () => "The account you are sending to hasn't done any transactions, please double check the address", }, contract_is_not_verified: { title: "Unverified smart contracts", - description: + description: () => "The dapp you're using has not opened its source code on the block explorers Starkscan or Voyager. This means that no one can check what the smart contract actually does. Make sure you trust the app before you proceed.", }, approval_too_high: { title: "Approval of spending limit is too high", - description: + description: () => "You're approving one or more addresses to spend more tokens than you're using in this transaction. These funds will not be spent but you should not proceed if you don’t trust this app.", }, unknown_token: { title: "Unknown token", - description: + description: () => "You're interacting with a token smart contract that is not known to our registries. Make sure that you trust the application and it is the correct token.", }, contract_is_black_listed: { title: "Smart contract on unsafe list", - description: - "You are using a smart contract that is on our unsafe list for the following reason: [….]. We recommend that you reject the transaction.", + description: (reason) => + `You are using a smart contract that is on our unsafe list for the following reason: ${reason}. We recommend that you reject the transaction.`, }, recipient_is_black_listed: { title: "Recipient on unsafe list", - description: - "You are sending to an unsafe contract that is blacklisted for the the following reason: [...].", + description: (reason) => + `You are sending to an unsafe contract that is blacklisted for the the following reason: ${reason}.`, }, spender_is_black_listed: { title: "Spender on unsafe list", - description: - "You are allowing an unsafe contract to access your funds. We deem this contract unsafe for the following reason: […].", + description: (reason) => + `You are allowing an unsafe contract to access your funds. We deem this contract unsafe for the following reason: ${reason}.`, }, operator_is_black_listed: { title: "Spender on unsafe list", - description: - "You are allowing an unsafe contract to access your funds. We deem this contract unsafe for the following reason: […].", + description: (reason) => + `You are allowing an unsafe contract to access your funds. We deem this contract unsafe for the following reason: ${reason}.`, }, + recipient_is_token_address: { title: "Unintentional burn of assets", - description: + description: () => "You're sending assets to a smart contract that defines a token. This will likely burn your assets (forever). Please double check if this is really your intent.", }, account_upgrade_to_unknown_implementation: { title: "Loss of funds due to invalid update", - description: + description: () => "You're about to execute an update of your Argent Account which is not verified by us. This is a dangerous operation and might lead to loss of your funds. We strongly advise you to not move forward.", }, account_state_change: { title: "Loss of funds due to ownership change", - description: - "You’re about to change the owner of your account. If you proceed with the transaction, you loose access to your funds. We strongly recommend that you reject the transaction.", + description: () => + "You’re about to change the owner of your account. If you proceed with the transaction, you lose access to your funds. We strongly recommend that you reject the transaction.", }, amount_mismatch_too_low: { title: "Poor trade/swap of tokens", - description: + description: () => "You are swapping two tokens at a poor exchange rate (more than 5%). Make sure that there aren't other options with better rates.", }, amount_mismatch_too_high: { title: "Uncommon trade/swap of tokens", - description: + description: () => "You are swapping two tokens at a rate that is much better than current market rates. You receive more than you invest. Double check if everything is correct.", }, internal_service_issue: { title: "Internal issue", - description: + description: () => "An internal issue occurred. Please try again later. If the issue persists, please contact support.", }, recipient_is_not_current_account: { title: "Sender address of token swap is different to receiver address", - description: + description: () => "You are sending tokens for swap, but you won't receive them. If this is not your intention, we strongly recommend to reject the transaction.", }, src_token_black_listed: { title: "Trade of an unsafe token", - description: "You are selling an unsafe token. Be aware of the risks.", + description: () => + "You are selling an unsafe token. Be aware of the risks.", }, dst_token_black_listed: { title: "You are buying an unsafe token.", - description: "You are buying an unsafe token. Be aware of the risks.", + description: () => "You are buying an unsafe token. Be aware of the risks.", }, token_a_black_listed: { title: "Use of an unsafe token", - description: "You are using an unsafe token. Be aware of the risks.", + description: () => "You are using an unsafe token. Be aware of the risks.", }, token_b_black_listed: { title: "Use of an unsafe token", - description: "You are using an unsafe token. Be aware of the risks.", + description: () => "You are using an unsafe token. Be aware of the risks.", + }, + domain_is_black_listed: { + title: "Use of a blacklisted domain", + description: () => + "You are currently on an unsafe domain. Be aware of the risks.", + }, + similar_to_existing_dapp_url: { + title: "Similar to an existing dapp", + description: () => + "You are currently on an unsafe domain. Be aware of the risks.", }, } diff --git a/packages/extension/src/ui/features/discover/AccountDiscoverScreen.tsx b/packages/extension/src/ui/features/discover/AccountDiscoverScreen.tsx new file mode 100644 index 000000000..e54656e77 --- /dev/null +++ b/packages/extension/src/ui/features/discover/AccountDiscoverScreen.tsx @@ -0,0 +1,41 @@ +import { CellStack, Empty, H4, SpacerCell, icons } from "@argent/ui" +import { Center } from "@chakra-ui/react" +import { isEmpty } from "lodash-es" +import { FC } from "react" + +import { NewsItem } from "../../../shared/discover/schema" +import { NewsItemCardCollection } from "./ui/NewsItemCardCollection" + +const { NetworkIcon } = icons + +interface AccountDiscoverScreenProps { + newsItems?: NewsItem[] +} + +export const AccountDiscoverScreen: FC = ({ + newsItems, +}) => { + const hasNewsItems = newsItems && !isEmpty(newsItems) + return ( + +
+

Discover

+
+ + {hasNewsItems ? ( + + ) : ( + } + title={ + <> + No updates. +
+ Check back soon 👀 + + } + /> + )} +
+ ) +} diff --git a/packages/extension/src/ui/features/discover/AccountDiscoverScreenContainer.tsx b/packages/extension/src/ui/features/discover/AccountDiscoverScreenContainer.tsx new file mode 100644 index 000000000..ea15ea8c3 --- /dev/null +++ b/packages/extension/src/ui/features/discover/AccountDiscoverScreenContainer.tsx @@ -0,0 +1,21 @@ +import { FC, useEffect } from "react" + +import { clientDiscoverService } from "../../services/discover" +import { discoverDataView } from "../../views/discover" +import { useView } from "../../views/implementation/react" +import { Account } from "../accounts/Account" +import { AccountDiscoverScreen } from "./AccountDiscoverScreen" + +export interface AccountDiscoverScrenContainerProps { + account: Account +} + +export const AccountDiscoverScreenContainer: FC< + AccountDiscoverScrenContainerProps +> = () => { + const discoverData = useView(discoverDataView) + useEffect(() => { + void clientDiscoverService.setViewedAt(Date.now()) + }, []) + return +} diff --git a/packages/extension/src/ui/features/discover/ui/NewsItemCard.tsx b/packages/extension/src/ui/features/discover/ui/NewsItemCard.tsx new file mode 100644 index 000000000..4aeca9272 --- /dev/null +++ b/packages/extension/src/ui/features/discover/ui/NewsItemCard.tsx @@ -0,0 +1,70 @@ +import { AspectRatio, Button, ButtonProps, Flex, Image } from "@chakra-ui/react" +import { NewsItem } from "../../../../shared/discover/schema" +import { FC } from "react" +import { H6, L2, P4 } from "@argent/ui" +import { isEmpty } from "lodash-es" + +interface NewsItemCardProps extends ButtonProps { + newsItem: NewsItem +} + +export const NewsItemCard: FC = ({ newsItem, ...rest }) => { + const { title, description, backgroundImageUrl, linkUrl, badgeText } = + newsItem + + const hasImage = !isEmpty(backgroundImageUrl) + return ( + + ) +} diff --git a/packages/extension/src/ui/features/discover/ui/NewsItemCardCollection.tsx b/packages/extension/src/ui/features/discover/ui/NewsItemCardCollection.tsx new file mode 100644 index 000000000..40b08f002 --- /dev/null +++ b/packages/extension/src/ui/features/discover/ui/NewsItemCardCollection.tsx @@ -0,0 +1,32 @@ +import { SimpleGrid } from "@chakra-ui/react" +import { FC } from "react" +import { pluralise, daysBetween } from "@argent/shared" + +import { NewsItem } from "../../../../shared/discover/schema" +import { NewsItemCard } from "./NewsItemCard" + +interface NewsItemCardCollectionProps { + newsItems: NewsItem[] +} + +export const NewsItemCardCollection: FC = ({ + newsItems, +}) => { + const now = new Date() + return ( + + {newsItems.map((newsItem, index) => { + const key = `${newsItem.dappId}-${index}` + if (!newsItem.badgeText && newsItem.endTime) { + const value = daysBetween(now, new Date(newsItem.endTime)) + const days = pluralise(value, "day") + const badgeText = `${days} remaining` + return ( + + ) + } + return + })} + + ) +} diff --git a/packages/extension/src/ui/features/funding/FundingFaucetFallbackScreen.tsx b/packages/extension/src/ui/features/funding/FundingFaucetFallbackScreen.tsx index ef448aa5e..097aa4de9 100644 --- a/packages/extension/src/ui/features/funding/FundingFaucetFallbackScreen.tsx +++ b/packages/extension/src/ui/features/funding/FundingFaucetFallbackScreen.tsx @@ -39,7 +39,7 @@ export const FundingFaucetFallbackScreen: FC = () => { variant="info" size="sm" backgroundColor="black" - title="There is no token faucet available yet on Testnet 2" + title={`There is no token faucet available yet on ${network.name}`} mb={3} /> diff --git a/packages/extension/src/ui/features/funding/FundingOnRampOption.tsx b/packages/extension/src/ui/features/funding/FundingOnRampOption.tsx index 1622ff6eb..3ea67f673 100644 --- a/packages/extension/src/ui/features/funding/FundingOnRampOption.tsx +++ b/packages/extension/src/ui/features/funding/FundingOnRampOption.tsx @@ -47,7 +47,7 @@ export const FundingOnRampOption: FC = ({