diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 0cc5d53b7..d1a9dc15b 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -5,23 +5,31 @@ on: - develop pull_request: +env: + FEATURE_PRIVACY_SETTINGS: "true" + FEATURE_EXPERIMENTAL_SETTINGS: "true" + FEATURE_BANXA: "true" + FEATURE_LAYERSWAP: "true" + FEATURE_ORBITER: "true" + FEATURE_VERIFIED_DAPPS: "true" + FEATURE_ARGENT_SHIELD: "true" + ARGENT_SHIELD_NETWORK_ID: "mainnet-alpha" + FEATURE_MULTISIG: "false" + + SEGMENT_WRITE_KEY: ${{ secrets.SEGMENT_WRITE_KEY }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + RAMP_API_KEY: ${{ secrets.RAMP_API_KEY }} + SAFE_ENV_VARS: true + ARGENT_API_BASE_URL: ${{ vars.ARGENT_API_BASE_URL }} + ARGENT_TRANSACTION_REVIEW_API_BASE_URL: ${{ vars.ARGENT_TRANSACTION_REVIEW_API_BASE_URL }} + ARGENT_X_STATUS_URL: ${{ vars.ARGENT_X_STATUS_URL }} + ARGENT_EXPLORER_BASE_URL: ${{ vars.ARGENT_EXPLORER_BASE_URL }} + jobs: setup: runs-on: ubuntu-latest - env: - FEATURE_PRIVACY_SETTINGS: "true" - FEATURE_EXPERIMENTAL_SETTINGS: "true" - FEATURE_BANXA: "true" - FEATURE_LAYERSWAP: "true" - FEATURE_ORBITER: "true" - FEATURE_VERIFIED_DAPPS: "false" - ARGENT_API_BASE_URL: ${{ vars.ARGENT_API_BASE_URL }} - ARGENT_TRANSACTION_REVIEW_API_BASE_URL: ${{ vars.ARGENT_TRANSACTION_REVIEW_API_BASE_URL }} - ARGENT_X_STATUS_URL: ${{ vars.ARGENT_X_STATUS_URL }} - ARGENT_EXPLORER_BASE_URL: ${{ vars.ARGENT_EXPLORER_BASE_URL }} - UPLOAD_SENTRY_SOURCEMAPS: false - steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 @@ -35,12 +43,28 @@ jobs: - name: Build extension run: yarn lerna run --scope @argent-x/extension build + - name: Check bundlesize for Chrome + run: yarn run bundlewatch + - name: Cache build uses: actions/cache@v3 with: path: ./* key: ${{ github.sha }} + - name: Set filename prefix + run: echo "FILENAME_PREFIX=$(echo argent-extension-${{ github.ref_name }} | tr / -)" >> $GITHUB_ENV + + - name: Create chrome zip + run: (cd ./packages/extension/dist && zip -r "../../../${{ env.FILENAME_PREFIX }}-chrome.zip" .) + + - name: Upload Chrome extension + uses: actions/upload-artifact@v3 + with: + name: ${{ env.FILENAME_PREFIX }}-chrome.zip + path: "*-chrome.zip" + retention-days: 5 + test-unit: runs-on: ubuntu-latest needs: [setup] @@ -57,6 +81,7 @@ jobs: with: node-version: "16" cache: "yarn" + fetch-depth: 0 - name: Restore cached build uses: actions/cache@v3 @@ -69,11 +94,23 @@ jobs: - name: Run tests run: yarn 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.8 + with: + projectBaseDir: ./packages/extension + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONARCLOUD_TOKEN }} test-e2e: runs-on: ubuntu-latest needs: [setup] - + strategy: + matrix: + project: [chromium] + shardIndex: [1, 2, 3, 4, 5, 6, 7, 8] + shardTotal: [8] services: devnet: image: argentlabs-argent-x.jfrog.io/e2e-starknet-devnet:latest @@ -85,7 +122,6 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 with: node-version: "16" @@ -101,7 +137,7 @@ jobs: run: npx playwright install chromium - name: Run e2e tests - run: xvfb-run --auto-servernum yarn test:e2e + run: xvfb-run --auto-servernum yarn test:e2e --project=${{ matrix.project }} --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} - name: Upload artifacts uses: actions/upload-artifact@v3 @@ -109,13 +145,14 @@ jobs: with: name: test-results path: | - packages/extension/test-results/ - packages/extension/e2e/artifacts/playwright/ - packages/extension/e2e/artifacts/reports/ + packages/test-results/ + packages/e2e/artifacts/playwright/ + packages/e2e/artifacts/reports/ retention-days: 5 - sonar: + build_firefox_extension: runs-on: ubuntu-latest + if: ${{ github.event_name == 'pull_request' }} # Run only for pull requests needs: [setup] steps: @@ -131,19 +168,35 @@ jobs: path: ./* key: ${{ github.sha }} - - name: SonarCloud Scan - uses: SonarSource/sonarcloud-github-action@master - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SONAR_TOKEN: ${{ secrets.SONARCLOUD_TOKEN }} + - name: Build Firefox version + run: MANIFEST_VERSION=v2 yarn --cwd packages/extension build + + - name: Set filename prefix + run: echo "FILENAME_PREFIX=$(echo argent-extension-${{ github.ref_name }} | tr / -)" >> $GITHUB_ENV + + - name: Create firefox zip + run: (cd ./packages/extension/dist && zip -r "../../../${{ env.FILENAME_PREFIX }}-firefox.zip" .) + + - name: Check bundlesize for firefox + run: yarn run bundlewatch + + - name: Upload artifacts for firefox + uses: actions/upload-artifact@v3 + with: + name: ${{ env.FILENAME_PREFIX }}-firefox.zip + path: "*-firefox.zip" + retention-days: 5 - artifacts: + create_sentry_release: runs-on: ubuntu-latest - if: ${{ github.event_name == 'pull_request' }} # Run only for pull requests - needs: [setup, test-unit, test-e2e] + if: ${{ github.event_name == 'pull_request' && github.actor != 'dependabot[bot]'}} # Run only for pull requests and if not triggered by dependabot + needs: [setup] steps: - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: actions/setup-node@v3 with: node-version: "16" @@ -155,34 +208,43 @@ jobs: path: ./* key: ${{ github.sha }} - - name: Set filename prefix - run: echo "FILENAME_PREFIX=$(echo argent-extension-${{ github.ref_name }} | tr / -)" >> $GITHUB_ENV + - name: Build extension + run: yarn lerna run --scope @argent-x/extension build - - name: Create chrome zip - run: (cd ./packages/extension/dist && zip -r "../../../${{ env.FILENAME_PREFIX }}-chrome.zip" .) + - name: Get Extension version + id: package-version + run: | + PACKAGE_VERSION=$(cat ./packages/extension/package.json | jq -r '.version') + echo "current-version=${PACKAGE_VERSION}" >> $GITHUB_OUTPUT - - name: Upload artifacts for chrome - uses: actions/upload-artifact@v3 + - name: Check sourcemaps + run: | + ls -l ./packages/extension + if [ ! -d "./packages/extension/sourcemaps" ]; then + echo "No sourcemaps found" + exit 0 + fi + + - name: Create Sentry release + uses: getsentry/action-release@v1 + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + SENTRY_LOG_LEVEL: debug with: - name: ${{ env.FILENAME_PREFIX }}-chrome.zip - path: "*-chrome.zip" - retention-days: 5 - - - name: Build Firefox version - run: MANIFEST_VERSION=v2 yarn --cwd packages/extension build + environment: staging + sourcemaps: "./packages/extension/sourcemaps" + version: ${{ steps.package-version.outputs.current-version }}-rc__${{ github.sha }} + ignore_missing: true - - name: Create firefox zip - run: (cd ./packages/extension/dist && zip -r "../../../${{ env.FILENAME_PREFIX }}-firefox.zip" .) - - - name: Check bundlesize for firefox - run: yarn run bundlewatch + add_pr_comments: + runs-on: ubuntu-latest + if: ${{ github.event_name == 'pull_request' && github.actor != 'dependabot[bot]'}} # Run only for pull requests and if not triggered by dependabot + needs: [build_firefox_extension, test-unit, test-e2e] - - name: Upload artifacts for firefox - uses: actions/upload-artifact@v3 - with: - name: ${{ env.FILENAME_PREFIX }}-firefox.zip - path: "*-firefox.zip" - retention-days: 5 + steps: + - uses: actions/checkout@v3 - name: Set GHA_BRANCH run: echo "GHA_BRANCH=$(echo $GITHUB_REF | awk -F / '{print $3}')" >> $GITHUB_ENV diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9609502cc..9d596d790 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,7 @@ name: Release on: push: tags: - - "v*.*.*" + - "extension/*" env: FEATURE_PRIVACY_SETTINGS: "true" @@ -14,25 +14,25 @@ env: FEATURE_VERIFIED_DAPPS: "true" FEATURE_ARGENT_SHIELD: "true" ARGENT_SHIELD_NETWORK_ID: "mainnet-alpha" + FEATURE_MULTISIG: "false" + NPM_ACCESS_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }} + SEGMENT_WRITE_KEY: ${{ secrets.SEGMENT_WRITE_KEY }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + RAMP_API_KEY: ${{ secrets.RAMP_API_KEY }} + FILENAME: argent-extension + SAFE_ENV_VARS: true + ARGENT_API_BASE_URL: ${{ vars.ARGENT_API_BASE_URL }} + ARGENT_TRANSACTION_REVIEW_API_BASE_URL: ${{ vars.ARGENT_TRANSACTION_REVIEW_API_BASE_URL }} + ARGENT_X_STATUS_URL: ${{ vars.ARGENT_X_STATUS_URL }} + ARGENT_EXPLORER_BASE_URL: ${{ vars.ARGENT_EXPLORER_BASE_URL }} jobs: build: runs-on: ubuntu-latest permissions: contents: write - env: - NPM_ACCESS_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }} - SEGMENT_WRITE_KEY: ${{ secrets.SEGMENT_WRITE_KEY }} - SENTRY_DSN: ${{ secrets.SENTRY_DSN }} - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - RAMP_API_KEY: ${{ secrets.RAMP_API_KEY }} - FILENAME: argent-extension-${{ github.ref_name }} - UPLOAD_SENTRY_SOURCEMAPS: true - SAFE_ENV_VARS: true - ARGENT_API_BASE_URL: ${{ vars.ARGENT_API_BASE_URL }} - ARGENT_TRANSACTION_REVIEW_API_BASE_URL: ${{ vars.ARGENT_TRANSACTION_REVIEW_API_BASE_URL }} - ARGENT_X_STATUS_URL: ${{ vars.ARGENT_X_STATUS_URL }} - ARGENT_EXPLORER_BASE_URL: ${{ vars.ARGENT_EXPLORER_BASE_URL }} + steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 @@ -40,7 +40,7 @@ jobs: node-version: "16" cache: "yarn" - run: yarn setup - - run: yarn build + - run: yarn build --ignore @argent/web - name: Release npm packages # if flow is triggered by a tag, publish to npm @@ -69,7 +69,6 @@ jobs: run: yarn run bundlewatch - name: Upload artifacts for chrome - if: ${{ !startsWith(github.ref, 'refs/tags/') }} uses: actions/upload-artifact@v3 with: name: chrome @@ -78,7 +77,6 @@ jobs: if-no-files-found: error - name: Upload artifacts for firefox - if: ${{ !startsWith(github.ref, 'refs/tags/') }} uses: actions/upload-artifact@v3 with: name: firefox @@ -86,6 +84,38 @@ jobs: retention-days: 14 if-no-files-found: error + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Get Extension version + id: package-version + run: | + PACKAGE_VERSION=$(cat ./packages/extension/package.json | jq -r '.version') + echo "current-version=${PACKAGE_VERSION}" >> $GITHUB_OUTPUT + + - name: Check sourcemaps + run: | + ls -l ./packages/extension + if [ ! -d "./packages/extension/sourcemaps" ]; then + echo "No sourcemaps found" + exit 0 + fi + + - name: Create Sentry release + uses: getsentry/action-release@v1 + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + SENTRY_LOG_LEVEL: debug + with: + environment: production + sourcemaps: "./packages/extension/sourcemaps" + url_prefix: "~/sourcemaps" + version: ${{ steps.package-version.outputs.current-version }} + ignore_missing: true + - name: Release if: startsWith(github.ref, 'refs/tags/') uses: softprops/action-gh-release@v1 diff --git a/.gitignore b/.gitignore index c435fb0a2..a64bb2498 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ sourcemaps coverage *.tsbuildinfo license-report.md + +**/.next \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..bdef82015 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["svelte.svelte-vscode"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 75c51808f..88d913bef 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,5 +8,9 @@ "**/.{idea,git,cache,output,temp}/**" ], "vitest.enable": true, - "vitest.commandLine": "npx vitest -r packages/extension/" + "vitest.commandLine": "npx vitest -r packages/extension/", + "explorer.fileNesting.patterns": { + "*.tsx": "${capture}.ts, ${capture}.typegen.ts, ${capture}Container.tsx, ${capture}.container.tsx, ${capture}.test.tsx, ${capture}.spec.tsx, ${capture}.test.ts, ${capture}.spec.ts", + "*.ts": "${capture}.ts, ${capture}.typegen.ts, ${capture}Container.tsx, ${capture}.container.tsx, ${capture}.test.tsx, ${capture}.spec.tsx, ${capture}.test.ts, ${capture}.spec.ts" + } } diff --git a/Readme.md b/Readme.md index e8ff8f5cb..9f1524b9c 100644 --- a/Readme.md +++ b/Readme.md @@ -13,6 +13,8 @@
- There is no demo token for this network, but you can deploy one and - add its address to this file: -
packages/dapp/src/token.service.ts
+ // There is no demo token for this network, but you can deploy one and + // add its address to this file: + //
- + + ERC20 + ETH token address + + + + {truncateAddress(ETHTokenAddress)} + + + + { + try { + await addToken(ETHTokenAddress) + setAddTokenError("") + } catch (error: any) { + setAddTokenError(error.message) + } + }} > - {truncateAddress(tokenAddress)} - - - - {addTokenError} + Add ETH token to wallet + + + { + try { + await addToken(DAITokenAddress) + setAddTokenError("") + } catch (error: any) { + setAddTokenError(error.message) + } + }} + > + Add DAI token to wallet + + {addTokenError} + + + Network + { + try { + await handleAddNetwork() + setAddNetworkError("") + } catch (error: any) { + setAddNetworkError(error.message) + } + }} + > + Add network to wallet + + + {addNetworkError} + + > ) } diff --git a/packages/dapp/src/pages/index.tsx b/packages/dapp/src/pages/index.tsx index 57795c689..5e600d412 100644 --- a/packages/dapp/src/pages/index.tsx +++ b/packages/dapp/src/pages/index.tsx @@ -1,7 +1,8 @@ +import { StarknetWindowObject } from "@argent/get-starknet" import { supportsSessions } from "@argent/x-sessions" import type { NextPage } from "next" import Head from "next/head" -import { useEffect, useState } from "react" +import { useCallback, useEffect, useState } from "react" import { AccountInterface } from "starknet" import { TokenDapp } from "../components/TokenDapp" @@ -18,7 +19,7 @@ import styles from "../styles/Home.module.css" const Home: NextPage = () => { const [address, setAddress] = useState() const [supportSessions, setSupportsSessions] = useState(null) - const [chain, setChain] = useState(chainId()) + const [chain, setChain] = useState(undefined) const [isConnected, setConnected] = useState(false) const [account, setAccount] = useState(null) @@ -26,13 +27,13 @@ const Home: NextPage = () => { const handler = async () => { const wallet = await silentConnectWallet() setAddress(wallet?.selectedAddress) - setChain(chainId()) + setChain(chainId(wallet?.provider as any)) setConnected(!!wallet?.isConnected) if (wallet?.account) { - setAccount(wallet.account) + setAccount(wallet.account as any) } setSupportsSessions(null) - if (wallet?.selectedAddress) { + if (wallet?.selectedAddress && wallet.provider) { try { const sessionSupport = await supportsSessions( wallet.selectedAddress, @@ -55,32 +56,41 @@ const Home: NextPage = () => { } }, []) - const handleConnectClick = async () => { - const wallet = await connectWallet() - setAddress(wallet?.selectedAddress) - setChain(chainId()) - setConnected(!!wallet?.isConnected) - if (wallet?.account) { - setAccount(wallet.account) - } - setSupportsSessions(null) - if (wallet?.selectedAddress) { - const sessionSupport = await supportsSessions( - wallet.selectedAddress, - wallet.provider, - ) - console.log( - "🚀 ~ file: index.tsx ~ line 72 ~ handleConnectClick ~ sessionSupport", - sessionSupport, - ) - setSupportsSessions(sessionSupport) - } - } + const handleConnectClick = useCallback( + ( + connectWallet: ( + enableWebWallet: boolean, + ) => Promise, + enableWebWallet = true, + ) => + async () => { + const wallet = await connectWallet(enableWebWallet) + setAddress(wallet?.selectedAddress) + setChain(chainId(wallet?.provider as any)) + setConnected(!!wallet?.isConnected) + if (wallet?.account) { + setAccount(wallet.account as any) + } + setSupportsSessions(null) + if (wallet?.selectedAddress && wallet.provider) { + try { + const sessionSupport = await supportsSessions( + wallet.selectedAddress, + wallet.provider, + ) + setSupportsSessions(sessionSupport) + } catch { + setSupportsSessions(false) + } + } + }, + [], + ) return ( - Argent x StarkNet test dapp + Test dapp @@ -102,9 +112,18 @@ const Home: NextPage = () => { > ) : ( <> - + Connect Wallet + + Connect Wallet (without WebWallet) + First connect wallet to use dapp. > )} diff --git a/packages/dapp/src/services/token.service.ts b/packages/dapp/src/services/token.service.ts index 985fee51f..80e25b6b0 100644 --- a/packages/dapp/src/services/token.service.ts +++ b/packages/dapp/src/services/token.service.ts @@ -1,21 +1,15 @@ -import { getStarknet } from "@argent/get-starknet" +import { connect } from "@argent/get-starknet" import { utils } from "ethers" import { Abi, Contract, number, uint256 } from "starknet" import Erc20Abi from "../../abi/ERC20.json" +import { windowStarknet } from "./wallet.service" -export const erc20TokenAddressByNetwork = { - "goerli-alpha": - "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", - "mainnet-alpha": - "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", -} - -export type PublicNetwork = keyof typeof erc20TokenAddressByNetwork -export type Network = PublicNetwork | "localhost" +export const ETHTokenAddress = + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7" -export const getErc20TokenAddress = (network: PublicNetwork) => - erc20TokenAddressByNetwork[network] +export const DAITokenAddress = + "0x00da114221cb83fa859dbdb4c44beeaa0bb37c7537ad5ae66fe5e0efd20e6eb3" function getUint256CalldataFromBN(bn: number.BigNumberish) { return { type: "struct" as const, ...uint256.bnToUint256(bn) } @@ -28,21 +22,17 @@ export function parseInputAmountToUint256( return getUint256CalldataFromBN(utils.parseUnits(input, decimals).toString()) } -export const mintToken = async ( - mintAmount: string, - network: PublicNetwork, -): Promise => { - const starknet = getStarknet() - if (!starknet?.isConnected) { +export const mintToken = async (mintAmount: string): Promise => { + if (!windowStarknet?.isConnected) { throw Error("starknet wallet not connected") } const erc20Contract = new Contract( Erc20Abi as Abi, - getErc20TokenAddress(network), - starknet.account as any, + ETHTokenAddress, + windowStarknet.account as any, ) - const address = starknet.selectedAddress + const address = windowStarknet.selectedAddress return erc20Contract.mint(address, parseInputAmountToUint256(mintAmount)) } @@ -50,17 +40,15 @@ export const mintToken = async ( export const transfer = async ( transferTo: string, transferAmount: string, - network: PublicNetwork, ): Promise => { - const starknet = getStarknet() - if (!starknet?.isConnected) { + if (!windowStarknet?.isConnected) { throw Error("starknet wallet not connected") } const erc20Contract = new Contract( Erc20Abi as any, - getErc20TokenAddress(network), - starknet.account as any, + ETHTokenAddress, + windowStarknet.account as any, ) return erc20Contract.transfer( diff --git a/packages/dapp/src/services/wallet.service.ts b/packages/dapp/src/services/wallet.service.ts index dd73f358a..2b2322bda 100644 --- a/packages/dapp/src/services/wallet.service.ts +++ b/packages/dapp/src/services/wallet.service.ts @@ -1,58 +1,36 @@ -import { connect, getStarknet } from "@argent/get-starknet" -import { CompiledContract, constants, shortString } from "starknet" +import { StarknetWindowObject, connect } from "@argent/get-starknet" +import type { AddStarknetChainParameters } from "get-starknet-core" +import { ProviderInterface, shortString } from "starknet" -import { Network } from "./token.service" +export let windowStarknet: StarknetWindowObject | null = null export const silentConnectWallet = async () => { - const windowStarknet = await connect({ showList: false }) - if (!windowStarknet?.isConnected) { - await windowStarknet?.enable({ - showModal: false, - starknetVersion: "v4", - } as any) - } - return windowStarknet + const _windowStarknet = await connect({ modalMode: "neverAsk" }) + windowStarknet = _windowStarknet + return windowStarknet ?? undefined } -export const connectWallet = async () => { - const windowStarknet = await connect({ - include: ["argentX"], +export const connectWallet = async (enableWebWallet: boolean) => { + const _windowStarknet = await connect({ + exclude: enableWebWallet ? [] : ["argentWebWallet"], + modalWalletAppearance: "email_first", }) - await windowStarknet?.enable({ starknetVersion: "v4" } as any) - return windowStarknet + windowStarknet = _windowStarknet + return windowStarknet ?? undefined } export const walletAddress = async (): Promise => { - const starknet = getStarknet() - if (!starknet?.isConnected) { - return - } - return starknet.selectedAddress -} - -export const networkId = (): Network | undefined => { - const starknet = getStarknet() - if (!starknet?.isConnected) { + if (!windowStarknet?.isConnected) { return } - try { - const { chainId } = starknet.provider - if (chainId === constants.StarknetChainId.MAINNET) { - return "mainnet-alpha" - } else if (chainId === constants.StarknetChainId.TESTNET) { - return "goerli-alpha" - } else { - return "localhost" - } - } catch {} + return windowStarknet.selectedAddress } export const addToken = async (address: string): Promise => { - const starknet = getStarknet() - if (!starknet?.isConnected) { + if (!windowStarknet?.isConnected) { throw Error("starknet wallet not connected") } - await starknet.request({ + await windowStarknet.request({ type: "wallet_watchAsset", params: { type: "ERC20", @@ -63,36 +41,25 @@ export const addToken = async (address: string): Promise => { }) } -export const getExplorerBaseUrl = (): string | undefined => { - const network = networkId() - if (network === "mainnet-alpha") { - return "https://voyager.online" - } else if (network === "goerli-alpha") { - return "https://goerli.voyager.online" - } -} - -export const chainId = (): string | undefined => { - const starknet = getStarknet() - if (!starknet?.isConnected) { - return - } +export const chainId = (provider?: ProviderInterface): string | undefined => { try { - return shortString.decodeShortString(starknet.provider.chainId) + if (!provider) { + throw Error("no provider") + } + return shortString.decodeShortString(provider.chainId) } catch {} } export const signMessage = async (message: string) => { - const starknet = getStarknet() - if (!starknet?.isConnected) throw Error("starknet wallet not connected") + if (!windowStarknet?.isConnected) throw Error("starknet wallet not connected") if (!shortString.isShortString(message)) { throw Error("message must be a short string") } - return starknet.account.signMessage({ + return windowStarknet.account.signMessage({ domain: { name: "Example DApp", - chainId: networkId() === "mainnet-alpha" ? "SN_MAIN" : "SN_GOERLI", + chainId: windowStarknet.chainId, version: "0.0.1", }, types: { @@ -111,41 +78,47 @@ export const signMessage = async (message: string) => { } export const waitForTransaction = async (hash: string) => { - const starknet = getStarknet() - if (!starknet?.isConnected) { + if (!windowStarknet?.isConnected) { return } - return starknet.provider.waitForTransaction(hash) + return windowStarknet.provider.waitForTransaction(hash) } export const addWalletChangeListener = async ( handleEvent: (accounts: string[]) => void, ) => { - const starknet = getStarknet() - if (!starknet?.isConnected) { + if (!windowStarknet?.isConnected) { return } - starknet.on("accountsChanged", handleEvent) + windowStarknet.on("accountsChanged", handleEvent) } export const removeWalletChangeListener = async ( handleEvent: (accounts: string[]) => void, ) => { - const starknet = getStarknet() - if (!starknet?.isConnected) { + if (!windowStarknet?.isConnected) { return } - starknet.off("accountsChanged", handleEvent) + windowStarknet.off("accountsChanged", handleEvent) } export const declare = async (contract: string, classHash: string) => { - const starknet = getStarknet() - if (!starknet?.isConnected) { + if (!windowStarknet?.isConnected) { throw Error("starknet wallet not connected") } - return starknet.account.declare({ + return windowStarknet.account.declare({ contract, classHash, }) } + +export const addNetwork = async (params: AddStarknetChainParameters) => { + if (!windowStarknet?.isConnected) { + throw Error("starknet wallet not connected") + } + await windowStarknet.request({ + type: "wallet_addStarknetChain", + params, + }) +} diff --git a/packages/dapp/src/styles/globals.css b/packages/dapp/src/styles/globals.css index 4a7390b38..401999535 100644 --- a/packages/dapp/src/styles/globals.css +++ b/packages/dapp/src/styles/globals.css @@ -9,6 +9,7 @@ } .columns > * { + flex-basis: 0; flex-grow: 1; } @@ -74,14 +75,6 @@ body { padding: 0; } -footer, -header, -main { - margin: 0 auto; - max-width: var(--width-content); - padding: 3rem 1rem; -} - hr { background-color: var(--color-bg-secondary); border: none; @@ -285,7 +278,6 @@ sup { a { color: var(--color-link); display: inline-block; - font-weight: bold; text-decoration: none; } @@ -294,11 +286,6 @@ a:active { text-decoration: underline; } -a:hover { - filter: brightness(var(--hover-brightness)); - text-decoration: underline; -} - a b, a em, a i, @@ -390,18 +377,6 @@ button[disabled]:hover { filter: none; } -form { - border: 1px solid var(--color-bg-secondary); - border-radius: var(--border-radius); - box-shadow: var(--box-shadow) var(--color-shadow); - display: block; - max-width: var(--width-card-wide); - min-width: var(--width-card); - padding: 1.5rem; - margin: 2rem 0; - text-align: var(--justify-normal); -} - form header { margin: 1.5rem 0; padding: 1.5rem 0; diff --git a/packages/extension/e2e/.eslintrc.js b/packages/e2e/.eslintrc.js similarity index 100% rename from packages/extension/e2e/.eslintrc.js rename to packages/e2e/.eslintrc.js diff --git a/packages/extension/e2e/.gitignore b/packages/e2e/.gitignore similarity index 100% rename from packages/extension/e2e/.gitignore rename to packages/e2e/.gitignore diff --git a/packages/extension/e2e/Dockerfile b/packages/e2e/Dockerfile similarity index 100% rename from packages/extension/e2e/Dockerfile rename to packages/e2e/Dockerfile diff --git a/packages/extension/e2e/network-setup/Dockerfile b/packages/e2e/extension/network-setup/Dockerfile similarity index 100% rename from packages/extension/e2e/network-setup/Dockerfile rename to packages/e2e/extension/network-setup/Dockerfile diff --git a/packages/extension/e2e/network-setup/build_and_push.sh b/packages/e2e/extension/network-setup/build_and_push.sh similarity index 100% rename from packages/extension/e2e/network-setup/build_and_push.sh rename to packages/e2e/extension/network-setup/build_and_push.sh diff --git a/packages/extension/e2e/network-setup/dump.pkl b/packages/e2e/extension/network-setup/dump.pkl similarity index 100% rename from packages/extension/e2e/network-setup/dump.pkl rename to packages/e2e/extension/network-setup/dump.pkl diff --git a/packages/extension/e2e/src/config.ts b/packages/e2e/extension/src/config.ts similarity index 85% rename from packages/extension/e2e/src/config.ts rename to packages/e2e/extension/src/config.ts index af8ab95e1..313bb9730 100644 --- a/packages/extension/e2e/src/config.ts +++ b/packages/e2e/extension/src/config.ts @@ -2,9 +2,9 @@ import path from "path" export default { password: "MyP@ss3!", - artifactsDir: path.resolve(__dirname, "../artifacts/playwright"), - reportsDir: path.resolve(__dirname, "../artifacts/reports"), - distDir: path.join(__dirname, "../../dist/"), + artifactsDir: path.resolve(__dirname, "../../artifacts/playwright"), + reportsDir: path.resolve(__dirname, "../../artifacts/reports"), + distDir: path.join(__dirname, "../../../extension/dist/"), wallets: [ { diff --git a/packages/extension/e2e/src/fixtures.ts b/packages/e2e/extension/src/fixtures.ts similarity index 100% rename from packages/extension/e2e/src/fixtures.ts rename to packages/e2e/extension/src/fixtures.ts diff --git a/packages/extension/e2e/src/languages/ILanguage.ts b/packages/e2e/extension/src/languages/ILanguage.ts similarity index 98% rename from packages/extension/e2e/src/languages/ILanguage.ts rename to packages/e2e/extension/src/languages/ILanguage.ts index 1d097ec3f..780d66051 100644 --- a/packages/extension/e2e/src/languages/ILanguage.ts +++ b/packages/e2e/extension/src/languages/ILanguage.ts @@ -24,7 +24,6 @@ export interface ILanguage { addFunds: string fundsFromStarkNet: string fullAccountAddress: string - showAccountList: string send: string export: string accountRecovery: string diff --git a/packages/extension/e2e/src/languages/en/index.ts b/packages/e2e/extension/src/languages/en/index.ts similarity index 98% rename from packages/extension/e2e/src/languages/en/index.ts rename to packages/e2e/extension/src/languages/en/index.ts index 730b02ba4..8afb708eb 100644 --- a/packages/extension/e2e/src/languages/en/index.ts +++ b/packages/e2e/extension/src/languages/en/index.ts @@ -25,7 +25,6 @@ const texts = { addFunds: "Add funds", fundsFromStarkNet: "From another StarkNet account", fullAccountAddress: "Full account address", - showAccountList: "Show account list", send: "Send", export: "Export", accountRecovery: "Set up account recovery", diff --git a/packages/extension/e2e/src/languages/index.ts b/packages/e2e/extension/src/languages/index.ts similarity index 99% rename from packages/extension/e2e/src/languages/index.ts rename to packages/e2e/extension/src/languages/index.ts index 653df9343..583a76ea9 100644 --- a/packages/extension/e2e/src/languages/index.ts +++ b/packages/e2e/extension/src/languages/index.ts @@ -1,6 +1,7 @@ import path from "node:path" import type { ILanguage } from "./ILanguage" + // eslint-disable-next-line @typescript-eslint/no-var-requires export const lang: ILanguage = require(path.join( __dirname, diff --git a/packages/extension/e2e/src/page-objects/Account.ts b/packages/e2e/extension/src/page-objects/Account.ts similarity index 91% rename from packages/extension/e2e/src/page-objects/Account.ts rename to packages/e2e/extension/src/page-objects/Account.ts index a891128a0..54d7062d1 100644 --- a/packages/extension/e2e/src/page-objects/Account.ts +++ b/packages/e2e/extension/src/page-objects/Account.ts @@ -44,8 +44,8 @@ export default class Account extends Navigation { return this.page.locator(`button :text-is('${tkn}')`) } - get accountList() { - return this.page.locator(`[aria-label="${lang.account.showAccountList}"]`) + get accountListSelector() { + return this.page.locator(`[aria-label="Show account list"]`) } get addANewccountFromAccountList() { @@ -73,7 +73,7 @@ export default class Account extends Navigation { } account(accountName: string) { - return this.page.locator(`[aria-label="Select ${accountName}"]`) + return this.page.locator(`[aria-label^="Select ${accountName}"]`) } get balance() { @@ -92,28 +92,30 @@ export default class Account extends Navigation { if (firstAccount) { await this.createAccount.click() } else { - await this.accountList.click() + await this.accountListSelector.click() await this.addANewccountFromAccountList.click() } await this.addStandardAccountFromNewAccountScreen.click() - await expect(this.accountList).toBeVisible() + + await this.account("").last().click() + await expect(this.accountListSelector).toBeVisible() await this.addFunds.click() await this.addFundsFromStartNet.click() const accountAddress = await this.accountAddress .textContent() .then((v) => v?.replaceAll(" ", "")) await this.close.last().click() - const accountName = await this.accountList.textContent() + const accountName = await this.accountListSelector.textContent() return [accountName, accountAddress] } async selectAccount(accountName: string) { - await this.accountList.click() + await this.accountListSelector.click() await this.account(accountName).click() } async ensureSelectedAccount(accountName: string) { - const currentAccount = await this.accountList.textContent() + const currentAccount = await this.accountListSelector.textContent() if (currentAccount != accountName) { await this.selectAccount(accountName) } diff --git a/packages/extension/e2e/src/page-objects/Activity.ts b/packages/e2e/extension/src/page-objects/Activity.ts similarity index 100% rename from packages/extension/e2e/src/page-objects/Activity.ts rename to packages/e2e/extension/src/page-objects/Activity.ts diff --git a/packages/extension/e2e/src/page-objects/AddressBook.ts b/packages/e2e/extension/src/page-objects/AddressBook.ts similarity index 100% rename from packages/extension/e2e/src/page-objects/AddressBook.ts rename to packages/e2e/extension/src/page-objects/AddressBook.ts diff --git a/packages/extension/e2e/src/page-objects/DeveloperSettings.ts b/packages/e2e/extension/src/page-objects/DeveloperSettings.ts similarity index 100% rename from packages/extension/e2e/src/page-objects/DeveloperSettings.ts rename to packages/e2e/extension/src/page-objects/DeveloperSettings.ts diff --git a/packages/extension/e2e/src/page-objects/ExtensionPage.ts b/packages/e2e/extension/src/page-objects/ExtensionPage.ts similarity index 100% rename from packages/extension/e2e/src/page-objects/ExtensionPage.ts rename to packages/e2e/extension/src/page-objects/ExtensionPage.ts diff --git a/packages/extension/e2e/src/page-objects/Navigation.ts b/packages/e2e/extension/src/page-objects/Navigation.ts similarity index 100% rename from packages/extension/e2e/src/page-objects/Navigation.ts rename to packages/e2e/extension/src/page-objects/Navigation.ts diff --git a/packages/extension/e2e/src/page-objects/Network.ts b/packages/e2e/extension/src/page-objects/Network.ts similarity index 100% rename from packages/extension/e2e/src/page-objects/Network.ts rename to packages/e2e/extension/src/page-objects/Network.ts diff --git a/packages/extension/e2e/src/page-objects/Settings.ts b/packages/e2e/extension/src/page-objects/Settings.ts similarity index 97% rename from packages/extension/e2e/src/page-objects/Settings.ts rename to packages/e2e/extension/src/page-objects/Settings.ts index 52908afbd..da816b751 100644 --- a/packages/extension/e2e/src/page-objects/Settings.ts +++ b/packages/e2e/extension/src/page-objects/Settings.ts @@ -100,7 +100,7 @@ export default class Settings { } get privacyStatement() { - return this.page.getByRole("link", { name: "Privacy Statement" }) + return this.page.getByRole("link", { name: "Privacy statement" }) } get privacyStatementText() { diff --git a/packages/extension/e2e/src/page-objects/Wallet.ts b/packages/e2e/extension/src/page-objects/Wallet.ts similarity index 85% rename from packages/extension/e2e/src/page-objects/Wallet.ts rename to packages/e2e/extension/src/page-objects/Wallet.ts index 7cb9ae9dc..22cc24c54 100644 --- a/packages/extension/e2e/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 "../config" import { lang } from "../languages" import Navigation from "./Navigation" @@ -9,7 +9,7 @@ export default class Wallet extends Navigation { super(page) } get banner() { - return this.page.locator(`div h1:text-is("${lang.wallet.banner1}")`) + return this.page.locator(`div h2:text-is("${lang.wallet.banner1}")`) } get description() { return this.page.locator(`div p:text-is("${lang.wallet.desc1}")`) @@ -23,7 +23,7 @@ export default class Wallet extends Navigation { //second screen get banner2() { - return this.page.locator(`div h1:text-is("${lang.wallet.banner2}")`) + return this.page.locator(`div h2:text-is("${lang.wallet.banner2}")`) } get description2() { return this.page.locator(`div p:text-is("${lang.wallet.desc2}")`) @@ -31,17 +31,17 @@ export default class Wallet extends Navigation { get disclaimerLostOfFunds() { return this.page.locator( - `//input[@name="lossOfFunds"]/following::p[contains(text(),'${lang.wallet.lossOfFunds}')]`, + `//input[@value="lossOfFunds"]/following::p[contains(text(),'${lang.wallet.lossOfFunds}')]`, ) } get disclaimerAlphaVersion() { return this.page.locator( - `//input[@name="alphaVersion"]/following::p[contains(text(),'${lang.wallet.alphaVersion}')]`, + `//input[@value="alphaVersion"]/following::p[contains(text(),'${lang.wallet.alphaVersion}')]`, ) } get privacyStatement() { - return this.page.getByRole("link", { name: "Privacy Statement" }) + return this.page.getByRole("link", { name: "Privacy statement" }) } get privacyStatementText() { @@ -50,7 +50,7 @@ export default class Wallet extends Navigation { //third screen get banner3() { - return this.page.locator(`div h1:text-is("${lang.wallet.banner3}")`) + return this.page.locator(`div h2:text-is("${lang.wallet.banner3}")`) } get description3() { return this.page.locator(`div p:text-is("${lang.wallet.desc3}")`) @@ -71,7 +71,7 @@ export default class Wallet extends Navigation { //fourth screen get banner4() { - return this.page.locator(`div h1:text-is("${lang.wallet.banner4}")`) + return this.page.locator(`div h2:text-is("${lang.wallet.banner4}")`) } get description4() { return this.page.locator(`div p:text-is("${lang.wallet.desc4}")`) diff --git a/packages/extension/e2e/src/specs/accountSettings.spec.ts b/packages/e2e/extension/src/specs/accountSettings.spec.ts similarity index 100% rename from packages/extension/e2e/src/specs/accountSettings.spec.ts rename to packages/e2e/extension/src/specs/accountSettings.spec.ts diff --git a/packages/extension/e2e/src/specs/addressBook.spec.ts b/packages/e2e/extension/src/specs/addressBook.spec.ts similarity index 100% rename from packages/extension/e2e/src/specs/addressBook.spec.ts rename to packages/e2e/extension/src/specs/addressBook.spec.ts diff --git a/packages/extension/e2e/src/specs/dappsBanner.spec.ts b/packages/e2e/extension/src/specs/dappsBanner.spec.ts similarity index 100% rename from packages/extension/e2e/src/specs/dappsBanner.spec.ts rename to packages/e2e/extension/src/specs/dappsBanner.spec.ts diff --git a/packages/extension/e2e/src/specs/links.spec.ts b/packages/e2e/extension/src/specs/links.spec.ts similarity index 100% rename from packages/extension/e2e/src/specs/links.spec.ts rename to packages/e2e/extension/src/specs/links.spec.ts diff --git a/packages/extension/e2e/src/specs/network.spec.ts b/packages/e2e/extension/src/specs/network.spec.ts similarity index 98% rename from packages/extension/e2e/src/specs/network.spec.ts rename to packages/e2e/extension/src/specs/network.spec.ts index d1dd08526..73ce362cd 100644 --- a/packages/extension/e2e/src/specs/network.spec.ts +++ b/packages/e2e/extension/src/specs/network.spec.ts @@ -67,7 +67,7 @@ test.describe("Network", () => { ).not.toBeVisible() }) - test("User should be able to restore default networks is network is not selected", async ({ + test("User should be able to restore default networks if network is not selected", async ({ extension, }) => { await extension.wallet.newWalletOnboarding() diff --git a/packages/extension/e2e/src/specs/receiveFunds.spec.ts b/packages/e2e/extension/src/specs/receiveFunds.spec.ts similarity index 100% rename from packages/extension/e2e/src/specs/receiveFunds.spec.ts rename to packages/e2e/extension/src/specs/receiveFunds.spec.ts diff --git a/packages/extension/e2e/src/specs/recovery.spec.ts b/packages/e2e/extension/src/specs/recovery.spec.ts similarity index 100% rename from packages/extension/e2e/src/specs/recovery.spec.ts rename to packages/e2e/extension/src/specs/recovery.spec.ts diff --git a/packages/e2e/extension/src/specs/sendFundsMax.spec.ts b/packages/e2e/extension/src/specs/sendFundsMax.spec.ts new file mode 100644 index 000000000..7e4eca6d9 --- /dev/null +++ b/packages/e2e/extension/src/specs/sendFundsMax.spec.ts @@ -0,0 +1,78 @@ +import { expect } from "@playwright/test" + +import test from "../test" + +test.describe("Send MAX funds", () => { + const otherAccount = + "0x02c786C7b4708b476a3a7c012922e6C3a161096F71EC694D61b590dbD4051Faf" + const setupWallet = async (extension: any) => { + await extension.wallet.newWalletOnboarding() + await extension.open() + await expect(extension.network.networkSelector).toBeVisible() + await extension.network.selectNetwork("Localhost 5050") + const [accountName1, accountAddress1] = await extension.account.addAccount( + {}, + ) + const [accountName2, accountAddress2] = await extension.account.addAccount({ + firstAccount: false, + }) + if (!accountName1 || !accountName2 || !accountAddress2) { + throw new Error("Invalid account names") + } + await extension.account.ensureAsset(accountName1, "ETH", "1.0") + await extension.account.ensureAsset(accountName2, "ETH", "1.0") + + return { accountName1, accountAddress1, accountName2, accountAddress2 } + } + + test("send MAX funds to other self account", async ({ extension }) => { + const { accountName1, accountName2, accountAddress2 } = await setupWallet( + extension, + ) + await extension.account.transfer({ + originAccountName: accountName1, + recepientAddress: accountAddress2, + tokenName: "Ethereum", + amount: "MAX", + }) + await extension.activity.checkActivity(1) + await extension.navigation.menuTokens.click() + await expect( + extension.navigation.menuPendingTransationsIndicator, + ).not.toBeVisible() + await expect(extension.account.currentBalance("ETH")).toContainText( + "0.0023", + ) + + await extension.account.token("Ethereum").click() + await expect(extension.account.balance).toContainText("0.0023") + await extension.account.back.click() + await extension.account.ensureSelectedAccount(accountName2) + await extension.account.token("Ethereum").click() + await expect(extension.account.balance).toContainText("1.9965") + await extension.account.back.click() + await expect(extension.account.currentBalance("ETH")).toContainText("1.9") + }) + + test("send MAX funds to other wallet/account", async ({ extension }) => { + const { accountName1 } = await setupWallet(extension) + + await extension.account.transfer({ + originAccountName: accountName1, + recepientAddress: otherAccount, + tokenName: "Ethereum", + amount: "MAX", + }) + await extension.activity.checkActivity(1) + await extension.navigation.menuTokens.click() + await expect( + extension.navigation.menuPendingTransationsIndicator, + ).not.toBeVisible() + await expect(extension.account.currentBalance("ETH")).toContainText( + "0.0023", + ) + + await extension.account.token("Ethereum").click() + await expect(extension.account.balance).toContainText("0.0023") + }) +}) diff --git a/packages/extension/e2e/src/specs/sendFunds.spec.ts b/packages/e2e/extension/src/specs/sendFundsPartial.spec.ts similarity index 60% rename from packages/extension/e2e/src/specs/sendFunds.spec.ts rename to packages/e2e/extension/src/specs/sendFundsPartial.spec.ts index edf344bec..855986b07 100644 --- a/packages/extension/e2e/src/specs/sendFunds.spec.ts +++ b/packages/e2e/extension/src/specs/sendFundsPartial.spec.ts @@ -2,7 +2,7 @@ import { expect } from "@playwright/test" import test from "../test" -test.describe("Send funds", () => { +test.describe("Send partial funds", () => { const otherAccount = "0x02c786C7b4708b476a3a7c012922e6C3a161096F71EC694D61b590dbD4051Faf" const setupWallet = async (extension: any) => { @@ -54,35 +54,6 @@ test.describe("Send funds", () => { await expect(extension.account.currentBalance("ETH")).toContainText("1.5") }) - test("send MAX funds to other self account", async ({ extension }) => { - const { accountName1, accountName2, accountAddress2 } = await setupWallet( - extension, - ) - await extension.account.transfer({ - originAccountName: accountName1, - recepientAddress: accountAddress2, - tokenName: "Ethereum", - amount: "MAX", - }) - await extension.activity.checkActivity(1) - await extension.navigation.menuTokens.click() - await expect( - extension.navigation.menuPendingTransationsIndicator, - ).not.toBeVisible() - await expect(extension.account.currentBalance("ETH")).toContainText( - "0.0023", - ) - - await extension.account.token("Ethereum").click() - await expect(extension.account.balance).toContainText("0.0023") - await extension.account.back.click() - await extension.account.ensureSelectedAccount(accountName2) - await extension.account.token("Ethereum").click() - await expect(extension.account.balance).toContainText("1.9965") - await extension.account.back.click() - await expect(extension.account.currentBalance("ETH")).toContainText("1.9") - }) - test("send partial funds to other wallet/account", async ({ extension }) => { const { accountName1 } = await setupWallet(extension) await extension.account.ensureAsset(accountName1, "ETH", "1.0") @@ -103,26 +74,4 @@ test.describe("Send funds", () => { await extension.account.token("Ethereum").click() await expect(extension.account.balance).toContainText("0.4988") }) - - test("send MAX funds to other wallet/account", async ({ extension }) => { - const { accountName1 } = await setupWallet(extension) - - await extension.account.transfer({ - originAccountName: accountName1, - recepientAddress: otherAccount, - tokenName: "Ethereum", - amount: "MAX", - }) - await extension.activity.checkActivity(1) - await extension.navigation.menuTokens.click() - await expect( - extension.navigation.menuPendingTransationsIndicator, - ).not.toBeVisible() - await expect(extension.account.currentBalance("ETH")).toContainText( - "0.0023", - ) - - await extension.account.token("Ethereum").click() - await expect(extension.account.balance).toContainText("0.0023") - }) }) diff --git a/packages/extension/e2e/src/specs/welcome.spec.ts b/packages/e2e/extension/src/specs/welcome.spec.ts similarity index 100% rename from packages/extension/e2e/src/specs/welcome.spec.ts rename to packages/e2e/extension/src/specs/welcome.spec.ts diff --git a/packages/extension/e2e/src/test.ts b/packages/e2e/extension/src/test.ts similarity index 98% rename from packages/extension/e2e/src/test.ts rename to packages/e2e/extension/src/test.ts index f275d7808..979454025 100644 --- a/packages/extension/e2e/src/test.ts +++ b/packages/e2e/extension/src/test.ts @@ -38,8 +38,8 @@ const keepArtifacts = async (testInfo: TestInfo, page: Page) => { const htmlContent = await page.content() await fs.promises .mkdir(path.resolve(config.artifactsDir, folder), { recursive: true }) - .catch(() => { - null + .catch((error) => { + console.error(error) }) await fs.promises .writeFile( diff --git a/packages/extension/e2e/src/utils/Messages.ts b/packages/e2e/extension/src/utils/Messages.ts similarity index 100% rename from packages/extension/e2e/src/utils/Messages.ts rename to packages/e2e/extension/src/utils/Messages.ts diff --git a/packages/e2e/package.json b/packages/e2e/package.json new file mode 100644 index 000000000..c32477a8f --- /dev/null +++ b/packages/e2e/package.json @@ -0,0 +1,15 @@ +{ + "name": "@argent-x/e2e", + "private": true, + "version": "6.3.0", + "main": "index.js", + "license": "MIT", + "devDependencies": { + "@playwright/test": "^1.32.0", + "@types/node": "^18.15.11" + }, + "scripts": { + "test:e2e:extension": "playwright test ./extension", + "test:e2e": "yarn run test:e2e:extension" + } +} diff --git a/packages/extension/playwright.config.ts b/packages/e2e/playwright.config.ts similarity index 87% rename from packages/extension/playwright.config.ts rename to packages/e2e/playwright.config.ts index 4420f94d6..220201962 100644 --- a/packages/extension/playwright.config.ts +++ b/packages/e2e/playwright.config.ts @@ -2,12 +2,17 @@ import path from "path" import type { PlaywrightTestConfig } from "@playwright/test" -import config from "./e2e/src/config" +import config from "./extension/src/config" const isCI = Boolean(process.env.CI) const playwrightConfig: PlaywrightTestConfig = { - workers: 2, + projects: [ + { + name: "chromium", + }, + ], + workers: 1, timeout: 5 * 60e3, // 5 minutes reportSlowTests: { threshold: 1 * 60e3, // 1 minute @@ -32,7 +37,7 @@ const playwrightConfig: PlaywrightTestConfig = { ] : "list", forbidOnly: isCI, - testDir: "e2e/src/specs", + testDir: "./extension/src/specs", testMatch: /\.spec.ts$/, retries: isCI ? 2 : 0, use: { diff --git a/packages/extension/e2e/tsconfig.json b/packages/e2e/tsconfig.json similarity index 80% rename from packages/extension/e2e/tsconfig.json rename to packages/e2e/tsconfig.json index e0c98e676..826af4096 100644 --- a/packages/extension/e2e/tsconfig.json +++ b/packages/e2e/tsconfig.json @@ -2,21 +2,19 @@ "compilerOptions": { "target": "Esnext", "moduleResolution": "node", + "module": "commonjs", "allowSyntheticDefaultImports": true, "strict": true, "noImplicitAny": true, "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitReturns": true, - "importsNotUsedAsValues": "error", "resolveJsonModule": true, "inlineSources": true, "inlineSourceMap": true, - "noEmit": true, - "skipLibCheck": true, "composite": true, "types": ["node"] }, - "include": ["src"], + "include": ["**/src"], "exclude": ["node_modules"] } diff --git a/packages/extension/.env.example b/packages/extension/.env.example index 2f6eff566..04c5ff7d3 100644 --- a/packages/extension/.env.example +++ b/packages/extension/.env.example @@ -1,6 +1,5 @@ SEGMENT_WRITE_KEY= SENTRY_AUTH_TOKEN= -UPLOAD_SENTRY_SOURCEMAPS= RAMP_API_KEY= ARGENT_API_BASE_URL= ARGENT_TRANSACTION_REVIEW_API_BASE_URL= @@ -17,5 +16,6 @@ ARGENT_X_STATUS_URL= #FEATURE_EXPERIMENTAL_SETTINGS= #FEATURE_ARGENT_SHIELD= #ARGENT_SHIELD_NETWORK_ID= +#FEATURE_VERIFIED_DAPPS= #FEATURE_MULTISIG= -#FEATURE_VERIFIED_DAPPS= \ No newline at end of file +#ARGENT_MULTISIG_BASE_URL= diff --git a/packages/extension/.eslintrc.js b/packages/extension/.eslintrc.js index acefa5cf1..ff07fe963 100644 --- a/packages/extension/.eslintrc.js +++ b/packages/extension/.eslintrc.js @@ -21,7 +21,15 @@ module.exports = { }, ecmaVersion: "latest", sourceType: "module", + project: "./tsconfig.json", + tsconfigRootDir: __dirname, }, + ignorePatterns: [ + "**/dist/**", + "**/node_modules/**", + "vite.config.ts", + "webpack.config.js", + ], plugins: ["react", "react-hooks", "@typescript-eslint"], rules: { "react/jsx-no-target-blank": "off", @@ -52,5 +60,7 @@ module.exports = { ], }, ], + "@typescript-eslint/no-misused-promises": "warn", + "@typescript-eslint/no-floating-promises": "warn", }, } diff --git a/packages/extension/manifest/v2.json b/packages/extension/manifest/v2.json index 6ff353379..78d56a968 100644 --- a/packages/extension/manifest/v2.json +++ b/packages/extension/manifest/v2.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/chrome-manifest.json", "name": "Argent X", "description": "The security of Ethereum with the scale of StarkNet", - "version": "5.3.21", + "version": "5.4.0", "manifest_version": 2, "browser_action": { "default_icon": { diff --git a/packages/extension/manifest/v3.json b/packages/extension/manifest/v3.json index 7324f4340..8091866fb 100644 --- a/packages/extension/manifest/v3.json +++ b/packages/extension/manifest/v3.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/chrome-manifest.json", "name": "Argent X", "description": "The security of Ethereum with the scale of StarkNet", - "version": "5.3.21", + "version": "5.4.0", "manifest_version": 3, "action": { "default_icon": { diff --git a/packages/extension/package.json b/packages/extension/package.json index 13ebec3bb..62b81e668 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -1,10 +1,10 @@ { "name": "@argent-x/extension", - "version": "5.3.21", + "version": "6.3.0", "main": "index.js", + "private": true, "license": "MIT", "devDependencies": { - "@playwright/test": "^1.31.2", "@sentry/webpack-plugin": "^1.18.9", "@svgr/webpack": "^6.0.0", "@testing-library/jest-dom": "^5.16.5", @@ -24,12 +24,13 @@ "@typescript-eslint/eslint-plugin": "^5.10.1", "@typescript-eslint/parser": "^5.10.1", "@vitejs/plugin-react": "^3.0.0", + "@vitest/coverage-istanbul": "^0.31.0", "chokidar": "^3.5.2", "concurrently": "^7.2.2", "copy-webpack-plugin": "^11.0.0", "cross-fetch": "^3.1.5", "dotenv-webpack": "^8.0.0", - "esbuild-loader": "^2.19.0", + "esbuild-loader": "^3.0.1", "eslint": "^8.7.0", "eslint-plugin-react": "^7.28.0", "eslint-plugin-react-hooks": "^4.6.0", @@ -39,10 +40,11 @@ "fork-ts-checker-webpack-plugin": "^8.0.0", "html-webpack-plugin": "^5.5.0", "isomorphic-fetch": "^3.0.0", - "jsdom": "^21.0.0", + "jsdom": "^22.0.0", "mitt": "^3.0.0", "msw": "^1.0.0", "raw-loader": "^4.0.2", + "type-fest": "^3.9.0", "typescript": "^4.9.4", "typescript-styled-plugin": "^0.18.2", "url-loader": "^4.1.1", @@ -64,8 +66,7 @@ "test": "vitest run", "test:watch": "vitest", "test:ci": "vitest run --coverage", - "test:e2e": "playwright test", - "version": "yarn run change-to-release-branch && yarn run sync-manifest-version && yarn run commit-and-tag-version-changes && yarn run push-release-branch", + "version": "yarn run change-to-release-branch && yarn run commit-and-tag-version-changes && yarn run push-release-branch", "change-to-release-branch": "git checkout -b release/v$npm_package_version", "sync-manifest-version": "concurrently \"yarn sync-manifest-version:v2\" \"yarn sync-manifest-version:v3\"", "sync-manifest-version:v2": "node -p \"JSON.stringify({...require('./manifest/v2.json'), version: '$npm_package_version'}, null, 2)\" > ./manifest/v2.temp.json && prettier --write ./manifest/v2.temp.json && mv ./manifest/v2.temp.json ./manifest/v2.json", @@ -74,24 +75,29 @@ "push-release-branch": "git push --set-upstream origin release/v$npm_package_version --follow-tags" }, "dependencies": { - "@argent/guardian": "^5.3.21", - "@argent/stack-router": "^5.3.21", - "@argent/ui": "^5.3.21", - "@argent/x-multicall": "^5.3.21", - "@argent/x-sessions": "^5.3.21", - "@argent/x-swap": "^5.3.21", - "@argent/x-window": "^5.3.21", + "@argent/guardian": "^6.3.0", + "@argent/stack-router": "^6.3.0", + "@argent/ui": "^6.3.0", + "@argent/x-multicall": "^6.3.0", + "@argent/x-sessions": "^6.3.0", + "@argent/x-swap": "^6.3.0", + "@argent/x-window": "^6.3.0", "@chakra-ui/icons": "^2.0.15", - "@chakra-ui/react": "2.5.1", + "@chakra-ui/react": "^2.6.1", "@extend-chrome/messages": "^1.2.2", "@google/model-viewer": "^3.0.0", + "@hookform/resolvers": "^3.0.1", "@mui/icons-material": "^5.3.1", "@mui/material": "^5.1.0", "@mui/styled-engine-sc": "^5.10.3", "@noble/hashes": "^1.1.3", + "@scure/bip39": "^1.2.0", "@sentry/react": "^7.6.0", "@sentry/tracing": "^7.6.0", "@tippyjs/react": "^4.2.6", + "@trpc/client": "^10.20.0", + "@trpc/server": "^10.20.0", + "@vitest/coverage-istanbul": "^0.31.0", "async-retry": "^1.3.3", "bignumber.js": "^9.0.2", "buffer": "^6.0.3", @@ -100,8 +106,9 @@ "dexie-react-hooks": "^1.1.1", "ethers": "^5.5.1", "jose": "^4.3.6", + "jotai": "^2.0.4", "lodash-es": "^4.17.21", - "micro-starknet": "^0.1.1", + "micro-starknet": "^0.2.3", "nanoid": "^4.0.0", "object-hash": "^3.0.0", "qr-code-styling": "^1.6.0-rc.1", @@ -122,10 +129,11 @@ "styled-components": "^5.3.5", "styled-normalize": "^8.0.7", "swr": "^1.3.0", + "trpc-extension": "^1.1.0", "url-join": "^5.0.0", "webextension-polyfill": "^0.10.0", "yup": "^1.0.0-beta.4", "zod": "^3.20.2", - "zustand": "^3.6.5" + "zustand": "^4.3.6" } } diff --git a/packages/extension/sonar-project.properties b/packages/extension/sonar-project.properties new file mode 100644 index 000000000..9889ec878 --- /dev/null +++ b/packages/extension/sonar-project.properties @@ -0,0 +1,6 @@ +# must be unique in a given SonarQube instance +sonar.projectKey=argentlabs_argent-x-private +sonar.organization=argentlabs +sonar.javascript.lcov.reportPaths=./coverage/lcov.info +sonar.exclusions=**/dist/**,**/spec/**,**/test/**,**/tests/**,**/node_modules/**,**/coverage/**,**/build/**,**/contracts/**,**/migrations/**,**/scripts/**,**/artifacts/**,**/deployments/**,**/deploy/**,**/deployed/**,**/de,**/*.test.tsx,**/*.spec.tsx,**/*.d.ts,**/*.json,**/*.sol,**/*.yml,**/*.yaml,**/*.md,**/*.html,**/*.css,**/*.scss,**/*.sass,**/*.less,**/*.styl, +sonar.sources=. diff --git a/packages/extension/src/assets/default-tokens.json b/packages/extension/src/assets/default-tokens.json index 9eaa49ee5..8c50ef6c2 100644 --- a/packages/extension/src/assets/default-tokens.json +++ b/packages/extension/src/assets/default-tokens.json @@ -128,5 +128,13 @@ "symbol": "SLF", "decimals": "6", "network": "goerli-alpha" + }, + { + "address": "0x0148f970a06fc95ee0682140e23a980351be8fad26168c5f0465e63940c46514", + "name": "Angle agEUR", + "symbol": "agEUR", + "decimals": "18", + "network": "mainnet-alpha", + "image": "https://dv3jj1unlp2jl.cloudfront.net/128/color/ageur.png" } ] diff --git a/packages/extension/src/background/__new/middleware/analytics.ts b/packages/extension/src/background/__new/middleware/analytics.ts new file mode 100644 index 000000000..0acc9e7ef --- /dev/null +++ b/packages/extension/src/background/__new/middleware/analytics.ts @@ -0,0 +1,65 @@ +import { + AnyRootConfig, + MiddlewareFunction, + ProcedureParams, + TRPCError, +} from "@trpc/server" + +import type { Events } from "../../../shared/analytics" +import { analytics } from "../../analytics" + +type AnyProduceParams = ProcedureParams< + AnyRootConfig, + unknown, + unknown, + unknown, + unknown, + unknown, + unknown +> + +type SuccessArg = { + input: T["_input_out"] + ctx: T["_ctx_out"] + output: T["_output_out"] +} +type ErrorArg = { + input: T["_input_out"] + ctx: T["_ctx_out"] + error: TRPCError +} + +export function trackMiddleware< + T extends keyof Events, + Params extends AnyProduceParams, +>( + event: T, + ...[successFn, errorFn]: Events[T] extends undefined // this simplification needs a body, otherwise you can not differentiate between error and success + ? never + : [ + successFn: (arg: SuccessArg) => Events[T], + error?: (arg: ErrorArg) => Events[T], + ] +): MiddlewareFunction { + return async ({ next, ...ctx }) => { + const result = await next() + + try { + if (result.ok) { + const successPayload = successFn?.({ ...ctx, output: result.data }) + if (successPayload) { + void analytics.track(event, successPayload) + } + } else { + const errorPayload = errorFn?.({ ...ctx, error: result.error }) + if (errorPayload) { + void analytics.track(event, errorPayload) + } + } + } catch { + console.warn("Error in trackMiddleware", event) + } + + return result + } +} diff --git a/packages/extension/src/background/__new/middleware/session.ts b/packages/extension/src/background/__new/middleware/session.ts new file mode 100644 index 000000000..bdd7c5165 --- /dev/null +++ b/packages/extension/src/background/__new/middleware/session.ts @@ -0,0 +1,13 @@ +import { TRPCError } from "@trpc/server" + +import { middleware } from "../trpc" + +export const openSessionMiddleware = middleware(async ({ ctx, next }) => { + if (!(await ctx.services.wallet.isSessionOpen())) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Open session needed", + }) + } + return next() +}) diff --git a/packages/extension/src/background/__new/procedures/account/create.ts b/packages/extension/src/background/__new/procedures/account/create.ts new file mode 100644 index 000000000..e07f32aac --- /dev/null +++ b/packages/extension/src/background/__new/procedures/account/create.ts @@ -0,0 +1,49 @@ +import { z } from "zod" + +// TODO: ⬇ should be a service which get injected in ctx +import { tryToMintFeeToken } from "../../../../shared/devnet/mintFeeToken" +import { createWalletAccountSchema } from "../../../../shared/wallet.model" +import { trackMiddleware } from "../../middleware/analytics" +import { openSessionMiddleware } from "../../middleware/session" +import { extensionOnlyProcedure } from "../permissions" + +const createAccountInputSchema = z.object({ + networkId: z.string(), + type: z.union([z.literal("standard"), z.literal("multisig")]), +}) + +export const createAccountProcedure = extensionOnlyProcedure + .use(openSessionMiddleware) + .input(createAccountInputSchema) + .output(createWalletAccountSchema) + .use( + trackMiddleware( + "createAccount", + ({ output, input }) => ({ + status: "success", + networkId: output.networkId, + type: input.type, + }), + ({ input: { networkId, type }, error }) => ({ + status: "failure", + networkId, + type, + errorMessage: error.message, + }), + ), + ) + .mutation( + async ({ + input: { networkId: network, type }, + ctx: { + services: { wallet }, + }, + }) => { + const account = await wallet.newAccount(network, type) + + // NOTE: ⬇ should be a service + void tryToMintFeeToken(account) + + return account + }, + ) diff --git a/packages/extension/src/background/__new/procedures/account/deploy.ts b/packages/extension/src/background/__new/procedures/account/deploy.ts new file mode 100644 index 000000000..7d3609bcf --- /dev/null +++ b/packages/extension/src/background/__new/procedures/account/deploy.ts @@ -0,0 +1,14 @@ +import { baseWalletAccountSchema } from "../../../../shared/wallet.model" +import { deployAccountAction } from "../../../accountDeploy" +import { openSessionMiddleware } from "../../middleware/session" +import { extensionOnlyProcedure } from "../permissions" + +export const deployAccountProcedure = extensionOnlyProcedure + .use(openSessionMiddleware) + .input(baseWalletAccountSchema) + .mutation(async ({ input: data, ctx: { services } }) => { + await deployAccountAction({ + account: data, + actionQueue: services.actionQueue, + }) + }) diff --git a/packages/extension/src/background/__new/procedures/account/index.ts b/packages/extension/src/background/__new/procedures/account/index.ts new file mode 100644 index 000000000..cae8998de --- /dev/null +++ b/packages/extension/src/background/__new/procedures/account/index.ts @@ -0,0 +1,10 @@ +import { router } from "../../trpc" +import { createAccountProcedure } from "./create" +import { deployAccountProcedure } from "./deploy" +import { upgradeAccountProcedure } from "./upgrade" + +export const accountRouter = router({ + create: createAccountProcedure, + deploy: deployAccountProcedure, + upgrade: upgradeAccountProcedure, +}) diff --git a/packages/extension/src/background/__new/procedures/account/upgrade.ts b/packages/extension/src/background/__new/procedures/account/upgrade.ts new file mode 100644 index 000000000..a656e2acc --- /dev/null +++ b/packages/extension/src/background/__new/procedures/account/upgrade.ts @@ -0,0 +1,33 @@ +import { z } from "zod" + +import { + argentAccountTypeSchema, + baseWalletAccountSchema, +} from "../../../../shared/wallet.model" +import { upgradeAccount } from "../../../accountUpgrade" +import { openSessionMiddleware } from "../../middleware/session" +import { extensionOnlyProcedure } from "../permissions" + +const upgradeAccountSchema = z.object({ + account: baseWalletAccountSchema, + targetImplementationType: argentAccountTypeSchema.optional(), +}) +export const upgradeAccountProcedure = extensionOnlyProcedure + .use(openSessionMiddleware) + .input(upgradeAccountSchema) + .mutation( + async ({ + input: { account, targetImplementationType }, + ctx: { + services: { wallet, actionQueue }, + }, + }) => { + // TODO ⬇ should be a service + await upgradeAccount({ + account, + wallet, + targetImplementationType, + actionQueue, + }) + }, + ) diff --git a/packages/extension/src/background/__new/procedures/network/add.ts b/packages/extension/src/background/__new/procedures/network/add.ts new file mode 100644 index 000000000..e8a845c67 --- /dev/null +++ b/packages/extension/src/background/__new/procedures/network/add.ts @@ -0,0 +1,11 @@ +import { addNetwork, networkSchema } from "../../../../shared/network" +import { openSessionMiddleware } from "../../middleware/session" +import { extensionOnlyProcedure } from "../permissions" + +export const addNetworkProcedure = extensionOnlyProcedure + .use(openSessionMiddleware) + .input(networkSchema) + .mutation(async ({ input }) => { + await addNetwork(input) + return true + }) diff --git a/packages/extension/src/background/__new/procedures/network/index.ts b/packages/extension/src/background/__new/procedures/network/index.ts new file mode 100644 index 000000000..84b83e578 --- /dev/null +++ b/packages/extension/src/background/__new/procedures/network/index.ts @@ -0,0 +1,6 @@ +import { router } from "../../trpc" +import { addNetworkProcedure } from "./add" + +export const networkRouter = router({ + add: addNetworkProcedure, +}) diff --git a/packages/extension/src/background/__new/procedures/permissions.ts b/packages/extension/src/background/__new/procedures/permissions.ts new file mode 100644 index 000000000..20f8668e8 --- /dev/null +++ b/packages/extension/src/background/__new/procedures/permissions.ts @@ -0,0 +1,45 @@ +import { TRPCError } from "@trpc/server" + +import { procedure } from "../trpc" + +// TODO: ⬇ should be service +function getOrigin(url: string) { + const { origin } = new URL(url) + return origin +} +function matchOrigin(urlToMatch: string, url?: string) { + try { + const matchOrigin = getOrigin(urlToMatch) + if (!url) { + return false + } + + const origin = getOrigin(url) + return origin === matchOrigin + } catch { + return false + } +} + +export const publicProcedure = procedure + +export const extensionOnlyProcedure = publicProcedure.use( + async ({ ctx, next }) => { + const extensionUrl = chrome.runtime.getURL("") + const sender = ctx.sender + const senderUrl = sender?.url ?? sender?.origin + if (!sender || !matchOrigin(extensionUrl, senderUrl)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Reserved for extension calls", + }) + } + + return next({ + ctx: { + ...ctx, + sender, // by passing it after checking, every method after this middleware will have a mandatory sender + }, + }) + }, +) diff --git a/packages/extension/src/background/__new/procedures/recovery/index.ts b/packages/extension/src/background/__new/procedures/recovery/index.ts new file mode 100644 index 000000000..e9acca882 --- /dev/null +++ b/packages/extension/src/background/__new/procedures/recovery/index.ts @@ -0,0 +1,8 @@ +import { router } from "../../trpc" +import { recoverBackupProcedure } from "./recoverBackup" +import { recoverSeedphraseProcedure } from "./recoverSeedphrase" + +export const recoveryRouter = router({ + recoverBackup: recoverBackupProcedure, + recoverSeedPhrase: recoverSeedphraseProcedure, +}) diff --git a/packages/extension/src/background/__new/procedures/recovery/recoverBackup.ts b/packages/extension/src/background/__new/procedures/recovery/recoverBackup.ts new file mode 100644 index 000000000..c72cb0233 --- /dev/null +++ b/packages/extension/src/background/__new/procedures/recovery/recoverBackup.ts @@ -0,0 +1,20 @@ +import { z } from "zod" + +import { extensionOnlyProcedure } from "../permissions" + +const recoverBackupSchema = z.object({ + backup: z.string(), +}) + +export const recoverBackupProcedure = extensionOnlyProcedure + .input(recoverBackupSchema) + .mutation( + async ({ + input: { backup }, + ctx: { + services: { wallet }, + }, + }) => { + await wallet.importBackup(backup) + }, + ) diff --git a/packages/extension/src/background/__new/procedures/recovery/recoverSeedphrase.ts b/packages/extension/src/background/__new/procedures/recovery/recoverSeedphrase.ts new file mode 100644 index 000000000..07da90069 --- /dev/null +++ b/packages/extension/src/background/__new/procedures/recovery/recoverSeedphrase.ts @@ -0,0 +1,41 @@ +import { compactDecrypt } from "jose" +import { z } from "zod" + +import { accountService } from "../../../../shared/account/service" +import { bytesToUft8 } from "../../../../shared/utils/encode" +import { getMessagingKeys } from "../../../keys/messagingKeys" +import { extensionOnlyProcedure } from "../permissions" + +const recoverSeedphraseSchema = z.object({ + jwe: z.string(), +}) + +const recoverSeedphraseResponseSchema = z.object({ + isSuccess: z.boolean(), +}) +export const recoverSeedphraseProcedure = extensionOnlyProcedure + .input(recoverSeedphraseSchema) + .output(recoverSeedphraseResponseSchema) + .mutation( + async ({ + input: { jwe }, + ctx: { + services: { wallet, transactionTracker }, + }, + }) => { + const messagingKeys = await getMessagingKeys() + + const { plaintext } = await compactDecrypt(jwe, messagingKeys.privateKey) + const { + seedPhrase, + newPassword, + }: { + seedPhrase: string + newPassword: string + } = JSON.parse(bytesToUft8(plaintext)) + + await wallet.restoreSeedPhrase(seedPhrase, newPassword) + void transactionTracker.loadHistory(await accountService.get()) + return { isSuccess: true } + }, + ) diff --git a/packages/extension/src/background/__new/router.ts b/packages/extension/src/background/__new/router.ts new file mode 100644 index 000000000..c81f6bb13 --- /dev/null +++ b/packages/extension/src/background/__new/router.ts @@ -0,0 +1,32 @@ +import { createChromeHandler } from "trpc-extension/adapter" + +import { globalActionQueueStore } from "../../shared/actionQueue/store" +import { ActionItem } from "../../shared/actionQueue/types" +import { getQueue } from "../actionQueue" +import { transactionTracker } from "../transactions/tracking" +import { walletSingleton } from "../walletSingleton" +import { accountRouter } from "./procedures/account" +import { networkRouter } from "./procedures/network" +import { recoveryRouter } from "./procedures/recovery" +import { router } from "./trpc" + +const appRouter = router({ + account: accountRouter, + network: networkRouter, + recovery: recoveryRouter, +}) + +export type AppRouter = typeof appRouter + +createChromeHandler({ + router: appRouter, + createContext: async ({ req: port }) => ({ + sender: port.sender, // changes on every request + services: { + // services can be shared accross requests, as we usually only handle one user at a time + wallet: walletSingleton, // wallet "service" is obviously way too big and should be split up + actionQueue: await getQueue(globalActionQueueStore), + transactionTracker, + }, + }), +}) diff --git a/packages/extension/src/background/__new/services/onboarding/implementation.test.ts b/packages/extension/src/background/__new/services/onboarding/implementation.test.ts new file mode 100644 index 000000000..4187cb4be --- /dev/null +++ b/packages/extension/src/background/__new/services/onboarding/implementation.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test, vi } from "vitest" + +import type { KeyValueStorage } from "../../../../shared/storage" +import type { WalletStorageProps } from "../../../../shared/wallet/walletStore" +import OnboardingService from "./implementation" + +describe("OnboardingService", () => { + const makeService = () => { + const uiService = { + closePopup: vi.fn(), + createTab: vi.fn(), + focusTab: vi.fn(), + getPopup: vi.fn(), + getTab: vi.fn(), + hasPopup: vi.fn(), + hasTab: vi.fn(), + setDefaultPopup: vi.fn(), + unsetDefaultPopup: vi.fn(), + } + const walletStore = { + get: vi.fn(), + } as unknown as KeyValueStorage + const onboardingService = new OnboardingService(uiService, walletStore) + return { + onboardingService, + uiService, + walletStore, + } + } + test("getOnboardingComplete", async () => { + const { onboardingService, walletStore } = makeService() + await onboardingService.getOnboardingComplete() + expect(walletStore.get).toHaveBeenCalledWith("backup") + }) + describe("openOnboarding", async () => { + test("when there is no popup or tab", async () => { + const { onboardingService, uiService } = makeService() + const iconClickOpensOnboardingSpy = vi.spyOn( + onboardingService, + "iconClickOpensOnboarding", + ) + uiService.hasPopup.mockImplementationOnce(() => false) + uiService.hasTab.mockImplementationOnce(() => false) + await onboardingService.openOnboarding() + expect(iconClickOpensOnboardingSpy).toHaveBeenCalled() + expect(uiService.hasPopup).toHaveBeenCalled() + expect(uiService.closePopup).not.toHaveBeenCalled() + expect(uiService.hasTab).toHaveBeenCalled() + expect(uiService.createTab).toHaveBeenCalled() + expect(uiService.focusTab).not.toHaveBeenCalled() + }) + test("when there is a popup but no tab", async () => { + const { onboardingService, uiService } = makeService() + const iconClickOpensOnboardingSpy = vi.spyOn( + onboardingService, + "iconClickOpensOnboarding", + ) + uiService.hasPopup.mockImplementationOnce(() => true) + uiService.hasTab.mockImplementationOnce(() => false) + await onboardingService.openOnboarding() + expect(iconClickOpensOnboardingSpy).toHaveBeenCalled() + expect(uiService.hasPopup).toHaveBeenCalled() + expect(uiService.closePopup).toHaveBeenCalled() + expect(uiService.hasTab).toHaveBeenCalled() + expect(uiService.createTab).toHaveBeenCalled() + expect(uiService.focusTab).not.toHaveBeenCalled() + }) + test("when there is a tab but no popup", async () => { + const { onboardingService, uiService } = makeService() + const iconClickOpensOnboardingSpy = vi.spyOn( + onboardingService, + "iconClickOpensOnboarding", + ) + uiService.hasPopup.mockImplementationOnce(() => false) + uiService.hasTab.mockImplementationOnce(() => true) + await onboardingService.openOnboarding() + expect(iconClickOpensOnboardingSpy).toHaveBeenCalled() + expect(uiService.hasPopup).toHaveBeenCalled() + expect(uiService.closePopup).not.toHaveBeenCalled() + expect(uiService.hasTab).toHaveBeenCalled() + expect(uiService.createTab).not.toHaveBeenCalled() + expect(uiService.focusTab).toHaveBeenCalled() + }) + }) +}) diff --git a/packages/extension/src/background/__new/services/onboarding/implementation.ts b/packages/extension/src/background/__new/services/onboarding/implementation.ts new file mode 100644 index 000000000..269261de2 --- /dev/null +++ b/packages/extension/src/background/__new/services/onboarding/implementation.ts @@ -0,0 +1,38 @@ +import type { IUIService } from "../../../../shared/__new/services/ui/interface" +import type { KeyValueStorage } from "../../../../shared/storage" +import type { WalletStorageProps } from "../../../../shared/wallet/walletStore" +import type { IOnboardingService } from "./interface" + +export default class OnboardingService implements IOnboardingService { + constructor( + private uiService: IUIService, + private walletStore: KeyValueStorage, + ) {} + + async getOnboardingComplete() { + const value = await this.walletStore.get("backup") + return Boolean(value) + } + + async openOnboarding() { + this.iconClickOpensOnboarding() + const hasPopup = this.uiService.hasPopup() + if (hasPopup) { + this.uiService.closePopup() + } + const hasTab = await this.uiService.hasTab() + if (hasTab) { + await this.uiService.focusTab() + } else { + await this.uiService.createTab() + } + } + + iconClickOpensOnboarding() { + void this.uiService.unsetDefaultPopup() + } + + iconClickOpensPopup() { + void this.uiService.setDefaultPopup() + } +} diff --git a/packages/extension/src/background/__new/services/onboarding/index.ts b/packages/extension/src/background/__new/services/onboarding/index.ts new file mode 100644 index 000000000..1d6fabf43 --- /dev/null +++ b/packages/extension/src/background/__new/services/onboarding/index.ts @@ -0,0 +1,17 @@ +import browser from "webextension-polyfill" + +import { uiService } from "../../../../shared/__new/services/ui" +import { old_walletStore } from "../../../../shared/wallet/walletStore" +import OnboardingService from "./implementation" +import OnboardingWorker from "./worker/implementation" + +export const onboardingService = new OnboardingService( + uiService, + old_walletStore, +) + +export const onboardingWorker = new OnboardingWorker( + onboardingService, + old_walletStore, + browser, +) diff --git a/packages/extension/src/background/__new/services/onboarding/interface.ts b/packages/extension/src/background/__new/services/onboarding/interface.ts new file mode 100644 index 000000000..f8e873006 --- /dev/null +++ b/packages/extension/src/background/__new/services/onboarding/interface.ts @@ -0,0 +1,13 @@ +export interface IOnboardingService { + /** whether user has an onboarded wallet */ + getOnboardingComplete(): Promise + + /** opens the onboarding flow */ + openOnboarding(): Promise + + /** whether clicking extension icon opens onboarding */ + iconClickOpensOnboarding(): void + + /** whether clicking extension icon opens popup */ + iconClickOpensPopup(): void +} diff --git a/packages/extension/src/background/__new/services/onboarding/worker/implementation.test.ts b/packages/extension/src/background/__new/services/onboarding/worker/implementation.test.ts new file mode 100644 index 000000000..2c4a3e05b --- /dev/null +++ b/packages/extension/src/background/__new/services/onboarding/worker/implementation.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test, vi } from "vitest" + +import type { KeyValueStorage } from "../../../../../shared/storage" +import type { WalletStorageProps } from "../../../../../shared/wallet/walletStore" +import OnboardingWorker from "./implementation" + +describe("OnboardingWorker", () => { + const makeService = () => { + const onboardingService = { + getOnboardingComplete: vi.fn(), + iconClickOpensPopup: vi.fn(), + iconClickOpensOnboarding: vi.fn(), + openOnboarding: vi.fn(), + } + const walletStore = { + subscribe: vi.fn(), + } as unknown as KeyValueStorage + const browser = { + runtime: { + onInstalled: { + addListener: vi.fn(), + }, + }, + browserAction: { + onClicked: { + addListener: vi.fn(), + }, + }, + } + const onboardingWorker = new OnboardingWorker( + onboardingService, + walletStore, + browser, + ) + return { + onboardingWorker, + onboardingService, + walletStore, + browser, + } + } + test("it should add listeners", async () => { + const { onboardingService, walletStore, browser } = makeService() + onboardingService.getOnboardingComplete.mockImplementationOnce( + async () => false, + ) + expect(browser.runtime.onInstalled.addListener).toHaveBeenCalled() + expect(browser.browserAction.onClicked.addListener).toHaveBeenCalled() + expect(walletStore.subscribe).toHaveBeenCalled() + expect(onboardingService.getOnboardingComplete).toHaveBeenCalled() + await Promise.resolve() + expect(onboardingService.iconClickOpensOnboarding).toHaveBeenCalled() + }) +}) diff --git a/packages/extension/src/background/__new/services/onboarding/worker/implementation.ts b/packages/extension/src/background/__new/services/onboarding/worker/implementation.ts new file mode 100644 index 000000000..e2b1c3a6d --- /dev/null +++ b/packages/extension/src/background/__new/services/onboarding/worker/implementation.ts @@ -0,0 +1,62 @@ +import type { KeyValueStorage } from "../../../../../shared/storage" +import type { StorageChange } from "../../../../../shared/storage/types" +import type { DeepPick } from "../../../../../shared/types/deepPick" +import type { WalletStorageProps } from "../../../../../shared/wallet/walletStore" +import type { IOnboardingService } from "../interface" + +type MinimalBrowser = DeepPick< + typeof chrome, + "runtime.onInstalled.addListener" | "browserAction.onClicked.addListener" +> + +export default class OnboardingWorker { + constructor( + private onboardingService: IOnboardingService, + private walletStore: KeyValueStorage, + private browser: MinimalBrowser, + ) { + this.browser.runtime.onInstalled.addListener(this.onInstalled.bind(this)) + this.browser.browserAction.onClicked.addListener( + this.onExtensionIconClick.bind(this), + ) + this.walletStore.subscribe( + "backup", + this.onWalletStoreBackupChange.bind(this), + ) + void (async () => { + /** initialise what happens when user clicks icon */ + const onboardingComplete = + await this.onboardingService.getOnboardingComplete() + if (onboardingComplete) { + this.onboardingService.iconClickOpensPopup() + } else { + this.onboardingService.iconClickOpensOnboarding() + } + })() + } + + private onInstalled(details: chrome.runtime.InstalledDetails) { + if (details.reason === chrome.runtime.OnInstalledReason.INSTALL) { + void this.onboardingService.openOnboarding() + } + } + + /** Icon click event that fires only when `iconClickOpensOnboarding` is set */ + private onExtensionIconClick() { + void this.onboardingService.openOnboarding() + } + + private onWalletStoreBackupChange( + _value: string | undefined, + { oldValue, newValue }: StorageChange, + ) { + if (oldValue === undefined && newValue !== undefined) { + /** New wallet created - onboarding done */ + this.onboardingService.iconClickOpensPopup() + } else if (oldValue !== undefined && newValue === undefined) { + /** Wallet destroyed - start onboarding again */ + this.onboardingService.iconClickOpensOnboarding() + void this.onboardingService.openOnboarding() + } + } +} diff --git a/packages/extension/src/background/__new/trpc.ts b/packages/extension/src/background/__new/trpc.ts new file mode 100644 index 000000000..019c09568 --- /dev/null +++ b/packages/extension/src/background/__new/trpc.ts @@ -0,0 +1,24 @@ +import { initTRPC } from "@trpc/server" + +import { ActionItem } from "../../shared/actionQueue/types" +import { Queue } from "../actionQueue" +import { TransactionTracker } from "../transactions/tracking" +import { Wallet } from "../wallet" + +interface Context { + sender?: chrome.runtime.MessageSender + services: { + wallet: Wallet + actionQueue: Queue + transactionTracker: TransactionTracker + } +} + +const t = initTRPC.context().create({ + isServer: false, + allowOutsideOfServer: true, +}) + +export const router = t.router +export const procedure = t.procedure +export const middleware = t.middleware diff --git a/packages/extension/src/background/accountDeploy.ts b/packages/extension/src/background/accountDeploy.ts index 900046d7c..916e5c4cf 100644 --- a/packages/extension/src/background/accountDeploy.ts +++ b/packages/extension/src/background/accountDeploy.ts @@ -17,6 +17,16 @@ export const deployAccountAction = async ({ }) } +export const deployMultisigAction = async ({ + actionQueue, + account, +}: IDeployAccount) => { + await actionQueue.push({ + type: "DEPLOY_MULTISIG_ACTION", + payload: account, + }) +} + export const isAccountDeployed = async ( account: WalletAccount, getClassAt: (address: string, blockIdentifier?: unknown) => Promise, diff --git a/packages/extension/src/background/accountMessaging.ts b/packages/extension/src/background/accountMessaging.ts index 72e41642e..c0c18c3ec 100644 --- a/packages/extension/src/background/accountMessaging.ts +++ b/packages/extension/src/background/accountMessaging.ts @@ -1,13 +1,10 @@ import { constants, number } from "starknet" -import { getAccounts, removeAccount } from "../shared/account/store" -import { tryToMintFeeToken } from "../shared/devnet/mintFeeToken" +import { accountService } from "../shared/account/service" import { AccountMessage } from "../shared/messages/AccountMessage" import { isEqualAddress } from "../ui/services/addresses" -import { deployAccountAction } from "./accountDeploy" import { upgradeAccount } from "./accountUpgrade" import { sendMessageToUi } from "./activeTabs" -import { analytics } from "./analytics" import { HandleMessage, UnhandledMessage } from "./background" import { encryptForUi } from "./crypto" import { addTransaction } from "./transactions/store" @@ -16,80 +13,8 @@ export const handleAccountMessage: HandleMessage = async ({ msg, background: { wallet, actionQueue }, messagingKeys: { privateKey }, - respond, }) => { switch (msg.type) { - case "GET_ACCOUNTS": { - return sendMessageToUi({ - type: "GET_ACCOUNTS_RES", - data: await getAccounts(msg.data?.showHidden ? () => true : undefined), - }) - } - - case "CONNECT_ACCOUNT": { - // Select an Account of BaseWalletAccount type - const selectedAccount = await wallet.getSelectedAccount() - - return respond({ - type: "CONNECT_ACCOUNT_RES", - data: selectedAccount, - }) - } - - case "NEW_ACCOUNT": { - if (!(await wallet.isSessionOpen())) { - throw Error("you need an open session") - } - - const network = msg.data - try { - const account = await wallet.newAccount(network) - - tryToMintFeeToken(account) - - analytics.track("createAccount", { - status: "success", - networkId: network, - }) - - const accounts = await getAccounts() - - return sendMessageToUi({ - type: "NEW_ACCOUNT_RES", - data: { - account, - accounts, - }, - }) - } catch (exception) { - const error = `${exception}` - - analytics.track("createAccount", { - status: "failure", - networkId: network, - errorMessage: error, - }) - - return sendMessageToUi({ - type: "NEW_ACCOUNT_REJ", - data: { error }, - }) - } - } - - case "DEPLOY_ACCOUNT": { - try { - await deployAccountAction({ - account: msg.data, - actionQueue, - }) - - return sendMessageToUi({ type: "DEPLOY_ACCOUNT_RES" }) - } catch (e) { - return sendMessageToUi({ type: "DEPLOY_ACCOUNT_REJ" }) - } - } - case "GET_SELECTED_ACCOUNT": { const selectedAccount = await wallet.getSelectedAccount() return sendMessageToUi({ @@ -117,7 +42,7 @@ export const handleAccountMessage: HandleMessage = async ({ const account = msg.data const fullAccount = await wallet.getAccount(account) const { txHash } = await wallet.redeployAccount(fullAccount) - addTransaction({ + void addTransaction({ hash: txHash, account: fullAccount, meta: { title: "Redeploy wallet", type: "DEPLOY_ACCOUNT" }, @@ -136,7 +61,7 @@ export const handleAccountMessage: HandleMessage = async ({ case "DELETE_ACCOUNT": { try { - await removeAccount(msg.data) + await accountService.remove(msg.data) return sendMessageToUi({ type: "DELETE_ACCOUNT_RES" }) } catch { return sendMessageToUi({ type: "DELETE_ACCOUNT_REJ" }) @@ -149,7 +74,7 @@ export const handleAccountMessage: HandleMessage = async ({ } const encryptedPrivateKey = await encryptForUi( - await wallet.exportPrivateKey(msg.data.account), + await wallet.getPrivateKey(msg.data.account), msg.data.encryptedSecret, privateKey, ) @@ -161,11 +86,11 @@ export const handleAccountMessage: HandleMessage = async ({ } case "GET_PUBLIC_KEY": { - const publicKey = await wallet.getPublicKey(msg.data) + const { publicKey, account } = await wallet.getPublicKey(msg.data) return sendMessageToUi({ type: "GET_PUBLIC_KEY_RES", - data: { publicKey }, + data: { publicKey, account }, }) } @@ -186,6 +111,22 @@ export const handleAccountMessage: HandleMessage = async ({ }) } + case "GET_NEXT_PUBLIC_KEY": { + try { + const { publicKey } = await wallet.getNextPublicKey(msg.data.networkId) + + return sendMessageToUi({ + type: "GET_NEXT_PUBLIC_KEY_RES", + data: { publicKey }, + }) + } catch (e) { + console.error(e) + return sendMessageToUi({ + type: "GET_NEXT_PUBLIC_KEY_REJ", + }) + } + } + case "DEPLOY_ACCOUNT_ACTION_FAILED": { return await actionQueue.remove(msg.data.actionHash) } @@ -193,22 +134,23 @@ export const handleAccountMessage: HandleMessage = async ({ case "ACCOUNT_CHANGE_GUARDIAN": { try { const { account, guardian } = msg.data + + const newGuardian = number.hexToDecimalString(guardian) + await actionQueue.push({ type: "TRANSACTION", payload: { transactions: { contractAddress: account.address, entrypoint: "changeGuardian", - calldata: [ - number.hexToDecimalString( - guardian || constants.ZERO.toString(), - ), - ], + calldata: [newGuardian], }, meta: { isChangeGuardian: true, title: "Change account guardian", - type: "INVOKE_FUNCTION", + type: number.toBN(newGuardian).isZero() // if guardian is 0, it's a remove guardian action + ? "REMOVE_ARGENT_SHIELD" + : "ADD_ARGENT_SHIELD", }, }, }) @@ -296,7 +238,7 @@ export const handleAccountMessage: HandleMessage = async ({ throw Error("no account selected") } - const publicKey = await wallet.getPublicKey(account) + const { publicKey } = await wallet.getPublicKey(account) if ( selectedAccount.guardian && diff --git a/packages/extension/src/background/accountUpgrade.ts b/packages/extension/src/background/accountUpgrade.ts index 07fa7cfc7..1e9150062 100644 --- a/packages/extension/src/background/accountUpgrade.ts +++ b/packages/extension/src/background/accountUpgrade.ts @@ -2,7 +2,6 @@ import { stark } from "starknet" import { ActionItem } from "../shared/actionQueue/types" import { getNetwork } from "../shared/network" -import { mapArgentAccountTypeToImplementationKey } from "../shared/network/utils" import { ArgentAccountType, BaseWalletAccount } from "../shared/wallet.model" import { Queue } from "./actionQueue" import { Wallet } from "./wallet" @@ -29,13 +28,12 @@ export const upgradeAccount = async ({ fullAccount.network.id, ) - if (!newImplementation) { + if (!newImplementation || !newImplementation.standard) { throw "Cannot upgrade account without a new contract implementation" } const implementationClassHash = - newImplementation[mapArgentAccountTypeToImplementationKey(accountType)] ?? - newImplementation.argentAccount + newImplementation[accountType] ?? newImplementation.standard const calldata = stark.compileCalldata({ implementation: implementationClassHash, diff --git a/packages/extension/src/background/actionHandlers.ts b/packages/extension/src/background/actionHandlers.ts index 6d397bf56..79efcd132 100644 --- a/packages/extension/src/background/actionHandlers.ts +++ b/packages/extension/src/background/actionHandlers.ts @@ -1,4 +1,4 @@ -import { getAccounts } from "../shared/account/store" +import { accountService } from "../shared/account/service" import { ActionItem, ExtQueueItem } from "../shared/actionQueue/types" import { MessageType } from "../shared/messages" import { addNetwork, getNetworks } from "../shared/network" @@ -8,6 +8,7 @@ import { assertNever } from "../ui/services/assertNever" import { accountDeployAction } from "./accountDeployAction" import { analytics } from "./analytics" import { BackgroundService } from "./background" +import { multisigDeployAction } from "./multisigDeployAction" import { openUi } from "./openUi" import { executeTransactionAction } from "./transactions/transactionExecution" import { udcDeclareContract, udcDeployContract } from "./udcAction" @@ -25,11 +26,11 @@ export const handleActionApproval = async ( const selectedAccount = await wallet.getSelectedAccount() if (!selectedAccount) { - openUi() + void openUi() return } - analytics.track("preauthorizeDapp", { + void analytics.track("preauthorizeDapp", { host, networkId: selectedAccount.networkId, }) @@ -59,7 +60,7 @@ export const handleActionApproval = async ( try { const txHash = await accountDeployAction(action, background) - analytics.track("deployAccount", { + void analytics.track("deployAccount", { status: "success", trigger: "sign", networkId: action.payload.networkId, @@ -75,7 +76,7 @@ export const handleActionApproval = async ( error = `${error}\n\nA 403 error means there's already something running on the selected port. On macOS, AirPlay is using port 5000 by default, so please try running your node on another port and changing the port in Argent X settings.` } - analytics.track("deployAccount", { + void analytics.track("deployAccount", { status: "failure", networkId: action.payload.networkId, errorMessage: `${error}`, @@ -88,6 +89,39 @@ export const handleActionApproval = async ( } } + case "DEPLOY_MULTISIG_ACTION": { + try { + const txHash = await multisigDeployAction(action, background) + + void analytics.track("deployMultisig", { + status: "success", + trigger: "transaction", + networkId: action.payload.networkId, + }) + + return { + type: "DEPLOY_MULTISIG_ACTION_SUBMITTED", + data: { txHash, actionHash }, + } + } catch (exception: unknown) { + let error = `${exception}` + if (error.includes("403")) { + error = `${error}\n\nA 403 error means there's already something running on the selected port. On macOS, AirPlay is using port 5000 by default, so please try running your node on another port and changing the port in Argent X settings.` + } + + void analytics.track("deployMultisig", { + status: "failure", + networkId: action.payload.networkId, + errorMessage: `${error}`, + }) + + return { + type: "DEPLOY_MULTISIG_ACTION_FAILED", + data: { actionHash, error: `${error}` }, + } + } + } + case "SIGN": { const typedData = action.payload if (!(await wallet.isSessionOpen())) { @@ -95,13 +129,12 @@ export const handleActionApproval = async ( } const starknetAccount = await wallet.getSelectedStarknetAccount() - const [r, s] = await starknetAccount.signMessage(typedData) + const signature = await starknetAccount.signMessage(typedData) return { type: "SIGNATURE_SUCCESS", data: { - r: r.toString(), - s: s.toString(), + signature, actionHash, }, } @@ -114,21 +147,6 @@ export const handleActionApproval = async ( } } - case "REQUEST_ADD_CUSTOM_NETWORK": { - try { - await addNetwork(action.payload) - return { - type: "APPROVE_REQUEST_ADD_CUSTOM_NETWORK", - data: { actionHash }, - } - } catch (error) { - return { - type: "REJECT_REQUEST_ADD_CUSTOM_NETWORK", - data: { actionHash }, - } - } - } - case "REQUEST_SWITCH_CUSTOM_NETWORK": { try { const networks = await getNetworks() @@ -141,7 +159,7 @@ export const handleActionApproval = async ( throw Error(`Network with chainId ${chainId} not found`) } - const accountsOnNetwork = await getAccounts((account) => { + const accountsOnNetwork = await accountService.get((account) => { return account.networkId === network.id && !account.hidden }) @@ -265,23 +283,23 @@ export const handleActionRejection = async ( } } - case "SIGN": { + case "DEPLOY_MULTISIG_ACTION": { return { - type: "SIGNATURE_FAILURE", + type: "DEPLOY_MULTISIG_ACTION_FAILED", data: { actionHash }, } } - case "REQUEST_TOKEN": { + case "SIGN": { return { - type: "REJECT_REQUEST_TOKEN", + type: "SIGNATURE_FAILURE", data: { actionHash }, } } - case "REQUEST_ADD_CUSTOM_NETWORK": { + case "REQUEST_TOKEN": { return { - type: "REJECT_REQUEST_ADD_CUSTOM_NETWORK", + type: "REJECT_REQUEST_TOKEN", data: { actionHash }, } } diff --git a/packages/extension/src/background/crypto.ts b/packages/extension/src/background/crypto.ts index bf3dc39e7..3e1922637 100644 --- a/packages/extension/src/background/crypto.ts +++ b/packages/extension/src/background/crypto.ts @@ -1,5 +1,6 @@ import { EncryptJWT, KeyLike, compactDecrypt, importJWK } from "jose" -import { encode } from "starknet" + +import { bytesToUft8 } from "../shared/utils/encode" export const encryptForUi = async ( value: string, @@ -8,7 +9,7 @@ export const encryptForUi = async ( ) => { const { plaintext } = await compactDecrypt(encryptedSecret, privateKey) - const jwk = JSON.parse(encode.arrayBufferToString(plaintext)) + const jwk = JSON.parse(bytesToUft8(plaintext)) const symmetricSecret = await importJWK(jwk) return await new EncryptJWT({ value }) diff --git a/packages/extension/src/background/index.ts b/packages/extension/src/background/index.ts index 0746e35e5..ebb8e4b51 100644 --- a/packages/extension/src/background/index.ts +++ b/packages/extension/src/background/index.ts @@ -1,20 +1,20 @@ +import "./__new/router" + import { StarknetMethodArgumentsSchemas } from "@argent/x-window" import browser from "webextension-polyfill" -import { accountStore, getAccounts } from "../shared/account/store" +import { accountService } from "../shared/account/service" import { globalActionQueueStore } from "../shared/actionQueue/store" import { ActionItem } from "../shared/actionQueue/types" import { MessageType, messageStream } from "../shared/messages" -import { getNetwork } from "../shared/network" +import { multisigTracker } from "../shared/multisig/tracking" import { isPreAuthorized, migratePreAuthorizations, } from "../shared/preAuthorizations" import { delay } from "../shared/utils/delay" import { migrateWallet } from "../shared/wallet/storeMigration" -import { walletStore } from "../shared/wallet/walletStore" import { handleAccountMessage } from "./accountMessaging" -import { loadContracts } from "./accounts" import { handleActionMessage } from "./actionMessaging" import { getQueue } from "./actionQueue" import { @@ -30,13 +30,14 @@ import { } from "./background" import { getMessagingKeys } from "./keys/messagingKeys" import { handleMiscellaneousMessage } from "./miscellaneousMessaging" +import { handleMultisigMessage } from "./multisigMessaging" +import { networkService } from "./network/network.service" import { handleNetworkMessage } from "./networkMessaging" import { initOnboarding } from "./onboarding" import { getOriginFromSender, handlePreAuthorizationMessage, } from "./preAuthorizationMessaging" -import { handleRecoveryMessage } from "./recoveryMessaging" import { handleSessionMessage } from "./sessionMessaging" import { handleShieldMessage } from "./shieldMessaging" import { handleTokenMessaging } from "./tokenMessaging" @@ -44,52 +45,103 @@ import { initBadgeText } from "./transactions/badgeText" import { transactionTracker } from "./transactions/tracking" import { handleTransactionMessage } from "./transactions/transactionMessaging" import { handleUdcMessaging } from "./udcMessaging" -import { Wallet, sessionStore } from "./wallet" +import { walletSingleton } from "./walletSingleton" const DEFAULT_POLLING_INTERVAL = 15 const LOCAL_POLLING_INTERVAL = 5 -browser.alarms.create("core:transactionTracker:history", { +const enum ALARM_NAMES { + TRANSACTION_TRACKER_HISTORY = "core:transactionTracker:history", + TRANSACTION_TRACKER_UPDATE = "core:transactionTracker:update", + MULTISIG_ACCOUNT_UPDATE = "core:multisig:updateDataForAccounts", + MULTISIG_PENDING_UPDATE = "core:multisig:updateDataForPendingMultisig", + MULTISIG_TRANSACTION_TRACKER = "core:multisig:transactionTracker", + NETWORK_STATUS_TRACKER = "core:networkStatusTracker:update", +} + +browser.alarms.create(ALARM_NAMES.TRANSACTION_TRACKER_HISTORY, { periodInMinutes: 5, // fetch history transactions every 5 minutes from voyager }) -browser.alarms.create("core:transactionTracker:update", { +browser.alarms.create(ALARM_NAMES.TRANSACTION_TRACKER_UPDATE, { periodInMinutes: 1, // fetch transaction updates of existing transactions every minute from onchain }) +browser.alarms.create(ALARM_NAMES.MULTISIG_ACCOUNT_UPDATE, { + periodInMinutes: 5, // fetch multisig updates of existing multisigs every 5 minutes from backend +}) +browser.alarms.create(ALARM_NAMES.MULTISIG_PENDING_UPDATE, { + periodInMinutes: 3, // fetch pending multisig updates of existing multisigs every 3 minutes from backend +}) +browser.alarms.create(ALARM_NAMES.MULTISIG_TRANSACTION_TRACKER, { + periodInMinutes: 2, // fetch transaction updates of existing multisig every 2 minutes from backend +}) + +// eslint-disable-next-line @typescript-eslint/no-misused-promises browser.alarms.onAlarm.addListener(async (alarm) => { - if (alarm.name === "core:transactionTracker:history") { - console.info("~> fetching transaction history") - await transactionTracker.loadHistory(await getAccounts()) - } - if (alarm.name === "core:transactionTracker:update") { - console.info("~> fetching transaction updates") - let inFlightTransactions = await transactionTracker.update() - // the config below will run transaction updates 4x per minute, if there are in-flight transactions - // By default it will update on second 0, 15, 30 and 45 but by updating WAIT_TIME we can change the number of executions - const maxExecutionTimeInMs = 60000 // 1 minute max execution time - let transactionPollingIntervalInS = DEFAULT_POLLING_INTERVAL - const startTime = Date.now() - - while ( - inFlightTransactions.length > 0 && - Date.now() - startTime < maxExecutionTimeInMs - ) { - const localTransaction = inFlightTransactions.find( - (tx) => tx.account.networkId === "localhost", - ) - if (localTransaction) { - transactionPollingIntervalInS = LOCAL_POLLING_INTERVAL - } else { - transactionPollingIntervalInS = DEFAULT_POLLING_INTERVAL + switch (alarm.name) { + case ALARM_NAMES.TRANSACTION_TRACKER_HISTORY: { + console.info("~> fetching transaction history") + await transactionTracker.loadHistory(await accountService.get()) + break + } + + case ALARM_NAMES.MULTISIG_ACCOUNT_UPDATE: { + console.info("~> fetching multisig account updates") + await multisigTracker.updateDataForAccounts() + break + } + + case ALARM_NAMES.MULTISIG_PENDING_UPDATE: { + console.info("~> fetching pending multisig account updates") + await multisigTracker.updateDataForPendingMultisig() + break + } + + case ALARM_NAMES.MULTISIG_TRANSACTION_TRACKER: { + console.info("~> fetching multisig transaction updates") + await multisigTracker.updateTransactions() + break + } + + case ALARM_NAMES.TRANSACTION_TRACKER_UPDATE: { + console.info("~> fetching transaction updates") + let inFlightTransactions = await transactionTracker.update() + // the config below will run transaction updates 4x per minute, if there are in-flight transactions + // By default it will update on second 0, 15, 30 and 45 but by updating WAIT_TIME we can change the number of executions + const maxExecutionTimeInMs = 60000 // 1 minute max execution time + let transactionPollingIntervalInS = DEFAULT_POLLING_INTERVAL + const startTime = Date.now() + + while ( + inFlightTransactions.length > 0 && + Date.now() - startTime < maxExecutionTimeInMs + ) { + const localTransaction = inFlightTransactions.find( + (tx) => tx.account.networkId === "localhost", + ) + if (localTransaction) { + transactionPollingIntervalInS = LOCAL_POLLING_INTERVAL + } else { + transactionPollingIntervalInS = DEFAULT_POLLING_INTERVAL + } + console.info( + `~> waiting ${transactionPollingIntervalInS}s for transaction updates`, + ) + await delay(transactionPollingIntervalInS * 1000) + console.info( + "~> fetching transaction updates as pending transactions were detected", + ) + inFlightTransactions = await transactionTracker.update() } - console.info( - `~> waiting ${transactionPollingIntervalInS}s for transaction updates`, - ) - await delay(transactionPollingIntervalInS * 1000) - console.info( - "~> fetching transaction updates as pending transactions were detected", - ) - inFlightTransactions = await transactionTracker.update() + break + } + + case ALARM_NAMES.NETWORK_STATUS_TRACKER: { + await networkService.updateStatuses() + break } + + default: + break } }) @@ -105,15 +157,16 @@ const handlers = [ handleMiscellaneousMessage, handleNetworkMessage, handlePreAuthorizationMessage, - handleRecoveryMessage, handleSessionMessage, handleTransactionMessage, handleTokenMessaging, handleUdcMessaging, handleShieldMessage, + handleMultisigMessage, ] as Array> -getAccounts() +accountService + .get() .then((x) => transactionTracker.loadHistory(x)) .catch(() => console.warn("failed to load transaction history")) @@ -141,7 +194,6 @@ const safeMessages: MessageType["type"][] = [ "REJECT_REQUEST_SWITCH_CUSTOM_NETWORK", "CONNECT_DAPP_RES", "CONNECT_ACCOUNT_RES", - "CONNECT_ACCOUNT", "REJECT_PREAUTHORIZATION", "REQUEST_DECLARE_CONTRACT_RES", "DECLARE_CONTRACT_ACTION_FAILED", @@ -152,7 +204,6 @@ const safeIfPreauthorizedMessages: MessageType["type"][] = [ "EXECUTE_TRANSACTION", "SIGN_MESSAGE", "REQUEST_TOKEN", - "REQUEST_ADD_CUSTOM_NETWORK", "REQUEST_SWITCH_CUSTOM_NETWORK", "REQUEST_DECLARE_CONTRACT", ] @@ -165,18 +216,10 @@ const handleMessage = async ( const messagingKeys = await getMessagingKeys() - const wallet = new Wallet( - walletStore, - accountStore, - sessionStore, - loadContracts, - getNetwork, - ) - const actionQueue = await getQueue(globalActionQueueStore) const background: BackgroundService = { - wallet, + wallet: walletSingleton, transactionTracker, actionQueue, } @@ -186,7 +229,7 @@ const handleMessage = async ( const origin = getOriginFromSender(sender) const isSafeOrigin = Boolean(origin === safeOrigin) - const currentAccount = await wallet.getSelectedAccount() + const currentAccount = await walletSingleton.getSelectedAccount() const senderIsPreauthorized = !!currentAccount && (await isPreAuthorized(currentAccount, origin)) @@ -237,6 +280,7 @@ const handleMessage = async ( } browser.runtime.onConnect.addListener((port) => { + // eslint-disable-next-line @typescript-eslint/no-misused-promises port.onMessage.addListener(async (msg: MessageType, port) => { const sender = port.sender if (sender) { @@ -272,6 +316,7 @@ browser.runtime.onConnect.addListener((port) => { }) }) +// eslint-disable-next-line @typescript-eslint/no-misused-promises messageStream.subscribe(handleMessage) // open onboarding flow on initial install diff --git a/packages/extension/src/background/miscellaneousMessaging.ts b/packages/extension/src/background/miscellaneousMessaging.ts index cb970a5f4..35ecb7ed9 100644 --- a/packages/extension/src/background/miscellaneousMessaging.ts +++ b/packages/extension/src/background/miscellaneousMessaging.ts @@ -17,10 +17,10 @@ export const handleMiscellaneousMessage: HandleMessage< case "RESET_ALL": { try { - browser.storage.local.clear() - browser.storage.sync.clear() - browser.storage.managed.clear() - browser.storage.session.clear() + await browser.storage.local.clear() + await browser.storage.sync.clear() + await browser.storage.managed.clear() + await browser.storage.session.clear() } catch { // Ignore browser.storage.session error "This is a read-only store" } diff --git a/packages/extension/src/background/multisigDeployAction.ts b/packages/extension/src/background/multisigDeployAction.ts new file mode 100644 index 000000000..1765722ae --- /dev/null +++ b/packages/extension/src/background/multisigDeployAction.ts @@ -0,0 +1,64 @@ +import { number } from "starknet" + +import { ExtQueueItem } from "../shared/actionQueue/types" +import { BaseWalletAccount } from "../shared/wallet.model" +import { BackgroundService } from "./background" +import { addTransaction } from "./transactions/store" +import { checkTransactionHash } from "./transactions/transactionExecution" +import { argentMaxFee } from "./utils/argentMaxFee" + +type DeployMultisigAction = ExtQueueItem<{ + type: "DEPLOY_MULTISIG_ACTION" + payload: BaseWalletAccount +}> + +export const multisigDeployAction = async ( + { payload: baseAccount }: DeployMultisigAction, + { wallet }: BackgroundService, +) => { + if (!(await wallet.isSessionOpen())) { + throw Error("you need an open session") + } + const selectedMultisig = await wallet.getMultisigAccount(baseAccount) + + const multisigNeedsDeploy = selectedMultisig.needsDeploy + + if (!multisigNeedsDeploy) { + throw Error("Account already deployed") + } + + let maxFee: string + + try { + const { suggestedMaxFee } = await wallet.getAccountDeploymentFee( + selectedMultisig, + ) + + maxFee = argentMaxFee(suggestedMaxFee) + } catch (error) { + const fallbackPrice = number.toBN(10e14) + maxFee = argentMaxFee(fallbackPrice) + } + + const { account, txHash } = await wallet.deployAccount(selectedMultisig, { + maxFee, + }) + + if (!checkTransactionHash(txHash)) { + throw Error( + "Deploy Multisig Transaction could not be added to the sequencer", + ) + } + + await addTransaction({ + hash: txHash, + account, + meta: { + title: "Activate Multisig", + isDeployAccount: true, + type: "DEPLOY_ACCOUNT", + }, + }) + + return txHash +} diff --git a/packages/extension/src/background/multisigMessaging.ts b/packages/extension/src/background/multisigMessaging.ts new file mode 100644 index 000000000..78ebd19aa --- /dev/null +++ b/packages/extension/src/background/multisigMessaging.ts @@ -0,0 +1,260 @@ +import { utils } from "ethers" +import { stark } from "starknet" + +import { tryToMintFeeToken } from "../shared/devnet/mintFeeToken" +import { MultisigMessage } from "../shared/messages/MultisigMessage" +import { MultisigAccount } from "../shared/multisig/account" +import { getMultisigAccounts } from "../shared/multisig/utils/baseMultisig" +import { deployMultisigAction } from "./accountDeploy" +import { sendMessageToUi } from "./activeTabs" +import { analytics } from "./analytics" +import { HandleMessage, UnhandledMessage } from "./background" + +export const handleMultisigMessage: HandleMessage = async ({ + msg, + background: { wallet, actionQueue }, +}) => { + switch (msg.type) { + case "NEW_MULTISIG_ACCOUNT": { + if (!(await wallet.isSessionOpen())) { + throw Error("you need an open session") + } + + const { networkId, signers, threshold, creator, publicKey } = msg.data + try { + const account = await wallet.newAccount(networkId, "multisig", { + signers, + threshold, + creator, + publicKey, + }) + tryToMintFeeToken(account) + + analytics.track("createAccount", { + status: "success", + networkId, + type: "multisig", + }) + + const accounts = await getMultisigAccounts() + + return sendMessageToUi({ + type: "NEW_MULTISIG_ACCOUNT_RES", + data: { + account, + accounts, + }, + }) + } catch (exception) { + const error = `${exception}` + + analytics.track("createAccount", { + status: "failure", + networkId: networkId, + type: "multisig", + errorMessage: error, + }) + + return sendMessageToUi({ + type: "NEW_MULTISIG_ACCOUNT_REJ", + data: { error }, + }) + } + } + + case "DEPLOY_MULTISIG": { + try { + await deployMultisigAction({ + account: msg.data, + actionQueue, + }) + + return sendMessageToUi({ type: "DEPLOY_MULTISIG_RES" }) + } catch (e) { + return sendMessageToUi({ type: "DEPLOY_MULTISIG_REJ" }) + } + } + + case "NEW_PENDING_MULTISIG": { + if (!(await wallet.isSessionOpen())) { + throw Error("you need an open session") + } + + const { networkId } = msg.data + try { + const pendingMultisig = await wallet.newPendingMultisig(networkId) + + // TODO: Add tracking + // analytics.track("createAccount", { + // status: "success", + // networkId, + // type: "multisig", + // }) + + return sendMessageToUi({ + type: "NEW_PENDING_MULTISIG_RES", + data: pendingMultisig, + }) + } catch (exception) { + const error = `${exception}` + + // TODO: Add tracking + + // analytics.track("createAccount", { + // status: "failure", + // networkId: networkId, + // type: "multisig", + // errorMessage: error, + // }) + + return sendMessageToUi({ + type: "NEW_PENDING_MULTISIG_REJ", + data: { error }, + }) + } + } + + case "ADD_MULTISIG_OWNERS": { + try { + const { address, signersToAdd, newThreshold } = msg.data + + const signersPayload = { + entrypoint: "addSigners", + calldata: stark.compileCalldata({ + new_threshold: newThreshold.toString(), + signers_to_add: signersToAdd.map((signer) => + utils.hexlify(utils.base58.decode(signer)), + ), + }), + contractAddress: address, + } + + await actionQueue.push({ + type: "TRANSACTION", + payload: { + transactions: signersPayload, + meta: { + title: "Add multisig owners", + type: "MULTISIG_ADD_SIGNERS", + }, + }, + }) + + return sendMessageToUi({ + type: "ADD_MULTISIG_OWNERS_RES", + }) + } catch (e) { + return sendMessageToUi({ + type: "ADD_MULTISIG_OWNERS_REJ", + data: { error: `${e}` }, + }) + } + } + + case "REMOVE_MULTISIG_OWNER": { + try { + const { address, signerToRemove, newThreshold } = msg.data + + const signersToRemove = [ + utils.hexlify(utils.base58.decode(signerToRemove)), + ] + + const signersPayload = { + entrypoint: "removeSigners", + calldata: stark.compileCalldata({ + new_threshold: newThreshold.toString(), + signers_to_remove: signersToRemove, + }), + contractAddress: address, + } + + await actionQueue.push({ + type: "TRANSACTION", + payload: { + transactions: signersPayload, + meta: { + title: "Remove multisig owner", + type: "MULTISIG_REMOVE_SIGNER", + }, + }, + }) + + return sendMessageToUi({ + type: "REMOVE_MULTISIG_OWNER_RES", + }) + } catch (e) { + return sendMessageToUi({ + type: "REMOVE_MULTISIG_OWNER_REJ", + data: { error: `${e}` }, + }) + } + } + case "UPDATE_MULTISIG_THRESHOLD": { + try { + const { address, newThreshold } = msg.data + + const thresholdPayload = { + entrypoint: "changeThreshold", + calldata: [newThreshold.toString()], + contractAddress: address, + } + + await actionQueue.push({ + type: "TRANSACTION", + payload: { + transactions: thresholdPayload, + meta: { + title: "Set confirmations threshold", + type: "MULTISIG_UPDATE_THRESHOLD", + }, + }, + }) + return sendMessageToUi({ + type: "UPDATE_MULTISIG_THRESHOLD_RES", + }) + } catch (e) { + return sendMessageToUi({ + type: "UPDATE_MULTISIG_THRESHOLD_REJ", + data: { error: `${e}` }, + }) + } + } + + case "ADD_MULTISIG_TRANSACTION_SIGNATURE": { + try { + const { requestId } = msg.data + + const selectedAccount = await wallet.getSelectedAccount() + + if (!selectedAccount) { + throw Error("No account selected") + } + + const multisigStarknetAccount = await wallet.getStarknetAccount( + selectedAccount, + ) + + if (!MultisigAccount.isMultisig(multisigStarknetAccount)) { + throw Error("Selected account is not a multisig account") + } + + const { transaction_hash } = + await multisigStarknetAccount.addRequestSignature(requestId) + + return sendMessageToUi({ + type: "ADD_MULTISIG_TRANSACTION_SIGNATURE_RES", + data: { + txHash: transaction_hash, + }, + }) + } catch (e) { + return sendMessageToUi({ + type: "ADD_MULTISIG_TRANSACTION_SIGNATURE_REJ", + data: { error: `${e}` }, + }) + } + } + } + + throw new UnhandledMessage() +} diff --git a/packages/extension/src/background/network/network.service.ts b/packages/extension/src/background/network/network.service.ts new file mode 100644 index 000000000..536b254f0 --- /dev/null +++ b/packages/extension/src/background/network/network.service.ts @@ -0,0 +1,31 @@ +import { uniqWith } from "lodash-es" + +import { Network, defaultNetworks } from "../../shared/network" +import { allNetworksStore, equalNetwork } from "../../shared/network/storage" +import { getNetworkStatuses } from "./networkStatus.worker" + +export interface NetworkService { + updateStatuses: () => Promise + loadNetworks: () => Promise +} + +export const networkService: NetworkService = { + async loadNetworks() { + const allNetworks = uniqWith( + [...(await allNetworksStore.get()), ...defaultNetworks], + equalNetwork, + ) + return allNetworks + }, + async updateStatuses() { + const networks = await this.loadNetworks() + const networkStatuses = await getNetworkStatuses(networks) + const networkWithUpdatedStatuses = networks.map((network) => { + return { + ...network, + status: networkStatuses[network.id] ?? "unknown", + } + }) + await allNetworksStore.push(networkWithUpdatedStatuses) + }, +} diff --git a/packages/extension/src/background/networkStatus.ts b/packages/extension/src/background/network/networkStatus.worker.ts similarity index 94% rename from packages/extension/src/background/networkStatus.ts rename to packages/extension/src/background/network/networkStatus.worker.ts index a7d3579bd..4ebd51888 100644 --- a/packages/extension/src/background/networkStatus.ts +++ b/packages/extension/src/background/network/networkStatus.worker.ts @@ -1,9 +1,9 @@ import urljoin from "url-join" -import { Network, NetworkStatus } from "../shared/network" -import { KeyValueStorage } from "../shared/storage" -import { createStaleWhileRevalidateCache } from "./swr" -import { fetchWithTimeout } from "./utils/fetchWithTimeout" +import { Network, NetworkStatus } from "../../shared/network" +import { KeyValueStorage } from "../../shared/storage" +import { createStaleWhileRevalidateCache } from "../swr" +import { fetchWithTimeout } from "../utils/fetchWithTimeout" type SwrCacheKey = string diff --git a/packages/extension/src/background/networkMessaging.ts b/packages/extension/src/background/networkMessaging.ts index 5f38ee3bb..7038aa16a 100644 --- a/packages/extension/src/background/networkMessaging.ts +++ b/packages/extension/src/background/networkMessaging.ts @@ -1,10 +1,9 @@ import { number, shortString } from "starknet" import { NetworkMessage } from "../shared/messages/NetworkMessage" -import { getNetwork, getNetworkByChainId, getNetworks } from "../shared/network" +import { getNetworkByChainId } from "../shared/network" import { UnhandledMessage } from "./background" import { HandleMessage } from "./background" -import { getNetworkStatuses } from "./networkStatus" export const handleNetworkMessage: HandleMessage = async ({ msg, @@ -12,62 +11,6 @@ export const handleNetworkMessage: HandleMessage = async ({ respond, }) => { switch (msg.type) { - case "GET_NETWORKS": { - return respond({ - type: "GET_NETWORKS_RES", - data: await getNetworks(), - }) - } - - case "GET_NETWORK": { - const allNetworks = await getNetworks() - - const network = allNetworks.find((n) => n.id === msg.data) - - if (!network) { - throw new Error(`Network with id ${msg.data} not found`) - } - - return respond({ - type: "GET_NETWORK_RES", - data: network, - }) - } - - case "GET_NETWORK_STATUSES": { - const networks = msg.data?.length ? msg.data : await getNetworks() - const statuses = await getNetworkStatuses(networks) - return respond({ - type: "GET_NETWORK_STATUSES_RES", - data: statuses, - }) - } - - case "REQUEST_ADD_CUSTOM_NETWORK": { - const exists = await getNetwork(msg.data.chainId) - - if (exists) { - return respond({ - type: "REQUEST_ADD_CUSTOM_NETWORK_REJ", - data: { - error: `Network with chainId ${msg.data.chainId} already exists`, - }, - }) - } - - const { meta } = await actionQueue.push({ - type: "REQUEST_ADD_CUSTOM_NETWORK", - payload: msg.data, - }) - - return respond({ - type: "REQUEST_ADD_CUSTOM_NETWORK_RES", - data: { - actionHash: meta.hash, - }, - }) - } - case "REQUEST_SWITCH_CUSTOM_NETWORK": { const { chainId } = msg.data diff --git a/packages/extension/src/background/nonce.ts b/packages/extension/src/background/nonce.ts index c51b3a216..8470c984d 100644 --- a/packages/extension/src/background/nonce.ts +++ b/packages/extension/src/background/nonce.ts @@ -1,7 +1,7 @@ import { number } from "starknet" import { KeyValueStorage } from "../shared/storage" -import { BaseWalletAccount } from "../shared/wallet.model" +import { BaseWalletAccount, WalletAccount } from "../shared/wallet.model" import { getAccountIdentifier } from "../shared/wallet.service" import { Wallet } from "./wallet" @@ -14,15 +14,20 @@ const nonceStore = new KeyValueStorage>( ) export async function getNonce( - baseWallet: BaseWalletAccount, + account: WalletAccount, wallet: Wallet, ): Promise { - const account = await wallet.getStarknetAccount(baseWallet) - const storageAddress = getAccountIdentifier(baseWallet) - const result = await account.getNonce() + const starknetAccount = await wallet.getStarknetAccount(account) + const storageAddress = getAccountIdentifier(account) + const result = await starknetAccount.getNonce() const nonceBn = number.toBN(result) const storedNonce = await nonceStore.get(storageAddress) + if (account.type === "multisig") { + // If the account is a multisig, we don't want to store the nonce + return number.toHex(nonceBn) + } + // If there's no nonce stored or the fetched nonce is bigger than the stored one, store the fetched nonce if (!storedNonce || nonceBn.gt(number.toBN(storedNonce))) { await nonceStore.set(storageAddress, number.toHex(nonceBn)) diff --git a/packages/extension/src/background/notification.ts b/packages/extension/src/background/notification.ts index 9863f6696..2347dab40 100644 --- a/packages/extension/src/background/notification.ts +++ b/packages/extension/src/background/notification.ts @@ -1,8 +1,10 @@ -import { Status } from "starknet" import browser from "webextension-polyfill" import { ArrayStorage } from "../shared/storage" -import { TransactionMeta } from "../shared/transactions" +import { + ExtendedTransactionStatus, + TransactionMeta, +} from "../shared/transactions" const notificationsStorage = new ArrayStorage( [], @@ -18,9 +20,9 @@ export async function addToAlreadyShown(hash: string) { await notificationsStorage.push(hash) } -export async function sentTransactionNotification( +export function sendTransactionNotification( hash: string, - status: Status, + status: ExtendedTransactionStatus, meta?: TransactionMeta, ) { const id = `TX:${hash}` @@ -37,3 +39,27 @@ export async function sentTransactionNotification( eventTime: Date.now(), }) } + +export function sendMultisigAccountReadyNotification(address: string) { + const id = `MS:READY:${address}` + const title = "Multisig is ready!" + return browser.notifications.create(id, { + type: "basic", + title, + message: "Your multisig account is ready to use", + iconUrl: "./assets/logo.png", + eventTime: Date.now(), + }) +} + +export function sendMultisigTransactionNotification(hash: string) { + const id = `MS:${hash}` + const title = "Multisig Transaction" + return browser.notifications.create(id, { + type: "basic", + title, + message: `New multisig transaction is waiting for your approval`, + iconUrl: "./assets/logo.png", + eventTime: Date.now(), + }) +} diff --git a/packages/extension/src/background/onboarding.ts b/packages/extension/src/background/onboarding.ts index 10ca1e573..b0ffee015 100644 --- a/packages/extension/src/background/onboarding.ts +++ b/packages/extension/src/background/onboarding.ts @@ -1,10 +1,8 @@ -import browser from "webextension-polyfill" +import { onboardingWorker } from "./__new/services/onboarding" -export const initOnboarding = () => { - browser.runtime.onInstalled.addListener((details) => { - if (details.reason === browser.runtime.OnInstalledReason.INSTALL) { - const url = browser.runtime.getURL("index.html") - browser.tabs.create({ url }) - } - }) +/** TODO: refactor: remove this facade */ +export function initOnboarding() { + return { + onboardingWorker, + } } diff --git a/packages/extension/src/background/recoveryMessaging.ts b/packages/extension/src/background/recoveryMessaging.ts deleted file mode 100644 index 2c6d627ad..000000000 --- a/packages/extension/src/background/recoveryMessaging.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { compactDecrypt } from "jose" -import { encode } from "starknet" - -import { getAccounts } from "../shared/account/store" -import { RecoveryMessage } from "../shared/messages/RecoveryMessage" -import { sendMessageToUi } from "./activeTabs" -import { UnhandledMessage } from "./background" -import { HandleMessage } from "./background" -import { downloadFile } from "./download" - -export const handleRecoveryMessage: HandleMessage = async ({ - msg, - messagingKeys: { privateKey }, - background: { wallet, transactionTracker }, -}) => { - switch (msg.type) { - case "RECOVER_BACKUP": { - try { - await wallet.importBackup(msg.data) - return sendMessageToUi({ type: "RECOVER_BACKUP_RES" }) - } catch (error) { - return sendMessageToUi({ - type: "RECOVER_BACKUP_REJ", - data: `${error}`, - }) - } - } - - case "DOWNLOAD_BACKUP_FILE": { - await downloadFile(await wallet.exportBackup()) - return sendMessageToUi({ type: "DOWNLOAD_BACKUP_FILE_RES" }) - } - - case "RECOVER_SEEDPHRASE": { - try { - const { secure, body } = msg.data - if (secure !== true) { - throw Error("session can only be started with encryption") - } - const { plaintext } = await compactDecrypt(body, privateKey) - const { - seedPhrase, - newPassword, - }: { - seedPhrase: string - newPassword: string - } = JSON.parse(encode.arrayBufferToString(plaintext)) - - await wallet.restoreSeedPhrase(seedPhrase, newPassword) - transactionTracker.loadHistory(await getAccounts()) - - return sendMessageToUi({ type: "RECOVER_SEEDPHRASE_RES" }) - } catch (error) { - return sendMessageToUi({ - type: "RECOVER_SEEDPHRASE_REJ", - data: "Something went wrong in the seedphrase recovery process", - }) - } - } - } - - throw new UnhandledMessage() -} diff --git a/packages/extension/src/background/sessionMessaging.ts b/packages/extension/src/background/sessionMessaging.ts index 519375aa6..9096a75e9 100644 --- a/packages/extension/src/background/sessionMessaging.ts +++ b/packages/extension/src/background/sessionMessaging.ts @@ -1,7 +1,7 @@ import { compactDecrypt } from "jose" -import { encode } from "starknet" import { SessionMessage } from "../shared/messages/SessionMessage" +import { bytesToUft8 } from "../shared/utils/encode" import { sendMessageToUi } from "./activeTabs" import { UnhandledMessage } from "./background" import { HandleMessage } from "./background" @@ -19,7 +19,7 @@ export const handleSessionMessage: HandleMessage = async ({ throw Error("session can only be started with encryption") } const { plaintext } = await compactDecrypt(body, privateKey) - const sessionPassword = encode.arrayBufferToString(plaintext) + const sessionPassword = bytesToUft8(plaintext) const result = await wallet.startSession(sessionPassword, (percent) => { respond({ type: "LOADING_PROGRESS", data: percent }) }) @@ -36,7 +36,7 @@ export const handleSessionMessage: HandleMessage = async ({ case "CHECK_PASSWORD": { const { body } = msg.data const { plaintext } = await compactDecrypt(body, privateKey) - const password = encode.arrayBufferToString(plaintext) + const password = bytesToUft8(plaintext) if (await wallet.checkPassword(password)) { return sendMessageToUi({ type: "CHECK_PASSWORD_RES" }) } diff --git a/packages/extension/src/background/shieldMessaging.ts b/packages/extension/src/background/shieldMessaging.ts index 0b1617d0b..79272226c 100644 --- a/packages/extension/src/background/shieldMessaging.ts +++ b/packages/extension/src/background/shieldMessaging.ts @@ -6,7 +6,7 @@ import { getNetworkSelector, withGuardianSelector, } from "../shared/account/selectors" -import { getAccounts } from "../shared/account/store" +import { accountService } from "../shared/account/service" import { ShieldMessage } from "../shared/messages/ShieldMessage" import { addBackendAccount, @@ -52,10 +52,10 @@ export const handleShieldMessage: HandleMessage = async ({ /** Get current account state */ - const localAccounts = await getAccounts( + const localAccounts = await accountService.get( getNetworkSelector(ARGENT_SHIELD_NETWORK_ID), ) - const localAccountsWithGuardian = await getAccounts( + const localAccountsWithGuardian = await accountService.get( withGuardianSelector, ) const backendAccounts = await getBackendAccounts() @@ -121,7 +121,7 @@ export const handleShieldMessage: HandleMessage = async ({ privateKeyHex, ) - const { r, s } = Signature.fromHex(deploySignature) + const { r, s } = Signature.fromDER(deploySignature.toDERHex()) const response = await addBackendAccount( publicKey, selectedAccount.address, diff --git a/packages/extension/src/background/transactions/badgeText.ts b/packages/extension/src/background/transactions/badgeText.ts index 2e7e923b4..a677743d1 100644 --- a/packages/extension/src/background/transactions/badgeText.ts +++ b/packages/extension/src/background/transactions/badgeText.ts @@ -4,10 +4,18 @@ import { hideNotificationBadge, showNotificationBadge, } from "../../shared/browser/badgeText" +import { + MultisigPendingTransaction, + multisigPendingTransactionsStore, +} from "../../shared/multisig/pendingTransactionsStore" +import { getMultisigAccountFromBaseWallet } from "../../shared/multisig/utils/baseMultisig" import { Transaction } from "../../shared/transactions" -import { BaseWalletAccount } from "../../shared/wallet.model" +import { + BaseWalletAccount, + MultisigWalletAccount, +} from "../../shared/wallet.model" import { accountsEqual } from "../../shared/wallet.service" -import { walletStore } from "../../shared/wallet/walletStore" +import { old_walletStore } from "../../shared/wallet/walletStore" import { transactionsStore } from "./store" // selects transactions that are pending and match the provided account @@ -19,25 +27,60 @@ export const pendingAccountTransactionsSelector = memoize( accountsEqual(account, transaction.account), ) +export const multisigPendingTransactionSelector = memoize( + (multisig: MultisigWalletAccount) => + (transaction: MultisigPendingTransaction) => { + const transactionAccount = { + address: transaction.address, + networkId: transaction.networkId, + } + + return accountsEqual(multisig, transactionAccount) && transaction.notify + }, +) + // show count of pending transactions for current account export const updateBadgeText = async () => { - const selectedWalletAccount = await walletStore.get("selected") + const selectedWalletAccount = await old_walletStore.get("selected") + if (!selectedWalletAccount) { hideNotificationBadge() return } - const selector = pendingAccountTransactionsSelector(selectedWalletAccount) - const pendingAccountTransactions = await transactionsStore.get(selector) - if (pendingAccountTransactions.length) { - showNotificationBadge(pendingAccountTransactions.length) + + const multisig = await getMultisigAccountFromBaseWallet(selectedWalletAccount) + + const transactionSelector = pendingAccountTransactionsSelector( + selectedWalletAccount, + ) + const pendingAccountTransactions = await transactionsStore.get( + transactionSelector, + ) + + let multisigTransactionsLength = 0 + + if (multisig) { + const multisigTransactionSelector = + multisigPendingTransactionSelector(multisig) + + multisigTransactionsLength = ( + await multisigPendingTransactionsStore.get(multisigTransactionSelector) + ).length + } + + const badgeSize = + pendingAccountTransactions.length + multisigTransactionsLength + + if (badgeSize) { + showNotificationBadge(badgeSize) } else { hideNotificationBadge() } } export const initBadgeText = () => { - walletStore.subscribe("selected", () => { + old_walletStore.subscribe("selected", () => { updateBadgeText() }) @@ -45,5 +88,9 @@ export const initBadgeText = () => { updateBadgeText() }) + multisigPendingTransactionsStore.subscribe(() => { + updateBadgeText() + }) + updateBadgeText() } diff --git a/packages/extension/src/background/transactions/fees/multisigFeeEstimation.ts b/packages/extension/src/background/transactions/fees/multisigFeeEstimation.ts new file mode 100644 index 000000000..20c796d94 --- /dev/null +++ b/packages/extension/src/background/transactions/fees/multisigFeeEstimation.ts @@ -0,0 +1,32 @@ +import { AllowArray, Call, number } from "starknet" +import { Account as Accountv5, ec } from "starknet5" + +import { getProviderv5 } from "../../../shared/network/provider" +import { WalletAccount } from "../../../shared/wallet.model" + +export const getEstimatedFeeForMultisigTx = async ( + selectedAccount: WalletAccount, + transactions: AllowArray, + nonce?: number.BigNumberish, +) => { + const providerV5 = getProviderv5(selectedAccount.network) + + const accountv5 = new Accountv5( + providerV5, + selectedAccount.address, + ec.starkCurve.utils.randomPrivateKey(), // Random private key works cuz we skipValidation is true + ) + + const { suggestedMaxFee, overall_fee } = await accountv5.estimateInvokeFee( + transactions, + { + nonce, + skipValidate: true, + }, + ) + + return { + overall_fee: number.toBN(overall_fee.toString()), + suggestedMaxFee: number.toBN(suggestedMaxFee.toString()), + } +} diff --git a/packages/extension/src/background/transactions/onupdate/index.ts b/packages/extension/src/background/transactions/onupdate/index.ts index cc848a0f3..03453ef72 100644 --- a/packages/extension/src/background/transactions/onupdate/index.ts +++ b/packages/extension/src/background/transactions/onupdate/index.ts @@ -1,6 +1,7 @@ import { handleChangeGuardianTransaction } from "./changeGuardian" import { handleDeclareContractTransaction } from "./declareContract" import { handleDeployAccountTransaction } from "./deployAccount" +import { handleMultisigUpdates } from "./multisigUpdates" import { checkResetStoredNonce } from "./nonce" import { notifyAboutCompletedTransactions } from "./notifications" import { TransactionUpdateListener } from "./type" @@ -11,6 +12,7 @@ const addedOrUpdatedHandlers: TransactionUpdateListener[] = [ handleDeployAccountTransaction, handleDeclareContractTransaction, handleChangeGuardianTransaction, + handleMultisigUpdates, checkResetStoredNonce, ] diff --git a/packages/extension/src/background/transactions/onupdate/multisigUpdates.ts b/packages/extension/src/background/transactions/onupdate/multisigUpdates.ts new file mode 100644 index 000000000..554ad1333 --- /dev/null +++ b/packages/extension/src/background/transactions/onupdate/multisigUpdates.ts @@ -0,0 +1,15 @@ +import { updateMultisigAccountDetails } from "../../../shared/account/update" +import { MULTISG_TXN_TYPES, Transaction } from "../../../shared/transactions" +import { TransactionUpdateListener } from "./type" + +export const handleMultisigUpdates: TransactionUpdateListener = async ( + updates: Transaction[], +) => { + const multisigUpdates = updates.filter( + (t) => t.meta?.type && MULTISG_TXN_TYPES.includes(t.meta.type), + ) + + if (multisigUpdates.length > 0) { + await updateMultisigAccountDetails(multisigUpdates.map((t) => t.account)) + } +} diff --git a/packages/extension/src/background/transactions/onupdate/notifications.ts b/packages/extension/src/background/transactions/onupdate/notifications.ts index bb8e34085..1a48fc33e 100644 --- a/packages/extension/src/background/transactions/onupdate/notifications.ts +++ b/packages/extension/src/background/transactions/onupdate/notifications.ts @@ -1,9 +1,9 @@ -import { SUCCESS_STATUSES } from "../../../shared/transactions" +import { FAILED_STATUS, SUCCESS_STATUSES } from "../../../shared/transactions" import { decrementTransactionsBeforeReview } from "../../../shared/userReview" import { addToAlreadyShown, hasShownNotification, - sentTransactionNotification, + sendTransactionNotification, } from "../../notification" import { TransactionUpdateListener } from "./type" @@ -12,14 +12,14 @@ export const notifyAboutCompletedTransactions: TransactionUpdateListener = for (const transaction of transactions) { const { hash, status, meta, account } = transaction if ( - (SUCCESS_STATUSES.includes(status) || status === "REJECTED") && + (SUCCESS_STATUSES.includes(status) || FAILED_STATUS.includes(status)) && !(await hasShownNotification(hash)) ) { addToAlreadyShown(hash) if (!account.hidden && !meta?.isDeployAccount) { await decrementTransactionsBeforeReview() - sentTransactionNotification(hash, status, meta) + sendTransactionNotification(hash, status, meta) } } } diff --git a/packages/extension/src/background/transactions/onupdate/type.ts b/packages/extension/src/background/transactions/onupdate/type.ts index b7e404f94..4c4b7e7d1 100644 --- a/packages/extension/src/background/transactions/onupdate/type.ts +++ b/packages/extension/src/background/transactions/onupdate/type.ts @@ -1,3 +1,5 @@ import type { Transaction } from "../../../shared/transactions" -export type TransactionUpdateListener = (updates: Transaction[]) => void +export type TransactionUpdateListener = ( + updates: Transaction[], +) => void | Promise diff --git a/packages/extension/src/background/transactions/store.ts b/packages/extension/src/background/transactions/store.ts index daf0e0d64..3d93c9bd2 100644 --- a/packages/extension/src/background/transactions/store.ts +++ b/packages/extension/src/background/transactions/store.ts @@ -3,6 +3,7 @@ import { differenceWith } from "lodash-es" import { ArrayStorage } from "../../shared/storage" import { StorageChange } from "../../shared/storage/types" import { + ExtendedTransactionStatus, Transaction, TransactionRequest, compareTransactions, @@ -18,14 +19,19 @@ export const transactionsStore = new ArrayStorage([], { const timestampInSeconds = (): number => Math.floor(Date.now() / 1000) -export const addTransaction = async (transaction: TransactionRequest) => { +export const addTransaction = async ( + transaction: TransactionRequest, + status?: ExtendedTransactionStatus, +) => { // sanity checks if (!checkTransactionHash(transaction.hash)) { return // dont throw } + const defaultStatus: ExtendedTransactionStatus = "RECEIVED" + const newTransaction = { - status: "RECEIVED" as const, + status: status ?? defaultStatus, timestamp: timestampInSeconds(), ...transaction, } diff --git a/packages/extension/src/background/transactions/transactionExecution.ts b/packages/extension/src/background/transactions/transactionExecution.ts index e5f57e746..1592e46b2 100644 --- a/packages/extension/src/background/transactions/transactionExecution.ts +++ b/packages/extension/src/background/transactions/transactionExecution.ts @@ -1,5 +1,6 @@ import { BigNumber } from "ethers" import { + Account, Call, EstimateFee, TransactionBulk, @@ -22,18 +23,20 @@ import { analytics } from "../analytics" import { BackgroundService } from "../background" import { getNonce, increaseStoredNonce, resetStoredNonce } from "../nonce" import { argentMaxFee } from "../utils/argentMaxFee" +import { getEstimatedFeeForMultisigTx } from "./fees/multisigFeeEstimation" import { getEstimatedFees } from "./fees/store" import { addTransaction, transactionsStore } from "./store" export const checkTransactionHash = ( transactionHash?: number.BigNumberish, + account?: WalletAccount, ): boolean => { try { if (!transactionHash) { throw Error("transactionHash not defined") } const bn = number.toBN(transactionHash) - if (bn.lte(constants.ZERO)) { + if (bn.lte(constants.ZERO) && account?.type !== "multisig") { throw Error("transactionHash needs to be >0") } return true @@ -99,10 +102,15 @@ export const executeTransactionAction = async ( !(await isAccountDeployed(selectedAccount, starknetAccount.getClassAt)) ) { if ("estimateFeeBulk" in starknetAccount) { + const deployAccountPayload = + selectedAccount.type === "multisig" + ? await wallet.getMultisigDeploymentPayload(selectedAccount) + : await wallet.getAccountDeploymentPayload(selectedAccount) + const bulkTransactions: TransactionBulk = [ { type: "DEPLOY_ACCOUNT", - payload: await wallet.getAccountDeploymentPayload(selectedAccount), + payload: deployAccountPayload, }, { type: "INVOKE_FUNCTION", @@ -123,7 +131,6 @@ export const executeTransactionAction = async ( const { account, txHash } = await wallet.deployAccount(selectedAccount, { maxFee: maxADFee, }) - if (!checkTransactionHash(txHash)) { throw Error( "Deploy Account Transaction could not get added to the sequencer", @@ -146,6 +153,14 @@ export const executeTransactionAction = async ( type: "DEPLOY_ACCOUNT", }, }) + } else if (selectedAccount.type === "multisig") { + const { suggestedMaxFee } = await getEstimatedFeeForMultisigTx( + selectedAccount, + transactions, + nonce, + ) + + maxFee = argentMaxFee(suggestedMaxFee) } else { if (hasUpgradePending && !preComputedFees?.suggestedMaxFee) { const oldStarknetAccount = await wallet.getStarknetAccount( @@ -167,30 +182,39 @@ export const executeTransactionAction = async ( } } - const transaction = await starknetAccount.execute(transactions, abis, { + const acc = + selectedAccount.type === "multisig" && starknetAccount instanceof Account // Multisig uses latest account interface + ? wallet.getStarknetAccountOfType(starknetAccount, "multisig") + : starknetAccount + const transaction = await acc.execute(transactions, abis, { ...transactionsDetail, nonce, maxFee, }) - if (!checkTransactionHash(transaction.transaction_hash)) { + if (!checkTransactionHash(transaction.transaction_hash, selectedAccount)) { throw Error("Transaction could not get added to the sequencer") } const title = nameTransaction(transactions) - await addTransaction({ - hash: transaction.transaction_hash, - account: selectedAccount, - meta: { - ...meta, - title, - transactions, - type: "DEPLOY_ACCOUNT", - }, - }) + // TODO: Remove this conditional as we now fallback to computed transactionHash for multisig + // So we can always add the transaction to the queue. The added transaction will have + // status "NOT_RECEIVED" until all the owners have signed the transaction + if (selectedAccount.type !== "multisig") { + await addTransaction({ + hash: transaction.transaction_hash, + account: selectedAccount, + meta: { + ...meta, + title, + transactions, + type: "INVOKE_FUNCTION", + }, + }) + } - if (!nonceWasProvidedByUI) { + if (!nonceWasProvidedByUI && selectedAccount.type !== "multisig") { await increaseStoredNonce(selectedAccount) } diff --git a/packages/extension/src/background/transactions/transactionMessaging.ts b/packages/extension/src/background/transactions/transactionMessaging.ts index a805d5ebf..2d029fada 100644 --- a/packages/extension/src/background/transactions/transactionMessaging.ts +++ b/packages/extension/src/background/transactions/transactionMessaging.ts @@ -11,413 +11,355 @@ import { TransactionMessage } from "../../shared/messages/TransactionMessage" import { isAccountDeployed } from "../accountDeploy" import { HandleMessage, UnhandledMessage } from "../background" import { argentMaxFee } from "../utils/argentMaxFee" +import { getEstimatedFeeForMultisigTx } from "./fees/multisigFeeEstimation" import { addEstimatedFees } from "./fees/store" -export const handleTransactionMessage: HandleMessage = - async ({ msg, background: { wallet, actionQueue }, respond: respond }) => { - switch (msg.type) { - case "EXECUTE_TRANSACTION": { - const { meta } = await actionQueue.push({ - type: "TRANSACTION", - payload: msg.data, - }) - return respond({ - type: "EXECUTE_TRANSACTION_RES", - data: { actionHash: meta.hash }, - }) - } +export const handleTransactionMessage: HandleMessage< + TransactionMessage +> = async ({ msg, background: { wallet, actionQueue }, respond: respond }) => { + switch (msg.type) { + case "EXECUTE_TRANSACTION": { + const { meta } = await actionQueue.push({ + type: "TRANSACTION", + payload: msg.data, + }) + return respond({ + type: "EXECUTE_TRANSACTION_RES", + data: { actionHash: meta.hash }, + }) + } - case "ESTIMATE_TRANSACTION_FEE": { - const selectedAccount = await wallet.getSelectedAccount() - const starknetAccount = await wallet.getSelectedStarknetAccount() - const transactions = msg.data + case "ESTIMATE_TRANSACTION_FEE": { + const selectedAccount = await wallet.getSelectedAccount() + const starknetAccount = await wallet.getSelectedStarknetAccount() + const transactions = msg.data - if (!selectedAccount) { - throw Error("no accounts") - } - try { - let txFee = "0", - maxTxFee = "0", - accountDeploymentFee: string | undefined, - maxADFee: string | undefined - - if ( - selectedAccount.needsDeploy && - !(await isAccountDeployed( - selectedAccount, - starknetAccount.getClassAt, - )) - ) { - if ("estimateFeeBulk" in starknetAccount) { - const bulkTransactions: TransactionBulk = [ - { - type: "DEPLOY_ACCOUNT", - payload: await wallet.getAccountDeploymentPayload( - selectedAccount, - ), - }, - { - type: "INVOKE_FUNCTION", - payload: transactions, - }, - ] + if (!selectedAccount) { + throw Error("no accounts") + } + try { + let txFee = "0", + maxTxFee = "0", + accountDeploymentFee: string | undefined, + maxADFee: string | undefined - const estimateFeeBulk = await starknetAccount.estimateFeeBulk( - bulkTransactions, - ) + if ( + selectedAccount.needsDeploy && + !(await isAccountDeployed( + selectedAccount, + starknetAccount.getClassAt, + )) + ) { + if ("estimateFeeBulk" in starknetAccount) { + const bulkTransactions: TransactionBulk = [ + { + type: "DEPLOY_ACCOUNT", + payload: await wallet.getAccountDeploymentPayload( + selectedAccount, + ), + }, + { + type: "INVOKE_FUNCTION", + payload: transactions, + }, + ] + + const estimateFeeBulk = await starknetAccount.estimateFeeBulk( + bulkTransactions, + ) + + accountDeploymentFee = number.toHex(estimateFeeBulk[0].overall_fee) + txFee = number.toHex(estimateFeeBulk[1].overall_fee) + + maxADFee = argentMaxFee(estimateFeeBulk[0].suggestedMaxFee) + maxTxFee = argentMaxFee(estimateFeeBulk[1].suggestedMaxFee) + } + } else if (selectedAccount.type === "multisig") { + const { overall_fee, suggestedMaxFee } = + await getEstimatedFeeForMultisigTx(selectedAccount, transactions) + txFee = number.toHex(overall_fee) + maxTxFee = number.toHex(suggestedMaxFee) + } else { + const { overall_fee, suggestedMaxFee } = + await starknetAccount.estimateFee(transactions) - accountDeploymentFee = number.toHex( - estimateFeeBulk[0].overall_fee, - ) - txFee = number.toHex(estimateFeeBulk[1].overall_fee) + txFee = number.toHex(overall_fee) + maxTxFee = number.toHex(suggestedMaxFee) // Here, maxFee = estimatedFee * 1.5x + } - maxADFee = argentMaxFee(estimateFeeBulk[0].suggestedMaxFee) - maxTxFee = argentMaxFee(estimateFeeBulk[1].suggestedMaxFee) - } - } else { - const { overall_fee, suggestedMaxFee } = - await starknetAccount.estimateFee(transactions) + const suggestedMaxFee = argentMaxFee(maxTxFee) - txFee = number.toHex(overall_fee) - maxTxFee = number.toHex(suggestedMaxFee) // Here, maxFee = estimatedFee * 1.5x - } - - const suggestedMaxFee = number.toHex( - stark.estimatedFeeToMaxFee(maxTxFee, 1), // This adds the 3x overhead. i.e: suggestedMaxFee = maxFee * 2x = estimatedFee * 3x - ) - addEstimatedFees({ + addEstimatedFees({ + amount: txFee, + suggestedMaxFee, + accountDeploymentFee, + maxADFee, + transactions, + }) + return respond({ + type: "ESTIMATE_TRANSACTION_FEE_RES", + data: { amount: txFee, suggestedMaxFee, accountDeploymentFee, maxADFee, - transactions, - }) - return respond({ - type: "ESTIMATE_TRANSACTION_FEE_RES", - data: { - amount: txFee, - suggestedMaxFee, - accountDeploymentFee, - maxADFee, - }, - }) - } catch (error) { - console.error(error) - return respond({ - type: "ESTIMATE_TRANSACTION_FEE_REJ", - data: { - error: - (error as any)?.message?.toString?.() ?? - (error as any)?.toString?.() ?? - "Unkown error", - }, - }) - } + }, + }) + } catch (error) { + console.error(error) + return respond({ + type: "ESTIMATE_TRANSACTION_FEE_REJ", + data: { + error: + (error as any)?.message?.toString?.() ?? + (error as any)?.toString?.() ?? + "Unkown error", + }, + }) } + } - case "ESTIMATE_ACCOUNT_DEPLOYMENT_FEE": { - const providedAccount = msg.data - const account = providedAccount - ? await wallet.getAccount(providedAccount) - : await wallet.getSelectedAccount() + case "ESTIMATE_ACCOUNT_DEPLOYMENT_FEE": { + const providedAccount = msg.data + const account = providedAccount + ? await wallet.getAccount(providedAccount) + : await wallet.getSelectedAccount() - if (!account) { - throw Error("no accounts") - } + if (!account) { + throw Error("no accounts") + } - try { - const { overall_fee, suggestedMaxFee } = - await wallet.getAccountDeploymentFee(account) + try { + const { overall_fee, suggestedMaxFee } = + await wallet.getAccountDeploymentFee(account) - const maxADFee = number.toHex( - stark.estimatedFeeToMaxFee(suggestedMaxFee, 1), // This adds the 3x overhead. i.e: suggestedMaxFee = maxFee * 2x = estimatedFee * 3x - ) + const maxADFee = number.toHex( + stark.estimatedFeeToMaxFee(suggestedMaxFee, 1), // This adds the 3x overhead. i.e: suggestedMaxFee = maxFee * 2x = estimatedFee * 3x + ) + return respond({ + type: "ESTIMATE_ACCOUNT_DEPLOYMENT_FEE_RES", + data: { + amount: number.toHex(overall_fee), + maxADFee, + }, + }) + } catch (error) { + // FIXME: This is a temporary fix for the case where the user has a multisig account. + // Once starknet 0.11 is released, we can remove this. + if (account.type === "multisig") { + const fallbackPrice = number.toBN(10e14) return respond({ type: "ESTIMATE_ACCOUNT_DEPLOYMENT_FEE_RES", data: { - amount: number.toHex(overall_fee), - maxADFee, - }, - }) - } catch (error) { - console.error(error) - return respond({ - type: "ESTIMATE_ACCOUNT_DEPLOYMENT_FEE_REJ", - data: { - error: - (error as any)?.message?.toString?.() ?? - (error as any)?.toString?.() ?? - "Unkown error", + amount: number.toHex(fallbackPrice), + maxADFee: argentMaxFee(fallbackPrice), }, }) } + + console.error(error) + return respond({ + type: "ESTIMATE_ACCOUNT_DEPLOYMENT_FEE_REJ", + data: { + error: + (error as any)?.message?.toString?.() ?? + (error as any)?.toString?.() ?? + "Unkown error", + }, + }) } + } - case "ESTIMATE_DECLARE_CONTRACT_FEE": { - const { classHash, contract, ...restData } = msg.data + case "ESTIMATE_DECLARE_CONTRACT_FEE": { + const { classHash, contract, ...restData } = msg.data - const selectedAccount = await wallet.getSelectedAccount() - const selectedStarknetAccount = - "address" in restData - ? await wallet.getStarknetAccount(restData) - : await wallet.getSelectedStarknetAccount() + const selectedAccount = await wallet.getSelectedAccount() + const selectedStarknetAccount = + "address" in restData + ? await wallet.getStarknetAccount(restData) + : await wallet.getSelectedStarknetAccount() - if (!selectedStarknetAccount) { - throw Error("no accounts") - } - - let txFee = "0", - maxTxFee = "0", - accountDeploymentFee: string | undefined, - maxADFee: string | undefined + if (!selectedStarknetAccount) { + throw Error("no accounts") + } - try { - if ( - selectedAccount?.needsDeploy && - !(await isAccountDeployed( - selectedAccount, - selectedStarknetAccount.getClassAt, - )) - ) { - if ("estimateFeeBulk" in selectedStarknetAccount) { - const bulkTransactions: TransactionBulk = [ - { - type: "DEPLOY_ACCOUNT", - payload: await wallet.getAccountDeploymentPayload( - selectedAccount, - ), - }, - { - type: "DECLARE", - payload: { - classHash, - contract, - }, + let txFee = "0", + maxTxFee = "0", + accountDeploymentFee: string | undefined, + maxADFee: string | undefined + + try { + if ( + selectedAccount?.needsDeploy && + !(await isAccountDeployed( + selectedAccount, + selectedStarknetAccount.getClassAt, + )) + ) { + if ("estimateFeeBulk" in selectedStarknetAccount) { + const deployPayload = + selectedAccount.type === "multisig" + ? await wallet.getMultisigDeploymentPayload(selectedAccount) + : await wallet.getAccountDeploymentPayload(selectedAccount) + const bulkTransactions: TransactionBulk = [ + { + type: "DEPLOY_ACCOUNT", + payload: deployPayload, + }, + { + type: "DECLARE", + payload: { + classHash, + contract, }, - ] + }, + ] - const estimateFeeBulk = - await selectedStarknetAccount.estimateFeeBulk(bulkTransactions) + const estimateFeeBulk = + await selectedStarknetAccount.estimateFeeBulk(bulkTransactions) - accountDeploymentFee = number.toHex( - estimateFeeBulk[0].overall_fee, - ) - txFee = number.toHex(estimateFeeBulk[1].overall_fee) + accountDeploymentFee = number.toHex(estimateFeeBulk[0].overall_fee) + txFee = number.toHex(estimateFeeBulk[1].overall_fee) - maxADFee = argentMaxFee(estimateFeeBulk[0].suggestedMaxFee) - maxTxFee = estimateFeeBulk[1].suggestedMaxFee - } + maxADFee = argentMaxFee(estimateFeeBulk[0].suggestedMaxFee) + maxTxFee = estimateFeeBulk[1].suggestedMaxFee + } + } else { + if ("estimateDeclareFee" in selectedStarknetAccount) { + const { overall_fee, suggestedMaxFee } = + await selectedStarknetAccount.estimateDeclareFee({ + classHash, + contract, + }) + txFee = number.toHex(overall_fee) + maxTxFee = number.toHex(suggestedMaxFee) } else { - if ("estimateDeclareFee" in selectedStarknetAccount) { - const { overall_fee, suggestedMaxFee } = - await selectedStarknetAccount.estimateDeclareFee({ - classHash, - contract, - }) - txFee = number.toHex(overall_fee) - maxTxFee = number.toHex(suggestedMaxFee) - } else { - throw Error("estimateDeclareFee not supported") - } + throw Error("estimateDeclareFee not supported") } - - const suggestedMaxFee = argentMaxFee(maxTxFee) // This add the 3x overhead. i.e: suggestedMaxFee = maxFee * 2x = estimatedFee * 3x - - return respond({ - type: "ESTIMATE_DECLARE_CONTRACT_FEE_RES", - data: { - amount: txFee, - suggestedMaxFee, - accountDeploymentFee, - maxADFee, - }, - }) - } catch (error) { - console.error(error) - return respond({ - type: "ESTIMATE_DECLARE_CONTRACT_FEE_REJ", - data: { - error: - (error as any)?.message?.toString?.() ?? - (error as any)?.toString?.() ?? - "Unkown error", - }, - }) } - } - case "ESTIMATE_DEPLOY_CONTRACT_FEE": { - const { classHash, constructorCalldata, salt, unique } = msg.data + const suggestedMaxFee = argentMaxFee(maxTxFee) // This add the 3x overhead. i.e: suggestedMaxFee = maxFee * 2x = estimatedFee * 3x - const selectedAccount = await wallet.getSelectedAccount() - const selectedStarknetAccount = - await wallet.getSelectedStarknetAccount() - - if (!selectedStarknetAccount || !selectedAccount) { - throw Error("no accounts") - } - - let txFee = "0", - maxTxFee = "0", - accountDeploymentFee: string | undefined, - maxADFee: string | undefined + return respond({ + type: "ESTIMATE_DECLARE_CONTRACT_FEE_RES", + data: { + amount: txFee, + suggestedMaxFee, + accountDeploymentFee, + maxADFee, + }, + }) + } catch (error) { + console.error(error) + return respond({ + type: "ESTIMATE_DECLARE_CONTRACT_FEE_REJ", + data: { + error: + (error as any)?.message?.toString?.() ?? + (error as any)?.toString?.() ?? + "Unkown error", + }, + }) + } + } - try { - if ( - selectedAccount?.needsDeploy && - !(await isAccountDeployed( - selectedAccount, - selectedStarknetAccount.getClassAt, - )) - ) { - if ("estimateFeeBulk" in selectedStarknetAccount) { - const bulkTransactions: TransactionBulk = [ - { - type: "DEPLOY_ACCOUNT", - payload: await wallet.getAccountDeploymentPayload( - selectedAccount, - ), - }, - { - type: "DEPLOY", - payload: { - classHash, - salt, - unique, - constructorCalldata, - }, - }, - ] + case "ESTIMATE_DEPLOY_CONTRACT_FEE": { + const { classHash, constructorCalldata, salt, unique } = msg.data - const estimateFeeBulk = - await selectedStarknetAccount.estimateFeeBulk(bulkTransactions) + const selectedAccount = await wallet.getSelectedAccount() + const selectedStarknetAccount = await wallet.getSelectedStarknetAccount() - accountDeploymentFee = number.toHex( - estimateFeeBulk[0].overall_fee, - ) - txFee = number.toHex(estimateFeeBulk[1].overall_fee) + if (!selectedStarknetAccount || !selectedAccount) { + throw Error("no accounts") + } - maxADFee = argentMaxFee(estimateFeeBulk[0].suggestedMaxFee) - maxTxFee = estimateFeeBulk[1].suggestedMaxFee - } - } else { - if ("estimateDeployFee" in selectedStarknetAccount) { - const { overall_fee, suggestedMaxFee } = - await selectedStarknetAccount.estimateDeployFee({ + let txFee = "0", + maxTxFee = "0", + accountDeploymentFee: string | undefined, + maxADFee: string | undefined + + try { + if ( + selectedAccount?.needsDeploy && + !(await isAccountDeployed( + selectedAccount, + selectedStarknetAccount.getClassAt, + )) + ) { + if ("estimateFeeBulk" in selectedStarknetAccount) { + const bulkTransactions: TransactionBulk = [ + { + type: "DEPLOY_ACCOUNT", + payload: await wallet.getAccountDeploymentPayload( + selectedAccount, + ), + }, + { + type: "DEPLOY", + payload: { classHash, salt, unique, constructorCalldata, - }) - txFee = number.toHex(overall_fee) - maxTxFee = number.toHex(suggestedMaxFee) - } else { - throw Error("estimateDeployFee not supported") - } - } - - const suggestedMaxFee = argentMaxFee(maxTxFee) // This adds the 3x overhead. i.e: suggestedMaxFee = maxFee * 2x = estimatedFee * 3x - - return respond({ - type: "ESTIMATE_DEPLOY_CONTRACT_FEE_RES", - data: { - amount: txFee, - suggestedMaxFee, - accountDeploymentFee, - maxADFee, - }, - }) - } catch (error) { - console.log(error) - return respond({ - type: "ESTIMATE_DEPLOY_CONTRACT_FEE_REJ", - data: { - error: - (error as any)?.message?.toString() ?? - (error as any)?.toString() ?? - "Unkown error", - }, - }) - } - } + }, + }, + ] - case "SIMULATE_TRANSACTION_INVOCATION": { - const transactions = Array.isArray(msg.data) ? msg.data : [msg.data] + const estimateFeeBulk = + await selectedStarknetAccount.estimateFeeBulk(bulkTransactions) - try { - const selectedAccount = await wallet.getSelectedAccount() - const starknetAccount = - (await wallet.getSelectedStarknetAccount()) as Account // Old accounts are not supported + accountDeploymentFee = number.toHex(estimateFeeBulk[0].overall_fee) + txFee = number.toHex(estimateFeeBulk[1].overall_fee) - if (!selectedAccount) { - throw Error("no accounts") + maxADFee = argentMaxFee(estimateFeeBulk[0].suggestedMaxFee) + maxTxFee = estimateFeeBulk[1].suggestedMaxFee } - - const nonce = await starknetAccount.getNonce() - - const chainId = starknetAccount.chainId - - const version = number.toHex(hash.feeTransactionVersion) - - const signerDetails: InvocationsSignerDetails = { - walletAddress: starknetAccount.address, - nonce, - maxFee: 0, - version, - chainId, + } else { + if ("estimateDeployFee" in selectedStarknetAccount) { + const { overall_fee, suggestedMaxFee } = + await selectedStarknetAccount.estimateDeployFee({ + classHash, + salt, + unique, + constructorCalldata, + }) + txFee = number.toHex(overall_fee) + maxTxFee = number.toHex(suggestedMaxFee) + } else { + throw Error("estimateDeployFee not supported") } + } - // TODO: Use this when Simulate Transaction allows multiple transaction types - // const signerDetailsWithZeroNonce = { - // ...signerDetails, - // nonce: 0, - // } - - // const accountDeployPayload = await wallet.getAccountDeploymentPayload( - // selectedAccount, - // ) - - // const accountDeployInvocation = - // await starknetAccount.buildAccountDeployPayload( - // accountDeployPayload, - // signerDetailsWithZeroNonce, - // ) - - const { contractAddress, calldata, signature } = - await starknetAccount.buildInvocation(transactions, signerDetails) - - const invocation = { - type: "INVOKE_FUNCTION" as const, - contract_address: contractAddress, - calldata, - signature, - nonce, - version, - } + const suggestedMaxFee = argentMaxFee(maxTxFee) // This adds the 3x overhead. i.e: suggestedMaxFee = maxFee * 2x = estimatedFee * 3x - return respond({ - type: "SIMULATE_TRANSACTION_INVOCATION_RES", - data: { - invocation, - chainId, - }, - }) - } catch (error) { - console.log(error) - return respond({ - type: "SIMULATE_TRANSACTION_INVOCATION_REJ", - data: { - error: - (error as any)?.message?.toString() ?? - (error as any)?.toString() ?? - "Unkown error", - }, - }) - } + return respond({ + type: "ESTIMATE_DEPLOY_CONTRACT_FEE_RES", + data: { + amount: txFee, + suggestedMaxFee, + accountDeploymentFee, + maxADFee, + }, + }) + } catch (error) { + console.log(error) + return respond({ + type: "ESTIMATE_DEPLOY_CONTRACT_FEE_REJ", + data: { + error: + (error as any)?.message?.toString() ?? + (error as any)?.toString() ?? + "Unkown error", + }, + }) } + } + + case "SIMULATE_TRANSACTION_INVOCATION": { + const transactions = Array.isArray(msg.data) ? msg.data : [msg.data] - case "SIMULATE_TRANSACTION_FALLBACK": { + try { const selectedAccount = await wallet.getSelectedAccount() const starknetAccount = (await wallet.getSelectedStarknetAccount()) as Account // Old accounts are not supported @@ -428,35 +370,103 @@ export const handleTransactionMessage: HandleMessage = const nonce = await starknetAccount.getNonce() - try { - const simulated = await starknetAccount.simulateTransaction( - msg.data, - { - nonce, - }, - ) + const chainId = starknetAccount.chainId - return respond({ - type: "SIMULATE_TRANSACTION_FALLBACK_RES", - data: simulated, - }) - } catch (error) { - return respond({ - type: "SIMULATE_TRANSACTION_FALLBACK_REJ", - data: { - error: - (error as any)?.message?.toString() ?? - (error as any)?.toString() ?? - "Unkown error", - }, - }) + const version = number.toHex(hash.feeTransactionVersion) + + const signerDetails: InvocationsSignerDetails = { + walletAddress: starknetAccount.address, + nonce, + maxFee: 0, + version, + chainId, + } + + // TODO: Use this when Simulate Transaction allows multiple transaction types + // const signerDetailsWithZeroNonce = { + // ...signerDetails, + // nonce: 0, + // } + + // const accountDeployPayload = await wallet.getAccountDeploymentPayload( + // selectedAccount, + // ) + + // const accountDeployInvocation = + // await starknetAccount.buildAccountDeployPayload( + // accountDeployPayload, + // signerDetailsWithZeroNonce, + // ) + + const { contractAddress, calldata, signature } = + await starknetAccount.buildInvocation(transactions, signerDetails) + + const invocation = { + type: "INVOKE_FUNCTION" as const, + contract_address: contractAddress, + calldata, + signature, + nonce, + version, } - } - case "TRANSACTION_FAILED": { - return await actionQueue.remove(msg.data.actionHash) + return respond({ + type: "SIMULATE_TRANSACTION_INVOCATION_RES", + data: { + invocation, + chainId, + }, + }) + } catch (error) { + console.log(error) + return respond({ + type: "SIMULATE_TRANSACTION_INVOCATION_REJ", + data: { + error: + (error as any)?.message?.toString() ?? + (error as any)?.toString() ?? + "Unkown error", + }, + }) } } - throw new UnhandledMessage() + case "TRANSACTION_FAILED": { + return await actionQueue.remove(msg.data.actionHash) + } + + case "SIMULATE_TRANSACTION_FALLBACK": { + const selectedAccount = await wallet.getSelectedAccount() + const starknetAccount = + (await wallet.getSelectedStarknetAccount()) as Account // Old accounts are not supported + + if (!selectedAccount) { + throw Error("no accounts") + } + + const nonce = await starknetAccount.getNonce() + + try { + const simulated = await starknetAccount.simulateTransaction(msg.data, { + nonce, + }) + + return respond({ + type: "SIMULATE_TRANSACTION_FALLBACK_RES", + data: simulated, + }) + } catch (error) { + return respond({ + type: "SIMULATE_TRANSACTION_FALLBACK_REJ", + data: { + error: + (error as any)?.message?.toString() ?? + (error as any)?.toString() ?? + "Unkown error", + }, + }) + } + } } + throw new UnhandledMessage() +} diff --git a/packages/extension/src/background/udcMessaging.ts b/packages/extension/src/background/udcMessaging.ts index 505b31496..f80b724b5 100644 --- a/packages/extension/src/background/udcMessaging.ts +++ b/packages/extension/src/background/udcMessaging.ts @@ -5,13 +5,10 @@ import { HandleMessage, UnhandledMessage } from "./background" export const handleUdcMessaging: HandleMessage = async ({ msg, - background, + background: { actionQueue, wallet }, respond, }) => { - const { actionQueue, wallet } = background - const { type } = msg - - switch (type) { + switch (msg.type) { case "REQUEST_DECLARE_CONTRACT": { const { data } = msg const { classHash, contract, ...restData } = data diff --git a/packages/extension/src/background/wallet.ts b/packages/extension/src/background/wallet.ts index fcad33993..18b9dff23 100644 --- a/packages/extension/src/background/wallet.ts +++ b/packages/extension/src/background/wallet.ts @@ -1,8 +1,17 @@ -import { ethers } from "ethers" +import { ethers, utils } from "ethers" import { ProgressCallback } from "ethers/lib/utils" -import { find, memoize, noop, throttle, union } from "lodash-es" +import { + find, + isEmpty, + memoize, + noop, + partition, + throttle, + union, +} from "lodash-es" import { Account, + DeployAccountContractPayload, DeployAccountContractTransaction, EstimateFee, InvocationsDetails, @@ -16,7 +25,16 @@ import { import { Account as Accountv4 } from "starknet4" import browser from "webextension-polyfill" -import { ArgentAccountType } from "./../shared/wallet.model" +import { updateAccountsWithNames } from "./../shared/account/details/updateAccountsWithNames" +import { sortByDerivationPath } from "./../shared/utils/accountsMultisigSort" +import { + ArgentAccountType, + BaseMultisigWalletAccount, + CreateAccountType, + CreateWalletAccount, + MultisigData, + MultisigWalletAccount, +} from "./../shared/wallet.model" import { getAccountEscapeFromChain } from "../shared/account/details/getAccountEscapeFromChain" import { getAccountGuardiansFromChain } from "../shared/account/details/getAccountGuardiansFromChain" import { getAccountTypesFromChain } from "../shared/account/details/getAccountTypesFromChain" @@ -26,6 +44,11 @@ import { } from "../shared/account/details/getAndMergeAccountDetails" import { withHiddenSelector } from "../shared/account/selectors" import { getMulticallForNetwork } from "../shared/multicall" +import { MultisigAccount } from "../shared/multisig/account" +import { fetchMultisigDataForSigner } from "../shared/multisig/multisig.service" +import { MultisigSigner } from "../shared/multisig/signer" +import { PendingMultisig } from "../shared/multisig/types" +import { getMultisigAccountFromBaseWallet } from "../shared/multisig/utils/baseMultisig" import { Network, defaultNetwork, @@ -33,7 +56,6 @@ import { getProvider, } from "../shared/network" import { getProviderv4 } from "../shared/network/provider" -import { mapArgentAccountTypeToImplementationKey } from "../shared/network/utils" import { cosignerSign } from "../shared/shield/backend/account" import { ARGENT_SHIELD_ENABLED } from "../shared/shield/constants" import { GuardianSelfSigner } from "../shared/shield/GuardianSelfSigner" @@ -96,11 +118,6 @@ export interface WalletStorageProps { selected?: BaseWalletAccount | null discoveredOnce?: boolean } -/* -export const walletStore = new KeyValueStorage( - {}, - "core:wallet", -) */ export const sessionStore = new ObjectStorage(null, { namespace: "core:wallet:session", @@ -132,6 +149,8 @@ export class Wallet { private readonly store: IKeyValueStorage, private readonly walletStore: IArrayStorage, private readonly sessionStore: IObjectStorage, + private readonly multisigStore: IArrayStorage, + private readonly pendingMultisigStore: IArrayStorage, private readonly loadContracts: LoadContracts, private readonly getNetwork: GetNetwork, ) {} @@ -227,11 +246,10 @@ export class Wallet { network: Network, accountType: ArgentAccountType, ): Promise { - if (network.accountClassHash) { + if (network.accountClassHash && network.accountClassHash.standard) { return ( - network.accountClassHash[ - mapArgentAccountTypeToImplementationKey(accountType) - ] ?? network.accountClassHash.argentAccount + network.accountClassHash[accountType] ?? + network.accountClassHash.standard ) } @@ -258,13 +276,21 @@ export class Wallet { const accounts: WalletAccount[] = [] - const networkAccountClassHash = await this.getAccountClassHashForNetwork( + const standardAccountClassHash = await this.getAccountClassHashForNetwork( network, - "argent", + "standard", + ) + + // This will be a standard account hash if multisig is not supported on the network + // It will be handled by the union function below + const multisigAccountClassHash = await this.getAccountClassHashForNetwork( + network, + "multisig", ) const accountClassHashes = union(ARGENT_ACCOUNT_CONTRACT_CLASS_HASHES, [ - networkAccountClassHash, + standardAccountClassHash, + multisigAccountClassHash, ]) const proxyClassHashes = PROXY_CONTRACT_CLASS_HASHES @@ -303,21 +329,59 @@ export class Wallet { 0, ) + const account: WalletAccount = { + name: "Unnamed Account", + address, + networkId: network.id, + network, + signer: { + type: "local_secret", + derivationPath: getPathForIndex(lastCheck, baseDerivationPath), + }, + type: "standard", + needsDeploy: false, // Only deployed accounts will be recovered + } + const code = await provider.getCode(address) if (code.bytecode.length > 0) { lastHit = lastCheck - accounts.push({ - address, - networkId: network.id, + accounts.push(account) // add a standard account + } else if ( + isEqualAddress(accountClassHash, multisigAccountClassHash) // this is required to ensure multisig accounts are only checked on networks that support them + ) { + // If it's not a standard account, check if the signer is a part of a Multisig + const multisigData = await fetchMultisigDataForSigner({ + signer: starkPub, network, - signer: { - type: "local_secret", - derivationPath: getPathForIndex(lastCheck, baseDerivationPath), - }, - type: "argent", - needsDeploy: false, // Only deployed accounts will be recovered }) + + // If the signer is not a part of multisig, the api doesn't throw an error + // but returns an empty content array + if (!isEmpty(multisigData.content)) { + lastHit = lastCheck + const { + address: multisigAddress, + creator, + signers, + threshold, + } = multisigData.content[0] + + accounts.push({ + ...account, + type: "multisig", + address: multisigAddress, + }) // add a multisig account + + await this.multisigStore.push({ + address: multisigAddress, + networkId: network.id, + signers, + threshold, + creator, + publicKey: starkPub, + }) + } } ++lastCheck @@ -340,7 +404,12 @@ export class Wallet { accountDetailFetchers, ) - return accountsWithDetails + const accountDetailsWithNames = + updateAccountsWithNames(accountsWithDetails) + + await this.walletStore.push(accountDetailsWithNames) + + return accountDetailsWithNames } catch (error) { console.error( "Error getting account types or guardians from chain", @@ -426,7 +495,37 @@ export class Wallet { await this.walletStore.push(accounts) } - public async newAccount(networkId: string): Promise { + public async getDefaultAccountName( + networkId: string, + type: CreateAccountType, + ): Promise { + const accounts = await this.walletStore.get(withHiddenSelector) + const pendingMultisigs = await this.pendingMultisigStore.get() + + const networkAccounts = accounts.filter( + (account) => account.networkId === networkId, + ) + + const [multisigs, standards] = partition( + networkAccounts, + (account) => account.type === "multisig", + ) + + const allMultisigs = [...multisigs, ...pendingMultisigs] + + const defaultAccountName = + type === "multisig" + ? `Multisig ${allMultisigs.length + 1}` + : `Account ${standards.length + 1}` + + return defaultAccountName + } + + public async newAccount( + networkId: string, + type: CreateAccountType = "standard", // Should not be able to create plugin accounts. Default to argent account + multisigPayload?: MultisigData, + ): Promise { const session = await this.sessionStore.get() if (!this.isSessionOpen() || !session) { throw Error("no open session") @@ -435,20 +534,34 @@ export class Wallet { const network = await this.getNetwork(networkId) const accounts = await this.walletStore.get(withHiddenSelector) + const pendingMultisigs = await this.pendingMultisigStore.get() + + const accountsOrPendingMultisigs = [...accounts, ...pendingMultisigs] - const currentPaths = accounts + const currentPaths = accountsOrPendingMultisigs .filter( (account) => account.signer.type === "local_secret" && - account.network.id === networkId, + account.networkId === networkId, ) .map((account) => account.signer.derivationPath) const index = getNextPathIndex(currentPaths, baseDerivationPath) - const { addressSalt, constructorCalldata } = - await this.getDeployContractPayloadForAccountIndex(index, networkId) + let payload + if (type === "multisig" && multisigPayload) { + payload = await this.getDeployContractPayloadForMultisig({ + index, + networkId, + ...multisigPayload, + }) + } else { + payload = await this.getDeployContractPayloadForAccountIndex( + index, + networkId, + ) + } const proxyClassHash = PROXY_CONTRACT_CLASS_HASHES[0] const proxyAddress = calculateContractAddressFromHash( @@ -458,7 +571,10 @@ export class Wallet { 0, ) - const account: WalletAccount = { + const defaultAccountName = await this.getDefaultAccountName(networkId, type) + + const account: CreateWalletAccount = { + name: defaultAccountName, network, networkId: network.id, address: proxyAddress, @@ -466,12 +582,23 @@ export class Wallet { type: "local_secret" as const, derivationPath: getPathForIndex(index, baseDerivationPath), }, - type: "argent", + type, needsDeploy: true, } await this.walletStore.push([account]) + if (type === "multisig" && multisigPayload) { + await this.multisigStore.push({ + address: account.address, + networkId: account.networkId, + signers: multisigPayload.signers, + threshold: multisigPayload.threshold, + creator: multisigPayload.creator, + publicKey: multisigPayload.publicKey, + }) + } + await this.selectAccount(account) return account @@ -487,9 +614,17 @@ export class Wallet { throw Error("Cannot deploy old accounts") } - const deployAccountPayload = await this.getAccountDeploymentPayload( - walletAccount, - ) + let deployAccountPayload: DeployAccountContractPayload + + if (walletAccount.type === "multisig") { + deployAccountPayload = await this.getMultisigDeploymentPayload( + walletAccount, + ) + } else { + deployAccountPayload = await this.getAccountDeploymentPayload( + walletAccount, + ) + } const { transaction_hash } = await starknetAccount.deployAccount( deployAccountPayload, @@ -510,9 +645,10 @@ export class Wallet { throw Error("Cannot estimate fee to deploy old accounts") } - const deployAccountPayload = await this.getAccountDeploymentPayload( - walletAccount, - ) + const deployAccountPayload = + walletAccount.type === "multisig" + ? await this.getMultisigDeploymentPayload(walletAccount) + : await this.getAccountDeploymentPayload(walletAccount) return starknetAccount.estimateAccountDeployFee(deployAccountPayload) } @@ -530,11 +666,14 @@ export class Wallet { return { account, txHash: deployTransaction.txHash } } + /** Get the Account Deployment Payload * Use it in the deployAccount and getAccountDeploymentFee methods * @param {WalletAccount} walletAccount */ - public async getAccountDeploymentPayload(walletAccount: WalletAccount) { + public async getAccountDeploymentPayload( + walletAccount: WalletAccount, + ): Promise> { const starkPair = await this.getKeyPairByDerivationPath( walletAccount.signer.derivationPath, ) @@ -543,7 +682,7 @@ export class Wallet { const accountClassHash = await this.getAccountClassHashForNetwork( walletAccount.network, - "argent", + walletAccount.type, ) const constructorCallData = { @@ -595,7 +734,61 @@ export class Wallet { return deployAccountPayload } - private async getDeployContractPayloadForAccountIndex( + public async getMultisigDeploymentPayload( + walletAccount: WalletAccount, + ): Promise> { + const multisigAccount = await getMultisigAccountFromBaseWallet( + walletAccount, + ) + + if (!multisigAccount) { + throw new Error("This multisig account does not exist") + } + + const starkPair = await this.getKeyPairByDerivationPath( + multisigAccount.signer.derivationPath, + ) + + const starkPub = ec.getStarkKey(starkPair) + + const accountClassHash = await this.getAccountClassHashForNetwork( + multisigAccount.network, + "multisig", // make sure to always use the multisig implementation + ) + + const constructorCallData = { + implementation: accountClassHash, + selector: getSelectorFromName("initialize"), + calldata: stark.compileCalldata({ + threshold: multisigAccount.threshold.toString(), + signers: multisigAccount.signers, + }), + } + + const deployMultisigPayload = { + classHash: PROXY_CONTRACT_CLASS_HASHES[0], + contractAddress: multisigAccount.address, + constructorCalldata: stark.compileCalldata(constructorCallData), + addressSalt: starkPub, + } + + // Mostly we don't need to calculate the address, + // but we do it here just to make sure the address is correct + const calculatedMultisigAddress = calculateContractAddressFromHash( + deployMultisigPayload.addressSalt, + deployMultisigPayload.classHash, + deployMultisigPayload.constructorCalldata, + 0, + ) + + if (!isEqualAddress(calculatedMultisigAddress, multisigAccount.address)) { + throw new Error("Calculated address does not match multisig address") + } + + return deployMultisigPayload + } + + public async getDeployContractPayloadForAccountIndex( index: number, networkId: string, ): Promise, "signature">> { @@ -616,7 +809,7 @@ export class Wallet { const accountClassHash = await this.getAccountClassHashForNetwork( network, - "argent", + "standard", ) const payload = { @@ -632,6 +825,54 @@ export class Wallet { return payload } + public async getDeployContractPayloadForMultisig({ + signers, + threshold, + index, + networkId, + }: { + threshold: number + signers: string[] + index: number + networkId: string + }): Promise> { + const hasSession = await this.isSessionOpen() + const session = await this.sessionStore.get() + const initialised = await this.isInitialized() + + if (!initialised) { + throw Error("wallet is not initialized") + } + if (!hasSession || !session) { + throw Error("no open session") + } + + const network = await this.getNetwork(networkId) + const starkPair = getStarkPair(index, session?.secret, baseDerivationPath) + const starkPub = ec.getStarkKey(starkPair) + + const accountClassHash = await this.getAccountClassHashForNetwork( + network, + "multisig", + ) + + const payload = { + classHash: accountClassHash, + constructorCalldata: stark.compileCalldata({ + implementation: accountClassHash, + selector: getSelectorFromName("initialize"), + calldata: stark.compileCalldata({ + threshold: threshold.toString(), + signers, + }), + }), + addressSalt: starkPub, + signature: starkPair.getPrivate(), + } + + return payload + } + public async getAccount(selector: BaseWalletAccount): Promise { const [hit] = await this.walletStore.get((account) => accountsEqual(account, selector), @@ -642,6 +883,32 @@ export class Wallet { return hit } + public async getMultisigAccount( + selector: BaseWalletAccount, + ): Promise { + const [walletAccount] = await this.walletStore.get( + (account) => + accountsEqual(account, selector) && account.type === "multisig", + ) + if (!walletAccount) { + throw Error("multisig wallet account not found") + } + + const [multisigBaseWalletAccount] = await this.multisigStore.get( + (account) => accountsEqual(account, selector), + ) + + if (!multisigBaseWalletAccount) { + throw Error("multisig base wallet account not found") + } + + return { + ...walletAccount, + ...multisigBaseWalletAccount, + type: "multisig", + } + } + public async getKeyPairByDerivationPath(derivationPath: string) { const session = await this.sessionStore.get() if (!session?.secret) { @@ -666,9 +933,21 @@ export class Wallet { return new GuardianSignerArgentX(keyPair, cosignerSign) } + // Return Multisig Signer if account is multisig + if (account.type === "multisig") { + return new MultisigSigner(keyPair) + } + return keyPair } + public getStarknetAccountOfType(account: Account, type: ArgentAccountType) { + if (type === "multisig") { + return MultisigAccount.fromAccount(account) + } + return account + } + public async getStarknetAccount( selector: BaseWalletAccount, useLatest = false, @@ -690,7 +969,9 @@ export class Wallet { const signer = await this.getSignerForAccount(account) if (account.needsDeploy || useLatest) { - return new Account(provider, account.address, signer) + const starknetAccount = new Account(provider, account.address, signer) + + return this.getStarknetAccountOfType(starknetAccount, account.type) } const providerV4 = getProviderv4( @@ -706,9 +987,11 @@ export class Wallet { account, ) + const starknetAccount = new Account(provider, account.address, signer) + return isOldAccount ? oldAccount - : new Account(provider, account.address, signer) + : this.getStarknetAccountOfType(starknetAccount, account.type) } public async getCurrentImplementation( @@ -737,6 +1020,55 @@ export class Wallet { return this.getStarknetAccount(account) } + public async getCalculatedMultisigAddress( + baseMultisigAccount: BaseMultisigWalletAccount, + ): Promise { + const multisigAccount = await getMultisigAccountFromBaseWallet( + baseMultisigAccount, + ) + + if (!multisigAccount) { + throw new Error("This multisig account does not exist") + } + + const starkPair = await this.getKeyPairByDerivationPath( + multisigAccount.signer.derivationPath, + ) + + const starkPub = ec.getStarkKey(starkPair) + + const accountClassHash = await this.getAccountClassHashForNetwork( + multisigAccount.network, + "multisig", // make sure to always use the multisig implementation + ) + + const decodedSigners = baseMultisigAccount.signers.map((signer) => + utils.hexlify(utils.base58.decode(signer)), + ) + + const constructorCallData = { + implementation: accountClassHash, + selector: getSelectorFromName("initialize"), + calldata: stark.compileCalldata({ + threshold: baseMultisigAccount.threshold.toString(), + signers: decodedSigners, + }), + } + + const deployMultisigPayload = { + classHash: PROXY_CONTRACT_CLASS_HASHES[0], + constructorCalldata: stark.compileCalldata(constructorCallData), + addressSalt: starkPub, + } + + return calculateContractAddressFromHash( + deployMultisigPayload.addressSalt, + deployMultisigPayload.classHash, + deployMultisigPayload.constructorCalldata, + 0, + ) + } + public async getSelectedAccount(): Promise { if (!this.isSessionOpen()) { return @@ -793,7 +1125,31 @@ export class Wallet { return { url, filename } } - public async getPublicKey(baseAccount?: BaseWalletAccount): Promise { + public async getPrivateKey( + baseWalletAccount: BaseWalletAccount, + ): Promise { + const session = await this.sessionStore.get() + if (!this.isSessionOpen() || !session?.secret) { + throw new Error("Session is not open") + } + + const account = await this.getAccount(baseWalletAccount) + + if (!account) { + throw new Error("no selected account") + } + + const starkPair = getStarkPair( + account.signer.derivationPath, + session.secret, + ) + + return starkPair.getPrivate().toString() + } + + public async getPublicKey( + baseAccount?: BaseWalletAccount, + ): Promise<{ publicKey: string; account: BaseWalletAccount }> { const account = baseAccount ? await this.getAccount(baseAccount) : await this.getSelectedAccount() @@ -808,29 +1164,67 @@ export class Wallet { const starkPub = ec.getStarkKey(starkPair) - return starkPub + return { publicKey: starkPub, account } } - public async exportPrivateKey( - baseWalletAccount: BaseWalletAccount, - ): Promise { + /** + * Given networkId, returns the next public key that will be used for a new account + * @param networkId + * @returns Public key + */ + public async getNextPublicKey( + networkId: string, + ): Promise<{ derivationPath: string; publicKey: string }> { const session = await this.sessionStore.get() - if (!this.isSessionOpen() || !session?.secret) { - throw new Error("Session is not open") + + if (!session?.secret) { + throw Error("session is not open") } - const account = await this.getAccount(baseWalletAccount) + const accounts = await this.walletStore.get(withHiddenSelector) + const pendingMultisigs = await this.pendingMultisigStore.get() - if (!account) { - throw new Error("no selected account") + const accountsOrPendingMultisigs = [...accounts, ...pendingMultisigs] + + const currentPaths = accountsOrPendingMultisigs + .filter( + (account) => + account.signer.type === "local_secret" && + account.networkId === networkId, + ) + .sort(sortByDerivationPath) + .map((account) => account.signer.derivationPath) + + const index = getNextPathIndex(currentPaths, baseDerivationPath) + + const path = getPathForIndex(index, baseDerivationPath) + const starkPair = getStarkPair(index, session?.secret, baseDerivationPath) + + return { + derivationPath: path, + publicKey: ec.getStarkKey(starkPair), } + } - const starkPair = getStarkPair( - account.signer.derivationPath, - session.secret, - ) + public async newPendingMultisig(networkId: string): Promise { + const { derivationPath, publicKey } = await this.getNextPublicKey(networkId) - return starkPair.getPrivate().toString() + const name = await this.getDefaultAccountName(networkId, "multisig") + + const pendingMultisig: PendingMultisig = { + name, + networkId, + signer: { + type: "local_secret", + derivationPath, + }, + publicKey, + type: "multisig", + } + + await this.pendingMultisigStore.push(pendingMultisig) + + return pendingMultisig } public static validateBackup(backupString: string): boolean { diff --git a/packages/extension/src/background/walletSingleton.ts b/packages/extension/src/background/walletSingleton.ts new file mode 100644 index 000000000..b5de1eba2 --- /dev/null +++ b/packages/extension/src/background/walletSingleton.ts @@ -0,0 +1,19 @@ +import { accountStore } from "../shared/account/store" +import { + multisigBaseWalletStore, + pendingMultisigStore, +} from "../shared/multisig/store" +import { getNetwork } from "../shared/network" +import { old_walletStore } from "../shared/wallet/walletStore" +import { loadContracts } from "./accounts" +import { Wallet, sessionStore } from "./wallet" + +export const walletSingleton = new Wallet( + old_walletStore, + accountStore, + sessionStore, + multisigBaseWalletStore, + pendingMultisigStore, + loadContracts, + getNetwork, +) diff --git a/packages/extension/src/content.ts b/packages/extension/src/content.ts index d67efc530..6398b7394 100644 --- a/packages/extension/src/content.ts +++ b/packages/extension/src/content.ts @@ -1,4 +1,5 @@ import { Relayer, WindowMessenger } from "@argent/x-window" +import { relay } from "trpc-extension/relay" import browser from "webextension-polyfill" import { ExtensionMessenger } from "./shared/extensionMessenger" @@ -22,4 +23,9 @@ const portMessenger = new ExtensionMessenger(port) const bridge = new Relayer(windowMessenger, portMessenger) // Please keep this log statement, it is used to detect if the bridge is loaded -console.log("Bridge ID:", bridge.id) +console.log("Legacy Bridge ID:", bridge.id) + +// NOTE: not used yet, as trpc is only used for UI <-> Background comms atm +const unsub = relay(window, port) +// unsub on content script unload +window.addEventListener("unload", unsub) diff --git a/packages/extension/src/inpage/ArgentXAccount.ts b/packages/extension/src/inpage/ArgentXAccount.ts index a4f9c852f..ea30dc371 100644 --- a/packages/extension/src/inpage/ArgentXAccount.ts +++ b/packages/extension/src/inpage/ArgentXAccount.ts @@ -144,6 +144,6 @@ export class ArgentXAccount extends Account { throw Error("User action timed out") } - return [result.r, result.s] + return result.signature } } diff --git a/packages/extension/src/inpage/ArgentXAccount3.ts b/packages/extension/src/inpage/ArgentXAccount3.ts index cda5543f0..97f5c5011 100644 --- a/packages/extension/src/inpage/ArgentXAccount3.ts +++ b/packages/extension/src/inpage/ArgentXAccount3.ts @@ -101,7 +101,7 @@ export class ArgentXAccount3 extends Account { throw Error("User action timed out") } - return [result.r, result.s] + return result.signature } } diff --git a/packages/extension/src/inpage/messaging.ts b/packages/extension/src/inpage/messaging.ts index 288e83af5..1183bb635 100644 --- a/packages/extension/src/inpage/messaging.ts +++ b/packages/extension/src/inpage/messaging.ts @@ -13,16 +13,3 @@ export const getIsPreauthorized = async () => { } return false } - -export const getNetwork = async (networkId: string) => { - try { - sendMessage({ - type: "GET_NETWORK", - data: networkId, - }) - return await waitForMessage("GET_NETWORK_RES", 2000) - } catch (error) { - console.error(`Error getting network: ${error} for networkId: ${networkId}`) - throw error - } -} diff --git a/packages/extension/src/inpage/requestMessageHandlers.ts b/packages/extension/src/inpage/requestMessageHandlers.ts index d2e3809de..adbad3af6 100644 --- a/packages/extension/src/inpage/requestMessageHandlers.ts +++ b/packages/extension/src/inpage/requestMessageHandlers.ts @@ -1,9 +1,6 @@ -import type { - AddStarknetChainParameters, - WatchAssetParameters, -} from "@argent/x-window" +import type { WatchAssetParameters } from "@argent/x-window" -import type { Network } from "../shared/network" +import type { Network } from "../shared/network/type" import { sendMessage, waitForMessage } from "./messageActions" export async function handleAddTokenRequest( @@ -55,66 +52,6 @@ export async function handleAddTokenRequest( return true } -export async function handleAddNetworkRequest( - callParams: AddStarknetChainParameters, -): Promise { - sendMessage({ - type: "REQUEST_ADD_CUSTOM_NETWORK", - data: { - id: callParams.id, - name: callParams.chainName, - chainId: callParams.chainId, - baseUrl: callParams.baseUrl, - rpcUrl: callParams.rpcUrls?.[0], - explorerUrl: callParams.blockExplorerUrls?.[0], - accountClassHash: (callParams as any).accountImplementation, - }, - }) - - const req = await Promise.race([ - waitForMessage("REQUEST_ADD_CUSTOM_NETWORK_RES", 1000), - waitForMessage("REQUEST_ADD_CUSTOM_NETWORK_REJ", 1000), - ]) - - if ("error" in req) { - throw Error(req.error) - } - - const { actionHash } = req - - sendMessage({ type: "OPEN_UI" }) - - const result = await Promise.race([ - waitForMessage( - "APPROVE_REQUEST_ADD_CUSTOM_NETWORK", - 11 * 60 * 1000, - (x) => x.data.actionHash === actionHash, - ), - waitForMessage( - "REJECT_REQUEST_ADD_CUSTOM_NETWORK", - 10 * 60 * 1000, - (x) => x.data.actionHash === actionHash, - ) - .then(() => "error" as const) - .catch(() => { - sendMessage({ - type: "REJECT_REQUEST_ADD_CUSTOM_NETWORK", - data: { actionHash }, - }) - return "timeout" as const - }), - ]) - - if (result === "error") { - throw Error("User abort") - } - if (result === "timeout") { - throw Error("User action timed out") - } - - return true -} - export async function handleSwitchNetworkRequest(callParams: { chainId: Network["chainId"] }): Promise { diff --git a/packages/extension/src/inpage/starknetWindowObject.ts b/packages/extension/src/inpage/starknetWindowObject.ts index 9d350ac69..fce8f43f9 100644 --- a/packages/extension/src/inpage/starknetWindowObject.ts +++ b/packages/extension/src/inpage/starknetWindowObject.ts @@ -8,11 +8,9 @@ import type { import { assertNever } from "./../ui/services/assertNever" import { getProvider } from "../shared/network/provider" import { ArgentXAccount } from "./ArgentXAccount" -import { ArgentXAccount3, getProvider3 } from "./ArgentXAccount3" import { sendMessage, waitForMessage } from "./messageActions" import { getIsPreauthorized } from "./messaging" import { - handleAddNetworkRequest, handleAddTokenRequest, handleSwitchNetworkRequest, } from "./requestMessageHandlers" @@ -40,7 +38,8 @@ export const starknetWindowObject: StarknetWindowObject = { ) { return await handleAddTokenRequest(call.params) } else if (call.type === "wallet_addStarknetChain" && "id" in call.params) { - return await handleAddNetworkRequest(call.params) + // TODO: implement + throw Error("Not implemented") } else if ( call.type === "wallet_switchStarknetChain" && "chainId" in call.params @@ -49,7 +48,7 @@ export const starknetWindowObject: StarknetWindowObject = { } throw Error("Not implemented") }, - enable: async ({ starknetVersion = "v3" } = {}) => { + enable: async ({ starknetVersion = "v4" } = {}) => { const walletAccountP = Promise.race([ waitForMessage("CONNECT_DAPP_RES", 10 * 60 * 1000), waitForMessage("REJECT_PREAUTHORIZATION", 10 * 60 * 1000).then( @@ -80,10 +79,9 @@ export const starknetWindowObject: StarknetWindowObject = { starknet.provider = provider starknet.account = new ArgentXAccount(address, provider) } else { - const provider = getProvider3(network) - ;(starknet as any).starknetJsVersion = "v3" - ;(starknet as any).provider = provider - ;(starknet as any).account = new ArgentXAccount3(address, provider) + throw Error( + "ArgentX only supports Account from starknet.js v4. We ask the dApp developers to use latest get-starknet package", + ) } starknet.selectedAddress = address diff --git a/packages/extension/src/inpage/trpcClient.ts b/packages/extension/src/inpage/trpcClient.ts new file mode 100644 index 000000000..1e7d2cc42 --- /dev/null +++ b/packages/extension/src/inpage/trpcClient.ts @@ -0,0 +1,8 @@ +import { createTRPCProxyClient } from "@trpc/client" +import { windowLink } from "trpc-extension/link" + +import { AppRouter } from "../background/__new/router" + +export const inpageMessageClient = createTRPCProxyClient({ + links: [windowLink({ window })], +}) diff --git a/packages/extension/src/shared/__new/services/ui/implementation.test.ts b/packages/extension/src/shared/__new/services/ui/implementation.test.ts new file mode 100644 index 000000000..d87230ee7 --- /dev/null +++ b/packages/extension/src/shared/__new/services/ui/implementation.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test, vi } from "vitest" + +import UIService from "./implementation" + +describe("UIService", () => { + const makeService = () => { + const browser = { + browserAction: { + setPopup: vi.fn(), + }, + extension: { + getViews: vi.fn(), + }, + runtime: { + getURL: vi.fn(), + }, + tabs: { + create: vi.fn(), + query: vi.fn(), + update: vi.fn(), + }, + windows: { + update: vi.fn(), + }, + } + const uiService = new UIService(browser) + return { + uiService, + browser, + } + } + test("setDefaultPopup", async () => { + const { uiService, browser } = makeService() + await uiService.setDefaultPopup() + expect(browser.browserAction.setPopup).toHaveBeenCalledWith({ + popup: "index.html", + }) + }) + test("unsetDefaultPopup", async () => { + const { uiService, browser } = makeService() + await uiService.unsetDefaultPopup() + expect(browser.browserAction.setPopup).toHaveBeenCalledWith({ + popup: "", + }) + }) + describe("focusTab", () => { + test("when there is no tab", async () => { + const { uiService, browser } = makeService() + const getTabSpy = vi.spyOn(uiService, "getTab") + getTabSpy.mockImplementationOnce(async () => { + return {} as chrome.tabs.Tab + }) + await uiService.focusTab() + expect(getTabSpy).toHaveBeenCalled() + expect(browser.windows.update).not.toHaveBeenCalled() + expect(browser.tabs.update).not.toHaveBeenCalled() + }) + test("when there is a tab", async () => { + const { uiService, browser } = makeService() + const getTabSpy = vi.spyOn(uiService, "getTab") + getTabSpy.mockImplementationOnce(async () => { + return { id: "123", windowId: "abc" } as never as chrome.tabs.Tab + }) + await uiService.focusTab() + expect(getTabSpy).toHaveBeenCalled() + expect(browser.windows.update).toHaveBeenCalledWith("abc", { + focused: true, + }) + expect(browser.tabs.update).toHaveBeenCalledWith("123", { + active: true, + }) + }) + }) +}) diff --git a/packages/extension/src/shared/__new/services/ui/implementation.ts b/packages/extension/src/shared/__new/services/ui/implementation.ts new file mode 100644 index 000000000..27a74d999 --- /dev/null +++ b/packages/extension/src/shared/__new/services/ui/implementation.ts @@ -0,0 +1,71 @@ +import { DeepPick } from "../../../types/deepPick" +import type { IUIService } from "./interface" + +type MinimalBrowser = DeepPick< + typeof chrome, + | "browserAction.setPopup" + | "extension.getViews" + | "runtime.getURL" + | "tabs.create" + | "tabs.query" + | "tabs.update" + | "windows.update" +> + +export default class UIService implements IUIService { + constructor(private browser: MinimalBrowser) {} + + setDefaultPopup(popup = "index.html") { + return this.browser.browserAction.setPopup({ popup }) + } + + unsetDefaultPopup() { + return this.setDefaultPopup("") + } + + getPopup() { + const [popup] = this.browser.extension.getViews({ type: "popup" }) + return popup + } + + hasPopup() { + const popup = this.getPopup() + return Boolean(popup) + } + + closePopup() { + const popup = this.getPopup() + if (popup) { + popup.close() + } + } + + async createTab(path = "index.html") { + const url = this.browser.runtime.getURL(path) + return this.browser.tabs.create({ url }) + } + + async getTab() { + const [tab] = await this.browser.tabs.query({ + url: [this.browser.runtime.getURL("/*")], + }) + return tab + } + + async hasTab() { + const tab = await this.getTab() + return Boolean(tab && tab.id && tab.windowId) + } + + async focusTab() { + const tab = await this.getTab() + if (tab && tab.id && tab.windowId) { + await this.browser.windows.update(tab.windowId, { + focused: true, + }) + await this.browser.tabs.update(tab.id, { + active: true, + }) + } + } +} diff --git a/packages/extension/src/shared/__new/services/ui/index.ts b/packages/extension/src/shared/__new/services/ui/index.ts new file mode 100644 index 000000000..f3db7da7f --- /dev/null +++ b/packages/extension/src/shared/__new/services/ui/index.ts @@ -0,0 +1,5 @@ +import browser from "webextension-polyfill" + +import UIService from "./implementation" + +export const uiService = new UIService(browser) diff --git a/packages/extension/src/shared/__new/services/ui/interface.ts b/packages/extension/src/shared/__new/services/ui/interface.ts new file mode 100644 index 000000000..98f369dc2 --- /dev/null +++ b/packages/extension/src/shared/__new/services/ui/interface.ts @@ -0,0 +1,51 @@ +export interface IUIService { + /** + * The equivalent of setting or unsetting `default_popup` in the manifest + */ + setDefaultPopup(popup?: string): Promise + + /** + * Unsets popup so that extension icon click event can be captured + */ + unsetDefaultPopup(): Promise + + /** + * Get popup + * @returns popup if it exists + */ + getPopup(): Window + + /** + * Determine if there is an existing popup + * @returns true if it exists + */ + hasPopup(): boolean + + /** + * Close popup if it exists + */ + closePopup(): void + + /** + * Creates a tab with the provided path + * @returns tab + */ + createTab(path?: string): Promise + + /** + * Determine if there is an existing tab + * @returns true if it exists + */ + hasTab(): Promise + + /** + * Get existing tab + * @returns tab if it exists + */ + getTab(): Promise + + /** + * Focus existing tab (and window) if it exists + */ + focusTab(): Promise +} diff --git a/packages/extension/src/shared/account/details/getAccountTypesFromChain.ts b/packages/extension/src/shared/account/details/getAccountTypesFromChain.ts index 1679f6ea3..957e04d8c 100644 --- a/packages/extension/src/shared/account/details/getAccountTypesFromChain.ts +++ b/packages/extension/src/shared/account/details/getAccountTypesFromChain.ts @@ -41,6 +41,19 @@ export async function getAccountTypesFromChain( > => { const network = await getNetwork(networkId) + const hasOnlyArgentAccounts = + network.accountClassHash && + Object.entries(network.accountClassHash).every( + ([key, value]) => key === "argent" || value === undefined, + ) + + if (hasOnlyArgentAccounts) { + return calls.map((call) => ({ + address: call.contractAddress, + type: "standard", + })) + } + if (network.multicallAddress) { const multicall = getMulticallForNetwork(network) const responses = await Promise.all( @@ -48,10 +61,8 @@ export async function getAccountTypesFromChain( ) const result = responses.map((response, i) => { const call = calls[i] - const type = mapImplementationToArgentAccountType( - response[0], - network, - ) + const type: ArgentAccountType = + mapImplementationToArgentAccountType(response[0], network) return { address: call.contractAddress, type, diff --git a/packages/extension/src/shared/account/details/getAndMergeAccountDetails.test.ts b/packages/extension/src/shared/account/details/getAndMergeAccountDetails.test.ts index 27eb1632b..a65f8007a 100644 --- a/packages/extension/src/shared/account/details/getAndMergeAccountDetails.test.ts +++ b/packages/extension/src/shared/account/details/getAndMergeAccountDetails.test.ts @@ -26,7 +26,7 @@ describe("getAndMergeAccountDetails", () => { ): Promise => { return accounts.map((account) => ({ ...account, - type: account.address === address1 ? "argent" : "argent-plugin", + type: account.address === address1 ? "standard" : "plugin", })) } const getAccountGuardiansFromChain = async ( @@ -48,13 +48,13 @@ describe("getAndMergeAccountDetails", () => { "address": "0x7e00d496e324876bbc8531f2d9a82bf154d1a04a50218ee74cdd372f75a551a", "guardian": "0x1", "networkId": "goerli-alpha", - "type": "argent", + "type": "standard", }, { "address": "0x69b49c2cc8b16e80e86bfc5b0614a59aa8c9b601569c7b80dde04d3f3151b79", "guardian": "0x2", "networkId": "mainnet-alpha", - "type": "argent-plugin", + "type": "plugin", }, ] `) diff --git a/packages/extension/src/shared/account/details/getEscape.ts b/packages/extension/src/shared/account/details/getEscape.ts index fcee6d9c8..d575a36f3 100644 --- a/packages/extension/src/shared/account/details/getEscape.ts +++ b/packages/extension/src/shared/account/details/getEscape.ts @@ -1,4 +1,5 @@ import { Call, number } from "starknet" +import { z } from "zod" import { getMulticallForNetwork } from "../../multicall" import { getNetwork } from "../../network" @@ -12,11 +13,16 @@ export const ESCAPE_TYPE_SIGNER = 2 export const ESCAPE_SECURITY_PERIOD_DAYS = 7 -export interface Escape { +export const escapeSchema = z.object({ /** Time stamp escape will be active, in seconds */ - activeAt: number - type: typeof ESCAPE_TYPE_GUARDIAN | typeof ESCAPE_TYPE_SIGNER -} + activeAt: z.number(), + type: z.union([ + z.literal(ESCAPE_TYPE_GUARDIAN), + z.literal(ESCAPE_TYPE_SIGNER), + ]), +}) + +export type Escape = z.infer /** * Get escape state from account diff --git a/packages/extension/src/shared/account/details/updateAccountsWithNames.ts b/packages/extension/src/shared/account/details/updateAccountsWithNames.ts new file mode 100644 index 000000000..30ca64352 --- /dev/null +++ b/packages/extension/src/shared/account/details/updateAccountsWithNames.ts @@ -0,0 +1,22 @@ +import { partition } from "lodash-es" + +import { WalletAccount } from "../../wallet.model" + +export const updateAccountsWithNames = (accounts: WalletAccount[]) => { + const [multisigAccounts, standardAccounts] = partition( + accounts, + (account) => account.type === "multisig", + ) + + const updatedMultisigAccounts = multisigAccounts.map((multisig, index) => ({ + ...multisig, + name: `Multisig ${index + 1}`, + })) + + const updatedStandardAccounts = standardAccounts.map((account, index) => ({ + ...account, + name: `Account ${index + 1}`, + })) + + return [...updatedStandardAccounts, ...updatedMultisigAccounts] +} diff --git a/packages/extension/src/shared/account/service/implementation.test.ts b/packages/extension/src/shared/account/service/implementation.test.ts new file mode 100644 index 000000000..9647e5630 --- /dev/null +++ b/packages/extension/src/shared/account/service/implementation.test.ts @@ -0,0 +1,82 @@ +import { messageClient } from "../../../ui/services/messaging/trpc" +import { + MockFnObjectStore, + MockFnRepository, +} from "../../storage/__new/__test__/mockFunctionImplementation" +import type { BaseWalletAccount, WalletAccount } from "../../wallet.model" +import type { IWalletStore } from "../../wallet/walletStore" +import { AccountService } from "./implementation" + +describe("AccountService", () => { + let accountRepo: MockFnRepository + let walletStore: IWalletStore + let accountService: AccountService + + beforeEach(() => { + accountRepo = new MockFnRepository() + walletStore = new MockFnObjectStore() + accountService = new AccountService(accountRepo, walletStore, messageClient) + }) + + describe("select", () => { + it("should update wallet store with selected account", async () => { + const baseAccount: BaseWalletAccount = { + address: "0x123", + networkId: "0x1", + // @ts-expect-error extraValue is not part of BaseWalletAccount + extraValue: "extraValue", + } + await accountService.select(baseAccount) + + expect(walletStore.set).toHaveBeenCalledWith({ + selected: { + address: baseAccount.address, + networkId: baseAccount.networkId, + }, + }) + }) + + it("should set selected account to null if baseAccount is null", async () => { + await accountService.select(null) + + expect(walletStore.set).toHaveBeenCalledWith({ selected: null }) + }) + }) + + describe("get", () => { + it("should return accounts based on the provided selector", async () => { + const accounts: WalletAccount[] = [ + { address: "0x123", networkId: "0x1", name: "test1" } as WalletAccount, + ] + accountRepo.get.mockResolvedValue(accounts) + + const result = await accountService.get() + + expect(accountRepo.get).toHaveBeenCalled() + expect(result).toEqual(accounts) + }) + }) + + describe("upsert", () => { + it("should upsert accounts to the accountRepo", async () => { + const accounts: WalletAccount[] = [ + /* mock array of WalletAccount */ + ] + await accountService.upsert(accounts) + + expect(accountRepo.upsert).toHaveBeenCalledWith(accounts) + }) + }) + + describe("remove", () => { + it("should remove accounts from the accountRepo", async () => { + const baseAccount: BaseWalletAccount = { + address: "0x123", + networkId: "0x1", + } + await accountService.remove(baseAccount) + + expect(accountRepo.remove).toHaveBeenCalled() + }) + }) +}) diff --git a/packages/extension/src/shared/account/service/implementation.ts b/packages/extension/src/shared/account/service/implementation.ts new file mode 100644 index 000000000..65a91bdeb --- /dev/null +++ b/packages/extension/src/shared/account/service/implementation.ts @@ -0,0 +1,152 @@ +import { Account } from "../../../ui/features/accounts/Account" +import { Multisig } from "../../../ui/features/multisig/Multisig" +import { deployNewMultisig } from "../../../ui/services/backgroundAccounts" +import { messageClient } from "../../../ui/services/messaging/trpc" +import type { AllowArray, SelectorFn } from "../../storage/__new/interface" +import type { + ArgentAccountType, + BaseWalletAccount, + CreateAccountType, + MultisigData, + WalletAccount, +} from "../../wallet.model" +import { accountsEqual } from "../../wallet.service" +import type { IWalletStore } from "../../wallet/walletStore" +import { withoutHiddenSelector } from "../selectors" +import type { IAccountRepo } from "../store" +import type { IAccountService } from "./interface" + +// TODO: once the data presentation of account changes, this should be updated and tests should be added +// TODO: once the messaging is trpc, we should add tests +export class AccountService implements IAccountService { + constructor( + private readonly accountRepo: IAccountRepo, + private readonly walletStore: IWalletStore, + private readonly trpcClient: typeof messageClient, + ) {} + + async select(baseAccount: BaseWalletAccount | null): Promise { + return this.walletStore.set({ + selected: baseAccount + ? { + // pick values from baseAccount to avoid leaking additional data into storage + address: baseAccount.address, + networkId: baseAccount.networkId, + } + : null, + }) + } + + async create( + type: CreateAccountType, + networkId: string, + multisigPayload?: MultisigData, + ): Promise { + if (type === "multisig" && !multisigPayload) { + throw new Error("Multisig payload is required") + } + + let newAccount: Account + if (type === "multisig") { + // get rid of these extra abstractions + newAccount = await Multisig.create(networkId, multisigPayload) + } else { + newAccount = await Account.create(networkId, type) + } + + // get WalletAccount format + const [hit] = await this.accountRepo.get((account) => + accountsEqual(account, newAccount), + ) + + if (!hit) { + throw new Error("Something went wrong") + } + + // switch background wallet to the account that was selected + await this.select(newAccount) + + return hit + } + + // TODO: make isomorphic + async deploy(baseAccount: BaseWalletAccount): Promise { + const [account] = await this.accountRepo.get((account) => + accountsEqual(account, baseAccount), + ) + + if (!account) { + throw new Error("Account not found") + } + + if (account.needsDeploy === false) { + throw new Error("Account already deployed") + } + + if (account.type === "multisig") { + // TODO refactor this when multisig is stable + await deployNewMultisig(account) + } else { + await this.trpcClient.account.deploy.mutate(account) + } + } + + // TODO: make isomorphic + async upgrade( + baseAccount: BaseWalletAccount, + targetImplementationType?: ArgentAccountType | undefined, + ): Promise { + return this.trpcClient.account.upgrade.mutate({ + account: baseAccount, + targetImplementationType, + }) + } + + async get( + selector: SelectorFn = withoutHiddenSelector, + ): Promise { + return this.accountRepo.get(selector) + } + + async upsert(account: AllowArray): Promise { + await this.accountRepo.upsert(account) + } + + async remove(baseAccount: BaseWalletAccount): Promise { + await this.accountRepo.remove((account) => + accountsEqual(account, baseAccount), + ) + } + + // TBD: should we expose this function and get rid of one function per property? Or should we keep it as is? + private async update( + selector: SelectorFn, + updateFn: (account: WalletAccount) => WalletAccount, + ): Promise { + await this.accountRepo.upsert((accounts) => { + return accounts.map((account) => { + if (selector(account)) { + return updateFn(account) + } + return account + }) + }) + } + + async setHide( + hidden: boolean, + baseAccount: BaseWalletAccount, + ): Promise { + return this.update( + (account) => accountsEqual(account, baseAccount), + (account) => ({ ...account, hidden }), + ) + } + + async setName(name: string, baseAccount: BaseWalletAccount): Promise { + return this.update( + (account) => accountsEqual(account, baseAccount), + (account) => ({ ...account, name }), + ) + } +} diff --git a/packages/extension/src/shared/account/service/index.ts b/packages/extension/src/shared/account/service/index.ts new file mode 100644 index 000000000..8b6ef087b --- /dev/null +++ b/packages/extension/src/shared/account/service/index.ts @@ -0,0 +1,10 @@ +import { messageClient } from "../../../ui/services/messaging/trpc" +import { walletStore } from "../../wallet/walletStore" +import { accountRepo } from "../store" +import { AccountService } from "./implementation" + +export const accountService = new AccountService( + accountRepo, + walletStore, + messageClient, +) diff --git a/packages/extension/src/shared/account/service/interface.ts b/packages/extension/src/shared/account/service/interface.ts new file mode 100644 index 000000000..59c516ca0 --- /dev/null +++ b/packages/extension/src/shared/account/service/interface.ts @@ -0,0 +1,34 @@ +import { AllowArray, SelectorFn } from "../../storage/__new/interface" +import { + ArgentAccountType, + BaseWalletAccount, + CreateAccountType, + MultisigData, + WalletAccount, +} from "../../wallet.model" + +export interface IAccountService { + // selected account + select(baseAccount: BaseWalletAccount): Promise + + // account methods + create( + type: CreateAccountType, + networkId: string, + multisigPayload?: MultisigData, + ): Promise + deploy(baseAccount: BaseWalletAccount): Promise + upgrade( + baseAccount: BaseWalletAccount, + targetImplementationType?: ArgentAccountType, + ): Promise + + // Repo methods + get(selector: SelectorFn): Promise + upsert(account: AllowArray): Promise + remove(baseAccount: BaseWalletAccount): Promise + + // mutations/updates + setHide(hidden: boolean, baseAccount: BaseWalletAccount): Promise + setName(name: string, baseAccount: BaseWalletAccount): Promise +} diff --git a/packages/extension/src/shared/account/store.ts b/packages/extension/src/shared/account/store.ts deleted file mode 100644 index 5ccd5830c..000000000 --- a/packages/extension/src/shared/account/store.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { ArrayStorage } from "../storage" -import { AllowArray, SelectorFn } from "../storage/types" -import { BaseWalletAccount, WalletAccount } from "../wallet.model" -import { accountsEqual } from "../wallet.service" -import { getAccountSelector, withoutHiddenSelector } from "./selectors" -import { deserialize, serialize } from "./serialize" - -export const accountStore = new ArrayStorage([], { - namespace: "core:accounts", - compare: accountsEqual, - serialize, - deserialize, -}) - -export async function getAccounts( - selector: SelectorFn = withoutHiddenSelector, -): Promise { - return accountStore.get(selector) -} - -export async function addAccounts( - account: AllowArray, -): Promise { - await accountStore.push(account) -} - -export async function removeAccount( - baseAccount: BaseWalletAccount, -): Promise { - await accountStore.remove((account) => accountsEqual(account, baseAccount)) -} - -export async function hideAccount( - baseAccount: BaseWalletAccount, -): Promise { - const [hit] = await getAccounts(getAccountSelector(baseAccount)) - if (!hit) { - return - } - await accountStore.push({ - ...hit, - hidden: true, - }) -} - -export async function unhideAccount( - baseAccount: BaseWalletAccount, -): Promise { - const [hit] = await getAccounts(getAccountSelector(baseAccount)) - if (!hit) { - return - } - await accountStore.push({ - ...hit, - hidden: false, - }) -} diff --git a/packages/extension/src/shared/account/store/index.ts b/packages/extension/src/shared/account/store/index.ts new file mode 100644 index 000000000..05ab6fc43 --- /dev/null +++ b/packages/extension/src/shared/account/store/index.ts @@ -0,0 +1,20 @@ +import { ArrayStorage } from "../../storage" +import type { IRepository } from "../../storage/__new/interface" +import { adaptArrayStorage } from "../../storage/__new/repository" +import type { WalletAccount } from "../../wallet.model" +import { accountsEqual } from "../../wallet.service" +import { deserialize, serialize } from "./serialize" + +export type IAccountRepo = IRepository + +/** + * @deprecated use `accountRepo` instead + */ +export const accountStore = new ArrayStorage([], { + namespace: "core:accounts", + compare: accountsEqual, + serialize, + deserialize, +}) + +export const accountRepo: IAccountRepo = adaptArrayStorage(accountStore) diff --git a/packages/extension/src/shared/account/store/serialize.test.ts b/packages/extension/src/shared/account/store/serialize.test.ts new file mode 100644 index 000000000..82fc19282 --- /dev/null +++ b/packages/extension/src/shared/account/store/serialize.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from "vitest" + +import { defaultNetwork } from "../../network/defaults" +import type { StoredWalletAccount, WalletAccount } from "../../wallet.model" +import { deserialize, serialize } from "./serialize" + +// Mock getNetwork function +vi.mock("../../network", () => ({ + getNetwork: vi.fn((networkId) => { + expect(networkId).toEqual("goerli-alpha") + return Promise.resolve(defaultNetwork) + }), +})) + +const mockAccounts: WalletAccount[] = [ + { + name: "Account1", + type: "standard", + address: "0x1", + signer: { derivationPath: "1", type: "local_secret" }, + networkId: "goerli-alpha", + network: defaultNetwork, + }, +] + +const mockStoredAccounts: StoredWalletAccount[] = [ + { + name: "Account1", + type: "standard", + address: "0x1", + signer: { derivationPath: "1", type: "local_secret" }, + networkId: "goerli-alpha", + }, +] + +describe("Wallet Account Serialization and Deserialization", () => { + it("Should correctly serialize wallet accounts", () => { + const serializedAccounts = serialize(mockAccounts) + expect(serializedAccounts).toEqual(mockStoredAccounts) + }) + + it("Should correctly deserialize stored wallet accounts", async () => { + const deserializedAccounts = await deserialize(mockStoredAccounts) + expect(deserializedAccounts).toEqual(mockAccounts) + }) +}) diff --git a/packages/extension/src/shared/account/serialize.ts b/packages/extension/src/shared/account/store/serialize.ts similarity index 81% rename from packages/extension/src/shared/account/serialize.ts rename to packages/extension/src/shared/account/store/serialize.ts index 0e177aaf3..aa84a21c7 100644 --- a/packages/extension/src/shared/account/serialize.ts +++ b/packages/extension/src/shared/account/store/serialize.ts @@ -1,5 +1,5 @@ -import { getNetwork } from "../network" -import { StoredWalletAccount, WalletAccount } from "../wallet.model" +import { getNetwork } from "../../network" +import { StoredWalletAccount, WalletAccount } from "../../wallet.model" export function serialize(accounts: WalletAccount[]): StoredWalletAccount[] { return accounts.map((account) => { diff --git a/packages/extension/src/shared/account/storeMigration.ts b/packages/extension/src/shared/account/storeMigration.ts index 3d8ff5804..86c291a8f 100644 --- a/packages/extension/src/shared/account/storeMigration.ts +++ b/packages/extension/src/shared/account/storeMigration.ts @@ -4,7 +4,7 @@ import browser from "webextension-polyfill" import { getNetwork } from "../network" import { WalletAccount } from "../wallet.model" import { accountsEqual } from "../wallet.service" -import { addAccounts } from "./store" +import { accountService } from "./service" export async function migrateWalletAccounts() { try { @@ -19,7 +19,7 @@ export async function migrateWalletAccounts() { const oldAccounts: WalletAccount[] = JSON.parse(needsMigration) const [newAccounts] = await checkAccountsForMigration(oldAccounts) - await addAccounts(newAccounts) + await accountService.upsert(newAccounts) return browser.storage.local.remove("wallet:accounts") } catch (e) { console.error(e) diff --git a/packages/extension/src/shared/account/update.ts b/packages/extension/src/shared/account/update.ts index 91219260c..76968463c 100644 --- a/packages/extension/src/shared/account/update.ts +++ b/packages/extension/src/shared/account/update.ts @@ -1,4 +1,11 @@ -import { WalletAccount } from "./../wallet.model" +import { + BaseMultisigWalletAccount, + MultisigWalletAccount, + WalletAccount, +} from "./../wallet.model" +import { fetchMultisigAccountData } from "../multisig/multisig.service" +import { multisigBaseWalletStore } from "../multisig/store" +import { getMultisigAccounts } from "../multisig/utils/baseMultisig" import { ARGENT_SHIELD_ENABLED } from "../shield/constants" import { BaseWalletAccount } from "../wallet.model" import { accountsEqual } from "../wallet.service" @@ -9,15 +16,16 @@ import { DetailFetchers, getAndMergeAccountDetails, } from "./details/getAndMergeAccountDetails" -import { addAccounts, getAccounts } from "./store" +import { accountService } from "./service" type UpdateScope = "all" | "type" | "deploy" | "guardian" +// TODO: move into worker instead of calling it explicitly export async function updateAccountDetails( scope: UpdateScope, accounts?: BaseWalletAccount[], ) { - const allAccounts = await getAccounts((a) => + const allAccounts = await accountService.get((a) => accounts ? accounts.some((a2) => accountsEqual(a, a2)) : true, ) @@ -53,5 +61,35 @@ export async function updateAccountDetails( accountDetailFetchers, ) - await addAccounts(newAccountsWithDetails) // handles deduplication and updates + await accountService.upsert(newAccountsWithDetails) // handles deduplication and updates +} + +export async function updateMultisigAccountDetails( + accounts?: BaseWalletAccount[], +) { + const multisigAccounts = await getMultisigAccounts((a) => + accounts ? accounts.some((a2) => accountsEqual(a, a2)) : true, + ) + + const updater = async ({ + address, + networkId, + publicKey, + }: MultisigWalletAccount): Promise => { + const { content } = await fetchMultisigAccountData({ + address, + networkId, + }) + + return { + ...content, + address, + networkId, + publicKey, + } + } + + const updated = await Promise.all(multisigAccounts.map(updater)) + + await multisigBaseWalletStore.push(updated) // handles deduplication and updates } diff --git a/packages/extension/src/shared/actionQueue/types.ts b/packages/extension/src/shared/actionQueue/types.ts index 1726cba4e..d7b1e0250 100644 --- a/packages/extension/src/shared/actionQueue/types.ts +++ b/packages/extension/src/shared/actionQueue/types.ts @@ -7,6 +7,7 @@ import type { typedData, } from "starknet" +import { Network } from "../network" import { TransactionMeta } from "../transactions" import { BaseWalletAccount } from "../wallet.model" @@ -41,6 +42,10 @@ export type ActionItem = type: "DEPLOY_ACCOUNT_ACTION" payload: BaseWalletAccount } + | { + type: "DEPLOY_MULTISIG_ACTION" + payload: BaseWalletAccount + } | { type: "SIGN" payload: typedData.TypedData @@ -55,29 +60,9 @@ export type ActionItem = networkId?: string } } - | { - type: "REQUEST_ADD_CUSTOM_NETWORK" - payload: { - id: string - name: string - chainId: string // A 0x-prefixed hexadecimal string - baseUrl: string - explorerUrl?: string - accountImplementation?: string - rpcUrl?: string - } - } | { type: "REQUEST_SWITCH_CUSTOM_NETWORK" - payload: { - id: string - name: string - chainId: string // A 0x-prefixed hexadecimal string - baseUrl: string - explorerUrl?: string - accountImplementation?: string - rpcUrl?: string - } + payload: Network } | { type: "DECLARE_CONTRACT_ACTION" diff --git a/packages/extension/src/shared/analytics.ts b/packages/extension/src/shared/analytics.ts index 9ae8533a9..cd337fd6b 100644 --- a/packages/extension/src/shared/analytics.ts +++ b/packages/extension/src/shared/analytics.ts @@ -1,9 +1,11 @@ import { base64 } from "ethers/lib/utils" import { encode } from "starknet" import browser from "webextension-polyfill" -import create from "zustand" +import { create } from "zustand" import { persist } from "zustand/middleware" +import { CreateAccountType } from "./wallet.model" + const SEGMENT_TRACK_URL = "https://api.segment.io/v1/track" const SEGMENT_PAGE_URL = "https://api.segment.io/v1/page" @@ -47,11 +49,13 @@ export interface Events { | { status: "success" networkId: string + type: CreateAccountType } | { status: "failure" errorMessage: string networkId: string + type: CreateAccountType } deployAccount: | { @@ -64,6 +68,17 @@ export interface Events { errorMessage: string networkId: string } + deployMultisig: + | { + status: "success" + trigger: "sign" | "transaction" + networkId: string + } + | { + status: "failure" + errorMessage: string + networkId: string + } preauthorizeDapp: { host: string networkId: string @@ -263,6 +278,10 @@ export function getAnalytics( return { track: async (event, ...[data]) => { if (!SEGMENT_WRITE_KEY) { + console.groupCollapsed(`Analytics: ${event}`) + console.log("You see this log because no SEGMENT_WRITE_KEY is set") + console.log(data) + console.groupEnd() return } const payload = { @@ -316,7 +335,7 @@ interface ActiveStore extends ActiveStoreValues { update: (key: keyof ActiveStoreValues) => void } -export const activeStore = create( +export const activeStore = create()( persist( (set) => ({ lastOpened: 0, // defaults to tracking once when no value set yet diff --git a/packages/extension/src/shared/api/constants.ts b/packages/extension/src/shared/api/constants.ts index ee97fbed0..92bc31a1d 100644 --- a/packages/extension/src/shared/api/constants.ts +++ b/packages/extension/src/shared/api/constants.ts @@ -2,6 +2,8 @@ import { isString } from "lodash-es" import urlJoin from "url-join" export const ARGENT_API_BASE_URL = process.env.ARGENT_API_BASE_URL as string +export const ARGENT_MULTISIG_BASE_URL = process.env + .ARGENT_MULTISIG_BASE_URL as string export const ARGENT_API_ENABLED = isString(ARGENT_API_BASE_URL) && ARGENT_API_BASE_URL.length > 0 @@ -46,3 +48,12 @@ export const ARGENT_TRANSACTION_SIMULATION_URL = ARGENT_API_ENABLED export const ARGENT_TRANSACTION_SIMULATION_API_ENABLED = isString(ARGENT_TRANSACTION_SIMULATION_URL) && ARGENT_TRANSACTION_SIMULATION_URL.length > 0 + +export const ARGENT_MULTISIG_ENABLED = + process.env.FEATURE_MULTISIG === "true" && + isString(ARGENT_MULTISIG_BASE_URL) && + ARGENT_MULTISIG_BASE_URL.length > 0 + +export const ARGENT_MULTISIG_URL = ARGENT_MULTISIG_ENABLED + ? ARGENT_MULTISIG_BASE_URL + : undefined diff --git a/packages/extension/src/shared/call/changeMultisigSignersCall.ts b/packages/extension/src/shared/call/changeMultisigSignersCall.ts new file mode 100644 index 000000000..a9be8878d --- /dev/null +++ b/packages/extension/src/shared/call/changeMultisigSignersCall.ts @@ -0,0 +1,37 @@ +import { Call, validateAndParseAddress } from "starknet" + +export interface AddMultisigSignersCall extends Call { + entrypoint: "addSigners" +} + +export const isAddMultisigSignersCall = ( + call: Call, +): call is AddMultisigSignersCall => { + try { + if (call.contractAddress && call.entrypoint === "addSigners") { + validateAndParseAddress(call.contractAddress) + return true + } + } catch (e) { + // failure implies invalid + } + return false +} + +export interface RemoveMultisigSignersCall extends Call { + entrypoint: "removeSigners" +} + +export const isRemoveMultisigSignersCall = ( + call: Call, +): call is AddMultisigSignersCall => { + try { + if (call.contractAddress && call.entrypoint === "removeSigners") { + validateAndParseAddress(call.contractAddress) + return true + } + } catch (e) { + // failure implies invalid + } + return false +} diff --git a/packages/extension/src/shared/call/setMultisigThresholdCalls.ts b/packages/extension/src/shared/call/setMultisigThresholdCalls.ts new file mode 100644 index 000000000..6d627b99b --- /dev/null +++ b/packages/extension/src/shared/call/setMultisigThresholdCalls.ts @@ -0,0 +1,19 @@ +import { Call, validateAndParseAddress } from "starknet" + +export interface ChangeTresholdMultisigCall extends Call { + entrypoint: "changeThreshold" +} + +export const isChangeTresholdMultisigCall = ( + call: Call, +): call is ChangeTresholdMultisigCall => { + try { + if (call.contractAddress && call.entrypoint === "changeThreshold") { + validateAndParseAddress(call.contractAddress) + return true + } + } catch (e) { + // failure implies invalid + } + return false +} diff --git a/packages/extension/src/shared/explorer/type.ts b/packages/extension/src/shared/explorer/type.ts index 1a7554e38..7609d5a06 100644 --- a/packages/extension/src/shared/explorer/type.ts +++ b/packages/extension/src/shared/explorer/type.ts @@ -1,4 +1,4 @@ -import { Status } from "starknet" +import { ExtendedTransactionStatus } from "../transactions" export interface IExplorerTransactionParameters { /** @example tokenId @example response_len */ @@ -37,7 +37,7 @@ export interface IExplorerTransaction { maxFee?: string /** @example 0x490b183da37 */ actualFee: string - status: Status + status: ExtendedTransactionStatus statusData: string events: IExplorerTransactionEvent[] calls?: IExplorerTransactionCall[] diff --git a/packages/extension/src/shared/messages/AccountMessage.ts b/packages/extension/src/shared/messages/AccountMessage.ts index 3756a9031..b460ab6de 100644 --- a/packages/extension/src/shared/messages/AccountMessage.ts +++ b/packages/extension/src/shared/messages/AccountMessage.ts @@ -5,18 +5,6 @@ import { } from "../wallet.model" export type AccountMessage = - | { type: "NEW_ACCOUNT"; data: string } - | { - type: "NEW_ACCOUNT_RES" - data: { - account: WalletAccount - accounts: WalletAccount[] - } - } - | { type: "NEW_ACCOUNT_REJ"; data: { error: string } } - | { type: "DEPLOY_ACCOUNT"; data: BaseWalletAccount } - | { type: "DEPLOY_ACCOUNT_RES" } - | { type: "DEPLOY_ACCOUNT_REJ" } | { type: "DEPLOY_ACCOUNT_ACTION_SUBMITTED" data: { txHash: string; actionHash: string } @@ -25,9 +13,6 @@ export type AccountMessage = type: "DEPLOY_ACCOUNT_ACTION_FAILED" data: { actionHash: string; error?: string } } - | { type: "GET_ACCOUNTS"; data?: { showHidden: boolean } } - | { type: "GET_ACCOUNTS_RES"; data: WalletAccount[] } - | { type: "CONNECT_ACCOUNT"; data?: BaseWalletAccount } | { type: "CONNECT_ACCOUNT_RES"; data?: WalletAccount } | { type: "DISCONNECT_ACCOUNT" } | { type: "GET_SELECTED_ACCOUNT" } @@ -70,8 +55,19 @@ export type AccountMessage = } | { type: "GET_PUBLIC_KEY_RES" + data: { publicKey: string; account: BaseWalletAccount } + } + | { + type: "GET_NEXT_PUBLIC_KEY" + data: { networkId: string } + } + | { + type: "GET_NEXT_PUBLIC_KEY_RES" data: { publicKey: string } } + | { + type: "GET_NEXT_PUBLIC_KEY_REJ" + } | { type: "GET_ENCRYPTED_SEED_PHRASE" data: { encryptedSecret: string } @@ -82,7 +78,7 @@ export type AccountMessage = } | { type: "ACCOUNT_CHANGE_GUARDIAN" - data: { account: BaseWalletAccount; guardian: string | undefined } + data: { account: BaseWalletAccount; guardian: string } } | { type: "ACCOUNT_CHANGE_GUARDIAN_RES" diff --git a/packages/extension/src/shared/messages/ActionMessage.ts b/packages/extension/src/shared/messages/ActionMessage.ts index 8e6716e33..8269ca6fc 100644 --- a/packages/extension/src/shared/messages/ActionMessage.ts +++ b/packages/extension/src/shared/messages/ActionMessage.ts @@ -1,4 +1,4 @@ -import type { typedData } from "starknet" +import type { Signature, typedData } from "starknet" import { ExtensionActionItem } from "../actionQueue/types" @@ -15,5 +15,5 @@ export type ActionMessage = | { type: "SIGNATURE_FAILURE"; data: { actionHash: string } } | { type: "SIGNATURE_SUCCESS" - data: { r: string; s: string; actionHash: string } + data: { signature: Signature; actionHash: string } } diff --git a/packages/extension/src/shared/messages/MultisigMessage.ts b/packages/extension/src/shared/messages/MultisigMessage.ts new file mode 100644 index 000000000..97d2d8854 --- /dev/null +++ b/packages/extension/src/shared/messages/MultisigMessage.ts @@ -0,0 +1,74 @@ +import { + AddOwnerMultisigPayload, + RemoveOwnerMultisigPayload, + UpdateMultisigThresholdPayload, +} from "../multisig/multisig.model" +import { PendingMultisig } from "../multisig/types" +import { BaseWalletAccount, MultisigData, WalletAccount } from "../wallet.model" + +export type MultisigMessage = + | { + type: "NEW_MULTISIG_ACCOUNT" + data: MultisigData & { networkId: string } + } + | { + type: "NEW_MULTISIG_ACCOUNT_RES" + data: { + account: WalletAccount + accounts: WalletAccount[] + } + } + | { type: "NEW_MULTISIG_ACCOUNT_REJ"; data: { error: string } } + | { + type: "NEW_PENDING_MULTISIG" + data: { networkId: string } + } + | { type: "NEW_PENDING_MULTISIG_RES"; data: PendingMultisig } + | { type: "NEW_PENDING_MULTISIG_REJ"; data: { error: string } } + | { type: "DEPLOY_MULTISIG"; data: BaseWalletAccount } + | { type: "DEPLOY_MULTISIG_RES" } + | { type: "DEPLOY_MULTISIG_REJ" } + | { + type: "DEPLOY_MULTISIG_ACTION_SUBMITTED" + data: { txHash: string; actionHash: string } + } + | { + type: "DEPLOY_MULTISIG_ACTION_FAILED" + data: { actionHash: string; error?: string } + } + | { type: "ADD_MULTISIG_OWNERS"; data: AddOwnerMultisigPayload } + | { + type: "ADD_MULTISIG_OWNERS_REJ" + data: { error: string } + } + | { + type: "ADD_MULTISIG_OWNERS_RES" + } + | { type: "UPDATE_MULTISIG_THRESHOLD"; data: UpdateMultisigThresholdPayload } + | { + type: "UPDATE_MULTISIG_THRESHOLD_REJ" + data: { error: string } + } + | { + type: "UPDATE_MULTISIG_THRESHOLD_RES" + } + | { type: "REMOVE_MULTISIG_OWNER"; data: RemoveOwnerMultisigPayload } + | { + type: "REMOVE_MULTISIG_OWNER_REJ" + data: { error: string } + } + | { + type: "REMOVE_MULTISIG_OWNER_RES" + } + | { + type: "ADD_MULTISIG_TRANSACTION_SIGNATURE" + data: { requestId: string } + } + | { + type: "ADD_MULTISIG_TRANSACTION_SIGNATURE_RES" + data: { txHash: string } + } + | { + type: "ADD_MULTISIG_TRANSACTION_SIGNATURE_REJ" + data: { error: string } + } diff --git a/packages/extension/src/shared/messages/NetworkMessage.ts b/packages/extension/src/shared/messages/NetworkMessage.ts index 56ade20ac..efc60b956 100644 --- a/packages/extension/src/shared/messages/NetworkMessage.ts +++ b/packages/extension/src/shared/messages/NetworkMessage.ts @@ -1,26 +1,9 @@ -import { Network, NetworkStatus } from "../network" +import { Network } from "../network" import { WalletAccount } from "../wallet.model" export type NetworkMessage = - // ***** networks ***** - | { type: "GET_NETWORKS" } - | { type: "GET_NETWORKS_RES"; data: Network[] } - | { type: "GET_NETWORK"; data: Network["id"] } - | { type: "GET_NETWORK_RES"; data: Network } - | { type: "GET_CUSTOM_NETWORKS" } - | { type: "GET_CUSTOM_NETWORKS_RES"; data: Network[] } - | { type: "ADD_CUSTOM_NETWORKS"; data: Network[] } - | { type: "ADD_CUSTOM_NETWORKS_RES"; data: Network[] } - | { type: "REMOVE_CUSTOM_NETWORKS"; data: Network["id"][] } - | { type: "REMOVE_CUSTOM_NETWORKS_RES"; data: Network[] } - | { type: "GET_NETWORK_STATUSES"; data?: Network[] } // allows ui to get specific network statuses and defaults to all - | { - type: "GET_NETWORK_STATUSES_RES" - data: Partial> - } - // - used by dapps to request addition of custom network - | { type: "REQUEST_ADD_CUSTOM_NETWORK"; data: Network } + // | { type: "REQUEST_ADD_CUSTOM_NETWORK"; data: Network } | { type: "REQUEST_ADD_CUSTOM_NETWORK_RES"; data: { actionHash: string } } | { type: "REQUEST_ADD_CUSTOM_NETWORK_REJ" diff --git a/packages/extension/src/shared/messages/RecoveryMessage.ts b/packages/extension/src/shared/messages/RecoveryMessage.ts deleted file mode 100644 index 639cdedcf..000000000 --- a/packages/extension/src/shared/messages/RecoveryMessage.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type RecoveryMessage = - | { type: "RECOVER_BACKUP"; data: string } - | { type: "RECOVER_BACKUP_RES" } - | { type: "RECOVER_BACKUP_REJ"; data: string } - | { type: "RECOVER_SEEDPHRASE"; data: { secure: true; body: string } } - | { type: "RECOVER_SEEDPHRASE_RES" } - | { type: "RECOVER_SEEDPHRASE_REJ"; data: string } - | { type: "DOWNLOAD_BACKUP_FILE" } - | { type: "DOWNLOAD_BACKUP_FILE_RES" } diff --git a/packages/extension/src/shared/messages/index.ts b/packages/extension/src/shared/messages/index.ts index 47de726d6..59f05811a 100644 --- a/packages/extension/src/shared/messages/index.ts +++ b/packages/extension/src/shared/messages/index.ts @@ -5,9 +5,9 @@ import { IS_DEV } from "../utils/dev" import { AccountMessage } from "./AccountMessage" import { ActionMessage } from "./ActionMessage" import { MiscenalleousMessage } from "./MiscellaneousMessage" +import { MultisigMessage } from "./MultisigMessage" import { NetworkMessage } from "./NetworkMessage" import { PreAuthorisationMessage } from "./PreAuthorisationMessage" -import { RecoveryMessage } from "./RecoveryMessage" import { SessionMessage } from "./SessionMessage" import { ShieldMessage } from "./ShieldMessage" import { TokenMessage } from "./TokenMessage" @@ -20,12 +20,12 @@ export type MessageType = | MiscenalleousMessage | NetworkMessage | PreAuthorisationMessage - | RecoveryMessage | SessionMessage | TokenMessage | TransactionMessage | UdcMessage | ShieldMessage + | MultisigMessage export type WindowMessageType = MessageType & { forwarded?: boolean @@ -50,6 +50,8 @@ export function sendMessage( return _sendMessage(cleanMessage, options) } +export type SendMessage = typeof sendMessage + export async function waitForMessage< K extends MessageType["type"], T extends { type: K } & MessageType, @@ -62,6 +64,8 @@ export async function waitForMessage< ).then(([msg]: any) => msg.data) } +export type WaitForMessage = typeof waitForMessage + if ((window).PLAYWRIGHT || IS_DEV) { ;(window).messageStream = messageStream ;(window).sendMessage = sendMessage diff --git a/packages/extension/src/shared/multisig/account.ts b/packages/extension/src/shared/multisig/account.ts new file mode 100644 index 000000000..1d2cfb1ba --- /dev/null +++ b/packages/extension/src/shared/multisig/account.ts @@ -0,0 +1,257 @@ +import { + Abi, + Account, + AllowArray, + Call, + InvocationsDetails, + InvocationsSignerDetails, + InvokeFunctionResponse, + KeyPair, + ProviderInterface, + ProviderOptions, + hash, + number, + transaction as starknetTransaction, +} from "starknet" +import { Account as AccountV4 } from "starknet4" +import urlJoin from "url-join" + +import { ARGENT_MULTISIG_URL } from "../api/constants" +import { fetcher } from "../api/fetcher" +import { + chainIdToStarknetNetwork, + starknetNetworkToNetworkId, +} from "../utils/starknetNetwork" +import { + ApiMultisigAddRequestSignatureSchema, + ApiMultisigPostRequestTxnSchema, + ApiMultisigTxnResponseSchema, +} from "./multisig.model" +import { + addToMultisigPendingTransactions, + cancelPendingMultisigTransactions, + getMultisigPendingTransaction, + multisigPendingTransactionToTransaction, +} from "./pendingTransactionsStore" +import { MultisigSigner } from "./signer" +import { getMultisigAccountFromBaseWallet } from "./utils/baseMultisig" + +export class MultisigAccount extends Account { + public readonly multsigBaseUrl?: string + + constructor( + providerOrOptions: ProviderInterface | ProviderOptions, + address: string, + keyPairOrSigner: MultisigSigner | KeyPair, + multisigBaseUrl?: string, + ) { + const multisigSigner = + "getPubKey" in keyPairOrSigner + ? keyPairOrSigner + : new MultisigSigner(keyPairOrSigner) + super(providerOrOptions, address, multisigSigner) + this.multsigBaseUrl = multisigBaseUrl ?? ARGENT_MULTISIG_URL + } + + static fromAccount(account: Account, baseUrl?: string): MultisigAccount { + return new MultisigAccount( + account, + account.address, + account.signer, + baseUrl, + ) + } + + static isMultisig(account: Account | AccountV4): account is MultisigAccount { + return "multsigBaseUrl" in account && "addRequestSignature" in account + } + + public async execute( + calls: AllowArray, + abis?: Abi[] | undefined, + transactionsDetail: InvocationsDetails = {}, + ): Promise { + if (!this.multsigBaseUrl) { + throw Error("Argent Multisig endpoint is not defined") + } + + const transactions = Array.isArray(calls) ? calls : [calls] + const nonce = number.toHex( + number.toBN(transactionsDetail.nonce ?? (await this.getNonce())), + ) + const version = number.toBN(hash.transactionVersion).toString() + const chainId = await this.getChainId() + + const maxFee = transactionsDetail.maxFee ?? "0x77d87d677d1a0" // TODO: implement estimateFee (also cant be 0) + + const signerDetails: InvocationsSignerDetails = { + walletAddress: this.address, + chainId, + nonce, + version, + maxFee, + } + + const signature = await this.signer.signTransaction( + transactions, + signerDetails, + abis, + ) + + const [creator, r, s] = signature.map(number.toHexString) + + const starknetNetwork = chainIdToStarknetNetwork(chainId) + const networkId = starknetNetworkToNetworkId(starknetNetwork) + + const txnWithHexCalldata = transactions.map((transaction) => ({ + ...transaction, + calldata: number.getHexStringArray(transaction.calldata ?? []), + })) + const request = ApiMultisigPostRequestTxnSchema.parse({ + creator, + transaction: { + nonce: number.toHexString(nonce), + version: number.toHexString(version), + // todo remove once we have 0.11 + maxFee: number.toHexString(maxFee), + calls: txnWithHexCalldata, + }, + starknetSignature: { r, s }, + signature: { r, s }, + }) + + const url = urlJoin( + this.multsigBaseUrl, + starknetNetwork, + this.address, + "request", + ) + + const response = await fetcher(url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + }) + + const data = ApiMultisigTxnResponseSchema.parse(response) + + const computedTransactionHash = hash.calculateTransactionHash( + this.address, + version, + starknetTransaction.fromCallsToExecuteCalldata(transactions), + maxFee, + chainId, + nonce, + ) + + const transactionHash = + data.content.transactionHash ?? computedTransactionHash + + await addToMultisigPendingTransactions({ + ...data.content, + requestId: data.content.id, + timestamp: Date.now(), + type: "INVOKE_FUNCTION", + address: this.address, + networkId, + transactionHash, + notify: false, // Don't notify the creator of the transaction + }) + + return { + transaction_hash: transactionHash, + } + } + + public async addRequestSignature(requestId: string) { + if (!this.multsigBaseUrl) { + throw Error("Argent Multisig endpoint is not defined") + } + + const chainId = await this.getChainId() + const starknetNetwork = chainIdToStarknetNetwork(chainId) + const networkId = starknetNetworkToNetworkId(starknetNetwork) + + const pendingTransaction = await getMultisigPendingTransaction(requestId) + const multisig = await getMultisigAccountFromBaseWallet({ + address: this.address, + networkId, + }) + + if (!multisig) { + throw Error(`Multisig wallet with address ${this.address} not found`) + } + + if (!pendingTransaction) { + throw Error( + `Pending Multisig transaction with requestId ${requestId} not found`, + ) + } + + const { calls, maxFee, nonce, version } = pendingTransaction.transaction + + const signerDetails: InvocationsSignerDetails = { + walletAddress: this.address, + chainId, + nonce, + version, + maxFee, + } + + const signature = await this.signer.signTransaction(calls, signerDetails) + + const [signer, r, s] = signature.map(number.toHexString) + + const url = urlJoin( + this.multsigBaseUrl, + starknetNetwork, + this.address, + "request", + requestId, + "signature", + ) + + const request = ApiMultisigAddRequestSignatureSchema.parse({ + signer, + starknetSignature: { r, s }, + }) + + const response = await fetcher(url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + }) + + const data = ApiMultisigTxnResponseSchema.parse(response) + + if (data.content.approvedSigners.length === multisig.threshold) { + await multisigPendingTransactionToTransaction( + data.content.id, + data.content.state, + ) + + await cancelPendingMultisigTransactions({ + address: this.address, + networkId, + }) + } else { + await addToMultisigPendingTransactions({ + ...pendingTransaction, + approvedSigners: data.content.approvedSigners, + nonApprovedSigners: data.content.nonApprovedSigners, + state: data.content.state, + notify: false, + }) + } + + return { + transaction_hash: pendingTransaction.transactionHash, + } + } +} diff --git a/packages/extension/src/shared/multisig/mocks/executeTransaction.mock.ts b/packages/extension/src/shared/multisig/mocks/executeTransaction.mock.ts new file mode 100644 index 000000000..d19986dfe --- /dev/null +++ b/packages/extension/src/shared/multisig/mocks/executeTransaction.mock.ts @@ -0,0 +1,35 @@ +export default { + content: { + id: "d3ac7ef8-a102-4550-8667-83b07e000f11", + multisigAddress: + "0x00b69d25639176e56e2207c1a9b8c28637a16225265b08d5df318b656a5bae87", + creator: + "0x07cd076ed8aef015ae2a470659a59b568589b9c0b84c3e5cfded6abc1cd2bbb7", + transaction: { + calls: [ + { + calldata: [ + "0xb69d25639176e56e2207c1a9b8c28637a16225265b08d5df318b656a5bae87", + "0x5af3107a4000", + "0x0", + ], + entrypoint: "transfer", + contractAddress: + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + }, + ], + nonce: "0x2", + maxFee: "0x2d93021e7720", + version: "0x1", + }, + nonce: 2, + approvedSigners: [ + "0x07cd076ed8aef015ae2a470659a59b568589b9c0b84c3e5cfded6abc1cd2bbb7", + ], + nonApprovedSigners: [ + "0x008cc97d662506bd45756fe2187e52f5ae022a107db569437813d901767a4163", + "0x0000000000000000000000000000000000000000000000000000000000000002", + ], + state: "TX_ACCEPTED_L2", + }, +} diff --git a/packages/extension/src/shared/multisig/multisig.model.ts b/packages/extension/src/shared/multisig/multisig.model.ts new file mode 100644 index 000000000..fc2fe7365 --- /dev/null +++ b/packages/extension/src/shared/multisig/multisig.model.ts @@ -0,0 +1,130 @@ +import { z } from "zod" + +export const ApiMultisigContentSchema = z.object({ + address: z.string(), + creator: z.string(), + signers: z.array(z.string()), + threshold: z.number(), +}) + +export const ApiMultisigDataForSignerSchema = z.object({ + totalPages: z.number(), + totalElements: z.number(), + size: z.number(), + content: z.array(ApiMultisigContentSchema), +}) + +export const ApiMultisigCallSchema = z.object({ + contractAddress: z.string(), + entrypoint: z.string(), + calldata: z.array(z.string()).optional(), +}) + +export const ApiMultisigTransactionSchema = z.object({ + maxFee: z.string(), + nonce: z.string(), + version: z.string(), + calls: z.array(ApiMultisigCallSchema), +}) + +export const ApiMultisigStarknetSignature = z.object({ + r: z.string(), + s: z.string(), +}) + +export const ApiMultisigPostRequestTxnSchema = z.object({ + creator: z.string(), + transaction: ApiMultisigTransactionSchema, + starknetSignature: ApiMultisigStarknetSignature, + signature: ApiMultisigStarknetSignature, +}) + +export const ApiMultisigStateSchema = z.union([ + z.literal("AWAITING_SIGNATURES"), + z.literal("SUBMITTING"), + z.literal("SUBMITTED"), + z.literal("TX_PENDING"), + z.literal("TX_ACCEPTED_L2"), + z.literal("COMPLETE"), + z.literal("ERROR"), + z.literal("CANCELLED"), +]) + +export const ApiMultisigTxnResponseSchema = z.object({ + content: z.object({ + id: z.string(), + multisigAddress: z.string(), + creator: z.string(), + transaction: ApiMultisigTransactionSchema, + nonce: z.number(), + approvedSigners: z.array(z.string()), + nonApprovedSigners: z.array(z.string()), + state: ApiMultisigStateSchema, + transactionHash: z.string().optional(), + }), +}) + +export const ApiMultisigGetRequestsSchema = z.object({ + totalPages: z.number(), + totalElements: z.number(), + size: z.number(), + content: z.array( + z.object({ + id: z.string(), + multisigAddress: z.string(), + creator: z.string(), + transaction: ApiMultisigTransactionSchema, + nonce: z.number(), + approvedSigners: z.array(z.string()), + nonApprovedSigners: z.array(z.string()), + state: ApiMultisigStateSchema, + transactionHash: z.string().optional(), + }), + ), +}) + +export const ApiMultisigAddRequestSignatureSchema = z.object({ + signer: z.string(), + starknetSignature: ApiMultisigStarknetSignature, +}) + +export type ApiMultisigContent = z.infer +export type ApiMultisigDataForSigner = z.infer< + typeof ApiMultisigDataForSignerSchema +> +export type ApiMultisigCall = z.infer +export type ApiMultisigTransaction = z.infer< + typeof ApiMultisigTransactionSchema +> +export type ApiMultisigPostRequestTxn = z.infer< + typeof ApiMultisigPostRequestTxnSchema +> + +export type ApiMultisigGetRequests = z.infer< + typeof ApiMultisigGetRequestsSchema +> +export type ApiMultisigState = z.infer +export type ApiMultisigTxnResponse = z.infer< + typeof ApiMultisigTxnResponseSchema +> +export type ApiMultisigAddRequestSignature = z.infer< + typeof ApiMultisigAddRequestSignatureSchema +> + +export type AddOwnerMultisigPayload = { + address: string + newThreshold: number + signersToAdd: string[] + currentThreshold?: number +} + +export type RemoveOwnerMultisigPayload = { + address: string + newThreshold: number + signerToRemove: string +} + +export type UpdateMultisigThresholdPayload = { + newThreshold: number + address: string +} diff --git a/packages/extension/src/shared/multisig/multisig.service.ts b/packages/extension/src/shared/multisig/multisig.service.ts new file mode 100644 index 000000000..b4fd13a6e --- /dev/null +++ b/packages/extension/src/shared/multisig/multisig.service.ts @@ -0,0 +1,176 @@ +import { Call } from "starknet" +import urlJoin from "url-join" + +import { ARGENT_MULTISIG_URL } from "../api/constants" +import { Fetcher, fetcher } from "../api/fetcher" +import { Network } from "../network" +import { + networkIdToStarknetNetwork, + networkToStarknetNetwork, +} from "../utils/starknetNetwork" +import { urlWithQuery } from "../utils/url" +import { + ApiMultisigContent, + ApiMultisigDataForSigner, + ApiMultisigDataForSignerSchema, + ApiMultisigGetRequests, + ApiMultisigGetRequestsSchema, + ApiMultisigTxnResponse, +} from "./multisig.model" + +const multisigTransactionTypes = { + addSigners: "addSigners", + changeThreshold: "changeThreshold", + removeSigners: "removeSigners", + replaceSigner: "replaceSigner", +} as const +export interface IFetchMultisigDataForSigner { + signer: string + network: Network + fetcher?: Fetcher +} + +export async function fetchMultisigDataForSigner({ + signer, + network, + fetcher: fetcherImpl = fetcher, +}: IFetchMultisigDataForSigner): Promise { + if (!ARGENT_MULTISIG_URL) { + throw "Argent Multisig endpoint is not defined" + } + + const starknetNetwork = networkToStarknetNetwork(network) + + const url = urlWithQuery([ARGENT_MULTISIG_URL, starknetNetwork], { + signer, + }) + + const data = await fetcherImpl(url, { + method: "GET", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + }) + + return ApiMultisigDataForSignerSchema.parse(data) +} + +export const getMultisigTransactionType = (transactions: Call[]) => { + const entryPoints = transactions.map((tx) => tx.entrypoint) + switch (true) { + case entryPoints.includes("addSigners"): { + return multisigTransactionTypes.addSigners + } + case entryPoints.includes("changeThreshold"): { + return multisigTransactionTypes.changeThreshold + } + default: { + return undefined + } + } +} + +export interface IFetchMultisigAccountData { + address: string + networkId: string + fetcher?: Fetcher +} + +export const fetchMultisigAccountData = async ({ + address, + networkId, + fetcher: fetcherImpl = fetcher, +}: IFetchMultisigAccountData) => { + try { + if (!ARGENT_MULTISIG_URL) { + throw new Error("Multisig endpoint is not defined") + } + + const starknetNetwork = networkIdToStarknetNetwork(networkId) + + const url = urlJoin(ARGENT_MULTISIG_URL, starknetNetwork, address) + + return fetcherImpl<{ + content: ApiMultisigContent + }>(url, { + method: "GET", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + }) + } catch (e) { + throw new Error(`An error occured ${e}`) + } +} + +export const fetchMultisigRequestData = async ({ + address, + networkId, + requestId, +}: { + address: string + networkId: string + requestId: string +}) => { + try { + if (!ARGENT_MULTISIG_URL) { + throw new Error("Multisig endpoint is not defined") + } + + const starknetNetwork = networkIdToStarknetNetwork(networkId) + const url = urlJoin( + ARGENT_MULTISIG_URL, + starknetNetwork, + address, + "request", + requestId, + ) + + return fetcher(url, { + method: "GET", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + }) + } catch (e) { + throw new Error(`An error occured ${e}`) + } +} + +export const fetchMultisigRequests = async ({ + address, + networkId, +}: { + address: string + networkId: string +}): Promise => { + try { + if (!ARGENT_MULTISIG_URL) { + throw new Error("Multisig endpoint is not defined") + } + + const starknetNetwork = networkIdToStarknetNetwork(networkId) + + const url = urlJoin( + ARGENT_MULTISIG_URL, + starknetNetwork, + address, + "request", + ) + + const data = await fetcher(url, { + method: "GET", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + }) + + return ApiMultisigGetRequestsSchema.parse(data) + } catch (e) { + throw new Error(`An error occured ${e}`) + } +} diff --git a/packages/extension/src/shared/multisig/pendingTransactionsStore.ts b/packages/extension/src/shared/multisig/pendingTransactionsStore.ts new file mode 100644 index 000000000..218b3b53e --- /dev/null +++ b/packages/extension/src/shared/multisig/pendingTransactionsStore.ts @@ -0,0 +1,138 @@ +import { memoize } from "lodash-es" +import { AllowArray } from "starknet" + +import { addTransaction } from "../../background/transactions/store" +import { ArrayStorage } from "../storage" +import { SelectorFn } from "../storage/types" +import { ExtendedTransactionType } from "../transactions" +import { BaseWalletAccount } from "../wallet.model" +import { getAccountIdentifier } from "../wallet.service" +import { ApiMultisigState, ApiMultisigTransaction } from "./multisig.model" +import { getMultisigAccountFromBaseWallet } from "./utils/baseMultisig" + +export type MultisigPendingTransaction = { + requestId: string + address: string + networkId: string + timestamp: number + transaction: ApiMultisigTransaction + type?: ExtendedTransactionType + approvedSigners: string[] + nonApprovedSigners: string[] + state: ApiMultisigState + creator: string + nonce: number + transactionHash: string + notify: boolean +} +export const multisigPendingTransactionsStore = + new ArrayStorage([], { + namespace: "core:multisig:pendingTransactions", + compare: (a, b) => a.requestId === b.requestId, + }) + +export const byAccountSelector = memoize( + (account?: BaseWalletAccount) => (transaction: MultisigPendingTransaction) => + Boolean(account && transaction.address === account.address), + (account) => (account ? getAccountIdentifier(account) : "unknown-account"), +) + +export async function getMultisigPendingTransactions( + selector: SelectorFn = () => true, +): Promise { + return multisigPendingTransactionsStore.get(selector) +} + +export async function getMultisigPendingTransaction( + requestId: string, +): Promise { + const pendingTransactions = await getMultisigPendingTransactions( + (transaction) => transaction.requestId === requestId, + ) + + if (pendingTransactions.length === 0) { + return undefined + } + + return pendingTransactions[0] +} + +export async function addToMultisigPendingTransactions( + pendingTransactions: AllowArray, +): Promise { + return multisigPendingTransactionsStore.push(pendingTransactions) +} + +export async function removeFromMultisigPendingTransactions( + pendingTransactions: AllowArray, +): Promise { + return multisigPendingTransactionsStore.remove(pendingTransactions) +} + +export async function multisigPendingTransactionToTransaction( + requestId: string, + state: ApiMultisigState, +): Promise { + const pendingTxn = await getMultisigPendingTransaction(requestId) + + if (!pendingTxn) { + throw new Error("Pending Multisig transaction not found") + } + + const { transaction, type, transactionHash, networkId, address } = pendingTxn + + const multisigAccount = await getMultisigAccountFromBaseWallet({ + address, + networkId, + }) + + if (!multisigAccount) { + throw new Error("Multisig account not found") + } + + if (state === "AWAITING_SIGNATURES") { + throw new Error("Transaction is still awaiting signatures") + } + + await addTransaction( + { + hash: transactionHash, + account: multisigAccount, + meta: { + type, + transactions: transaction.calls, + }, + }, + state === "CANCELLED" ? "CANCELLED" : "RECEIVED", + ) + + await removeFromMultisigPendingTransactions(pendingTxn) +} + +export async function setHasSeenTransaction(requestId: string) { + const pendingTxn = await getMultisigPendingTransaction(requestId) + + if (!pendingTxn || !pendingTxn.notify) { + return + } + + return await multisigPendingTransactionsStore.push({ + ...pendingTxn, + notify: false, + }) +} + +export const cancelPendingMultisigTransactions = async ( + account: BaseWalletAccount, +) => { + const pendingTransactions = await getMultisigPendingTransactions( + byAccountSelector(account), + ) + + for (const pendingTransaction of pendingTransactions) { + await multisigPendingTransactionToTransaction( + pendingTransaction.requestId, + "CANCELLED", + ) + } +} diff --git a/packages/extension/src/shared/multisig/signer.ts b/packages/extension/src/shared/multisig/signer.ts new file mode 100644 index 000000000..2e5c622ac --- /dev/null +++ b/packages/extension/src/shared/multisig/signer.ts @@ -0,0 +1,44 @@ +import { + Abi, + Call, + DeployAccountSignerDetails, + InvocationsSignerDetails, + KeyPair, + Signature, + Signer, + number, +} from "starknet" + +export class MultisigSigner extends Signer { + constructor(keyPair: KeyPair) { + super(keyPair) + } + + public async signDeployAccountTransaction( + deployAccountSignerDetails: DeployAccountSignerDetails, + ): Promise { + const signatures = await super.signDeployAccountTransaction( + deployAccountSignerDetails, + ) + + const publicSigner = await this.getPubKey() + + return [number.toFelt(publicSigner), ...signatures] + } + + public async signTransaction( + transactions: Call[], + transactionsDetail: InvocationsSignerDetails, + abis?: Abi[] | undefined, + ): Promise { + const signatures = await super.signTransaction( + transactions, + transactionsDetail, + abis, + ) + + const publicSigner = await this.getPubKey() + + return [number.toFelt(publicSigner), ...signatures] + } +} diff --git a/packages/extension/src/shared/multisig/store.ts b/packages/extension/src/shared/multisig/store.ts new file mode 100644 index 000000000..8ba870d53 --- /dev/null +++ b/packages/extension/src/shared/multisig/store.ts @@ -0,0 +1,20 @@ +import { ArrayStorage } from "../storage" +import { BaseMultisigWalletAccount } from "../wallet.model" +import { accountsEqual } from "../wallet.service" +import { BasePendingMultisig, PendingMultisig } from "./types" + +export const multisigBaseWalletStore = + new ArrayStorage([], { + namespace: "core:multisig:baseWallet", + compare: accountsEqual, + }) + +export const pendingMultisigEqual = ( + a: BasePendingMultisig, + b: BasePendingMultisig, +) => a.networkId === b.networkId && a.publicKey === b.publicKey + +export const pendingMultisigStore = new ArrayStorage([], { + namespace: "core:multisig:pending", + compare: pendingMultisigEqual, +}) diff --git a/packages/extension/src/shared/multisig/tracking.ts b/packages/extension/src/shared/multisig/tracking.ts new file mode 100644 index 000000000..76bf7f029 --- /dev/null +++ b/packages/extension/src/shared/multisig/tracking.ts @@ -0,0 +1,207 @@ +import { flatMap, isEmpty, partition } from "lodash-es" +import { hash, transaction } from "starknet" + +import { + sendMultisigAccountReadyNotification, + sendMultisigTransactionNotification, +} from "../../background/notification" +import { getNetwork } from "../network" +import { networkIdToChainId } from "../utils/starknetNetwork" +import { + BaseMultisigWalletAccount, + MultisigWalletAccount, +} from "../wallet.model" +import { + fetchMultisigAccountData, + fetchMultisigDataForSigner, + fetchMultisigRequests, +} from "./multisig.service" +import { + MultisigPendingTransaction, + addToMultisigPendingTransactions, + getMultisigPendingTransactions, + multisigPendingTransactionToTransaction, +} from "./pendingTransactionsStore" +import { multisigBaseWalletStore } from "./store" +import { BasePendingMultisig } from "./types" +import { getMultisigAccounts } from "./utils/baseMultisig" +import { pendingMultisigToMultisig } from "./utils/pendingMultisig" +import { getAllPendingMultisigs } from "./utils/pendingMultisig" + +export interface MultisigTracker { + updateDataForPendingMultisig: () => Promise + updateDataForAccounts: () => Promise + updateTransactions: () => Promise +} + +export const multisigTracker: MultisigTracker = { + async updateDataForPendingMultisig() { + // get all base mutlisig accounts + const pendingMultisigs = await getAllPendingMultisigs() + + // Check with backend for any updates + const updater = async (pendingMultisig: BasePendingMultisig) => { + const network = await getNetwork(pendingMultisig.networkId) + + const { content } = await fetchMultisigDataForSigner({ + signer: pendingMultisig.publicKey, + network, + }) + + if (isEmpty(content)) { + // early return if the content is empty + return + } + + const baseMultisig: BaseMultisigWalletAccount = { + address: content[0].address, + networkId: pendingMultisig.networkId, + signers: content[0].signers, + threshold: content[0].threshold, + creator: content[0].creator, + publicKey: pendingMultisig.publicKey, + } + + sendMultisigAccountReadyNotification(baseMultisig.address) + // If the content is not empty, it means that the account is now a multisig account + return pendingMultisigToMultisig(pendingMultisig, baseMultisig) + } + await Promise.all(pendingMultisigs.map(updater)) + }, + + async updateDataForAccounts() { + // get all mutlisig accounts + const multisigAccounts = await getMultisigAccounts() + // Check with backend for any updates + const updater = async ({ + address, + networkId, + publicKey, + }: MultisigWalletAccount): Promise => { + const { content } = await fetchMultisigAccountData({ + address, + networkId, + }) + + return { + ...content, + address, + networkId, + publicKey, + } + } + + const updated = await Promise.all(multisigAccounts.map(updater)) + + // Update the accounts + await multisigBaseWalletStore.push(updated) + }, + async updateTransactions() { + // fetch all requests for full multisig accounts + const multisigs = await getMultisigAccounts() + let localPendingRequests = await getMultisigPendingTransactions() + + const fetcher = async (multisig: MultisigWalletAccount) => { + const data = await fetchMultisigRequests({ + address: multisig.address, + networkId: multisig.networkId, + }) + + return { + ...data, + address: multisig.address, + networkId: multisig.networkId, + } + } + + const allRequestsData = await Promise.all(multisigs.map(fetcher)) + + const allRequests = flatMap(allRequestsData, (a) => + a.content.map((c) => ({ + ...c, + address: a.address, + networkId: a.networkId, + })), + ) + + const [pendingRequests, fulfilledRequests] = partition( + allRequests, + (r) => r.state === "AWAITING_SIGNATURES", + ) + + // Update the state of local pending requests with the state of the request from the backend. Also add new requests + const updatedPendingMultisigTransactions = + pendingRequests.map((request) => { + const localPendingRequest = localPendingRequests.find( + (r) => r.requestId === request.id, + ) + + if (localPendingRequest) { + // if the request is already in the local pending requests, update the state + return { + ...localPendingRequest, + state: request.state, + approvedSigners: request.approvedSigners, + nonApprovedSigners: request.nonApprovedSigners, + } + } + + const { version, maxFee, calls, nonce } = request.transaction + + const computedTransactionHash = hash.calculateTransactionHash( + request.address, + version, + transaction.fromCallsToExecuteCalldata(calls), + maxFee, + networkIdToChainId(request.networkId), + nonce, + ) + + // if the request is not in the local pending requests, add it + // Show notifications before adding to the store + sendMultisigTransactionNotification(computedTransactionHash) + + return { + ...request, + requestId: request.id, + timestamp: Date.now(), + transactionHash: request.transactionHash ?? computedTransactionHash, + notify: true, + } + }) + + if (updatedPendingMultisigTransactions.length > 0) { + // if there are any updated pending transactions, add them to the store + await addToMultisigPendingTransactions(updatedPendingMultisigTransactions) + } + + // Update local pending requests with fulfilled requests + localPendingRequests = await getMultisigPendingTransactions() // get the updated local pending requests + + const updatedFulfilledMultisigTransactions: MultisigPendingTransaction[] = + localPendingRequests + .map((request) => { + const fulfilledRequest = fulfilledRequests.find( + (r) => r.id === request.requestId, + ) + if (fulfilledRequest) { + return { + ...request, + requestId: request.requestId, + state: fulfilledRequest.state, + } + } + + return null + }) + .filter((r): r is MultisigPendingTransaction => r !== null) // simplify the filter condition + + // if there are any pending transactions that are fulfilled, remove them from the multisigPendingTransactions store + // and add them to the transactions store + await Promise.all( + updatedFulfilledMultisigTransactions.map((r) => + multisigPendingTransactionToTransaction(r.requestId, r.state), + ), + ) + }, +} diff --git a/packages/extension/src/shared/multisig/types.ts b/packages/extension/src/shared/multisig/types.ts new file mode 100644 index 000000000..09b8b89e0 --- /dev/null +++ b/packages/extension/src/shared/multisig/types.ts @@ -0,0 +1,12 @@ +import { WalletAccountSigner } from "../wallet.model" + +export interface BasePendingMultisig { + networkId: string + publicKey: string +} +export interface PendingMultisig extends BasePendingMultisig { + signer: WalletAccountSigner + name: string + type: "multisig" + hidden?: boolean +} diff --git a/packages/extension/src/shared/multisig/utils/baseMultisig.ts b/packages/extension/src/shared/multisig/utils/baseMultisig.ts new file mode 100644 index 000000000..e10f909f5 --- /dev/null +++ b/packages/extension/src/shared/multisig/utils/baseMultisig.ts @@ -0,0 +1,102 @@ +import { AllowArray } from "starknet" + +import { withoutHiddenSelector } from "../../account/selectors" +import { accountService } from "../../account/service" +import { SelectorFn } from "../../storage/types" +import { + BaseMultisigWalletAccount, + BaseWalletAccount, + MultisigWalletAccount, + WalletAccount, +} from "../../wallet.model" +import { accountsEqual } from "../../wallet.service" +import { multisigBaseWalletStore } from "../store" + +export async function getBaseMultisigAccounts(): Promise< + BaseMultisigWalletAccount[] +> { + return multisigBaseWalletStore.get() +} + +export async function getMultisigAccounts( + selector: SelectorFn = withoutHiddenSelector, +): Promise { + const baseMultisigAccounts = await getBaseMultisigAccounts() + const walletAccounts = await accountService.get(selector) + + return baseMultisigAccounts + .map((baseMultisigAccount) => { + const walletAccount = walletAccounts.find((walletAccount) => + accountsEqual(walletAccount, baseMultisigAccount), + ) + + return { + ...walletAccount, + ...baseMultisigAccount, + type: "multisig", + } + }) + .filter((account): account is MultisigWalletAccount => !!account) // If the account is hidden, it will be undefined and filtered out here +} + +export async function getMultisigAccountFromBaseWallet( + baseWalletAccount: BaseWalletAccount, +): Promise { + const baseMultisigWalletAccounts = await getBaseMultisigAccounts() + const walletAccounts = await accountService.get() + + const baseMultisigAccount = baseMultisigWalletAccounts.find((acc) => + accountsEqual(acc, baseWalletAccount), + ) + + const walletAccount = walletAccounts.find((acc) => + accountsEqual(acc, baseWalletAccount), + ) + + if (!baseMultisigAccount || !walletAccount) { + return undefined + } + + return { + ...walletAccount, + ...baseMultisigAccount, + type: "multisig", + } +} + +export async function addBaseMultisigAccounts( + account: AllowArray, +): Promise { + await multisigBaseWalletStore.push(account) +} + +export async function addMultisigAccounts( + account: AllowArray, +): Promise { + await accountService.upsert(account) + await addBaseMultisigAccounts(account) +} + +export async function updateBaseMultisigAccount( + baseAccount: BaseMultisigWalletAccount, +) { + await multisigBaseWalletStore.push(baseAccount) + + return getMultisigAccountFromBaseWallet(baseAccount) +} + +export async function removeMultisigAccount( + baseAccount: BaseMultisigWalletAccount, +): Promise { + await multisigBaseWalletStore.remove((account) => + accountsEqual(account, baseAccount), + ) + + await accountService.remove(baseAccount) +} + +export async function hideMultisig( + baseAccount: BaseMultisigWalletAccount, +): Promise { + await accountService.setHide(true, baseAccount) +} diff --git a/packages/extension/src/shared/multisig/utils/pendingMultisig.ts b/packages/extension/src/shared/multisig/utils/pendingMultisig.ts new file mode 100644 index 000000000..2129bced2 --- /dev/null +++ b/packages/extension/src/shared/multisig/utils/pendingMultisig.ts @@ -0,0 +1,116 @@ +import { AllowArray } from "starknet" + +import { getNetwork } from "../../network" +import { SelectorFn } from "../../storage/types" +import { + BaseMultisigWalletAccount, + MultisigWalletAccount, +} from "../../wallet.model" +import { pendingMultisigEqual, pendingMultisigStore } from "./../store" +import { BasePendingMultisig, PendingMultisig } from "../types" +import { addMultisigAccounts } from "./baseMultisig" +import { + getPendingMultisigSelector, + withoutHiddenPendingMultisig, +} from "./selectors" + +export async function getAllPendingMultisigs( + selector: SelectorFn = withoutHiddenPendingMultisig, +): Promise { + return pendingMultisigStore.get(selector) +} + +export async function getPendingMultisig( + basePendingMultisig: BasePendingMultisig, +) { + const pendingMultisigs = await getAllPendingMultisigs() + return pendingMultisigs.find((pendingMultisig) => + pendingMultisigEqual(pendingMultisig, basePendingMultisig), + ) +} + +export async function addPendingMultisig( + pendingMultisig: AllowArray, +): Promise { + return pendingMultisigStore.push(pendingMultisig) +} + +export async function removePendingMultisig( + basePendingMultisig: BasePendingMultisig, +): Promise { + const pendingMultisig = await getPendingMultisig(basePendingMultisig) + + if (!pendingMultisig) { + throw new Error("Pending multisig to remove not found") + } + + return pendingMultisigStore.remove(pendingMultisig) +} + +export async function pendingMultisigToMultisig( + basePendingMultisig: BasePendingMultisig, + multisigData: BaseMultisigWalletAccount, +) { + const network = await getNetwork(multisigData.networkId) + + const pendingMultisig = await getPendingMultisig(basePendingMultisig) + + if (!pendingMultisig) { + throw new Error("Pending multisig to convert to Multisig not found") + } + + const fullMultisig: MultisigWalletAccount = { + address: multisigData.address, + name: pendingMultisig.name, + type: "multisig", + networkId: pendingMultisig.networkId, + signer: pendingMultisig.signer, + signers: multisigData.signers, + publicKey: pendingMultisig.publicKey, + threshold: multisigData.threshold, + creator: multisigData.creator, + network, + needsDeploy: false, + hidden: false, + } + + await removePendingMultisig(pendingMultisig) + await addMultisigAccounts(fullMultisig) + return fullMultisig +} + +export async function updatePendingMultisigName( + base: BasePendingMultisig, + name: string, +) { + const [hit] = await getAllPendingMultisigs(getPendingMultisigSelector(base)) + if (!hit) { + return + } + await pendingMultisigStore.push({ + ...hit, + name, + }) +} + +export async function hidePendingMultisig(base: BasePendingMultisig) { + const [hit] = await getAllPendingMultisigs(getPendingMultisigSelector(base)) + if (!hit) { + return + } + await pendingMultisigStore.push({ + ...hit, + hidden: true, + }) +} + +export async function unhidePendingMultisig(base: BasePendingMultisig) { + const [hit] = await getAllPendingMultisigs(getPendingMultisigSelector(base)) + if (!hit) { + return + } + await pendingMultisigStore.push({ + ...hit, + hidden: false, + }) +} diff --git a/packages/extension/src/shared/multisig/utils/selectors.ts b/packages/extension/src/shared/multisig/utils/selectors.ts new file mode 100644 index 000000000..6254d8e7a --- /dev/null +++ b/packages/extension/src/shared/multisig/utils/selectors.ts @@ -0,0 +1,18 @@ +import { memoize } from "lodash-es" + +import { pendingMultisigEqual } from "../store" +import { BasePendingMultisig, PendingMultisig } from "../types" + +export const getPendingMultisigSelector = memoize( + (base: BasePendingMultisig) => (multisig: PendingMultisig) => + pendingMultisigEqual(multisig, base), +) + +export const withoutHiddenPendingMultisig = ( + pendingMultisig: PendingMultisig, +) => !pendingMultisig.hidden + +export const withHiddenPendingMultisig = memoize( + () => true, + () => "default", +) diff --git a/packages/extension/src/shared/network/defaults.ts b/packages/extension/src/shared/network/defaults.ts index a1afc1677..b05496844 100644 --- a/packages/extension/src/shared/network/defaults.ts +++ b/packages/extension/src/shared/network/defaults.ts @@ -1,4 +1,4 @@ -import { Network } from "./type" +import type { Network } from "./type" const DEV_ONLY_NETWORKS: Network[] = [ { @@ -7,11 +7,12 @@ const DEV_ONLY_NETWORKS: Network[] = [ chainId: "SN_GOERLI", baseUrl: "https://external.integration.starknet.io", accountClassHash: { - argentAccount: + standard: "0x033434ad846cdd5f23eb73ff09fe6fddd568284a0fb7d1be20ee482f044dabe2", }, multicallAddress: "0x05754af3760f3356da99aea5c3ec39ccac7783d925a19666ebbeca58ff0087f4", + status: "unknown", }, ] @@ -23,11 +24,12 @@ export const defaultNetworks: Network[] = [ baseUrl: "https://alpha-mainnet.starknet.io", explorerUrl: "https://voyager.online", accountClassHash: { - argentAccount: + standard: "0x033434ad846cdd5f23eb73ff09fe6fddd568284a0fb7d1be20ee482f044dabe2", }, multicallAddress: "0x05754af3760f3356da99aea5c3ec39ccac7783d925a19666ebbeca58ff0087f4", + status: "unknown", readonly: true, }, { @@ -37,17 +39,20 @@ export const defaultNetworks: Network[] = [ baseUrl: "https://alpha4.starknet.io", explorerUrl: "https://goerli.voyager.online", accountClassHash: { - argentAccount: + standard: "0x033434ad846cdd5f23eb73ff09fe6fddd568284a0fb7d1be20ee482f044dabe2", - argentPluginAccount: + plugin: "0x4ee23ad83fb55c1e3fac26e2cd951c60abf3ddc851caa9a7fbb9f5eddb2091", - argentBetterMulticallAccount: + betterMulticall: "0x057c2f22f0209a819e6c60f78ad7d3690f82ade9c0c68caea492151698934ede", argent5MinuteEscapeTestingAccount: "0x058a42e2553e65e301b7f22fb89e4a2576e9867c1e20bb1d32746c74ff823639", + multisig: + "0x04ba0f956a26b5e0d7e491661a0c56a6eb0fc25d49912677de09439673c3c828", }, multicallAddress: "0x05754af3760f3356da99aea5c3ec39ccac7783d925a19666ebbeca58ff0087f4", + status: "unknown", readonly: true, }, { @@ -57,17 +62,20 @@ export const defaultNetworks: Network[] = [ baseUrl: "https://alpha4-2.starknet.io", explorerUrl: "https://goerli-2.voyager.online/", accountClassHash: { - argentAccount: + standard: "0x033434ad846cdd5f23eb73ff09fe6fddd568284a0fb7d1be20ee482f044dabe2", - argentPluginAccount: + plugin: "0x4ee23ad83fb55c1e3fac26e2cd951c60abf3ddc851caa9a7fbb9f5eddb2091", - argentBetterMulticallAccount: + multisig: + "0x04ba0f956a26b5e0d7e491661a0c56a6eb0fc25d49912677de09439673c3c828", + betterMulticall: "0x057c2f22f0209a819e6c60f78ad7d3690f82ade9c0c68caea492151698934ede", argent5MinuteEscapeTestingAccount: "0x058a42e2553e65e301b7f22fb89e4a2576e9867c1e20bb1d32746c74ff823639", }, multicallAddress: "0x05754af3760f3356da99aea5c3ec39ccac7783d925a19666ebbeca58ff0087f4", + status: "unknown", }, ...(process.env.NODE_ENV === "development" ? DEV_ONLY_NETWORKS : []), { @@ -75,8 +83,9 @@ export const defaultNetworks: Network[] = [ chainId: "SN_GOERLI", baseUrl: "http://localhost:5050", explorerUrl: "https://devnet.starkscan.co", + status: "ok", name: "Localhost 5050", }, ] -export const defaultNetwork = defaultNetworks[1] +export const defaultNetwork = defaultNetworks[0] // default to mainnet. Previously was testnet. diff --git a/packages/extension/src/shared/network/index.ts b/packages/extension/src/shared/network/index.ts index 6f77032f7..e176e7cba 100644 --- a/packages/extension/src/shared/network/index.ts +++ b/packages/extension/src/shared/network/index.ts @@ -1,11 +1,10 @@ import { mergeArrayStableWith } from "../storage/array" import { SelectorFn } from "../storage/types" -import { assertSchema } from "../utils/schema" +import { defaultNetworks } from "./defaults" import { networkSchema } from "./schema" import { networkSelector, networkSelectorByChainId } from "./selectors" import { - customNetworksStore, - defaultCustomNetworks, + allNetworksStore, defaultReadonlyNetworks, equalNetwork, } from "./storage" @@ -22,8 +21,8 @@ export function extendByDefaultReadonlyNetworks(customNetworks: Network[]) { export async function getNetworks( selector?: SelectorFn, ): Promise { - const customNetworks = await customNetworksStore.get() - const allNetworks = extendByDefaultReadonlyNetworks(customNetworks) + const storedNetworks = await allNetworksStore.get() + const allNetworks = extendByDefaultReadonlyNetworks(storedNetworks) if (selector) { return allNetworks.filter(selector) } @@ -41,22 +40,20 @@ export async function getNetworkByChainId(chainId: string) { } export const addNetwork = async (network: Network) => { - await assertSchema(networkSchema, network) - return customNetworksStore.push(network) + networkSchema.parse(network) + await allNetworksStore.push(network) } export const removeNetwork = async (networkId: string) => { - return customNetworksStore.remove(networkSelector(networkId)) + return allNetworksStore.remove(networkSelector(networkId)) } export const restoreDefaultCustomNetworks = async () => { - const customNetworks = await customNetworksStore.get() - await customNetworksStore.remove(customNetworks) - await customNetworksStore.push(defaultCustomNetworks) + await allNetworksStore.remove((network) => !!network.id) + await allNetworksStore.push(defaultNetworks) } export type { Network, NetworkStatus } from "./type" -export { customNetworksStore } from "./storage" export { networkSchema } from "./schema" export { getProvider } from "./provider" export { defaultNetworks, defaultNetwork } from "./defaults" diff --git a/packages/extension/src/shared/network/provider.ts b/packages/extension/src/shared/network/provider.ts index d87fffd61..736066daa 100644 --- a/packages/extension/src/shared/network/provider.ts +++ b/packages/extension/src/shared/network/provider.ts @@ -1,6 +1,7 @@ import { memoize } from "lodash-es" import { SequencerProvider } from "starknet" import { SequencerProvider as SequencerProviderv4 } from "starknet4" +import { SequencerProvider as SequencerProviderv5 } from "starknet5" import { Network } from "./type" @@ -19,3 +20,11 @@ const getProviderV4ForBaseUrl = memoize((baseUrl: string) => { export function getProviderv4(network: Network) { return getProviderV4ForBaseUrl(network.baseUrl) } + +const getProviderV5ForBaseUrl = memoize((baseUrl: string) => { + return new SequencerProviderv5({ baseUrl }) +}) + +export function getProviderv5(network: Network) { + return getProviderV5ForBaseUrl(network.baseUrl) +} diff --git a/packages/extension/src/shared/network/schema.ts b/packages/extension/src/shared/network/schema.ts index 700bdbeb5..6043e84a6 100644 --- a/packages/extension/src/shared/network/schema.ts +++ b/packages/extension/src/shared/network/schema.ts @@ -1,49 +1,84 @@ -import { Schema, boolean, object, string } from "yup" - -import { Network } from "./type" +import { z } from "zod" const REGEX_HEXSTRING = /^0x[a-f0-9]+$/i const REGEX_URL_WITH_LOCAL = /^(https?:\/\/)(?:\S+(?::\S*)?@)?(?:(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*\.?)(?::\d{2,5})?(?:[/?#]\S*)?$/ -export const networkSchema: Schema = object() - .required() - .shape({ - id: string().required().min(2).max(31), - name: string().required().min(2).max(128), - chainId: string() - .required() - .min(2) - .max(31) // max 31 characters as required by starknet short strings - .matches(/^[a-zA-Z0-9_]+$/, { - message: - "${path} must be hexadecimal string, uppercase alphanumeric or underscore, like 'SN_GOERLI'", - }), - baseUrl: string() - .required() - .matches(REGEX_URL_WITH_LOCAL, "${path} must be a valid URL"), - accountImplementation: string().optional().matches(REGEX_HEXSTRING), - accountClassHash: object({ - argentAccount: string() - .label("Account class hash") - .required() - .matches(REGEX_HEXSTRING), - argentPluginAccount: string() - .label("Plugin account class hash") - .optional() - .matches(REGEX_HEXSTRING), - }).default( - undefined, - ) /** default(undefined) for an optional object with required children {@see https://github.com/jquense/yup/issues/772#issuecomment-743270211} */, - explorerUrl: string() - .optional() - .matches(REGEX_URL_WITH_LOCAL, "${path} must be a valid URL"), - blockExplorerUrl: string() - .optional() - .matches(REGEX_URL_WITH_LOCAL, "${path} must be a valid URL"), - rpcUrl: string() - .optional() - .matches(REGEX_URL_WITH_LOCAL, "${path} must be a valid URL"), - multicallAddress: string().optional().matches(REGEX_HEXSTRING), - readonly: boolean().optional(), - }) +export const networkStatusSchema = z.enum([ + "ok", + "degraded", + "error", + "unknown", +]) +export const networkSchema = z.object({ + id: z.string().min(2).max(31), + name: z.string().min(2).max(128), + chainId: z + .string() + .min(2) + .max(31) // max 31 characters as required by starknet short strings + .regex(/^[a-zA-Z0-9_]+$/, { + message: + "chain id must be hexadecimal string, uppercase alphanumeric or underscore, like 'SN_GOERLI'", + }), + baseUrl: z + .string() + .regex(REGEX_URL_WITH_LOCAL, "base url must be a valid URL"), + + accountImplementation: z.optional( + z.string().regex(REGEX_HEXSTRING, { + message: `Account class hash must match the following: /^0x[a-f0-9]+$/i`, + }), + ), + accountClassHash: z.union([ + z.object({ + standard: z + .string() + .regex(REGEX_HEXSTRING, { + message: `Account class hash must match the following: /^0x[a-f0-9]+$/i`, + }) + .optional(), + plugin: z + .string() + .regex(REGEX_HEXSTRING, { + message: `Account class hash must match the following: /^0x[a-f0-9]+$/i`, + }) + .optional(), + multisig: z + .string() + .regex(REGEX_HEXSTRING, { + message: `Account class hash must match the following: /^0x[a-f0-9]+$/i`, + }) + .optional(), + betterMulticall: z + .string() + .regex(REGEX_HEXSTRING, { + message: `Account class hash must match the following: /^0x[a-f0-9]+$/i`, + }) + .optional(), + argent5MinuteEscapeTestingAccount: z + .string() + .regex(REGEX_HEXSTRING, { + message: `Account class hash must match the following: /^0x[a-f0-9]+$/i`, + }) + .optional(), + }), + z.undefined(), + ]), + explorerUrl: z.optional( + z.string().regex(REGEX_URL_WITH_LOCAL, "explorer url must be a valid URL"), + ), + blockExplorerUrl: z.optional( + z + .string() + .regex(REGEX_URL_WITH_LOCAL, "block explorer url must be a valid URL"), + ), + rpcUrl: z.optional( + z.string().regex(REGEX_URL_WITH_LOCAL, "rpc url must be a valid URL"), + ), + multicallAddress: z.optional( + z.string().regex(REGEX_HEXSTRING, "multicall address must be a valid URL"), + ), + readonly: z.optional(z.boolean()), + status: networkStatusSchema, +}) diff --git a/packages/extension/src/shared/network/service/implementation.ts b/packages/extension/src/shared/network/service/implementation.ts new file mode 100644 index 000000000..7f7aa0512 --- /dev/null +++ b/packages/extension/src/shared/network/service/implementation.ts @@ -0,0 +1,11 @@ +import { messageClient } from "../../../ui/services/messaging/trpc" +import { Network } from "../type" +import type { INetworkService } from "./interface" + +export class NetworkService implements INetworkService { + constructor(private readonly trpcClient: typeof messageClient) {} + + async add(network: Network): Promise { + return this.trpcClient.network.add.mutate(network) + } +} diff --git a/packages/extension/src/shared/network/service/interface.ts b/packages/extension/src/shared/network/service/interface.ts new file mode 100644 index 000000000..2420a03ff --- /dev/null +++ b/packages/extension/src/shared/network/service/interface.ts @@ -0,0 +1,5 @@ +import { Network } from "../type" + +export interface INetworkService { + add(network: Network): Promise +} diff --git a/packages/extension/src/shared/network/storage.ts b/packages/extension/src/shared/network/storage.ts index 7321dcf34..d1d5a346d 100644 --- a/packages/extension/src/shared/network/storage.ts +++ b/packages/extension/src/shared/network/storage.ts @@ -12,10 +12,7 @@ export const defaultReadonlyNetworks = defaultNetworks.filter( ({ readonly }) => !!readonly, ) -export const customNetworksStore = new ArrayStorage( - defaultCustomNetworks, - { - namespace: "core:customNetworks", - compare: equalNetwork, - }, -) +export const allNetworksStore = new ArrayStorage(defaultNetworks, { + namespace: "core:allNetworks", + compare: equalNetwork, +}) diff --git a/packages/extension/src/shared/network/type.ts b/packages/extension/src/shared/network/type.ts index b7c784046..374c4fc3f 100644 --- a/packages/extension/src/shared/network/type.ts +++ b/packages/extension/src/shared/network/type.ts @@ -1,21 +1,7 @@ -export interface Network { - id: string - name: string - chainId: string - baseUrl: string - /** URL of the block explorer API service */ - explorerUrl?: string - /** URL of the user-facing block explorer web interface */ - blockExplorerUrl?: string - accountClassHash?: { - argentAccount: string - argentPluginAccount?: string - argentBetterMulticallAccount?: string - argent5MinuteEscapeTestingAccount?: string - } - rpcUrl?: string - readonly?: boolean - multicallAddress?: string -} +import { z } from "zod" -export type NetworkStatus = "ok" | "degraded" | "error" | "unknown" +import { networkSchema, networkStatusSchema } from "./schema" + +export type Network = z.infer + +export type NetworkStatus = z.infer diff --git a/packages/extension/src/shared/network/utils.ts b/packages/extension/src/shared/network/utils.ts index 3ff4120b6..b3213eac0 100644 --- a/packages/extension/src/shared/network/utils.ts +++ b/packages/extension/src/shared/network/utils.ts @@ -4,41 +4,43 @@ import { isEqualAddress } from "../../ui/services/addresses" import { ArgentAccountType } from "../wallet.model" import { Network } from "./type" -export function mapArgentAccountTypeToImplementationKey( - type: ArgentAccountType, -): keyof Required["accountClassHash"] { - switch (type) { - case "argent-plugin": - return "argentPluginAccount" - case "argent-better-multicall": - return "argentBetterMulticallAccount" - case "argent": - default: - return "argentAccount" - } -} - export function mapImplementationToArgentAccountType( implementation: string, network: Network, ): ArgentAccountType { - if ( - isEqualAddress( - implementation, - network.accountClassHash?.argentPluginAccount, - ) - ) { - return "argent-plugin" + if (isEqualAddress(implementation, network.accountClassHash?.plugin)) { + return "plugin" + } + + if (isEqualAddress(implementation, network.accountClassHash?.multisig)) { + return "multisig" } + if ( - isEqualAddress( - implementation, - network.accountClassHash?.argentBetterMulticallAccount, - ) + isEqualAddress(implementation, network.accountClassHash?.betterMulticall) ) { - return "argent-better-multicall" + return "betterMulticall" + } + + return "standard" +} + +export function getChainIdFromNetworkId( + networkId: string, +): constants.StarknetChainId { + switch (networkId) { + case "mainnet-alpha": + return constants.StarknetChainId.SN_MAIN + + case "goerli-alpha": + return constants.StarknetChainId.SN_GOERLI + + case "goerli-alpha-2": + return constants.StarknetChainId.SN_GOERLI2 + + default: + throw new Error(`Unknown networkId: ${networkId}`) } - return "argent" } export function getChainIdFromNetworkId( diff --git a/packages/extension/src/shared/network/view/index.ts b/packages/extension/src/shared/network/view/index.ts new file mode 100644 index 000000000..7e203b0a9 --- /dev/null +++ b/packages/extension/src/shared/network/view/index.ts @@ -0,0 +1,4 @@ +import { atomFromRepo } from "../../../ui/views/implementation/atomFromRepo" +import { networksRepository } from "../../storage/__new/repositories/network" + +export const networksView = atomFromRepo(networksRepository) diff --git a/packages/extension/src/shared/preAuthorizations.ts b/packages/extension/src/shared/preAuthorizations.ts index e45d605c2..a71121278 100644 --- a/packages/extension/src/shared/preAuthorizations.ts +++ b/packages/extension/src/shared/preAuthorizations.ts @@ -1,7 +1,8 @@ import { isArray, pick } from "lodash-es" import browser from "webextension-polyfill" -import { accountStore } from "./account/store" +import { withHiddenSelector } from "./account/selectors" +import { accountService } from "./account/service" import { ArrayStorage } from "./storage" import { useArrayStorage } from "./storage/hooks" import { BaseWalletAccount } from "./wallet.model" @@ -35,7 +36,7 @@ export const migratePreAuthorizations = async () => { const old = await getFromStorage("PREAUTHORIZATION:APPROVED") if (isArray(old) && old.length > 0) { await browser.storage.local.remove("PREAUTHORIZATION:APPROVED") - const allAccounts = await accountStore.get() + const allAccounts = await accountService.get(withHiddenSelector) const accountHostCombinations = old.flatMap((h) => allAccounts.map((a) => ({ diff --git a/packages/extension/src/shared/schemas/address.ts b/packages/extension/src/shared/schemas/address.ts new file mode 100644 index 000000000..9a57a3b6e --- /dev/null +++ b/packages/extension/src/shared/schemas/address.ts @@ -0,0 +1,26 @@ +import { validateChecksumAddress } from "starknet5" +import { z } from "zod" + +import { Hex } from "./hex" + +type Address = Hex + +export const addressSchemaBase = z + .string() + .regex(/^0x[0-9a-fA-F]+$/, "Invalid address") + .min(50, "Addresses must at least be 50 characters long") + .max(66, "Addresses must at most be 66 characters long") + +export const addressSchema = addressSchemaBase + .refine((value) => { + // if contains capital letters, make sure to check checksum + return value.toLowerCase() === value || validateChecksumAddress(value) + }, "Invalid address (checksum error)") + .transform((value) => { + // remove 0x prefix + const withoutPrefix = value.startsWith("0x") ? value.slice(2) : value + // pad left until length is 64 + const padded = withoutPrefix.padStart(64, "0") + // add 0x prefix + return `0x${padded}` + }) diff --git a/packages/extension/src/shared/schemas/hex.ts b/packages/extension/src/shared/schemas/hex.ts new file mode 100644 index 000000000..a0fc82faa --- /dev/null +++ b/packages/extension/src/shared/schemas/hex.ts @@ -0,0 +1,17 @@ +import { z } from "zod" + +export type Hex = `0x${string}` + +export const hexSchemaBase = z + .string() + .regex(/^(0x)?[0-9a-fA-F]+$/, "Invalid hex string") + +export const hexSchema = hexSchemaBase.transform((value) => { + // remove 0x prefix + const withoutPrefix = value.startsWith("0x") ? value.slice(2) : value + // pad left until length is even + const padded = + withoutPrefix.length % 2 === 0 ? withoutPrefix : `0${withoutPrefix}` + // add 0x prefix + return `0x${padded}` +}) diff --git a/packages/extension/src/shared/schemas/seedphrase.ts b/packages/extension/src/shared/schemas/seedphrase.ts new file mode 100644 index 000000000..86ee2e586 --- /dev/null +++ b/packages/extension/src/shared/schemas/seedphrase.ts @@ -0,0 +1,7 @@ +import { validateMnemonic } from "@scure/bip39" +import { wordlist as en } from "@scure/bip39/wordlists/english" +import { z } from "zod" + +export const seedphraseSchema = z.string().refine((value) => { + return validateMnemonic(value, en) // we only support english for now +}, "Invalid seedphrase") diff --git a/packages/extension/src/shared/shield/jwtFetcher.ts b/packages/extension/src/shared/shield/jwtFetcher.ts index 6b490c593..2ad8d253b 100644 --- a/packages/extension/src/shared/shield/jwtFetcher.ts +++ b/packages/extension/src/shared/shield/jwtFetcher.ts @@ -20,7 +20,7 @@ export const jwtFetcher = async ( } const fetcher = fetcherWithArgentApiHeaders() try { - return fetcher(input, initWithArgentJwtHeaders) + return await fetcher(input, initWithArgentJwtHeaders) } catch (error) { IS_DEV && console.warn(coerceErrorToString(error)) throw error diff --git a/packages/extension/src/shared/storage/__new/__test__/inmemoryImplementations.test.ts b/packages/extension/src/shared/storage/__new/__test__/inmemoryImplementations.test.ts new file mode 100644 index 000000000..80baa5260 --- /dev/null +++ b/packages/extension/src/shared/storage/__new/__test__/inmemoryImplementations.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from "vitest" + +import { + InMemoryObjectStore, + InMemoryRepository, +} from "./inmemoryImplementations" + +describe("InMemoryObjectStore", () => { + const store = new InMemoryObjectStore<{ + key: string + }>({ namespace: "test" }) + + test("get method returns default data", async () => { + const data = await store.get() + expect(data).toEqual({}) + }) + + test("set method updates data", async () => { + await store.set({ key: "value" }) + const data = await store.get() + expect(data).toEqual({ key: "value" }) + }) + + test("subscribe method listens to changes", async () => { + let change + const unsubscribe = store.subscribe((storageChange) => { + change = storageChange + }) + + await store.set({ key: "newValue" }) + + expect(change).toEqual({ + oldValue: { key: "value" }, + newValue: { key: "newValue" }, + }) + + unsubscribe() + }) +}) + +describe("InMemoryRepository", () => { + const repo = new InMemoryRepository<{ + id: number + value: string + }>({ + namespace: "test", + compare(a, b) { + return a.id === b.id + }, + }) + + test("get method returns default data", async () => { + const data = await repo.get() + expect(data).toEqual([]) + }) + + test("upsert method creates and updates items", async () => { + const upsertResult1 = await repo.upsert({ id: 1, value: "a" }) + expect(upsertResult1).toEqual({ created: 1, updated: 0 }) + + const upsertResult2 = await repo.upsert({ id: 1, value: "b" }) + expect(upsertResult2).toEqual({ created: 0, updated: 1 }) + + const data = await repo.get() + expect(data).toEqual([{ id: 1, value: "b" }]) + }) + + test("remove method removes items", async () => { + const removedItems = await repo.remove({ id: 1, value: "b" }) + expect(removedItems).toEqual([{ id: 1, value: "b" }]) + }) + + test("subscribe method listens to changes", async () => { + const data = await repo.get() + expect(data).toEqual([]) + + let change + const unsubscribe = repo.subscribe((storageChange) => { + change = storageChange + }) + + await repo.upsert({ id: 2, value: "c" }) + + expect(change).toEqual({ + oldValue: [], + newValue: [{ id: 2, value: "c" }], + }) + + unsubscribe() + }) +}) diff --git a/packages/extension/src/shared/storage/__new/__test__/inmemoryImplementations.ts b/packages/extension/src/shared/storage/__new/__test__/inmemoryImplementations.ts new file mode 100644 index 000000000..618b3a627 --- /dev/null +++ b/packages/extension/src/shared/storage/__new/__test__/inmemoryImplementations.ts @@ -0,0 +1,147 @@ +import { isArray, isEqual, isFunction } from "lodash-es" + +import { + AllowArray, + AllowPromise, + IObjectStore, + IObjectStoreOptions, + IRepository, + IRepositoryOptions, + SelectorFn, + SetterFn, + StorageChange, + UpsertResult, +} from "../interface" + +export class InMemoryObjectStore implements IObjectStore { + public namespace: string + + private _data: T + + private _merge: Required>["merge"] + + private _subscribers: Set<(value: StorageChange) => AllowPromise> = + new Set() + + constructor(options: IObjectStoreOptions) { + this.namespace = options.namespace + this._data = options.defaults ? { ...options.defaults } : ({} as T) + this._merge = + options.merge || ((oldValue, newValue) => ({ ...oldValue, ...newValue })) + + if (options.deserialize || options.serialize) { + throw new Error("Serialization is not supported in InMemoryObjectStore") + } + } + + async get(): Promise { + return this._data + } + + async set(value: T): Promise { + const oldValue = this._data + this._data = this._merge(oldValue, value) + const change: StorageChange = { oldValue, newValue: this._data } + this._subscribers.forEach((subscriber) => { + void subscriber(change) + }) + } + + subscribe( + callback: (value: StorageChange) => AllowPromise, + ): () => void { + this._subscribers.add(callback) + return () => { + this._subscribers.delete(callback) + } + } +} + +export class InMemoryRepository implements IRepository { + public namespace: string + + private _data: T[] + + private _compare: Required>["compare"] + + private _subscribers: Set< + (changeSet: StorageChange) => AllowPromise + > = new Set() + + constructor(public readonly options: IRepositoryOptions) { + this.namespace = options.namespace + this._data = options.defaults ? [...options.defaults] : [] + this._compare = options.compare ?? isEqual + } + + async get(selector?: SelectorFn): Promise { + return selector ? this._data.filter(selector) : this._data + } + + async upsert(value: AllowArray | SetterFn): Promise { + const oldValue = [...this._data] + const items = isFunction(value) + ? value(oldValue) + : isArray(value) + ? value + : [value] + + let created = 0 + let updated = 0 + + for (const item of items) { + const index = this._data.findIndex((existing) => + this._compare(existing, item), + ) + + if (index >= 0) { + this._data[index] = item + updated++ + } else { + this._data.push(item) + created++ + } + } + + const change: StorageChange = { oldValue, newValue: this._data } + this._subscribers.forEach((subscriber) => { + void subscriber(change) + }) + + return { created, updated } + } + + async remove(value: AllowArray | SelectorFn): Promise { + const oldValue = [...this._data] + const selector: SelectorFn = isFunction(value) + ? value + : isArray(value) + ? (item) => value.some((v) => this._compare(v, item)) + : (item) => this._compare(value, item) + const removed: T[] = [] + + this._data = this._data.filter((item) => { + if (selector(item)) { + removed.push(item) + return false + } + return true + }) + + const change: StorageChange = { oldValue, newValue: this._data } + this._subscribers.forEach((subscriber) => { + void subscriber(change) + }) + + return removed + } + + subscribe( + callback: (changeSet: StorageChange) => AllowPromise, + ): () => void { + this._subscribers.add(callback) + return () => { + this._subscribers.delete(callback) + } + } +} diff --git a/packages/extension/src/shared/storage/__new/__test__/keyvalue.test.ts b/packages/extension/src/shared/storage/__new/__test__/keyvalue.test.ts new file mode 100644 index 000000000..ad7701039 --- /dev/null +++ b/packages/extension/src/shared/storage/__new/__test__/keyvalue.test.ts @@ -0,0 +1,61 @@ +import { KeyValueStorage } from "../../keyvalue" +import { IObjectStore } from "../interface" +import { adaptKeyValue } from "../keyvalue" + +type TestData = { + foo: string | null + bar: number +} + +describe("adaptKeyValueStore", () => { + let store: KeyValueStorage + let adaptedStore: IObjectStore + + beforeAll(() => { + store = new KeyValueStorage( + { foo: null, bar: 2 }, + { namespace: "testAdapt", areaName: "local" }, + ) + adaptedStore = adaptKeyValue(store) + }) + + it("should get data from the store", async () => { + const result = await adaptedStore.get() + expect(result).toEqual({ foo: null, bar: 2 }) + }) + + it("should set data to the store", async () => { + await adaptedStore.set({ foo: "baz", bar: 3 }) + const barResult = await store.get("bar") + const fooResult = await store.get("foo") + expect(barResult).toEqual(3) + expect(fooResult).toEqual("baz") + }) + + it("allows data to be set to null", async () => { + await adaptedStore.set({ foo: null, bar: 3 }) + const barResult = await store.get("bar") + const fooResult = await store.get("foo") + + expect(barResult).toEqual(3) + expect(fooResult).toEqual(null) + }) + + it("should subscribe to the store", async () => { + const callback = vi.fn() + adaptedStore.subscribe(callback) + + await adaptedStore.set({ foo: "bar" }) + + expect(callback).toHaveBeenCalledWith({ foo: "bar", bar: 3 }) + }) + + it("should batch multiple changes into one callback", async () => { + const callback = vi.fn() + adaptedStore.subscribe(callback) + + await adaptedStore.set({ foo: "baz", bar: 4 }) + + expect(callback).toHaveBeenCalledWith({ foo: "baz", bar: 4 }) + }) +}) diff --git a/packages/extension/src/shared/storage/__new/__test__/mockFunctionImplementation.ts b/packages/extension/src/shared/storage/__new/__test__/mockFunctionImplementation.ts new file mode 100644 index 000000000..9def2c83d --- /dev/null +++ b/packages/extension/src/shared/storage/__new/__test__/mockFunctionImplementation.ts @@ -0,0 +1,18 @@ +import { vi } from "vitest" + +import { IObjectStore, IRepository } from "../interface" + +export class MockFnObjectStore implements IObjectStore { + public namespace = "test:mockFnObjectStore" + get = vi.fn() + set = vi.fn() + subscribe = vi.fn() +} + +export class MockFnRepository implements IRepository { + public namespace = "test:mockFnRepository" + get = vi.fn() + upsert = vi.fn() + subscribe = vi.fn() + remove = vi.fn() +} diff --git a/packages/extension/src/shared/storage/__new/interface.ts b/packages/extension/src/shared/storage/__new/interface.ts new file mode 100644 index 000000000..f9f02805c --- /dev/null +++ b/packages/extension/src/shared/storage/__new/interface.ts @@ -0,0 +1,106 @@ +/** + * Represents a value of type T or a Promise of type T. + */ +export type AllowPromise = T | Promise + +/** + * Represents a value of type T or an array of type T. + */ +export type AllowArray = T | T[] + +/** + * A function that takes a value of type T and returns a boolean. + */ +export type SelectorFn = (value: T) => boolean + +/** + * A function that takes an array of values of type T and returns an array of values of type T. + */ +export type SetterFn = (value: T[]) => T[] + +/** + * Represents a change in storage, including the old and new values. + */ +export interface StorageChange { + /** Optional. The new value of the item, if there is a new value. */ + newValue?: T + /** Optional. The old value of the item, if there was an old value. */ + oldValue?: T +} + +/** + * Represents options for creating a new repository. + */ +export interface IRepositoryOptions { + /** The namespace for the repository. */ + namespace: string + /** Optional. The default values for the repository. */ + defaults?: T[] + /** Optional. A function that serializes a value of type T. */ + serialize?: (value: T[]) => any + /** Optional. A function that deserializes a value to type T. */ + deserialize?: (value: any) => AllowPromise + /** Optional. A function that compares two values of type T and returns a boolean. */ + compare?: (a: T, b: T) => boolean +} + +export type UpsertResult = { created: number; updated: number } + +/** + * Represents a repository for managing data of type T. + */ +export interface IRepository { + /** The namespace for the repository. */ + namespace: string + + /** + * Retrieves items from the repository based on the provided selector function. + * @param selector - Optional. A function that filters the items to be retrieved. + * @returns A Promise that resolves to an array of items of type T. + */ + get(selector?: SelectorFn): Promise + + /** + * Inserts or updates items in the repository. + * @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 + + /** + * Removes items from the repository based on the provided value or selector function. + * @param value - An array of items, a single item, or a selector function that filters the items to be removed. + * @returns A Promise that resolves to an array of removed items of type T. + */ + remove(value: AllowArray | SelectorFn): Promise + + /** + * Subscribes to changes in the repository. + * @param callback - A function that gets called when there are changes in the repository. + * @returns A function that can be called to unsubscribe from the changes. + */ + subscribe( + callback: (changeSet: StorageChange) => AllowPromise, + ): () => void +} + +export interface IObjectStoreOptions { + namespace: string + /** Optional. The default values for the repository. */ + defaults?: T + /** Optional. A function that serializes a value of type T. */ + serialize?: (value: T) => any + /** Optional. A function that deserializes a value to type T. */ + deserialize?: (value: any) => AllowPromise + /** Optional. A function that merges two values of type T. */ + merge?: (oldValue: T, newValue: Partial
+ + {truncateAddress(ETHTokenAddress)} + +
First connect wallet to use dapp.