diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c83e2ad8..7060c3d7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -41,18 +41,21 @@ jobs: echo "VERSION=${BASE}-dev-${HASH}-${DATE}" echo "TAG=dev" echo "ENV=dev" + echo "LOG_LEVEL=all" } >> "$GITHUB_OUTPUT" elif [[ $ENV == "staging" ]]; then { echo "VERSION=${BASE}-staging" echo "TAG=staging" echo "ENV=staging" + echo "LOG_LEVEL=off" } >> "$GITHUB_OUTPUT" elif [[ $ENV == "production" ]]; then { echo "VERSION=${BASE}" echo "TAG=latest" echo "ENV=prod" + echo "LOG_LEVEL=off" } >> "$GITHUB_OUTPUT" else echo "Invalid environment" @@ -67,6 +70,7 @@ jobs: AWS_S3_URL: ${{ steps.prepare_parameters.outputs.AWS_S3_URL }} GET_STARKNET_PUBLIC_PATH: ${{ steps.prepare_parameters.outputs.GET_STARKNET_PUBLIC_PATH }} CACHE_KEY: ${{ github.sha }}-${{ steps.prepare_parameters.outputs.ENV }} + LOG_LEVEL: ${{ steps.prepare_parameters.outputs.LOG_LEVEL }} install-build: needs: @@ -103,7 +107,7 @@ jobs: echo "Building UI with version $VERSION" - REACT_APP_SNAP_VERSION="$VERSION" yarn workspace wallet-ui build + REACT_APP_DEBUG_LEVEL="${LOG_LEVEL}" REACT_APP_SNAP_VERSION="${VERSION}" yarn workspace wallet-ui build echo "Building Get Starknet with GET_STARKNET_PUBLIC_PATH=$GET_STARKNET_PUBLIC_PATH" @@ -114,6 +118,7 @@ jobs: VOYAGER_API_KEY: ${{ secrets.VOYAGER_API_KEY }} ALCHEMY_API_KEY: ${{ secrets.ALCHEMY_API_KEY }} GET_STARKNET_PUBLIC_PATH: ${{ needs.prepare-deployment.outputs.GET_STARKNET_PUBLIC_PATH }} + LOG_LEVEL: ${{ needs.prepare-deployment.outputs.LOG_LEVEL }} - name: Cache Build uses: actions/cache@v3 id: cache diff --git a/package.json b/package.json index 312340d0..e2e186c1 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "clean": "yarn workspaces foreach --parallel --interlaced --verbose run clean", "build": "yarn workspaces foreach --parallel --interlaced --verbose run build", "lint": "yarn workspaces foreach --parallel --interlaced --verbose run lint", + "lint:fix": "yarn workspaces foreach --parallel --interlaced --verbose run lint:fix", "start": "yarn workspaces foreach --parallel --interlaced --verbose run start", "test": "yarn workspaces foreach --parallel --interlaced --verbose run test", "prepare": "husky install" diff --git a/packages/starknet-snap/package.json b/packages/starknet-snap/package.json index aa4781ac..59840ec6 100644 --- a/packages/starknet-snap/package.json +++ b/packages/starknet-snap/package.json @@ -27,8 +27,8 @@ "serve": "mm-snap serve", "start": "mm-snap watch", "test": "yarn run test:unit && yarn run cover:report", - "test:unit": "nyc --check-coverage --statements 80 --branches 80 --functions 80 --lines 80 mocha --colors -r ts-node/register \"test/**/*.test.ts\"", - "test:unit:one": "nyc --check-coverage --statements 80 --branches 80 --functions 80 --lines 80 mocha --colors -r ts-node/register" + "test:unit": "nyc --check-coverage --statements 70 --branches 70 --functions 70 --lines 70 mocha --colors -r ts-node/register \"test/**/*.test.ts\"", + "test:unit:one": "nyc --check-coverage --statements 70 --branches 70 --functions 70 --lines 70 mocha --colors -r ts-node/register" }, "nyc": { "exclude": [ diff --git a/packages/starknet-snap/src/createAccount.ts b/packages/starknet-snap/src/createAccount.ts index ca9bcdf9..03510f65 100644 --- a/packages/starknet-snap/src/createAccount.ts +++ b/packages/starknet-snap/src/createAccount.ts @@ -4,18 +4,22 @@ import { getAccContractAddressAndCallData, deployAccount, waitForTransaction, + getAccContractAddressAndCallDataLegacy, + estimateAccountDeployFee, } from './utils/starknetUtils'; import { getNetworkFromChainId, getValidNumber, upsertAccount, upsertTransaction, - addDialogTxt, + getSendTxnText, } from './utils/snapUtils'; import { AccContract, VoyagerTransactionType, Transaction, TransactionStatus } from './types/snapState'; import { ApiParams, CreateAccountRequestParams } from './types/snapApi'; -import { heading, panel, text, DialogType } from '@metamask/snaps-sdk'; +import { heading, panel, DialogType } from '@metamask/snaps-sdk'; import { logger } from './utils/logger'; +import { CAIRO_VERSION, CAIRO_VERSION_LEGACY } from './utils/constants'; +import { CairoVersion, EstimateFee, num } from 'starknet'; /** * Create an starknet account. @@ -24,11 +28,15 @@ import { logger } from './utils/logger'; * @param silentMode - The flag to disable the confirmation dialog from snap. * @param waitMode - The flag to enable an determination by doing an recursive fetch to check if the deploy account status is on L2 or not. The wait mode is only useful when it compose with other txn together, it can make sure the deploy txn execute complete, avoiding the latter txn failed. */ -export async function createAccount(params: ApiParams, silentMode = false, waitMode = false) { +export async function createAccount( + params: ApiParams, + silentMode = false, + waitMode = false, + cairoVersion: CairoVersion = CAIRO_VERSION, +) { try { const { state, wallet, saveMutex, keyDeriver, requestParams } = params; const requestParamsObj = requestParams as CreateAccountRequestParams; - const addressIndex = getValidNumber(requestParamsObj.addressIndex, -1, 0); const network = getNetworkFromChainId(state, requestParamsObj.chainId); const deploy = !!requestParamsObj.deploy; @@ -39,29 +47,49 @@ export async function createAccount(params: ApiParams, silentMode = false, waitM addressIndex: addressIndexInUsed, derivationPath, } = await getKeysFromAddressIndex(keyDeriver, network.chainId, state, addressIndex); - const { address: contractAddress, callData: contractCallData } = getAccContractAddressAndCallData(publicKey); + + const { address: contractAddress, callData: contractCallData } = + cairoVersion == CAIRO_VERSION_LEGACY + ? getAccContractAddressAndCallDataLegacy(publicKey) + : getAccContractAddressAndCallData(publicKey); logger.log( `createAccount:\ncontractAddress = ${contractAddress}\npublicKey = ${publicKey}\naddressIndex = ${addressIndexInUsed}`, ); if (deploy) { if (!silentMode) { - const components = []; - addDialogTxt(components, 'Address', contractAddress); - addDialogTxt(components, 'Public Key', publicKey); - addDialogTxt(components, 'Address Index', addressIndex.toString()); + logger.log( + `estimateAccountDeployFee:\ncontractAddress = ${contractAddress}\npublicKey = ${publicKey}\naddressIndex = ${addressIndexInUsed}`, + ); + + const estimateDeployFee: EstimateFee = await estimateAccountDeployFee( + network, + contractAddress, + contractCallData, + publicKey, + privateKey, + cairoVersion, + ); + logger.log(`estimateAccountDeployFee:\nestimateDeployFee: ${toJson(estimateDeployFee)}`); + const maxFee = num.toBigInt(estimateDeployFee.suggestedMaxFee.toString(10) ?? '0'); + const dialogComponents = getSendTxnText( + state, + contractAddress, + 'deploy', + contractCallData, + contractAddress, + maxFee, + network, + ); const response = await wallet.request({ method: 'snap_dialog', params: { type: DialogType.Confirmation, - content: panel([ - heading('Do you want to sign this deploy account transaction ?'), - text(`It will be signed with address: ${contractAddress}`), - ...components, - ]), + content: panel([heading('Do you want to sign this deploy transaction ?'), ...dialogComponents]), }, }); + if (!response) return { address: contractAddress, @@ -69,7 +97,14 @@ export async function createAccount(params: ApiParams, silentMode = false, waitM } // Deploy account will auto estimate the fee from the network if not provided - const deployResp = await deployAccount(network, contractAddress, contractCallData, publicKey, privateKey); + const deployResp = await deployAccount( + network, + contractAddress, + contractCallData, + publicKey, + privateKey, + cairoVersion, + ); if (deployResp.contract_address && deployResp.transaction_hash) { const userAccount: AccContract = { @@ -80,6 +115,8 @@ export async function createAccount(params: ApiParams, silentMode = false, waitM derivationPath, deployTxnHash: deployResp.transaction_hash, chainId: network.chainId, + upgradeRequired: cairoVersion === CAIRO_VERSION_LEGACY, + deployRequired: false, }; await upsertAccount(userAccount, wallet, saveMutex); diff --git a/packages/starknet-snap/src/declareContract.ts b/packages/starknet-snap/src/declareContract.ts index 516ed21a..683eee6a 100644 --- a/packages/starknet-snap/src/declareContract.ts +++ b/packages/starknet-snap/src/declareContract.ts @@ -1,7 +1,11 @@ import { toJson } from './utils/serializer'; import { ApiParams, DeclareContractRequestParams } from './types/snapApi'; -import { getNetworkFromChainId, getDeclareSnapTxt, showUpgradeRequestModal } from './utils/snapUtils'; -import { getKeysFromAddress, declareContract as declareContractUtil, isUpgradeRequired } from './utils/starknetUtils'; +import { getNetworkFromChainId, getDeclareSnapTxt, showAccountRequireUpgradeOrDeployModal } from './utils/snapUtils'; +import { + getKeysFromAddress, + declareContract as declareContractUtil, + validateAccountRequireUpgradeOrDeploy, +} from './utils/starknetUtils'; import { heading, panel, DialogType } from '@metamask/snaps-sdk'; import { logger } from './utils/logger'; @@ -14,11 +18,13 @@ export async function declareContract(params: ApiParams) { const senderAddress = requestParamsObj.senderAddress; const network = getNetworkFromChainId(state, requestParamsObj.chainId); - const { privateKey } = await getKeysFromAddress(keyDeriver, network, state, senderAddress); + const { privateKey, publicKey } = await getKeysFromAddress(keyDeriver, network, state, senderAddress); - if (await isUpgradeRequired(network, senderAddress)) { - await showUpgradeRequestModal(wallet); - throw new Error('Upgrade required'); + try { + await validateAccountRequireUpgradeOrDeploy(network, senderAddress, publicKey); + } catch (e) { + await showAccountRequireUpgradeOrDeployModal(wallet, e); + throw e; } const snapComponents = getDeclareSnapTxt( diff --git a/packages/starknet-snap/src/estimateFee.ts b/packages/starknet-snap/src/estimateFee.ts index c917c6e4..7dbd55c0 100644 --- a/packages/starknet-snap/src/estimateFee.ts +++ b/packages/starknet-snap/src/estimateFee.ts @@ -1,6 +1,6 @@ import { toJson } from './utils/serializer'; import { Invocations, TransactionType } from 'starknet'; -import { validateAndParseAddress } from '../src/utils/starknetUtils'; +import { validateAccountRequireUpgradeOrDeploy, validateAndParseAddress } from '../src/utils/starknetUtils'; import { ApiParams, EstimateFeeRequestParams } from './types/snapApi'; import { getNetworkFromChainId } from './utils/snapUtils'; import { @@ -11,7 +11,6 @@ import { estimateFeeBulk, addFeesFromAllTransactions, isAccountDeployed, - isUpgradeRequired, } from './utils/starknetUtils'; import { ACCOUNT_CLASS_HASH } from './utils/constants'; import { logger } from './utils/logger'; @@ -45,10 +44,6 @@ export async function estimateFee(params: ApiParams) { throw new Error(`The given sender address is invalid: ${senderAddress}`); } - if (await isUpgradeRequired(network, senderAddress)) { - throw new Error('Upgrade required'); - } - const { privateKey: senderPrivateKey, publicKey } = await getKeysFromAddress( keyDeriver, network, @@ -56,6 +51,8 @@ export async function estimateFee(params: ApiParams) { senderAddress, ); + await validateAccountRequireUpgradeOrDeploy(network, senderAddress, publicKey); + const txnInvocation = { contractAddress, entrypoint: contractFuncName, diff --git a/packages/starknet-snap/src/executeTxn.ts b/packages/starknet-snap/src/executeTxn.ts index cb1d97d5..946df03b 100644 --- a/packages/starknet-snap/src/executeTxn.ts +++ b/packages/starknet-snap/src/executeTxn.ts @@ -1,5 +1,10 @@ import { Invocations, TransactionType } from 'starknet'; -import { getNetworkFromChainId, getTxnSnapTxt, addDialogTxt, showUpgradeRequestModal } from './utils/snapUtils'; +import { + getNetworkFromChainId, + getTxnSnapTxt, + addDialogTxt, + showAccountRequireUpgradeOrDeployModal, +} from './utils/snapUtils'; import { getKeysFromAddress, executeTxn as executeTxnUtil, @@ -7,7 +12,7 @@ import { estimateFeeBulk, getAccContractAddressAndCallData, addFeesFromAllTransactions, - isUpgradeRequired, + validateAccountRequireUpgradeOrDeploy, } from './utils/starknetUtils'; import { ApiParams, ExecuteTxnRequestParams } from './types/snapApi'; import { createAccount } from './createAccount'; @@ -27,9 +32,11 @@ export async function executeTxn(params: ApiParams) { addressIndex, } = await getKeysFromAddress(keyDeriver, network, state, senderAddress); - if (await isUpgradeRequired(network, senderAddress)) { - await showUpgradeRequestModal(wallet); - throw new Error('Upgrade required'); + try { + await validateAccountRequireUpgradeOrDeploy(network, senderAddress, publicKey); + } catch (e) { + await showAccountRequireUpgradeOrDeployModal(wallet, e); + throw e; } const txnInvocationArray = Array.isArray(requestParamsObj.txnInvocation) diff --git a/packages/starknet-snap/src/extractPrivateKey.ts b/packages/starknet-snap/src/extractPrivateKey.ts index ec449bf6..1225cb97 100644 --- a/packages/starknet-snap/src/extractPrivateKey.ts +++ b/packages/starknet-snap/src/extractPrivateKey.ts @@ -1,8 +1,8 @@ import { toJson } from './utils/serializer'; -import { validateAndParseAddress } from '../src/utils/starknetUtils'; +import { validateAccountRequireUpgradeOrDeploy, validateAndParseAddress } from '../src/utils/starknetUtils'; import { ApiParams, ExtractPrivateKeyRequestParams } from './types/snapApi'; import { getNetworkFromChainId } from './utils/snapUtils'; -import { getKeysFromAddress, isUpgradeRequired } from './utils/starknetUtils'; +import { getKeysFromAddress } from './utils/starknetUtils'; import { copyable, panel, text, DialogType } from '@metamask/snaps-sdk'; import { logger } from './utils/logger'; @@ -22,9 +22,8 @@ export async function extractPrivateKey(params: ApiParams) { throw new Error(`The given user address is invalid: ${userAddress}`); } - if (await isUpgradeRequired(network, userAddress)) { - throw new Error('Upgrade required'); - } + const { privateKey: userPrivateKey, publicKey } = await getKeysFromAddress(keyDeriver, network, state, userAddress); + await validateAccountRequireUpgradeOrDeploy(network, userAddress, publicKey); const response = await wallet.request({ method: 'snap_dialog', @@ -35,8 +34,6 @@ export async function extractPrivateKey(params: ApiParams) { }); if (response === true) { - const { privateKey: userPrivateKey } = await getKeysFromAddress(keyDeriver, network, state, userAddress); - await wallet.request({ method: 'snap_dialog', params: { diff --git a/packages/starknet-snap/src/extractPublicKey.ts b/packages/starknet-snap/src/extractPublicKey.ts index 1fa67fe5..7909b564 100644 --- a/packages/starknet-snap/src/extractPublicKey.ts +++ b/packages/starknet-snap/src/extractPublicKey.ts @@ -1,6 +1,6 @@ import { toJson } from './utils/serializer'; import { constants, num } from 'starknet'; -import { validateAndParseAddress, isUpgradeRequired } from '../src/utils/starknetUtils'; +import { validateAndParseAddress, validateAccountRequireUpgradeOrDeploy } from '../src/utils/starknetUtils'; import { ApiParams, ExtractPublicKeyRequestParams } from './types/snapApi'; import { getAccount, getNetworkFromChainId } from './utils/snapUtils'; import { getKeysFromAddress } from './utils/starknetUtils'; @@ -26,15 +26,14 @@ export async function extractPublicKey(params: ApiParams) { throw new Error(`The given user address is invalid: ${requestParamsObj.userAddress}`); } - if (await isUpgradeRequired(network, userAddress)) { - throw new Error('Upgrade required'); - } + // [TODO] logic below is redundant, getKeysFromAddress is doing the same + const { publicKey } = await getKeysFromAddress(keyDeriver, network, state, userAddress); + await validateAccountRequireUpgradeOrDeploy(network, userAddress, publicKey); let userPublicKey; const accContract = getAccount(state, userAddress, network.chainId); if (!accContract?.publicKey || num.toBigInt(accContract.publicKey) === constants.ZERO) { logger.log(`extractPublicKey: User address cannot be found or the signer public key is 0x0: ${userAddress}`); - const { publicKey } = await getKeysFromAddress(keyDeriver, network, state, userAddress); userPublicKey = publicKey; } else { userPublicKey = accContract.publicKey; diff --git a/packages/starknet-snap/src/index.ts b/packages/starknet-snap/src/index.ts index b6a55714..854e3fb2 100644 --- a/packages/starknet-snap/src/index.ts +++ b/packages/starknet-snap/src/index.ts @@ -12,27 +12,23 @@ import { addErc20Token } from './addErc20Token'; import { getStoredErc20Tokens } from './getStoredErc20Tokens'; import { estimateFee } from './estimateFee'; import { getStoredUserAccounts } from './getStoredUserAccounts'; -import { AccContract, SnapState } from './types/snapState'; +import { SnapState } from './types/snapState'; import { extractPrivateKey } from './extractPrivateKey'; import { extractPublicKey } from './extractPublicKey'; import { addNetwork } from './addNetwork'; import { switchNetwork } from './switchNetwork'; import { getCurrentNetwork } from './getCurrentNetwork'; import { + CAIRO_VERSION_LEGACY, + ETHER_MAINNET, + ETHER_SEPOLIA_TESTNET, PRELOADED_TOKENS, STARKNET_INTEGRATION_NETWORK, STARKNET_MAINNET_NETWORK, STARKNET_SEPOLIA_TESTNET_NETWORK, STARKNET_TESTNET_NETWORK, } from './utils/constants'; -import { - dappUrl, - getNetworkFromChainId, - isSameChainId, - upsertErc20Token, - upsertNetwork, - removeNetwork, -} from './utils/snapUtils'; +import { dappUrl, upsertErc20Token, upsertNetwork, removeNetwork } from './utils/snapUtils'; import { getStoredNetworks } from './getStoredNetworks'; import { getStoredTransactions } from './getStoredTransactions'; import { getTransactions } from './getTransactions'; @@ -52,6 +48,7 @@ import { getStarkName } from './getStarkName'; import type { OnRpcRequestHandler, OnHomePageHandler, OnInstallHandler, OnUpdateHandler } from '@metamask/snaps-sdk'; import { InternalError, panel, row, divider, text, copyable } from '@metamask/snaps-sdk'; import { ethers } from 'ethers'; +import { getBalance, getCorrectContractAddress, getKeysFromAddressIndex } from './utils/starknetUtils'; declare const snap; const saveMutex = new Mutex(); @@ -124,6 +121,10 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ origin, request }) => apiParams.keyDeriver = await getAddressKeyDeriver(snap); return createAccount(apiParams); + case 'starkNet_createAccountLegacy': + apiParams.keyDeriver = await getAddressKeyDeriver(snap); + return createAccount(apiParams, false, true, CAIRO_VERSION_LEGACY); + case 'starkNet_getStoredUserAccounts': return await getStoredUserAccounts(apiParams); @@ -263,7 +264,6 @@ export const onUpdate: OnUpdateHandler = async () => { }; export const onHomePage: OnHomePageHandler = async () => { - const panelItems = []; try { const state: SnapState = await snap.request({ method: 'snap_manageState', @@ -272,60 +272,37 @@ export const onHomePage: OnHomePageHandler = async () => { }, }); - // Account may not exist if the recover account process has not executed. - let accContract: AccContract; - if (state) { - let chainId = STARKNET_SEPOLIA_TESTNET_NETWORK.chainId; + if (!state) { + throw new Error('State not found.'); + } - if (state.currentNetwork && state.currentNetwork.chainId !== STARKNET_TESTNET_NETWORK.chainId) { - chainId = state.currentNetwork.chainId; - } + // default network is testnet + let network = STARKNET_SEPOLIA_TESTNET_NETWORK; - if (state.accContracts && state.accContracts.length > 0) { - accContract = state.accContracts.find((n) => isSameChainId(n.chainId, chainId)); - } + if (state.currentNetwork && state.currentNetwork.chainId !== STARKNET_TESTNET_NETWORK.chainId) { + network = state.currentNetwork; } - if (accContract) { - const userAddress = accContract.address; - const chainId = accContract.chainId; - const network = getNetworkFromChainId(state, chainId); - panelItems.push(text('Address')); - panelItems.push(copyable(`${userAddress}`)); - panelItems.push(row('Network', text(`${network.name}`))); - - const ercToken = state.erc20Tokens.find( - (t) => t.symbol.toLowerCase() === 'eth' && isSameChainId(t.chainId, chainId), - ); - if (ercToken) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const params: any = { - state, - requestParams: { - tokenAddress: ercToken.address, - userAddress: userAddress, - chainId: network.chainId, - }, - }; - const balance = await getErc20TokenBalance(params); - const displayBalance = ethers.utils.formatUnits(ethers.BigNumber.from(balance), ercToken.decimals); - panelItems.push(row('Balance', text(`${displayBalance} ETH`))); - } - - panelItems.push(divider()); - panelItems.push( - text(`Visit the [companion dapp for Starknet](${dappUrl(process.env.SNAP_ENV)}) to manage your account.`), - ); - } else { - panelItems.push(text(`**Your Starknet account is not yet deployed or recovered.**`)); - panelItems.push( - text( - `Initiate a transaction to create your Starknet account. Visit the [companion dapp for Starknet](${dappUrl( - process.env.SNAP_ENV, - )}) to get started.`, - ), - ); - } + // we only support 1 address at this moment + const idx = 0; + const keyDeriver = await getAddressKeyDeriver(snap); + const { publicKey } = await getKeysFromAddressIndex(keyDeriver, network.chainId, state, idx); + const { address } = await getCorrectContractAddress(network, publicKey); + + const ethToken = network.chainId === ETHER_SEPOLIA_TESTNET.chainId ? ETHER_SEPOLIA_TESTNET : ETHER_MAINNET; + const balance = (await getBalance(address, ethToken.address, network)) ?? BigInt(0); + const displayBalance = ethers.utils.formatUnits(ethers.BigNumber.from(balance), ethToken.decimals); + + const panelItems = []; + panelItems.push(text('Address')); + panelItems.push(copyable(`${address}`)); + panelItems.push(row('Network', text(`${network.name}`))); + panelItems.push(row('Balance', text(`${displayBalance} ETH`))); + panelItems.push(divider()); + panelItems.push( + text(`Visit the [companion dapp for Starknet](${dappUrl(process.env.SNAP_ENV)}) to manage your account.`), + ); + return { content: panel(panelItems), }; diff --git a/packages/starknet-snap/src/recoverAccounts.ts b/packages/starknet-snap/src/recoverAccounts.ts index fb4cef06..a33c051b 100644 --- a/packages/starknet-snap/src/recoverAccounts.ts +++ b/packages/starknet-snap/src/recoverAccounts.ts @@ -33,6 +33,7 @@ export async function recoverAccounts(params: ApiParams) { address: contractAddress, signerPubKey: signerPublicKey, upgradeRequired, + deployRequired, } = await getCorrectContractAddress(network, publicKey); logger.log( `recoverAccounts: index ${i}:\ncontractAddress = ${contractAddress}\npublicKey = ${publicKey}\nisUpgradeRequired = ${upgradeRequired}`, @@ -57,6 +58,7 @@ export async function recoverAccounts(params: ApiParams) { deployTxnHash: '', chainId: network.chainId, upgradeRequired: upgradeRequired, + deployRequired: deployRequired, }; logger.log(`recoverAccounts: index ${i}\nuserAccount: ${toJson(userAccount)}`); diff --git a/packages/starknet-snap/src/signDeclareTransaction.ts b/packages/starknet-snap/src/signDeclareTransaction.ts index 681db510..e2261e84 100644 --- a/packages/starknet-snap/src/signDeclareTransaction.ts +++ b/packages/starknet-snap/src/signDeclareTransaction.ts @@ -4,9 +4,9 @@ import { ApiParams, SignDeclareTransactionRequestParams } from './types/snapApi' import { getKeysFromAddress, signDeclareTransaction as signDeclareTransactionUtil, - isUpgradeRequired, + validateAccountRequireUpgradeOrDeploy, } from './utils/starknetUtils'; -import { getNetworkFromChainId, getSignTxnTxt, showUpgradeRequestModal } from './utils/snapUtils'; +import { getNetworkFromChainId, getSignTxnTxt, showAccountRequireUpgradeOrDeployModal } from './utils/snapUtils'; import { heading, panel, DialogType } from '@metamask/snaps-sdk'; import { logger } from './utils/logger'; @@ -16,11 +16,13 @@ export async function signDeclareTransaction(params: ApiParams): Promise { return (callDataStr ?? '') @@ -182,8 +186,9 @@ export const deployAccount = async ( cairoVersion?: CairoVersion, invocationsDetails?: UniversalDetails, ): Promise => { + const classHash = cairoVersion == CAIRO_VERSION ? ACCOUNT_CLASS_HASH : PROXY_CONTRACT_HASH; const deployAccountPayload = { - classHash: ACCOUNT_CLASS_HASH, + classHash: classHash, contractAddress: contractAddress, constructorCalldata: contractCallData, addressSalt, @@ -204,8 +209,9 @@ export const estimateAccountDeployFee = async ( cairoVersion?: CairoVersion, invocationsDetails?: UniversalDetails, ): Promise => { + const classHash = cairoVersion == CAIRO_VERSION ? ACCOUNT_CLASS_HASH : PROXY_CONTRACT_HASH; const deployAccountPayload = { - classHash: ACCOUNT_CLASS_HASH, + classHash: classHash, contractAddress: contractAddress, constructorCalldata: contractCallData, addressSalt, @@ -248,6 +254,15 @@ export const getBalance = async (address: string, tokenAddress: string, network: return resp[0]; }; +export const isEthBalanceEmpty = async (network: Network, address: string, maxFee: bigint = constants.ZERO) => { + const etherErc20TokenAddress = + network.chainId === ETHER_SEPOLIA_TESTNET.chainId ? ETHER_SEPOLIA_TESTNET.address : ETHER_MAINNET.address; + + return ( + num.toBigInt((await getBalance(address, etherErc20TokenAddress, network)) ?? num.toBigInt(constants.ZERO)) <= maxFee + ); +}; + export const getTransactionStatus = async (transactionHash: num.BigNumberish, network: Network) => { const provider = getProvider(network); const receipt = (await provider.getTransactionReceipt(transactionHash)) as RpcV4GetTransactionReceiptResponse; @@ -526,6 +541,7 @@ export const getAccContractAddressAndCallData = (publicKey) => { * @returns - address and calldata. */ export const getAccContractAddressAndCallDataLegacy = (publicKey) => { + // [TODO]: Check why use ACCOUNT_CLASS_HASH_LEGACY and PROXY_CONTRACT_HASH ? const callData = CallData.compile({ implementation: ACCOUNT_CLASS_HASH_LEGACY, selector: hash.getSelectorFromName('initialize'), @@ -674,6 +690,30 @@ export const getPermutationAddresses = (pk: string) => { }; }; +/** + * Check address needed deploy by using getVersion and check if eth balance is non empty. + * + * @param network - Network. + * @param address - Input address. + * @returns - boolean. + */ +export const isDeployRequired = async (network: Network, address: string, pubKey: string) => { + logger.log(`isDeployRequired: address = ${address}`); + const { address: addressLegacy } = getAccContractAddressAndCallDataLegacy(pubKey); + + try { + if (address === addressLegacy) { + await getVersion(address, network); + } + return false; + } catch (err) { + if (!err.message.includes('Contract not found')) { + throw err; + } + return !(await isEthBalanceEmpty(network, address)); + } +}; + /** * Check address needed upgrade by using getVersion and compare with MIN_ACC_CONTRACT_VERSION * @@ -707,6 +747,50 @@ export const isGTEMinVersion = (version: string) => { return Number(versionArr[1]) >= MIN_ACC_CONTRACT_VERSION[1]; }; +/** + * Generate the transaction invocation object for upgrading a contract. + * + * @param contractAddress - The address of the contract to upgrade. + * @returns An object representing the transaction invocation. + */ +export function getUpgradeTxnInvocation(contractAddress: string) { + const method = 'upgrade'; + + const calldata = CallData.compile({ + implementation: ACCOUNT_CLASS_HASH, + calldata: [0], + }); + + return { + contractAddress, + entrypoint: method, + calldata, + }; +} + +/** + * Calculate the transaction fee for upgrading a contract. + * + * @param network - The network on which the contract is deployed. + * @param contractAddress - The address of the contract to upgrade. + * @param privateKey - The private key of the account performing the upgrade. + * @param maxFee - The maximum fee allowed for the transaction. + * @returns The calculated transaction fee as a bigint. + */ +export async function estimateAccountUpgradeFee( + network: Network, + contractAddress: string, + privateKey: string, + maxFee: BigNumberish = constants.ZERO, +) { + if (maxFee === constants.ZERO) { + const txnInvocation = getUpgradeTxnInvocation(contractAddress); + const estFeeResp = await estimateFee(network, contractAddress, privateKey, txnInvocation, CAIRO_VERSION_LEGACY); + return num.toBigInt(estFeeResp.suggestedMaxFee.toString(10) ?? '0'); + } + return maxFee; +} + /** * Get user address by public key, return address if the address has deployed * @@ -714,7 +798,7 @@ export const isGTEMinVersion = (version: string) => { * @param publicKey - address's public key. * @returns - address and address's public key. */ -export const getCorrectContractAddress = async (network: Network, publicKey: string) => { +export const getCorrectContractAddress = async (network: Network, publicKey: string, maxFee = constants.ZERO) => { const { address: contractAddress, addressLegacy: contractAddressLegacy } = getPermutationAddresses(publicKey); logger.log( @@ -723,6 +807,7 @@ export const getCorrectContractAddress = async (network: Network, publicKey: str let address = contractAddress; let upgradeRequired = false; + let deployRequired = false; let pk = ''; try { @@ -733,11 +818,10 @@ export const getCorrectContractAddress = async (network: Network, publicKey: str throw e; } - logger.log( - `getContractAddressByKey: cairo ${CAIRO_VERSION} contract cant found, try cairo ${CAIRO_VERSION_LEGACY}`, - ); + logger.log(`getContractAddressByKey: cairo ${CAIRO_VERSION} contract not found, try cairo ${CAIRO_VERSION_LEGACY}`); try { + address = contractAddressLegacy; const version = await getVersion(contractAddressLegacy, network); upgradeRequired = isGTEMinVersion(hexToString(version)) ? false : true; pk = await getContractOwner( @@ -745,20 +829,34 @@ export const getCorrectContractAddress = async (network: Network, publicKey: str network, upgradeRequired ? CAIRO_VERSION_LEGACY : CAIRO_VERSION, ); - address = contractAddressLegacy; } catch (e) { if (!e.message.includes('Contract not found')) { throw e; } - - logger.log(`getContractAddressByKey: no deployed contract found, fallback to cairo ${CAIRO_VERSION}`); + // Here account is not deployed, proceed with edge case detection + try { + if (await isEthBalanceEmpty(network, address, maxFee)) { + address = contractAddress; + logger.log(`getContractAddressByKey: no deployed contract found, fallback to cairo ${CAIRO_VERSION}`); + } else { + upgradeRequired = true; + deployRequired = true; + logger.log( + `getContractAddressByKey: non deployed cairo0 contract found with non-zero balance, force cairo ${CAIRO_VERSION_LEGACY}`, + ); + } + } catch (err) { + logger.log(`getContractAddressByKey: balance check failed with error ${err}`); + throw err; + } } } return { address, signerPubKey: pk, - upgradeRequired: upgradeRequired, + upgradeRequired, + deployRequired, }; }; @@ -800,3 +898,11 @@ export const getStarkNameUtil = async (network: Network, userAddress: string) => const provider = getProvider(network); return Account.getStarkName(provider, userAddress); }; + +export const validateAccountRequireUpgradeOrDeploy = async (network: Network, address: string, pubKey: string) => { + if (await isUpgradeRequired(network, address)) { + throw new UpgradeRequiredError('Upgrade required'); + } else if (!(await isDeployRequired(network, address, pubKey))) { + throw new DeployRequiredError(`Cairo 0 contract address ${address} balance is not empty, deploy required`); + } +}; diff --git a/packages/starknet-snap/src/verifySignedMessage.ts b/packages/starknet-snap/src/verifySignedMessage.ts index 8917852e..a7264b7e 100644 --- a/packages/starknet-snap/src/verifySignedMessage.ts +++ b/packages/starknet-snap/src/verifySignedMessage.ts @@ -4,7 +4,7 @@ import { verifyTypedDataMessageSignature, getFullPublicKeyPairFromPrivateKey, getKeysFromAddress, - isUpgradeRequired, + validateAccountRequireUpgradeOrDeploy, } from './utils/starknetUtils'; import { getNetworkFromChainId } from './utils/snapUtils'; import { ApiParams, VerifySignedMessageRequestParams } from './types/snapApi'; @@ -40,11 +40,13 @@ export async function verifySignedMessage(params: ApiParams) { throw new Error(`The given signer address is invalid: ${verifySignerAddress}`); } - if (await isUpgradeRequired(network, verifySignerAddress)) { - throw new Error('Upgrade required'); - } - - const { privateKey: signerPrivateKey } = await getKeysFromAddress(keyDeriver, network, state, verifySignerAddress); + const { privateKey: signerPrivateKey, publicKey } = await getKeysFromAddress( + keyDeriver, + network, + state, + verifySignerAddress, + ); + await validateAccountRequireUpgradeOrDeploy(network, verifySignerAddress, publicKey); const fullPublicKey = getFullPublicKeyPairFromPrivateKey(signerPrivateKey); diff --git a/packages/starknet-snap/test/constants.test.ts b/packages/starknet-snap/test/constants.test.ts index 95c8e1f0..465745a5 100644 --- a/packages/starknet-snap/test/constants.test.ts +++ b/packages/starknet-snap/test/constants.test.ts @@ -26,7 +26,7 @@ export const account1: AccContract = { addressIndex: 0, derivationPath: "m / bip32:44' / bip32:9004' / bip32:0' / bip32:0", deployTxnHash: '0x5da2d94a324bc56f80cf1fb985c22c85769db434ed403ae71774a07103d229b', - publicKey: '0x0154c7b20442ee954f50831702ca844ec185ad484c21719575d351583deec90b', + publicKey: '0x154c7b20442ee954f50831702ca844ec185ad484c21719575d351583deec90b', chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, }; @@ -36,7 +36,7 @@ export const account2: AccContract = { addressIndex: 1, derivationPath: "m / bip32:44' / bip32:9004' / bip32:0' / bip32:0", deployTxnHash: '0x5bc00132b8f2fc0f673dc232594b26727e712b204a2716f9dc28a8c5f607b5e', - publicKey: '0x019e59f349e1aa813ab4556c5836d0472e5e1ae82d1e5c3b3e8aabfeb290befd', + publicKey: '0x19e59f349e1aa813ab4556c5836d0472e5e1ae82d1e5c3b3e8aabfeb290befd', chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, }; @@ -70,6 +70,13 @@ export const Cairo1Account1: AccContract = { chainId: constants.StarknetChainId.SN_GOERLI, }; +export const token0: Erc20Token = { + address: '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7', + name: 'Ether', + symbol: 'ETH', + decimals: 18, + chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, +}; export const token1: Erc20Token = { address: '0x244c20d51109adcf604fde1bbf878e5dcd549b3877ac87911ec6a158bd7aa62', name: 'Starknet ERC-20 sample', diff --git a/packages/starknet-snap/test/src/createAccount.test.ts b/packages/starknet-snap/test/src/createAccount.test.ts index c689b4a2..241002ba 100644 --- a/packages/starknet-snap/test/src/createAccount.test.ts +++ b/packages/starknet-snap/test/src/createAccount.test.ts @@ -17,8 +17,6 @@ import { estimateDeployFeeResp, getBalanceResp, account1, - estimateDeployFeeResp2, - estimateDeployFeeResp3, } from '../constants.test'; import { getAddressKeyDeriver } from '../../src/utils/keyPair'; import { Mutex } from 'async-mutex'; @@ -53,6 +51,9 @@ describe('Test function: createAccount', function () { walletStub.rpcStubs.snap_manageState.resolves(state); waitForTransactionStub = sandbox.stub(utils, 'waitForTransaction'); waitForTransactionStub.resolves({} as unknown as GetTransactionReceiptResponse); + sandbox.stub(utils, 'estimateAccountDeployFee').callsFake(async () => { + return estimateDeployFeeResp; + }); }); afterEach(function () { diff --git a/packages/starknet-snap/test/src/declareContract.test.ts b/packages/starknet-snap/test/src/declareContract.test.ts index 45c440e9..36ba7374 100644 --- a/packages/starknet-snap/test/src/declareContract.test.ts +++ b/packages/starknet-snap/test/src/declareContract.test.ts @@ -11,6 +11,7 @@ import { createAccountProxyTxn, getBip44EntropyStub, account1 } from '../constan import { getAddressKeyDeriver } from '../../src/utils/keyPair'; import { Mutex } from 'async-mutex'; import { ApiParams, DeclareContractRequestParams } from '../../src/types/snapApi'; +import { DeployRequiredError, UpgradeRequiredError } from '../../src/utils/exceptions'; chai.use(sinonChai); const sandbox = sinon.createSandbox(); @@ -49,6 +50,14 @@ describe('Test function: declareContract', function () { sandbox.useFakeTimers(createAccountProxyTxn.timestamp); walletStub.rpcStubs.snap_dialog.resolves(true); walletStub.rpcStubs.snap_manageState.resolves(state); + + sandbox.stub(snapsUtil, 'showAccountRequireUpgradeOrDeployModal').callsFake(async (wallet, e) => { + if (e instanceof DeployRequiredError) { + await snapsUtil.showDeployRequestModal(wallet); + } else if (e instanceof UpgradeRequiredError) { + await snapsUtil.showUpgradeRequestModal(wallet); + } + }); }); afterEach(function () { @@ -56,8 +65,10 @@ describe('Test function: declareContract', function () { sandbox.restore(); }); - it('should 1) throw an error and 2) show upgrade modal if account deployed required', async function () { - const isUpgradeRequiredStub = sandbox.stub(utils, 'isUpgradeRequired').resolves(true); + it('should 1) throw an error and 2) show upgrade modal if account upgrade required', async function () { + const validateAccountRequireUpgradeOrDeployStub = sandbox + .stub(utils, 'validateAccountRequireUpgradeOrDeploy') + .throws(new UpgradeRequiredError('Upgrade Required')); const showUpgradeRequestModalStub = sandbox.stub(snapsUtil, 'showUpgradeRequestModal').resolves(); let result; try { @@ -65,14 +76,41 @@ describe('Test function: declareContract', function () { } catch (err) { result = err; } finally { - expect(isUpgradeRequiredStub).to.have.been.calledOnceWith(STARKNET_SEPOLIA_TESTNET_NETWORK, account1.address); + expect(validateAccountRequireUpgradeOrDeployStub).to.have.been.calledOnceWith( + STARKNET_SEPOLIA_TESTNET_NETWORK, + account1.address, + account1.publicKey, + ); expect(showUpgradeRequestModalStub).to.have.been.calledOnce; expect(result).to.be.an('Error'); } }); + it('should 1) throw an error and 2) show deploy modal if account deployed required', async function () { + const validateAccountRequireUpgradeOrDeployStub = sandbox + .stub(utils, 'validateAccountRequireUpgradeOrDeploy') + .throws( + new DeployRequiredError(`Cairo 0 contract address ${account1.address} balance is not empty, deploy required`), + ); + const showDeployRequestModalStub = sandbox.stub(snapsUtil, 'showDeployRequestModal').resolves(); + let result; + try { + result = await declareContract(apiParams); + } catch (err) { + result = err; + } finally { + expect(validateAccountRequireUpgradeOrDeployStub).to.have.been.calledOnceWith( + STARKNET_SEPOLIA_TESTNET_NETWORK, + account1.address, + account1.publicKey, + ); + expect(showDeployRequestModalStub).to.have.been.calledOnce; + expect(result).to.be.an('Error'); + } + }); + it('should declareContract correctly', async function () { - sandbox.stub(utils, 'isUpgradeRequired').resolves(false); + sandbox.stub(utils, 'validateAccountRequireUpgradeOrDeploy').resolves(null); const declareContractStub = sandbox.stub(utils, 'declareContract').resolves({ transaction_hash: 'transaction_hash', class_hash: 'class_hash', @@ -100,7 +138,7 @@ describe('Test function: declareContract', function () { }); it('should throw error if declareContract fail', async function () { - sandbox.stub(utils, 'isUpgradeRequired').resolves(false); + sandbox.stub(utils, 'validateAccountRequireUpgradeOrDeploy').resolves(null); const declareContractStub = sandbox.stub(utils, 'declareContract').rejects('error'); const { privateKey } = await utils.getKeysFromAddress( apiParams.keyDeriver, @@ -127,7 +165,7 @@ describe('Test function: declareContract', function () { }); it('should return false if user rejected to sign the transaction', async function () { - sandbox.stub(utils, 'isUpgradeRequired').resolves(false); + sandbox.stub(utils, 'validateAccountRequireUpgradeOrDeploy').resolves(null); walletStub.rpcStubs.snap_dialog.resolves(false); const declareContractStub = sandbox.stub(utils, 'declareContract').resolves({ transaction_hash: 'transaction_hash', diff --git a/packages/starknet-snap/test/src/estimateFee.test.ts b/packages/starknet-snap/test/src/estimateFee.test.ts index a7f49327..1e736080 100644 --- a/packages/starknet-snap/test/src/estimateFee.test.ts +++ b/packages/starknet-snap/test/src/estimateFee.test.ts @@ -18,6 +18,7 @@ import { import { Mutex } from 'async-mutex'; import { ApiParams, EstimateFeeRequestParams } from '../../src/types/snapApi'; import { TransactionType } from 'starknet'; +import { UpgradeRequiredError } from '../../src/utils/exceptions'; chai.use(sinonChai); const sandbox = sinon.createSandbox(); @@ -48,6 +49,7 @@ describe('Test function: estimateFee', function () { walletStub.rpcStubs.snap_getBip44Entropy.callsFake(getBip44EntropyStub); apiParams.keyDeriver = await getAddressKeyDeriver(walletStub); sandbox.stub(utils, 'callContract').resolves(getBalanceResp); + sandbox.stub(utils, 'getAccContractAddressAndCallDataLegacy').resolves(account2.address); }); afterEach(function () { @@ -105,6 +107,12 @@ describe('Test function: estimateFee', function () { describe('when request param validation pass', function () { beforeEach(async function () { apiParams.requestParams = Object.assign({}, requestObject); + sandbox.stub(utils, 'getKeysFromAddress').resolves({ + privateKey: 'pk', + publicKey: account2.publicKey, + addressIndex: account2.addressIndex, + derivationPath: `m / bip32:1' / bip32:1' / bip32:1' / bip32:1'`, + }); }); afterEach(async function () { @@ -112,9 +120,11 @@ describe('Test function: estimateFee', function () { }); describe('when account require upgrade', function () { - let isUpgradeRequiredStub: sinon.SinonStub; + let validateAccountRequireUpgradeOrDeployStub: sinon.SinonStub; beforeEach(async function () { - isUpgradeRequiredStub = sandbox.stub(utils, 'isUpgradeRequired').resolves(true); + validateAccountRequireUpgradeOrDeployStub = sandbox + .stub(utils, 'validateAccountRequireUpgradeOrDeploy') + .throws(new UpgradeRequiredError('Upgrade Required')); }); it('should throw error if upgrade required', async function () { @@ -124,8 +134,13 @@ describe('Test function: estimateFee', function () { } catch (err) { result = err; } finally { - expect(isUpgradeRequiredStub).to.have.been.calledOnceWith(STARKNET_SEPOLIA_TESTNET_NETWORK, account2.address); + expect(validateAccountRequireUpgradeOrDeployStub).to.have.been.calledOnceWith( + STARKNET_SEPOLIA_TESTNET_NETWORK, + account2.address, + account2.publicKey, + ); expect(result).to.be.an('Error'); + expect(result.message).to.equal('Upgrade Required'); } }); }); @@ -145,7 +160,7 @@ describe('Test function: estimateFee', function () { describe('when account is deployed', function () { beforeEach(async function () { estimateFeeBulkStub = sandbox.stub(utils, 'estimateFeeBulk'); - sandbox.stub(utils, 'isAccountDeployed').resolves(true); + sandbox.stub(utils, 'validateAccountRequireUpgradeOrDeploy').resolves(null); }); it('should estimate the fee correctly', async function () { @@ -160,6 +175,7 @@ describe('Test function: estimateFee', function () { describe('when account is not deployed', function () { beforeEach(async function () { estimateFeeStub = sandbox.stub(utils, 'estimateFee'); + sandbox.stub(utils, 'validateAccountRequireUpgradeOrDeploy').resolves(null); sandbox.stub(utils, 'isAccountDeployed').resolves(false); }); diff --git a/packages/starknet-snap/test/src/executeTxn.test.ts b/packages/starknet-snap/test/src/executeTxn.test.ts index b01b2afc..9510a734 100644 --- a/packages/starknet-snap/test/src/executeTxn.test.ts +++ b/packages/starknet-snap/test/src/executeTxn.test.ts @@ -19,6 +19,7 @@ import { getAddressKeyDeriver } from '../../src/utils/keyPair'; import { Mutex } from 'async-mutex'; import { ApiParams, ExecuteTxnRequestParams } from '../../src/types/snapApi'; import { GetTransactionReceiptResponse } from 'starknet'; +import { DeployRequiredError, UpgradeRequiredError } from '../../src/utils/exceptions'; chai.use(sinonChai); const sandbox = sinon.createSandbox(); @@ -72,6 +73,13 @@ describe('Test function: executeTxn', function () { walletStub.rpcStubs.snap_dialog.resolves(true); walletStub.rpcStubs.snap_manageState.resolves(state); sandbox.stub(utils, 'waitForTransaction').resolves({} as unknown as GetTransactionReceiptResponse); + sandbox.stub(snapsUtil, 'showAccountRequireUpgradeOrDeployModal').callsFake(async (wallet, e) => { + if (e instanceof DeployRequiredError) { + await snapsUtil.showDeployRequestModal(wallet); + } else if (e instanceof UpgradeRequiredError) { + await snapsUtil.showUpgradeRequestModal(wallet); + } + }); }); afterEach(function () { @@ -80,8 +88,10 @@ describe('Test function: executeTxn', function () { apiParams.requestParams = requestObject; }); - it('should 1) throw an error and 2) show upgrade modal if account deployed required', async function () { - const isUpgradeRequiredStub = sandbox.stub(utils, 'isUpgradeRequired').resolves(true); + it('should 1) throw an error and 2) show upgrade modal if account upgrade required', async function () { + const validateAccountRequireUpgradeOrDeployStub = sandbox + .stub(utils, 'validateAccountRequireUpgradeOrDeploy') + .throws(new UpgradeRequiredError('Upgrade Required')); const showUpgradeRequestModalStub = sandbox.stub(snapsUtil, 'showUpgradeRequestModal').resolves(); let result; try { @@ -89,14 +99,33 @@ describe('Test function: executeTxn', function () { } catch (err) { result = err; } finally { - expect(isUpgradeRequiredStub).to.have.been.calledOnceWith(STARKNET_MAINNET_NETWORK, account1.address); + expect(validateAccountRequireUpgradeOrDeployStub).to.have.been.calledOnce; expect(showUpgradeRequestModalStub).to.have.been.calledOnce; expect(result).to.be.an('Error'); } }); + it('should 1) throw an error and 2) show deploy modal if account deployed required', async function () { + const validateAccountRequireUpgradeOrDeployStub = sandbox + .stub(utils, 'validateAccountRequireUpgradeOrDeploy') + .throws( + new DeployRequiredError(`Cairo 0 contract address ${account1.address} balance is not empty, deploy required`), + ); + const showDeployRequestModalStub = sandbox.stub(snapsUtil, 'showDeployRequestModal').resolves(); + let result; + try { + result = await executeTxn(apiParams); + } catch (err) { + result = err; + } finally { + expect(validateAccountRequireUpgradeOrDeployStub).to.have.been.calledOnce; + expect(showDeployRequestModalStub).to.have.been.calledOnce; + expect(result).to.be.an('Error'); + } + }); + it('should executeTxn correctly and deploy an account', async function () { - sandbox.stub(utils, 'isUpgradeRequired').resolves(false); + sandbox.stub(utils, 'validateAccountRequireUpgradeOrDeploy').resolves(null); sandbox.stub(utils, 'isAccountDeployed').resolves(false); const createAccountStub = sandbox.stub(createAccountUtils, 'createAccount').resolvesThis(); const stub = sandbox.stub(utils, 'executeTxn').resolves({ @@ -128,7 +157,7 @@ describe('Test function: executeTxn', function () { }); it('should executeTxn multiple and deploy an account', async function () { - sandbox.stub(utils, 'isUpgradeRequired').resolves(false); + sandbox.stub(utils, 'validateAccountRequireUpgradeOrDeploy').resolves(null); sandbox.stub(utils, 'isAccountDeployed').resolves(false); const createAccountStub = sandbox.stub(createAccountUtils, 'createAccount').resolvesThis(); const stub = sandbox.stub(utils, 'executeTxn').resolves({ @@ -189,7 +218,7 @@ describe('Test function: executeTxn', function () { it('should executeTxn and not deploy an account', async function () { const createAccountStub = sandbox.stub(createAccountUtils, 'createAccount'); - sandbox.stub(utils, 'isUpgradeRequired').resolves(false); + sandbox.stub(utils, 'validateAccountRequireUpgradeOrDeploy').resolves(null); sandbox.stub(utils, 'isAccountDeployed').resolves(true); const stub = sandbox.stub(utils, 'executeTxn').resolves({ transaction_hash: 'transaction_hash', @@ -223,7 +252,7 @@ describe('Test function: executeTxn', function () { it('should executeTxn multiple and not deploy an account', async function () { const createAccountStub = sandbox.stub(createAccountUtils, 'createAccount'); - sandbox.stub(utils, 'isUpgradeRequired').resolves(false); + sandbox.stub(utils, 'validateAccountRequireUpgradeOrDeploy').resolves(null); sandbox.stub(utils, 'isAccountDeployed').resolves(true); const stub = sandbox.stub(utils, 'executeTxn').resolves({ transaction_hash: 'transaction_hash', @@ -282,7 +311,7 @@ describe('Test function: executeTxn', function () { }); it('should throw error if executeTxn fail', async function () { - sandbox.stub(utils, 'isUpgradeRequired').resolves(false); + sandbox.stub(utils, 'validateAccountRequireUpgradeOrDeploy').resolves(null); sandbox.stub(utils, 'isAccountDeployed').resolves(true); const stub = sandbox.stub(utils, 'executeTxn').rejects('error'); const { privateKey } = await utils.getKeysFromAddress( @@ -315,8 +344,8 @@ describe('Test function: executeTxn', function () { }); it('should return false if user rejected to sign the transaction', async function () { + sandbox.stub(utils, 'validateAccountRequireUpgradeOrDeploy').resolves(null); sandbox.stub(utils, 'isAccountDeployed').resolves(true); - sandbox.stub(utils, 'isUpgradeRequired').resolves(false); walletStub.rpcStubs.snap_dialog.resolves(false); const stub = sandbox.stub(utils, 'executeTxn').resolves({ transaction_hash: 'transaction_hash', diff --git a/packages/starknet-snap/test/src/extractPrivateKey.test.ts b/packages/starknet-snap/test/src/extractPrivateKey.test.ts index b7f0e591..ea29b98e 100644 --- a/packages/starknet-snap/test/src/extractPrivateKey.test.ts +++ b/packages/starknet-snap/test/src/extractPrivateKey.test.ts @@ -10,6 +10,7 @@ import { getAddressKeyDeriver } from '../../src/utils/keyPair'; import * as utils from '../../src/utils/starknetUtils'; import { Mutex } from 'async-mutex'; import { ApiParams, ExtractPrivateKeyRequestParams } from '../../src/types/snapApi'; +import { UpgradeRequiredError } from '../../src/utils/exceptions'; chai.use(sinonChai); const sandbox = sinon.createSandbox(); @@ -87,25 +88,33 @@ describe('Test function: extractPrivateKey', function () { apiParams.requestParams = Object.assign({}, requestObject); }); - describe('when require upgrade checking fail', function () { + describe('when validateAccountRequireUpgradeOrDeploy fail', function () { it('should throw error', async function () { - const isUpgradeRequiredStub = sandbox.stub(utils, 'isUpgradeRequired').throws('network error'); + const validateAccountRequireUpgradeOrDeployStub = sandbox + .stub(utils, 'validateAccountRequireUpgradeOrDeploy') + .throws('network error'); let result; try { result = await extractPrivateKey(apiParams); } catch (err) { result = err; } finally { - expect(isUpgradeRequiredStub).to.have.been.calledOnceWith(STARKNET_SEPOLIA_TESTNET_NETWORK, account1.address); + expect(validateAccountRequireUpgradeOrDeployStub).to.have.been.calledOnceWith( + STARKNET_SEPOLIA_TESTNET_NETWORK, + account1.address, + account1.publicKey, + ); expect(result).to.be.an('Error'); } }); }); describe('when account require upgrade', function () { - let isUpgradeRequiredStub: sinon.SinonStub; + let validateAccountRequireUpgradeOrDeployStub: sinon.SinonStub; beforeEach(async function () { - isUpgradeRequiredStub = sandbox.stub(utils, 'isUpgradeRequired').resolves(true); + validateAccountRequireUpgradeOrDeployStub = sandbox + .stub(utils, 'validateAccountRequireUpgradeOrDeploy') + .throws(new UpgradeRequiredError('Upgrade Required')); }); it('should throw error if upgrade required', async function () { @@ -115,7 +124,11 @@ describe('Test function: extractPrivateKey', function () { } catch (err) { result = err; } finally { - expect(isUpgradeRequiredStub).to.have.been.calledOnceWith(STARKNET_SEPOLIA_TESTNET_NETWORK, account1.address); + expect(validateAccountRequireUpgradeOrDeployStub).to.have.been.calledOnceWith( + STARKNET_SEPOLIA_TESTNET_NETWORK, + account1.address, + account1.publicKey, + ); expect(result).to.be.an('Error'); } }); @@ -123,7 +136,7 @@ describe('Test function: extractPrivateKey', function () { describe('when account is not require upgrade', function () { beforeEach(async function () { - sandbox.stub(utils, 'isUpgradeRequired').resolves(false); + sandbox.stub(utils, 'validateAccountRequireUpgradeOrDeploy').resolves(null); }); it('should get the private key of the specified user account correctly', async function () { diff --git a/packages/starknet-snap/test/src/extractPublicKey.test.ts b/packages/starknet-snap/test/src/extractPublicKey.test.ts index 8dd022fd..a79df0f4 100644 --- a/packages/starknet-snap/test/src/extractPublicKey.test.ts +++ b/packages/starknet-snap/test/src/extractPublicKey.test.ts @@ -10,6 +10,7 @@ import { getAddressKeyDeriver } from '../../src/utils/keyPair'; import * as utils from '../../src/utils/starknetUtils'; import { Mutex } from 'async-mutex'; import { ApiParams, ExtractPublicKeyRequestParams } from '../../src/types/snapApi'; +import { UpgradeRequiredError } from '../../src/utils/exceptions'; chai.use(sinonChai); const sandbox = sinon.createSandbox(); @@ -86,25 +87,33 @@ describe('Test function: extractPublicKey', function () { apiParams.requestParams = Object.assign({}, requestObject); }); - describe('when require upgrade checking fail', function () { + describe('when validateAccountRequireUpgradeOrDeploy checking fail', function () { it('should throw error', async function () { - const isUpgradeRequiredStub = sandbox.stub(utils, 'isUpgradeRequired').throws('network error'); + const validateAccountRequireUpgradeOrDeployStub = sandbox + .stub(utils, 'validateAccountRequireUpgradeOrDeploy') + .throws('network error'); let result; try { result = await extractPublicKey(apiParams); } catch (err) { result = err; } finally { - expect(isUpgradeRequiredStub).to.have.been.calledOnceWith(STARKNET_SEPOLIA_TESTNET_NETWORK, account1.address); + expect(validateAccountRequireUpgradeOrDeployStub).to.have.been.calledOnceWith( + STARKNET_SEPOLIA_TESTNET_NETWORK, + account1.address, + account1.publicKey, + ); expect(result).to.be.an('Error'); } }); }); describe('when account require upgrade', function () { - let isUpgradeRequiredStub: sinon.SinonStub; + let validateAccountRequireUpgradeOrDeployStub: sinon.SinonStub; beforeEach(async function () { - isUpgradeRequiredStub = sandbox.stub(utils, 'isUpgradeRequired').resolves(true); + validateAccountRequireUpgradeOrDeployStub = sandbox + .stub(utils, 'validateAccountRequireUpgradeOrDeploy') + .throws(new UpgradeRequiredError('Upgrade Required')); }); it('should throw error if upgrade required', async function () { @@ -114,15 +123,19 @@ describe('Test function: extractPublicKey', function () { } catch (err) { result = err; } finally { - expect(isUpgradeRequiredStub).to.have.been.calledOnceWith(STARKNET_SEPOLIA_TESTNET_NETWORK, account1.address); + expect(validateAccountRequireUpgradeOrDeployStub).to.have.been.calledOnceWith( + STARKNET_SEPOLIA_TESTNET_NETWORK, + account1.address, + account1.publicKey, + ); expect(result).to.be.an('Error'); } }); }); - describe('when account is not require upgrade', function () { + describe('when account does not require upgrade', function () { beforeEach(async function () { - sandbox.stub(utils, 'isUpgradeRequired').resolves(false); + sandbox.stub(utils, 'validateAccountRequireUpgradeOrDeploy').resolves(null); }); it('should get the public key of the specified user account correctly', async function () { diff --git a/packages/starknet-snap/test/src/index.test.ts b/packages/starknet-snap/test/src/index.test.ts new file mode 100644 index 00000000..1bb5d3c1 --- /dev/null +++ b/packages/starknet-snap/test/src/index.test.ts @@ -0,0 +1,171 @@ +import chai, { expect } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { WalletMock } from '../wallet.mock.test'; +import { getValue } from '../../src/getValue'; +import { + createAccountProxyTxn, + testnetAccAddresses, + testnetPublicKeys, + mainnetPublicKeys, + mainnetAccAddresses, + invalidNetwork as INVALID_NETWORK, + getBip44EntropyStub, + account1, +} from '../constants.test'; +import { SnapState } from '../../src/types/snapState'; +import { + ETHER_MAINNET, + ETHER_SEPOLIA_TESTNET, + STARKNET_MAINNET_NETWORK, + STARKNET_SEPOLIA_TESTNET_NETWORK, +} from '../../src/utils/constants'; +import { Mutex } from 'async-mutex'; +import * as snapUtils from '../../src/utils/snapUtils'; +import * as starknetUtils from '../../src/utils/starknetUtils'; +import { onHomePage } from '../../src'; + +chai.use(sinonChai); +const sandbox = sinon.createSandbox(); + +describe('Test function: onHomePage', function () { + const walletStub = new WalletMock(); + // eslint-disable-next-line no-restricted-globals + const globalAny: any = global; + const state: SnapState = { + accContracts: [], + erc20Tokens: [ETHER_MAINNET, ETHER_SEPOLIA_TESTNET], + networks: [STARKNET_SEPOLIA_TESTNET_NETWORK, STARKNET_MAINNET_NETWORK], + transactions: [], + currentNetwork: undefined, + }; + + beforeEach(function () { + globalAny.snap = walletStub; + walletStub.rpcStubs.snap_getBip44Entropy.callsFake(getBip44EntropyStub); + }); + + afterEach(function () { + walletStub.reset(); + sandbox.restore(); + globalAny.snap = undefined; + }); + + const prepareAccountDiscovery = () => { + const getKeysFromAddressIndexSpy = sandbox.stub(starknetUtils, 'getKeysFromAddressIndex'); + const getCorrectContractAddressSpy = sandbox.stub(starknetUtils, 'getCorrectContractAddress'); + const getBalanceSpy = sandbox.stub(starknetUtils, 'getBalance'); + + getKeysFromAddressIndexSpy.resolves({ + privateKey: 'pk', + publicKey: 'pubkey', + addressIndex: 1, + derivationPath: `m / bip32:1' / bip32:1' / bip32:1' / bip32:1'`, + }); + + getCorrectContractAddressSpy.resolves({ + address: account1.address, + signerPubKey: account1.publicKey, + upgradeRequired: false, + deployRequired: false, + }); + + getBalanceSpy.resolves('1000'); + }; + + it('renders user address, user balance and network', async function () { + walletStub.rpcStubs.snap_manageState.resolves(state); + prepareAccountDiscovery(); + + const result = await onHomePage(); + expect(result).to.eql({ + content: { + type: 'panel', + children: [ + { type: 'text', value: 'Address' }, + { + type: 'copyable', + value: account1.address, + }, + { + type: 'row', + label: 'Network', + value: { + type: 'text', + value: STARKNET_SEPOLIA_TESTNET_NETWORK.name, + }, + }, + { + type: 'row', + label: 'Balance', + value: { + type: 'text', + value: '0.000000000000001 ETH', + }, + }, + { type: 'divider' }, + { + type: 'text', + value: + 'Visit the [companion dapp for Starknet](https://snaps.consensys.io/starknet) to manage your account.', + }, + ], + }, + }); + }); + + it('renders selected network from state if `currentNetwork` is not undefined', async function () { + walletStub.rpcStubs.snap_manageState.resolves({ + ...state, + currentNetwork: ETHER_MAINNET, + }); + prepareAccountDiscovery(); + + const result = await onHomePage(); + expect(result).to.eql({ + content: { + type: 'panel', + children: [ + { type: 'text', value: 'Address' }, + { + type: 'copyable', + value: account1.address, + }, + { + type: 'row', + label: 'Network', + value: { + type: 'text', + value: ETHER_MAINNET.name, + }, + }, + { + type: 'row', + label: 'Balance', + value: { + type: 'text', + value: '0.000000000000001 ETH', + }, + }, + { type: 'divider' }, + { + type: 'text', + value: + 'Visit the [companion dapp for Starknet](https://snaps.consensys.io/starknet) to manage your account.', + }, + ], + }, + }); + }); + + it('throws error when state not found', async function () { + let error; + try { + await onHomePage(); + } catch (err) { + error = err; + } finally { + expect(error).to.be.an('error'); + } + }); +}); diff --git a/packages/starknet-snap/test/src/recoverAccounts.test.ts b/packages/starknet-snap/test/src/recoverAccounts.test.ts index 1ff1d2e6..ba98fd9a 100644 --- a/packages/starknet-snap/test/src/recoverAccounts.test.ts +++ b/packages/starknet-snap/test/src/recoverAccounts.test.ts @@ -66,14 +66,18 @@ describe('Test function: recoverAccounts', function () { for (let i = 0; i < maxScanned; i++) { if (i < validPublicKeys) { - getCorrectContractAddressStub - .onCall(i) - .resolves({ address: mainnetAccAddresses[i], signerPubKey: mainnetPublicKeys[i], upgradeRequired: false }); + getCorrectContractAddressStub.onCall(i).resolves({ + address: mainnetAccAddresses[i], + signerPubKey: mainnetPublicKeys[i], + upgradeRequired: false, + deployRequired: false, + }); } else { getCorrectContractAddressStub.onCall(i).resolves({ address: mainnetAccAddresses[i], signerPubKey: num.toHex(constants.ZERO), upgradeRequired: false, + deployRequired: false, }); } } @@ -110,14 +114,18 @@ describe('Test function: recoverAccounts', function () { for (let i = 0; i < maxScanned; i++) { if (i < validPublicKeys) { - getCorrectContractAddressStub - .onCall(i) - .resolves({ address: testnetAccAddresses[i], signerPubKey: testnetPublicKeys[i], upgradeRequired: false }); + getCorrectContractAddressStub.onCall(i).resolves({ + address: testnetAccAddresses[i], + signerPubKey: testnetPublicKeys[i], + upgradeRequired: false, + deployRequired: false, + }); } else { getCorrectContractAddressStub.onCall(i).resolves({ address: testnetAccAddresses[i], signerPubKey: num.toHex(constants.ZERO), upgradeRequired: false, + deployRequired: false, }); } } @@ -184,14 +192,18 @@ describe('Test function: recoverAccounts', function () { for (let i = 0; i < maxScanned; i++) { if (i < validPublicKeys) { - getCorrectContractAddressStub - .onCall(i) - .resolves({ address: mainnetAccAddresses[i], signerPubKey: mainnetPublicKeys[i], upgradeRequired: false }); + getCorrectContractAddressStub.onCall(i).resolves({ + address: mainnetAccAddresses[i], + signerPubKey: mainnetPublicKeys[i], + upgradeRequired: false, + deployRequired: false, + }); } else { getCorrectContractAddressStub.onCall(i).resolves({ address: mainnetAccAddresses[i], signerPubKey: num.toHex(constants.ZERO), upgradeRequired: false, + deployRequired: false, }); } } diff --git a/packages/starknet-snap/test/src/signDeclareTransaction.test.ts b/packages/starknet-snap/test/src/signDeclareTransaction.test.ts index c15d43bd..8b50a32e 100644 --- a/packages/starknet-snap/test/src/signDeclareTransaction.test.ts +++ b/packages/starknet-snap/test/src/signDeclareTransaction.test.ts @@ -12,6 +12,7 @@ import { ApiParams, SignDeclareTransactionRequestParams } from '../../src/types/ import { DeclareSignerDetails, constants } from 'starknet'; import * as utils from '../../src/utils/starknetUtils'; import * as snapsUtil from '../../src/utils/snapUtils'; +import { DeployRequiredError, UpgradeRequiredError } from '../../src/utils/exceptions'; chai.use(sinonChai); const sandbox = sinon.createSandbox(); @@ -55,6 +56,13 @@ describe('Test function: signDeclareTransaction', function () { sandbox.useFakeTimers(createAccountProxyTxn.timestamp); walletStub.rpcStubs.snap_dialog.resolves(true); walletStub.rpcStubs.snap_manageState.resolves(state); + sandbox.stub(snapsUtil, 'showAccountRequireUpgradeOrDeployModal').callsFake(async (wallet, e) => { + if (e instanceof DeployRequiredError) { + await snapsUtil.showDeployRequestModal(wallet); + } else if (e instanceof UpgradeRequiredError) { + await snapsUtil.showUpgradeRequestModal(wallet); + } + }); }); afterEach(function () { @@ -64,15 +72,17 @@ describe('Test function: signDeclareTransaction', function () { }); it('should sign a transaction from an user account correctly', async function () { - sandbox.stub(utils, 'isUpgradeRequired').resolves(false); + sandbox.stub(utils, 'validateAccountRequireUpgradeOrDeploy').resolves(null); sandbox.stub(utils, 'signDeclareTransaction').resolves(signature3); const result = await signDeclareTransaction(apiParams); expect(walletStub.rpcStubs.snap_dialog).to.have.been.calledOnce; expect(result).to.be.eql(signature3); }); - it('should 1) throw an error and 2) show upgrade modal if account deployed required', async function () { - const isUpgradeRequiredStub = sandbox.stub(utils, 'isUpgradeRequired').resolves(true); + it('should 1) throw an error and 2) show upgrade modal if account upgrade required', async function () { + const validateAccountRequireUpgradeOrDeployStub = sandbox + .stub(utils, 'validateAccountRequireUpgradeOrDeploy') + .throws(new UpgradeRequiredError('Upgrade Required')); const showUpgradeRequestModalStub = sandbox.stub(snapsUtil, 'showUpgradeRequestModal').resolves(); let result; try { @@ -80,14 +90,42 @@ describe('Test function: signDeclareTransaction', function () { } catch (err) { result = err; } finally { - expect(isUpgradeRequiredStub).to.have.been.calledOnceWith(STARKNET_SEPOLIA_TESTNET_NETWORK, account1.address); + expect(validateAccountRequireUpgradeOrDeployStub).to.have.been.calledOnceWith( + STARKNET_SEPOLIA_TESTNET_NETWORK, + account1.address, + account1.publicKey, + ); expect(showUpgradeRequestModalStub).to.have.been.calledOnce; expect(result).to.be.an('Error'); + expect(result.message).to.equal('Upgrade Required'); + } + }); + + it('should 1) throw an error and 2) show deploy modal if account deployed required', async function () { + const validateAccountRequireUpgradeOrDeployStub = sandbox + .stub(utils, 'validateAccountRequireUpgradeOrDeploy') + .throws( + new DeployRequiredError(`Cairo 0 contract address ${account1.address} balance is not empty, deploy required`), + ); + const showDeployRequestModalStub = sandbox.stub(snapsUtil, 'showDeployRequestModal').resolves(); + let result; + try { + result = await signDeclareTransaction(apiParams); + } catch (err) { + result = err; + } finally { + expect(validateAccountRequireUpgradeOrDeployStub).to.have.been.calledOnceWith( + STARKNET_SEPOLIA_TESTNET_NETWORK, + account1.address, + account1.publicKey, + ); + expect(showDeployRequestModalStub).to.have.been.calledOnce; + expect(result).to.be.an('Error'); } }); it('should throw error if signDeclareTransaction fail', async function () { - sandbox.stub(utils, 'isUpgradeRequired').resolves(false); + sandbox.stub(utils, 'validateAccountRequireUpgradeOrDeploy').resolves(null); sandbox.stub(utils, 'signDeclareTransaction').throws(new Error()); let result; try { @@ -101,7 +139,7 @@ describe('Test function: signDeclareTransaction', function () { }); it('should return false if user deny to sign the transaction', async function () { - sandbox.stub(utils, 'isUpgradeRequired').resolves(false); + sandbox.stub(utils, 'validateAccountRequireUpgradeOrDeploy').resolves(null); const stub = sandbox.stub(utils, 'signDeclareTransaction'); walletStub.rpcStubs.snap_dialog.resolves(false); @@ -112,7 +150,7 @@ describe('Test function: signDeclareTransaction', function () { }); it('should skip dialog if enableAuthorize is false', async function () { - sandbox.stub(utils, 'isUpgradeRequired').resolves(false); + sandbox.stub(utils, 'validateAccountRequireUpgradeOrDeploy').resolves(null); sandbox.stub(utils, 'signDeclareTransaction').resolves(signature3); const paramsObject = apiParams.requestParams as SignDeclareTransactionRequestParams; paramsObject.enableAuthorize = false; @@ -123,7 +161,7 @@ describe('Test function: signDeclareTransaction', function () { }); it('should skip dialog if enableAuthorize is omit', async function () { - sandbox.stub(utils, 'isUpgradeRequired').resolves(false); + sandbox.stub(utils, 'validateAccountRequireUpgradeOrDeploy').resolves(null); sandbox.stub(utils, 'signDeclareTransaction').resolves(signature3); const paramsObject = apiParams.requestParams as SignDeclareTransactionRequestParams; paramsObject.enableAuthorize = undefined; diff --git a/packages/starknet-snap/test/src/signDeployAccountTransaction.test.ts b/packages/starknet-snap/test/src/signDeployAccountTransaction.test.ts index f290bc80..66f307cd 100644 --- a/packages/starknet-snap/test/src/signDeployAccountTransaction.test.ts +++ b/packages/starknet-snap/test/src/signDeployAccountTransaction.test.ts @@ -12,6 +12,7 @@ import { ApiParams, SignDeployAccountTransactionRequestParams } from '../../src/ import { DeployAccountSignerDetails, constants } from 'starknet'; import * as utils from '../../src/utils/starknetUtils'; import * as snapsUtil from '../../src/utils/snapUtils'; +import { DeployRequiredError, UpgradeRequiredError } from '../../src/utils/exceptions'; chai.use(sinonChai); const sandbox = sinon.createSandbox(); @@ -57,6 +58,13 @@ describe('Test function: signDeployAccountTransaction', function () { sandbox.useFakeTimers(createAccountProxyTxn.timestamp); walletStub.rpcStubs.snap_dialog.resolves(true); walletStub.rpcStubs.snap_manageState.resolves(state); + sandbox.stub(snapsUtil, 'showAccountRequireUpgradeOrDeployModal').callsFake(async (wallet, e) => { + if (e instanceof DeployRequiredError) { + await snapsUtil.showDeployRequestModal(wallet); + } else if (e instanceof UpgradeRequiredError) { + await snapsUtil.showUpgradeRequestModal(wallet); + } + }); }); afterEach(function () { @@ -66,15 +74,17 @@ describe('Test function: signDeployAccountTransaction', function () { }); it('should sign a transaction from an user account correctly', async function () { - sandbox.stub(utils, 'isUpgradeRequired').resolves(false); + sandbox.stub(utils, 'validateAccountRequireUpgradeOrDeploy').resolves(null); sandbox.stub(utils, 'signDeployAccountTransaction').resolves(signature3); const result = await signDeployAccountTransaction(apiParams); expect(walletStub.rpcStubs.snap_dialog).to.have.been.calledOnce; expect(result).to.be.eql(signature3); }); - it('should 1) throw an error and 2) show upgrade modal if account deployed required', async function () { - const isUpgradeRequiredStub = sandbox.stub(utils, 'isUpgradeRequired').resolves(true); + it('should 1) throw an error and 2) show upgrade modal if account upgrade required', async function () { + const validateAccountRequireUpgradeOrDeployStub = sandbox + .stub(utils, 'validateAccountRequireUpgradeOrDeploy') + .throws(new UpgradeRequiredError('Upgrade Required')); const showUpgradeRequestModalStub = sandbox.stub(snapsUtil, 'showUpgradeRequestModal').resolves(); let result; try { @@ -82,14 +92,42 @@ describe('Test function: signDeployAccountTransaction', function () { } catch (err) { result = err; } finally { - expect(isUpgradeRequiredStub).to.have.been.calledOnceWith(STARKNET_SEPOLIA_TESTNET_NETWORK, account1.address); + expect(validateAccountRequireUpgradeOrDeployStub).to.have.been.calledOnceWith( + STARKNET_SEPOLIA_TESTNET_NETWORK, + account1.address, + account1.publicKey, + ); expect(showUpgradeRequestModalStub).to.have.been.calledOnce; expect(result).to.be.an('Error'); + expect(result.message).to.equal('Upgrade Required'); + } + }); + + it('should 1) throw an error and 2) show deploy modal if account deployed required', async function () { + const validateAccountRequireUpgradeOrDeployStub = sandbox + .stub(utils, 'validateAccountRequireUpgradeOrDeploy') + .throws( + new DeployRequiredError(`Cairo 0 contract address ${account1.address} balance is not empty, deploy required`), + ); + const showDeployRequestModalStub = sandbox.stub(snapsUtil, 'showDeployRequestModal').resolves(); + let result; + try { + result = await signDeployAccountTransaction(apiParams); + } catch (err) { + result = err; + } finally { + expect(validateAccountRequireUpgradeOrDeployStub).to.have.been.calledOnceWith( + STARKNET_SEPOLIA_TESTNET_NETWORK, + account1.address, + account1.publicKey, + ); + expect(showDeployRequestModalStub).to.have.been.calledOnce; + expect(result).to.be.an('Error'); } }); it('should throw error if signDeployAccountTransaction fail', async function () { - sandbox.stub(utils, 'isUpgradeRequired').resolves(false); + sandbox.stub(utils, 'validateAccountRequireUpgradeOrDeploy').resolves(null); sandbox.stub(utils, 'signDeployAccountTransaction').throws(new Error()); let result; try { @@ -103,7 +141,7 @@ describe('Test function: signDeployAccountTransaction', function () { }); it('should return false if user deny to sign the transaction', async function () { - sandbox.stub(utils, 'isUpgradeRequired').resolves(false); + sandbox.stub(utils, 'validateAccountRequireUpgradeOrDeploy').resolves(null); const stub = sandbox.stub(utils, 'signDeployAccountTransaction'); walletStub.rpcStubs.snap_dialog.resolves(false); @@ -114,7 +152,7 @@ describe('Test function: signDeployAccountTransaction', function () { }); it('should skip dialog if enableAuthorize is false', async function () { - sandbox.stub(utils, 'isUpgradeRequired').resolves(false); + sandbox.stub(utils, 'validateAccountRequireUpgradeOrDeploy').resolves(null); sandbox.stub(utils, 'signDeployAccountTransaction').resolves(signature3); const paramsObject = apiParams.requestParams as SignDeployAccountTransactionRequestParams; paramsObject.enableAuthorize = false; @@ -125,7 +163,7 @@ describe('Test function: signDeployAccountTransaction', function () { }); it('should skip dialog if enableAuthorize is omit', async function () { - sandbox.stub(utils, 'isUpgradeRequired').resolves(false); + sandbox.stub(utils, 'validateAccountRequireUpgradeOrDeploy').resolves(null); sandbox.stub(utils, 'signDeployAccountTransaction').resolves(signature3); const paramsObject = apiParams.requestParams as SignDeployAccountTransactionRequestParams; paramsObject.enableAuthorize = undefined; diff --git a/packages/starknet-snap/test/src/signMessage.test.ts b/packages/starknet-snap/test/src/signMessage.test.ts index 9f6fca21..b1dec77a 100644 --- a/packages/starknet-snap/test/src/signMessage.test.ts +++ b/packages/starknet-snap/test/src/signMessage.test.ts @@ -18,6 +18,7 @@ import { getAddressKeyDeriver } from '../../src/utils/keyPair'; import * as utils from '../../src/utils/starknetUtils'; import { Mutex } from 'async-mutex'; import { ApiParams, SignMessageRequestParams } from '../../src/types/snapApi'; +import { DeployRequiredError, UpgradeRequiredError } from '../../src/utils/exceptions'; chai.use(sinonChai); const sandbox = sinon.createSandbox(); @@ -102,7 +103,7 @@ describe('Test function: signMessage', function () { }); it('skip dialog if enableAuthorize is false or omit', async function () { - sandbox.stub(utils, 'isUpgradeRequired').resolves(false); + sandbox.stub(utils, 'validateAccountRequireUpgradeOrDeploy').resolves(null); const paramsObject = apiParams.requestParams as SignMessageRequestParams; paramsObject.enableAuthorize = false; @@ -118,23 +119,31 @@ describe('Test function: signMessage', function () { describe('when require upgrade checking fail', function () { it('should throw error', async function () { - const isUpgradeRequiredStub = sandbox.stub(utils, 'isUpgradeRequired').throws('network error'); + const validateAccountRequireUpgradeOrDeployStub = sandbox + .stub(utils, 'validateAccountRequireUpgradeOrDeploy') + .throws('network error'); let result; try { result = await signMessage(apiParams); } catch (err) { result = err; } finally { - expect(isUpgradeRequiredStub).to.have.been.calledOnceWith(STARKNET_SEPOLIA_TESTNET_NETWORK, account1.address); + expect(validateAccountRequireUpgradeOrDeployStub).to.have.been.calledOnceWith( + STARKNET_SEPOLIA_TESTNET_NETWORK, + account1.address, + account1.publicKey, + ); expect(result).to.be.an('Error'); } }); }); describe('when account require upgrade', function () { - let isUpgradeRequiredStub: sinon.SinonStub; + let validateAccountRequireUpgradeOrDeployStub: sinon.SinonStub; beforeEach(async function () { - isUpgradeRequiredStub = sandbox.stub(utils, 'isUpgradeRequired').resolves(true); + validateAccountRequireUpgradeOrDeployStub = sandbox + .stub(utils, 'validateAccountRequireUpgradeOrDeploy') + .throws(new UpgradeRequiredError('Upgrade Required')); }); it('should throw error if upgrade required', async function () { @@ -144,8 +153,45 @@ describe('Test function: signMessage', function () { } catch (err) { result = err; } finally { - expect(isUpgradeRequiredStub).to.have.been.calledOnceWith(STARKNET_SEPOLIA_TESTNET_NETWORK, account1.address); + expect(validateAccountRequireUpgradeOrDeployStub).to.have.been.calledOnceWith( + STARKNET_SEPOLIA_TESTNET_NETWORK, + account1.address, + account1.publicKey, + ); expect(result).to.be.an('Error'); + expect(result.message).to.equal('Upgrade Required'); + } + }); + }); + + describe('when account require deploy', function () { + let validateAccountRequireUpgradeOrDeployStub: sinon.SinonStub; + beforeEach(async function () { + validateAccountRequireUpgradeOrDeployStub = sandbox + .stub(utils, 'validateAccountRequireUpgradeOrDeploy') + .throws( + new DeployRequiredError( + `Cairo 0 contract address ${account1.address} balance is not empty, deploy required`, + ), + ); + }); + + it('should throw error if deploy required', async function () { + let result; + try { + result = await signMessage(apiParams); + } catch (err) { + result = err; + } finally { + expect(validateAccountRequireUpgradeOrDeployStub).to.have.been.calledOnceWith( + STARKNET_SEPOLIA_TESTNET_NETWORK, + account1.address, + account1.publicKey, + ); + expect(result).to.be.an('Error'); + expect(result.message).to.equal( + `Cairo 0 contract address ${account1.address} balance is not empty, deploy required`, + ); } }); }); @@ -156,7 +202,7 @@ describe('Test function: signMessage', function () { ...apiParams.requestParams, signerAddress: Cairo1Account1.address, }; - sandbox.stub(utils, 'isUpgradeRequired').resolves(false); + sandbox.stub(utils, 'validateAccountRequireUpgradeOrDeploy').resolves(null); }); it('should sign a message from an user account correctly', async function () { @@ -185,7 +231,7 @@ describe('Test function: signMessage', function () { } finally { expect(result).to.be.an('Error'); } - expect(walletStub.rpcStubs.snap_dialog).to.have.been.calledOnce; + expect(walletStub.rpcStubs.snap_dialog).to.have.not.been.called; expect(walletStub.rpcStubs.snap_manageState).not.to.have.been.called; }); diff --git a/packages/starknet-snap/test/src/signTransaction.test.ts b/packages/starknet-snap/test/src/signTransaction.test.ts index faecfd03..05e3d5b9 100644 --- a/packages/starknet-snap/test/src/signTransaction.test.ts +++ b/packages/starknet-snap/test/src/signTransaction.test.ts @@ -12,11 +12,12 @@ import { ApiParams, SignTransactionRequestParams } from '../../src/types/snapApi import { constants } from 'starknet'; import * as utils from '../../src/utils/starknetUtils'; import * as snapsUtil from '../../src/utils/snapUtils'; +import { DeployRequiredError, UpgradeRequiredError } from '../../src/utils/exceptions'; chai.use(sinonChai); const sandbox = sinon.createSandbox(); -describe('Test function: signMessage', function () { +describe('Test function: signTransaction', function () { this.timeout(10000); const walletStub = new WalletMock(); const state: SnapState = { @@ -65,6 +66,13 @@ describe('Test function: signMessage', function () { sandbox.useFakeTimers(createAccountProxyTxn.timestamp); walletStub.rpcStubs.snap_dialog.resolves(true); walletStub.rpcStubs.snap_manageState.resolves(state); + sandbox.stub(snapsUtil, 'showAccountRequireUpgradeOrDeployModal').callsFake(async (wallet, e) => { + if (e instanceof DeployRequiredError) { + await snapsUtil.showDeployRequestModal(wallet); + } else if (e instanceof UpgradeRequiredError) { + await snapsUtil.showUpgradeRequestModal(wallet); + } + }); }); afterEach(function () { @@ -74,14 +82,16 @@ describe('Test function: signMessage', function () { }); it('should sign a transaction from an user account correctly', async function () { - sandbox.stub(utils, 'isUpgradeRequired').resolves(false); + sandbox.stub(utils, 'validateAccountRequireUpgradeOrDeploy').resolves(null); const result = await signTransaction(apiParams); expect(walletStub.rpcStubs.snap_dialog).to.have.been.calledOnce; expect(result).to.be.eql(signature3); }); - it('should 1) throw an error and 2) show upgrade modal if account deployed required', async function () { - const isUpgradeRequiredStub = sandbox.stub(utils, 'isUpgradeRequired').resolves(true); + it('should 1) throw an error and 2) show upgrade modal if account upgrade required', async function () { + const validateAccountRequireUpgradeOrDeployStub = sandbox + .stub(utils, 'validateAccountRequireUpgradeOrDeploy') + .throws(new UpgradeRequiredError('Upgrade Required')); const showUpgradeRequestModalStub = sandbox.stub(snapsUtil, 'showUpgradeRequestModal').resolves(); let result; try { @@ -89,14 +99,42 @@ describe('Test function: signMessage', function () { } catch (err) { result = err; } finally { - expect(isUpgradeRequiredStub).to.have.been.calledOnceWith(STARKNET_SEPOLIA_TESTNET_NETWORK, account1.address); + expect(validateAccountRequireUpgradeOrDeployStub).to.have.been.calledOnceWith( + STARKNET_SEPOLIA_TESTNET_NETWORK, + account1.address, + account1.publicKey, + ); expect(showUpgradeRequestModalStub).to.have.been.calledOnce; expect(result).to.be.an('Error'); + expect(result.message).to.equal('Upgrade Required'); + } + }); + + it('should 1) throw an error and 2) show deploy modal if account deployed required', async function () { + const validateAccountRequireUpgradeOrDeployStub = sandbox + .stub(utils, 'validateAccountRequireUpgradeOrDeploy') + .throws( + new DeployRequiredError(`Cairo 0 contract address ${account1.address} balance is not empty, deploy required`), + ); + const showDeployRequestModalStub = sandbox.stub(snapsUtil, 'showDeployRequestModal').resolves(); + let result; + try { + result = await signTransaction(apiParams); + } catch (err) { + result = err; + } finally { + expect(validateAccountRequireUpgradeOrDeployStub).to.have.been.calledOnceWith( + STARKNET_SEPOLIA_TESTNET_NETWORK, + account1.address, + account1.publicKey, + ); + expect(showDeployRequestModalStub).to.have.been.calledOnce; + expect(result).to.be.an('Error'); } }); it('should throw error if signTransaction fail', async function () { - sandbox.stub(utils, 'isUpgradeRequired').resolves(false); + sandbox.stub(utils, 'validateAccountRequireUpgradeOrDeploy').resolves(null); sandbox.stub(utils, 'signTransactions').throws(new Error()); let result; try { @@ -110,7 +148,7 @@ describe('Test function: signMessage', function () { }); it('should return false if user deny to sign the transaction', async function () { - sandbox.stub(utils, 'isUpgradeRequired').resolves(false); + sandbox.stub(utils, 'validateAccountRequireUpgradeOrDeploy').resolves(null); const stub = sandbox.stub(utils, 'signTransactions'); walletStub.rpcStubs.snap_dialog.resolves(false); @@ -121,7 +159,7 @@ describe('Test function: signMessage', function () { }); it('should skip dialog if enableAuthorize is false', async function () { - sandbox.stub(utils, 'isUpgradeRequired').resolves(false); + sandbox.stub(utils, 'validateAccountRequireUpgradeOrDeploy').resolves(null); const paramsObject = apiParams.requestParams as SignTransactionRequestParams; paramsObject.enableAuthorize = false; const result = await signTransaction(apiParams); @@ -131,7 +169,7 @@ describe('Test function: signMessage', function () { }); it('should skip dialog if enableAuthorize is omit', async function () { - sandbox.stub(utils, 'isUpgradeRequired').resolves(false); + sandbox.stub(utils, 'validateAccountRequireUpgradeOrDeploy').resolves(null); const paramsObject = apiParams.requestParams as SignTransactionRequestParams; paramsObject.enableAuthorize = undefined; const result = await signTransaction(apiParams); diff --git a/packages/starknet-snap/test/src/verifySignedMessage.test.ts b/packages/starknet-snap/test/src/verifySignedMessage.test.ts index 5e1aa2ea..8ff58c0c 100644 --- a/packages/starknet-snap/test/src/verifySignedMessage.test.ts +++ b/packages/starknet-snap/test/src/verifySignedMessage.test.ts @@ -10,6 +10,7 @@ import { getAddressKeyDeriver } from '../../src/utils/keyPair'; import * as utils from '../../src/utils/starknetUtils'; import { Mutex } from 'async-mutex'; import { ApiParams, VerifySignedMessageRequestParams } from '../../src/types/snapApi'; +import { UpgradeRequiredError } from '../../src/utils/exceptions'; chai.use(sinonChai); const sandbox = sinon.createSandbox(); @@ -91,25 +92,33 @@ describe('Test function: verifySignedMessage', function () { apiParams.requestParams = Object.assign({}, requestObject); }); - describe('when require upgrade checking fail', function () { + describe('when validateAccountRequireUpgradeOrDeploy fail', function () { it('should throw error', async function () { - const isUpgradeRequiredStub = sandbox.stub(utils, 'isUpgradeRequired').throws('network error'); + const validateAccountRequireUpgradeOrDeployStub = sandbox + .stub(utils, 'validateAccountRequireUpgradeOrDeploy') + .throws('network error'); let result; try { result = await verifySignedMessage(apiParams); } catch (err) { result = err; } finally { - expect(isUpgradeRequiredStub).to.have.been.calledOnceWith(STARKNET_SEPOLIA_TESTNET_NETWORK, account1.address); + expect(validateAccountRequireUpgradeOrDeployStub).to.have.been.calledOnceWith( + STARKNET_SEPOLIA_TESTNET_NETWORK, + account1.address, + account1.publicKey, + ); expect(result).to.be.an('Error'); } }); }); describe('when account require upgrade', function () { - let isUpgradeRequiredStub: sinon.SinonStub; + let validateAccountRequireUpgradeOrDeployStub: sinon.SinonStub; beforeEach(async function () { - isUpgradeRequiredStub = sandbox.stub(utils, 'isUpgradeRequired').resolves(true); + validateAccountRequireUpgradeOrDeployStub = sandbox + .stub(utils, 'validateAccountRequireUpgradeOrDeploy') + .throws(new UpgradeRequiredError('Upgrade Required')); }); it('should throw error if upgrade required', async function () { @@ -119,7 +128,11 @@ describe('Test function: verifySignedMessage', function () { } catch (err) { result = err; } finally { - expect(isUpgradeRequiredStub).to.have.been.calledOnceWith(STARKNET_SEPOLIA_TESTNET_NETWORK, account1.address); + expect(validateAccountRequireUpgradeOrDeployStub).to.have.been.calledOnceWith( + STARKNET_SEPOLIA_TESTNET_NETWORK, + account1.address, + account1.publicKey, + ); expect(result).to.be.an('Error'); } }); @@ -127,7 +140,7 @@ describe('Test function: verifySignedMessage', function () { describe('when account is not require upgrade', function () { beforeEach(async function () { - sandbox.stub(utils, 'isUpgradeRequired').resolves(false); + sandbox.stub(utils, 'validateAccountRequireUpgradeOrDeploy').resolves(null); }); it('should verify a signed message from an user account correctly', async function () { diff --git a/packages/starknet-snap/test/utils/starknetUtils.test.ts b/packages/starknet-snap/test/utils/starknetUtils.test.ts index 7ef654f2..b56c0e98 100644 --- a/packages/starknet-snap/test/utils/starknetUtils.test.ts +++ b/packages/starknet-snap/test/utils/starknetUtils.test.ts @@ -12,6 +12,7 @@ import { account1, account2, account3, + getBalanceResp, } from '../constants.test'; import { SnapState } from '../../src/types/snapState'; import { Calldata, num, Account, Provider, GetTransactionReceiptResponse } from 'starknet'; @@ -499,7 +500,7 @@ describe('Test function: getCorrectContractAddress', function () { }); describe(`when contact is Cairo${CAIRO_VERSION} has deployed`, function () { - it(`should return Cairo${CAIRO_VERSION} address with pubic key`, async function () { + it(`should return Cairo${CAIRO_VERSION} address with public key`, async function () { getVersionStub = sandbox.stub(utils, 'getVersion').resolves(cairoVersionHex); getSignerStub = sandbox.stub(utils, 'getSigner').resolves(PK); getOwnerStub = sandbox.stub(utils, 'getOwner').resolves(PK); @@ -514,9 +515,9 @@ describe('Test function: getCorrectContractAddress', function () { }); }); - describe(`when contact is Cairo${CAIRO_VERSION} has not deployed`, function () { - describe(`when when is Cairo${CAIRO_VERSION_LEGACY} has deployed`, function () { - describe(`when when is Cairo${CAIRO_VERSION_LEGACY} has upgraded`, function () { + describe(`when Cairo${CAIRO_VERSION} has not deployed`, function () { + describe(`and Cairo${CAIRO_VERSION_LEGACY} has deployed`, function () { + describe(`and Cairo${CAIRO_VERSION_LEGACY} has upgraded`, function () { it(`should return Cairo${CAIRO_VERSION_LEGACY} address with upgrade = false`, async function () { sandbox .stub(utils, 'getVersion') @@ -561,9 +562,10 @@ describe('Test function: getCorrectContractAddress', function () { }); }); - describe(`when when is Cairo${CAIRO_VERSION_LEGACY} has not deployed`, function () { - it(`should return Cairo${CAIRO_VERSION} address with upgrade = false`, async function () { + describe(`when when Cairo${CAIRO_VERSION_LEGACY} is not deployed`, function () { + it(`should return Cairo${CAIRO_VERSION} address with upgrade = false and deploy = false if no balance`, async function () { sandbox.stub(utils, 'getVersion').rejects(new Error('Contract not found')); + sandbox.stub(utils, 'getBalance').callsFake(async () => getBalanceResp[0]); getSignerStub = sandbox.stub(utils, 'getSigner').resolves(PK); getOwnerStub = sandbox.stub(utils, 'getOwner').resolves(PK); @@ -576,6 +578,22 @@ describe('Test function: getCorrectContractAddress', function () { expect(result.signerPubKey).to.be.eq(''); expect(result.upgradeRequired).to.be.eq(false); }); + it(`should return Cairo${CAIRO_VERSION_LEGACY} address with upgrade = true and deploy = true if balance`, async function () { + sandbox.stub(utils, 'getVersion').rejects(new Error('Contract not found')); + sandbox.stub(utils, 'isEthBalanceEmpty').resolves(false); + + getSignerStub = sandbox.stub(utils, 'getSigner').resolves(PK); + getOwnerStub = sandbox.stub(utils, 'getOwner').resolves(PK); + + const result = await utils.getCorrectContractAddress(STARKNET_SEPOLIA_TESTNET_NETWORK, PK); + + expect(getSignerStub).to.have.been.callCount(0); + expect(getOwnerStub).to.have.been.callCount(0); + expect(result.address).to.be.eq(account2.address); + expect(result.signerPubKey).to.be.eq(''); + expect(result.upgradeRequired).to.be.eq(true); + expect(result.deployRequired).to.be.eq(true); + }); }); }); }); diff --git a/packages/wallet-ui/package.json b/packages/wallet-ui/package.json index c6349ca3..a5c2b668 100644 --- a/packages/wallet-ui/package.json +++ b/packages/wallet-ui/package.json @@ -13,7 +13,6 @@ "@fortawesome/free-solid-svg-icons": "^6.1.1", "@fortawesome/react-fontawesome": "^0.1.18", "@headlessui/react": "^1.6.4", - "@metamask/detect-provider": "^1.2.0", "@metamask/jazzicon": "github:metamask/jazzicon#d923914fda6a8795f74c2e66134f73cd72070667", "@mui/icons-material": "^5.6.2", "@mui/material": "^5.6.2", diff --git a/packages/wallet-ui/src/App.tsx b/packages/wallet-ui/src/App.tsx index d37475e8..338d071e 100644 --- a/packages/wallet-ui/src/App.tsx +++ b/packages/wallet-ui/src/App.tsx @@ -20,13 +20,16 @@ import { NoMetamaskModal } from 'components/ui/organism/NoMetamaskModal'; import { MinVersionModal } from './components/ui/organism/MinVersionModal'; import { useHasMetamask } from 'hooks/useHasMetamask'; import { DUMMY_ADDRESS } from 'utils/constants'; +import { DeployModal } from 'components/ui/organism/DeployModal'; library.add(fas, far); function App() { const { initSnap, getWalletData, checkConnection } = useStarkNetSnap(); const { connected, forceReconnect, provider } = useAppSelector((state) => state.wallet); - const { infoModalVisible, minVersionModalVisible, upgradeModalVisible } = useAppSelector((state) => state.modals); + const { infoModalVisible, minVersionModalVisible, upgradeModalVisible, deployModalVisible } = useAppSelector( + (state) => state.modals, + ); const { loader } = useAppSelector((state) => state.UI); const networks = useAppSelector((state) => state.networks); const { accounts } = useAppSelector((state) => state.wallet); @@ -56,7 +59,6 @@ function App() { }, [networks.activeNetwork, provider]); const loading = loader.isLoading; - return ( @@ -76,6 +78,9 @@ function App() { + + + {loading && {loader.loadingMessage}} diff --git a/packages/wallet-ui/src/components/ui/organism/DeployModal/DeployModal.stories.tsx b/packages/wallet-ui/src/components/ui/organism/DeployModal/DeployModal.stories.tsx new file mode 100644 index 00000000..c9cb4b88 --- /dev/null +++ b/packages/wallet-ui/src/components/ui/organism/DeployModal/DeployModal.stories.tsx @@ -0,0 +1,23 @@ +import { Meta } from '@storybook/react'; +import { useState } from 'react'; +import { PopIn } from 'components/ui/molecule/PopIn'; +import { DeployModalView } from './DeployModal.view'; + +export default { + title: 'Organism/DeployModal', + component: DeployModalView, +} as Meta; + +export const ContentOnly = () => ; + +export const WithModal = () => { + let [isOpen, setIsOpen] = useState(false); + return ( + <> + + + + + + ); +}; diff --git a/packages/wallet-ui/src/components/ui/organism/DeployModal/DeployModal.style.ts b/packages/wallet-ui/src/components/ui/organism/DeployModal/DeployModal.style.ts new file mode 100644 index 00000000..585e5c0b --- /dev/null +++ b/packages/wallet-ui/src/components/ui/organism/DeployModal/DeployModal.style.ts @@ -0,0 +1,64 @@ +import styled from 'styled-components'; +import starknetSrc from 'assets/images/starknet-logo.svg'; +import { Button } from 'components/ui/atom/Button'; + +export const Wrapper = styled.div` + display: flex; + flex-direction: column; + background-color: ${(props) => props.theme.palette.grey.white}; + width: ${(props) => props.theme.modal.base}; + padding: ${(props) => props.theme.spacing.base}; + padding-top: 40px; + border-radius: 8px; + align-items: center; +`; + +export const StarknetLogo = styled.img.attrs(() => ({ + src: starknetSrc, +}))` + width: 158px; + height: 32px; + margin-bottom: 32px; +`; + +export const Title = styled.div` + text-align: center; + font-weight: ${(props) => props.theme.typography.h3.fontWeight}; + font-size: ${(props) => props.theme.typography.h3.fontSize}; + font-family: ${(props) => props.theme.typography.h3.fontFamily}; + line-height: ${(props) => props.theme.typography.h3.lineHeight}; + margin-bottom: 8px; +`; + +export const Description = styled.div` + font-size: ${(props) => props.theme.typography.p2.fontSize}; + color: ${(props) => props.theme.palette.grey.grey1}; +`; + +export const DescriptionCentered = styled(Description)` + text-align: center; + width: 264px; +`; + +export const Txnlink = styled.div` + margin-top: 12px; + font-size: ${(props) => props.theme.typography.p2.fontSize}; + color: ${(props) => props.theme.palette.primary.main}; + font-weight: ${(props) => props.theme.typography.bold.fontWeight}; + font-family: ${(props) => props.theme.typography.bold.fontFamily}; + text-decoration: underline; + cursor: pointer; +`; + +export const DeployButton = styled(Button).attrs((props) => ({ + textStyle: { + fontSize: props.theme.typography.p1.fontSize, + fontWeight: 900, + }, + upperCaseOnly: false, + backgroundTransparent: true, +}))` + box-shadow: 0px 14px 24px -6px rgba(106, 115, 125, 0.2); + padding-top: 16px; + padding-bottom: 16px; +`; diff --git a/packages/wallet-ui/src/components/ui/organism/DeployModal/DeployModal.view.tsx b/packages/wallet-ui/src/components/ui/organism/DeployModal/DeployModal.view.tsx new file mode 100644 index 00000000..19f3dbd0 --- /dev/null +++ b/packages/wallet-ui/src/components/ui/organism/DeployModal/DeployModal.view.tsx @@ -0,0 +1,125 @@ +import { useEffect, useState } from 'react'; +import { useStarkNetSnap } from 'services'; +import { useAppSelector, useAppDispatch } from 'hooks/redux'; +import Toastr from 'toastr2'; + +import { setDeployModalVisible } from 'slices/modalSlice'; +import { openExplorerTab, shortenAddress } from '../../../../utils/utils'; +import { DeployButton, StarknetLogo, Title, Wrapper, DescriptionCentered, Txnlink } from './DeployModal.style'; +import { AccountAddressView } from 'components/ui/molecule/AccountAddress/AccountAddress.view'; + +interface Props { + address: string; +} + +enum Stage { + INIT = 0, + WAITING_FOR_TXN = 1, + SUCCESS = 2, + FAIL = 3, +} + +export const DeployModalView = ({ address }: Props) => { + const dispatch = useAppDispatch(); + const { deployAccount, waitForAccountCreation } = useStarkNetSnap(); + const [txnHash, setTxnHash] = useState(''); + const [stage, setStage] = useState(Stage.INIT); + const networks = useAppSelector((state) => state.networks); + const chainId = networks?.items[networks.activeNetwork]?.chainId; + const toastr = new Toastr(); + + const onDeploy = async () => { + try { + const resp = await deployAccount(address, '0', chainId); + + if (resp === false) { + return; + } + + if (resp.transaction_hash) { + setTxnHash(resp.transaction_hash); + } else { + throw new Error('no transaction hash'); + } + } catch (err) { + //eslint-disable-next-line no-console + console.error(err); + toastr.error(`Deploy account failed`); + } + }; + + useEffect(() => { + if (txnHash) { + setStage(Stage.WAITING_FOR_TXN); + waitForAccountCreation(txnHash, address, chainId) + .then((resp) => { + setStage(resp === true ? Stage.SUCCESS : Stage.FAIL); + }) + .catch(() => { + setStage(Stage.FAIL); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [txnHash, address, chainId]); + + useEffect(() => { + if (stage === Stage.SUCCESS) { + toastr.success(`Account deployed successfully`); + dispatch(setDeployModalVisible(false)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [stage, dispatch]); + + const renderComponent = () => { + switch (stage) { + case Stage.INIT: + return ( + <> + + You have a non-zero balance on an Cairo 0 non-deployed address +
+
+
+ +
+
+ A deployment of your address is necessary to proceed with the Snap. +
+
+ Click on the "Deploy" button to proceed. +
+ Thank you! +
+ Deploy + + ); + case Stage.WAITING_FOR_TXN: + return Waiting for transaction to be complete.; + case Stage.SUCCESS: + return Account deployd successfully.; + default: + return ( + + Transaction Hash:
{' '} + openExplorerTab(txnHash, 'tx', chainId)}>{shortenAddress(txnHash)} +
+ Unfortunately, you reached the maximum number of deploy tentatives allowed. +
+
+ Please try again in a couple of hours. +
+
+ Thank you for your comprehension. +
+ ); + } + }; + + return ( + + + Deploy Account + {renderComponent()} + + ); +}; diff --git a/packages/wallet-ui/src/components/ui/organism/DeployModal/index.ts b/packages/wallet-ui/src/components/ui/organism/DeployModal/index.ts new file mode 100644 index 00000000..e0c02921 --- /dev/null +++ b/packages/wallet-ui/src/components/ui/organism/DeployModal/index.ts @@ -0,0 +1 @@ +export { DeployModalView as DeployModal } from './DeployModal.view'; diff --git a/packages/wallet-ui/src/hooks/useHasMetamask.ts b/packages/wallet-ui/src/hooks/useHasMetamask.ts index 6e42962b..091e2679 100644 --- a/packages/wallet-ui/src/hooks/useHasMetamask.ts +++ b/packages/wallet-ui/src/hooks/useHasMetamask.ts @@ -1,22 +1,94 @@ -import detectEthereumProvider from '@metamask/detect-provider'; import { useEffect, useState } from 'react'; import { useAppDispatch } from 'hooks/redux'; import { setProvider } from 'slices/walletSlice'; import { enableLoadingWithMessage, disableLoading } from 'slices/UISlice'; +interface MetaMaskProvider { + isMetaMask: boolean; + request(options: { method: string }): Promise; +} + +declare global { + interface Window { + ethereum?: MetaMaskProvider; + } +} + +function isMetaMaskProvider(obj: unknown): obj is MetaMaskProvider { + return obj !== null && typeof obj === 'object' && obj.hasOwnProperty('isMetaMask') && obj.hasOwnProperty('request'); +} + +function detectMetaMaskProvider( + windowObject: Window & typeof globalThis, + { timeout = 3000 } = {}, +): Promise { + let handled = false; + return new Promise((resolve) => { + const handleEIP6963Provider = (event: CustomEvent) => { + const { info, provider } = event.detail; + if (['io.metamask', 'io.metamask.flask'].includes(info.rdns) && isMetaMaskProvider(provider)) { + resolve(provider); + handled = true; + } + }; + + if (typeof windowObject.addEventListener === 'function') { + windowObject.addEventListener('eip6963:announceProvider', (event: Event) => { + handleEIP6963Provider(event as CustomEvent); + }); + } + + setTimeout(() => { + if (!handled) { + resolve(null); + } + }, timeout); + + // Notify event listeners and other parts of the dapp that a provider is requested. + if (typeof windowObject.dispatchEvent === 'function') { + windowObject.dispatchEvent(new Event('eip6963:requestProvider')); + } + }); +} + +async function waitForMetaMaskProvider( + windowObject: Window & typeof globalThis, + { timeout = 1000, retries = 0 } = {}, +): Promise { + return detectMetaMaskProvider(windowObject, { timeout }) + .catch(function () { + return null; + }) + .then(function (provider) { + if (provider || retries === 0) { + return provider; + } + return waitForMetaMaskProvider(windowObject, { + timeout, + retries: retries - 1, + }); + }); +} + +async function detectMetamaskSupport(windowObject: Window & typeof globalThis) { + const provider = await waitForMetaMaskProvider(windowObject, { retries: 3 }); + return provider; +} + export const useHasMetamask = () => { const dispatch = useAppDispatch(); const [hasMetamask, setHasMetamask] = useState(null); + useEffect(() => { const init = async () => { try { dispatch(enableLoadingWithMessage('Detecting Metamask...')); - //make sure mm has installed - if (await detectMetamask()) { - //metamask SDK is not support when multiple wallet installed, and each wallet may injected window.ethereum, some may override isMetamask - const _provider = await getProvider(); - dispatch(setProvider(_provider)); - setHasMetamask(_provider != null); + const provider = await detectMetamaskSupport(window); + // Use the new detection method + + if (provider && (await isSupportSnap(provider))) { + dispatch(setProvider(provider)); + setHasMetamask(provider != null); } else { dispatch(setProvider(null)); setHasMetamask(false); @@ -36,40 +108,6 @@ export const useHasMetamask = () => { }; }; -export const detectMetamask = async () => { - try { - const hasMetamask = await detectEthereumProvider({ mustBeMetaMask: true }); - if (hasMetamask) { - return true; - } - return false; - } catch (e) { - console.log('Error', e); - return false; - } -}; - -export const getProvider = async () => { - let { ethereum } = window as any; - let providers = [ethereum]; - - //ethereum.detected or ethereum.providers may exist when more than 1 wallet installed - if ('detected' in ethereum) { - providers = ethereum['detected']; - } else if ('providers' in ethereum) { - providers = ethereum['providers']; - } - - //delect provider by sending request - for (const provider of providers) { - if (provider && (await isSupportSnap(provider))) { - window.ethereum = provider; - return window.ethereum; - } - } - return null; -}; - const isSupportSnap = async (provider: any) => { try { await provider.request({ diff --git a/packages/wallet-ui/src/hooks/useHasMetamaskFlask.ts b/packages/wallet-ui/src/hooks/useHasMetamaskFlask.ts deleted file mode 100644 index 919bfdc5..00000000 --- a/packages/wallet-ui/src/hooks/useHasMetamaskFlask.ts +++ /dev/null @@ -1,39 +0,0 @@ -//TODO: remove when metamask is released with snap support -import detectEthereumProvider from '@metamask/detect-provider'; -import { useEffect, useState } from 'react'; - -export const useHasMetamaskFlask = () => { - const [hasMetamaskFlask, setHasMetamaskFlask] = useState(null); - - const detectMetamaskFlask = async () => { - try { - const provider = (await detectEthereumProvider({ - mustBeMetaMask: false, - silent: true, - })) as any | undefined; - const isFlask = (await provider?.request({ method: 'web3_clientVersion' }))?.includes('flask'); - if (provider && isFlask) { - return true; - } - return false; - } catch (e) { - console.log('Error', e); - return false; - } - }; - - useEffect(() => { - detectMetamaskFlask() - .then((result) => { - setHasMetamaskFlask(result); - }) - .catch((err) => { - console.error(err); - setHasMetamaskFlask(false); - }); - }, []); - - return { - hasMetamaskFlask, - }; -}; diff --git a/packages/wallet-ui/src/services/useStarkNetSnap.ts b/packages/wallet-ui/src/services/useStarkNetSnap.ts index 3e59f1c7..edeba2e2 100644 --- a/packages/wallet-ui/src/services/useStarkNetSnap.ts +++ b/packages/wallet-ui/src/services/useStarkNetSnap.ts @@ -1,4 +1,9 @@ -import { setInfoModalVisible, setMinVersionModalVisible, setUpgradeModalVisible } from 'slices/modalSlice'; +import { + setInfoModalVisible, + setMinVersionModalVisible, + setUpgradeModalVisible, + setDeployModalVisible, +} from 'slices/modalSlice'; import { setNetworks } from 'slices/networkSlice'; import { useAppDispatch, useAppSelector } from 'hooks/redux'; import { @@ -235,8 +240,9 @@ export const useStarkNetSnap = () => { const tokens = await getTokens(chainId); let acc: Account[] | Account = await recoverAccounts(chainId); let upgradeRequired = false; - - if (!acc || acc.length === 0 || !acc[0].publicKey) { + let deployRequired = false; + deployRequired = (Array.isArray(acc) ? acc[0].deployRequired : (acc as Account).deployRequired) ?? false; + if (!acc || acc.length === 0 || (!acc[0].publicKey && !deployRequired)) { acc = await addAccount(chainId); } else { upgradeRequired = (Array.isArray(acc) ? acc[0].upgradeRequired : (acc as Account).upgradeRequired) ?? false; @@ -269,7 +275,8 @@ export const useStarkNetSnap = () => { if (!Array.isArray(acc)) { dispatch(setInfoModalVisible(true)); } - dispatch(setUpgradeModalVisible(upgradeRequired)); + dispatch(setUpgradeModalVisible(upgradeRequired && !deployRequired)); + dispatch(setDeployModalVisible(deployRequired)); dispatch(disableLoading()); }; @@ -415,6 +422,35 @@ export const useStarkNetSnap = () => { } }; + const deployAccount = async (contractAddress: string, maxFee: string, chainId: string) => { + dispatch(enableLoadingWithMessage('Deploying account...')); + try { + const response = await provider.request({ + method: 'wallet_invokeSnap', + params: { + snapId, + request: { + method: 'starkNet_createAccountLegacy', + params: { + ...defaultParam, + contractAddress, + maxFee, + chainId, + deploy: true, + }, + }, + }, + }); + dispatch(disableLoading()); + return response; + } catch (err) { + dispatch(disableLoading()); + //eslint-disable-next-line no-console + console.error(err); + throw err; + } + }; + const upgradeAccount = async (contractAddress: string, maxFee: string, chainId: string) => { dispatch(enableLoadingWithMessage('Upgrading account...')); try { @@ -644,6 +680,46 @@ export const useStarkNetSnap = () => { return txStatus; }; + const waitForAccountCreation = async (transactionHash: string, accountAddress: string, chainId: string) => { + dispatch(enableLoadingWithMessage('Waiting for transaction to be finalised.')); + const toastr = new Toastr(); + let result = false; + + try { + // read transaction to check if the txn is ready + await waitForTransaction(transactionHash, chainId); + } catch (e) { + //eslint-disable-next-line no-console + console.log(`error while wait for transaction: ${e}`); + } + + try { + const executeFn = async (): Promise => { + // read contract to check if upgrade is required + const resp = await readContract(accountAddress, 'getVersion'); + if (!resp || !resp[0]) { + return false; + } + + // recover accounts to update snap state + await recoverAccounts(chainId); + return true; + }; + + result = await retry(executeFn, { + maxAttempts: 20, + }); + } catch (e: any) { + //eslint-disable-next-line no-console + console.log(`error while processing waitForAccountDeploy: ${e}`); + toastr.error('Snap is unable to verify the contract deploy process'); + } + + dispatch(disableLoading()); + + return result; + }; + const waitForAccountUpdate = async (transactionHash: string, accountAddress: string, chainId: string) => { dispatch(enableLoadingWithMessage('Waiting for transaction to be finalised.')); const toastr = new Toastr(); @@ -762,11 +838,13 @@ export const useStarkNetSnap = () => { estimateFees, sendTransaction, upgradeAccount, + deployAccount, getTransactions, getTransactionStatus, recoverAccounts, waitForTransaction, waitForAccountUpdate, + waitForAccountCreation, updateTokenBalance, getTokenBalance, addErc20Token, diff --git a/packages/wallet-ui/src/slices/modalSlice.ts b/packages/wallet-ui/src/slices/modalSlice.ts index 329e8314..e6fa61e6 100644 --- a/packages/wallet-ui/src/slices/modalSlice.ts +++ b/packages/wallet-ui/src/slices/modalSlice.ts @@ -4,12 +4,14 @@ export interface modalState { infoModalVisible: boolean; minVersionModalVisible: boolean; upgradeModalVisible: boolean; + deployModalVisible: boolean; } const initialState: modalState = { infoModalVisible: false, minVersionModalVisible: false, upgradeModalVisible: false, + deployModalVisible: false, }; export const modalSlice = createSlice({ @@ -23,12 +25,16 @@ export const modalSlice = createSlice({ setUpgradeModalVisible: (state, { payload }) => { state.upgradeModalVisible = payload; }, + setDeployModalVisible: (state, { payload }) => { + state.deployModalVisible = payload; + }, setMinVersionModalVisible: (state, { payload }) => { state.minVersionModalVisible = payload; }, }, }); -export const { setInfoModalVisible, setMinVersionModalVisible, setUpgradeModalVisible } = modalSlice.actions; +export const { setInfoModalVisible, setMinVersionModalVisible, setUpgradeModalVisible, setDeployModalVisible } = + modalSlice.actions; export default modalSlice.reducer; diff --git a/packages/wallet-ui/src/types/index.ts b/packages/wallet-ui/src/types/index.ts index 5a5855e3..cb22cbc8 100644 --- a/packages/wallet-ui/src/types/index.ts +++ b/packages/wallet-ui/src/types/index.ts @@ -1,7 +1,7 @@ import * as Types from '@consensys/starknet-snap/src/types/snapState'; import { BigNumber } from 'ethers'; -export type Account = Pick; +export type Account = Pick; export type Network = Pick; export interface Erc20TokenBalance extends Types.Erc20Token { diff --git a/yarn.lock b/yarn.lock index 0e60138b..9662ed8b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3176,7 +3176,7 @@ __metadata: "@consensys/starknet-snap@file:../starknet-snap::locator=wallet-ui%40workspace%3Apackages%2Fwallet-ui": version: 2.8.0 - resolution: "@consensys/starknet-snap@file:../starknet-snap#../starknet-snap::hash=511eed&locator=wallet-ui%40workspace%3Apackages%2Fwallet-ui" + resolution: "@consensys/starknet-snap@file:../starknet-snap#../starknet-snap::hash=7bbefb&locator=wallet-ui%40workspace%3Apackages%2Fwallet-ui" dependencies: "@metamask/key-tree": 9.0.0 "@metamask/snaps-sdk": ^4.0.0 @@ -3185,7 +3185,7 @@ __metadata: ethers: ^5.5.1 starknet: 6.7.0 starknet_v4.22.0: "npm:starknet@4.22.0" - checksum: 12c71045fd6bd5d137d51a9fb9a92b3aae206b7b1b1a036bd8fe4ddbf953d94967be56ecb170c4e871a30fa065ba64571dca714180522b89b5a9bf4cc6476b41 + checksum: 1b61bb3753a444b81cd7a45acdc1e016c8e44e5a8b5528ce0d6832ec93e1b6a371985696a7f2a27f95df27f8b5b4afb4a74f4ab2eee84fb377e77da7b99e4694 languageName: node linkType: hard @@ -4831,13 +4831,6 @@ __metadata: languageName: node linkType: hard -"@metamask/detect-provider@npm:^1.2.0": - version: 1.2.0 - resolution: "@metamask/detect-provider@npm:1.2.0" - checksum: 2c152534a8dd15bc1430bb5159cdf58993549a644cff344a1ff43f4ede8f041aad72b909e822747f6545de3ed293a740ecffc86a859daf7a925c4096efd61eb3 - languageName: node - linkType: hard - "@metamask/eth-query@npm:^4.0.0": version: 4.0.0 resolution: "@metamask/eth-query@npm:4.0.0" @@ -27244,7 +27237,6 @@ __metadata: "@fortawesome/free-solid-svg-icons": ^6.1.1 "@fortawesome/react-fontawesome": ^0.1.18 "@headlessui/react": ^1.6.4 - "@metamask/detect-provider": ^1.2.0 "@metamask/jazzicon": "github:metamask/jazzicon#d923914fda6a8795f74c2e66134f73cd72070667" "@mui/icons-material": ^5.6.2 "@mui/material": ^5.6.2