From fdbeeb2c307678cfa71409e66f425ba4a0cf476d Mon Sep 17 00:00:00 2001 From: khanti42 Date: Wed, 11 Sep 2024 13:26:13 +0200 Subject: [PATCH 01/50] fix: qa review request, show only one balance (#352) --- .../AmountInput/AmountInput.stories.tsx | 1 - .../molecule/AmountInput/AmountInput.view.tsx | 2 +- .../AssetListItem/AssetListItem.stories.tsx | 1 - .../ui/organism/Header/Header.view.tsx | 2 +- .../Header/SendModal/SendModal.view.tsx | 2 +- .../wallet-ui/src/services/useStarkNetSnap.ts | 6 ++-- packages/wallet-ui/src/types/index.ts | 4 +-- packages/wallet-ui/src/utils/utils.ts | 31 +++++-------------- 8 files changed, 13 insertions(+), 36 deletions(-) diff --git a/packages/wallet-ui/src/components/ui/molecule/AmountInput/AmountInput.stories.tsx b/packages/wallet-ui/src/components/ui/molecule/AmountInput/AmountInput.stories.tsx index c286b655..8ae157ad 100644 --- a/packages/wallet-ui/src/components/ui/molecule/AmountInput/AmountInput.stories.tsx +++ b/packages/wallet-ui/src/components/ui/molecule/AmountInput/AmountInput.stories.tsx @@ -11,7 +11,6 @@ export default { const asset = { address: '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7', amount: BigNumber.from('1000000000000000000'), - spendableAmount: BigNumber.from('1000000000000000000'), chainId: constants.StarknetChainId.SN_SEPOLIA, decimals: 18, name: 'Ether', diff --git a/packages/wallet-ui/src/components/ui/molecule/AmountInput/AmountInput.view.tsx b/packages/wallet-ui/src/components/ui/molecule/AmountInput/AmountInput.view.tsx index 7e38d1a6..52409d84 100644 --- a/packages/wallet-ui/src/components/ui/molecule/AmountInput/AmountInput.view.tsx +++ b/packages/wallet-ui/src/components/ui/molecule/AmountInput/AmountInput.view.tsx @@ -112,7 +112,7 @@ export const AmountInputView = ({ const handleMaxClick = () => { if (inputRef.current && asset.usdPrice) { const amountStr = ethers.utils - .formatUnits(asset.spendableAmount, asset.decimals) + .formatUnits(asset.amount, asset.decimals) .toString(); const amountFloat = parseFloat(amountStr); inputRef.current.value = usdMode diff --git a/packages/wallet-ui/src/components/ui/molecule/AssetsList/AssetListItem/AssetListItem.stories.tsx b/packages/wallet-ui/src/components/ui/molecule/AssetsList/AssetListItem/AssetListItem.stories.tsx index ddbde96f..273451e8 100644 --- a/packages/wallet-ui/src/components/ui/molecule/AssetsList/AssetListItem/AssetListItem.stories.tsx +++ b/packages/wallet-ui/src/components/ui/molecule/AssetsList/AssetListItem/AssetListItem.stories.tsx @@ -12,7 +12,6 @@ export default { const asset: Erc20TokenBalance = { address: '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7', amount: BigNumber.from('1000000000000000000'), - spendableAmount: BigNumber.from('1000000000000000000'), chainId: constants.StarknetChainId.SN_SEPOLIA, decimals: 18, name: 'Ether', diff --git a/packages/wallet-ui/src/components/ui/organism/Header/Header.view.tsx b/packages/wallet-ui/src/components/ui/organism/Header/Header.view.tsx index 9980874d..5acd07d3 100644 --- a/packages/wallet-ui/src/components/ui/organism/Header/Header.view.tsx +++ b/packages/wallet-ui/src/components/ui/organism/Header/Header.view.tsx @@ -27,7 +27,7 @@ export const HeaderView = ({ address }: Props) => { const getUSDValue = () => { const amountFloat = parseFloat( ethers.utils.formatUnits( - wallet.erc20TokenBalanceSelected.spendableAmount, + wallet.erc20TokenBalanceSelected.amount, wallet.erc20TokenBalanceSelected.decimals, ), ); diff --git a/packages/wallet-ui/src/components/ui/organism/Header/SendModal/SendModal.view.tsx b/packages/wallet-ui/src/components/ui/organism/Header/SendModal/SendModal.view.tsx index a206cd52..0acc05ca 100644 --- a/packages/wallet-ui/src/components/ui/organism/Header/SendModal/SendModal.view.tsx +++ b/packages/wallet-ui/src/components/ui/organism/Header/SendModal/SendModal.view.tsx @@ -53,7 +53,7 @@ export const SendModalView = ({ closeModal }: Props) => { fieldValue, wallet.erc20TokenBalanceSelected.decimals, ); - const userBalance = wallet.erc20TokenBalanceSelected.spendableAmount; + const userBalance = wallet.erc20TokenBalanceSelected.amount; if (inputAmount.gt(userBalance)) { setErrors((prevErrors) => ({ ...prevErrors, diff --git a/packages/wallet-ui/src/services/useStarkNetSnap.ts b/packages/wallet-ui/src/services/useStarkNetSnap.ts index 72c9f664..911f350b 100644 --- a/packages/wallet-ui/src/services/useStarkNetSnap.ts +++ b/packages/wallet-ui/src/services/useStarkNetSnap.ts @@ -712,15 +712,13 @@ export const useStarkNetSnap = () => { }, }); return { - balanceLatest: BigNumber.from(response.balanceLatest), - balancePending: BigNumber.from(response.balancePending), + balance: BigNumber.from(response.balancePending), }; } catch (err) { //eslint-disable-next-line no-console console.error(err); return { - balanceLatest: BigNumber.from('0x0'), - balancePending: BigNumber.from('0x0'), + balance: BigNumber.from('0x0'), }; } }; diff --git a/packages/wallet-ui/src/types/index.ts b/packages/wallet-ui/src/types/index.ts index 6ebee8be..783306a1 100644 --- a/packages/wallet-ui/src/types/index.ts +++ b/packages/wallet-ui/src/types/index.ts @@ -12,7 +12,6 @@ export type Network = Pick< export interface Erc20TokenBalance extends Types.Erc20Token { amount: BigNumber; - spendableAmount: BigNumber; usdPrice?: number; } export type TransactionStatusOptions = @@ -50,8 +49,7 @@ export type { // Define the type for your token balances export interface TokenBalance { - balancePending: BigNumber; - balanceLatest: BigNumber; + balance: BigNumber; } export enum FeeToken { diff --git a/packages/wallet-ui/src/utils/utils.ts b/packages/wallet-ui/src/utils/utils.ts index 6ac6e0b1..b4767e43 100644 --- a/packages/wallet-ui/src/utils/utils.ts +++ b/packages/wallet-ui/src/utils/utils.ts @@ -45,18 +45,14 @@ export const isValidAddress = (toCheck: string) => { export const addMissingPropertiesToToken = ( token: Erc20Token, balance?: string, - balanceSpendable?: string, usdPrice?: number, ): Erc20TokenBalance => { // when balance is undefined, use 0 const hexBalance = balance ?? '0x0'; - // when balanceSpendable is undefined, we use hexBalance - const hexSpendableBalance = balanceSpendable ?? hexBalance; return { ...token, amount: ethers.BigNumber.from(hexBalance), - spendableAmount: ethers.BigNumber.from(hexSpendableBalance), usdPrice: usdPrice, }; }; @@ -87,20 +83,15 @@ export const getHumanReadableAmount = ( }; export const getSpendableTotalBalance = (asset: Erc20TokenBalance): string => { - if (asset.spendableAmount === undefined) { - throw new Error('Spendable amount can not be undefined'); + if (asset.amount === undefined) { + throw new Error('Amount can not be undefined'); } - const spendableAmount = getHumanReadableAmount( + const amount = getHumanReadableAmount( asset, - ethers.utils.formatUnits(asset.spendableAmount, asset.decimals), + ethers.utils.formatUnits(asset.amount, asset.decimals), ); - if (asset.spendableAmount.eq(asset.amount)) { - return `${spendableAmount}`; - } - - const totalAmount = getHumanReadableAmount(asset); - return `${spendableAmount} (${totalAmount})`; + return amount; }; export const getMaxDecimalsReadable = ( @@ -248,14 +239,6 @@ export function getTokenBalanceWithDetails( token: Erc20Token, tokenUSDPrice?: number, ): Erc20TokenBalance { - const { balancePending, balanceLatest } = tokenBalance; - const spendableBalance = balancePending.lt(balanceLatest) - ? balancePending - : balanceLatest; - return addMissingPropertiesToToken( - token, - balanceLatest.toString(), - spendableBalance.toString(), - tokenUSDPrice, - ); + const { balance } = tokenBalance; + return addMissingPropertiesToToken(token, balance.toString(), tokenUSDPrice); } From cdd17c68bc6ad86d651d0b463d7cb55fb7c4e60c Mon Sep 17 00:00:00 2001 From: khanti42 Date: Thu, 19 Sep 2024 14:38:40 +0200 Subject: [PATCH 02/50] chore: ensure backward compatibility with remoteEntry.js of snap v2.9.0 (#355) * feat: ensure backward compatibility with remoteEntry.js of snap v2.9.0 * chore: address review comment * feat: add unit test for mapDeprecatedParams * Update packages/starknet-snap/src/utils/formatterUtils.test.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/utils/formatterUtils.test.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/utils/formatterUtils.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/utils/formatterUtils.test.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/utils/formatterUtils.test.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/utils/formatterUtils.test.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * chore: address comment --------- Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> --- packages/starknet-snap/src/rpcs/executeTxn.ts | 16 +++++ .../src/rpcs/sign-declare-transaction.ts | 17 +++++ .../starknet-snap/src/rpcs/signMessage.ts | 14 ++++ .../starknet-snap/src/rpcs/signTransaction.ts | 14 ++++ .../src/utils/formatterUtils.test.ts | 67 +++++++++++++++++++ .../starknet-snap/src/utils/formatterUtils.ts | 30 +++++++++ 6 files changed, 158 insertions(+) create mode 100644 packages/starknet-snap/src/utils/formatterUtils.test.ts diff --git a/packages/starknet-snap/src/rpcs/executeTxn.ts b/packages/starknet-snap/src/rpcs/executeTxn.ts index dcb55a00..3b89ff93 100644 --- a/packages/starknet-snap/src/rpcs/executeTxn.ts +++ b/packages/starknet-snap/src/rpcs/executeTxn.ts @@ -25,6 +25,7 @@ import { confirmDialog, UniversalDetailsStruct, CallsStruct, + mapDeprecatedParams, } from '../utils'; import { logger } from '../utils/logger'; import { @@ -76,6 +77,21 @@ export class ExecuteTxnRpc extends AccountRpcController< this.tokenStateManager = new TokenStateManager(); } + protected async preExecute(params: ExecuteTxnParams): Promise { + // Define mappings to ensure backward compatibility with previous versions of the API. + // These mappings replace deprecated parameter names with the updated equivalents, + // allowing older integrations to function without changes + const paramMappings: Record = { + senderAddress: 'address', + txnInvocation: 'calls', + invocationsDetails: 'details', + }; + + // Apply the mappings to params + mapDeprecatedParams(params, paramMappings); + await super.preExecute(params); + } + /** * Execute the transaction request handler. * diff --git a/packages/starknet-snap/src/rpcs/sign-declare-transaction.ts b/packages/starknet-snap/src/rpcs/sign-declare-transaction.ts index b6496553..f9f784a4 100644 --- a/packages/starknet-snap/src/rpcs/sign-declare-transaction.ts +++ b/packages/starknet-snap/src/rpcs/sign-declare-transaction.ts @@ -16,6 +16,7 @@ import { BaseRequestStruct, AccountRpcController, DeclareSignDetailsStruct, + mapDeprecatedParams, } from '../utils'; import { signDeclareTransaction as signDeclareTransactionUtil } from '../utils/starknetUtils'; @@ -48,6 +49,22 @@ export class SignDeclareTransactionRpc extends AccountRpcController< protected responseStruct = SignDeclareTransactionResponseStruct; + protected async preExecute( + params: SignDeclareTransactionParams, + ): Promise { + // Define mappings to ensure backward compatibility with previous versions of the API. + // These mappings replace deprecated parameter names with the updated equivalents, + // allowing older integrations to function without changes + const paramMappings: Record = { + signerAddress: 'address', + transaction: 'details', + }; + + // Apply the mappings to params + mapDeprecatedParams(params, paramMappings); + await super.preExecute(params); + } + /** * Execute the sign declare transaction request handler. * It will show a confirmation dialog to the user before signing the declare transaction. diff --git a/packages/starknet-snap/src/rpcs/signMessage.ts b/packages/starknet-snap/src/rpcs/signMessage.ts index a04b9adc..896bcfed 100644 --- a/packages/starknet-snap/src/rpcs/signMessage.ts +++ b/packages/starknet-snap/src/rpcs/signMessage.ts @@ -16,6 +16,7 @@ import { AuthorizableStruct, BaseRequestStruct, AccountRpcController, + mapDeprecatedParams, } from '../utils'; import { signMessage as signMessageUtil } from '../utils/starknetUtils'; @@ -45,6 +46,19 @@ export class SignMessageRpc extends AccountRpcController< protected responseStruct = SignMessageResponseStruct; + protected async preExecute(params: SignMessageParams): Promise { + // Define mappings to ensure backward compatibility with previous versions of the API. + // These mappings replace deprecated parameter names with the updated equivalents, + // allowing older integrations to function without changes + const paramMappings: Record = { + signerAddress: 'address', + }; + + // Apply the mappings to params + mapDeprecatedParams(params, paramMappings); + await super.preExecute(params); + } + /** * Execute the sign message request handler. * It will show a confirmation dialog to the user before signing the message. diff --git a/packages/starknet-snap/src/rpcs/signTransaction.ts b/packages/starknet-snap/src/rpcs/signTransaction.ts index c7f79b57..6f66350e 100644 --- a/packages/starknet-snap/src/rpcs/signTransaction.ts +++ b/packages/starknet-snap/src/rpcs/signTransaction.ts @@ -17,6 +17,7 @@ import { AccountRpcController, CallDataStruct, toJson, + mapDeprecatedParams, } from '../utils'; import { signTransactions } from '../utils/starknetUtils'; @@ -49,6 +50,19 @@ export class SignTransactionRpc extends AccountRpcController< protected responseStruct = SignTransactionResponseStruct; + protected async preExecute(params: SignTransactionParams): Promise { + // Define mappings to ensure backward compatibility with previous versions of the API. + // These mappings replace deprecated parameter names with the updated equivalents, + // allowing older integrations to function without changes + const paramMappings: Record = { + signerAddress: 'address', + }; + + // Apply the mappings to params + mapDeprecatedParams(params, paramMappings); + await super.preExecute(params); + } + /** * Execute the sign transaction request handler. * It will show a confirmation dialog to the user before signing the transaction. diff --git a/packages/starknet-snap/src/utils/formatterUtils.test.ts b/packages/starknet-snap/src/utils/formatterUtils.test.ts new file mode 100644 index 00000000..0ea9b941 --- /dev/null +++ b/packages/starknet-snap/src/utils/formatterUtils.test.ts @@ -0,0 +1,67 @@ +import { mapDeprecatedParams } from './formatterUtils'; + +describe('mapDeprecatedParams', () => { + it('maps deprecated parameters to their new equivalents', () => { + const requestParams = { + signerAddress: '0x123', + txnInvocation: 'invoke', + }; + const mappings = { + signerAddress: 'address', + txnInvocation: 'calls', + }; + + const expected = { + address: '0x123', + calls: 'invoke', + }; + + mapDeprecatedParams(requestParams, mappings); + + expect(requestParams).toStrictEqual(expected); + }); + + it('removes the deprecated parameter after mapping', () => { + const requestParams = { + signerAddress: '0x123', + txnInvocation: 'invoke', + }; + const mappings = { + signerAddress: 'address', + txnInvocation: 'calls', + }; + + mapDeprecatedParams(requestParams, mappings); + + expect(requestParams).not.toHaveProperty('signerAddress'); + expect(requestParams).not.toHaveProperty('txnInvocation'); + }); + + it('does nothing if the deprecated parameter does not exist', () => { + const requestParams = { + otherParam: 'value', + }; + const mappings = { + signerAddress: 'address', + }; + + const expected = { otherParam: 'value' }; + + mapDeprecatedParams(requestParams, mappings); + + expect(requestParams).toStrictEqual(expected); + }); + + it('does nothing if the mapping is empty', () => { + const requestParams = { + signerAddress: '0x123', + }; + const mappings = {}; + + const expected = { signerAddress: '0x123' }; + + mapDeprecatedParams(requestParams, mappings); + + expect(requestParams).toStrictEqual(expected); + }); +}); diff --git a/packages/starknet-snap/src/utils/formatterUtils.ts b/packages/starknet-snap/src/utils/formatterUtils.ts index ebfdf475..c91e1488 100644 --- a/packages/starknet-snap/src/utils/formatterUtils.ts +++ b/packages/starknet-snap/src/utils/formatterUtils.ts @@ -7,3 +7,33 @@ export const hexToString = (hexStr) => { } return str; }; + +/** + * Maps deprecated parameters to their new equivalents in the requestParams object + * and removes the deprecated parameters afterward. + * + * @param requestParams - The object containing the API request parameters. + * @param mappings - A record of key-value pairs where the key is the old parameter + * and the value is the new parameter. + * @example + * const paramMappings = { + * signerAddress: 'address', + * senderAddress: 'address', + * txnInvocation: 'calls', + * invocationsDetails: 'details', + * transaction: 'details' + * }; + * mapDeprecatedParams(apiParams.requestParams, paramMappings); + */ +export const mapDeprecatedParams = ( + requestParams: Params, + mappings: Record, +) => { + Object.keys(mappings).forEach((oldParam) => { + const newParam = mappings[oldParam] as unknown as keyof Params; + if (Object.prototype.hasOwnProperty.call(requestParams, oldParam)) { + requestParams[newParam] = requestParams[oldParam]; + delete requestParams[oldParam]; // Remove old param after mapping + } + }); +}; From b2eccb74e958d2087917484469cb2139e2f537b7 Mon Sep 17 00:00:00 2001 From: khanti42 Date: Fri, 20 Sep 2024 11:51:06 +0200 Subject: [PATCH 03/50] feat: change default network to mainnet (#357) * feat: default network should be main net * chore: rollback onInstall * chore: remove use of network state manager in onHomePage --- packages/starknet-snap/src/index.test.ts | 2 +- packages/starknet-snap/src/index.ts | 5 ++--- packages/starknet-snap/src/utils/snapUtils.ts | 5 +++-- .../starknet-snap/test/src/addErc20Token.test.ts | 4 ++-- .../test/src/estimateAccountDeployFee.test.ts | 7 +++++-- packages/starknet-snap/test/src/estimateFee.test.ts | 10 +++++----- .../starknet-snap/test/src/extractPublicKey.test.ts | 8 ++++---- .../starknet-snap/test/src/getCurrentNetwork.test.ts | 4 ++-- .../test/src/getErc20TokenBalance.test.ts | 4 ++-- packages/starknet-snap/test/src/getStarkName.test.ts | 4 ++-- .../test/src/getStoredErc20Tokens.test.ts | 8 ++++++-- .../test/src/getStoredTransactions.test.ts | 8 ++++---- .../test/src/getStoredUserAccounts.test.ts | 4 +++- .../test/src/getTransactionStatus.test.ts | 4 ++-- .../starknet-snap/test/src/getTransactions.test.ts | 5 +++++ packages/starknet-snap/test/src/getValue.test.ts | 4 ++-- .../starknet-snap/test/src/sendTransaction.test.ts | 12 +++++++++--- 17 files changed, 59 insertions(+), 39 deletions(-) diff --git a/packages/starknet-snap/src/index.test.ts b/packages/starknet-snap/src/index.test.ts index d0bbd14d..465a7332 100644 --- a/packages/starknet-snap/src/index.test.ts +++ b/packages/starknet-snap/src/index.test.ts @@ -153,7 +153,7 @@ describe('onHomePage', () => { label: 'Network', value: { type: 'text', - value: STARKNET_SEPOLIA_TESTNET_NETWORK.name, + value: STARKNET_MAINNET_NETWORK.name, }, }, { diff --git a/packages/starknet-snap/src/index.ts b/packages/starknet-snap/src/index.ts index c17db8ee..55f38277 100644 --- a/packages/starknet-snap/src/index.ts +++ b/packages/starknet-snap/src/index.ts @@ -353,9 +353,8 @@ export const onHomePage: OnHomePageHandler = async () => { throw new Error('State not found.'); } - // default network is testnet - let network = STARKNET_SEPOLIA_TESTNET_NETWORK; - + // default network is mainnet + let network = STARKNET_MAINNET_NETWORK; if ( state.currentNetwork && state.currentNetwork.chainId !== STARKNET_TESTNET_NETWORK.chainId diff --git a/packages/starknet-snap/src/utils/snapUtils.ts b/packages/starknet-snap/src/utils/snapUtils.ts index 9dedd0a8..ea6a78ab 100644 --- a/packages/starknet-snap/src/utils/snapUtils.ts +++ b/packages/starknet-snap/src/utils/snapUtils.ts @@ -34,6 +34,7 @@ import { MAXIMUM_TOKEN_SYMBOL_LENGTH, PRELOADED_NETWORKS, PRELOADED_TOKENS, + STARKNET_MAINNET_NETWORK, STARKNET_SEPOLIA_TESTNET_NETWORK, } from './constants'; import { DeployRequiredError, UpgradeRequiredError } from './exceptions'; @@ -854,7 +855,7 @@ export function getNetworkFromChainId( state: SnapState, targerChainId: string | undefined, ) { - const chainId = targerChainId ?? STARKNET_SEPOLIA_TESTNET_NETWORK.chainId; + const chainId = targerChainId ?? STARKNET_MAINNET_NETWORK.chainId; const network = getNetwork(state, chainId); if (network === undefined) { throw new Error( @@ -1116,7 +1117,7 @@ export async function removeAcceptedTransaction( * @param state */ export function getCurrentNetwork(state: SnapState) { - return state.currentNetwork ?? STARKNET_SEPOLIA_TESTNET_NETWORK; + return state.currentNetwork ?? STARKNET_MAINNET_NETWORK; } /** diff --git a/packages/starknet-snap/test/src/addErc20Token.test.ts b/packages/starknet-snap/test/src/addErc20Token.test.ts index 9a226354..60e1d93b 100644 --- a/packages/starknet-snap/test/src/addErc20Token.test.ts +++ b/packages/starknet-snap/test/src/addErc20Token.test.ts @@ -7,7 +7,7 @@ import { SnapState } from '../../src/types/snapState'; import * as snapUtils from '../../src/utils/snapUtils'; import { DEFAULT_DECIMAL_PLACES, - STARKNET_SEPOLIA_TESTNET_NETWORK, + STARKNET_MAINNET_NETWORK, } from '../../src/utils/constants'; import { Mutex } from 'async-mutex'; import { AddErc20TokenRequestParams, ApiParams } from '../../src/types/snapApi'; @@ -20,7 +20,7 @@ describe('Test function: addErc20Token', function () { const state: SnapState = { accContracts: [], erc20Tokens: [], - networks: [STARKNET_SEPOLIA_TESTNET_NETWORK], + networks: [STARKNET_MAINNET_NETWORK], transactions: [], }; const apiParams: ApiParams = { diff --git a/packages/starknet-snap/test/src/estimateAccountDeployFee.test.ts b/packages/starknet-snap/test/src/estimateAccountDeployFee.test.ts index 44907deb..b8276c8b 100644 --- a/packages/starknet-snap/test/src/estimateAccountDeployFee.test.ts +++ b/packages/starknet-snap/test/src/estimateAccountDeployFee.test.ts @@ -5,7 +5,10 @@ import { WalletMock } from '../wallet.mock.test'; import * as utils from '../../src/utils/starknetUtils'; import { estimateAccDeployFee } from '../../src/estimateAccountDeployFee'; import { SnapState } from '../../src/types/snapState'; -import { STARKNET_SEPOLIA_TESTNET_NETWORK } from '../../src/utils/constants'; +import { + STARKNET_MAINNET_NETWORK, + STARKNET_SEPOLIA_TESTNET_NETWORK, +} from '../../src/utils/constants'; import { getAddressKeyDeriver } from '../../src/utils/keyPair'; import { estimateDeployFeeResp3, @@ -27,7 +30,7 @@ describe('Test function: estimateAccountDeployFee', function () { const state: SnapState = { accContracts: [], erc20Tokens: [], - networks: [STARKNET_SEPOLIA_TESTNET_NETWORK], + networks: [STARKNET_MAINNET_NETWORK, STARKNET_SEPOLIA_TESTNET_NETWORK], transactions: [], }; const requestObject: EstimateAccountDeployFeeRequestParams = { diff --git a/packages/starknet-snap/test/src/estimateFee.test.ts b/packages/starknet-snap/test/src/estimateFee.test.ts index 084de926..05bb2289 100644 --- a/packages/starknet-snap/test/src/estimateFee.test.ts +++ b/packages/starknet-snap/test/src/estimateFee.test.ts @@ -7,7 +7,7 @@ import { estimateFee } from '../../src/estimateFee'; import { SnapState } from '../../src/types/snapState'; import { ACCOUNT_CLASS_HASH, - STARKNET_SEPOLIA_TESTNET_NETWORK, + STARKNET_MAINNET_NETWORK, } from '../../src/utils/constants'; import { getAddressKeyDeriver } from '../../src/utils/keyPair'; import { @@ -35,7 +35,7 @@ describe('Test function: estimateFee', function () { const state: SnapState = { accContracts: [], erc20Tokens: [], - networks: [STARKNET_SEPOLIA_TESTNET_NETWORK], + networks: [STARKNET_MAINNET_NETWORK], transactions: [], }; const requestObject: EstimateFeeRequestParams = { @@ -149,7 +149,7 @@ describe('Test function: estimateFee', function () { expect( validateAccountRequireUpgradeOrDeployStub, ).to.have.been.calledOnceWith( - STARKNET_SEPOLIA_TESTNET_NETWORK, + STARKNET_MAINNET_NETWORK, account2.address, account2.publicKey, ); @@ -208,7 +208,7 @@ describe('Test function: estimateFee', function () { const { privateKey, publicKey } = await utils.getKeysFromAddress( apiParams.keyDeriver, - STARKNET_SEPOLIA_TESTNET_NETWORK, + STARKNET_MAINNET_NETWORK, state, Cairo1Account1.address, ); @@ -244,7 +244,7 @@ describe('Test function: estimateFee', function () { ); expect(estimateFeeBulkStub).callCount(1); expect(estimateFeeBulkStub).to.be.calledWith( - STARKNET_SEPOLIA_TESTNET_NETWORK, + STARKNET_MAINNET_NETWORK, Cairo1Account1.address, privateKey, expectedBulkTransaction, diff --git a/packages/starknet-snap/test/src/extractPublicKey.test.ts b/packages/starknet-snap/test/src/extractPublicKey.test.ts index f87fb41c..547d5be8 100644 --- a/packages/starknet-snap/test/src/extractPublicKey.test.ts +++ b/packages/starknet-snap/test/src/extractPublicKey.test.ts @@ -4,7 +4,7 @@ import sinonChai from 'sinon-chai'; import { WalletMock } from '../wallet.mock.test'; import { SnapState } from '../../src/types/snapState'; import { extractPublicKey } from '../../src/extractPublicKey'; -import { STARKNET_SEPOLIA_TESTNET_NETWORK } from '../../src/utils/constants'; +import { STARKNET_MAINNET_NETWORK } from '../../src/utils/constants'; import { account1, getBip44EntropyStub, @@ -28,7 +28,7 @@ describe('Test function: extractPublicKey', function () { const state: SnapState = { accContracts: [account1], erc20Tokens: [], - networks: [STARKNET_SEPOLIA_TESTNET_NETWORK], + networks: [STARKNET_MAINNET_NETWORK], transactions: [], }; let apiParams: ApiParamsWithKeyDeriver; @@ -110,7 +110,7 @@ describe('Test function: extractPublicKey', function () { expect( validateAccountRequireUpgradeOrDeployStub, ).to.have.been.calledOnceWith( - STARKNET_SEPOLIA_TESTNET_NETWORK, + STARKNET_MAINNET_NETWORK, account1.address, account1.publicKey, ); @@ -137,7 +137,7 @@ describe('Test function: extractPublicKey', function () { expect( validateAccountRequireUpgradeOrDeployStub, ).to.have.been.calledOnceWith( - STARKNET_SEPOLIA_TESTNET_NETWORK, + STARKNET_MAINNET_NETWORK, account1.address, account1.publicKey, ); diff --git a/packages/starknet-snap/test/src/getCurrentNetwork.test.ts b/packages/starknet-snap/test/src/getCurrentNetwork.test.ts index 8a7af9a2..112e4e07 100644 --- a/packages/starknet-snap/test/src/getCurrentNetwork.test.ts +++ b/packages/starknet-snap/test/src/getCurrentNetwork.test.ts @@ -47,10 +47,10 @@ describe('Test function: getStoredNetworks', function () { expect(result).to.be.eql(STARKNET_MAINNET_NETWORK); }); - it('should get STARKNET_SEPOLIA_TESTNET_NETWORK if current network is undefined', async function () { + it('should get STARKNET_MAINNET_NETWORK if current network is undefined', async function () { state.currentNetwork = undefined; const result = await getCurrentNetwork(apiParams); expect(stateStub).not.to.have.been.called; - expect(result).to.be.eql(STARKNET_SEPOLIA_TESTNET_NETWORK); + expect(result).to.be.eql(STARKNET_MAINNET_NETWORK); }); }); diff --git a/packages/starknet-snap/test/src/getErc20TokenBalance.test.ts b/packages/starknet-snap/test/src/getErc20TokenBalance.test.ts index bb850f94..dc2e297d 100644 --- a/packages/starknet-snap/test/src/getErc20TokenBalance.test.ts +++ b/packages/starknet-snap/test/src/getErc20TokenBalance.test.ts @@ -7,7 +7,7 @@ import { getErc20TokenBalance } from '../../src/getErc20TokenBalance'; import { SnapState } from '../../src/types/snapState'; import { BlockIdentifierEnum, - STARKNET_SEPOLIA_TESTNET_NETWORK, + STARKNET_MAINNET_NETWORK, } from '../../src/utils/constants'; import { Mutex } from 'async-mutex'; import { @@ -23,7 +23,7 @@ describe('Test function: getErc20TokenBalance', function () { const state: SnapState = { accContracts: [], erc20Tokens: [], - networks: [STARKNET_SEPOLIA_TESTNET_NETWORK], + networks: [STARKNET_MAINNET_NETWORK], transactions: [], }; const apiParams: ApiParams = { diff --git a/packages/starknet-snap/test/src/getStarkName.test.ts b/packages/starknet-snap/test/src/getStarkName.test.ts index 7d4ae24b..bb197f44 100644 --- a/packages/starknet-snap/test/src/getStarkName.test.ts +++ b/packages/starknet-snap/test/src/getStarkName.test.ts @@ -5,7 +5,7 @@ import { WalletMock } from '../wallet.mock.test'; import { getStarkName } from '../../src/getStarkName'; import * as utils from '../../src/utils/starknetUtils'; import { SnapState } from '../../src/types/snapState'; -import { STARKNET_SEPOLIA_TESTNET_NETWORK } from '../../src/utils/constants'; +import { STARKNET_MAINNET_NETWORK } from '../../src/utils/constants'; import { Mutex } from 'async-mutex'; import { ApiParams, GetStarkNameRequestParam } from '../../src/types/snapApi'; @@ -17,7 +17,7 @@ describe('Test function: getStarkName', function () { const state: SnapState = { accContracts: [], erc20Tokens: [], - networks: [STARKNET_SEPOLIA_TESTNET_NETWORK], + networks: [STARKNET_MAINNET_NETWORK], transactions: [], }; const apiParams: ApiParams = { diff --git a/packages/starknet-snap/test/src/getStoredErc20Tokens.test.ts b/packages/starknet-snap/test/src/getStoredErc20Tokens.test.ts index c936d8f0..82c111f5 100644 --- a/packages/starknet-snap/test/src/getStoredErc20Tokens.test.ts +++ b/packages/starknet-snap/test/src/getStoredErc20Tokens.test.ts @@ -37,7 +37,9 @@ describe('Test function: getStoredErc20Tokens', function () { }); it('should get the stored ERC-20 tokens correctly', async function () { - const requestObject: GetStoredErc20TokensRequestParams = {}; + const requestObject: GetStoredErc20TokensRequestParams = { + chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, + }; apiParams.requestParams = requestObject; const result = await getStoredErc20Tokens(apiParams); expect(walletStub.rpcStubs.snap_manageState).not.to.have.been.called; @@ -47,7 +49,9 @@ describe('Test function: getStoredErc20Tokens', function () { it('should throw error if getErc20Tokens failed', async function () { sandbox.stub(snapUtils, 'getErc20Tokens').throws(new Error()); - const requestObject: GetStoredErc20TokensRequestParams = {}; + const requestObject: GetStoredErc20TokensRequestParams = { + chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, + }; apiParams.requestParams = requestObject; let result; diff --git a/packages/starknet-snap/test/src/getStoredTransactions.test.ts b/packages/starknet-snap/test/src/getStoredTransactions.test.ts index 842e2e8a..c070e953 100644 --- a/packages/starknet-snap/test/src/getStoredTransactions.test.ts +++ b/packages/starknet-snap/test/src/getStoredTransactions.test.ts @@ -55,7 +55,9 @@ describe('Test function: getStoredTransactions', function () { }); it('should get the stored transactions of SN_SEPOLIA correctly', async function () { - const requestObject: GetStoredTransactionsRequestParams = {}; + const requestObject: GetStoredTransactionsRequestParams = { + chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, + }; apiParams.requestParams = requestObject; const result = await getStoredTransactions(apiParams); @@ -65,9 +67,7 @@ describe('Test function: getStoredTransactions', function () { }); it('should get the stored transactions of mainnet correctly', async function () { - const requestObject: GetStoredTransactionsRequestParams = { - chainId: STARKNET_MAINNET_NETWORK.chainId, - }; + const requestObject: GetStoredTransactionsRequestParams = {}; apiParams.requestParams = requestObject; const result = await getStoredTransactions(apiParams); expect(walletStub.rpcStubs.snap_manageState).not.to.have.been.called; diff --git a/packages/starknet-snap/test/src/getStoredUserAccounts.test.ts b/packages/starknet-snap/test/src/getStoredUserAccounts.test.ts index f63337ba..a8191b1a 100644 --- a/packages/starknet-snap/test/src/getStoredUserAccounts.test.ts +++ b/packages/starknet-snap/test/src/getStoredUserAccounts.test.ts @@ -37,7 +37,9 @@ describe('Test function: getStoredUserAccounts', function () { }); it('should get the stored user accounts correctly', async function () { - const requestObject: GetStoredUserAccountsRequestParams = {}; + const requestObject: GetStoredUserAccountsRequestParams = { + chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, + }; apiParams.requestParams = requestObject; const result = await getStoredUserAccounts(apiParams); expect(walletStub.rpcStubs.snap_manageState).not.to.have.been.called; diff --git a/packages/starknet-snap/test/src/getTransactionStatus.test.ts b/packages/starknet-snap/test/src/getTransactionStatus.test.ts index 275a48f7..cc3b67fc 100644 --- a/packages/starknet-snap/test/src/getTransactionStatus.test.ts +++ b/packages/starknet-snap/test/src/getTransactionStatus.test.ts @@ -5,7 +5,7 @@ import { WalletMock } from '../wallet.mock.test'; import * as utils from '../../src/utils/starknetUtils'; import { getTransactionStatus } from '../../src/getTransactionStatus'; import { SnapState } from '../../src/types/snapState'; -import { STARKNET_SEPOLIA_TESTNET_NETWORK } from '../../src/utils/constants'; +import { STARKNET_MAINNET_NETWORK } from '../../src/utils/constants'; import { getTxnStatusResp } from '../constants.test'; import { Mutex } from 'async-mutex'; import { @@ -21,7 +21,7 @@ describe('Test function: getTransactionStatus', function () { const state: SnapState = { accContracts: [], erc20Tokens: [], - networks: [STARKNET_SEPOLIA_TESTNET_NETWORK], + networks: [STARKNET_MAINNET_NETWORK], transactions: [], }; const apiParams: ApiParams = { diff --git a/packages/starknet-snap/test/src/getTransactions.test.ts b/packages/starknet-snap/test/src/getTransactions.test.ts index 911733f9..5688c26c 100644 --- a/packages/starknet-snap/test/src/getTransactions.test.ts +++ b/packages/starknet-snap/test/src/getTransactions.test.ts @@ -104,6 +104,7 @@ describe('Test function: getTransactions', function () { const requestObject: GetTransactionsRequestParams = { senderAddress: txn4.senderAddress, pageSize: '10', + chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, }; apiParams.requestParams = requestObject; @@ -118,6 +119,7 @@ describe('Test function: getTransactions', function () { const requestObject: GetTransactionsRequestParams = { senderAddress: txn4.senderAddress, pageSize: '10', + chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, }; apiParams.requestParams = requestObject; @@ -146,6 +148,7 @@ describe('Test function: getTransactions', function () { senderAddress: txn4.senderAddress, pageSize: '10', onlyFromState: true, + chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, }; apiParams.requestParams = requestObject; const result = await getTransactions(apiParams); @@ -159,6 +162,7 @@ describe('Test function: getTransactions', function () { senderAddress: txn4.senderAddress, pageSize: '10', withDeployTxn: true, + chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, }; apiParams.requestParams = requestObject; const result = await getTransactions(apiParams); @@ -173,6 +177,7 @@ describe('Test function: getTransactions', function () { senderAddress: txn4.senderAddress, pageSize: '10', withDeployTxn: true, + chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, }; apiParams.requestParams = requestObject; diff --git a/packages/starknet-snap/test/src/getValue.test.ts b/packages/starknet-snap/test/src/getValue.test.ts index b87ec036..cf0db142 100644 --- a/packages/starknet-snap/test/src/getValue.test.ts +++ b/packages/starknet-snap/test/src/getValue.test.ts @@ -5,7 +5,7 @@ import { WalletMock } from '../wallet.mock.test'; import { getValue } from '../../src/getValue'; import * as utils from '../../src/utils/starknetUtils'; import { SnapState } from '../../src/types/snapState'; -import { STARKNET_SEPOLIA_TESTNET_NETWORK } from '../../src/utils/constants'; +import { STARKNET_MAINNET_NETWORK } from '../../src/utils/constants'; import { Mutex } from 'async-mutex'; import { ApiParams, GetValueRequestParams } from '../../src/types/snapApi'; @@ -17,7 +17,7 @@ describe('Test function: getValue', function () { const state: SnapState = { accContracts: [], erc20Tokens: [], - networks: [STARKNET_SEPOLIA_TESTNET_NETWORK], + networks: [STARKNET_MAINNET_NETWORK], transactions: [], }; const apiParams: ApiParams = { diff --git a/packages/starknet-snap/test/src/sendTransaction.test.ts b/packages/starknet-snap/test/src/sendTransaction.test.ts index 5cb42d48..809c7a69 100644 --- a/packages/starknet-snap/test/src/sendTransaction.test.ts +++ b/packages/starknet-snap/test/src/sendTransaction.test.ts @@ -8,7 +8,10 @@ import * as snapUtils from '../../src/utils/snapUtils'; import { SnapState } from '../../src/types/snapState'; import { sendTransaction } from '../../src/sendTransaction'; import * as estimateFeeSnap from '../../src/estimateFee'; -import { STARKNET_SEPOLIA_TESTNET_NETWORK } from '../../src/utils/constants'; +import { + STARKNET_MAINNET_NETWORK, + STARKNET_SEPOLIA_TESTNET_NETWORK, +} from '../../src/utils/constants'; import { account1, createAccountProxyResp, @@ -42,7 +45,7 @@ describe('Test function: sendTransaction', function () { const state: SnapState = { accContracts: [], erc20Tokens: [token2, token3], - networks: [STARKNET_SEPOLIA_TESTNET_NETWORK], + networks: [STARKNET_MAINNET_NETWORK, STARKNET_SEPOLIA_TESTNET_NETWORK], transactions: [], }; let apiParams: ApiParamsWithKeyDeriver; @@ -61,7 +64,9 @@ describe('Test function: sendTransaction', function () { walletStub.rpcStubs.snap_getBip44Entropy.callsFake(getBip44EntropyStub); apiParams = { state, - requestParams: {}, + requestParams: { + chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, + }, wallet: walletStub, saveMutex: new Mutex(), keyDeriver: await getAddressKeyDeriver(walletStub), @@ -351,6 +356,7 @@ describe('Test function: sendTransaction', function () { contractFuncName: 'get_signer', contractCallData: '**foo**', senderAddress: account1.address, + chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, }; apiParams.requestParams = requestObject; await sendTransaction(apiParams); From 17172f19f6d906d3a8398f8124b0acbf75ac8f4a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 30 Sep 2024 15:18:23 +0200 Subject: [PATCH 04/50] chore: release main (#300) * chore: release main * chore: release main * chore: release main --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Florin Dzeladini --- .release-please-manifest.json | 6 +++--- packages/get-starknet/CHANGELOG.md | 7 +++++++ packages/get-starknet/package.json | 2 +- packages/starknet-snap/CHANGELOG.md | 9 +++++++++ .../openrpc/starknet_snap_api_openrpc.json | 2 +- packages/starknet-snap/package.json | 2 +- packages/starknet-snap/snap.manifest.json | 2 +- packages/wallet-ui/CHANGELOG.md | 13 +++++++++++++ packages/wallet-ui/package.json | 2 +- 9 files changed, 37 insertions(+), 8 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 430e6f54..fdaf7cd9 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,5 +1,5 @@ { - "packages/starknet-snap": "2.9.0", - "packages/wallet-ui": "1.23.0", - "packages/get-starknet": "1.1.0" + "packages/starknet-snap": "2.10.0", + "packages/wallet-ui": "1.24.0", + "packages/get-starknet": "1.2.0" } \ No newline at end of file diff --git a/packages/get-starknet/CHANGELOG.md b/packages/get-starknet/CHANGELOG.md index 79509d19..2787809c 100644 --- a/packages/get-starknet/CHANGELOG.md +++ b/packages/get-starknet/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.2.0](https://github.com/Consensys/starknet-snap/compare/get-starknet-v1.1.0...get-starknet-v1.2.0) (2024-09-20) + + +### Features + +* bump starknet.js to v6.11.0 ([#296](https://github.com/Consensys/starknet-snap/issues/296)) ([e298244](https://github.com/Consensys/starknet-snap/commit/e298244a5e68e2809ab6367330e104c53ca5c861)) + ## [1.1.0](https://github.com/Consensys/starknet-snap/compare/get-starknet-v1.0.0...get-starknet-v1.1.0) (2024-07-16) diff --git a/packages/get-starknet/package.json b/packages/get-starknet/package.json index c36f5b4e..b4453914 100644 --- a/packages/get-starknet/package.json +++ b/packages/get-starknet/package.json @@ -1,6 +1,6 @@ { "name": "@consensys/get-starknet", - "version": "1.1.0", + "version": "1.2.0", "license": "(Apache-2.0 OR MIT)", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/starknet-snap/CHANGELOG.md b/packages/starknet-snap/CHANGELOG.md index cc2a4168..d3da991c 100644 --- a/packages/starknet-snap/CHANGELOG.md +++ b/packages/starknet-snap/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [2.10.0](https://github.com/Consensys/starknet-snap/compare/starknet-snap-v2.9.0...starknet-snap-v2.10.0) (2024-09-20) + + +### Features + +* support STRK token for the gas fee in sending transaction and estimate fee ([#271](https://github.com/Consensys/starknet-snap/issues/271)) ([8f50a33](https://github.com/Consensys/starknet-snap/commit/8f50a33ca7cdce88c6853ce1945cd7f7a7b24fae)) +* change default network to mainnet ([#357](https://github.com/Consensys/starknet-snap/issues/357)) ([b2eccb7](https://github.com/Consensys/starknet-snap/commit/b2eccb74e958d2087917484469cb2139e2f537b7)) +* bump starknet.js to v6.11.0 ([#296](https://github.com/Consensys/starknet-snap/issues/296)) ([e298244](https://github.com/Consensys/starknet-snap/commit/e298244a5e68e2809ab6367330e104c53ca5c861)) +* allow multiple consecutive transactions ([#289](https://github.com/Consensys/starknet-snap/issues/289)) ([5a501f9](https://github.com/Consensys/starknet-snap/commit/5a501f9aae7c3cdf041f479eac38f4a1e82855e9)) ## [2.9.0](https://github.com/Consensys/starknet-snap/compare/starknet-snap-v2.8.0...starknet-snap-v2.9.0) (2024-07-16) diff --git a/packages/starknet-snap/openrpc/starknet_snap_api_openrpc.json b/packages/starknet-snap/openrpc/starknet_snap_api_openrpc.json index 0dd14653..0b1fcae5 100644 --- a/packages/starknet-snap/openrpc/starknet_snap_api_openrpc.json +++ b/packages/starknet-snap/openrpc/starknet_snap_api_openrpc.json @@ -1,7 +1,7 @@ { "openrpc": "1.0.0-rc1", "info": { - "version": "2.9.0", + "version": "2.10.0", "title": "Starknet MetaMask Snap API", "license": {} }, diff --git a/packages/starknet-snap/package.json b/packages/starknet-snap/package.json index 4b589095..14b5c1a4 100644 --- a/packages/starknet-snap/package.json +++ b/packages/starknet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@consensys/starknet-snap", - "version": "2.9.0", + "version": "2.10.0", "keywords": [], "repository": { "type": "git", diff --git a/packages/starknet-snap/snap.manifest.json b/packages/starknet-snap/snap.manifest.json index 8b5c0c28..8dae0e8b 100644 --- a/packages/starknet-snap/snap.manifest.json +++ b/packages/starknet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "2.9.0", + "version": "2.10.0", "description": "Manage Starknet accounts and assets with MetaMask.", "proposedName": "Starknet", "repository": { diff --git a/packages/wallet-ui/CHANGELOG.md b/packages/wallet-ui/CHANGELOG.md index 4c5072de..8b60d9b9 100644 --- a/packages/wallet-ui/CHANGELOG.md +++ b/packages/wallet-ui/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +## [1.24.0](https://github.com/Consensys/starknet-snap/compare/wallet-ui-v1.23.0...wallet-ui-v1.24.0) (2024-09-20) + + +### Features + +* support STRK token for the gas fee in wallet-ui ([#271](https://github.com/Consensys/starknet-snap/issues/271)) ([8f50a33](https://github.com/Consensys/starknet-snap/commit/8f50a33ca7cdce88c6853ce1945cd7f7a7b24fae)) +* bump starknet.js to v6.11.0 ([#296](https://github.com/Consensys/starknet-snap/issues/296)) ([e298244](https://github.com/Consensys/starknet-snap/commit/e298244a5e68e2809ab6367330e104c53ca5c861)) + + +### Bug Fixes + +* update message for waiting deploy/upgrade txn complete ([#297](https://github.com/Consensys/starknet-snap/issues/297)) ([141fa20](https://github.com/Consensys/starknet-snap/commit/141fa2023911e8c6f2d1b495a2d78bec79a3e5d7)) + ## [1.23.0](https://github.com/Consensys/starknet-snap/compare/wallet-ui-v1.22.0...wallet-ui-v1.23.0) (2024-07-16) diff --git a/packages/wallet-ui/package.json b/packages/wallet-ui/package.json index 2c556e9c..09322aac 100644 --- a/packages/wallet-ui/package.json +++ b/packages/wallet-ui/package.json @@ -1,6 +1,6 @@ { "name": "wallet-ui", - "version": "1.23.0", + "version": "1.24.0", "private": true, "homepage": "/starknet", "license": "(Apache-2.0 OR MIT)", From a0a80c80d86391919f344ec0e226e8fc0a44e665 Mon Sep 17 00:00:00 2001 From: khanti42 Date: Tue, 1 Oct 2024 09:33:55 +0200 Subject: [PATCH 05/50] chore: update feature change notice dialog for 2.10.0 (#360) * chore: change text of on update handler * chore: update text --------- Co-authored-by: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> --- packages/starknet-snap/src/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/starknet-snap/src/index.ts b/packages/starknet-snap/src/index.ts index 55f38277..52bcced5 100644 --- a/packages/starknet-snap/src/index.ts +++ b/packages/starknet-snap/src/index.ts @@ -328,7 +328,11 @@ export const onInstall: OnInstallHandler = async () => { export const onUpdate: OnUpdateHandler = async () => { const component = panel([ text('Features released with this update:'), - text('Cairo contract upgrade support.'), + text( + 'Support STRK token for the gas fee in sending transaction and estimating fee.', + ), + text('Default network changed to mainnet.'), + text('Support for multiple consecutive transactions.'), ]); await snap.request({ From 61175090256aa3d614b0e850947e0f90b5e05908 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 2 Oct 2024 18:07:34 +0800 Subject: [PATCH 06/50] fix: logger undefine if not init (#363) --- packages/starknet-snap/src/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/starknet-snap/src/index.ts b/packages/starknet-snap/src/index.ts index 52bcced5..1f79f901 100644 --- a/packages/starknet-snap/src/index.ts +++ b/packages/starknet-snap/src/index.ts @@ -89,12 +89,11 @@ import { } from './utils/starknetUtils'; declare const snap; +logger.logLevel = parseInt(Config.logLevel, 10); export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { const requestParams = request?.params as unknown as ApiRequestParams; - logger.logLevel = parseInt(Config.logLevel, 10); - logger.log(`${request.method}:\nrequestParams: ${toJson(requestParams)}`); try { From f9a108e357f86915e228945ad6bb928c9aadf708 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 2 Oct 2024 13:14:40 +0200 Subject: [PATCH 07/50] chore: release main (#364) * chore: release main * chore: update change log * chore: lint --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> --- .release-please-manifest.json | 2 +- packages/starknet-snap/CHANGELOG.md | 7 +++++++ .../starknet-snap/openrpc/starknet_snap_api_openrpc.json | 2 +- packages/starknet-snap/package.json | 2 +- packages/starknet-snap/snap.manifest.json | 2 +- 5 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index fdaf7cd9..505d10cd 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,5 +1,5 @@ { - "packages/starknet-snap": "2.10.0", + "packages/starknet-snap": "2.10.1", "packages/wallet-ui": "1.24.0", "packages/get-starknet": "1.2.0" } \ No newline at end of file diff --git a/packages/starknet-snap/CHANGELOG.md b/packages/starknet-snap/CHANGELOG.md index d3da991c..6b29a3e1 100644 --- a/packages/starknet-snap/CHANGELOG.md +++ b/packages/starknet-snap/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [2.10.1](https://github.com/Consensys/starknet-snap/compare/starknet-snap-v2.10.0...starknet-snap-v2.10.1) (2024-10-02) + + +### Bug Fixes + +* fix snap homepage screen error if the logger is not initialised ([#363](https://github.com/Consensys/starknet-snap/issues/363)) ([6117509](https://github.com/Consensys/starknet-snap/commit/61175090256aa3d614b0e850947e0f90b5e05908)) + ## [2.10.0](https://github.com/Consensys/starknet-snap/compare/starknet-snap-v2.9.0...starknet-snap-v2.10.0) (2024-09-20) diff --git a/packages/starknet-snap/openrpc/starknet_snap_api_openrpc.json b/packages/starknet-snap/openrpc/starknet_snap_api_openrpc.json index 0b1fcae5..68422415 100644 --- a/packages/starknet-snap/openrpc/starknet_snap_api_openrpc.json +++ b/packages/starknet-snap/openrpc/starknet_snap_api_openrpc.json @@ -1,7 +1,7 @@ { "openrpc": "1.0.0-rc1", "info": { - "version": "2.10.0", + "version": "2.10.1", "title": "Starknet MetaMask Snap API", "license": {} }, diff --git a/packages/starknet-snap/package.json b/packages/starknet-snap/package.json index 14b5c1a4..e797fe2c 100644 --- a/packages/starknet-snap/package.json +++ b/packages/starknet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@consensys/starknet-snap", - "version": "2.10.0", + "version": "2.10.1", "keywords": [], "repository": { "type": "git", diff --git a/packages/starknet-snap/snap.manifest.json b/packages/starknet-snap/snap.manifest.json index 8dae0e8b..f6726679 100644 --- a/packages/starknet-snap/snap.manifest.json +++ b/packages/starknet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "2.10.0", + "version": "2.10.1", "description": "Manage Starknet accounts and assets with MetaMask.", "proposedName": "Starknet", "repository": { From c96f75eb6c95b76513e3a0488d7ccdb3d59e5a71 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 4 Oct 2024 16:59:06 +0800 Subject: [PATCH 08/50] fix(get-starknet): address not update when network change in get-starknet (#366) --- packages/get-starknet/src/wallet.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/get-starknet/src/wallet.ts b/packages/get-starknet/src/wallet.ts index a24c81ca..a08f1b56 100644 --- a/packages/get-starknet/src/wallet.ts +++ b/packages/get-starknet/src/wallet.ts @@ -101,11 +101,6 @@ export class MetaMaskSnapWallet implements IStarknetWindowObject { } async #getWalletAddress(chainId: string) { - // address always same regardless network, only single address provided - if (this.selectedAddress) { - return this.selectedAddress; - } - const accountResponse = await this.snap.recoverDefaultAccount(chainId); if (!accountResponse?.address) { From 5b2696fd0f038bb998b8b3575b44c937d7aba6ca Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 8 Oct 2024 15:53:49 +0800 Subject: [PATCH 09/50] refactor(NetworkStateManager): update `NetworkStateManager` with new configuration (#369) * chore: update network state mgr with default network config * chore: lint fix * chore: update function comment * chore: lint fix --- packages/starknet-snap/src/config.ts | 16 +++++- .../src/state/network-state-manager.test.ts | 49 ++++++++++++++----- .../src/state/network-state-manager.ts | 34 ++++++++++--- packages/starknet-snap/src/utils/snapUtils.ts | 6 +-- 4 files changed, 81 insertions(+), 24 deletions(-) diff --git a/packages/starknet-snap/src/config.ts b/packages/starknet-snap/src/config.ts index 8fb6bc24..cec6227f 100644 --- a/packages/starknet-snap/src/config.ts +++ b/packages/starknet-snap/src/config.ts @@ -1,9 +1,16 @@ -import { SnapEnv } from './utils/constants'; +import type { Network } from './types/snapState'; +import { + SnapEnv, + STARKNET_MAINNET_NETWORK, + STARKNET_SEPOLIA_TESTNET_NETWORK, +} from './utils/constants'; import { LogLevel } from './utils/logger'; export type SnapConfig = { logLevel: string; snapEnv: SnapEnv; + defaultNetwork: Network; + availableNetworks: Network[]; }; export const Config: SnapConfig = { @@ -11,4 +18,11 @@ export const Config: SnapConfig = { logLevel: process.env.LOG_LEVEL ?? LogLevel.OFF.valueOf().toString(), // eslint-disable-next-line no-restricted-globals snapEnv: (process.env.SNAP_ENV ?? SnapEnv.Prod) as unknown as SnapEnv, + + defaultNetwork: STARKNET_MAINNET_NETWORK, + + availableNetworks: [ + STARKNET_MAINNET_NETWORK, + STARKNET_SEPOLIA_TESTNET_NETWORK, + ], }; diff --git a/packages/starknet-snap/src/state/network-state-manager.test.ts b/packages/starknet-snap/src/state/network-state-manager.test.ts index 6e199f83..3003d8ca 100644 --- a/packages/starknet-snap/src/state/network-state-manager.test.ts +++ b/packages/starknet-snap/src/state/network-state-manager.test.ts @@ -1,9 +1,11 @@ import { constants } from 'starknet'; +import { Config } from '../config'; import type { Network } from '../types/snapState'; import { STARKNET_MAINNET_NETWORK, STARKNET_SEPOLIA_TESTNET_NETWORK, + STARKNET_TESTNET_NETWORK, } from '../utils/constants'; import { mockState } from './__tests__/helper'; import { NetworkStateManager, ChainIdFilter } from './network-state-manager'; @@ -14,7 +16,7 @@ describe('NetworkStateManager', () => { it('returns the network', async () => { const chainId = constants.StarknetChainId.SN_SEPOLIA; await mockState({ - networks: [STARKNET_MAINNET_NETWORK, STARKNET_SEPOLIA_TESTNET_NETWORK], + networks: Config.availableNetworks, }); const stateManager = new NetworkStateManager(); @@ -25,15 +27,27 @@ describe('NetworkStateManager', () => { expect(result).toStrictEqual(STARKNET_SEPOLIA_TESTNET_NETWORK); }); - it('returns null if the network can not be found', async () => { - const chainId = constants.StarknetChainId.SN_SEPOLIA; + it('looks up the configuration if the network cant be found in state', async () => { await mockState({ networks: [STARKNET_MAINNET_NETWORK], }); const stateManager = new NetworkStateManager(); const result = await stateManager.getNetwork({ - chainId, + chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, + }); + + expect(result).toStrictEqual(STARKNET_SEPOLIA_TESTNET_NETWORK); + }); + + it('returns null if the network can not be found', async () => { + await mockState({ + networks: Config.availableNetworks, + }); + + const stateManager = new NetworkStateManager(); + const result = await stateManager.getNetwork({ + chainId: '0x9999', }); expect(result).toBeNull(); @@ -103,7 +117,7 @@ describe('NetworkStateManager', () => { it('returns the list of network by chainId', async () => { const chainId = constants.StarknetChainId.SN_SEPOLIA; await mockState({ - networks: [STARKNET_MAINNET_NETWORK, STARKNET_SEPOLIA_TESTNET_NETWORK], + networks: Config.availableNetworks, }); const stateManager = new NetworkStateManager(); @@ -163,7 +177,7 @@ describe('NetworkStateManager', () => { describe('getCurrentNetwork', () => { it('get the current network', async () => { await mockState({ - networks: [STARKNET_MAINNET_NETWORK, STARKNET_SEPOLIA_TESTNET_NETWORK], + networks: Config.availableNetworks, currentNetwork: STARKNET_MAINNET_NETWORK, }); @@ -173,15 +187,27 @@ describe('NetworkStateManager', () => { expect(result).toStrictEqual(STARKNET_MAINNET_NETWORK); }); - it('returns null if the current network is null or undefined', async () => { + it(`returns default network if the current network is null or undefined`, async () => { await mockState({ - networks: [STARKNET_MAINNET_NETWORK, STARKNET_SEPOLIA_TESTNET_NETWORK], + networks: Config.availableNetworks, }); const stateManager = new NetworkStateManager(); const result = await stateManager.getCurrentNetwork(); - expect(result).toBeNull(); + expect(result).toStrictEqual(Config.defaultNetwork); + }); + + it(`returns default network if the current network is neither mainnet or sepolia testnet`, async () => { + await mockState({ + networks: Config.availableNetworks, + currentNetwork: STARKNET_TESTNET_NETWORK, + }); + + const stateManager = new NetworkStateManager(); + const result = await stateManager.getCurrentNetwork(); + + expect(result).toStrictEqual(Config.defaultNetwork); }); }); @@ -213,10 +239,7 @@ describe('NetworkStateManager', () => { updateTo: Network; }) => { const { state } = await mockState({ - networks: [ - STARKNET_MAINNET_NETWORK, - STARKNET_SEPOLIA_TESTNET_NETWORK, - ], + networks: Config.availableNetworks, currentNetwork, }); diff --git a/packages/starknet-snap/src/state/network-state-manager.ts b/packages/starknet-snap/src/state/network-state-manager.ts index a3588e2e..65a9312a 100644 --- a/packages/starknet-snap/src/state/network-state-manager.ts +++ b/packages/starknet-snap/src/state/network-state-manager.ts @@ -1,5 +1,6 @@ import { assert, string } from 'superstruct'; +import { Config } from '../config'; import type { Network, SnapState } from '../types/snapState'; import type { IFilter } from './filter'; import { ChainIdFilter as BaseChainIdFilter } from './filter'; @@ -59,6 +60,9 @@ export class NetworkStateManager extends StateManager { /** * Finds a network based on the given chainId. + * The query will first be looked up in the state. If the result is false, it will then fallback to the available Networks constants. + * + * (Note) Due to the returned network object may not exist in the state, it may failed to execute `updateNetwork` with the returned network object. * * @param param - The param object. * @param param.chainId - The chainId to search for. @@ -74,7 +78,12 @@ export class NetworkStateManager extends StateManager { state?: SnapState, ): Promise { const filters: INetworkFilter[] = [new ChainIdFilter([chainId])]; - return this.find(filters, state); + // in case the network not found from the state, try to get the network from the available Networks constants + return ( + (await this.find(filters, state)) ?? + Config.availableNetworks.find((network) => network.chainId === chainId) ?? + null + ); } /** @@ -88,10 +97,9 @@ export class NetworkStateManager extends StateManager { async updateNetwork(data: Network): Promise { try { await this.update(async (state: SnapState) => { - const dataInState = await this.getNetwork( - { - chainId: data.chainId, - }, + // Use underlying function `find` to avoid searching network from constants + const dataInState = await this.find( + [new ChainIdFilter([data.chainId])], state, ); @@ -111,8 +119,20 @@ export class NetworkStateManager extends StateManager { * @param [state] - The optional SnapState object. * @returns A Promise that resolves with the current Network object if found, or null if not found. */ - async getCurrentNetwork(state?: SnapState): Promise { - return (state ?? (await this.get())).currentNetwork ?? null; + async getCurrentNetwork(state?: SnapState): Promise { + const { currentNetwork } = state ?? (await this.get()); + + // Make sure the current network is either Sepolia testnet or Mainnet. By default it will be Mainnet. + if ( + !currentNetwork || + !Config.availableNetworks.find( + (network) => network.chainId === currentNetwork.chainId, + ) + ) { + return Config.defaultNetwork; + } + + return currentNetwork; } /** diff --git a/packages/starknet-snap/src/utils/snapUtils.ts b/packages/starknet-snap/src/utils/snapUtils.ts index ea6a78ab..92d23744 100644 --- a/packages/starknet-snap/src/utils/snapUtils.ts +++ b/packages/starknet-snap/src/utils/snapUtils.ts @@ -14,6 +14,7 @@ import type { UniversalDetails, } from 'starknet'; +import { Config } from '../config'; import { FeeToken, type AddErc20TokenRequestParams, @@ -34,7 +35,6 @@ import { MAXIMUM_TOKEN_SYMBOL_LENGTH, PRELOADED_NETWORKS, PRELOADED_TOKENS, - STARKNET_MAINNET_NETWORK, STARKNET_SEPOLIA_TESTNET_NETWORK, } from './constants'; import { DeployRequiredError, UpgradeRequiredError } from './exceptions'; @@ -855,7 +855,7 @@ export function getNetworkFromChainId( state: SnapState, targerChainId: string | undefined, ) { - const chainId = targerChainId ?? STARKNET_MAINNET_NETWORK.chainId; + const chainId = targerChainId ?? Config.defaultNetwork.chainId; const network = getNetwork(state, chainId); if (network === undefined) { throw new Error( @@ -1117,7 +1117,7 @@ export async function removeAcceptedTransaction( * @param state */ export function getCurrentNetwork(state: SnapState) { - return state.currentNetwork ?? STARKNET_MAINNET_NETWORK; + return state.currentNetwork ?? Config.defaultNetwork; } /** From 52bf68f2f55a99d46db1dc47eace27c5e1b8abe3 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 8 Oct 2024 19:27:44 +0800 Subject: [PATCH 10/50] refactor(HomePage): revamp snap `onHomePage` handler (#345) * refactor: dappUrl function * chore: update config * chore: fix lint * refactor: revamp on home page handler * fix: lint * chore: update home page controller location * fix: lint * chore: update home page test * chore: update network state mgr with default network config * chore: lint fix * chore: lint * chore: get balance with pending block only in HP --------- Co-authored-by: khanti42 --- packages/starknet-snap/src/index.test.ts | 163 +----------- packages/starknet-snap/src/index.ts | 79 +----- .../starknet-snap/src/on-home-page.test.ts | 237 ++++++++++++++++++ packages/starknet-snap/src/on-home-page.ts | 122 +++++++++ 4 files changed, 370 insertions(+), 231 deletions(-) create mode 100644 packages/starknet-snap/src/on-home-page.test.ts create mode 100644 packages/starknet-snap/src/on-home-page.ts diff --git a/packages/starknet-snap/src/index.test.ts b/packages/starknet-snap/src/index.test.ts index 465a7332..2426ddf5 100644 --- a/packages/starknet-snap/src/index.test.ts +++ b/packages/starknet-snap/src/index.test.ts @@ -1,19 +1,9 @@ -import { MethodNotFoundError, SnapError } from '@metamask/snaps-sdk'; -import { constants } from 'starknet'; +import { text, MethodNotFoundError, SnapError } from '@metamask/snaps-sdk'; -import { onRpcRequest, onHomePage } from '.'; -import { manageStateSpy } from '../test/snap-provider.mock'; -import { generateAccounts, type StarknetAccount } from './__tests__/helper'; +import { onHomePage, onRpcRequest } from '.'; import * as createAccountApi from './createAccount'; -import type { SnapState } from './types/snapState'; -import { - ETHER_MAINNET, - ETHER_SEPOLIA_TESTNET, - STARKNET_MAINNET_NETWORK, - STARKNET_SEPOLIA_TESTNET_NETWORK, -} from './utils/constants'; +import { HomePageController } from './on-home-page'; import * as keyPairUtils from './utils/keyPair'; -import * as starknetUtils from './utils/starknetUtils'; jest.mock('./utils/logger'); @@ -79,153 +69,18 @@ describe('onRpcRequest', () => { }); describe('onHomePage', () => { - const state: SnapState = { - accContracts: [], - erc20Tokens: [ETHER_MAINNET, ETHER_SEPOLIA_TESTNET], - networks: [STARKNET_MAINNET_NETWORK, STARKNET_SEPOLIA_TESTNET_NETWORK], - transactions: [], - currentNetwork: undefined, - }; - - const mockState = (snapState: SnapState) => { - manageStateSpy.mockResolvedValue(snapState); - }; - - const mockAccount = async (chainId: constants.StarknetChainId) => { - const accounts = await generateAccounts(chainId); - return accounts[0]; - }; - - const mockAccountDiscovery = (account: StarknetAccount) => { - const getKeysFromAddressIndexSpy = jest.spyOn( - starknetUtils, - 'getKeysFromAddressIndex', - ); - const getCorrectContractAddressSpy = jest.spyOn( - starknetUtils, - 'getCorrectContractAddress', - ); - - getKeysFromAddressIndexSpy.mockResolvedValue({ - privateKey: account.privateKey, - publicKey: account.publicKey, - addressIndex: account.addressIndex, - derivationPath: account.derivationPath as unknown as any, - }); - - getCorrectContractAddressSpy.mockResolvedValue({ - address: account.address, - signerPubKey: account.publicKey, - upgradeRequired: false, - deployRequired: false, - }); - - return { - getKeysFromAddressIndexSpy, - getCorrectContractAddressSpy, - }; - }; - - const mockGetBalance = (balance: string) => { - const getBalanceSpy = jest.spyOn(starknetUtils, 'getBalance'); - getBalanceSpy.mockResolvedValue(balance); - }; - - it('renders user address, user balance and network', async () => { - const account = await mockAccount(constants.StarknetChainId.SN_SEPOLIA); - mockState(state); - mockAccountDiscovery(account); - mockGetBalance('1000'); - - const result = await onHomePage(); - - expect(result).toStrictEqual({ - content: { - type: 'panel', - children: [ - { type: 'text', value: 'Address' }, - { - type: 'copyable', - value: account.address, - }, - { - type: 'row', - label: 'Network', - value: { - type: 'text', - value: STARKNET_MAINNET_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 with network from state if `currentNetwork` is not undefined', async () => { - const network = STARKNET_MAINNET_NETWORK; - const account = await mockAccount(constants.StarknetChainId.SN_MAIN); - mockState({ - ...state, - currentNetwork: network, - }); - mockAccountDiscovery(account); - mockGetBalance('1000'); + it('executes homePageController', async () => { + const executeSpy = jest.spyOn(HomePageController.prototype, 'execute'); + executeSpy.mockResolvedValue({ content: text('test') }); const result = await onHomePage(); + expect(executeSpy).toHaveBeenCalledTimes(1); expect(result).toStrictEqual({ content: { - type: 'panel', - children: [ - { type: 'text', value: 'Address' }, - { - type: 'copyable', - value: account.address, - }, - { - type: 'row', - label: 'Network', - value: { - type: 'text', - value: 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.', - }, - ], + type: 'text', + value: 'test', }, }); }); - - it('throws `Unable to initialize Snap HomePage` error when state not found', async () => { - await expect(onHomePage()).rejects.toThrow( - 'Unable to initialize Snap HomePage', - ); - }); }); diff --git a/packages/starknet-snap/src/index.ts b/packages/starknet-snap/src/index.ts index 1f79f901..a3f778d2 100644 --- a/packages/starknet-snap/src/index.ts +++ b/packages/starknet-snap/src/index.ts @@ -3,18 +3,13 @@ import type { OnHomePageHandler, OnInstallHandler, OnUpdateHandler, - Component, } from '@metamask/snaps-sdk'; import { panel, - row, - divider, text, - copyable, SnapError, MethodNotFoundError, } from '@metamask/snaps-sdk'; -import { ethers } from 'ethers'; import { addErc20Token } from './addErc20Token'; import { addNetwork } from './addNetwork'; @@ -34,6 +29,7 @@ import { getStoredUserAccounts } from './getStoredUserAccounts'; import { getTransactions } from './getTransactions'; import { getTransactionStatus } from './getTransactionStatus'; import { getValue } from './getValue'; +import { homePageController } from './on-home-page'; import { recoverAccounts } from './recoverAccounts'; import type { DisplayPrivateKeyParams, @@ -66,8 +62,6 @@ import { upgradeAccContract } from './upgradeAccContract'; import { getDappUrl, isSnapRpcError } from './utils'; import { CAIRO_VERSION_LEGACY, - ETHER_MAINNET, - ETHER_SEPOLIA_TESTNET, PRELOADED_TOKENS, STARKNET_MAINNET_NETWORK, STARKNET_SEPOLIA_TESTNET_NETWORK, @@ -82,11 +76,6 @@ import { upsertNetwork, removeNetwork, } from './utils/snapUtils'; -import { - getBalance, - getCorrectContractAddress, - getKeysFromAddressIndex, -} from './utils/starknetUtils'; declare const snap; logger.logLevel = parseInt(Config.logLevel, 10); @@ -344,69 +333,5 @@ export const onUpdate: OnUpdateHandler = async () => { }; export const onHomePage: OnHomePageHandler = async () => { - try { - const state: SnapState = await snap.request({ - method: 'snap_manageState', - params: { - operation: 'get', - }, - }); - - if (!state) { - throw new Error('State not found.'); - } - - // default network is mainnet - let network = STARKNET_MAINNET_NETWORK; - if ( - state.currentNetwork && - state.currentNetwork.chainId !== STARKNET_TESTNET_NETWORK.chainId - ) { - network = state.currentNetwork; - } - - // 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: Component[] = []; - 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](${getDappUrl()}) to manage your account.`, - ), - ); - - return { - content: panel(panelItems), - }; - } catch (error) { - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - logger.error(`Error: ${error}`); - throw new SnapError( - 'Unable to initialize Snap HomePage', - ) as unknown as Error; - } + return await homePageController.execute(); }; diff --git a/packages/starknet-snap/src/on-home-page.test.ts b/packages/starknet-snap/src/on-home-page.test.ts new file mode 100644 index 00000000..dfbc9388 --- /dev/null +++ b/packages/starknet-snap/src/on-home-page.test.ts @@ -0,0 +1,237 @@ +import { ethers } from 'ethers'; +import { constants } from 'starknet'; + +import { generateAccounts, type StarknetAccount } from './__tests__/helper'; +import { HomePageController } from './on-home-page'; +import type { Network, SnapState } from './types/snapState'; +import { + BlockIdentifierEnum, + ETHER_MAINNET, + STARKNET_SEPOLIA_TESTNET_NETWORK, +} from './utils/constants'; +import * as snapHelper from './utils/snap'; +import * as starknetUtils from './utils/starknetUtils'; + +jest.mock('./utils/snap'); +jest.mock('./utils/logger'); + +describe('homepageController', () => { + const state: SnapState = { + accContracts: [], + erc20Tokens: [], + networks: [STARKNET_SEPOLIA_TESTNET_NETWORK], + transactions: [], + currentNetwork: STARKNET_SEPOLIA_TESTNET_NETWORK, + }; + + const mockAccount = async (chainId: constants.StarknetChainId) => { + return (await generateAccounts(chainId, 1))[0]; + }; + + const mockState = async () => { + const getStateDataSpy = jest.spyOn(snapHelper, 'getStateData'); + getStateDataSpy.mockResolvedValue(state); + return { + getStateDataSpy, + }; + }; + + class MockHomePageController extends HomePageController { + async getAddress(network: Network): Promise { + return super.getAddress(network); + } + + async getBalance(network: Network, address: string): Promise { + return super.getBalance(network, address); + } + } + + describe('execute', () => { + const prepareExecuteMock = (account: StarknetAccount, balance: string) => { + const getAddressSpy = jest.spyOn( + MockHomePageController.prototype, + 'getAddress', + ); + const getBalanceSpy = jest.spyOn( + MockHomePageController.prototype, + 'getBalance', + ); + getAddressSpy.mockResolvedValue(account.address); + getBalanceSpy.mockResolvedValue(balance); + return { + getAddressSpy, + getBalanceSpy, + }; + }; + + it('returns the correct homepage response', async () => { + const { currentNetwork } = state; + await mockState(); + const account = await mockAccount( + currentNetwork?.chainId as unknown as constants.StarknetChainId, + ); + const balance = '100'; + + const { getAddressSpy, getBalanceSpy } = prepareExecuteMock( + account, + balance, + ); + + const homepageController = new MockHomePageController(); + const result = await homepageController.execute(); + + expect(result).toStrictEqual({ + content: { + children: [ + { + type: 'text', + value: 'Address', + }, + { + type: 'copyable', + value: account.address, + }, + { + label: 'Network', + type: 'row', + value: { + type: 'text', + value: currentNetwork?.name, + }, + }, + { + label: 'Balance', + type: 'row', + value: { + type: 'text', + value: `${balance} ETH`, + }, + }, + { + type: 'divider', + }, + { + type: 'text', + value: + 'Visit the [companion dapp for Starknet](https://snaps.consensys.io/starknet) to manage your account.', + }, + ], + type: 'panel', + }, + }); + expect(getAddressSpy).toHaveBeenCalledWith(currentNetwork); + expect(getBalanceSpy).toHaveBeenCalledWith( + currentNetwork, + account.address, + ); + }); + + it('throws `Failed to initialize Snap HomePage` error if an error was thrown', async () => { + await mockState(); + const account = await mockAccount(constants.StarknetChainId.SN_SEPOLIA); + const balance = '100'; + + const { getAddressSpy } = prepareExecuteMock(account, balance); + getAddressSpy.mockReset().mockRejectedValue(new Error('error')); + + const homepageController = new MockHomePageController(); + await expect(homepageController.execute()).rejects.toThrow( + 'Failed to initialize Snap HomePage', + ); + }); + }); + + describe('getAddress', () => { + const prepareGetAddressMock = async (account: StarknetAccount) => { + const getKeysFromAddressSpy = jest.spyOn( + starknetUtils, + 'getKeysFromAddressIndex', + ); + + getKeysFromAddressSpy.mockResolvedValue({ + privateKey: account.privateKey, + publicKey: account.publicKey, + addressIndex: account.addressIndex, + derivationPath: account.derivationPath as unknown as any, + }); + + const getCorrectContractAddressSpy = jest.spyOn( + starknetUtils, + 'getCorrectContractAddress', + ); + getCorrectContractAddressSpy.mockResolvedValue({ + address: account.address, + signerPubKey: account.publicKey, + upgradeRequired: false, + deployRequired: false, + }); + return { + getKeysFromAddressSpy, + getCorrectContractAddressSpy, + }; + }; + + it('returns the correct homepage response', async () => { + const network = STARKNET_SEPOLIA_TESTNET_NETWORK; + await mockState(); + const account = await mockAccount(constants.StarknetChainId.SN_SEPOLIA); + const { getKeysFromAddressSpy, getCorrectContractAddressSpy } = + await prepareGetAddressMock(account); + + const homepageController = new MockHomePageController(); + const result = await homepageController.getAddress(network); + + expect(result).toStrictEqual(account.address); + expect(getKeysFromAddressSpy).toHaveBeenCalledWith( + // BIP44 Deriver has mocked as undefined, hence this argument should be undefined + undefined, + network.chainId, + state, + 0, + ); + expect(getCorrectContractAddressSpy).toHaveBeenCalledWith( + network, + account.publicKey, + ); + }); + }); + + describe('getBalance', () => { + const prepareGetBalanceMock = async (balance: number) => { + const getBalanceSpy = jest.spyOn(starknetUtils, 'getBalance'); + + getBalanceSpy.mockResolvedValue(balance.toString(16)); + + return { + getBalanceSpy, + }; + }; + + it('returns the balance on pending block', async () => { + const network = STARKNET_SEPOLIA_TESTNET_NETWORK; + const token = ETHER_MAINNET; + const expectedBalance = 100; + await mockState(); + const { address } = await mockAccount( + constants.StarknetChainId.SN_SEPOLIA, + ); + const { getBalanceSpy } = await prepareGetBalanceMock(expectedBalance); + + const homepageController = new MockHomePageController(); + const result = await homepageController.getBalance(network, address); + + expect(result).toStrictEqual( + ethers.utils.formatUnits( + ethers.BigNumber.from(expectedBalance.toString(16)), + token.decimals, + ), + ); + expect(getBalanceSpy).toHaveBeenCalledWith( + address, + token.address, + network, + BlockIdentifierEnum.Pending, + ); + }); + }); +}); diff --git a/packages/starknet-snap/src/on-home-page.ts b/packages/starknet-snap/src/on-home-page.ts new file mode 100644 index 00000000..d84fa7ee --- /dev/null +++ b/packages/starknet-snap/src/on-home-page.ts @@ -0,0 +1,122 @@ +import type { Component, OnHomePageResponse } from '@metamask/snaps-sdk'; +import { + SnapError, + copyable, + divider, + panel, + row, + text, +} from '@metamask/snaps-sdk'; +import { ethers } from 'ethers'; + +import { NetworkStateManager } from './state/network-state-manager'; +import type { Network, SnapState } from './types/snapState'; +import { + getBip44Deriver, + getDappUrl, + getStateData, + logger, + toJson, +} from './utils'; +import { BlockIdentifierEnum, ETHER_MAINNET } from './utils/constants'; +import { + getBalance, + getCorrectContractAddress, + getKeysFromAddressIndex, +} from './utils/starknetUtils'; + +/** + * The onHomePage handler to execute the home page event operation. + */ +export class HomePageController { + networkStateMgr: NetworkStateManager; + + constructor() { + this.networkStateMgr = new NetworkStateManager(); + } + + /** + * Execute the on home page event operation. + * It derives an account address with index 0 and retrieves the spendable balance of ETH. + * It returns a snap panel component with the address, network, and balance. + * + * @returns A promise that resolve to a OnHomePageResponse object. + */ + async execute(): Promise { + try { + const network = await this.networkStateMgr.getCurrentNetwork(); + + const address = await this.getAddress(network); + + const balance = await this.getBalance(network, address); + + return this.buildComponenets(address, network, balance); + } catch (error) { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + logger.error('Failed to execute onHomePage', toJson(error)); + + throw new SnapError('Failed to initialize Snap HomePage'); + } + } + + protected async getAddress(network: Network): Promise { + const deriver = await getBip44Deriver(); + const state = await getStateData(); + + const { publicKey } = await getKeysFromAddressIndex( + deriver, + network.chainId, + state, + 0, + ); + + const { address } = await getCorrectContractAddress(network, publicKey); + + return address; + } + + protected async getBalance( + network: Network, + address: string, + ): Promise { + // As the snap only accept mainnet / sepolia testnet, and ETH token address are same across all networks + // hence we can hardcode the token + const ethToken = ETHER_MAINNET; + + // Align with the FE Dapp to use the pending block for enquiry the account balance + const balance = await getBalance( + address, + ethToken.address, + network, + BlockIdentifierEnum.Pending, + ); + + return ethers.utils.formatUnits( + ethers.BigNumber.from(balance), + ethToken.decimals, + ); + } + + protected buildComponenets( + address: string, + network: Network, + balance: string, + ): OnHomePageResponse { + const panelItems: Component[] = []; + panelItems.push(text('Address')); + panelItems.push(copyable(`${address}`)); + panelItems.push(row('Network', text(`${network.name}`))); + panelItems.push(row('Balance', text(`${balance} ETH`))); + panelItems.push(divider()); + panelItems.push( + text( + `Visit the [companion dapp for Starknet](${getDappUrl()}) to manage your account.`, + ), + ); + return { + content: panel(panelItems), + }; + } +} + +export const homePageController = new HomePageController(); From 40dad6e62d349b528304c5abdc3153658d4295cf Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 9 Oct 2024 18:36:08 +0800 Subject: [PATCH 11/50] chore: change state mgr to return in withTransaction (#373) --- packages/starknet-snap/src/utils/snap-state.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/starknet-snap/src/utils/snap-state.ts b/packages/starknet-snap/src/utils/snap-state.ts index 6902ba76..310bd345 100644 --- a/packages/starknet-snap/src/utils/snap-state.ts +++ b/packages/starknet-snap/src/utils/snap-state.ts @@ -67,10 +67,10 @@ export abstract class SnapStateManager { * * @param callback - A Promise function that takes the state as an argument. */ - public async withTransaction( - callback: (state: State) => Promise, - ): Promise { - await this.mtx.runExclusive(async () => { + public async withTransaction( + callback: (state: State) => Promise, + ): Promise { + return await this.mtx.runExclusive(async () => { await this.#beginTransaction(); if ( @@ -88,8 +88,9 @@ export abstract class SnapStateManager { ); try { - await callback(this.#transaction.current); + const result = await callback(this.#transaction.current); await this.set(this.#transaction.current); + return result; } catch (error) { logger.info( `SnapStateManager.withTransaction [${ From e61eb8bb4b7b1e2af50ed02bbdd4dac517867710 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 9 Oct 2024 18:39:09 +0800 Subject: [PATCH 12/50] chore(Error): add error wrapper for wallet rpc error code (#374) * chore: add error wrapper * fix: update estimate fee error test * chore: packages/starknet-snap/src/index.ts Co-authored-by: khanti42 * chore: update var name --------- Co-authored-by: khanti42 --- packages/starknet-snap/src/index.ts | 11 ++--- .../src/rpcs/displayPrivateKey.test.ts | 16 ++++---- .../src/rpcs/displayPrivateKey.ts | 5 ++- .../src/rpcs/estimateFee.test.ts | 6 +-- .../starknet-snap/src/rpcs/executeTxn.test.ts | 16 ++++---- packages/starknet-snap/src/rpcs/executeTxn.ts | 11 ++--- .../src/rpcs/sign-declare-transaction.test.ts | 16 ++++---- .../src/rpcs/sign-declare-transaction.ts | 10 ++--- .../src/rpcs/signMessage.test.ts | 16 ++++---- .../starknet-snap/src/rpcs/signMessage.ts | 10 ++--- .../src/rpcs/signTransaction.test.ts | 16 ++++---- .../starknet-snap/src/rpcs/signTransaction.ts | 10 ++--- .../src/rpcs/verify-signature.test.ts | 6 +-- .../starknet-snap/src/utils/error.test.ts | 37 ++++++++++++++++- packages/starknet-snap/src/utils/error.ts | 29 +++++++++++++ .../starknet-snap/src/utils/exceptions.ts | 41 ++++++++++++++++++- packages/starknet-snap/src/utils/rpc.test.ts | 8 ++-- packages/starknet-snap/src/utils/rpc.ts | 8 ++-- 18 files changed, 177 insertions(+), 95 deletions(-) diff --git a/packages/starknet-snap/src/index.ts b/packages/starknet-snap/src/index.ts index a3f778d2..46859407 100644 --- a/packages/starknet-snap/src/index.ts +++ b/packages/starknet-snap/src/index.ts @@ -4,12 +4,7 @@ import type { OnInstallHandler, OnUpdateHandler, } from '@metamask/snaps-sdk'; -import { - panel, - text, - SnapError, - MethodNotFoundError, -} from '@metamask/snaps-sdk'; +import { panel, text, MethodNotFoundError } from '@metamask/snaps-sdk'; import { addErc20Token } from './addErc20Token'; import { addNetwork } from './addNetwork'; @@ -67,6 +62,7 @@ import { STARKNET_SEPOLIA_TESTNET_NETWORK, STARKNET_TESTNET_NETWORK, } from './utils/constants'; +import { UnknownError } from './utils/exceptions'; import { getAddressKeyDeriver } from './utils/keyPair'; import { acquireLock } from './utils/lock'; import { logger } from './utils/logger'; @@ -287,7 +283,8 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { let snapError = error; if (!isSnapRpcError(error)) { - snapError = new SnapError('Unable to execute the rpc request'); + // To ensure the error meets both the SnapError format and WalletRpc format. + snapError = new UnknownError('Unable to execute the rpc request'); } logger.error( `onRpcRequest error: ${JSON.stringify(snapError.toJSON(), null, 2)}`, diff --git a/packages/starknet-snap/src/rpcs/displayPrivateKey.test.ts b/packages/starknet-snap/src/rpcs/displayPrivateKey.test.ts index 07c9faef..2fefde3d 100644 --- a/packages/starknet-snap/src/rpcs/displayPrivateKey.test.ts +++ b/packages/starknet-snap/src/rpcs/displayPrivateKey.test.ts @@ -1,11 +1,11 @@ -import { - InvalidParamsError, - UserRejectedRequestError, -} from '@metamask/snaps-sdk'; import { constants } from 'starknet'; import type { SnapState } from '../types/snapState'; import { STARKNET_SEPOLIA_TESTNET_NETWORK } from '../utils/constants'; +import { + UserRejectedOpError, + InvalidRequestParamsError, +} from '../utils/exceptions'; import { mockAccount, prepareAlertDialog, @@ -77,7 +77,7 @@ describe('displayPrivateKey', () => { ]); }); - it('throws `UserRejectedRequestError` if user denies the operation', async () => { + it('throws `UserRejectedOpError` if user denies the operation', async () => { const chainId = constants.StarknetChainId.SN_SEPOLIA; const account = await mockAccount(chainId); prepareMockAccount(account, state); @@ -89,7 +89,7 @@ describe('displayPrivateKey', () => { const request = createRequestParam(chainId, account.address); await expect(displayPrivateKey.execute(request)).rejects.toThrow( - UserRejectedRequestError, + UserRejectedOpError, ); }); @@ -108,11 +108,11 @@ describe('displayPrivateKey', () => { }, }, ])( - 'throws `InvalidParamsError` when $case', + 'throws `InvalidRequestParamsError` when $case', async ({ request }: { request: unknown }) => { await expect( displayPrivateKey.execute(request as DisplayPrivateKeyParams), - ).rejects.toThrow(InvalidParamsError); + ).rejects.toThrow(InvalidRequestParamsError); }, ); }); diff --git a/packages/starknet-snap/src/rpcs/displayPrivateKey.ts b/packages/starknet-snap/src/rpcs/displayPrivateKey.ts index 38f5092d..b1dd38ef 100644 --- a/packages/starknet-snap/src/rpcs/displayPrivateKey.ts +++ b/packages/starknet-snap/src/rpcs/displayPrivateKey.ts @@ -1,4 +1,4 @@ -import { copyable, text, UserRejectedRequestError } from '@metamask/snaps-sdk'; +import { copyable, text } from '@metamask/snaps-sdk'; import { type Infer, object, literal, assign } from 'superstruct'; import { @@ -8,6 +8,7 @@ import { alertDialog, BaseRequestStruct, } from '../utils'; +import { UserRejectedOpError } from '../utils/exceptions'; export const DisplayPrivateKeyRequestStruct = assign( object({ @@ -58,7 +59,7 @@ export class DisplayPrivateKeyRpc extends AccountRpcController< const confirmComponents = [text('Do you want to export your private key?')]; if (!(await confirmDialog(confirmComponents))) { - throw new UserRejectedRequestError() as unknown as Error; + throw new UserRejectedOpError() as unknown as Error; } const alertComponents = [ diff --git a/packages/starknet-snap/src/rpcs/estimateFee.test.ts b/packages/starknet-snap/src/rpcs/estimateFee.test.ts index bf2cdcf6..bada8c5c 100644 --- a/packages/starknet-snap/src/rpcs/estimateFee.test.ts +++ b/packages/starknet-snap/src/rpcs/estimateFee.test.ts @@ -1,4 +1,3 @@ -import { InvalidParamsError } from '@metamask/snaps-sdk'; import type { Invocations } from 'starknet'; import { constants, TransactionType } from 'starknet'; import type { Infer } from 'superstruct'; @@ -6,6 +5,7 @@ import type { Infer } from 'superstruct'; import { getEstimateFees } from '../__tests__/helper'; import { FeeTokenUnit } from '../types/snapApi'; import { STARKNET_SEPOLIA_TESTNET_NETWORK } from '../utils/constants'; +import { InvalidRequestParamsError } from '../utils/exceptions'; import * as starknetUtils from '../utils/starknetUtils'; import type { TxVersionStruct } from '../utils/superstruct'; import { mockAccount, prepareMockAccount } from './__tests__/helper'; @@ -101,9 +101,9 @@ describe('estimateFee', () => { }); }); - it('throws `InvalidParamsError` when request parameter is not correct', async () => { + it('throws `InvalidRequestParamsError` when request parameter is not correct', async () => { await expect( estimateFee.execute({} as unknown as EstimateFeeParams), - ).rejects.toThrow(InvalidParamsError); + ).rejects.toThrow(InvalidRequestParamsError); }); }); diff --git a/packages/starknet-snap/src/rpcs/executeTxn.test.ts b/packages/starknet-snap/src/rpcs/executeTxn.test.ts index 7dfdfe11..31d2f500 100644 --- a/packages/starknet-snap/src/rpcs/executeTxn.test.ts +++ b/packages/starknet-snap/src/rpcs/executeTxn.test.ts @@ -1,7 +1,3 @@ -import { - InvalidParamsError, - UserRejectedRequestError, -} from '@metamask/snaps-sdk'; import type { UniversalDetails, Call, InvokeFunctionResponse } from 'starknet'; import { constants } from 'starknet'; @@ -9,6 +5,10 @@ import callsExamples from '../__tests__/fixture/callsExamples.json'; // Assuming import { getEstimateFees } from '../__tests__/helper'; import type { FeeTokenUnit } from '../types/snapApi'; import { STARKNET_SEPOLIA_TESTNET_NETWORK } from '../utils/constants'; +import { + UserRejectedOpError, + InvalidRequestParamsError, +} from '../utils/exceptions'; import * as starknetUtils from '../utils/starknetUtils'; import { executeTxn as executeTxnUtil } from '../utils/starknetUtils'; import { @@ -179,7 +179,7 @@ describe('ExecuteTxn', () => { }, ); - it('throws UserRejectedRequestError if user cancels execution', async () => { + it('throws UserRejectedOpError if user cancels execution', async () => { callsExample = callsExamples[1]; const { request, confirmDialogSpy } = await prepareMockExecuteTxn( callsExample.hash, @@ -190,7 +190,7 @@ describe('ExecuteTxn', () => { confirmDialogSpy.mockResolvedValue(false); await expect(executeTxn.execute(request)).rejects.toThrow( - UserRejectedRequestError, + UserRejectedOpError, ); }); @@ -209,9 +209,9 @@ describe('ExecuteTxn', () => { await expect(executeTxn.execute(request)).rejects.toThrow(Error); }); - it('throws `InvalidParamsError` when request parameter is not correct', async () => { + it('throws `InvalidRequestParamsError` when request parameter is not correct', async () => { await expect( executeTxn.execute({} as unknown as ExecuteTxnParams), - ).rejects.toThrow(InvalidParamsError); + ).rejects.toThrow(InvalidRequestParamsError); }); }); diff --git a/packages/starknet-snap/src/rpcs/executeTxn.ts b/packages/starknet-snap/src/rpcs/executeTxn.ts index 3b89ff93..0da48cce 100644 --- a/packages/starknet-snap/src/rpcs/executeTxn.ts +++ b/packages/starknet-snap/src/rpcs/executeTxn.ts @@ -1,11 +1,5 @@ import type { Component, Json } from '@metamask/snaps-sdk'; -import { - heading, - row, - UserRejectedRequestError, - text, - divider, -} from '@metamask/snaps-sdk'; +import { heading, row, text, divider } from '@metamask/snaps-sdk'; import convert from 'ethereum-unit-converter'; import type { Call, Calldata } from 'starknet'; import { constants, TransactionStatus, TransactionType } from 'starknet'; @@ -27,6 +21,7 @@ import { CallsStruct, mapDeprecatedParams, } from '../utils'; +import { UserRejectedOpError } from '../utils/exceptions'; import { logger } from '../utils/logger'; import { createAccount, @@ -140,7 +135,7 @@ export class ExecuteTxnRpc extends AccountRpcController< version, )) ) { - throw new UserRejectedRequestError() as unknown as Error; + throw new UserRejectedOpError() as unknown as Error; } if (!accountDeployed) { diff --git a/packages/starknet-snap/src/rpcs/sign-declare-transaction.test.ts b/packages/starknet-snap/src/rpcs/sign-declare-transaction.test.ts index 5cb8f1ee..73defc9b 100644 --- a/packages/starknet-snap/src/rpcs/sign-declare-transaction.test.ts +++ b/packages/starknet-snap/src/rpcs/sign-declare-transaction.test.ts @@ -1,13 +1,13 @@ -import { - InvalidParamsError, - UserRejectedRequestError, -} from '@metamask/snaps-sdk'; import type { DeclareSignerDetails } from 'starknet'; import { constants } from 'starknet'; import type { SnapState } from '../types/snapState'; import { toJson } from '../utils'; import { STARKNET_SEPOLIA_TESTNET_NETWORK } from '../utils/constants'; +import { + UserRejectedOpError, + InvalidRequestParamsError, +} from '../utils/exceptions'; import * as starknetUtils from '../utils/starknetUtils'; import { mockAccount, @@ -108,7 +108,7 @@ describe('signDeclareTransaction', () => { ]); }); - it('throws `UserRejectedRequestError` if user denied the operation', async () => { + it('throws `UserRejectedOpError` if user denied the operation', async () => { const chainId = constants.StarknetChainId.SN_SEPOLIA; const account = await mockAccount(chainId); @@ -120,15 +120,15 @@ describe('signDeclareTransaction', () => { const request = createRequest(chainId, account.address); await expect(signDeclareTransaction.execute(request)).rejects.toThrow( - UserRejectedRequestError, + UserRejectedOpError, ); }); - it('throws `InvalidParamsError` when request parameter is not correct', async () => { + it('throws `InvalidRequestParamsError` when request parameter is not correct', async () => { await expect( signDeclareTransaction.execute( {} as unknown as SignDeclareTransactionParams, ), - ).rejects.toThrow(InvalidParamsError); + ).rejects.toThrow(InvalidRequestParamsError); }); }); diff --git a/packages/starknet-snap/src/rpcs/sign-declare-transaction.ts b/packages/starknet-snap/src/rpcs/sign-declare-transaction.ts index f9f784a4..9a9d92e1 100644 --- a/packages/starknet-snap/src/rpcs/sign-declare-transaction.ts +++ b/packages/starknet-snap/src/rpcs/sign-declare-transaction.ts @@ -1,10 +1,5 @@ import type { Component } from '@metamask/snaps-sdk'; -import { - heading, - row, - text, - UserRejectedRequestError, -} from '@metamask/snaps-sdk'; +import { heading, row, text } from '@metamask/snaps-sdk'; import type { DeclareSignerDetails } from 'starknet'; import type { Infer } from 'superstruct'; import { array, object, string, assign } from 'superstruct'; @@ -18,6 +13,7 @@ import { DeclareSignDetailsStruct, mapDeprecatedParams, } from '../utils'; +import { UserRejectedOpError } from '../utils/exceptions'; import { signDeclareTransaction as signDeclareTransactionUtil } from '../utils/starknetUtils'; export const SignDeclareTransactionRequestStruct = assign( @@ -87,7 +83,7 @@ export class SignDeclareTransactionRpc extends AccountRpcController< ): Promise { const { details } = params; if (!(await this.getSignDeclareTransactionConsensus(details))) { - throw new UserRejectedRequestError() as unknown as Error; + throw new UserRejectedOpError() as unknown as Error; } return (await signDeclareTransactionUtil( diff --git a/packages/starknet-snap/src/rpcs/signMessage.test.ts b/packages/starknet-snap/src/rpcs/signMessage.test.ts index b44a66cd..50b03727 100644 --- a/packages/starknet-snap/src/rpcs/signMessage.test.ts +++ b/packages/starknet-snap/src/rpcs/signMessage.test.ts @@ -1,13 +1,13 @@ -import { - InvalidParamsError, - UserRejectedRequestError, -} from '@metamask/snaps-sdk'; import { constants } from 'starknet'; import typedDataExample from '../__tests__/fixture/typedDataExample.json'; import type { SnapState } from '../types/snapState'; import { toJson } from '../utils'; import { STARKNET_SEPOLIA_TESTNET_NETWORK } from '../utils/constants'; +import { + UserRejectedOpError, + InvalidRequestParamsError, +} from '../utils/exceptions'; import * as starknetUtils from '../utils/starknetUtils'; import { mockAccount, @@ -89,7 +89,7 @@ describe('signMessage', () => { ]); }); - it('throws `UserRejectedRequestError` if user denied the operation', async () => { + it('throws `UserRejectedOpError` if user denied the operation', async () => { const account = await mockAccount(constants.StarknetChainId.SN_SEPOLIA); prepareMockAccount(account, state); @@ -105,13 +105,13 @@ describe('signMessage', () => { }; await expect(signMessage.execute(request)).rejects.toThrow( - UserRejectedRequestError, + UserRejectedOpError, ); }); - it('throws `InvalidParamsError` when request parameter is not correct', async () => { + it('throws `InvalidRequestParamsError` when request parameter is not correct', async () => { await expect( signMessage.execute({} as unknown as SignMessageParams), - ).rejects.toThrow(InvalidParamsError); + ).rejects.toThrow(InvalidRequestParamsError); }); }); diff --git a/packages/starknet-snap/src/rpcs/signMessage.ts b/packages/starknet-snap/src/rpcs/signMessage.ts index 896bcfed..79d8d692 100644 --- a/packages/starknet-snap/src/rpcs/signMessage.ts +++ b/packages/starknet-snap/src/rpcs/signMessage.ts @@ -1,10 +1,5 @@ import type { Component } from '@metamask/snaps-sdk'; -import { - heading, - row, - text, - UserRejectedRequestError, -} from '@metamask/snaps-sdk'; +import { heading, row, text } from '@metamask/snaps-sdk'; import type { Infer } from 'superstruct'; import { array, object, string, assign } from 'superstruct'; @@ -18,6 +13,7 @@ import { AccountRpcController, mapDeprecatedParams, } from '../utils'; +import { UserRejectedOpError } from '../utils/exceptions'; import { signMessage as signMessageUtil } from '../utils/starknetUtils'; export const SignMessageRequestStruct = assign( @@ -84,7 +80,7 @@ export class SignMessageRpc extends AccountRpcController< enableAuthorize && !(await this.getSignMessageConsensus(typedDataMessage, address)) ) { - throw new UserRejectedRequestError() as unknown as Error; + throw new UserRejectedOpError() as unknown as Error; } return await signMessageUtil( diff --git a/packages/starknet-snap/src/rpcs/signTransaction.test.ts b/packages/starknet-snap/src/rpcs/signTransaction.test.ts index 2c7e1e14..d4f37ec2 100644 --- a/packages/starknet-snap/src/rpcs/signTransaction.test.ts +++ b/packages/starknet-snap/src/rpcs/signTransaction.test.ts @@ -1,7 +1,3 @@ -import { - InvalidParamsError, - UserRejectedRequestError, -} from '@metamask/snaps-sdk'; import type { InvocationsSignerDetails } from 'starknet'; import { constants } from 'starknet'; @@ -9,6 +5,10 @@ import transactionExample from '../__tests__/fixture/transactionExample.json'; / import type { SnapState } from '../types/snapState'; import { toJson } from '../utils'; import { STARKNET_SEPOLIA_TESTNET_NETWORK } from '../utils/constants'; +import { + UserRejectedOpError, + InvalidRequestParamsError, +} from '../utils/exceptions'; import * as starknetUtils from '../utils/starknetUtils'; import { mockAccount, @@ -118,7 +118,7 @@ describe('signTransaction', () => { expect(confirmDialogSpy).not.toHaveBeenCalled(); }); - it('throws `UserRejectedRequestError` if user denied the operation', async () => { + it('throws `UserRejectedOpError` if user denied the operation', async () => { const chainId = constants.StarknetChainId.SN_SEPOLIA; const account = await mockAccount(chainId); prepareMockAccount(account, state); @@ -127,13 +127,13 @@ describe('signTransaction', () => { const request = createRequestParam(chainId, account.address, true); await expect(signTransaction.execute(request)).rejects.toThrow( - UserRejectedRequestError, + UserRejectedOpError, ); }); - it('throws `InvalidParamsError` when request parameter is not correct', async () => { + it('throws `InvalidRequestParamsError` when request parameter is not correct', async () => { await expect( signTransaction.execute({} as unknown as SignTransactionParams), - ).rejects.toThrow(InvalidParamsError); + ).rejects.toThrow(InvalidRequestParamsError); }); }); diff --git a/packages/starknet-snap/src/rpcs/signTransaction.ts b/packages/starknet-snap/src/rpcs/signTransaction.ts index 6f66350e..592ec8e9 100644 --- a/packages/starknet-snap/src/rpcs/signTransaction.ts +++ b/packages/starknet-snap/src/rpcs/signTransaction.ts @@ -1,10 +1,5 @@ import type { DialogResult } from '@metamask/snaps-sdk'; -import { - heading, - row, - text, - UserRejectedRequestError, -} from '@metamask/snaps-sdk'; +import { heading, row, text } from '@metamask/snaps-sdk'; import type { Call, InvocationsSignerDetails } from 'starknet'; import type { Infer } from 'superstruct'; import { array, object, string, assign, any } from 'superstruct'; @@ -19,6 +14,7 @@ import { toJson, mapDeprecatedParams, } from '../utils'; +import { UserRejectedOpError } from '../utils/exceptions'; import { signTransactions } from '../utils/starknetUtils'; export const SignTransactionRequestStruct = assign( @@ -93,7 +89,7 @@ export class SignTransactionRpc extends AccountRpcController< transactions as unknown as Call[], )) ) { - throw new UserRejectedRequestError() as unknown as Error; + throw new UserRejectedOpError() as unknown as Error; } return (await signTransactions( diff --git a/packages/starknet-snap/src/rpcs/verify-signature.test.ts b/packages/starknet-snap/src/rpcs/verify-signature.test.ts index e69c4327..037da54b 100644 --- a/packages/starknet-snap/src/rpcs/verify-signature.test.ts +++ b/packages/starknet-snap/src/rpcs/verify-signature.test.ts @@ -1,9 +1,9 @@ -import { InvalidParamsError } from '@metamask/snaps-sdk'; import { constants } from 'starknet'; import typedDataExample from '../__tests__/fixture/typedDataExample.json'; import type { SnapState } from '../types/snapState'; import { STARKNET_SEPOLIA_TESTNET_NETWORK } from '../utils/constants'; +import { InvalidRequestParamsError } from '../utils/exceptions'; import * as starknetUtils from '../utils/starknetUtils'; import { mockAccount, prepareMockAccount } from './__tests__/helper'; import { verifySignature } from './verify-signature'; @@ -67,9 +67,9 @@ describe('verifySignature', () => { expect(result).toBe(false); }); - it('throws `InvalidParamsError` when request parameter is not correct', async () => { + it('throws `InvalidRequestParamsError` when request parameter is not correct', async () => { await expect( verifySignature.execute({} as unknown as VerifySignatureParams), - ).rejects.toThrow(InvalidParamsError); + ).rejects.toThrow(InvalidRequestParamsError); }); }); diff --git a/packages/starknet-snap/src/utils/error.test.ts b/packages/starknet-snap/src/utils/error.test.ts index 140bb608..1b23fb54 100644 --- a/packages/starknet-snap/src/utils/error.test.ts +++ b/packages/starknet-snap/src/utils/error.test.ts @@ -18,7 +18,17 @@ import { SnapError, } from '@metamask/snaps-sdk'; -import { isSnapRpcError } from './error'; +import { + createWalletRpcErrorWrapper, + isSnapRpcError, + WalletRpcErrorCode, +} from './error'; +import { + InvalidNetworkError, + UnknownError, + UserRejectedOpError, + InvalidRequestParamsError, +} from './exceptions'; describe('isSnapRpcError', () => { it('returns true for a Snap RPC error', () => { @@ -42,7 +52,14 @@ describe('isSnapRpcError', () => { LimitExceededError, ]; - for (const ErrorCtor of snapErrors) { + const customSnapErrors = [ + InvalidNetworkError, + UserRejectedOpError, + UnknownError, + InvalidRequestParamsError, + ]; + + for (const ErrorCtor of [...snapErrors, ...customSnapErrors]) { const error = new ErrorCtor('snap error message'); expect(isSnapRpcError(error)).toBe(true); } @@ -53,3 +70,19 @@ describe('isSnapRpcError', () => { expect(isSnapRpcError(error)).toBe(false); }); }); + +describe('createWalletRpcErrorWrapper', () => { + it('returns a serialized SnapError', () => { + const wrapper = createWalletRpcErrorWrapper( + WalletRpcErrorCode.InvalidRequest, + { someData: 'data' }, + ); + + expect(wrapper).toStrictEqual({ + walletRpcError: { + someData: 'data', + code: WalletRpcErrorCode.InvalidRequest, + }, + }); + }); +}); diff --git a/packages/starknet-snap/src/utils/error.ts b/packages/starknet-snap/src/utils/error.ts index 2bdfebcf..d9feafee 100644 --- a/packages/starknet-snap/src/utils/error.ts +++ b/packages/starknet-snap/src/utils/error.ts @@ -1,3 +1,4 @@ +import type { Json } from '@metamask/snaps-sdk'; import { MethodNotFoundError, UserRejectedRequestError, @@ -46,3 +47,31 @@ export function isSnapRpcError(error: Error): boolean { ]; return errors.some((errType) => error instanceof errType); } + +// The error code is following the Starknet Wallet RPC 0.7.2 specification. +export enum WalletRpcErrorCode { + InvalidErc20 = 111, + InvalidNetwork = 112, + UserDeny = 113, + InvalidRequest = 114, + AccountAlreadyDeployed = 115, + Unknown = 163, +} + +/** + * Creates a wallet RPC error wrapper for custom snap error. + * + * @param code - The `WalletRpcErrorCode` error code. + * @param [data] - The error data. + */ +export function createWalletRpcErrorWrapper( + code: WalletRpcErrorCode, + data?: Record, +) { + return { + walletRpcError: { + ...data, + code, + }, + }; +} diff --git a/packages/starknet-snap/src/utils/exceptions.ts b/packages/starknet-snap/src/utils/exceptions.ts index d91b7d14..7c8ffe8e 100644 --- a/packages/starknet-snap/src/utils/exceptions.ts +++ b/packages/starknet-snap/src/utils/exceptions.ts @@ -1,4 +1,10 @@ -import { SnapError } from '@metamask/snaps-sdk'; +import { + InvalidParamsError, + SnapError, + UserRejectedRequestError, +} from '@metamask/snaps-sdk'; + +import { createWalletRpcErrorWrapper, WalletRpcErrorCode } from './error'; // Extend SnapError to allow error message visible to client export class UpgradeRequiredError extends SnapError { @@ -15,3 +21,36 @@ export class DeployRequiredError extends SnapError { ); } } + +export class InvalidNetworkError extends SnapError { + constructor(message?: string) { + super( + message ?? 'Network not Supported', + createWalletRpcErrorWrapper(WalletRpcErrorCode.InvalidNetwork), + ); + } +} + +export class UserRejectedOpError extends UserRejectedRequestError { + constructor(message?: string) { + super(message, createWalletRpcErrorWrapper(WalletRpcErrorCode.UserDeny)); + } +} + +export class InvalidRequestParamsError extends InvalidParamsError { + constructor(message?: string) { + super( + message, + createWalletRpcErrorWrapper(WalletRpcErrorCode.InvalidRequest), + ); + } +} + +export class UnknownError extends SnapError { + constructor(message?: string) { + super( + message ?? 'Unknown Error', + createWalletRpcErrorWrapper(WalletRpcErrorCode.Unknown), + ); + } +} diff --git a/packages/starknet-snap/src/utils/rpc.test.ts b/packages/starknet-snap/src/utils/rpc.test.ts index 38268d9e..d8f01ad7 100644 --- a/packages/starknet-snap/src/utils/rpc.test.ts +++ b/packages/starknet-snap/src/utils/rpc.test.ts @@ -1,4 +1,3 @@ -import { InvalidParamsError, SnapError } from '@metamask/snaps-sdk'; import { constants } from 'starknet'; import { object, string } from 'superstruct'; import type { Struct, Infer } from 'superstruct'; @@ -7,6 +6,7 @@ import type { StarknetAccount } from '../__tests__/helper'; import { generateAccounts } from '../__tests__/helper'; import type { SnapState } from '../types/snapState'; import { STARKNET_SEPOLIA_TESTNET_NETWORK } from './constants'; +import { InvalidRequestParamsError, UnknownError } from './exceptions'; import { AccountRpcController, RpcController, @@ -37,14 +37,14 @@ describe('validateRequest', () => { ).not.toThrow(); }); - it('throws `InvalidParamsError` if the request is invalid', () => { + it('throws `InvalidRequestParamsError` if the request is invalid', () => { const requestParams = { signerAddress: 1234, }; expect(() => validateRequest(requestParams, validateStruct as unknown as Struct), - ).toThrow(InvalidParamsError); + ).toThrow(InvalidRequestParamsError); }); }); @@ -62,7 +62,7 @@ describe('validateResponse', () => { expect(() => validateResponse(response, validateStruct as unknown as Struct), - ).toThrow(new SnapError('Invalid Response')); + ).toThrow(new UnknownError('Invalid Response')); }); }); diff --git a/packages/starknet-snap/src/utils/rpc.ts b/packages/starknet-snap/src/utils/rpc.ts index b4a8809e..70571a92 100644 --- a/packages/starknet-snap/src/utils/rpc.ts +++ b/packages/starknet-snap/src/utils/rpc.ts @@ -1,10 +1,10 @@ import type { getBIP44ChangePathString } from '@metamask/key-tree/dist/types/utils'; import type { Json } from '@metamask/snaps-sdk'; -import { InvalidParamsError, SnapError } from '@metamask/snaps-sdk'; import type { Struct } from 'superstruct'; import { assert } from 'superstruct'; import type { Network, SnapState } from '../types/snapState'; +import { InvalidRequestParamsError, UnknownError } from './exceptions'; import { logger } from './logger'; import { getBip44Deriver, getStateData } from './snap'; import { @@ -19,13 +19,13 @@ import { getKeysFromAddress } from './starknetUtils'; * @template Params - The expected structure of the request parameters. * @param requestParams - The request parameters to validate. * @param struct - The expected structure of the request parameters. - * @throws {InvalidParamsError} If the request parameters do not conform to the expected structure. + * @throws {InvalidRequestParamsError} If the request parameters do not conform to the expected structure. */ export function validateRequest(requestParams: Params, struct: Struct) { try { assert(requestParams, struct); } catch (error) { - throw new InvalidParamsError(error.message) as unknown as Error; + throw new InvalidRequestParamsError(error.message) as unknown as Error; } } @@ -41,7 +41,7 @@ export function validateResponse(response: Params, struct: Struct) { try { assert(response, struct); } catch (error) { - throw new SnapError('Invalid Response') as unknown as Error; + throw new UnknownError('Invalid Response') as unknown as Error; } } From d0384bf9c9476c2168586cf7dc48fe6adb965bcb Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 10 Oct 2024 18:55:11 +0800 Subject: [PATCH 13/50] refactor(SwitchNetwork): revamp RPC `starkNet_switchNetwork` (#368) * chore: revamp switch network * chore: remove legacy code for switch network * fix: util `getCurrentNetwork ` * chore: update network state mgr with default network config * chore: lint fix * chore: lint fix * chore: rebase * chore: update comment * chore: update comment * chore: use new error format * chore: rollback snapstate change --------- Co-authored-by: khanti42 --- packages/starknet-snap/src/index.ts | 7 +- packages/starknet-snap/src/rpcs/index.ts | 1 + .../src/rpcs/switch-network.test.ts | 187 ++++++++++++++++++ .../starknet-snap/src/rpcs/switch-network.ts | 126 ++++++++++++ packages/starknet-snap/src/switchNetwork.ts | 52 ----- packages/starknet-snap/src/utils/rpc.ts | 2 +- .../test/src/switchNetwork.test.ts | 109 ---------- 7 files changed, 320 insertions(+), 164 deletions(-) create mode 100644 packages/starknet-snap/src/rpcs/switch-network.test.ts create mode 100644 packages/starknet-snap/src/rpcs/switch-network.ts delete mode 100644 packages/starknet-snap/src/switchNetwork.ts delete mode 100644 packages/starknet-snap/test/src/switchNetwork.test.ts diff --git a/packages/starknet-snap/src/index.ts b/packages/starknet-snap/src/index.ts index 46859407..724b0f9c 100644 --- a/packages/starknet-snap/src/index.ts +++ b/packages/starknet-snap/src/index.ts @@ -34,6 +34,7 @@ import type { SignTransactionParams, SignDeclareTransactionParams, VerifySignatureParams, + SwitchNetworkParams, } from './rpcs'; import { displayPrivateKey, @@ -43,10 +44,10 @@ import { signTransaction, signDeclareTransaction, verifySignature, + switchNetwork, } from './rpcs'; import { sendTransaction } from './sendTransaction'; import { signDeployAccountTransaction } from './signDeployAccountTransaction'; -import { switchNetwork } from './switchNetwork'; import type { ApiParams, ApiParamsWithKeyDeriver, @@ -230,7 +231,9 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { return await addNetwork(apiParams); case 'starkNet_switchNetwork': - return await switchNetwork(apiParams); + return await switchNetwork.execute( + apiParams.requestParams as unknown as SwitchNetworkParams, + ); case 'starkNet_getCurrentNetwork': return await getCurrentNetwork(apiParams); diff --git a/packages/starknet-snap/src/rpcs/index.ts b/packages/starknet-snap/src/rpcs/index.ts index e3bc2fb0..13140791 100644 --- a/packages/starknet-snap/src/rpcs/index.ts +++ b/packages/starknet-snap/src/rpcs/index.ts @@ -5,3 +5,4 @@ export * from './signMessage'; export * from './signTransaction'; export * from './sign-declare-transaction'; export * from './verify-signature'; +export * from './switch-network'; diff --git a/packages/starknet-snap/src/rpcs/switch-network.test.ts b/packages/starknet-snap/src/rpcs/switch-network.test.ts new file mode 100644 index 00000000..5058a6c0 --- /dev/null +++ b/packages/starknet-snap/src/rpcs/switch-network.test.ts @@ -0,0 +1,187 @@ +import type { constants } from 'starknet'; + +import { Config } from '../config'; +import { NetworkStateManager } from '../state/network-state-manager'; +import type { Network } from '../types/snapState'; +import { + STARKNET_SEPOLIA_TESTNET_NETWORK, + STARKNET_MAINNET_NETWORK, +} from '../utils/constants'; +import { + InvalidNetworkError, + InvalidRequestParamsError, + UserRejectedOpError, +} from '../utils/exceptions'; +import { prepareConfirmDialog } from './__tests__/helper'; +import { switchNetwork } from './switch-network'; +import type { SwitchNetworkParams } from './switch-network'; + +jest.mock('../utils/logger'); + +describe('switchNetwork', () => { + const createRequestParam = ( + chainId: constants.StarknetChainId | string, + enableAuthorize?: boolean, + ): SwitchNetworkParams => { + const request: SwitchNetworkParams = { + chainId: chainId as constants.StarknetChainId, + }; + if (enableAuthorize) { + request.enableAuthorize = enableAuthorize; + } + return request; + }; + + const mockNetworkStateManager = ({ + network = STARKNET_SEPOLIA_TESTNET_NETWORK, + currentNetwork = STARKNET_MAINNET_NETWORK, + }: { + network?: Network | null; + currentNetwork?: Network; + }) => { + const txStateSpy = jest.spyOn( + NetworkStateManager.prototype, + 'withTransaction', + ); + const getNetworkSpy = jest.spyOn( + NetworkStateManager.prototype, + 'getNetwork', + ); + const setCurrentNetworkSpy = jest.spyOn( + NetworkStateManager.prototype, + 'setCurrentNetwork', + ); + const getCurrentNetworkSpy = jest.spyOn( + NetworkStateManager.prototype, + 'getCurrentNetwork', + ); + + getNetworkSpy.mockResolvedValue(network); + getCurrentNetworkSpy.mockResolvedValue(currentNetwork); + txStateSpy.mockImplementation(async (fn) => { + return await fn({ + accContracts: [], + erc20Tokens: [], + networks: Config.availableNetworks, + transactions: [], + }); + }); + + return { getNetworkSpy, setCurrentNetworkSpy, getCurrentNetworkSpy }; + }; + + it('switchs a network correctly', async () => { + const currentNetwork = STARKNET_MAINNET_NETWORK; + const requestNetwork = STARKNET_SEPOLIA_TESTNET_NETWORK; + const { getNetworkSpy, setCurrentNetworkSpy, getCurrentNetworkSpy } = + mockNetworkStateManager({ + currentNetwork, + network: requestNetwork, + }); + const request = createRequestParam(requestNetwork.chainId); + + const result = await switchNetwork.execute(request); + + expect(result).toBe(true); + expect(getCurrentNetworkSpy).toHaveBeenCalled(); + expect(getNetworkSpy).toHaveBeenCalledWith( + { chainId: requestNetwork.chainId }, + expect.anything(), + ); + expect(setCurrentNetworkSpy).toHaveBeenCalledWith(requestNetwork); + }); + + it('returns `true` if the request chainId is the same with current network', async () => { + const currentNetwork = STARKNET_SEPOLIA_TESTNET_NETWORK; + const requestNetwork = STARKNET_SEPOLIA_TESTNET_NETWORK; + const { getNetworkSpy, setCurrentNetworkSpy, getCurrentNetworkSpy } = + mockNetworkStateManager({ + currentNetwork, + network: requestNetwork, + }); + const request = createRequestParam(requestNetwork.chainId); + + const result = await switchNetwork.execute(request); + + expect(result).toBe(true); + expect(getCurrentNetworkSpy).toHaveBeenCalled(); + expect(getNetworkSpy).not.toHaveBeenCalled(); + expect(setCurrentNetworkSpy).not.toHaveBeenCalled(); + }); + + it('renders confirmation dialog', async () => { + const currentNetwork = STARKNET_MAINNET_NETWORK; + const requestNetwork = STARKNET_SEPOLIA_TESTNET_NETWORK; + mockNetworkStateManager({ + currentNetwork, + network: requestNetwork, + }); + const { confirmDialogSpy } = prepareConfirmDialog(); + const request = createRequestParam(requestNetwork.chainId, true); + + await switchNetwork.execute(request); + + expect(confirmDialogSpy).toHaveBeenCalledWith([ + { type: 'heading', value: 'Do you want to switch to this network?' }, + { + type: 'row', + label: 'Chain Name', + value: { + value: requestNetwork.name, + markdown: false, + type: 'text', + }, + }, + { + type: 'divider', + }, + { + type: 'row', + label: 'Chain ID', + value: { + value: requestNetwork.chainId, + markdown: false, + type: 'text', + }, + }, + ]); + }); + + it('throws `UserRejectedRequestError` if user denied the operation', async () => { + const currentNetwork = STARKNET_MAINNET_NETWORK; + const requestNetwork = STARKNET_SEPOLIA_TESTNET_NETWORK; + mockNetworkStateManager({ + currentNetwork, + network: requestNetwork, + }); + const { confirmDialogSpy } = prepareConfirmDialog(); + confirmDialogSpy.mockResolvedValue(false); + const request = createRequestParam(requestNetwork.chainId, true); + + await expect(switchNetwork.execute(request)).rejects.toThrow( + UserRejectedOpError, + ); + }); + + it('throws `Network not supported` error if the request network is not support', async () => { + const currentNetwork = STARKNET_MAINNET_NETWORK; + const requestNetwork = STARKNET_SEPOLIA_TESTNET_NETWORK; + // Mock the network state manager to return null network + // even if the request chain id is not block by the superstruct + mockNetworkStateManager({ + currentNetwork, + network: null, + }); + const request = createRequestParam(requestNetwork.chainId); + + await expect(switchNetwork.execute(request)).rejects.toThrow( + InvalidNetworkError, + ); + }); + + it('throws `InvalidRequestParamsError` when request parameter is not correct', async () => { + await expect( + switchNetwork.execute({} as unknown as SwitchNetworkParams), + ).rejects.toThrow(InvalidRequestParamsError); + }); +}); diff --git a/packages/starknet-snap/src/rpcs/switch-network.ts b/packages/starknet-snap/src/rpcs/switch-network.ts new file mode 100644 index 00000000..61bc0fbf --- /dev/null +++ b/packages/starknet-snap/src/rpcs/switch-network.ts @@ -0,0 +1,126 @@ +import type { Component } from '@metamask/snaps-sdk'; +import { divider, heading, row, text } from '@metamask/snaps-sdk'; +import type { Infer } from 'superstruct'; +import { assign, boolean } from 'superstruct'; + +import { NetworkStateManager } from '../state/network-state-manager'; +import { + confirmDialog, + AuthorizableStruct, + BaseRequestStruct, + RpcController, +} from '../utils'; +import { InvalidNetworkError, UserRejectedOpError } from '../utils/exceptions'; + +export const SwitchNetworkRequestStruct = assign( + AuthorizableStruct, + BaseRequestStruct, +); + +export const SwitchNetworkResponseStruct = boolean(); + +export type SwitchNetworkParams = Infer; + +export type SwitchNetworkResponse = Infer; + +/** + * The RPC handler to switch the network. + */ +export class SwitchNetworkRpc extends RpcController< + SwitchNetworkParams, + SwitchNetworkResponse +> { + protected requestStruct = SwitchNetworkRequestStruct; + + protected responseStruct = SwitchNetworkResponseStruct; + + /** + * Execute the switching network request handler. + * It switch to a supported network based on the chain id. + * It will show a confirmation dialog to the user before switching a network. + * + * @param params - The parameters of the request. + * @param [params.enableAuthorize] - Optional, a flag to enable or display the confirmation dialog to the user. + * @param params.chainId - The chain id of the network to switch. + * @returns the response of the switching a network in boolean. + * @throws {UserRejectedRequestError} If the user rejects the request. + * @throws {Error} If the network with the chain id is not supported. + */ + async execute(params: SwitchNetworkParams): Promise { + return super.execute(params); + } + + protected async handleRequest( + params: SwitchNetworkParams, + ): Promise { + const { enableAuthorize, chainId } = params; + const networkStateMgr = new NetworkStateManager(); + + // Using transactional state interaction to ensure that the state is updated atomically + // To avoid a use case while 2 requests are trying to update/read the state at the same time + return await networkStateMgr.withTransaction(async (state) => { + const currentNetwork = await networkStateMgr.getCurrentNetwork(state); + + // Return early if the current network is the same as the requested network + if (currentNetwork.chainId === chainId) { + return true; + } + + const network = await networkStateMgr.getNetwork( + { + chainId, + }, + state, + ); + + // if the network is not in the list of networks that we support, we throw an error + if (!network) { + throw new InvalidNetworkError() as unknown as Error; + } + + if ( + // Get Starknet expected show the confirm dialog, while the companion doesnt needed, + // therefore, `enableAuthorize` is to enable/disable the confirmation + enableAuthorize && + !(await this.getSwitchNetworkConsensus(network.name, network.chainId)) + ) { + throw new UserRejectedOpError() as unknown as Error; + } + + await networkStateMgr.setCurrentNetwork(network); + + return true; + }); + } + + protected async getSwitchNetworkConsensus( + networkName: string, + networkChainId: string, + ) { + const components: Component[] = []; + components.push(heading('Do you want to switch to this network?')); + components.push( + row( + 'Chain Name', + text({ + value: networkName, + markdown: false, + }), + ), + ); + components.push(divider()); + components.push( + row( + 'Chain ID', + text({ + value: networkChainId, + markdown: false, + }), + ), + ); + + return await confirmDialog(components); + } +} + +export const switchNetwork = new SwitchNetworkRpc(); diff --git a/packages/starknet-snap/src/switchNetwork.ts b/packages/starknet-snap/src/switchNetwork.ts deleted file mode 100644 index 258d51fa..00000000 --- a/packages/starknet-snap/src/switchNetwork.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { panel, heading, DialogType } from '@metamask/snaps-sdk'; - -import type { ApiParams, SwitchNetworkRequestParams } from './types/snapApi'; -import { logger } from './utils/logger'; -import { toJson } from './utils/serializer'; -import { - getNetwork, - setCurrentNetwork, - getNetworkTxt, -} from './utils/snapUtils'; - -/** - * - * @param params - */ -export async function switchNetwork(params: ApiParams) { - try { - const { state, wallet, saveMutex, requestParams } = params; - const requestParamsObj = requestParams as SwitchNetworkRequestParams; - const network = getNetwork(state, requestParamsObj.chainId); - if (!network) { - throw new Error( - `The given chainId is invalid: ${requestParamsObj.chainId}`, - ); - } - const components = getNetworkTxt(network); - - if (requestParamsObj.enableAuthorize) { - const response = await wallet.request({ - method: 'snap_dialog', - params: { - type: DialogType.Confirmation, - content: panel([ - heading('Do you want to switch to this network?'), - ...components, - ]), - }, - }); - if (!response) { - return false; - } - } - - logger.log(`switchNetwork: network:\n${toJson(network, 2)}`); - await setCurrentNetwork(network, wallet, saveMutex, state); - - return true; - } catch (error) { - logger.error(`Problem found:`, error); - throw error; - } -} diff --git a/packages/starknet-snap/src/utils/rpc.ts b/packages/starknet-snap/src/utils/rpc.ts index 70571a92..4d88e347 100644 --- a/packages/starknet-snap/src/utils/rpc.ts +++ b/packages/starknet-snap/src/utils/rpc.ts @@ -86,7 +86,7 @@ export abstract class RpcController< } // TODO: the Type should be moved to a common place -export type AccountRpcParams = Json & { +export type AccountRpcParams = { chainId: string; address: string; }; diff --git a/packages/starknet-snap/test/src/switchNetwork.test.ts b/packages/starknet-snap/test/src/switchNetwork.test.ts deleted file mode 100644 index 2678911b..00000000 --- a/packages/starknet-snap/test/src/switchNetwork.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import chai, { expect } from 'chai'; -import sinon from 'sinon'; -import sinonChai from 'sinon-chai'; -import { WalletMock } from '../wallet.mock.test'; -import { SnapState } from '../../src/types/snapState'; -import * as snapUtils from '../../src/utils/snapUtils'; -import { - STARKNET_MAINNET_NETWORK, - STARKNET_SEPOLIA_TESTNET_NETWORK, -} from '../../src/utils/constants'; -import { Mutex } from 'async-mutex'; -import { SwitchNetworkRequestParams, ApiParams } from '../../src/types/snapApi'; -import { switchNetwork } from '../../src/switchNetwork'; - -chai.use(sinonChai); -const sandbox = sinon.createSandbox(); - -describe('Test function: switchNetwork', function () { - const walletStub = new WalletMock(); - const state: SnapState = { - accContracts: [], - erc20Tokens: [], - networks: [STARKNET_MAINNET_NETWORK, STARKNET_SEPOLIA_TESTNET_NETWORK], - transactions: [], - currentNetwork: STARKNET_SEPOLIA_TESTNET_NETWORK, - }; - const apiParams: ApiParams = { - state, - requestParams: {}, - wallet: walletStub, - saveMutex: new Mutex(), - }; - let stateStub: sinon.SinonStub; - let dialogStub: sinon.SinonStub; - beforeEach(function () { - stateStub = walletStub.rpcStubs.snap_manageState; - dialogStub = walletStub.rpcStubs.snap_dialog; - stateStub.resolves(state); - dialogStub.resolves(true); - }); - - afterEach(function () { - walletStub.reset(); - sandbox.restore(); - }); - - it('should switch the network correctly', async function () { - const requestObject: SwitchNetworkRequestParams = { - chainId: STARKNET_MAINNET_NETWORK.chainId, - enableAuthorize: true, - }; - apiParams.requestParams = requestObject; - const result = await switchNetwork(apiParams); - expect(result).to.be.eql(true); - expect(stateStub).to.be.calledOnce; - expect(dialogStub).to.be.calledOnce; - expect(state.currentNetwork).to.be.eql(STARKNET_MAINNET_NETWORK); - }); - - it('should skip authorize when enableAuthorize is false or omit', async function () { - const requestObject: SwitchNetworkRequestParams = { - chainId: STARKNET_MAINNET_NETWORK.chainId, - }; - apiParams.requestParams = requestObject; - const result = await switchNetwork(apiParams); - expect(result).to.be.eql(true); - expect(stateStub).to.be.calledOnce; - expect(dialogStub).to.be.callCount(0); - expect(state.currentNetwork).to.be.eql(STARKNET_MAINNET_NETWORK); - }); - - it('should throw an error if network not found', async function () { - const requestObject: SwitchNetworkRequestParams = { - chainId: '123', - enableAuthorize: true, - }; - apiParams.requestParams = requestObject; - let result; - try { - await switchNetwork(apiParams); - } catch (err) { - result = err; - } finally { - expect(result).to.be.an('Error'); - expect(stateStub).to.be.callCount(0); - expect(dialogStub).to.be.callCount(0); - expect(state.currentNetwork).to.be.eql(STARKNET_MAINNET_NETWORK); - } - }); - - it('should throw an error if setCurrentNetwork failed', async function () { - sandbox.stub(snapUtils, 'setCurrentNetwork').throws(new Error()); - const requestObject: SwitchNetworkRequestParams = { - chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, - enableAuthorize: true, - }; - apiParams.requestParams = requestObject; - let result; - try { - await switchNetwork(apiParams); - } catch (err) { - result = err; - } finally { - expect(result).to.be.an('Error'); - expect(dialogStub).to.be.callCount(1); - expect(state.currentNetwork).to.be.eql(STARKNET_MAINNET_NETWORK); - } - }); -}); From 508b9584b534bd93235296fd36328fbaaa52334b Mon Sep 17 00:00:00 2001 From: khanti42 Date: Fri, 11 Oct 2024 09:52:55 +0200 Subject: [PATCH 14/50] fix: the RPC starkNet_executeTxn storing incorrect state data if single calls argument was given (#376) --- .../src/__tests__/fixture/callsExamples.json | 20 ++-- .../starknet-snap/src/rpcs/executeTxn.test.ts | 97 +++++++++++++++---- packages/starknet-snap/src/rpcs/executeTxn.ts | 7 +- 3 files changed, 91 insertions(+), 33 deletions(-) diff --git a/packages/starknet-snap/src/__tests__/fixture/callsExamples.json b/packages/starknet-snap/src/__tests__/fixture/callsExamples.json index 0cb14a3c..b05faa17 100644 --- a/packages/starknet-snap/src/__tests__/fixture/callsExamples.json +++ b/packages/starknet-snap/src/__tests__/fixture/callsExamples.json @@ -1,5 +1,5 @@ -[ - { +{ + "multipleCalls": { "calls": [ { "contractAddress": "0x00b28a089e7fb83debee4607b6334d687918644796b47d9e9e38ea8213833137", @@ -14,14 +14,12 @@ }, "hash": "0x042f5e546b2e55eb6b1b735f15fbfbd7621fc01ea7c96dcf87928ac27f054adb" }, - { - "calls": [ - { - "contractAddress": "0x00b28a089e7fb83debee4607b6334d687918644796b47d9e9e38ea8213833137", - "entrypoint": "functionName", - "calldata": ["1", "1"] - } - ], + "singleCall": { + "calls": { + "contractAddress": "0x00b28a089e7fb83debee4607b6334d687918644796b47d9e9e38ea8213833137", + "entrypoint": "functionName", + "calldata": ["1", "1"] + }, "details": { "nonce": "0x2", "version": "0x1", @@ -29,4 +27,4 @@ }, "hash": "0x06385d46da9fbed4a5798298b17df069ac5f786e4c9f8f6b81c665540aea245a" } -] +} diff --git a/packages/starknet-snap/src/rpcs/executeTxn.test.ts b/packages/starknet-snap/src/rpcs/executeTxn.test.ts index 31d2f500..b51c701b 100644 --- a/packages/starknet-snap/src/rpcs/executeTxn.test.ts +++ b/packages/starknet-snap/src/rpcs/executeTxn.test.ts @@ -74,12 +74,15 @@ const prepareMockExecuteTxn = async ( address: account.address, }); + const createInvokeTxnSpy = jest.spyOn(executeTxn as any, 'createInvokeTxn'); + return { network: state.networks[0], account, request, confirmDialogSpy, createAccountSpy, + createInvokeTxnSpy, executeTxnRespMock, executeTxnUtilSpy, getEstimatedFeesSpy, @@ -88,10 +91,8 @@ const prepareMockExecuteTxn = async ( }; describe('ExecuteTxn', () => { - let callsExample: any; - it('executes transaction correctly if the account is deployed', async () => { - callsExample = callsExamples[0]; + const calls = callsExamples.multipleCalls; const { account, createAccountSpy, @@ -100,9 +101,9 @@ describe('ExecuteTxn', () => { getEstimatedFeesRepsMock, request, } = await prepareMockExecuteTxn( - callsExample.hash, - callsExample.calls, - callsExample.details, + calls.hash, + calls.calls, + calls.details, true, ); @@ -116,7 +117,7 @@ describe('ExecuteTxn', () => { request.calls, undefined, { - ...callsExample.details, + ...calls.details, maxFee: getEstimatedFeesRepsMock.suggestedMaxFee, resourceBounds: getEstimatedFeesRepsMock.estimateResults[0].resourceBounds, @@ -126,10 +127,64 @@ describe('ExecuteTxn', () => { expect(createAccountSpy).not.toHaveBeenCalled(); }); + it.each([ + { + calls: callsExamples.multipleCalls, + testCaseTitle: 'an array of call object', + }, + { + calls: callsExamples.singleCall, + testCaseTitle: 'a call object', + }, + ])( + 'stores transaction in state correctly if the params `calls` is $testCaseTitle', + async ({ calls }: { calls: any }) => { + const call = Array.isArray(calls.calls) ? calls.calls[0] : calls.calls; + const { + account, + createAccountSpy, + createInvokeTxnSpy, + executeTxnRespMock, + getEstimatedFeesSpy, + getEstimatedFeesRepsMock, + request, + } = await prepareMockExecuteTxn( + calls.hash, + calls.calls, + calls.details, + true, + ); + + const result = await executeTxn.execute(request); + + expect(result).toStrictEqual(executeTxnRespMock); + expect(executeTxnUtil).toHaveBeenCalledWith( + STARKNET_SEPOLIA_TESTNET_NETWORK, + account.address, + account.privateKey, + request.calls, + undefined, + { + ...calls.details, + maxFee: getEstimatedFeesRepsMock.suggestedMaxFee, + resourceBounds: + getEstimatedFeesRepsMock.estimateResults[0].resourceBounds, + }, + ); + expect(getEstimatedFeesSpy).toHaveBeenCalled(); + expect(createAccountSpy).not.toHaveBeenCalled(); + expect(createInvokeTxnSpy).toHaveBeenCalledWith( + account.address, + calls.hash, + call, + ); + }, + ); + it.each([constants.TRANSACTION_VERSION.V1, constants.TRANSACTION_VERSION.V3])( 'creates an account and execute the transaction with nonce 1 with transaction version %s if the account is not deployed', async (transactionVersion) => { - callsExample = callsExamples[1]; + const calls = callsExamples.multipleCalls; const { account, createAccountSpy, @@ -139,10 +194,10 @@ describe('ExecuteTxn', () => { network, request, } = await prepareMockExecuteTxn( - callsExample.hash, - callsExample.calls, + calls.hash, + calls.calls, { - ...callsExample.details, + ...calls.details, version: transactionVersion, }, false, @@ -165,10 +220,10 @@ describe('ExecuteTxn', () => { network, account.address, account.privateKey, - callsExample.calls, + calls.calls, undefined, { - ...callsExample.details, + ...calls.details, version: transactionVersion, maxFee: getEstimatedFeesRepsMock.suggestedMaxFee, nonce: 1, @@ -180,11 +235,11 @@ describe('ExecuteTxn', () => { ); it('throws UserRejectedOpError if user cancels execution', async () => { - callsExample = callsExamples[1]; + const calls = callsExamples.multipleCalls; const { request, confirmDialogSpy } = await prepareMockExecuteTxn( - callsExample.hash, - callsExample.calls, - callsExample.details, + calls.hash, + calls.calls, + calls.details, true, ); confirmDialogSpy.mockResolvedValue(false); @@ -195,11 +250,11 @@ describe('ExecuteTxn', () => { }); it('throws `Failed to execute transaction` when the transaction hash is not returned from executeTxnUtil', async () => { - callsExample = callsExamples[1]; + const calls = callsExamples.multipleCalls; const { request, executeTxnUtilSpy } = await prepareMockExecuteTxn( - callsExample.hash, - callsExample.calls, - callsExample.details, + calls.hash, + calls.calls, + calls.details, true, ); executeTxnUtilSpy.mockResolvedValue( diff --git a/packages/starknet-snap/src/rpcs/executeTxn.ts b/packages/starknet-snap/src/rpcs/executeTxn.ts index 0da48cce..25dbc2ee 100644 --- a/packages/starknet-snap/src/rpcs/executeTxn.ts +++ b/packages/starknet-snap/src/rpcs/executeTxn.ts @@ -176,8 +176,13 @@ export class ExecuteTxnRpc extends AccountRpcController< throw new Error('Failed to execute transaction'); } + // Since the RPC supports the `calls` parameter either as a single `call` object or an array of `call` objects, + // and the current state data structure does not yet support multiple `call` objects in a single transaction, + // we need to convert `calls` into a single `call` object as a temporary fix. + const call = Array.isArray(calls) ? calls[0] : calls; + await this.txnStateManager.addTransaction( - this.createInvokeTxn(address, executeTxnResp.transaction_hash, calls[0]), + this.createInvokeTxn(address, executeTxnResp.transaction_hash, call), ); return executeTxnResp; From a034bcfb3b60242559e57f7ffbba9a7359444f1f Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 17 Oct 2024 18:02:38 +0800 Subject: [PATCH 15/50] feat: add RPC `starkNet_getDeploymentData` (#381) * feat: add RPC `starkNet_getDeploymentData` * chore: add comment --- packages/starknet-snap/src/index.ts | 7 ++ .../src/rpcs/get-deployment-data.test.ts | 89 ++++++++++++++++++ .../src/rpcs/get-deployment-data.ts | 91 +++++++++++++++++++ packages/starknet-snap/src/rpcs/index.ts | 1 + .../starknet-snap/src/utils/exceptions.ts | 9 ++ 5 files changed, 197 insertions(+) create mode 100644 packages/starknet-snap/src/rpcs/get-deployment-data.test.ts create mode 100644 packages/starknet-snap/src/rpcs/get-deployment-data.ts diff --git a/packages/starknet-snap/src/index.ts b/packages/starknet-snap/src/index.ts index 724b0f9c..14f66798 100644 --- a/packages/starknet-snap/src/index.ts +++ b/packages/starknet-snap/src/index.ts @@ -35,6 +35,7 @@ import type { SignDeclareTransactionParams, VerifySignatureParams, SwitchNetworkParams, + GetDeploymentDataParams, } from './rpcs'; import { displayPrivateKey, @@ -45,6 +46,7 @@ import { signDeclareTransaction, verifySignature, switchNetwork, + getDeploymentData, } from './rpcs'; import { sendTransaction } from './sendTransaction'; import { signDeployAccountTransaction } from './signDeployAccountTransaction'; @@ -279,6 +281,11 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { case 'starkNet_getStarkName': return await getStarkName(apiParams); + case 'starkNet_getDeploymentData': + return await getDeploymentData.execute( + apiParams as unknown as GetDeploymentDataParams, + ); + default: throw new MethodNotFoundError() as unknown as Error; } diff --git a/packages/starknet-snap/src/rpcs/get-deployment-data.test.ts b/packages/starknet-snap/src/rpcs/get-deployment-data.test.ts new file mode 100644 index 00000000..355acb48 --- /dev/null +++ b/packages/starknet-snap/src/rpcs/get-deployment-data.test.ts @@ -0,0 +1,89 @@ +import { constants } from 'starknet'; + +import type { SnapState } from '../types/snapState'; +import { + ACCOUNT_CLASS_HASH, + CAIRO_VERSION, + STARKNET_SEPOLIA_TESTNET_NETWORK, +} from '../utils/constants'; +import { + InvalidRequestParamsError, + AccountAlreadyDeployedError, +} from '../utils/exceptions'; +import * as starknetUtils from '../utils/starknetUtils'; +import { mockAccount, prepareMockAccount } from './__tests__/helper'; +import type { GetDeploymentDataParams } from './get-deployment-data'; +import { getDeploymentData } from './get-deployment-data'; + +jest.mock('../utils/snap'); +jest.mock('../utils/logger'); + +describe('GetDeploymentDataRpc', () => { + const state: SnapState = { + accContracts: [], + erc20Tokens: [], + networks: [STARKNET_SEPOLIA_TESTNET_NETWORK], + transactions: [], + }; + + const createRequest = ( + chainId: constants.StarknetChainId, + address: string, + ) => ({ + address, + chainId, + }); + + const mockIsAccountDeployed = (deployed: boolean) => { + const spy = jest.spyOn(starknetUtils, 'isAccountDeployed'); + spy.mockResolvedValue(deployed); + return spy; + }; + + const prepareGetDeploymentDataTest = async (deployed: boolean) => { + const chainId = constants.StarknetChainId.SN_SEPOLIA; + const account = await mockAccount(chainId); + prepareMockAccount(account, state); + mockIsAccountDeployed(deployed); + const request = createRequest(chainId, account.address); + + return { + account, + request, + }; + }; + + it('returns the deployment data', async () => { + const { account, request } = await prepareGetDeploymentDataTest(false); + const { address, publicKey } = account; + const expectedResult = { + address, + // eslint-disable-next-line @typescript-eslint/naming-convention + class_hash: ACCOUNT_CLASS_HASH, + salt: publicKey, + calldata: starknetUtils.getDeployAccountCallData( + publicKey, + CAIRO_VERSION, + ), + version: CAIRO_VERSION, + }; + + const result = await getDeploymentData.execute(request); + + expect(result).toStrictEqual(expectedResult); + }); + + it('throws `AccountAlreadyDeployedError` if the account has deployed', async () => { + const { request } = await prepareGetDeploymentDataTest(true); + + await expect(getDeploymentData.execute(request)).rejects.toThrow( + AccountAlreadyDeployedError, + ); + }); + + it('throws `InvalidRequestParamsError` when request parameter is not correct', async () => { + await expect( + getDeploymentData.execute({} as unknown as GetDeploymentDataParams), + ).rejects.toThrow(InvalidRequestParamsError); + }); +}); diff --git a/packages/starknet-snap/src/rpcs/get-deployment-data.ts b/packages/starknet-snap/src/rpcs/get-deployment-data.ts new file mode 100644 index 00000000..8206db31 --- /dev/null +++ b/packages/starknet-snap/src/rpcs/get-deployment-data.ts @@ -0,0 +1,91 @@ +import type { Infer } from 'superstruct'; +import { object, string, assign, array } from 'superstruct'; + +import { + AddressStruct, + BaseRequestStruct, + AccountRpcController, + CairoVersionStruct, +} from '../utils'; +import { ACCOUNT_CLASS_HASH, CAIRO_VERSION } from '../utils/constants'; +import { AccountAlreadyDeployedError } from '../utils/exceptions'; +import { + getDeployAccountCallData, + isAccountDeployed, +} from '../utils/starknetUtils'; + +export const GetDeploymentDataRequestStruct = assign( + object({ + address: AddressStruct, + }), + BaseRequestStruct, +); + +export const GetDeploymentDataResponseStruct = object({ + address: AddressStruct, + // eslint-disable-next-line @typescript-eslint/naming-convention + class_hash: string(), + salt: string(), + calldata: array(string()), + version: CairoVersionStruct, +}); + +export type GetDeploymentDataParams = Infer< + typeof GetDeploymentDataRequestStruct +>; + +export type GetDeploymentDataResponse = Infer< + typeof GetDeploymentDataResponseStruct +>; + +/** + * The RPC handler to get the deployment data. + * + */ +export class GetDeploymentDataRpc extends AccountRpcController< + GetDeploymentDataParams, + GetDeploymentDataResponse +> { + protected requestStruct = GetDeploymentDataRequestStruct; + + protected responseStruct = GetDeploymentDataResponseStruct; + + /** + * Execute the get deployment data request handler. + * + * @param params - The parameters of the request. + * @param params.address - The address of the account. + * @param params.chainId - The chain id of the network. + * @returns A promise that resolve to a `Deployment Data`. + */ + async execute( + params: GetDeploymentDataParams, + ): Promise { + return super.execute(params); + } + + protected async handleRequest( + params: GetDeploymentDataParams, + ): Promise { + const { address } = params; + // Due to AccountRpcController built-in validation, + // if the account required to force deploy (Cairo 0 with balance), it will alert with a warning dialog. + // if the account required to force upgrade (Cairo 0 without balance), it will alert with a warning dialog. + // hence we can safely assume that the account is Cairo 1 account. + if (await isAccountDeployed(this.network, address)) { + throw new AccountAlreadyDeployedError(); + } + + // We only need to take care the deployment data for Cairo 1 account. + return { + address: params.address, + // eslint-disable-next-line @typescript-eslint/naming-convention + class_hash: ACCOUNT_CLASS_HASH, + salt: this.account.publicKey, + calldata: getDeployAccountCallData(this.account.publicKey, CAIRO_VERSION), + version: CAIRO_VERSION, + }; + } +} + +export const getDeploymentData = new GetDeploymentDataRpc(); diff --git a/packages/starknet-snap/src/rpcs/index.ts b/packages/starknet-snap/src/rpcs/index.ts index 13140791..47983696 100644 --- a/packages/starknet-snap/src/rpcs/index.ts +++ b/packages/starknet-snap/src/rpcs/index.ts @@ -6,3 +6,4 @@ export * from './signTransaction'; export * from './sign-declare-transaction'; export * from './verify-signature'; export * from './switch-network'; +export * from './get-deployment-data'; diff --git a/packages/starknet-snap/src/utils/exceptions.ts b/packages/starknet-snap/src/utils/exceptions.ts index 7c8ffe8e..8f30a62d 100644 --- a/packages/starknet-snap/src/utils/exceptions.ts +++ b/packages/starknet-snap/src/utils/exceptions.ts @@ -54,3 +54,12 @@ export class UnknownError extends SnapError { ); } } + +export class AccountAlreadyDeployedError extends SnapError { + constructor(message?: string) { + super( + message ?? 'Account already deployed', + createWalletRpcErrorWrapper(WalletRpcErrorCode.AccountAlreadyDeployed), + ); + } +} From 157b5ad2930fe4dfa0c154596c942c295d9c4d99 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 18 Oct 2024 17:05:39 +0800 Subject: [PATCH 16/50] refactor(SNAP): revamp RPC `starkNet_addErc20Token` (#388) * chore: revamp starkNet_addErc20Token * chore: update watch asset * fix: pr comment * chore: update superstruct --- packages/starknet-snap/src/addErc20Token.ts | 79 ---- packages/starknet-snap/src/config.ts | 22 +- packages/starknet-snap/src/index.ts | 7 +- .../src/rpcs/__tests__/helper.ts | 16 + packages/starknet-snap/src/rpcs/index.ts | 1 + .../src/rpcs/watch-asset.test.ts | 170 ++++++++ .../starknet-snap/src/rpcs/watch-asset.ts | 191 +++++++++ .../src/state/token-state-manager.test.ts | 40 ++ .../src/state/token-state-manager.ts | 29 +- .../starknet-snap/src/utils/exceptions.ts | 9 + packages/starknet-snap/src/utils/index.ts | 2 + packages/starknet-snap/src/utils/snapUtils.ts | 138 +----- .../starknet-snap/src/utils/string.test.ts | 35 ++ packages/starknet-snap/src/utils/string.ts | 20 + .../src/utils/superstruct.test.ts | 38 ++ .../starknet-snap/src/utils/superstruct.ts | 30 +- .../starknet-snap/src/utils/token.test.ts | 60 +++ packages/starknet-snap/src/utils/token.ts | 35 ++ .../test/src/addErc20Token.test.ts | 398 ------------------ 19 files changed, 703 insertions(+), 617 deletions(-) delete mode 100644 packages/starknet-snap/src/addErc20Token.ts create mode 100644 packages/starknet-snap/src/rpcs/watch-asset.test.ts create mode 100644 packages/starknet-snap/src/rpcs/watch-asset.ts create mode 100644 packages/starknet-snap/src/utils/string.test.ts create mode 100644 packages/starknet-snap/src/utils/string.ts create mode 100644 packages/starknet-snap/src/utils/token.test.ts create mode 100644 packages/starknet-snap/src/utils/token.ts delete mode 100644 packages/starknet-snap/test/src/addErc20Token.test.ts diff --git a/packages/starknet-snap/src/addErc20Token.ts b/packages/starknet-snap/src/addErc20Token.ts deleted file mode 100644 index 83b40328..00000000 --- a/packages/starknet-snap/src/addErc20Token.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { heading, panel, DialogType } from '@metamask/snaps-sdk'; - -import type { AddErc20TokenRequestParams, ApiParams } from './types/snapApi'; -import type { Erc20Token } from './types/snapState'; -import { DEFAULT_DECIMAL_PLACES } from './utils/constants'; -import { logger } from './utils/logger'; -import { toJson } from './utils/serializer'; -import { - getNetworkFromChainId, - upsertErc20Token, - getValidNumber, - validateAddErc20TokenParams, - getAddTokenText, -} from './utils/snapUtils'; - -/** - * - * @param params - */ -export async function addErc20Token(params: ApiParams) { - try { - const { state, wallet, saveMutex, requestParams } = params; - const requestParamsObj = requestParams as AddErc20TokenRequestParams; - const { tokenAddress, tokenName, tokenSymbol } = requestParamsObj; - - if (!tokenAddress || !tokenName || !tokenSymbol) { - throw new Error( - `The given token address, name, and symbol need to be non-empty string, got: ${toJson( - requestParamsObj, - )}`, - ); - } - - const network = getNetworkFromChainId(state, requestParamsObj.chainId); - const tokenDecimals = getValidNumber( - requestParamsObj.tokenDecimals, - DEFAULT_DECIMAL_PLACES, - 0, - ); - - validateAddErc20TokenParams(requestParamsObj, network); - - const response = await wallet.request({ - method: 'snap_dialog', - params: { - type: DialogType.Confirmation, - content: panel([ - heading('Do you want to add this token?'), - ...getAddTokenText( - tokenAddress, - tokenName, - tokenSymbol, - tokenDecimals, - network, - ), - ]), - }, - }); - if (!response) { - return false; - } - - const erc20Token: Erc20Token = { - address: tokenAddress, - name: tokenName, - symbol: tokenSymbol, - decimals: tokenDecimals, - chainId: network.chainId, - }; - - await upsertErc20Token(erc20Token, wallet, saveMutex); - - logger.log(`addErc20Token:\nerc20Token: ${toJson(erc20Token)}`); - return erc20Token; - } catch (error) { - logger.error(`Problem found:`, error); - throw error; - } -} diff --git a/packages/starknet-snap/src/config.ts b/packages/starknet-snap/src/config.ts index cec6227f..16e79c77 100644 --- a/packages/starknet-snap/src/config.ts +++ b/packages/starknet-snap/src/config.ts @@ -1,8 +1,16 @@ -import type { Network } from './types/snapState'; +import type { Erc20Token, Network } from './types/snapState'; import { SnapEnv, STARKNET_MAINNET_NETWORK, STARKNET_SEPOLIA_TESTNET_NETWORK, + ETHER_MAINNET, + ETHER_SEPOLIA_TESTNET, + USDC_MAINNET, + USDC_SEPOLIA_TESTNET, + USDT_MAINNET, + USDT_SEPOLIA_TESTNET, + STRK_MAINNET, + STRK_SEPOLIA_TESTNET, } from './utils/constants'; import { LogLevel } from './utils/logger'; @@ -11,6 +19,7 @@ export type SnapConfig = { snapEnv: SnapEnv; defaultNetwork: Network; availableNetworks: Network[]; + preloadTokens: Erc20Token[]; }; export const Config: SnapConfig = { @@ -25,4 +34,15 @@ export const Config: SnapConfig = { STARKNET_MAINNET_NETWORK, STARKNET_SEPOLIA_TESTNET_NETWORK, ], + + preloadTokens: [ + ETHER_MAINNET, + ETHER_SEPOLIA_TESTNET, + USDC_MAINNET, + USDC_SEPOLIA_TESTNET, + USDT_MAINNET, + USDT_SEPOLIA_TESTNET, + STRK_MAINNET, + STRK_SEPOLIA_TESTNET, + ], }; diff --git a/packages/starknet-snap/src/index.ts b/packages/starknet-snap/src/index.ts index 14f66798..80a5349c 100644 --- a/packages/starknet-snap/src/index.ts +++ b/packages/starknet-snap/src/index.ts @@ -6,7 +6,6 @@ import type { } from '@metamask/snaps-sdk'; import { panel, text, MethodNotFoundError } from '@metamask/snaps-sdk'; -import { addErc20Token } from './addErc20Token'; import { addNetwork } from './addNetwork'; import { Config } from './config'; import { createAccount } from './createAccount'; @@ -36,6 +35,7 @@ import type { VerifySignatureParams, SwitchNetworkParams, GetDeploymentDataParams, + WatchAssetParams, } from './rpcs'; import { displayPrivateKey, @@ -47,6 +47,7 @@ import { verifySignature, switchNetwork, getDeploymentData, + watchAsset, } from './rpcs'; import { sendTransaction } from './sendTransaction'; import { signDeployAccountTransaction } from './signDeployAccountTransaction'; @@ -224,7 +225,9 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { ); case 'starkNet_addErc20Token': - return await addErc20Token(apiParams); + return await watchAsset.execute( + apiParams.requestParams as unknown as WatchAssetParams, + ); case 'starkNet_getStoredErc20Tokens': return await getStoredErc20Tokens(apiParams); diff --git a/packages/starknet-snap/src/rpcs/__tests__/helper.ts b/packages/starknet-snap/src/rpcs/__tests__/helper.ts index f0336064..06734f23 100644 --- a/packages/starknet-snap/src/rpcs/__tests__/helper.ts +++ b/packages/starknet-snap/src/rpcs/__tests__/helper.ts @@ -66,3 +66,19 @@ export function prepareAlertDialog() { alertDialogSpy, }; } + +export const buildRowComponent = (label: string, value: string) => ({ + type: 'row', + label, + value: { + value, + markdown: false, + type: 'text', + }, +}); + +export const buildDividerComponent = () => { + return { + type: 'divider', + }; +}; diff --git a/packages/starknet-snap/src/rpcs/index.ts b/packages/starknet-snap/src/rpcs/index.ts index 47983696..428b7514 100644 --- a/packages/starknet-snap/src/rpcs/index.ts +++ b/packages/starknet-snap/src/rpcs/index.ts @@ -7,3 +7,4 @@ export * from './sign-declare-transaction'; export * from './verify-signature'; export * from './switch-network'; export * from './get-deployment-data'; +export * from './watch-asset'; diff --git a/packages/starknet-snap/src/rpcs/watch-asset.test.ts b/packages/starknet-snap/src/rpcs/watch-asset.test.ts new file mode 100644 index 00000000..2569ca7e --- /dev/null +++ b/packages/starknet-snap/src/rpcs/watch-asset.test.ts @@ -0,0 +1,170 @@ +import { constants } from 'starknet'; + +import { Config } from '../config'; +import { NetworkStateManager } from '../state/network-state-manager'; +import { TokenStateManager } from '../state/token-state-manager'; +import type { Network } from '../types/snapState'; +import { STARKNET_SEPOLIA_TESTNET_NETWORK } from '../utils/constants'; +import { + InvalidRequestParamsError, + TokenIsPreloadedError, + InvalidNetworkError, + UserRejectedOpError, +} from '../utils/exceptions'; +import { + buildDividerComponent, + buildRowComponent, + prepareConfirmDialog, +} from './__tests__/helper'; +import type { WatchAssetParams } from './watch-asset'; +import { watchAsset } from './watch-asset'; + +jest.mock('../utils/snap'); +jest.mock('../utils/logger'); + +describe('WatchAssetRpc', () => { + const createRequest = ({ + chainId = constants.StarknetChainId.SN_SEPOLIA, + tokenName = 'Valid Token', + tokenSymbol = 'VT', + tokenAddress = '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004d99', + tokenDecimals = 18, + }: { + chainId?: constants.StarknetChainId; + tokenName?: string; + tokenSymbol?: string; + tokenDecimals?: number; + tokenAddress?: string; + }) => ({ + tokenAddress, + tokenName, + tokenSymbol, + tokenDecimals, + chainId, + }); + + const mockNetworkStateManager = ({ + network, + }: { + network: Network | null; + }) => { + const getNetworkSpy = jest.spyOn( + NetworkStateManager.prototype, + 'getNetwork', + ); + + getNetworkSpy.mockResolvedValue(network); + + return { getNetworkSpy }; + }; + + const mockTokenStateManager = () => { + const upsertTokenSpy = jest.spyOn( + TokenStateManager.prototype, + 'upsertToken', + ); + + return { upsertTokenSpy }; + }; + + const prepareWatchAssetTest = async ({ + network = STARKNET_SEPOLIA_TESTNET_NETWORK, + }: { + network?: Network; + }) => { + const request = createRequest({ + chainId: network.chainId as unknown as constants.StarknetChainId, + }); + const { confirmDialogSpy } = prepareConfirmDialog(); + const { getNetworkSpy } = mockNetworkStateManager({ + network, + }); + const { upsertTokenSpy } = mockTokenStateManager(); + + return { + getNetworkSpy, + confirmDialogSpy, + upsertTokenSpy, + request, + }; + }; + + it('returns true if the token is added', async () => { + const { request } = await prepareWatchAssetTest({}); + + const expectedResult = true; + + const result = await watchAsset.execute(request); + + expect(result).toStrictEqual(expectedResult); + }); + + it('renders confirmation dialog', async () => { + const network = STARKNET_SEPOLIA_TESTNET_NETWORK; + const { request, confirmDialogSpy } = await prepareWatchAssetTest({ + network, + }); + + await watchAsset.execute(request); + + expect(confirmDialogSpy).toHaveBeenCalledWith([ + { type: 'heading', value: 'Do you want to add this token?' }, + buildRowComponent('Network', network.name), + buildDividerComponent(), + buildRowComponent('Token Address', request.tokenAddress), + buildDividerComponent(), + buildRowComponent('Token Name', request.tokenName), + buildDividerComponent(), + buildRowComponent('Token Symbol', request.tokenSymbol), + buildDividerComponent(), + buildRowComponent('Token Decimals', request.tokenDecimals.toString()), + ]); + }); + + it('throws `InvalidNetworkError` if the network can not be found', async () => { + const { request, getNetworkSpy } = await prepareWatchAssetTest({}); + getNetworkSpy.mockResolvedValue(null); + + await expect(watchAsset.execute(request)).rejects.toThrow( + InvalidNetworkError, + ); + }); + + it('throws `TokenIsPreloadedError` if the given token is one of the preloaded tokens', async () => { + const preloadedToken = Config.preloadTokens[0]; + const { address, symbol, decimals, name, chainId } = preloadedToken; + // Ensure the network is matching the preloaded token + const network = Config.availableNetworks.find( + (net) => net.chainId === chainId, + ); + await prepareWatchAssetTest({ + network, + }); + const request = createRequest({ + tokenAddress: address, + tokenName: name, + tokenSymbol: symbol, + tokenDecimals: decimals, + chainId: chainId as unknown as constants.StarknetChainId, + }); + + await expect(watchAsset.execute(request)).rejects.toThrow( + TokenIsPreloadedError, + ); + }); + + it('throws `UserRejectedOpError` if user denied the operation', async () => { + const { request, confirmDialogSpy } = await prepareWatchAssetTest({}); + confirmDialogSpy.mockResolvedValue(false); + + await expect(watchAsset.execute(request)).rejects.toThrow( + UserRejectedOpError, + ); + }); + + it('throws `InvalidRequestParamsError` when request parameter is not correct', async () => { + await expect( + watchAsset.execute({} as unknown as WatchAssetParams), + ).rejects.toThrow(InvalidRequestParamsError); + }); +}); diff --git a/packages/starknet-snap/src/rpcs/watch-asset.ts b/packages/starknet-snap/src/rpcs/watch-asset.ts new file mode 100644 index 00000000..f011efcb --- /dev/null +++ b/packages/starknet-snap/src/rpcs/watch-asset.ts @@ -0,0 +1,191 @@ +import type { Component } from '@metamask/snaps-sdk'; +import { divider, heading, row, text } from '@metamask/snaps-sdk'; +import type { Infer } from 'superstruct'; +import { assign, boolean, min, number, object, optional } from 'superstruct'; + +import { NetworkStateManager } from '../state/network-state-manager'; +import { TokenStateManager } from '../state/token-state-manager'; +import type { Erc20Token, Network } from '../types/snapState'; +import { + confirmDialog, + BaseRequestStruct, + RpcController, + AddressStruct, + TokenNameStruct, + TokenSymbolStruct, + isPreloadedToken, +} from '../utils'; +import { DEFAULT_DECIMAL_PLACES } from '../utils/constants'; +import { + InvalidNetworkError, + TokenIsPreloadedError, + UserRejectedOpError, +} from '../utils/exceptions'; +import { getValidNumber } from '../utils/snapUtils'; + +export const WatchAssetRequestStruct = assign( + object({ + tokenAddress: AddressStruct, + tokenName: TokenNameStruct, + tokenSymbol: TokenSymbolStruct, + tokenDecimals: optional(min(number(), 0)), + }), + BaseRequestStruct, +); + +export const WatchAssetResponseStruct = boolean(); + +export type WatchAssetParams = Infer; + +export type WatchAssetResponse = Infer; + +/** + * The RPC handler to add a ERC20 asset. + */ +export class WatchAssetRpc extends RpcController< + WatchAssetParams, + WatchAssetResponse +> { + protected requestStruct = WatchAssetRequestStruct; + + protected responseStruct = WatchAssetResponseStruct; + + protected readonly tokenStateMgr: TokenStateManager; + + protected readonly networkStateMgr: NetworkStateManager; + + constructor() { + super(); + this.tokenStateMgr = new TokenStateManager(); + this.networkStateMgr = new NetworkStateManager(); + } + + /** + * Execute the watch asset request handler. + * It will prompt a dialog to ask user confirmation. + * + * @param params - The parameters of the request. + * @param params.tokenAddress - The address of the token to add. + * @param params.tokenName - The name of the token to add. + * @param params.tokenSymbol - The symbol of the token to add. + * @param params.tokenDecimals - The decimals of the token to add. + * @param params.chainId - The chain id of the network to switch. + * @returns the response of adding the asset in boolean. + * @throws {UserRejectedRequestError} If the user rejects the request. + * @throws {Error} If the network with the chain id is not supported. + * @throws {Error} If the token address, name, or symbol is the same as one of the preloaded tokens. + */ + async execute(params: WatchAssetParams): Promise { + return super.execute(params); + } + + protected async getNetworkFromChainId(chainId: string): Promise { + const network = await this.networkStateMgr.getNetwork({ + chainId, + }); + + // It should be never happen, as the chainId should be validated by the superstruct + if (!network) { + throw new InvalidNetworkError() as unknown as Error; + } + + return network; + } + + protected buildErc20Token(params: WatchAssetParams): Erc20Token { + const { chainId, tokenAddress, tokenName, tokenSymbol, tokenDecimals } = + params; + + // If the token to added is one of the preloaded tokens, we throw an error + if ( + isPreloadedToken({ + tokenAddress, + tokenName, + tokenSymbol, + chainId, + }) + ) { + throw new TokenIsPreloadedError() as unknown as Error; + } + + const decimalsPlace = getValidNumber( + tokenDecimals, + DEFAULT_DECIMAL_PLACES, + 0, + ); + + return { + address: tokenAddress, + name: tokenName, + symbol: tokenSymbol, + decimals: decimalsPlace, + chainId, + }; + } + + protected async handleRequest( + params: WatchAssetParams, + ): Promise { + const { chainId } = params; + + const erc20Token: Erc20Token = this.buildErc20Token(params); + const network = await this.getNetworkFromChainId(chainId); + + if (!(await this.getWatchAssetConsensus(network.name, erc20Token))) { + throw new UserRejectedOpError() as unknown as Error; + } + + await this.tokenStateMgr.upsertToken(erc20Token); + + return true; + } + + protected async getWatchAssetConsensus( + networkName: string, + erc20Token: Erc20Token, + ) { + const { address, name, symbol, decimals } = erc20Token; + + const componentPairs = [ + { + label: 'Network', + value: networkName, + }, + { + label: 'Token Address', + value: address, + }, + { + label: 'Token Name', + value: name, + }, + { + label: 'Token Symbol', + value: symbol, + }, + { + label: 'Token Decimals', + value: decimals.toString(), + }, + ]; + const components: Component[] = []; + components.push(heading('Do you want to add this token?')); + componentPairs.forEach(({ label, value }, idx) => { + components.push( + row( + label, + text({ + value, + markdown: false, + }), + ), + ); + if (idx < componentPairs.length - 1) { + components.push(divider()); + } + }); + return await confirmDialog(components); + } +} + +export const watchAsset = new WatchAssetRpc(); diff --git a/packages/starknet-snap/src/state/token-state-manager.test.ts b/packages/starknet-snap/src/state/token-state-manager.test.ts index 1078ef19..3060804f 100644 --- a/packages/starknet-snap/src/state/token-state-manager.test.ts +++ b/packages/starknet-snap/src/state/token-state-manager.test.ts @@ -308,4 +308,44 @@ describe('TokenStateManager', () => { ); }); }); + + describe('upsertToken', () => { + const createTokenToUpsert = () => { + return { + address: '0x123455', + symbol: 'TO', + name: 'Token', + decimals: 18, + chainId: constants.StarknetChainId.SN_SEPOLIA, + }; + }; + it('adds the token to state if it does not exist', async () => { + const token = createTokenToUpsert(); + const { state } = await mockState({ + tokens: [ETHER_MAINNET], + }); + + const stateManager = new TokenStateManager(); + await stateManager.upsertToken(token); + + expect(state.erc20Tokens[1]).toStrictEqual(token); + }); + + it('updates the token in state if it exists', async () => { + const token = createTokenToUpsert(); + const { state } = await mockState({ + tokens: [ETHER_MAINNET, token], + }); + + const updatedEntity = { + ...token, + name: 'Updated Token', + }; + + const stateManager = new TokenStateManager(); + await stateManager.upsertToken(updatedEntity); + + expect(state.erc20Tokens[1]).toStrictEqual(updatedEntity); + }); + }); }); diff --git a/packages/starknet-snap/src/state/token-state-manager.ts b/packages/starknet-snap/src/state/token-state-manager.ts index c91bdc24..c12b306e 100644 --- a/packages/starknet-snap/src/state/token-state-manager.ts +++ b/packages/starknet-snap/src/state/token-state-manager.ts @@ -30,7 +30,7 @@ export class TokenStateManager extends StateManager { return state.erc20Tokens; } - protected updateEntity(dataInState: Erc20Token, data: Erc20Token): void { + updateEntity(dataInState: Erc20Token, data: Erc20Token): void { assert(data.name, nonempty(string())); assert(data.symbol, nonempty(string())); assert(data.decimals, min(number(), 0)); @@ -242,4 +242,31 @@ export class TokenStateManager extends StateManager { return ''; } } + + /** + * Upsert a Erc20Token object. + * + * @param token - The Erc20Token object to upsert. + */ + async upsertToken(token: Erc20Token): Promise { + try { + await this.update(async (state: SnapState) => { + const dataInState = await this.getToken( + { + address: token.address, + chainId: token.chainId, + }, + state, + ); + // eslint-disable-next-line no-negated-condition + if (!dataInState) { + state.erc20Tokens.push(token); + } else { + this.updateEntity(dataInState, token); + } + }); + } catch (error) { + throw new StateManagerError(error.message); + } + } } diff --git a/packages/starknet-snap/src/utils/exceptions.ts b/packages/starknet-snap/src/utils/exceptions.ts index 8f30a62d..c4c8c59a 100644 --- a/packages/starknet-snap/src/utils/exceptions.ts +++ b/packages/starknet-snap/src/utils/exceptions.ts @@ -63,3 +63,12 @@ export class AccountAlreadyDeployedError extends SnapError { ); } } + +export class TokenIsPreloadedError extends SnapError { + constructor(message?: string) { + super( + message ?? + 'Token address, name, or symbol is the same as one of the preloaded tokens', + ); + } +} diff --git a/packages/starknet-snap/src/utils/index.ts b/packages/starknet-snap/src/utils/index.ts index 311bc4e9..f0c8bc07 100644 --- a/packages/starknet-snap/src/utils/index.ts +++ b/packages/starknet-snap/src/utils/index.ts @@ -7,4 +7,6 @@ export * from './logger'; export * from './formatterUtils'; export * from './snap-state'; export * from './url'; +export * from './string'; +export * from './token'; // TODO: add other utils diff --git a/packages/starknet-snap/src/utils/snapUtils.ts b/packages/starknet-snap/src/utils/snapUtils.ts index 92d23744..f1107acc 100644 --- a/packages/starknet-snap/src/utils/snapUtils.ts +++ b/packages/starknet-snap/src/utils/snapUtils.ts @@ -15,11 +15,7 @@ import type { } from 'starknet'; import { Config } from '../config'; -import { - FeeToken, - type AddErc20TokenRequestParams, - type AddNetworkRequestParams, -} from '../types/snapApi'; +import { FeeToken, type AddNetworkRequestParams } from '../types/snapApi'; import { TransactionStatus } from '../types/snapState'; import type { Network, @@ -31,20 +27,15 @@ import type { } from '../types/snapState'; import { MAXIMUM_NETWORK_NAME_LENGTH, - MAXIMUM_TOKEN_NAME_LENGTH, - MAXIMUM_TOKEN_SYMBOL_LENGTH, PRELOADED_NETWORKS, - PRELOADED_TOKENS, STARKNET_SEPOLIA_TESTNET_NETWORK, } from './constants'; import { DeployRequiredError, UpgradeRequiredError } from './exceptions'; import { logger } from './logger'; import { toJson } from './serializer'; import { alertDialog } from './snap'; -import { - validateAccountRequireUpgradeOrDeploy, - validateAndParseAddress, -} from './starknetUtils'; +import { validateAccountRequireUpgradeOrDeploy } from './starknetUtils'; +import { isValidAsciiStrField } from './string'; import { filterTransactions, TimestampFilter, @@ -87,27 +78,6 @@ async function setState( }); } -/** - * - * @param str - */ -function hasOnlyAsciiChars(str: string) { - return /^[ -~]+$/u.test(str); -} - -/** - * - * @param fieldStr - * @param maxLength - */ -function isValidAsciiStrField(fieldStr: string, maxLength: number) { - return ( - hasOnlyAsciiChars(fieldStr) && - fieldStr.trim().length > 0 && - fieldStr.length <= maxLength - ); -} - /** * * @param str @@ -130,22 +100,6 @@ function isValidHttpUrl(urlStr: string) { return url.protocol === 'http:' || url.protocol === 'https:'; } -/** - * - * @param tokenName - */ -function isValidTokenName(tokenName: string) { - return isValidAsciiStrField(tokenName, MAXIMUM_TOKEN_NAME_LENGTH); -} - -/** - * - * @param tokenSymbol - */ -function isValidTokenSymbol(tokenSymbol: string) { - return isValidAsciiStrField(tokenSymbol, MAXIMUM_TOKEN_SYMBOL_LENGTH); -} - /** * * @param networkName @@ -154,52 +108,6 @@ function isValidNetworkName(networkName: string) { return isValidAsciiStrField(networkName, MAXIMUM_NETWORK_NAME_LENGTH); } -/** - * - * @param tokenName - * @param chainId - */ -function isPreloadedTokenName(tokenName: string, chainId: string) { - return Boolean( - PRELOADED_TOKENS.find( - (token) => - token.name.trim() === tokenName.trim() && - isSameChainId(token.chainId, chainId), - ), - ); -} - -/** - * - * @param tokenSymbol - * @param chainId - */ -function isPreloadedTokenSymbol(tokenSymbol: string, chainId: string) { - return Boolean( - PRELOADED_TOKENS.find( - (token) => - token.symbol.trim() === tokenSymbol.trim() && - isSameChainId(token.chainId, chainId), - ), - ); -} - -/** - * - * @param tokenAddress - * @param chainId - */ -function isPreloadedTokenAddress(tokenAddress: string, chainId: string) { - const bigIntTokenAddress = numUtils.toBigInt(tokenAddress); - return Boolean( - PRELOADED_TOKENS.find( - (token) => - numUtils.toBigInt(token.address) === bigIntTokenAddress && - isSameChainId(token.chainId, chainId), - ), - ); -} - /** * * @param chainId @@ -224,46 +132,6 @@ function isPreloadedNetworkName(networkName: string) { ); } -/** - * - * @param params - * @param network - */ -export function validateAddErc20TokenParams( - params: AddErc20TokenRequestParams, - network: Network, -) { - try { - validateAndParseAddress(params.tokenAddress); - } catch (error) { - throw new Error( - `The given token address is invalid: ${params.tokenAddress}`, - ); - } - - if (!isValidTokenName(params.tokenName)) { - throw new Error( - `The given token name is invalid, needs to be in ASCII chars, not all spaces, and has length larger than ${MAXIMUM_TOKEN_NAME_LENGTH}: ${params.tokenName}`, - ); - } - - if (!isValidTokenSymbol(params.tokenSymbol)) { - throw new Error( - `The given token symbol is invalid, needs to be in ASCII chars, not all spaces, and has length larger than ${MAXIMUM_TOKEN_SYMBOL_LENGTH}: ${params.tokenSymbol}`, - ); - } - - if ( - isPreloadedTokenAddress(params.tokenAddress, network.chainId) || - isPreloadedTokenName(params.tokenName, network.chainId) || - isPreloadedTokenSymbol(params.tokenSymbol, network.chainId) - ) { - throw new Error( - 'The given token address, name, or symbol is the same as one of the preloaded tokens, and thus cannot be added', - ); - } -} - /** * * @param params diff --git a/packages/starknet-snap/src/utils/string.test.ts b/packages/starknet-snap/src/utils/string.test.ts new file mode 100644 index 00000000..212e6335 --- /dev/null +++ b/packages/starknet-snap/src/utils/string.test.ts @@ -0,0 +1,35 @@ +import { isAsciiString, isValidAsciiStrField } from './string'; + +describe('isAsciiString', () => { + it('returns true for a ASCII string', () => { + expect(isAsciiString('hello')).toBe(true); + }); + + it('returns false for a non ASCII string', () => { + // non ASCII string + expect(isAsciiString('Schönen Tag noch')).toBe(false); + }); +}); + +describe('isValidAsciiStrField', () => { + it.each(['hello', 'hello '])( + 'returns true for a valid ASCII string: %s', + (str: string) => { + expect(isValidAsciiStrField(str, 10)).toBe(true); + }, + ); + + it.each([ + // invalid length, longer than 10 chars + 'Have a nice day', + // non ASCII string + 'Schönen', + // non ASCII string + ' Schönaa ', + ])( + 'returns false for a string that fails ASCII check or length validation: %s', + (str: string) => { + expect(isValidAsciiStrField(str, 10)).toBe(false); + }, + ); +}); diff --git a/packages/starknet-snap/src/utils/string.ts b/packages/starknet-snap/src/utils/string.ts new file mode 100644 index 00000000..50d64a8a --- /dev/null +++ b/packages/starknet-snap/src/utils/string.ts @@ -0,0 +1,20 @@ +/** + * Check if a string is an ASCII string. + * + * @param str - The string to check. + */ +export function isAsciiString(str: string) { + return /^[ -~]+$/u.test(str); +} + +/** + * Check if a string is a valid ASCII string field. + * + * @param value - The string to check. + * @param maxLength - The maximum length of the string. + */ +export function isValidAsciiStrField(value: string, maxLength: number) { + return ( + isAsciiString(value) && value.trim().length > 0 && value.length <= maxLength + ); +} diff --git a/packages/starknet-snap/src/utils/superstruct.test.ts b/packages/starknet-snap/src/utils/superstruct.test.ts index ae05e1b7..1e978c4e 100644 --- a/packages/starknet-snap/src/utils/superstruct.test.ts +++ b/packages/starknet-snap/src/utils/superstruct.test.ts @@ -26,8 +26,46 @@ import { V3TransactionDetailStruct, InvocationsStruct, ChainIdStruct, + TokenSymbolStruct, + TokenNameStruct, } from './superstruct'; +describe('TokenNameStruct', () => { + it.each(['Hello', 'Hello World'])( + 'does not throw error if the token name is valid - %s', + (tokenName) => { + expect(() => assert(tokenName, TokenNameStruct)).not.toThrow(); + }, + ); + + it.each([ + // non ASCII string + 'Schönen', + // invalid length, longer than 64 chars + '372da3dfe1c53170ad75893832469bf87b62b13e84662565c4a88f25cddddddd372da3dfe1c53170ad75893832469bf87b62b13e84662565c4a88f25cddddddd', + ])('throws error if the token name is invalid - %s', (tokenName) => { + expect(() => assert(tokenName, TokenNameStruct)).toThrow(StructError); + }); +}); + +describe('TokenSymbolStruct', () => { + it.each(['symbol', 'symbol A'])( + 'does not throw error if the token symbol is valid - %s', + (tokenSymbol) => { + expect(() => assert(tokenSymbol, TokenSymbolStruct)).not.toThrow(); + }, + ); + + it.each([ + // non ASCII string + 'Schönen', + // invalid length, longer than 16 chars + 'ABCDEFGHIJKLMNOPABCDEFGHIJKLMNOP', + ])('throws error if the token symbol is invalid - %s', (tokenSymbol) => { + expect(() => assert(tokenSymbol, TokenSymbolStruct)).toThrow(StructError); + }); +}); + describe('AddressStruct', () => { it.each([ '0x04882a372da3dfe1c53170ad75893832469bf87b62b13e84662565c4a88f25cd', diff --git a/packages/starknet-snap/src/utils/superstruct.ts b/packages/starknet-snap/src/utils/superstruct.ts index cd3c72f2..7ee6fb40 100644 --- a/packages/starknet-snap/src/utils/superstruct.ts +++ b/packages/starknet-snap/src/utils/superstruct.ts @@ -28,7 +28,35 @@ import { unknown, } from 'superstruct'; -import { CAIRO_VERSION_LEGACY, CAIRO_VERSION } from './constants'; +import { + CAIRO_VERSION_LEGACY, + CAIRO_VERSION, + MAXIMUM_TOKEN_NAME_LENGTH, + MAXIMUM_TOKEN_SYMBOL_LENGTH, +} from './constants'; +import { isValidAsciiStrField } from './string'; + +export const TokenNameStruct = refine( + string(), + 'TokenNameStruct', + (value: string) => { + if (isValidAsciiStrField(value, MAXIMUM_TOKEN_NAME_LENGTH)) { + return true; + } + return `The given token name is invalid`; + }, +); + +export const TokenSymbolStruct = refine( + string(), + 'TokenSymbolStruct', + (value: string) => { + if (isValidAsciiStrField(value, MAXIMUM_TOKEN_SYMBOL_LENGTH)) { + return true; + } + return `The given token symbol is invalid`; + }, +); export const AddressStruct = refine( string(), diff --git a/packages/starknet-snap/src/utils/token.test.ts b/packages/starknet-snap/src/utils/token.test.ts new file mode 100644 index 00000000..b7cb615a --- /dev/null +++ b/packages/starknet-snap/src/utils/token.test.ts @@ -0,0 +1,60 @@ +import { constants } from 'starknet'; + +import { Config } from '../config'; +import type { Erc20Token } from '../types/snapState'; +import { isPreloadedToken } from './token'; + +describe('isPreloadedToken', () => { + it.each(Config.preloadTokens)( + 'returns true if the token is a preloaded token', + (token: Erc20Token) => { + expect( + isPreloadedToken({ + tokenName: token.name, + tokenSymbol: token.symbol, + tokenAddress: token.address, + chainId: token.chainId, + }), + ).toBe(true); + }, + ); + + it.each([ + // To test the case where the token is a preloaded token but with different name, symbol, address + { + ...Config.preloadTokens[0], + name: 'different name', + }, + { + ...Config.preloadTokens[0], + symbol: 'different symbol', + }, + { + ...Config.preloadTokens[0], + address: '0x12345', + }, + ])( + 'returns true if the token is a preloaded token but with different name, symbol, address', + (token: Erc20Token) => { + expect( + isPreloadedToken({ + tokenName: token.name, + tokenSymbol: token.symbol, + tokenAddress: token.address, + chainId: token.chainId, + }), + ).toBe(true); + }, + ); + + it('returns false if the token is not a preloaded token', () => { + expect( + isPreloadedToken({ + tokenName: 'New Token', + tokenSymbol: 'NT', + tokenAddress: '0x12345', + chainId: constants.StarknetChainId.SN_SEPOLIA, + }), + ).toBe(false); + }); +}); diff --git a/packages/starknet-snap/src/utils/token.ts b/packages/starknet-snap/src/utils/token.ts new file mode 100644 index 00000000..2d918ed7 --- /dev/null +++ b/packages/starknet-snap/src/utils/token.ts @@ -0,0 +1,35 @@ +import { Config } from '../config'; +import { isSameChainId } from './snapUtils'; + +/** + * Check if the token is preloaded token. + * + * @param params - The params object. + * @param params.tokenName - The token name. + * @param params.chainId - The chain id. + * @param params.tokenSymbol - The token symbol. + * @param params.tokenAddress - The token address. + * @returns True if the token is preloaded token, false otherwise. + */ +export function isPreloadedToken({ + tokenName, + tokenSymbol, + tokenAddress, + chainId, +}: { + tokenName: string; + tokenSymbol: string; + tokenAddress: string; + chainId: string; +}) { + const bigIntTokenAddress = BigInt(tokenAddress); + return Boolean( + Config.preloadTokens.find( + (token) => + (token.name.trim() === tokenName.trim() || + token.symbol.trim() === tokenSymbol.trim() || + BigInt(token.address) === bigIntTokenAddress) && + isSameChainId(token.chainId, chainId), + ), + ); +} diff --git a/packages/starknet-snap/test/src/addErc20Token.test.ts b/packages/starknet-snap/test/src/addErc20Token.test.ts deleted file mode 100644 index 60e1d93b..00000000 --- a/packages/starknet-snap/test/src/addErc20Token.test.ts +++ /dev/null @@ -1,398 +0,0 @@ -import chai, { expect } from 'chai'; -import sinon from 'sinon'; -import sinonChai from 'sinon-chai'; -import { WalletMock } from '../wallet.mock.test'; -import { addErc20Token } from '../../src/addErc20Token'; -import { SnapState } from '../../src/types/snapState'; -import * as snapUtils from '../../src/utils/snapUtils'; -import { - DEFAULT_DECIMAL_PLACES, - STARKNET_MAINNET_NETWORK, -} from '../../src/utils/constants'; -import { Mutex } from 'async-mutex'; -import { AddErc20TokenRequestParams, ApiParams } from '../../src/types/snapApi'; - -chai.use(sinonChai); -const sandbox = sinon.createSandbox(); - -describe('Test function: addErc20Token', function () { - const walletStub = new WalletMock(); - const state: SnapState = { - accContracts: [], - erc20Tokens: [], - networks: [STARKNET_MAINNET_NETWORK], - transactions: [], - }; - const apiParams: ApiParams = { - state, - requestParams: {}, - wallet: walletStub, - saveMutex: new Mutex(), - }; - - beforeEach(function () { - walletStub.rpcStubs.snap_manageState.resolves(state); - walletStub.rpcStubs.snap_dialog.resolves(true); - }); - - afterEach(function () { - walletStub.reset(); - sandbox.restore(); - }); - - it('should reject to add the ERC-20 token when deline in dialog', async function () { - walletStub.rpcStubs.snap_dialog.resolves(false); - const requestObject: AddErc20TokenRequestParams = { - tokenAddress: - '0x244c20d51109adcf604fde1bbf878e5dcd549b3877ac87911ec6a158bd7aa62', - tokenName: 'Starknet ERC-20 sample', - tokenSymbol: 'SNET', - tokenDecimals: 18, - }; - apiParams.requestParams = requestObject; - await addErc20Token(apiParams); - expect(walletStub.rpcStubs.snap_dialog).to.have.been.calledOnce; - expect(state.erc20Tokens.length).to.be.eq(0); - }); - - it('should add the ERC-20 token in SN_SEPOLIA correctly', async function () { - const requestObject: AddErc20TokenRequestParams = { - tokenAddress: - '0x244c20d51109adcf604fde1bbf878e5dcd549b3877ac87911ec6a158bd7aa62', - tokenName: 'Starknet ERC-20 sample', - tokenSymbol: 'SNET', - tokenDecimals: 18, - }; - apiParams.requestParams = requestObject; - await addErc20Token(apiParams); - expect(walletStub.rpcStubs.snap_manageState).to.have.been.calledTwice; - expect(walletStub.rpcStubs.snap_dialog).to.have.been.calledOnce; - expect(state.erc20Tokens.length).to.be.eq(1); - expect(state.erc20Tokens[0].symbol).to.be.eq(requestObject.tokenSymbol); - }); - - it('should add the ERC-20 token (with undefined tokenDecimals) in SN_SEPOLIA with default token decimal places correctly', async function () { - const requestObject: AddErc20TokenRequestParams = { - tokenAddress: - '0x244c20d51109adcf604fde1bbf878e5dcd549b3877ac87911ec6a158bd7bb99', - tokenName: 'Starknet ERC-20 sample 2', - tokenSymbol: 'SNET', - }; - apiParams.requestParams = requestObject; - await addErc20Token(apiParams); - expect(walletStub.rpcStubs.snap_manageState).to.have.been.calledTwice; - expect(walletStub.rpcStubs.snap_dialog).to.have.been.calledOnce; - expect(state.erc20Tokens.length).to.be.eq(2); - expect(state.erc20Tokens[1].decimals).to.be.eq(DEFAULT_DECIMAL_PLACES); - }); - - it('should add the ERC-20 token (with empty string tokenDecimals) in SN_SEPOLIA with default token decimal places correctly', async function () { - const requestObject: AddErc20TokenRequestParams = { - tokenAddress: - '0x244c20d51109adcf604fde1bbf878e5dcd549b3877ac87911ec6a158bd7cc99', - tokenName: 'Starknet ERC-20 sample 2', - tokenSymbol: 'SNET', - tokenDecimals: '', - }; - apiParams.requestParams = requestObject; - await addErc20Token(apiParams); - expect(walletStub.rpcStubs.snap_manageState).to.have.been.calledTwice; - expect(walletStub.rpcStubs.snap_dialog).to.have.been.calledOnce; - expect(state.erc20Tokens.length).to.be.eq(3); - expect(state.erc20Tokens[1].decimals).to.be.eq(DEFAULT_DECIMAL_PLACES); - }); - - it('should update the ERC-20 token in SN_SEPOLIA correctly', async function () { - const requestObject: AddErc20TokenRequestParams = { - tokenAddress: - '0x244c20d51109adcf604fde1bbf878e5dcd549b3877ac87911ec6a158bd7aa62', - tokenName: 'Starknet ERC-20 sample', - tokenSymbol: 'SNET-2', - tokenDecimals: 18, - }; - apiParams.requestParams = requestObject; - await addErc20Token(apiParams); - expect(walletStub.rpcStubs.snap_manageState).to.have.been.calledTwice; - expect(walletStub.rpcStubs.snap_dialog).to.have.been.calledOnce; - expect(state.erc20Tokens.length).to.be.eq(3); - expect(state.erc20Tokens[0].symbol).to.be.eq(requestObject.tokenSymbol); - }); - - it('should not update snap state with the duplicated ERC-20 token in SN_SEPOLIA', async function () { - const requestObject: AddErc20TokenRequestParams = { - tokenAddress: - '0x244c20d51109adcf604fde1bbf878e5dcd549b3877ac87911ec6a158bd7aa62', - tokenName: 'Starknet ERC-20 sample', - tokenSymbol: 'SNET-2', - tokenDecimals: 18, - }; - apiParams.requestParams = requestObject; - await addErc20Token(apiParams); - expect(walletStub.rpcStubs.snap_manageState).to.have.been.calledOnce; - expect(walletStub.rpcStubs.snap_dialog).to.have.been.calledOnce; - expect(state.erc20Tokens.length).to.be.eq(3); - expect(state.erc20Tokens[0].symbol).to.be.eq(requestObject.tokenSymbol); - }); - - it('should throw error if upsertErc20Token failed', async function () { - sandbox.stub(snapUtils, 'upsertErc20Token').throws(new Error()); - const requestObject: AddErc20TokenRequestParams = { - tokenAddress: - '0x244c20d51109adcf604fde1bbf878e5dcd549b3877ac87911ec6a158bd7aa62', - tokenName: 'Starknet ERC-20 sample', - tokenSymbol: 'SNET', - tokenDecimals: 18, - }; - apiParams.requestParams = requestObject; - - let result; - try { - await addErc20Token(apiParams); - } catch (err) { - result = err; - } finally { - expect(result).to.be.an('Error'); - } - }); - - it('should throw error if tokenAddress is invalid', async function () { - const requestObject: AddErc20TokenRequestParams = { - tokenAddress: - '0x244c20d51109adcf604fde1bbf878e5dcd549b3877ac87911ec6a158bd7aaXX', - tokenName: 'Starknet ERC-20 sample', - tokenSymbol: 'SNET', - tokenDecimals: 18, - }; - apiParams.requestParams = requestObject; - - let result; - try { - await addErc20Token(apiParams); - } catch (err) { - result = err; - } finally { - expect(result).to.be.an('Error'); - } - }); - - it('should throw error if tokenName is empty', async function () { - const requestObject: AddErc20TokenRequestParams = { - tokenAddress: - '0x244c20d51109adcf604fde1bbf878e5dcd549b3877ac87911ec6a158bd7aa62', - tokenName: '', - tokenSymbol: 'SNET', - tokenDecimals: 18, - }; - apiParams.requestParams = requestObject; - - let result; - try { - await addErc20Token(apiParams); - } catch (err) { - result = err; - } finally { - expect(result).to.be.an('Error'); - } - }); - - it('should throw error if tokenSymbol is empty', async function () { - const requestObject: AddErc20TokenRequestParams = { - tokenAddress: - '0x244c20d51109adcf604fde1bbf878e5dcd549b3877ac87911ec6a158bd7aa62', - tokenName: 'Starknet ERC-20 sample', - tokenSymbol: '', - tokenDecimals: 18, - }; - apiParams.requestParams = requestObject; - - let result; - try { - await addErc20Token(apiParams); - } catch (err) { - result = err; - } finally { - expect(result).to.be.an('Error'); - } - }); - - it('should throw error if tokenName is in non-ASCII character', async function () { - const requestObject: AddErc20TokenRequestParams = { - tokenAddress: - '0x244c20d51109adcf604fde1bbf878e5dcd549b3877ac87911ec6a158bd7aa62', - tokenName: 'Starknet ERC-20 sample for аррӏе', - tokenSymbol: 'SNET', - tokenDecimals: 18, - }; - apiParams.requestParams = requestObject; - - let result; - try { - await addErc20Token(apiParams); - } catch (err) { - result = err; - } finally { - expect(result).to.be.an('Error'); - } - }); - - it('should throw error if tokenName is in longer than 64 characters', async function () { - const requestObject: AddErc20TokenRequestParams = { - tokenAddress: - '0x244c20d51109adcf604fde1bbf878e5dcd549b3877ac87911ec6a158bd7aa62', - tokenName: - 'Starknet ERC-20 sample for xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', - tokenSymbol: 'SNET', - tokenDecimals: 18, - }; - apiParams.requestParams = requestObject; - - let result; - try { - await addErc20Token(apiParams); - } catch (err) { - result = err; - } finally { - expect(result).to.be.an('Error'); - } - }); - - it('should throw error if tokenName is an all spaces string', async function () { - const requestObject: AddErc20TokenRequestParams = { - tokenAddress: - '0x244c20d51109adcf604fde1bbf878e5dcd549b3877ac87911ec6a158bd7aa62', - tokenName: ' ', - tokenSymbol: 'SNET', - tokenDecimals: 18, - }; - apiParams.requestParams = requestObject; - - let result; - try { - await addErc20Token(apiParams); - } catch (err) { - result = err; - } finally { - expect(result).to.be.an('Error'); - } - }); - - it('should throw error if tokenSymbol is in non-ASCII character', async function () { - const requestObject: AddErc20TokenRequestParams = { - tokenAddress: - '0x244c20d51109adcf604fde1bbf878e5dcd549b3877ac87911ec6a158bd7aa62', - tokenName: 'Starknet ERC-20 sample', - tokenSymbol: 'аррӏе', - tokenDecimals: 18, - }; - apiParams.requestParams = requestObject; - - let result; - try { - await addErc20Token(apiParams); - } catch (err) { - result = err; - } finally { - expect(result).to.be.an('Error'); - } - }); - - it('should throw error if tokenSymbol is in longer than 16 characters', async function () { - const requestObject: AddErc20TokenRequestParams = { - tokenAddress: - '0x244c20d51109adcf604fde1bbf878e5dcd549b3877ac87911ec6a158bd7aa62', - tokenName: 'Starknet ERC-20 sample', - tokenSymbol: 'SNETXXXXXXXXXXXXXXXXX', - tokenDecimals: 18, - }; - apiParams.requestParams = requestObject; - - let result; - try { - await addErc20Token(apiParams); - } catch (err) { - result = err; - } finally { - expect(result).to.be.an('Error'); - } - }); - - it('should throw error if tokenSymbol is an all-spaces string', async function () { - const requestObject: AddErc20TokenRequestParams = { - tokenAddress: - '0x244c20d51109adcf604fde1bbf878e5dcd549b3877ac87911ec6a158bd7aa62', - tokenName: 'Starknet ERC-20 sample', - tokenSymbol: ' ', - tokenDecimals: 18, - }; - apiParams.requestParams = requestObject; - - let result; - try { - await addErc20Token(apiParams); - } catch (err) { - result = err; - } finally { - expect(result).to.be.an('Error'); - } - }); - - it('should throw error if tokenAddress is one of the preload token addresses', async function () { - const requestObject: AddErc20TokenRequestParams = { - tokenAddress: - '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7', - tokenName: 'Starknet ERC-20 sample', - tokenSymbol: 'SNET', - tokenDecimals: 18, - }; - apiParams.requestParams = requestObject; - - let result; - try { - await addErc20Token(apiParams); - } catch (err) { - result = err; - } finally { - expect(result).to.be.an('Error'); - } - }); - - it('should throw error if tokenName is one of the preload token names', async function () { - const requestObject: AddErc20TokenRequestParams = { - tokenAddress: - '0x244c20d51109adcf604fde1bbf878e5dcd549b3877ac87911ec6a158bd7aa62', - tokenName: 'Ether', - tokenSymbol: 'SNET', - tokenDecimals: 18, - }; - apiParams.requestParams = requestObject; - - let result; - try { - await addErc20Token(apiParams); - } catch (err) { - result = err; - } finally { - expect(result).to.be.an('Error'); - } - }); - - it('should throw error if tokenSymbol is one of the preload token symbols', async function () { - const requestObject: AddErc20TokenRequestParams = { - tokenAddress: - '0x244c20d51109adcf604fde1bbf878e5dcd549b3877ac87911ec6a158bd7aa62', - tokenName: 'Starknet ERC-20 sample', - tokenSymbol: 'ETH', - tokenDecimals: 18, - }; - apiParams.requestParams = requestObject; - - let result; - try { - await addErc20Token(apiParams); - } catch (err) { - result = err; - } finally { - expect(result).to.be.an('Error'); - } - }); -}); From 063b2a6f8abd4d23d03778f71d21aaebc95e47b4 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 18 Oct 2024 19:56:09 +0800 Subject: [PATCH 17/50] refactor(SNAP): rename file and clean up (#391) * chore: rename file * chore: remove unused var and file * fix: lint --- ...ey.test.ts => display-private-key.test.ts} | 4 +- ...ayPrivateKey.ts => display-private-key.ts} | 0 ...timateFee.test.ts => estimate-fee.test.ts} | 4 +- .../rpcs/{estimateFee.ts => estimate-fee.ts} | 0 ...executeTxn.test.ts => execute-txn.test.ts} | 4 +- .../rpcs/{executeTxn.ts => execute-txn.ts} | 0 packages/starknet-snap/src/rpcs/index.ts | 10 +-- ...gnMessage.test.ts => sign-message.test.ts} | 4 +- .../rpcs/{signMessage.ts => sign-message.ts} | 0 ...ction.test.ts => sign-transaction.test.ts} | 4 +- ...signTransaction.ts => sign-transaction.ts} | 0 .../src/typedData/typedDataExample.json | 35 ---------- packages/starknet-snap/src/types/snapApi.ts | 64 +------------------ ...rUtils.test.ts => formatter-utils.test.ts} | 2 +- .../{formatterUtils.ts => formatter-utils.ts} | 0 packages/starknet-snap/src/utils/index.ts | 2 +- .../starknet-snap/src/utils/starknetUtils.ts | 2 +- .../src/utils/superstruct.test.ts | 52 +-------------- .../starknet-snap/src/utils/superstruct.ts | 41 +----------- .../test/utils/starknetUtils.test.ts | 2 +- 20 files changed, 23 insertions(+), 207 deletions(-) rename packages/starknet-snap/src/rpcs/{displayPrivateKey.test.ts => display-private-key.test.ts} (96%) rename packages/starknet-snap/src/rpcs/{displayPrivateKey.ts => display-private-key.ts} (100%) rename packages/starknet-snap/src/rpcs/{estimateFee.test.ts => estimate-fee.test.ts} (96%) rename packages/starknet-snap/src/rpcs/{estimateFee.ts => estimate-fee.ts} (100%) rename packages/starknet-snap/src/rpcs/{executeTxn.test.ts => execute-txn.test.ts} (98%) rename packages/starknet-snap/src/rpcs/{executeTxn.ts => execute-txn.ts} (100%) rename packages/starknet-snap/src/rpcs/{signMessage.test.ts => sign-message.test.ts} (96%) rename packages/starknet-snap/src/rpcs/{signMessage.ts => sign-message.ts} (100%) rename packages/starknet-snap/src/rpcs/{signTransaction.test.ts => sign-transaction.test.ts} (97%) rename packages/starknet-snap/src/rpcs/{signTransaction.ts => sign-transaction.ts} (100%) delete mode 100644 packages/starknet-snap/src/typedData/typedDataExample.json rename packages/starknet-snap/src/utils/{formatterUtils.test.ts => formatter-utils.test.ts} (96%) rename packages/starknet-snap/src/utils/{formatterUtils.ts => formatter-utils.ts} (100%) diff --git a/packages/starknet-snap/src/rpcs/displayPrivateKey.test.ts b/packages/starknet-snap/src/rpcs/display-private-key.test.ts similarity index 96% rename from packages/starknet-snap/src/rpcs/displayPrivateKey.test.ts rename to packages/starknet-snap/src/rpcs/display-private-key.test.ts index 2fefde3d..fc6a17f8 100644 --- a/packages/starknet-snap/src/rpcs/displayPrivateKey.test.ts +++ b/packages/starknet-snap/src/rpcs/display-private-key.test.ts @@ -12,8 +12,8 @@ import { prepareMockAccount, prepareConfirmDialog, } from './__tests__/helper'; -import { displayPrivateKey } from './displayPrivateKey'; -import type { DisplayPrivateKeyParams } from './displayPrivateKey'; +import { displayPrivateKey } from './display-private-key'; +import type { DisplayPrivateKeyParams } from './display-private-key'; jest.mock('../utils/logger'); diff --git a/packages/starknet-snap/src/rpcs/displayPrivateKey.ts b/packages/starknet-snap/src/rpcs/display-private-key.ts similarity index 100% rename from packages/starknet-snap/src/rpcs/displayPrivateKey.ts rename to packages/starknet-snap/src/rpcs/display-private-key.ts diff --git a/packages/starknet-snap/src/rpcs/estimateFee.test.ts b/packages/starknet-snap/src/rpcs/estimate-fee.test.ts similarity index 96% rename from packages/starknet-snap/src/rpcs/estimateFee.test.ts rename to packages/starknet-snap/src/rpcs/estimate-fee.test.ts index bada8c5c..87e1234a 100644 --- a/packages/starknet-snap/src/rpcs/estimateFee.test.ts +++ b/packages/starknet-snap/src/rpcs/estimate-fee.test.ts @@ -9,8 +9,8 @@ import { InvalidRequestParamsError } from '../utils/exceptions'; import * as starknetUtils from '../utils/starknetUtils'; import type { TxVersionStruct } from '../utils/superstruct'; import { mockAccount, prepareMockAccount } from './__tests__/helper'; -import { estimateFee } from './estimateFee'; -import type { EstimateFeeParams } from './estimateFee'; +import { estimateFee } from './estimate-fee'; +import type { EstimateFeeParams } from './estimate-fee'; jest.mock('../utils/snap'); jest.mock('../utils/logger'); diff --git a/packages/starknet-snap/src/rpcs/estimateFee.ts b/packages/starknet-snap/src/rpcs/estimate-fee.ts similarity index 100% rename from packages/starknet-snap/src/rpcs/estimateFee.ts rename to packages/starknet-snap/src/rpcs/estimate-fee.ts diff --git a/packages/starknet-snap/src/rpcs/executeTxn.test.ts b/packages/starknet-snap/src/rpcs/execute-txn.test.ts similarity index 98% rename from packages/starknet-snap/src/rpcs/executeTxn.test.ts rename to packages/starknet-snap/src/rpcs/execute-txn.test.ts index b51c701b..9dc6e58b 100644 --- a/packages/starknet-snap/src/rpcs/executeTxn.test.ts +++ b/packages/starknet-snap/src/rpcs/execute-txn.test.ts @@ -16,8 +16,8 @@ import { prepareConfirmDialog, prepareMockAccount, } from './__tests__/helper'; -import type { ExecuteTxnParams } from './executeTxn'; -import { executeTxn } from './executeTxn'; +import type { ExecuteTxnParams } from './execute-txn'; +import { executeTxn } from './execute-txn'; jest.mock('../utils/snap'); jest.mock('../utils/logger'); diff --git a/packages/starknet-snap/src/rpcs/executeTxn.ts b/packages/starknet-snap/src/rpcs/execute-txn.ts similarity index 100% rename from packages/starknet-snap/src/rpcs/executeTxn.ts rename to packages/starknet-snap/src/rpcs/execute-txn.ts diff --git a/packages/starknet-snap/src/rpcs/index.ts b/packages/starknet-snap/src/rpcs/index.ts index 428b7514..d4bd560e 100644 --- a/packages/starknet-snap/src/rpcs/index.ts +++ b/packages/starknet-snap/src/rpcs/index.ts @@ -1,8 +1,8 @@ -export * from './displayPrivateKey'; -export * from './estimateFee'; -export * from './executeTxn'; -export * from './signMessage'; -export * from './signTransaction'; +export * from './display-private-key'; +export * from './estimate-fee'; +export * from './execute-txn'; +export * from './sign-message'; +export * from './sign-transaction'; export * from './sign-declare-transaction'; export * from './verify-signature'; export * from './switch-network'; diff --git a/packages/starknet-snap/src/rpcs/signMessage.test.ts b/packages/starknet-snap/src/rpcs/sign-message.test.ts similarity index 96% rename from packages/starknet-snap/src/rpcs/signMessage.test.ts rename to packages/starknet-snap/src/rpcs/sign-message.test.ts index 50b03727..1e219ffe 100644 --- a/packages/starknet-snap/src/rpcs/signMessage.test.ts +++ b/packages/starknet-snap/src/rpcs/sign-message.test.ts @@ -14,8 +14,8 @@ import { prepareMockAccount, prepareConfirmDialog, } from './__tests__/helper'; -import { signMessage } from './signMessage'; -import type { SignMessageParams } from './signMessage'; +import { signMessage } from './sign-message'; +import type { SignMessageParams } from './sign-message'; jest.mock('../utils/snap'); jest.mock('../utils/logger'); diff --git a/packages/starknet-snap/src/rpcs/signMessage.ts b/packages/starknet-snap/src/rpcs/sign-message.ts similarity index 100% rename from packages/starknet-snap/src/rpcs/signMessage.ts rename to packages/starknet-snap/src/rpcs/sign-message.ts diff --git a/packages/starknet-snap/src/rpcs/signTransaction.test.ts b/packages/starknet-snap/src/rpcs/sign-transaction.test.ts similarity index 97% rename from packages/starknet-snap/src/rpcs/signTransaction.test.ts rename to packages/starknet-snap/src/rpcs/sign-transaction.test.ts index d4f37ec2..0d4026a2 100644 --- a/packages/starknet-snap/src/rpcs/signTransaction.test.ts +++ b/packages/starknet-snap/src/rpcs/sign-transaction.test.ts @@ -15,8 +15,8 @@ import { prepareMockAccount, prepareConfirmDialog, } from './__tests__/helper'; -import { signTransaction } from './signTransaction'; -import type { SignTransactionParams } from './signTransaction'; +import { signTransaction } from './sign-transaction'; +import type { SignTransactionParams } from './sign-transaction'; jest.mock('../utils/logger'); diff --git a/packages/starknet-snap/src/rpcs/signTransaction.ts b/packages/starknet-snap/src/rpcs/sign-transaction.ts similarity index 100% rename from packages/starknet-snap/src/rpcs/signTransaction.ts rename to packages/starknet-snap/src/rpcs/sign-transaction.ts diff --git a/packages/starknet-snap/src/typedData/typedDataExample.json b/packages/starknet-snap/src/typedData/typedDataExample.json deleted file mode 100644 index d8bb55b9..00000000 --- a/packages/starknet-snap/src/typedData/typedDataExample.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "types": { - "StarkNetDomain": [ - { "name": "name", "type": "felt" }, - { "name": "version", "type": "felt" }, - { "name": "chainId", "type": "felt" } - ], - "Person": [ - { "name": "name", "type": "felt" }, - { "name": "wallet", "type": "felt" } - ], - "Mail": [ - { "name": "from", "type": "Person" }, - { "name": "to", "type": "Person" }, - { "name": "contents", "type": "felt" } - ] - }, - "primaryType": "Mail", - "domain": { - "name": "Starknet Mail", - "version": "1", - "chainId": 1 - }, - "message": { - "from": { - "name": "Cow", - "wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" - }, - "to": { - "name": "Bob", - "wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" - }, - "contents": "Hello, Bob!" - } -} diff --git a/packages/starknet-snap/src/types/snapApi.ts b/packages/starknet-snap/src/types/snapApi.ts index 87f3eccd..639952de 100644 --- a/packages/starknet-snap/src/types/snapApi.ts +++ b/packages/starknet-snap/src/types/snapApi.ts @@ -1,16 +1,11 @@ import type { BIP44AddressKeyDeriver } from '@metamask/key-tree'; import type Mutex from 'async-mutex/lib/Mutex'; import type { - Abi, - Call, - InvocationsSignerDetails, DeclareContractPayload, InvocationsDetails, Invocations, EstimateFeeDetails, DeployAccountSignerDetails, - DeclareSignerDetails, - typedData, constants, } from 'starknet'; @@ -31,27 +26,21 @@ export type ApiParamsWithKeyDeriver = ApiParams & { export type ApiRequestParams = | CreateAccountRequestParams | GetStoredUserAccountsRequestParams - | ExtractPrivateKeyRequestParams | ExtractPublicKeyRequestParams - | SignMessageRequestParams - | VerifySignedMessageRequestParams | GetErc20TokenBalanceRequestParams | GetTransactionStatusRequestParams | SendTransactionRequestParams | GetValueRequestParams | EstimateFeeRequestParams | EstimateAccountDeployFeeRequestParams - | AddErc20TokenRequestParams | GetStoredErc20TokensRequestParams | AddNetworkRequestParams | GetStoredNetworksRequestParams | GetStoredTransactionsRequestParams | GetTransactionsRequestParams | RecoverAccountsRequestParams - | ExecuteTxnRequestParams | EstimateFeesRequestParams - | DeclareContractRequestParams - | SignTransactionRequestParams; + | DeclareContractRequestParams; export type BaseRequestParams = { chainId?: string; @@ -74,26 +63,10 @@ export type GetStoredErc20TokensRequestParams = BaseRequestParams; export type GetStoredNetworksRequestParams = Omit; -export type ExtractPrivateKeyRequestParams = { - userAddress: string; -} & BaseRequestParams; - export type ExtractPublicKeyRequestParams = { userAddress: string; } & BaseRequestParams; -export type SignMessageRequestParams = { - typedDataMessage: typeof typedData.TypedData; -} & Authorizable & - SignRequestParams & - BaseRequestParams; - -export type VerifySignedMessageRequestParams = { - signerAddress: string; - signature: string; - typedDataMessage?: string; -} & BaseRequestParams; - export type GetErc20TokenBalanceRequestParams = { tokenAddress: string; userAddress: string; @@ -134,13 +107,6 @@ export type EstimateAccountDeployFeeRequestParams = { addressIndex?: string | number; } & BaseRequestParams; -export type AddErc20TokenRequestParams = { - tokenAddress: string; - tokenName: string; - tokenSymbol: string; - tokenDecimals?: string | number; -} & BaseRequestParams; - export type AddNetworkRequestParams = { networkName: string; networkChainId: string; @@ -172,13 +138,6 @@ export type RecoverAccountsRequestParams = { maxMissed?: string | number; } & BaseRequestParams; -export type ExecuteTxnRequestParams = { - senderAddress: string; - txnInvocation: Call | Call[]; - abis?: Abi[]; - invocationsDetails?: InvocationsDetails; -} & BaseRequestParams; - export type EstimateFeesRequestParams = { senderAddress: string; invocations: Invocations; @@ -205,30 +164,12 @@ export type SignRequestParams = { signerAddress: string; }; -export type SignTransactionRequestParams = { - transactions: Call[]; - transactionsDetail: InvocationsSignerDetails; -} & Authorizable & - SignRequestParams & - BaseRequestParams; - export type SignDeployAccountTransactionRequestParams = { transaction: DeployAccountSignerDetails; } & Authorizable & SignRequestParams & BaseRequestParams; -export type SignDeclareTransactionRequestParams = { - transaction: DeclareSignerDetails; -} & Authorizable & - SignRequestParams & - BaseRequestParams; - -export type SwitchNetworkRequestParams = { - chainId: string; -} & Authorizable & - BaseRequestParams; - export type GetStarkNameRequestParam = { userAddress: string; } & BaseRequestParams; @@ -242,5 +183,4 @@ export enum FeeTokenUnit { ETH = 'wei', STRK = 'fri', } - -/* eslint-disable */ +/* eslint-enable */ diff --git a/packages/starknet-snap/src/utils/formatterUtils.test.ts b/packages/starknet-snap/src/utils/formatter-utils.test.ts similarity index 96% rename from packages/starknet-snap/src/utils/formatterUtils.test.ts rename to packages/starknet-snap/src/utils/formatter-utils.test.ts index 0ea9b941..4e6dc850 100644 --- a/packages/starknet-snap/src/utils/formatterUtils.test.ts +++ b/packages/starknet-snap/src/utils/formatter-utils.test.ts @@ -1,4 +1,4 @@ -import { mapDeprecatedParams } from './formatterUtils'; +import { mapDeprecatedParams } from './formatter-utils'; describe('mapDeprecatedParams', () => { it('maps deprecated parameters to their new equivalents', () => { diff --git a/packages/starknet-snap/src/utils/formatterUtils.ts b/packages/starknet-snap/src/utils/formatter-utils.ts similarity index 100% rename from packages/starknet-snap/src/utils/formatterUtils.ts rename to packages/starknet-snap/src/utils/formatter-utils.ts diff --git a/packages/starknet-snap/src/utils/index.ts b/packages/starknet-snap/src/utils/index.ts index f0c8bc07..b43b60e6 100644 --- a/packages/starknet-snap/src/utils/index.ts +++ b/packages/starknet-snap/src/utils/index.ts @@ -4,7 +4,7 @@ export * from './snap'; export * from './serializer'; export * from './superstruct'; export * from './logger'; -export * from './formatterUtils'; +export * from './formatter-utils'; export * from './snap-state'; export * from './url'; export * from './string'; diff --git a/packages/starknet-snap/src/utils/starknetUtils.ts b/packages/starknet-snap/src/utils/starknetUtils.ts index 3c3fe673..7c610528 100644 --- a/packages/starknet-snap/src/utils/starknetUtils.ts +++ b/packages/starknet-snap/src/utils/starknetUtils.ts @@ -70,7 +70,7 @@ import { BlockIdentifierEnum, } from './constants'; import { DeployRequiredError, UpgradeRequiredError } from './exceptions'; -import { hexToString } from './formatterUtils'; +import { hexToString } from './formatter-utils'; import { getAddressKey } from './keyPair'; import { logger } from './logger'; import { toJson } from './serializer'; diff --git a/packages/starknet-snap/src/utils/superstruct.test.ts b/packages/starknet-snap/src/utils/superstruct.test.ts index 1e978c4e..44c8eced 100644 --- a/packages/starknet-snap/src/utils/superstruct.test.ts +++ b/packages/starknet-snap/src/utils/superstruct.test.ts @@ -1,5 +1,5 @@ import { constants, TransactionType } from 'starknet'; -import { StructError, assert, object, number, string } from 'superstruct'; +import { StructError, assert } from 'superstruct'; import contractExample from '../__tests__/fixture/contract-example.json'; import transactionExample from '../__tests__/fixture/transactionExample.json'; @@ -16,7 +16,6 @@ import { BaseRequestStruct, CairoVersionStruct, CallDataStruct, - createStructWithAdditionalProperties, DeclareSignDetailsStruct, EDataModeStruct, NumberStringStruct, @@ -387,55 +386,6 @@ describe('DeclareSignDetailsStruct', () => { }); }); -describe('createStructWithAdditionalProperties', () => { - const predefinedProperties = object({ - name: string(), - age: number(), - }); - - const additionalPropertyTypes = string(); // Additional properties should be strings - const ExtendedPropStruct = createStructWithAdditionalProperties( - predefinedProperties, - additionalPropertyTypes, - ); - it('should validate predefined properties correctly', () => { - const validData = { - name: 'John', - age: 30, - }; - const [error, result] = ExtendedPropStruct.validate(validData); - - expect(error).toBeUndefined(); - expect(result).toStrictEqual(validData); - }); - - it('should validate additional properties correctly', () => { - const validDataWithExtra = { - name: 'John', - age: 30, - nickname: 'Johnny', - }; - - const [error, result] = ExtendedPropStruct.validate(validDataWithExtra); - - expect(error).toBeUndefined(); - expect(result).toStrictEqual(validDataWithExtra); - }); - - it('should fail validation if additional properties are of the wrong type', () => { - const invalidData = { - name: 'John', - age: 30, - nickname: 12345, // Invalid type for additional property - }; - - const [error] = ExtendedPropStruct.validate(invalidData); - - expect(error).toBeDefined(); - expect(error?.message).toContain('Expected a string, but received'); - }); -}); - describe('InvocationsStruct', () => { it.each([ { diff --git a/packages/starknet-snap/src/utils/superstruct.ts b/packages/starknet-snap/src/utils/superstruct.ts index 7ee6fb40..94082863 100644 --- a/packages/starknet-snap/src/utils/superstruct.ts +++ b/packages/starknet-snap/src/utils/superstruct.ts @@ -20,7 +20,6 @@ import { number, array, assign, - dynamic, define, mask, validate, @@ -189,44 +188,6 @@ export const DeclareSignDetailsStruct = assign( }), ); -/** - * Creates a struct that combines predefined properties with additional dynamic properties. - * - * This function generates a Superstruct schema that includes both the predefined properties - * and any additional properties found in the input. The additional properties are validated - * according to the specified `additionalPropertyTypes`, or `any` if not provided. - * - * @param predefinedProperties - A Superstruct schema defining the base set of properties that are expected. - * @param additionalPropertyTypes - A Superstruct schema that defines the types for any additional properties. - * Defaults to `any`, allowing any additional properties. - * @returns A dynamic struct that first validates against the predefined properties and then - * includes any additional properties that match the `additionalPropertyTypes` schema. - */ -export const createStructWithAdditionalProperties = ( - predefinedProperties: Struct, - additionalPropertyTypes: Struct = any(), -) => { - return dynamic((value) => { - if (typeof value !== 'object' || value === null) { - return predefinedProperties; - } - - const additionalProperties = Object.keys(value).reduce< - Record - >((schema, key) => { - if (!(key in predefinedProperties.schema)) { - schema[key] = additionalPropertyTypes; - } - return schema; - }, {}); - - return assign(predefinedProperties, object(additionalProperties)); - }); -}; - -// Define the types you expect for additional properties -export const additionalPropertyTypes = union([string(), number(), any()]); - /* ------------------------------ Contract Struct ------------------------------ */ /* eslint-disable */ export const SierraContractEntryPointFieldsStruct = object({ @@ -275,7 +236,7 @@ export const LegacyCompiledContractStruct = object({ entry_points_by_type: EntryPointByTypeStruct, abi: any(), }); -/* eslint-disable */ +/* eslint-enable */ /* ------------------------------ Contract Struct ------------------------------ */ // TODO: add unit test diff --git a/packages/starknet-snap/test/utils/starknetUtils.test.ts b/packages/starknet-snap/test/utils/starknetUtils.test.ts index 72a039af..0287145c 100644 --- a/packages/starknet-snap/test/utils/starknetUtils.test.ts +++ b/packages/starknet-snap/test/utils/starknetUtils.test.ts @@ -26,7 +26,7 @@ import { Provider, GetTransactionReceiptResponse, } from 'starknet'; -import { hexToString } from '../../src/utils/formatterUtils'; +import { hexToString } from '../../src/utils/formatter-utils'; import { BIP44AddressKeyDeriver } from '@metamask/key-tree'; chai.use(sinonChai); From c9c1aafb45120b7d0767337f300e1ab6ff277ab3 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 22 Oct 2024 09:55:46 +0800 Subject: [PATCH 18/50] chore: remove ui dependencies on snap (#397) --- packages/wallet-ui/package.json | 1 - packages/wallet-ui/src/types/index.ts | 51 +++++++++++++++++++-------- yarn.lock | 18 ---------- 3 files changed, 36 insertions(+), 34 deletions(-) diff --git a/packages/wallet-ui/package.json b/packages/wallet-ui/package.json index 09322aac..1024ccd0 100644 --- a/packages/wallet-ui/package.json +++ b/packages/wallet-ui/package.json @@ -44,7 +44,6 @@ "terser": "^5.14.2" }, "dependencies": { - "@consensys/starknet-snap": "file:../starknet-snap", "@emotion/react": "^11.9.0", "@emotion/styled": "^11.8.1", "@fortawesome/fontawesome-svg-core": "^6.1.1", diff --git a/packages/wallet-ui/src/types/index.ts b/packages/wallet-ui/src/types/index.ts index 783306a1..d6c064a8 100644 --- a/packages/wallet-ui/src/types/index.ts +++ b/packages/wallet-ui/src/types/index.ts @@ -1,16 +1,42 @@ -import * as Types from '@consensys/starknet-snap/src/types/snapState'; import { BigNumber } from 'ethers'; -export type Account = Pick< - Types.AccContract, - 'address' | 'publicKey' | 'upgradeRequired' | 'deployRequired' ->; -export type Network = Pick< - Types.Network, - 'name' | 'chainId' | 'baseUrl' | 'nodeUrl' ->; +export type Transaction = { + txnHash: string; // in hex + txnType: string; + chainId: string; // in hex + senderAddress: string; // in hex + contractAddress: string; // in hex + contractFuncName: string; + contractCallData: string[] | number[]; + status?: TransactionStatus | string; + executionStatus?: TransactionStatus | string; + finalityStatus?: TransactionStatus | string; + failureReason: string; + eventIds: string[]; + timestamp: number; +}; -export interface Erc20TokenBalance extends Types.Erc20Token { +export type Account = { + address: string; + publicKey: string; + upgradeRequired: boolean; + deployRequired: boolean; +}; + +export type Network = { + name: string; + chainId: string; +}; + +export interface Erc20Token { + address: string; + name: string; + symbol: string; + decimals: number; + chainId: string; +} + +export interface Erc20TokenBalance extends Erc20Token { amount: BigNumber; usdPrice?: number; } @@ -42,11 +68,6 @@ export enum BalanceType { Total = 'total', } -export type { - Erc20Token, - Transaction, -} from '@consensys/starknet-snap/src/types/snapState'; - // Define the type for your token balances export interface TokenBalance { balance: BigNumber; diff --git a/yarn.lock b/yarn.lock index 235fb486..8b2a3824 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2214,23 +2214,6 @@ __metadata: languageName: unknown linkType: soft -"@consensys/starknet-snap@file:../starknet-snap::locator=wallet-ui%40workspace%3Apackages%2Fwallet-ui": - version: 2.9.0 - resolution: "@consensys/starknet-snap@file:../starknet-snap#../starknet-snap::hash=e2922a&locator=wallet-ui%40workspace%3Apackages%2Fwallet-ui" - dependencies: - "@metamask/key-tree": 9.0.0 - "@metamask/snaps-sdk": ^4.0.0 - async-mutex: ^0.3.2 - ethereum-unit-converter: ^0.0.17 - ethers: ^5.5.1 - starknet: 6.11.0 - starknet_v4.22.0: "npm:starknet@4.22.0" - superstruct: ^2.0.2 - uuid: ^10.0.0 - checksum: 67b923b31e050b59a219a0484548aaf5e8ff5453215ed5e32cc2d62209f49aad0c9eb087fd750e2cfd14d0f40d20181976da16940bd8982f650829ff0db8b3df - languageName: node - linkType: hard - "@consensys/starknet-snap@workspace:packages/starknet-snap": version: 0.0.0-use.local resolution: "@consensys/starknet-snap@workspace:packages/starknet-snap" @@ -27472,7 +27455,6 @@ __metadata: version: 0.0.0-use.local resolution: "wallet-ui@workspace:packages/wallet-ui" dependencies: - "@consensys/starknet-snap": "file:../starknet-snap" "@craco/craco": ^7.1.0 "@emotion/react": ^11.9.0 "@emotion/styled": ^11.8.1 From 5617ccf85af58943313ef81bf3a03deed0c4eb0f Mon Sep 17 00:00:00 2001 From: khanti42 Date: Tue, 22 Oct 2024 11:02:40 +0200 Subject: [PATCH 19/50] refactor(DeclareContract): revamp RPC starkNet_declareContract (#398) * feat: refactor RPC starkNet_declareContract to have superstruct validation * Update packages/starknet-snap/src/rpcs/declare-contract.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/rpcs/declare-contract.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/rpcs/declare-contract.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/rpcs/declare-contract.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/rpcs/declare-contract.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/rpcs/declare-contract.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * chore: fix comments * Update packages/starknet-snap/src/rpcs/declare-contract.test.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/rpcs/declare-contract.test.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/rpcs/declare-contract.test.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/rpcs/declare-contract.test.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * chore: fix comments on tests * chore: add generateRandomFee to standardize fee generation in tests * chore: use network from prepareMockDeclareContract * chore: use existing generateRandomValue helper --------- Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> --- packages/starknet-snap/src/declareContract.ts | 75 ------ packages/starknet-snap/src/index.ts | 8 +- .../src/rpcs/__tests__/helper.ts | 26 +- .../src/rpcs/declare-contract.test.ts | 229 ++++++++++++++++++ .../src/rpcs/declare-contract.ts | 208 ++++++++++++++++ .../src/rpcs/execute-txn.test.ts | 5 +- packages/starknet-snap/src/rpcs/index.ts | 1 + .../starknet-snap/src/utils/superstruct.ts | 2 +- .../test/src/declareContract.test.ts | 146 ----------- 9 files changed, 471 insertions(+), 229 deletions(-) delete mode 100644 packages/starknet-snap/src/declareContract.ts create mode 100644 packages/starknet-snap/src/rpcs/declare-contract.test.ts create mode 100644 packages/starknet-snap/src/rpcs/declare-contract.ts delete mode 100644 packages/starknet-snap/test/src/declareContract.test.ts diff --git a/packages/starknet-snap/src/declareContract.ts b/packages/starknet-snap/src/declareContract.ts deleted file mode 100644 index 583e244e..00000000 --- a/packages/starknet-snap/src/declareContract.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { heading, panel, DialogType } from '@metamask/snaps-sdk'; - -import type { - ApiParamsWithKeyDeriver, - DeclareContractRequestParams, -} from './types/snapApi'; -import { logger } from './utils/logger'; -import { toJson } from './utils/serializer'; -import { - getNetworkFromChainId, - getDeclareSnapTxt, - verifyIfAccountNeedUpgradeOrDeploy, -} from './utils/snapUtils'; -import { - getKeysFromAddress, - declareContract as declareContractUtil, -} from './utils/starknetUtils'; - -/** - * - * @param params - */ -export async function declareContract(params: ApiParamsWithKeyDeriver) { - try { - const { state, keyDeriver, requestParams, wallet } = params; - - const requestParamsObj = requestParams as DeclareContractRequestParams; - - logger.log(`executeTxn params: ${toJson(requestParamsObj, 2)}}`); - - const { senderAddress } = requestParamsObj; - const network = getNetworkFromChainId(state, requestParamsObj.chainId); - const { privateKey, publicKey } = await getKeysFromAddress( - keyDeriver, - network, - state, - senderAddress, - ); - - await verifyIfAccountNeedUpgradeOrDeploy(network, senderAddress, publicKey); - - const snapComponents = getDeclareSnapTxt( - senderAddress, - network, - requestParamsObj.contractPayload, - requestParamsObj.invocationsDetails, - ); - - const response = await wallet.request({ - method: 'snap_dialog', - params: { - type: DialogType.Confirmation, - content: panel([ - heading('Do you want to sign this transaction?'), - ...snapComponents, - ]), - }, - }); - - if (!response) { - return false; - } - - return await declareContractUtil( - network, - senderAddress, - privateKey, - requestParamsObj.contractPayload, - requestParamsObj.invocationsDetails, - ); - } catch (error) { - logger.error(`Problem found:`, error); - throw error; - } -} diff --git a/packages/starknet-snap/src/index.ts b/packages/starknet-snap/src/index.ts index 80a5349c..6c71914b 100644 --- a/packages/starknet-snap/src/index.ts +++ b/packages/starknet-snap/src/index.ts @@ -9,7 +9,6 @@ import { panel, text, MethodNotFoundError } from '@metamask/snaps-sdk'; import { addNetwork } from './addNetwork'; import { Config } from './config'; import { createAccount } from './createAccount'; -import { declareContract } from './declareContract'; import { estimateAccDeployFee } from './estimateAccountDeployFee'; import { estimateFees } from './estimateFees'; import { extractPublicKey } from './extractPublicKey'; @@ -35,12 +34,14 @@ import type { VerifySignatureParams, SwitchNetworkParams, GetDeploymentDataParams, + DeclareContractParams, WatchAssetParams, } from './rpcs'; import { displayPrivateKey, estimateFee, executeTxn, + declareContract, signMessage, signTransaction, signDeclareTransaction, @@ -276,9 +277,8 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { ); case 'starkNet_declareContract': - apiParams.keyDeriver = await getAddressKeyDeriver(snap); - return await declareContract( - apiParams as unknown as ApiParamsWithKeyDeriver, + return await declareContract.execute( + apiParams as unknown as DeclareContractParams, ); case 'starkNet_getStarkName': diff --git a/packages/starknet-snap/src/rpcs/__tests__/helper.ts b/packages/starknet-snap/src/rpcs/__tests__/helper.ts index 06734f23..70c08cd8 100644 --- a/packages/starknet-snap/src/rpcs/__tests__/helper.ts +++ b/packages/starknet-snap/src/rpcs/__tests__/helper.ts @@ -1,7 +1,8 @@ +import { BigNumber } from 'ethers'; import type { constants } from 'starknet'; import type { StarknetAccount } from '../../__tests__/helper'; -import { generateAccounts } from '../../__tests__/helper'; +import { generateAccounts, generateRandomValue } from '../../__tests__/helper'; import type { SnapState } from '../../types/snapState'; import * as snapHelper from '../../utils/snap'; import * as snapUtils from '../../utils/snapUtils'; @@ -82,3 +83,26 @@ export const buildDividerComponent = () => { type: 'divider', }; }; + +/** + * + * @param min + * @param max + * @param useBigInt + */ +export function generateRandomFee( + min = '100000000000000', + max = '1000000000000000', + useBigInt = false, +) { + const minFee = BigInt(min); + const maxFee = BigInt(max); + const randomFactor = generateRandomValue(); + const randomFee = BigInt( + Math.max(Number(minFee), Math.floor(randomFactor * Number(maxFee))), + ); + + return useBigInt + ? randomFee.toString(10) + : BigNumber.from(randomFee).toString(); +} diff --git a/packages/starknet-snap/src/rpcs/declare-contract.test.ts b/packages/starknet-snap/src/rpcs/declare-contract.test.ts new file mode 100644 index 00000000..4f916a9b --- /dev/null +++ b/packages/starknet-snap/src/rpcs/declare-contract.test.ts @@ -0,0 +1,229 @@ +import { utils } from 'ethers'; +import type { Abi, UniversalDetails } from 'starknet'; +import { constants } from 'starknet'; +import type { Infer } from 'superstruct'; + +import { toJson, type DeclareContractPayloadStruct } from '../utils'; +import { STARKNET_SEPOLIA_TESTNET_NETWORK } from '../utils/constants'; +import { + UserRejectedOpError, + InvalidRequestParamsError, + UnknownError, +} from '../utils/exceptions'; +import * as starknetUtils from '../utils/starknetUtils'; +import { + buildDividerComponent, + buildRowComponent, + generateRandomFee, + mockAccount, + prepareConfirmDialog, + prepareMockAccount, +} from './__tests__/helper'; +import { declareContract } from './declare-contract'; +import type { + DeclareContractParams, + DeclareContractResponse, +} from './declare-contract'; + +jest.mock('../utils/snap'); +jest.mock('../utils/logger'); + +type DeclareContractPayload = Infer; + +// Helper function to generate the expected DeclareContractPayload +const generateExpectedDeclareTransactionPayload = + (): DeclareContractPayload => ({ + compiledClassHash: '0xcompiledClassHash', + classHash: '0xclassHash', + contract: { + // eslint-disable-next-line @typescript-eslint/naming-convention + sierra_program: ['0x1', '0x2'], + // eslint-disable-next-line @typescript-eslint/naming-convention + contract_class_version: '1.0.0', + // eslint-disable-next-line @typescript-eslint/naming-convention + entry_points_by_type: { + // eslint-disable-next-line @typescript-eslint/naming-convention + CONSTRUCTOR: [{ selector: '0xconstructorSelector', function_idx: 0 }], + // eslint-disable-next-line @typescript-eslint/naming-convention + EXTERNAL: [{ selector: '0xexternalSelector', function_idx: 1 }], + // eslint-disable-next-line @typescript-eslint/naming-convention + L1_HANDLER: [{ selector: '0xhandlerSelector', function_idx: 2 }], + }, + abi: '[{"type":"function","name":"transfer"}]' as unknown as Abi, + }, + }); + +const prepareMockDeclareContract = async ( + transactionHash: string, + payload: DeclareContractPayload, + details: UniversalDetails, +) => { + const state = { + accContracts: [], + erc20Tokens: [], + networks: [STARKNET_SEPOLIA_TESTNET_NETWORK], + transactions: [], + }; + const { confirmDialogSpy } = prepareConfirmDialog(); + + const account = await mockAccount(constants.StarknetChainId.SN_SEPOLIA); + prepareMockAccount(account, state); + + const request = { + chainId: state.networks[0].chainId as unknown as constants.StarknetChainId, + address: account.address, + payload, + details, + }; + + const declareContractRespMock: DeclareContractResponse = { + // eslint-disable-next-line @typescript-eslint/naming-convention + transaction_hash: transactionHash, + // eslint-disable-next-line @typescript-eslint/naming-convention + class_hash: '0x123456789abcdef', + }; + + const declareContractUtilSpy = jest.spyOn(starknetUtils, 'declareContract'); + declareContractUtilSpy.mockResolvedValue(declareContractRespMock); + + return { + network: state.networks[0], + account, + request, + confirmDialogSpy, + declareContractRespMock, + declareContractUtilSpy, + }; +}; + +describe('DeclareContractRpc', () => { + it('declares a contract correctly if user confirms the dialog', async () => { + const payload = generateExpectedDeclareTransactionPayload(); + const details = { + maxFee: generateRandomFee('1000000000000000', '2000000000000000'), + }; + const transactionHash = '0x123'; + + const { + account, + request, + network, + declareContractRespMock, + confirmDialogSpy, + declareContractUtilSpy, + } = await prepareMockDeclareContract(transactionHash, payload, details); + + confirmDialogSpy.mockResolvedValue(true); + + const result = await declareContract.execute(request); + + expect(result).toStrictEqual(declareContractRespMock); + expect(declareContractUtilSpy).toHaveBeenCalledWith( + network, + account.address, + account.privateKey, + request.payload, + request.details, + ); + }); + + it('throws UserRejectedOpError if user cancels the dialog', async () => { + const payload = generateExpectedDeclareTransactionPayload(); + const details = { + maxFee: generateRandomFee('1000000000000000', '2000000000000000'), + }; + const transactionHash = + '0x07f901c023bac6c874691244c4c2332c6825b916fb68d240c807c6156db84fd3'; + + const { request, confirmDialogSpy } = await prepareMockDeclareContract( + transactionHash, + payload, + details, + ); + confirmDialogSpy.mockResolvedValue(false); + + await expect(declareContract.execute(request)).rejects.toThrow( + UserRejectedOpError, + ); + }); + + it('throws `InvalidRequestParamsError` when request parameter is not correct', async () => { + await expect( + declareContract.execute({} as unknown as DeclareContractParams), + ).rejects.toThrow(InvalidRequestParamsError); + }); + + it.each([ + { + testCase: 'class_hash is missing', + declareContractRespMock: { + // eslint-disable-next-line @typescript-eslint/naming-convention + transaction_hash: '0x123', + }, + }, + { + testCase: 'transaction_hash is missing', + declareContractRespMock: { + // eslint-disable-next-line @typescript-eslint/naming-convention + class_hash: '0x123456789abcdef', + }, + }, + { + testCase: 'empty object is returned', + declareContractRespMock: {}, + }, + ])( + 'throws `Unknown Error` when $testCase', + async ({ declareContractRespMock }) => { + const payload = generateExpectedDeclareTransactionPayload(); + const details = { + maxFee: generateRandomFee('1000000000000000', '2000000000000000'), + }; + const transactionHash = '0x123'; + + const { request, declareContractUtilSpy } = + await prepareMockDeclareContract(transactionHash, payload, details); + + declareContractUtilSpy.mockResolvedValue( + declareContractRespMock as unknown as DeclareContractResponse, + ); + + await expect(declareContract.execute(request)).rejects.toThrow( + UnknownError, + ); + }, + ); + + it('renders confirmation dialog', async () => { + const payload = generateExpectedDeclareTransactionPayload(); + const details = { + maxFee: generateRandomFee('1000000000000000', '2000000000000000'), + }; + // Convert maxFee to ETH from Wei + const maxFeeInEth = utils.formatUnits(details.maxFee, 'ether'); + const transactionHash = '0x123'; + + const { request, network, confirmDialogSpy, account } = + await prepareMockDeclareContract(transactionHash, payload, details); + + await declareContract.execute(request); + + expect(confirmDialogSpy).toHaveBeenCalledWith([ + { + type: 'heading', + value: 'Do you want to sign this transaction?', + }, + buildRowComponent('Signer Address', account.address), + buildDividerComponent(), + buildRowComponent('Network', network.name), + buildDividerComponent(), + buildRowComponent('Contract', toJson(payload.contract)), + buildDividerComponent(), + buildRowComponent('Compiled Class Hash', payload.compiledClassHash ?? ''), + buildDividerComponent(), + buildRowComponent('Class Hash', payload.classHash ?? ''), + buildDividerComponent(), + buildRowComponent('Max Fee (ETH)', maxFeeInEth), + ]); + }); +}); diff --git a/packages/starknet-snap/src/rpcs/declare-contract.ts b/packages/starknet-snap/src/rpcs/declare-contract.ts new file mode 100644 index 00000000..533549cb --- /dev/null +++ b/packages/starknet-snap/src/rpcs/declare-contract.ts @@ -0,0 +1,208 @@ +import type { Component } from '@metamask/snaps-sdk'; +import { heading, divider, row, text } from '@metamask/snaps-sdk'; +import convert from 'ethereum-unit-converter'; +import type { Infer } from 'superstruct'; +import { assign, object, optional, string } from 'superstruct'; + +import { + AddressStruct, + BaseRequestStruct, + DeclareContractPayloadStruct, + mapDeprecatedParams, + UniversalDetailsStruct, + confirmDialog, + AccountRpcController, + toJson, +} from '../utils'; +import { UserRejectedOpError } from '../utils/exceptions'; +import { declareContract as declareContractUtil } from '../utils/starknetUtils'; + +// Define the DeclareContractRequestStruct +export const DeclareContractRequestStruct = assign( + object({ + address: AddressStruct, // Sender address + payload: DeclareContractPayloadStruct, // Contract payload structure + details: optional(UniversalDetailsStruct), // Optional invocation details + }), + BaseRequestStruct, // Base request struct, could include chainId, etc. +); + +export const DeclareContractResponseStruct = object({ + // eslint-disable-next-line @typescript-eslint/naming-convention + transaction_hash: string(), + // eslint-disable-next-line @typescript-eslint/naming-convention + class_hash: string(), +}); + +export type DeclareContractParams = Infer; +export type DeclareContractResponse = Infer< + typeof DeclareContractResponseStruct +>; + +/** + * The RPC handler to declare a contract. + */ +export class DeclareContractRpc extends AccountRpcController< + DeclareContractParams, + DeclareContractResponse +> { + protected requestStruct = DeclareContractRequestStruct; + + protected responseStruct = DeclareContractResponseStruct; + + protected async preExecute(params: DeclareContractParams): Promise { + // Define mappings to ensure backward compatibility with previous versions of the API. + // These mappings replace deprecated parameter names with the updated equivalents, + // allowing older integrations to function without changes + const paramMappings: Record = { + senderAddress: 'address', + invocationsDetails: 'details', + contractPayload: 'payload', + }; + + // Apply the mappings to params + mapDeprecatedParams(params, paramMappings); + await super.preExecute(params); + } + + /** + * Execute the declare contract request handler. + * It will show a confirmation dialog to the user before signing the contract declaration. + * + * @param params - The parameters of the request. + * @param params.address - The address of the request account. + * @param params.payload - The contract payload of the declare transaction. + * @param [params.details] - The declare transaction details. + * @param params.chainId - The chain id of the network. + * @returns A Promise that resolve the `DeclareContractResponse` object. + */ + async execute( + params: DeclareContractParams, + ): Promise { + return super.execute(params); + } + + protected async handleRequest( + params: DeclareContractParams, + ): Promise { + const { payload, details, address } = params; + + if (!(await this.getDeclareContractConsensus(params))) { + throw new UserRejectedOpError() as unknown as Error; + } + + return (await declareContractUtil( + this.network, + address, + this.account.privateKey, + payload, + details, + )) as DeclareContractResponse; + } + + protected async getDeclareContractConsensus(params: DeclareContractParams) { + const { payload, details, address } = params; + const components: Component[] = []; + components.push(heading('Do you want to sign this transaction?')); + + components.push( + row( + 'Signer Address', + text({ + value: address, + markdown: false, + }), + ), + ); + + components.push(divider()); + + components.push( + row( + 'Network', + text({ + value: this.network.name, + markdown: false, + }), + ), + ); + + if (payload.contract) { + components.push(divider()); + const contractDetails = + typeof payload.contract === 'string' + ? payload.contract + : toJson(payload.contract); + components.push( + row( + 'Contract', + text({ + value: contractDetails, + markdown: false, + }), + ), + ); + } + + if (payload.compiledClassHash) { + components.push(divider()); + components.push( + row( + 'Compiled Class Hash', + text({ + value: payload.compiledClassHash, + markdown: false, + }), + ), + ); + } + + if (payload.classHash) { + components.push(divider()); + components.push( + row( + 'Class Hash', + text({ + value: payload.classHash, + markdown: false, + }), + ), + ); + } + + if (payload.casm) { + const casmDetails = toJson(payload.casm); + components.push(divider()); + components.push( + row( + 'Casm', + text({ + value: casmDetails, + markdown: false, + }), + ), + ); + } + + if (details?.maxFee) { + const maxFeeInEth = convert(details.maxFee, 'wei', 'ether'); + components.push(divider()); + components.push( + row( + 'Max Fee (ETH)', + text({ + value: maxFeeInEth, + markdown: false, + }), + ), + ); + } + + // Return the confirmation dialog with all the components + return await confirmDialog(components); + } +} + +export const declareContract = new DeclareContractRpc({ + showInvalidAccountAlert: true, +}); diff --git a/packages/starknet-snap/src/rpcs/execute-txn.test.ts b/packages/starknet-snap/src/rpcs/execute-txn.test.ts index 9dc6e58b..f9498055 100644 --- a/packages/starknet-snap/src/rpcs/execute-txn.test.ts +++ b/packages/starknet-snap/src/rpcs/execute-txn.test.ts @@ -12,6 +12,7 @@ import { import * as starknetUtils from '../utils/starknetUtils'; import { executeTxn as executeTxnUtil } from '../utils/starknetUtils'; import { + generateRandomFee, mockAccount, prepareConfirmDialog, prepareMockAccount, @@ -54,8 +55,8 @@ const prepareMockExecuteTxn = async ( const estimateResults = getEstimateFees(); const getEstimatedFeesRepsMock = { - suggestedMaxFee: BigInt(1000000000000000).toString(10), - overallFee: BigInt(1000000000000000).toString(10), + suggestedMaxFee: generateRandomFee('1000000000000000', '2000000000000000'), + overallFee: generateRandomFee('1000000000000000', '2000000000000000'), includeDeploy: !accountDeployed, unit: 'wei' as FeeTokenUnit, estimateResults, diff --git a/packages/starknet-snap/src/rpcs/index.ts b/packages/starknet-snap/src/rpcs/index.ts index d4bd560e..09e65cf3 100644 --- a/packages/starknet-snap/src/rpcs/index.ts +++ b/packages/starknet-snap/src/rpcs/index.ts @@ -1,6 +1,7 @@ export * from './display-private-key'; export * from './estimate-fee'; export * from './execute-txn'; +export * from './declare-contract'; export * from './sign-message'; export * from './sign-transaction'; export * from './sign-declare-transaction'; diff --git a/packages/starknet-snap/src/utils/superstruct.ts b/packages/starknet-snap/src/utils/superstruct.ts index 94082863..505d7f94 100644 --- a/packages/starknet-snap/src/utils/superstruct.ts +++ b/packages/starknet-snap/src/utils/superstruct.ts @@ -192,7 +192,7 @@ export const DeclareSignDetailsStruct = assign( /* eslint-disable */ export const SierraContractEntryPointFieldsStruct = object({ selector: string(), - function_idx: string(), + function_idx: number(), }); export const ContractEntryPointFieldsStruct = object({ diff --git a/packages/starknet-snap/test/src/declareContract.test.ts b/packages/starknet-snap/test/src/declareContract.test.ts deleted file mode 100644 index 137aaae2..00000000 --- a/packages/starknet-snap/test/src/declareContract.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import chai, { expect } from 'chai'; -import sinon from 'sinon'; -import sinonChai from 'sinon-chai'; -import { WalletMock } from '../wallet.mock.test'; -import * as utils from '../../src/utils/starknetUtils'; -import * as snapsUtil from '../../src/utils/snapUtils'; -import { declareContract } from '../../src/declareContract'; -import { SnapState } from '../../src/types/snapState'; -import { - STARKNET_MAINNET_NETWORK, - STARKNET_SEPOLIA_TESTNET_NETWORK, -} from '../../src/utils/constants'; -import { - createAccountProxyTxn, - getBip44EntropyStub, - account1, -} from '../constants.test'; -import { getAddressKeyDeriver } from '../../src/utils/keyPair'; -import { Mutex } from 'async-mutex'; -import { - ApiParamsWithKeyDeriver, - DeclareContractRequestParams, -} from '../../src/types/snapApi'; -import { - DeployRequiredError, - UpgradeRequiredError, -} from '../../src/utils/exceptions'; - -chai.use(sinonChai); -const sandbox = sinon.createSandbox(); - -describe('Test function: declareContract', function () { - this.timeout(10000); - const walletStub = new WalletMock(); - const state: SnapState = { - accContracts: [account1], - erc20Tokens: [], - networks: [STARKNET_MAINNET_NETWORK, STARKNET_SEPOLIA_TESTNET_NETWORK], - transactions: [], - }; - let apiParams: ApiParamsWithKeyDeriver; - - const requestObject: DeclareContractRequestParams = { - chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, - senderAddress: account1.address, - contractPayload: { - contract: 'TestContract', - }, - invocationsDetails: { - maxFee: 100, - }, - }; - - beforeEach(async function () { - walletStub.rpcStubs.snap_getBip44Entropy.callsFake(getBip44EntropyStub); - - apiParams = { - state, - requestParams: requestObject, - wallet: walletStub, - saveMutex: new Mutex(), - keyDeriver: await getAddressKeyDeriver(walletStub), - }; - sandbox.useFakeTimers(createAccountProxyTxn.timestamp); - walletStub.rpcStubs.snap_dialog.resolves(true); - walletStub.rpcStubs.snap_manageState.resolves(state); - }); - - afterEach(function () { - walletStub.reset(); - sandbox.restore(); - }); - - it('should declareContract correctly', async function () { - sandbox.stub(utils, 'validateAccountRequireUpgradeOrDeploy').resolvesThis(); - const declareContractStub = sandbox - .stub(utils, 'declareContract') - .resolves({ - transaction_hash: 'transaction_hash', - class_hash: 'class_hash', - }); - const result = await declareContract(apiParams); - const { privateKey } = await utils.getKeysFromAddress( - apiParams.keyDeriver, - STARKNET_SEPOLIA_TESTNET_NETWORK, - state, - account1.address, - ); - - expect(result).to.eql({ - transaction_hash: 'transaction_hash', - class_hash: 'class_hash', - }); - expect(declareContractStub).to.have.been.calledOnce; - expect(declareContractStub).to.have.been.calledWith( - STARKNET_SEPOLIA_TESTNET_NETWORK, - account1.address, - privateKey, - { contract: 'TestContract' }, - { maxFee: 100 }, - ); - }); - - it('should throw error if declareContract fail', async function () { - sandbox.stub(utils, 'validateAccountRequireUpgradeOrDeploy').resolvesThis(); - const declareContractStub = sandbox - .stub(utils, 'declareContract') - .rejects('error'); - const { privateKey } = await utils.getKeysFromAddress( - apiParams.keyDeriver, - STARKNET_SEPOLIA_TESTNET_NETWORK, - state, - account1.address, - ); - let result; - try { - await declareContract(apiParams); - } catch (e) { - result = e; - } finally { - expect(result).to.be.an('Error'); - expect(declareContractStub).to.have.been.calledOnce; - expect(declareContractStub).to.have.been.calledWith( - STARKNET_SEPOLIA_TESTNET_NETWORK, - account1.address, - privateKey, - { contract: 'TestContract' }, - { maxFee: 100 }, - ); - } - }); - - it('should return false if user rejected to sign the transaction', async function () { - sandbox.stub(utils, 'validateAccountRequireUpgradeOrDeploy').resolvesThis(); - walletStub.rpcStubs.snap_dialog.resolves(false); - const declareContractStub = sandbox - .stub(utils, 'declareContract') - .resolves({ - transaction_hash: 'transaction_hash', - class_hash: 'class_hash', - }); - const result = await declareContract(apiParams); - expect(result).to.equal(false); - expect(declareContractStub).to.have.been.not.called; - }); -}); From 8bf8463e0aefd3eb9e59f3cbcb44493e5de2fb5f Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 22 Oct 2024 17:32:41 +0800 Subject: [PATCH 20/50] feat(get-starknet): Integrate `get-starknet` v4 (#400) * feat(get-starknet): add skeleton to support get-starknet v4 (#365) * feat: implement skeleton * chore: update skeletion * chore: update test * Update packages/get-starknet/src/rpcs/switch-network.ts Co-authored-by: khanti42 --------- Co-authored-by: khanti42 * chore: get-starknet linter relax (#371) * feat: implement skeleton * chore: update skeletion * chore: update test * Update packages/get-starknet/src/rpcs/switch-network.ts Co-authored-by: khanti42 * chore: remove js from linter * Update packages/get-starknet/.eslintrc.js Co-authored-by: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> * feat(get-starknet): add wallet_supportedSpecs rpc method (#370) * feat: implement skeleton * chore: update skeletion * chore: update test * Update packages/get-starknet/src/rpcs/switch-network.ts Co-authored-by: khanti42 * feat: add wallet_supportedSpecs handling * Update packages/get-starknet/src/constants.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> --------- Co-authored-by: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> * refactor(get-starknet): add get-starknet error handling (#375) * chore: add error handling * chore: update error name * chore: change wallet rpc and error handle * chore: update get-starknet switch network rpc * chore: change wallet rpc and error handle (#377) * feat(get-starknet): add `wallet_requestChainId` rpc method (#379) * chore: change wallet rpc and error handle * feat(get-starknet): add wallet_requestChainId rpc method * feat(get-starknet): add `wallet_requestAccounts` rpc method (#380) * feat: add wallet_requestAccounts rpc to get-starknet * chore: rename rpc file name --------- Co-authored-by: khanti42 * feat(get-starknet): add wallet_supportedWalletApi rpc method (#372) * feat: add wallet_supportedWalletApi handling * chore: lint * fix: remove type duplicate * feat(get-starknet): add `wallet_deploymentData` rpc method (#382) * chore: add get-starknet deployment data rpc * feat(get-starknet): add `wallet_deploymentData` rpc method * feat(get-starknet): add `wallet_signTypedData` rpc method (#386) * feat(get-starknet): add `wallet_signTypedData` rpc method * chore: update get-starknet wallet instance * feat(get-starknet): add `wallet_watchAsset` rpc method (#387) * feat(get-starknet): add rpc `wallet_watchAsset` * chore: fix comment * chore: update error message (#389) * feat(get-starknet): add `wallet_addInvokeTransaction` rpc method (#385) * feat: add suport for add-invoke rpcs in get-starknet * fix: test * feat: format calls to starknet.js format before calling snap * chore: address comments review * Update packages/get-starknet/src/rpcs/add-invoke.test.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/get-starknet/src/rpcs/add-invoke.test.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> --------- Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * feat: add rpc wallet_getPermissions (#390) * feat(get-starknet): add `WalletAddDeclareTransaction` rpc method (#392) * feat: handle add-declare rpc and request formatting * chore: enable rpc method add declare * Update packages/get-starknet/src/utils/formatter.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * chore: address comments review * Update packages/get-starknet/src/utils/formatter.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/get-starknet/src/utils/formatter.test.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> --------- Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * chore: refactor get-starknet rpc that doesnt need to connect with snap (#393) * fix: incorrect params on get-starknet rpc sign data (#394) * refactor(get-starknet): change get starknet snap params structure (#395) * fix: incorrect params on get-starknet rpc sign data * chore: refactor get-starknet * chore: update get-starknet snap util * chore: update get-starknet account obj * chore: fix comment * chore: remove get-starknet-core-v3" --------- Co-authored-by: khanti42 --- .../__tests__/fixture/typedDataExample.json | 35 +++ packages/get-starknet/.eslintrc.js | 2 +- packages/get-starknet/jest.config.js | 31 +++ packages/get-starknet/package.json | 7 +- packages/get-starknet/src/__mocks__/snap.ts | 31 +++ packages/get-starknet/src/__tests__/helper.ts | 117 ++++++++++ packages/get-starknet/src/accounts.ts | 36 ++- packages/get-starknet/src/constants.ts | 22 ++ .../get-starknet/src/rpcs/add-declare.test.ts | 48 ++++ packages/get-starknet/src/rpcs/add-declare.ts | 18 ++ .../get-starknet/src/rpcs/add-invoke.test.ts | 40 ++++ packages/get-starknet/src/rpcs/add-invoke.ts | 19 ++ .../src/rpcs/deployment-data.test.ts | 25 ++ .../get-starknet/src/rpcs/deployment-data.ts | 16 ++ .../src/rpcs/get-permissions.test.ts | 12 + .../get-starknet/src/rpcs/get-permissions.ts | 12 + packages/get-starknet/src/rpcs/index.ts | 11 + .../src/rpcs/request-account.test.ts | 15 ++ .../get-starknet/src/rpcs/request-account.ts | 12 + .../src/rpcs/request-chain-id.test.ts | 14 ++ .../get-starknet/src/rpcs/request-chain-id.ts | 12 + .../src/rpcs/sign-typed-data.test.ts | 32 +++ .../get-starknet/src/rpcs/sign-typed-data.ts | 26 +++ .../src/rpcs/supported-specs.test.ts | 11 + .../get-starknet/src/rpcs/supported-specs.ts | 13 ++ .../src/rpcs/supported-wallet-api.test.ts | 11 + .../src/rpcs/supported-wallet-api.ts | 13 ++ .../src/rpcs/switch-network.test.ts | 62 +++++ .../get-starknet/src/rpcs/switch-network.ts | 44 ++++ .../get-starknet/src/rpcs/watch-asset.test.ts | 32 +++ packages/get-starknet/src/rpcs/watch-asset.ts | 24 ++ packages/get-starknet/src/signer.ts | 28 ++- packages/get-starknet/src/snap.ts | 208 ++++++++++++----- packages/get-starknet/src/type.ts | 11 + packages/get-starknet/src/utils/error.test.ts | 29 +++ packages/get-starknet/src/utils/error.ts | 51 ++++ .../get-starknet/src/utils/formatter.test.ts | 170 ++++++++++++++ packages/get-starknet/src/utils/formatter.ts | 69 ++++++ packages/get-starknet/src/utils/index.ts | 1 + packages/get-starknet/src/utils/rpc.ts | 37 +++ packages/get-starknet/src/wallet.test.ts | 154 +++++++++++++ packages/get-starknet/src/wallet.ts | 217 +++++++++++------- yarn.lock | 37 +-- 43 files changed, 1633 insertions(+), 182 deletions(-) create mode 100644 packages/__tests__/fixture/typedDataExample.json create mode 100644 packages/get-starknet/jest.config.js create mode 100644 packages/get-starknet/src/__mocks__/snap.ts create mode 100644 packages/get-starknet/src/__tests__/helper.ts create mode 100644 packages/get-starknet/src/constants.ts create mode 100644 packages/get-starknet/src/rpcs/add-declare.test.ts create mode 100644 packages/get-starknet/src/rpcs/add-declare.ts create mode 100644 packages/get-starknet/src/rpcs/add-invoke.test.ts create mode 100644 packages/get-starknet/src/rpcs/add-invoke.ts create mode 100644 packages/get-starknet/src/rpcs/deployment-data.test.ts create mode 100644 packages/get-starknet/src/rpcs/deployment-data.ts create mode 100644 packages/get-starknet/src/rpcs/get-permissions.test.ts create mode 100644 packages/get-starknet/src/rpcs/get-permissions.ts create mode 100644 packages/get-starknet/src/rpcs/index.ts create mode 100644 packages/get-starknet/src/rpcs/request-account.test.ts create mode 100644 packages/get-starknet/src/rpcs/request-account.ts create mode 100644 packages/get-starknet/src/rpcs/request-chain-id.test.ts create mode 100644 packages/get-starknet/src/rpcs/request-chain-id.ts create mode 100644 packages/get-starknet/src/rpcs/sign-typed-data.test.ts create mode 100644 packages/get-starknet/src/rpcs/sign-typed-data.ts create mode 100644 packages/get-starknet/src/rpcs/supported-specs.test.ts create mode 100644 packages/get-starknet/src/rpcs/supported-specs.ts create mode 100644 packages/get-starknet/src/rpcs/supported-wallet-api.test.ts create mode 100644 packages/get-starknet/src/rpcs/supported-wallet-api.ts create mode 100644 packages/get-starknet/src/rpcs/switch-network.test.ts create mode 100644 packages/get-starknet/src/rpcs/switch-network.ts create mode 100644 packages/get-starknet/src/rpcs/watch-asset.test.ts create mode 100644 packages/get-starknet/src/rpcs/watch-asset.ts create mode 100644 packages/get-starknet/src/utils/error.test.ts create mode 100644 packages/get-starknet/src/utils/error.ts create mode 100644 packages/get-starknet/src/utils/formatter.test.ts create mode 100644 packages/get-starknet/src/utils/formatter.ts create mode 100644 packages/get-starknet/src/utils/index.ts create mode 100644 packages/get-starknet/src/utils/rpc.ts create mode 100644 packages/get-starknet/src/wallet.test.ts diff --git a/packages/__tests__/fixture/typedDataExample.json b/packages/__tests__/fixture/typedDataExample.json new file mode 100644 index 00000000..d8bb55b9 --- /dev/null +++ b/packages/__tests__/fixture/typedDataExample.json @@ -0,0 +1,35 @@ +{ + "types": { + "StarkNetDomain": [ + { "name": "name", "type": "felt" }, + { "name": "version", "type": "felt" }, + { "name": "chainId", "type": "felt" } + ], + "Person": [ + { "name": "name", "type": "felt" }, + { "name": "wallet", "type": "felt" } + ], + "Mail": [ + { "name": "from", "type": "Person" }, + { "name": "to", "type": "Person" }, + { "name": "contents", "type": "felt" } + ] + }, + "primaryType": "Mail", + "domain": { + "name": "Starknet Mail", + "version": "1", + "chainId": 1 + }, + "message": { + "from": { + "name": "Cow", + "wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" + }, + "to": { + "name": "Bob", + "wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" + }, + "contents": "Hello, Bob!" + } +} diff --git a/packages/get-starknet/.eslintrc.js b/packages/get-starknet/.eslintrc.js index 13477795..54cd37ce 100644 --- a/packages/get-starknet/.eslintrc.js +++ b/packages/get-starknet/.eslintrc.js @@ -38,5 +38,5 @@ module.exports = { }, ], - ignorePatterns: ['!.eslintrc.js', 'dist/', '**/test', '.nyc_output/', 'coverage/'], + ignorePatterns: ['!.eslintrc.js', 'dist/', '**/test', '.nyc_output/', 'coverage/', 'webpack.*.js'], }; diff --git a/packages/get-starknet/jest.config.js b/packages/get-starknet/jest.config.js new file mode 100644 index 00000000..c81f2b18 --- /dev/null +++ b/packages/get-starknet/jest.config.js @@ -0,0 +1,31 @@ +module.exports = { + transform: { + '^.+\\.(t|j)sx?$': 'ts-jest', + }, + restoreMocks: true, + resetMocks: true, + verbose: true, + testPathIgnorePatterns: ['/node_modules/', '/__mocks__/'], + testMatch: ['/src/**/?(*.)+(spec|test).[tj]s?(x)'], + // Switch off the collectCoverage until jest replace mocha + collectCoverage: false, + // An array of glob patterns indicating a set of files for which coverage information should be collected + collectCoverageFrom: [ + './src/**/*.ts', + '!./src/**/*.d.ts', + '!./src/**/index.ts', + '!./src/**/__mocks__/**', + '!./src/config/*.ts', + '!./src/**/type?(s).ts', + '!./src/**/exception?(s).ts', + '!./src/**/constant?(s).ts', + '!./test/**', + './src/index.ts', + ], + // The directory where Jest should output its coverage files + coverageDirectory: 'coverage', + // Indicates which provider should be used to instrument code for coverage + coverageProvider: 'babel', + // A list of reporter names that Jest uses when writing coverage reports + coverageReporters: ['html', 'json-summary', 'text'], +}; diff --git a/packages/get-starknet/package.json b/packages/get-starknet/package.json index b4453914..8e39e95b 100644 --- a/packages/get-starknet/package.json +++ b/packages/get-starknet/package.json @@ -18,7 +18,7 @@ "prettier": "prettier --write \"src/**/*.ts\"", "lint": "eslint 'src/*.{js,ts,tsx}' --max-warnings 0 -f json -o eslint-report.json", "lint:fix": "eslint '**/*.{js,ts,tsx}' --fix", - "test:unit": "" + "test": "jest --passWithNoTests" }, "keywords": [], "author": "Consensys", @@ -38,10 +38,12 @@ "eslint-plugin-n": "^15.7.0", "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-promise": "^6.1.1", - "get-starknet-core": "^3.2.0", + "get-starknet-core": "^4.0.0", + "jest": "^29.5.0", "prettier": "^2.7.1", "rimraf": "^3.0.2", "serve": "14.2.1", + "ts-jest": "^29.1.0", "ts-loader": "^9.5.1", "typescript": "^4.6.3", "webpack": "^5.91.0", @@ -53,6 +55,7 @@ "registry": "https://registry.npmjs.org/" }, "dependencies": { + "async-mutex": "^0.3.2", "starknet": "6.11.0" } } diff --git a/packages/get-starknet/src/__mocks__/snap.ts b/packages/get-starknet/src/__mocks__/snap.ts new file mode 100644 index 00000000..31c1c53a --- /dev/null +++ b/packages/get-starknet/src/__mocks__/snap.ts @@ -0,0 +1,31 @@ +export class MetaMaskSnap { + getPubKey = jest.fn(); + + signTransaction = jest.fn(); + + signDeployAccountTransaction = jest.fn(); + + signDeclareTransaction = jest.fn(); + + execute = jest.fn(); + + signMessage = jest.fn(); + + declare = jest.fn(); + + getNetwork = jest.fn(); + + recoverDefaultAccount = jest.fn(); + + recoverAccounts = jest.fn(); + + switchNetwork = jest.fn(); + + addStarknetChain = jest.fn(); + + watchAsset = jest.fn(); + + getCurrentNetwork = jest.fn(); + + installIfNot = jest.fn(); +} diff --git a/packages/get-starknet/src/__tests__/helper.ts b/packages/get-starknet/src/__tests__/helper.ts new file mode 100644 index 00000000..8f949f76 --- /dev/null +++ b/packages/get-starknet/src/__tests__/helper.ts @@ -0,0 +1,117 @@ +import { constants } from 'starknet'; + +import { MetaMaskSnap } from '../snap'; +import type { MetaMaskProvider, Network } from '../type'; +import { MetaMaskSnapWallet } from '../wallet'; + +export const SepoliaNetwork: Network = { + name: 'Sepolia Testnet', + baseUrl: 'https://alpha-sepolia.starknet.io', + chainId: constants.StarknetChainId.SN_SEPOLIA, + nodeUrl: 'https://nodeUrl.com', + voyagerUrl: '', + accountClassHash: '', // from argent-x repo +}; + +export const MainnetNetwork: Network = { + name: 'Mainnet', + baseUrl: 'https://mainnet.starknet.io', + chainId: constants.StarknetChainId.SN_MAIN, + nodeUrl: 'https://nodeUrl.com', + voyagerUrl: '', + accountClassHash: '', // from argent-x repo +}; + +export const EthAsset = { + address: '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7', + name: 'Ether', + symbol: 'ETH', + decimals: 18, +}; + +/** + * Generate an account object. + * + * @param params + * @param params.addressSalt - The salt of the address. + * @param params.publicKey - The public key of the account. + * @param params.address - The address of the account. + * @param params.addressIndex - The index of the address. + * @param params.derivationPath - The derivation path of the address. + * @param params.deployTxnHash - The transaction hash of the deploy transaction. + * @param params.chainId - The chain id of the account. + * @returns The account object. + */ +export function generateAccount({ + addressSalt = 'addressSalt', + publicKey = 'publicKey', + address = '0x04882a372da3dfe1c53170ad75893832469bf87b62b13e84662565c4a88f25cd', + addressIndex = 0, + derivationPath = "m/44'/60'/0'/0/0", + deployTxnHash = '', + chainId = SepoliaNetwork.chainId, +}: { + addressSalt?: string; + publicKey?: string; + address?: string; + addressIndex?: number; + derivationPath?: string; + deployTxnHash?: string; + chainId?: string; +}) { + return { + addressSalt, + publicKey, + address, + addressIndex, + derivationPath, + deployTxnHash, + chainId, + }; +} + +export class MockProvider implements MetaMaskProvider { + request = jest.fn(); +} + +/** + * Create a wallet instance. + */ +export function createWallet() { + return new MetaMaskSnapWallet(new MockProvider()); +} + +/** + * Mock the wallet init method. + * + * @param params + * @param params.install - The return value of the installIfNot method. + * @param params.currentNetwork - The return value of the getCurrentNetwork method. + * @param params.address - The address of the account. + * @returns The spy objects. + */ +export function mockWalletInit({ + install = true, + currentNetwork = SepoliaNetwork, + address = '0x04882a372da3dfe1c53170ad75893832469bf87b62b13e84662565c4a88f25cd', +}: { + install?: boolean; + currentNetwork?: Network; + address?: string; +}) { + const installSpy = jest.spyOn(MetaMaskSnap.prototype, 'installIfNot'); + const getCurrentNetworkSpy = jest.spyOn(MetaMaskSnap.prototype, 'getCurrentNetwork'); + const recoverDefaultAccountSpy = jest.spyOn(MetaMaskSnap.prototype, 'recoverDefaultAccount'); + const initSpy = jest.spyOn(MetaMaskSnapWallet.prototype, 'init'); + + installSpy.mockResolvedValue(install); + getCurrentNetworkSpy.mockResolvedValue(currentNetwork); + recoverDefaultAccountSpy.mockResolvedValue(generateAccount({ address })); + + return { + initSpy, + installSpy, + getCurrentNetworkSpy, + recoverDefaultAccountSpy, + }; +} diff --git a/packages/get-starknet/src/accounts.ts b/packages/get-starknet/src/accounts.ts index f51558a5..4301ae6a 100644 --- a/packages/get-starknet/src/accounts.ts +++ b/packages/get-starknet/src/accounts.ts @@ -36,23 +36,43 @@ export class MetaMaskAccount extends Account { async execute( calls: AllowArray, + // ABIs is deprecated and will be removed in the future abisOrTransactionsDetail?: Abi[] | InvocationsDetails, - transactionsDetail?: InvocationsDetails, + details?: InvocationsDetails, ): Promise { - if (!transactionsDetail) { - return this.#snap.execute(this.#address, calls, undefined, abisOrTransactionsDetail as InvocationsDetails); + // if abisOrTransactionsDetail is an array, we assume it's an array of ABIs + // otherwise, we assume it's an InvocationsDetails object + if (Array.isArray(abisOrTransactionsDetail)) { + return this.#snap.execute({ + address: this.#address, + calls, + details, + abis: abisOrTransactionsDetail, + }); } - return this.#snap.execute(this.#address, calls, abisOrTransactionsDetail as Abi[], transactionsDetail); + return this.#snap.execute({ + address: this.#address, + calls, + details: abisOrTransactionsDetail as unknown as InvocationsDetails, + }); } - async signMessage(typedData: TypedData): Promise { - return this.#snap.signMessage(typedData, true, this.#address); + async signMessage(typedDataMessage: TypedData): Promise { + return this.#snap.signMessage({ + typedDataMessage, + address: this.#address, + enableAuthorize: true, + }); } async declare( contractPayload: DeclareContractPayload, - transactionsDetails?: InvocationsDetails, + invocationsDetails?: InvocationsDetails, ): Promise { - return this.#snap.declare(this.#address, contractPayload, transactionsDetails); + return this.#snap.declare({ + senderAddress: this.#address, + contractPayload, + invocationsDetails, + }); } } diff --git a/packages/get-starknet/src/constants.ts b/packages/get-starknet/src/constants.ts new file mode 100644 index 00000000..5d0af17d --- /dev/null +++ b/packages/get-starknet/src/constants.ts @@ -0,0 +1,22 @@ +export enum RpcMethod { + WalletSwitchStarknetChain = 'wallet_switchStarknetChain', + WalletSupportedSpecs = 'wallet_supportedSpecs', + WalletDeploymentData = 'wallet_deploymentData', + WalletSupportedWalletApi = 'wallet_supportedWalletApi', + WalletRequestAccounts = 'wallet_requestAccounts', + WalletRequestChainId = 'wallet_requestChainId', + WalletAddInvokeTransaction = 'wallet_addInvokeTransaction', + WalletAddDeclareTransaction = 'wallet_addDeclareTransaction', + WalletWatchAsset = 'wallet_watchAsset', + WalletSignTypedData = 'wallet_signTypedData', + WalletGetPermissions = 'wallet_getPermissions', +} + +export const WalletIconMetaData = `data:image/svg+xml;utf8;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMTIiIGhlaWdodD0iMTg5IiB2aWV3Qm94PSIwIDAgMjEyIDE4OSI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cG9seWdvbiBmaWxsPSIjQ0RCREIyIiBwb2ludHM9IjYwLjc1IDE3My4yNSA4OC4zMTMgMTgwLjU2MyA4OC4zMTMgMTcxIDkwLjU2MyAxNjguNzUgMTA2LjMxMyAxNjguNzUgMTA2LjMxMyAxODAgMTA2LjMxMyAxODcuODc1IDg5LjQzOCAxODcuODc1IDY4LjYyNSAxNzguODc1Ii8+PHBvbHlnb24gZmlsbD0iI0NEQkRCMiIgcG9pbnRzPSIxMDUuNzUgMTczLjI1IDEzMi43NSAxODAuNTYzIDEzMi43NSAxNzEgMTM1IDE2OC43NSAxNTAuNzUgMTY4Ljc1IDE1MC43NSAxODAgMTUwLjc1IDE4Ny44NzUgMTMzLjg3NSAxODcuODc1IDExMy4wNjMgMTc4Ljg3NSIgdHJhbnNmb3JtPSJtYXRyaXgoLTEgMCAwIDEgMjU2LjUgMCkiLz48cG9seWdvbiBmaWxsPSIjMzkzOTM5IiBwb2ludHM9IjkwLjU2MyAxNTIuNDM4IDg4LjMxMyAxNzEgOTEuMTI1IDE2OC43NSAxMjAuMzc1IDE2OC43NSAxMjMuNzUgMTcxIDEyMS41IDE1Mi40MzggMTE3IDE0OS42MjUgOTQuNSAxNTAuMTg4Ii8+PHBvbHlnb24gZmlsbD0iI0Y4OUMzNSIgcG9pbnRzPSI3NS4zNzUgMjcgODguODc1IDU4LjUgOTUuMDYzIDE1MC4xODggMTE3IDE1MC4xODggMTIzLjc1IDU4LjUgMTM2LjEyNSAyNyIvPjxwb2x5Z29uIGZpbGw9IiNGODlEMzUiIHBvaW50cz0iMTYuMzEzIDk2LjE4OCAuNTYzIDE0MS43NSAzOS45MzggMTM5LjUgNjUuMjUgMTM5LjUgNjUuMjUgMTE5LjgxMyA2NC4xMjUgNzkuMzEzIDU4LjUgODMuODEzIi8+PHBvbHlnb24gZmlsbD0iI0Q4N0MzMCIgcG9pbnRzPSI0Ni4xMjUgMTAxLjI1IDkyLjI1IDEwMi4zNzUgODcuMTg4IDEyNiA2NS4yNSAxMjAuMzc1Ii8+PHBvbHlnb24gZmlsbD0iI0VBOEQzQSIgcG9pbnRzPSI0Ni4xMjUgMTAxLjgxMyA2NS4yNSAxMTkuODEzIDY1LjI1IDEzNy44MTMiLz48cG9seWdvbiBmaWxsPSIjRjg5RDM1IiBwb2ludHM9IjY1LjI1IDEyMC4zNzUgODcuNzUgMTI2IDk1LjA2MyAxNTAuMTg4IDkwIDE1MyA2NS4yNSAxMzguMzc1Ii8+PHBvbHlnb24gZmlsbD0iI0VCOEYzNSIgcG9pbnRzPSI2NS4yNSAxMzguMzc1IDYwLjc1IDE3My4yNSA5MC41NjMgMTUyLjQzOCIvPjxwb2x5Z29uIGZpbGw9IiNFQThFM0EiIHBvaW50cz0iOTIuMjUgMTAyLjM3NSA5NS4wNjMgMTUwLjE4OCA4Ni42MjUgMTI1LjcxOSIvPjxwb2x5Z29uIGZpbGw9IiNEODdDMzAiIHBvaW50cz0iMzkuMzc1IDEzOC45MzggNjUuMjUgMTM4LjM3NSA2MC43NSAxNzMuMjUiLz48cG9seWdvbiBmaWxsPSIjRUI4RjM1IiBwb2ludHM9IjEyLjkzOCAxODguNDM4IDYwLjc1IDE3My4yNSAzOS4zNzUgMTM4LjkzOCAuNTYzIDE0MS43NSIvPjxwb2x5Z29uIGZpbGw9IiNFODgyMUUiIHBvaW50cz0iODguODc1IDU4LjUgNjQuNjg4IDc4Ljc1IDQ2LjEyNSAxMDEuMjUgOTIuMjUgMTAyLjkzOCIvPjxwb2x5Z29uIGZpbGw9IiNERkNFQzMiIHBvaW50cz0iNjAuNzUgMTczLjI1IDkwLjU2MyAxNTIuNDM4IDg4LjMxMyAxNzAuNDM4IDg4LjMxMyAxODAuNTYzIDY4LjA2MyAxNzYuNjI1Ii8+PHBvbHlnb24gZmlsbD0iI0RGQ0VDMyIgcG9pbnRzPSIxMjEuNSAxNzMuMjUgMTUwLjc1IDE1Mi40MzggMTQ4LjUgMTcwLjQzOCAxNDguNSAxODAuNTYzIDEyOC4yNSAxNzYuNjI1IiB0cmFuc2Zvcm09Im1hdHJpeCgtMSAwIDAgMSAyNzIuMjUgMCkiLz48cG9seWdvbiBmaWxsPSIjMzkzOTM5IiBwb2ludHM9IjcwLjMxMyAxMTIuNSA2NC4xMjUgMTI1LjQzOCA4Ni4wNjMgMTE5LjgxMyIgdHJhbnNmb3JtPSJtYXRyaXgoLTEgMCAwIDEgMTUwLjE4OCAwKSIvPjxwb2x5Z29uIGZpbGw9IiNFODhGMzUiIHBvaW50cz0iMTIuMzc1IC41NjMgODguODc1IDU4LjUgNzUuOTM4IDI3Ii8+PHBhdGggZmlsbD0iIzhFNUEzMCIgZD0iTTEyLjM3NTAwMDIsMC41NjI1MDAwMDggTDIuMjUwMDAwMDMsMzEuNTAwMDAwNSBMNy44NzUwMDAxMiw2NS4yNTAwMDEgTDMuOTM3NTAwMDYsNjcuNTAwMDAxIEw5LjU2MjUwMDE0LDcyLjU2MjUgTDUuMDYyNTAwMDgsNzYuNTAwMDAxMSBMMTEuMjUsODIuMTI1MDAxMiBMNy4zMTI1MDAxMSw4NS41MDAwMDEzIEwxNi4zMTI1MDAyLDk2Ljc1MDAwMTQgTDU4LjUwMDAwMDksODMuODEyNTAxMiBDNzkuMTI1MDAxMiw2Ny4zMTI1MDA0IDg5LjI1MDAwMTMsNTguODc1MDAwMyA4OC44NzUwMDEzLDU4LjUwMDAwMDkgQzg4LjUwMDAwMTMsNTguMTI1MDAwOSA2My4wMDAwMDA5LDM4LjgxMjUwMDYgMTIuMzc1MDAwMiwwLjU2MjUwMDAwOCBaIi8+PGcgdHJhbnNmb3JtPSJtYXRyaXgoLTEgMCAwIDEgMjExLjUgMCkiPjxwb2x5Z29uIGZpbGw9IiNGODlEMzUiIHBvaW50cz0iMTYuMzEzIDk2LjE4OCAuNTYzIDE0MS43NSAzOS45MzggMTM5LjUgNjUuMjUgMTM5LjUgNjUuMjUgMTE5LjgxMyA2NC4xMjUgNzkuMzEzIDU4LjUgODMuODEzIi8+PHBvbHlnb24gZmlsbD0iI0Q4N0MzMCIgcG9pbnRzPSI0Ni4xMjUgMTAxLjI1IDkyLjI1IDEwMi4zNzUgODcuMTg4IDEyNiA2NS4yNSAxMjAuMzc1Ii8+PHBvbHlnb24gZmlsbD0iI0VBOEQzQSIgcG9pbnRzPSI0Ni4xMjUgMTAxLjgxMyA2NS4yNSAxMTkuODEzIDY1LjI1IDEzNy44MTMiLz48cG9seWdvbiBmaWxsPSIjRjg5RDM1IiBwb2ludHM9IjY1LjI1IDEyMC4zNzUgODcuNzUgMTI2IDk1LjA2MyAxNTAuMTg4IDkwIDE1MyA2NS4yNSAxMzguMzc1Ii8+PHBvbHlnb24gZmlsbD0iI0VCOEYzNSIgcG9pbnRzPSI2NS4yNSAxMzguMzc1IDYwLjc1IDE3My4yNSA5MCAxNTMiLz48cG9seWdvbiBmaWxsPSIjRUE4RTNBIiBwb2ludHM9IjkyLjI1IDEwMi4zNzUgOTUuMDYzIDE1MC4xODggODYuNjI1IDEyNS43MTkiLz48cG9seWdvbiBmaWxsPSIjRDg3QzMwIiBwb2ludHM9IjM5LjM3NSAxMzguOTM4IDY1LjI1IDEzOC4zNzUgNjAuNzUgMTczLjI1Ii8+PHBvbHlnb24gZmlsbD0iI0VCOEYzNSIgcG9pbnRzPSIxMi45MzggMTg4LjQzOCA2MC43NSAxNzMuMjUgMzkuMzc1IDEzOC45MzggLjU2MyAxNDEuNzUiLz48cG9seWdvbiBmaWxsPSIjRTg4MjFFIiBwb2ludHM9Ijg4Ljg3NSA1OC41IDY0LjY4OCA3OC43NSA0Ni4xMjUgMTAxLjI1IDkyLjI1IDEwMi45MzgiLz48cG9seWdvbiBmaWxsPSIjMzkzOTM5IiBwb2ludHM9IjcwLjMxMyAxMTIuNSA2NC4xMjUgMTI1LjQzOCA4Ni4wNjMgMTE5LjgxMyIgdHJhbnNmb3JtPSJtYXRyaXgoLTEgMCAwIDEgMTUwLjE4OCAwKSIvPjxwb2x5Z29uIGZpbGw9IiNFODhGMzUiIHBvaW50cz0iMTIuMzc1IC41NjMgODguODc1IDU4LjUgNzUuOTM4IDI3Ii8+PHBhdGggZmlsbD0iIzhFNUEzMCIgZD0iTTEyLjM3NTAwMDIsMC41NjI1MDAwMDggTDIuMjUwMDAwMDMsMzEuNTAwMDAwNSBMNy44NzUwMDAxMiw2NS4yNTAwMDEgTDMuOTM3NTAwMDYsNjcuNTAwMDAxIEw5LjU2MjUwMDE0LDcyLjU2MjUgTDUuMDYyNTAwMDgsNzYuNTAwMDAxMSBMMTEuMjUsODIuMTI1MDAxMiBMNy4zMTI1MDAxMSw4NS41MDAwMDEzIEwxNi4zMTI1MDAyLDk2Ljc1MDAwMTQgTDU4LjUwMDAwMDksODMuODEyNTAxMiBDNzkuMTI1MDAxMiw2Ny4zMTI1MDA0IDg5LjI1MDAwMTMsNTguODc1MDAwMyA4OC44NzUwMDEzLDU4LjUwMDAwMDkgQzg4LjUwMDAwMTMsNTguMTI1MDAwOSA2My4wMDAwMDA5LDM4LjgxMjUwMDYgMTIuMzc1MDAwMiwwLjU2MjUwMDAwOCBaIi8+PC9nPjwvZz48L3N2Zz4=`; + +// The supported RPC version should be update base on the Provider. +// With Provider `Alchemy`, it is 0.7 +export const SupportedStarknetSpecVersion = ['0.7']; + +// The wallet API support is 0.7.2 but the RPC specs requests xx.yy. Hence we skip the last digits. +export const SupportedWalletApi = ['0.7']; diff --git a/packages/get-starknet/src/rpcs/add-declare.test.ts b/packages/get-starknet/src/rpcs/add-declare.test.ts new file mode 100644 index 00000000..3c93d5d8 --- /dev/null +++ b/packages/get-starknet/src/rpcs/add-declare.test.ts @@ -0,0 +1,48 @@ +import { mockWalletInit, createWallet, generateAccount } from '../__tests__/helper'; +import { MetaMaskSnap } from '../snap'; +import { formatDeclareTransaction } from '../utils/formatter'; +import { WalletAddDeclareTransaction } from './add-declare'; + +/* eslint-disable @typescript-eslint/naming-convention */ +describe('WalletAddDeclareTransaction', () => { + it('submits a declare transaction and returns transaction hash', async () => { + const params = { + compiled_class_hash: '0xcompiledClassHash', + class_hash: '0xclassHash', + contract_class: { + sierra_program: ['0x1', '0x2'], + contract_class_version: '1.0.0', + entry_points_by_type: { + CONSTRUCTOR: [{ selector: '0xconstructorSelector', function_idx: 0 }], + EXTERNAL: [{ selector: '0xexternalSelector', function_idx: 1 }], + L1_HANDLER: [{ selector: '0xhandlerSelector', function_idx: 2 }], + }, + abi: '[{"type":"function","name":"transfer"}]', // passing as a string (no parsing) + }, + }; + + const formattedParams = formatDeclareTransaction(params); + const expectedResult = { + transaction_hash: '0x12345abcd', + class_hash: '0x000', + }; + + const wallet = createWallet(); + const account = generateAccount({}); + mockWalletInit({ address: account.address }); + + const declareSpy = jest.spyOn(MetaMaskSnap.prototype, 'declare'); + declareSpy.mockResolvedValue(expectedResult); + + const walletAddDeclareTransaction = new WalletAddDeclareTransaction(wallet); + const result = await walletAddDeclareTransaction.execute(params); + + expect(result).toStrictEqual(expectedResult); + expect(declareSpy).toHaveBeenCalledWith({ + senderAddress: account.address, + contractPayload: formattedParams, + chainId: wallet.chainId, + }); + }); +}); +/* eslint-enable @typescript-eslint/naming-convention */ diff --git a/packages/get-starknet/src/rpcs/add-declare.ts b/packages/get-starknet/src/rpcs/add-declare.ts new file mode 100644 index 00000000..af2ce2e7 --- /dev/null +++ b/packages/get-starknet/src/rpcs/add-declare.ts @@ -0,0 +1,18 @@ +import type { RpcTypeToMessageMap } from 'get-starknet-core'; + +import { formatDeclareTransaction } from '../utils/formatter'; +import { StarknetWalletRpc } from '../utils/rpc'; + +export type WalletAddDeclareTransactionMethod = 'wallet_addDeclareTransaction'; +type Params = RpcTypeToMessageMap[WalletAddDeclareTransactionMethod]['params']; +type Result = RpcTypeToMessageMap[WalletAddDeclareTransactionMethod]['result']; + +export class WalletAddDeclareTransaction extends StarknetWalletRpc { + async handleRequest(params: Params): Promise { + return await this.snap.declare({ + senderAddress: this.wallet.selectedAddress, + contractPayload: formatDeclareTransaction(params), + chainId: this.wallet.chainId, + }); + } +} diff --git a/packages/get-starknet/src/rpcs/add-invoke.test.ts b/packages/get-starknet/src/rpcs/add-invoke.test.ts new file mode 100644 index 00000000..1242bec8 --- /dev/null +++ b/packages/get-starknet/src/rpcs/add-invoke.test.ts @@ -0,0 +1,40 @@ +import { mockWalletInit, createWallet, generateAccount } from '../__tests__/helper'; +import { MetaMaskSnap } from '../snap'; +import { formatCalls } from '../utils/formatter'; +import { WalletAddInvokeTransaction } from './add-invoke'; + +describe('WalletAddInvokeTransaction', () => { + it('submits an invoke transaction and returns transaction hash', async () => { + const calls = [ + { + // eslint-disable-next-line @typescript-eslint/naming-convention + contract_address: '0xabcdef', + // eslint-disable-next-line @typescript-eslint/naming-convention + entry_point: 'transfer', + calldata: ['0x1', '0x2', '0x3'], + }, + ]; + const callsFormated = formatCalls(calls); + const expectedResult = { + // eslint-disable-next-line @typescript-eslint/naming-convention + transaction_hash: '0x12345abcd', + }; + const wallet = createWallet(); + const account = generateAccount({}); + mockWalletInit({ address: account.address }); + const executeSpy = jest.spyOn(MetaMaskSnap.prototype, 'execute'); + executeSpy.mockResolvedValue(expectedResult); + + const walletAddInvokeTransaction = new WalletAddInvokeTransaction(wallet); + const result = await walletAddInvokeTransaction.execute({ + calls, + }); + + expect(result).toStrictEqual(expectedResult); + expect(executeSpy).toHaveBeenCalledWith({ + calls: callsFormated, + address: account.address, + chainId: wallet.chainId, + }); + }); +}); diff --git a/packages/get-starknet/src/rpcs/add-invoke.ts b/packages/get-starknet/src/rpcs/add-invoke.ts new file mode 100644 index 00000000..585dc676 --- /dev/null +++ b/packages/get-starknet/src/rpcs/add-invoke.ts @@ -0,0 +1,19 @@ +import type { RpcTypeToMessageMap } from 'get-starknet-core'; + +import { formatCalls } from '../utils/formatter'; +import { StarknetWalletRpc } from '../utils/rpc'; + +export type WalletAddInvokeTransactionMethod = 'wallet_addInvokeTransaction'; +type Params = RpcTypeToMessageMap[WalletAddInvokeTransactionMethod]['params']; +type Result = RpcTypeToMessageMap[WalletAddInvokeTransactionMethod]['result']; + +export class WalletAddInvokeTransaction extends StarknetWalletRpc { + async handleRequest(params: Params): Promise { + const { calls } = params; + return await this.snap.execute({ + address: this.wallet.selectedAddress, + calls: formatCalls(calls), + chainId: this.wallet.chainId, + }); + } +} diff --git a/packages/get-starknet/src/rpcs/deployment-data.test.ts b/packages/get-starknet/src/rpcs/deployment-data.test.ts new file mode 100644 index 00000000..ee15565e --- /dev/null +++ b/packages/get-starknet/src/rpcs/deployment-data.test.ts @@ -0,0 +1,25 @@ +import { mockWalletInit, createWallet } from '../__tests__/helper'; +import { MetaMaskSnap } from '../snap'; +import type { DeploymentData } from '../type'; +import { WalletDeploymentData } from './deployment-data'; + +describe('WalletDeploymentData', () => { + it('returns deployment data', async () => { + const expectedResult: DeploymentData = { + address: '0x04882a372da3dfe1c53170ad75893832469bf87b62b13e84662565c4a88f25cd', + // eslint-disable-next-line @typescript-eslint/naming-convention + class_hash: 'class_hash', + salt: 'salt', + calldata: ['0', '1'], + version: 1, + }; + const wallet = createWallet(); + mockWalletInit({ address: expectedResult.address }); + const spy = jest.spyOn(MetaMaskSnap.prototype, 'getDeploymentData'); + spy.mockResolvedValue(expectedResult); + const walletDeploymentData = new WalletDeploymentData(wallet); + const result = await walletDeploymentData.execute(); + + expect(result).toStrictEqual(expectedResult); + }); +}); diff --git a/packages/get-starknet/src/rpcs/deployment-data.ts b/packages/get-starknet/src/rpcs/deployment-data.ts new file mode 100644 index 00000000..0213b4f2 --- /dev/null +++ b/packages/get-starknet/src/rpcs/deployment-data.ts @@ -0,0 +1,16 @@ +import type { RpcTypeToMessageMap } from 'get-starknet-core'; + +import { StarknetWalletRpc } from '../utils/rpc'; + +export type WalletDeploymentDataMethod = 'wallet_deploymentData'; +type Params = RpcTypeToMessageMap[WalletDeploymentDataMethod]['params']; +type Result = RpcTypeToMessageMap[WalletDeploymentDataMethod]['result']; + +export class WalletDeploymentData extends StarknetWalletRpc { + async handleRequest(_param: Params): Promise { + return await this.snap.getDeploymentData({ + chainId: this.wallet.chainId, + address: this.wallet.selectedAddress, + }); + } +} diff --git a/packages/get-starknet/src/rpcs/get-permissions.test.ts b/packages/get-starknet/src/rpcs/get-permissions.test.ts new file mode 100644 index 00000000..68614063 --- /dev/null +++ b/packages/get-starknet/src/rpcs/get-permissions.test.ts @@ -0,0 +1,12 @@ +import { Permission } from 'get-starknet-core'; + +import { WalletGetPermissions } from './get-permissions'; + +describe('WalletGetPermissions', () => { + it('returns the permissions', async () => { + const walletGetPermissions = new WalletGetPermissions(); + const result = await walletGetPermissions.execute(); + + expect(result).toStrictEqual([Permission.ACCOUNTS]); + }); +}); diff --git a/packages/get-starknet/src/rpcs/get-permissions.ts b/packages/get-starknet/src/rpcs/get-permissions.ts new file mode 100644 index 00000000..2a585616 --- /dev/null +++ b/packages/get-starknet/src/rpcs/get-permissions.ts @@ -0,0 +1,12 @@ +import { Permission, type RpcTypeToMessageMap } from 'get-starknet-core'; + +import type { IStarknetWalletRpc } from '../utils/rpc'; + +export type WalletGetPermissionsMethod = 'wallet_getPermissions'; +type Result = RpcTypeToMessageMap[WalletGetPermissionsMethod]['result']; + +export class WalletGetPermissions implements IStarknetWalletRpc { + async execute(): Promise { + return [Permission.ACCOUNTS]; + } +} diff --git a/packages/get-starknet/src/rpcs/index.ts b/packages/get-starknet/src/rpcs/index.ts new file mode 100644 index 00000000..a48abe4d --- /dev/null +++ b/packages/get-starknet/src/rpcs/index.ts @@ -0,0 +1,11 @@ +export * from './switch-network'; +export * from './supported-specs'; +export * from './deployment-data'; +export * from './supported-wallet-api'; +export * from './request-account'; +export * from './request-chain-id'; +export * from './add-invoke'; +export * from './watch-asset'; +export * from './sign-typed-data'; +export * from './get-permissions'; +export * from './add-declare'; diff --git a/packages/get-starknet/src/rpcs/request-account.test.ts b/packages/get-starknet/src/rpcs/request-account.test.ts new file mode 100644 index 00000000..28a0be60 --- /dev/null +++ b/packages/get-starknet/src/rpcs/request-account.test.ts @@ -0,0 +1,15 @@ +import { mockWalletInit, createWallet } from '../__tests__/helper'; +import { WalletRequestAccount } from './request-account'; + +describe('WalletRequestAccount', () => { + it('returns accounts', async () => { + const expectedAccountAddress = '0x04882a372da3dfe1c53170ad75893832469bf87b62b13e84662565c4a88f25cd'; + const wallet = createWallet(); + mockWalletInit({ address: expectedAccountAddress }); + + const walletRequestAccount = new WalletRequestAccount(wallet); + const result = await walletRequestAccount.execute(); + + expect(result).toStrictEqual([expectedAccountAddress]); + }); +}); diff --git a/packages/get-starknet/src/rpcs/request-account.ts b/packages/get-starknet/src/rpcs/request-account.ts new file mode 100644 index 00000000..80821d04 --- /dev/null +++ b/packages/get-starknet/src/rpcs/request-account.ts @@ -0,0 +1,12 @@ +import type { RpcTypeToMessageMap } from 'get-starknet-core'; + +import { StarknetWalletRpc } from '../utils/rpc'; + +export type WalletRequestAccountMethod = 'wallet_requestAccounts'; +type Result = RpcTypeToMessageMap[WalletRequestAccountMethod]['result']; + +export class WalletRequestAccount extends StarknetWalletRpc { + async handleRequest(): Promise { + return [this.wallet.selectedAddress]; + } +} diff --git a/packages/get-starknet/src/rpcs/request-chain-id.test.ts b/packages/get-starknet/src/rpcs/request-chain-id.test.ts new file mode 100644 index 00000000..4294e9fe --- /dev/null +++ b/packages/get-starknet/src/rpcs/request-chain-id.test.ts @@ -0,0 +1,14 @@ +import { mockWalletInit, createWallet, SepoliaNetwork } from '../__tests__/helper'; +import { WalletRequestChainId } from './request-chain-id'; + +describe('WalletRequestChainId', () => { + it('returns the current chain Id', async () => { + const wallet = createWallet(); + mockWalletInit({ currentNetwork: SepoliaNetwork }); + + const walletRequestChainId = new WalletRequestChainId(wallet); + const result = await walletRequestChainId.execute(); + + expect(result).toBe(SepoliaNetwork.chainId); + }); +}); diff --git a/packages/get-starknet/src/rpcs/request-chain-id.ts b/packages/get-starknet/src/rpcs/request-chain-id.ts new file mode 100644 index 00000000..cc15f0fb --- /dev/null +++ b/packages/get-starknet/src/rpcs/request-chain-id.ts @@ -0,0 +1,12 @@ +import type { RpcTypeToMessageMap } from 'get-starknet-core'; + +import { StarknetWalletRpc } from '../utils/rpc'; + +export type WalletRequestChainIdMethod = 'wallet_requestChainId'; +type Result = RpcTypeToMessageMap[WalletRequestChainIdMethod]['result']; + +export class WalletRequestChainId extends StarknetWalletRpc { + async handleRequest(): Promise { + return this.wallet.chainId; + } +} diff --git a/packages/get-starknet/src/rpcs/sign-typed-data.test.ts b/packages/get-starknet/src/rpcs/sign-typed-data.test.ts new file mode 100644 index 00000000..76584723 --- /dev/null +++ b/packages/get-starknet/src/rpcs/sign-typed-data.test.ts @@ -0,0 +1,32 @@ +import typedDataExample from '../../../__tests__/fixture/typedDataExample.json'; +import { mockWalletInit, createWallet, SepoliaNetwork, generateAccount } from '../__tests__/helper'; +import { SupportedWalletApi } from '../constants'; +import { MetaMaskSnap } from '../snap'; +import { WalletSignTypedData } from './sign-typed-data'; + +describe('WalletSignTypedData', () => { + it('returns the signature', async () => { + const network = SepoliaNetwork; + const wallet = createWallet(); + const account = generateAccount({ chainId: network.chainId }); + mockWalletInit({ currentNetwork: network, address: account.address }); + const expectedResult = ['signature1', 'signature2']; + const signSpy = jest.spyOn(MetaMaskSnap.prototype, 'signMessage'); + signSpy.mockResolvedValue(expectedResult); + + const walletSignTypedData = new WalletSignTypedData(wallet); + const result = await walletSignTypedData.execute({ + ...typedDataExample, + // eslint-disable-next-line @typescript-eslint/naming-convention + api_version: SupportedWalletApi[0], + }); + + expect(signSpy).toHaveBeenCalledWith({ + chainId: network.chainId, + typedDataMessage: typedDataExample, + enableAuthorize: true, + address: account.address, + }); + expect(result).toStrictEqual(expectedResult); + }); +}); diff --git a/packages/get-starknet/src/rpcs/sign-typed-data.ts b/packages/get-starknet/src/rpcs/sign-typed-data.ts new file mode 100644 index 00000000..89ac8e55 --- /dev/null +++ b/packages/get-starknet/src/rpcs/sign-typed-data.ts @@ -0,0 +1,26 @@ +import type { RpcTypeToMessageMap } from 'get-starknet-core'; + +import { StarknetWalletRpc } from '../utils/rpc'; + +export type WalletSignTypedDataMethod = 'wallet_signTypedData'; +type Params = RpcTypeToMessageMap[WalletSignTypedDataMethod]['params']; +type Result = RpcTypeToMessageMap[WalletSignTypedDataMethod]['result']; + +export class WalletSignTypedData extends StarknetWalletRpc { + async handleRequest(params: Params): Promise { + return (await this.snap.signMessage({ + chainId: this.wallet.chainId, + // To form the `TypedData` object in a more specific way, + // preventing the `params` contains other properties that we dont need + typedDataMessage: { + domain: params.domain, + types: params.types, + message: params.message, + primaryType: params.primaryType, + }, + // Ensure there will be a dialog to confirm the sign operation + enableAuthorize: true, + address: this.wallet.selectedAddress, + })) as unknown as Result; + } +} diff --git a/packages/get-starknet/src/rpcs/supported-specs.test.ts b/packages/get-starknet/src/rpcs/supported-specs.test.ts new file mode 100644 index 00000000..2db79f5f --- /dev/null +++ b/packages/get-starknet/src/rpcs/supported-specs.test.ts @@ -0,0 +1,11 @@ +import { SupportedStarknetSpecVersion } from '../constants'; +import { WalletSupportedSpecs } from './supported-specs'; + +describe('WalletSupportedWalletApi', () => { + it('returns the supported wallet api version', async () => { + const walletSupportedSpecs = new WalletSupportedSpecs(); + const result = await walletSupportedSpecs.execute(); + + expect(result).toStrictEqual(SupportedStarknetSpecVersion); + }); +}); diff --git a/packages/get-starknet/src/rpcs/supported-specs.ts b/packages/get-starknet/src/rpcs/supported-specs.ts new file mode 100644 index 00000000..fb5e93c1 --- /dev/null +++ b/packages/get-starknet/src/rpcs/supported-specs.ts @@ -0,0 +1,13 @@ +import type { RpcTypeToMessageMap } from 'get-starknet-core'; + +import { SupportedStarknetSpecVersion } from '../constants'; +import type { IStarknetWalletRpc } from '../utils'; + +export type WalletSupportedSpecsMethod = 'wallet_supportedSpecs'; +type Result = RpcTypeToMessageMap[WalletSupportedSpecsMethod]['result']; + +export class WalletSupportedSpecs implements IStarknetWalletRpc { + async execute(): Promise { + return SupportedStarknetSpecVersion; + } +} diff --git a/packages/get-starknet/src/rpcs/supported-wallet-api.test.ts b/packages/get-starknet/src/rpcs/supported-wallet-api.test.ts new file mode 100644 index 00000000..0dac3dda --- /dev/null +++ b/packages/get-starknet/src/rpcs/supported-wallet-api.test.ts @@ -0,0 +1,11 @@ +import { SupportedWalletApi } from '../constants'; +import { WalletSupportedWalletApi } from './supported-wallet-api'; + +describe('WalletSupportedWalletApi', () => { + it('returns the supported wallet api version', async () => { + const walletSupportedWalletApi = new WalletSupportedWalletApi(); + const result = await walletSupportedWalletApi.execute(); + + expect(result).toStrictEqual(SupportedWalletApi); + }); +}); diff --git a/packages/get-starknet/src/rpcs/supported-wallet-api.ts b/packages/get-starknet/src/rpcs/supported-wallet-api.ts new file mode 100644 index 00000000..d282c08c --- /dev/null +++ b/packages/get-starknet/src/rpcs/supported-wallet-api.ts @@ -0,0 +1,13 @@ +import type { RpcTypeToMessageMap } from 'get-starknet-core'; + +import { SupportedWalletApi } from '../constants'; +import type { IStarknetWalletRpc } from '../utils/rpc'; + +export type WalletSupportedWalletApiMethod = 'wallet_supportedWalletApi'; +type Result = RpcTypeToMessageMap[WalletSupportedWalletApiMethod]['result']; + +export class WalletSupportedWalletApi implements IStarknetWalletRpc { + async execute(): Promise { + return SupportedWalletApi as unknown as Result; + } +} diff --git a/packages/get-starknet/src/rpcs/switch-network.test.ts b/packages/get-starknet/src/rpcs/switch-network.test.ts new file mode 100644 index 00000000..27e8adff --- /dev/null +++ b/packages/get-starknet/src/rpcs/switch-network.test.ts @@ -0,0 +1,62 @@ +import { mockWalletInit, MainnetNetwork, createWallet, SepoliaNetwork } from '../__tests__/helper'; +import { MetaMaskSnap } from '../snap'; +import type { Network } from '../type'; +import { WalletRpcError } from '../utils/error'; +import { WalletSwitchStarknetChain } from './switch-network'; + +describe('WalletSwitchStarknetChain', () => { + const mockSwitchNetwork = (result: boolean) => { + const spy = jest.spyOn(MetaMaskSnap.prototype, 'switchNetwork'); + spy.mockResolvedValue(result); + return spy; + }; + + const prepareSwitchNetwork = (result: boolean, network?: Network) => { + const wallet = createWallet(); + const { initSpy: walletInitSpy } = mockWalletInit({ currentNetwork: network }); + const switchNetworkSpy = mockSwitchNetwork(result); + return { + wallet, + walletInitSpy, + switchNetworkSpy, + }; + }; + + it('switchs the network', async () => { + const expectedResult = true; + const { wallet, switchNetworkSpy, walletInitSpy } = prepareSwitchNetwork(expectedResult); + + const walletSwitchStarknetChain = new WalletSwitchStarknetChain(wallet); + const result = await walletSwitchStarknetChain.execute({ chainId: MainnetNetwork.chainId }); + + expect(result).toBe(expectedResult); + expect(switchNetworkSpy).toHaveBeenCalledWith(MainnetNetwork.chainId); + // Init will be called before and after switching the network + // because the wallet will be re-initialized after switching the network + expect(walletInitSpy).toHaveBeenCalledTimes(2); + }); + + it('returns true directly if the request network is the same with the current network', async () => { + const requestNetwork = SepoliaNetwork; + const { wallet, switchNetworkSpy, walletInitSpy } = prepareSwitchNetwork(true, requestNetwork); + + const walletSwitchStarknetChain = new WalletSwitchStarknetChain(wallet); + const result = await walletSwitchStarknetChain.execute({ chainId: requestNetwork.chainId }); + + expect(switchNetworkSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); + // If the request network is the same with the current network, init will be called once + expect(walletInitSpy).toHaveBeenCalledTimes(1); + }); + + it('throws `WalletRpcError` if switching network failed', async () => { + const { wallet, switchNetworkSpy } = prepareSwitchNetwork(false); + switchNetworkSpy.mockRejectedValue(new Error('Switch network failed')); + + const walletSwitchStarknetChain = new WalletSwitchStarknetChain(wallet); + + await expect(walletSwitchStarknetChain.execute({ chainId: MainnetNetwork.chainId })).rejects.toThrow( + WalletRpcError, + ); + }); +}); diff --git a/packages/get-starknet/src/rpcs/switch-network.ts b/packages/get-starknet/src/rpcs/switch-network.ts new file mode 100644 index 00000000..cc72573f --- /dev/null +++ b/packages/get-starknet/src/rpcs/switch-network.ts @@ -0,0 +1,44 @@ +import type { RpcTypeToMessageMap } from 'get-starknet-core'; + +import { createStarkError } from '../utils/error'; +import { StarknetWalletRpc } from '../utils/rpc'; + +export type WalletSwitchStarknetChainMethod = 'wallet_switchStarknetChain'; +type Params = RpcTypeToMessageMap[WalletSwitchStarknetChainMethod]['params']; +type Result = RpcTypeToMessageMap[WalletSwitchStarknetChainMethod]['result']; + +export class WalletSwitchStarknetChain extends StarknetWalletRpc { + async execute(params: Params): Promise { + // Adding a lock can make sure the switching network process can only process once at a time with get-starknet, + // For cross dapp switching network, which already handle by the snap, + // Example scenario: + // [Rq1] wallet init and send switch network B request to snap at T0 + // [Rq2] wallet init and send switch network B request to snap at T1 <-- this request will be on hold by the lock + // [Rq1] confrim request and network switch to B, assign local chain Id to B at T2 + // [Rq2] lock release, wallet inited and local chainId is B, which is same as request, so we return true directly at T3 + try { + return await this.wallet.lock.runExclusive(async () => { + await this.wallet.init(false); + return this.handleRequest(params); + }); + } catch (error) { + throw createStarkError(error?.data?.walletRpcError?.code); + } + } + + async handleRequest(param: Params): Promise { + const { chainId } = param; + + // The wallet.chainId always refer to the latest chainId of the snap + if (this.wallet.chainId === chainId) { + return true; + } + + const result = await this.snap.switchNetwork(chainId); + // after switching the network, + // we need to re-init the wallet object to assign the latest chainId into it + await this.wallet.init(false); + + return result; + } +} diff --git a/packages/get-starknet/src/rpcs/watch-asset.test.ts b/packages/get-starknet/src/rpcs/watch-asset.test.ts new file mode 100644 index 00000000..e78d594d --- /dev/null +++ b/packages/get-starknet/src/rpcs/watch-asset.test.ts @@ -0,0 +1,32 @@ +import { mockWalletInit, createWallet, SepoliaNetwork, EthAsset } from '../__tests__/helper'; +import { SupportedWalletApi } from '../constants'; +import { MetaMaskSnap } from '../snap'; +import { WalletWatchAsset } from './watch-asset'; + +describe('WalletWatchAsset', () => { + it('watches the specified asset and returns a success response', async () => { + const wallet = createWallet(); + const network = SepoliaNetwork; + mockWalletInit({ currentNetwork: network }); + const expectedResult = true; + const watchAssetSpy = jest.spyOn(MetaMaskSnap.prototype, 'watchAsset'); + watchAssetSpy.mockResolvedValue(expectedResult); + + const walletWatchAsset = new WalletWatchAsset(wallet); + const result = await walletWatchAsset.execute({ + type: 'ERC20', + options: EthAsset, + // eslint-disable-next-line @typescript-eslint/naming-convention + api_version: SupportedWalletApi[0], + }); + + expect(watchAssetSpy).toHaveBeenCalledWith({ + address: EthAsset.address, + symbol: EthAsset.symbol, + decimals: EthAsset.decimals, + name: EthAsset.name, + chainId: network.chainId, + }); + expect(result).toStrictEqual(expectedResult); + }); +}); diff --git a/packages/get-starknet/src/rpcs/watch-asset.ts b/packages/get-starknet/src/rpcs/watch-asset.ts new file mode 100644 index 00000000..8cd37d81 --- /dev/null +++ b/packages/get-starknet/src/rpcs/watch-asset.ts @@ -0,0 +1,24 @@ +import type { RpcTypeToMessageMap } from 'get-starknet-core'; + +import { StarknetWalletRpc } from '../utils/rpc'; + +export type WalletWatchAssetMethod = 'wallet_watchAsset'; +type Params = RpcTypeToMessageMap[WalletWatchAssetMethod]['params']; +type Result = RpcTypeToMessageMap[WalletWatchAssetMethod]['result']; + +export class WalletWatchAsset extends StarknetWalletRpc { + async handleRequest(params: Params): Promise { + const { address, symbol, decimals, name } = params.options; + + // All parameters are required in the snap, + // However, some are optional in get-starknet framework. + // Therefore, we assigned default values to bypass the type issue, and let the snap throw the validation error. + return (await this.snap.watchAsset({ + address, + symbol: symbol ?? '', + decimals: decimals ?? 0, + name: name ?? '', + chainId: this.wallet.chainId, + })) as unknown as Result; + } +} diff --git a/packages/get-starknet/src/signer.ts b/packages/get-starknet/src/signer.ts index 252c05fc..29e6cbeb 100644 --- a/packages/get-starknet/src/signer.ts +++ b/packages/get-starknet/src/signer.ts @@ -24,11 +24,17 @@ export class MetaMaskSigner implements SignerInterface { } async getPubKey(): Promise { - return this.#snap.getPubKey(this.#address); + return this.#snap.getPubKey({ + userAddress: this.#address, + }); } - async signMessage(typedData: TypedData, accountAddress: string): Promise { - const result = (await this.#snap.signMessage(typedData, false, accountAddress)) as ArraySignatureType; + async signMessage(typedDataMessage: TypedData, address: string): Promise { + const result = (await this.#snap.signMessage({ + typedDataMessage, + enableAuthorize: false, + address, + })) as ArraySignatureType; return new ec.starkCurve.Signature(numUtils.toBigInt(result[0]), numUtils.toBigInt(result[1])); } @@ -45,21 +51,27 @@ export class MetaMaskSigner implements SignerInterface { transactionsDetail: InvocationsSignerDetails, _abis?: Abi[] | undefined, ): Promise { - const result = (await this.#snap.signTransaction( - this.#address, + const result = (await this.#snap.signTransaction({ + address: this.#address, transactions, transactionsDetail, - )) as ArraySignatureType; + })) as ArraySignatureType; return new ec.starkCurve.Signature(numUtils.toBigInt(result[0]), numUtils.toBigInt(result[1])); } async signDeployAccountTransaction(transaction: DeployAccountSignerDetails): Promise { - const result = (await this.#snap.signDeployAccountTransaction(this.#address, transaction)) as ArraySignatureType; + const result = (await this.#snap.signDeployAccountTransaction({ + signerAddress: this.#address, + transaction, + })) as ArraySignatureType; return new ec.starkCurve.Signature(numUtils.toBigInt(result[0]), numUtils.toBigInt(result[1])); } async signDeclareTransaction(transaction: DeclareSignerDetails): Promise { - const result = (await this.#snap.signDeclareTransaction(this.#address, transaction)) as ArraySignatureType; + const result = (await this.#snap.signDeclareTransaction({ + address: this.#address, + details: transaction, + })) as ArraySignatureType; return new ec.starkCurve.Signature(numUtils.toBigInt(result[0]), numUtils.toBigInt(result[1])); } } diff --git a/packages/get-starknet/src/snap.ts b/packages/get-starknet/src/snap.ts index bac8e669..17149863 100644 --- a/packages/get-starknet/src/snap.ts +++ b/packages/get-starknet/src/snap.ts @@ -13,7 +13,7 @@ import type { TypedData, } from 'starknet'; -import type { AccContract, MetaMaskProvider, Network, RequestSnapResponse } from './type'; +import type { AccContract, DeploymentData, MetaMaskProvider, Network, RequestSnapResponse } from './type'; export class MetaMaskSnap { #provider: MetaMaskProvider; @@ -28,146 +28,189 @@ export class MetaMaskSnap { this.#version = version; } - async getPubKey(userAddress: string): Promise { + async getPubKey({ userAddress, chainId }: { userAddress: string; chainId?: string }): Promise { return (await this.#provider.request({ method: 'wallet_invokeSnap', params: { snapId: this.#snapId, request: { method: 'starkNet_extractPublicKey', - params: { + params: await this.#getSnapParams({ userAddress, - ...(await this.#getSnapParams()), - }, + chainId, + }), }, }, })) as string; } - async signTransaction( - address: string, - transactions: Call[], - transactionsDetail: InvocationsSignerDetails, - ): Promise { + async signTransaction({ + address, + transactions, + transactionsDetail, + chainId, + }: { + address: string; + transactions: Call[]; + transactionsDetail: InvocationsSignerDetails; + chainId?: string; + }): Promise { return (await this.#provider.request({ method: 'wallet_invokeSnap', params: { snapId: this.#snapId, request: { method: 'starkNet_signTransaction', - params: this.removeUndefined({ + params: await this.#getSnapParams({ address, transactions, transactionsDetail, - ...(await this.#getSnapParams()), + chainId, }), }, }, })) as Signature; } - async signDeployAccountTransaction( - signerAddress: string, - transaction: DeployAccountSignerDetails, - ): Promise { + async signDeployAccountTransaction({ + signerAddress, + transaction, + chainId, + }: { + signerAddress: string; + transaction: DeployAccountSignerDetails; + chainId?: string; + }): Promise { return (await this.#provider.request({ method: 'wallet_invokeSnap', params: { snapId: this.#snapId, request: { method: 'starkNet_signDeployAccountTransaction', - params: this.removeUndefined({ + params: await this.#getSnapParams({ signerAddress, transaction, - ...(await this.#getSnapParams()), + chainId, }), }, }, })) as Signature; } - async signDeclareTransaction(address: string, details: DeclareSignerDetails): Promise { + async signDeclareTransaction({ + address, + details, + chainId, + }: { + address: string; + details: DeclareSignerDetails; + chainId?: string; + }): Promise { return (await this.#provider.request({ method: 'wallet_invokeSnap', params: { snapId: this.#snapId, request: { method: 'starkNet_signDeclareTransaction', - params: this.removeUndefined({ + params: await this.#getSnapParams({ address, details, - ...(await this.#getSnapParams()), + chainId, }), }, }, })) as Signature; } - async execute( - address: string, - calls: AllowArray, - abis?: Abi[], - details?: InvocationsDetails, - ): Promise { + async execute({ + address, + calls, + abis, + details, + chainId, + }: { + address: string; + calls: AllowArray; + abis?: Abi[]; + details?: InvocationsDetails; + chainId?: string; + }): Promise { return (await this.#provider.request({ method: 'wallet_invokeSnap', params: { snapId: this.#snapId, request: { method: 'starkNet_executeTxn', - params: this.removeUndefined({ + params: await this.#getSnapParams({ address, calls, details, abis, - ...(await this.#getSnapParams()), + chainId, }), }, }, })) as InvokeFunctionResponse; } - async signMessage(typedDataMessage: TypedData, enableAuthorize: boolean, address: string): Promise { + async signMessage({ + typedDataMessage, + enableAuthorize, + address, + chainId, + }: { + typedDataMessage: TypedData; + enableAuthorize: boolean; + address: string; + chainId?: string; + }): Promise { return (await this.#provider.request({ method: 'wallet_invokeSnap', params: { snapId: this.#snapId, request: { method: 'starkNet_signMessage', - params: this.removeUndefined({ + params: await this.#getSnapParams({ address, typedDataMessage, enableAuthorize, - ...(await this.#getSnapParams()), + chainId, }), }, }, })) as Signature; } - async declare( - senderAddress: string, - contractPayload: DeclareContractPayload, - invocationsDetails?: InvocationsDetails, - ): Promise { + async declare({ + senderAddress, + contractPayload, + invocationsDetails, + chainId, + }: { + senderAddress: string; + contractPayload: DeclareContractPayload; + invocationsDetails?: InvocationsDetails; + chainId?: string; + }): Promise { return (await this.#provider.request({ method: 'wallet_invokeSnap', params: { snapId: this.#snapId, request: { method: 'starkNet_declareContract', - params: this.removeUndefined({ + params: await this.#getSnapParams({ senderAddress, contractPayload, invocationsDetails, - ...(await this.#getSnapParams()), + chainId, }), }, }, })) as DeclareContractResponse; } - async getNetwork(chainId: string): Promise { + // Method will be deprecated, replaced by get current network + async getNetwork(chainId): Promise { const response = (await this.#provider.request({ method: 'wallet_invokeSnap', params: { @@ -187,23 +230,38 @@ export class MetaMaskSnap { } async recoverDefaultAccount(chainId: string): Promise { - const result = await this.recoverAccounts(chainId, 0, 1, 1); + const result = await this.recoverAccounts({ + chainId, + startScanIndex: 0, + maxScanned: 1, + maxMissed: 1, + }); return result[0]; } - async recoverAccounts(chainId: string, startScanIndex = 0, maxScanned = 1, maxMissed = 1): Promise { + async recoverAccounts({ + chainId, + startScanIndex = 0, + maxScanned = 1, + maxMissed = 1, + }: { + chainId?: string; + startScanIndex?: number; + maxScanned?: number; + maxMissed?: number; + }): Promise { return (await this.#provider.request({ method: 'wallet_invokeSnap', params: { snapId: this.#snapId, request: { method: 'starkNet_recoverAccounts', - params: { + params: await this.#getSnapParams({ startScanIndex, maxScanned, maxMissed, chainId, - }, + }), }, }, })) as AccContract[]; @@ -225,14 +283,25 @@ export class MetaMaskSnap { })) as boolean; } - async addStarknetChain(chainName: string, chainId: string, rpcUrl: string, explorerUrl: string): Promise { + // Method to be deprecated, no longer supported + async addStarknetChain({ + chainName, + chainId, + rpcUrl, + explorerUrl, + }: { + chainName: string; + chainId: string; + rpcUrl: string; + explorerUrl: string; + }): Promise { return (await this.#provider.request({ method: 'wallet_invokeSnap', params: { snapId: this.#snapId, request: { method: 'starkNet_addNetwork', - params: this.removeUndefined({ + params: this.#removeUndefined({ networkName: chainName, networkChainId: chainId, networkNodeUrl: rpcUrl, @@ -243,18 +312,31 @@ export class MetaMaskSnap { })) as boolean; } - async watchAsset(address: string, name: string, symbol: string, decimals: number): Promise { + async watchAsset({ + address, + name, + symbol, + decimals, + chainId, + }: { + address: string; + name: string; + symbol: string; + decimals: number; + chainId?: string; + }): Promise { return this.#provider.request({ method: 'wallet_invokeSnap', params: { snapId: this.#snapId, request: { method: 'starkNet_addErc20Token', - params: this.removeUndefined({ + params: await this.#getSnapParams({ tokenAddress: address, tokenName: name, tokenSymbol: symbol, tokenDecimals: decimals, + chainId, }), }, }, @@ -276,11 +358,29 @@ export class MetaMaskSnap { return response; } - async #getSnapParams() { - const network = await this.getCurrentNetwork(); - return { - chainId: network.chainId, - }; + async getDeploymentData({ chainId, address }: { chainId: string; address: string }): Promise { + const response = (await this.#provider.request({ + method: 'wallet_invokeSnap', + params: { + snapId: this.#snapId, + request: { + method: 'starkNet_getDeploymentData', + params: { + chainId, + address, + }, + }, + }, + })) as DeploymentData; + + return response; + } + + async #getSnapParams(params: Record & { chainId?: string }): Promise> { + return this.#removeUndefined({ + ...params, + chainId: params.chainId ?? (await this.getCurrentNetwork()).chainId, + }); } static async getProvider(window: { @@ -353,7 +453,7 @@ export class MetaMaskSnap { } } - removeUndefined(obj: Record) { + #removeUndefined(obj: Record) { // eslint-disable-next-line @typescript-eslint/no-unused-vars return Object.fromEntries(Object.entries(obj).filter(([_, val]) => val !== undefined)); } diff --git a/packages/get-starknet/src/type.ts b/packages/get-starknet/src/type.ts index b989b38a..f13f9b58 100644 --- a/packages/get-starknet/src/type.ts +++ b/packages/get-starknet/src/type.ts @@ -10,6 +10,8 @@ export type AccContract = { derivationPath: string; deployTxnHash: string; // in hex chainId: string; // in hex + upgradeRequired?: boolean; + deployRequired?: boolean; }; export type Network = { @@ -22,6 +24,15 @@ export type Network = { useOldAccounts?: boolean; }; +export type DeploymentData = { + address: string; + // eslint-disable-next-line @typescript-eslint/naming-convention + class_hash: string; + salt: string; + calldata: string[]; + version: 0 | 1; +}; + export type RequestSnapResponse = { [key in string]: { enabled: boolean; diff --git a/packages/get-starknet/src/utils/error.test.ts b/packages/get-starknet/src/utils/error.test.ts new file mode 100644 index 00000000..b29efef7 --- /dev/null +++ b/packages/get-starknet/src/utils/error.test.ts @@ -0,0 +1,29 @@ +import { createStarkError, WalletRpcError, WalletRpcErrorMap, defaultErrorMessage, defaultErrorCode } from './error'; + +describe('createStarkError', () => { + it.each( + Object.entries(WalletRpcErrorMap).map(([code, message]) => ({ + code: parseInt(code, 10), + message, + })), + )('returns corresponding error if the error code is $code', ({ code, message }) => { + const error = createStarkError(code); + expect(error).toBeInstanceOf(WalletRpcError); + expect(error.message).toStrictEqual(message); + expect(error.code).toStrictEqual(code); + }); + + it('returns default error code and message if the error code is undefined', () => { + const error = createStarkError(undefined); + expect(error).toBeInstanceOf(WalletRpcError); + expect(error.message).toStrictEqual(defaultErrorMessage); + expect(error.code).toStrictEqual(defaultErrorCode); + }); + + it('returns default error code and message if the error code does not exist in the mapping', () => { + const error = createStarkError(0); + expect(error).toBeInstanceOf(WalletRpcError); + expect(error.message).toStrictEqual(defaultErrorMessage); + expect(error.code).toStrictEqual(defaultErrorCode); + }); +}); diff --git a/packages/get-starknet/src/utils/error.ts b/packages/get-starknet/src/utils/error.ts new file mode 100644 index 00000000..b872b027 --- /dev/null +++ b/packages/get-starknet/src/utils/error.ts @@ -0,0 +1,51 @@ +// The error code is following the Starknet Wallet RPC 0.7.2 specification. +export enum WalletRpcErrorCode { + InvalidErc20 = 111, + InvalidNetwork = 112, + UserDeny = 113, + InvalidRequest = 114, + AccountAlreadyDeployed = 115, + ApiVersionNotSupported = 162, + Unknown = 163, +} + +// Here we define the error message for each error +export const WalletRpcErrorMap = { + [WalletRpcErrorCode.InvalidErc20]: 'An error occurred (NOT_ERC20)', + [WalletRpcErrorCode.InvalidNetwork]: 'An error occurred (UNLISTED_NETWORK)', + [WalletRpcErrorCode.UserDeny]: 'An error occurred (USER_REFUSED_OP)', + [WalletRpcErrorCode.InvalidRequest]: 'An error occurred (INVALID_REQUEST_PAYLOAD)', + [WalletRpcErrorCode.AccountAlreadyDeployed]: 'An error occurred (ACCOUNT_ALREADY_DEPLOYED)', + [WalletRpcErrorCode.ApiVersionNotSupported]: 'An error occurred (API_VERSION_NOT_SUPPORTED)', + [WalletRpcErrorCode.Unknown]: 'An error occurred (UNKNOWN_ERROR)', +}; +export const defaultErrorCode = WalletRpcErrorCode.Unknown; +export const defaultErrorMessage = WalletRpcErrorMap[defaultErrorCode]; + +export class WalletRpcError extends Error { + readonly code: number; + + constructor(message: string, errorCode: number) { + super(message); + this.code = errorCode; + } +} + +/** + * Create WalletRpcError object based on the given error code to map with the Wallet API error. + * + * @param [errorCode] - Error code to map with the Wallet API error. + * @returns A WalletRpcError Object that contains the corresponing Wallet API Error code and message. + */ +export function createStarkError(errorCode?: number) { + let code = errorCode ?? defaultErrorCode; + let message = defaultErrorMessage; + + if (WalletRpcErrorMap[code]) { + message = WalletRpcErrorMap[code]; + } else { + code = defaultErrorCode; + } + + return new WalletRpcError(message, code); +} diff --git a/packages/get-starknet/src/utils/formatter.test.ts b/packages/get-starknet/src/utils/formatter.test.ts new file mode 100644 index 00000000..a63bf1ff --- /dev/null +++ b/packages/get-starknet/src/utils/formatter.test.ts @@ -0,0 +1,170 @@ +import { formatCalls, formatDeclareTransaction } from './formatter'; + +describe('formatCalls', () => { + it('converts a list of `Call` objects to the expected format', () => { + const calls = [ + { + // eslint-disable-next-line @typescript-eslint/naming-convention + contract_address: '0xabc', + // eslint-disable-next-line @typescript-eslint/naming-convention + entry_point: 'transfer', + calldata: ['0x1', '0x2'], + }, + ]; + + const expected = [ + { + contractAddress: '0xabc', + entrypoint: 'transfer', + calldata: ['0x1', '0x2'], + }, + ]; + + const result = formatCalls(calls); + + expect(result).toStrictEqual(expected); + }); + + it('remains unchanged if the `Call` object is in the expected format', () => { + const calls = [ + { + contractAddress: '0xdef', + entrypoint: 'approve', + calldata: ['0x3', '0x4'], + }, + ]; + + const expected = [ + { + contractAddress: '0xdef', + entrypoint: 'approve', + calldata: ['0x3', '0x4'], + }, + ]; + + const result = formatCalls(calls); + + expect(result).toStrictEqual(expected); + }); + + it('remains `calldata` undefined if it is undefined in the `Call` object', () => { + const calls = [ + { contractAddress: '0xdef', entrypoint: 'approve' }, // no calldata + ]; + + const expected = [ + { contractAddress: '0xdef', entrypoint: 'approve', calldata: undefined }, // empty calldata + ]; + + const result = formatCalls(calls); + + expect(result).toStrictEqual(expected); + }); +}); + +/* eslint-disable @typescript-eslint/naming-convention */ +describe('formatDeclareTransaction', () => { + // Helper function to generate the declare contract test params + const generateDeclareTransactionParams = ({ + compiledClassHash = '0xcompiledClassHash', + classHash = '0xclassHash', + entryPointsConstructor = [{ selector: '0xconstructorSelector', function_idx: 0 }], + entryPointsExternal = [{ selector: '0xexternalSelector', function_idx: 1 }], + entryPointsL1Handler = [{ selector: '0xhandlerSelector', function_idx: 2 }], + abi = '[{"type":"function","name":"transfer"}]', + } = {}) => ({ + compiled_class_hash: compiledClassHash, + class_hash: classHash, + contract_class: { + sierra_program: ['0x1', '0x2'], + contract_class_version: '1.0.0', + entry_points_by_type: { + CONSTRUCTOR: entryPointsConstructor, + EXTERNAL: entryPointsExternal, + L1_HANDLER: entryPointsL1Handler, + }, + abi, + }, + }); + + // Helper function to generate the expected result of the declare transaction + const generateExpectedDeclareTransactionPayload = ({ + compiledClassHash = '0xcompiledClassHash', + classHash = '0xclassHash', + entryPointsConstructor = [{ selector: '0xconstructorSelector', function_idx: 0 }], + entryPointsExternal = [{ selector: '0xexternalSelector', function_idx: 1 }], + entryPointsL1Handler = [{ selector: '0xhandlerSelector', function_idx: 2 }], + abi = '[{"type":"function","name":"transfer"}]', + } = {}) => ({ + compiledClassHash, + classHash, + contract: { + sierra_program: ['0x1', '0x2'], + contract_class_version: '1.0.0', + entry_points_by_type: { + CONSTRUCTOR: entryPointsConstructor, + EXTERNAL: entryPointsExternal, + L1_HANDLER: entryPointsL1Handler, + }, + abi, + }, + }); + + it('converts the `AddDeclareTransactionParameters` object to the expected format', () => { + const params = generateDeclareTransactionParams(); + const expected = generateExpectedDeclareTransactionPayload(); + + const result = formatDeclareTransaction(params); + + expect(result).toStrictEqual(expected); + }); + + it('remains `class_hash` undefined if it is undefined', () => { + const params = generateDeclareTransactionParams({ classHash: undefined }); + const expected = generateExpectedDeclareTransactionPayload({ classHash: undefined }); + + const result = formatDeclareTransaction(params); + + expect(result).toStrictEqual(expected); + }); + + // Test each entry point property individually when empty + it.each([ + { + entryType: 'CONSTRUCTOR', + entryPointsConstructor: [], + entryPointsExternal: [{ selector: '0xexternalSelector', function_idx: 1 }], + entryPointsL1Handler: [{ selector: '0xhandlerSelector', function_idx: 2 }], + }, + { + entryType: 'EXTERNAL', + entryPointsConstructor: [{ selector: '0xconstructorSelector', function_idx: 0 }], + entryPointsExternal: [], + entryPointsL1Handler: [{ selector: '0xhandlerSelector', function_idx: 2 }], + }, + { + entryType: 'L1_HANDLER', + entryPointsConstructor: [{ selector: '0xconstructorSelector', function_idx: 0 }], + entryPointsExternal: [{ selector: '0xexternalSelector', function_idx: 1 }], + entryPointsL1Handler: [], + }, + ])('handles empty $entryType correctly', ({ entryPointsConstructor, entryPointsExternal, entryPointsL1Handler }) => { + const params = generateDeclareTransactionParams({ + entryPointsConstructor, + entryPointsExternal, + entryPointsL1Handler, + abi: '[]', // empty ABI string + }); + + const expected = generateExpectedDeclareTransactionPayload({ + entryPointsConstructor, + entryPointsExternal, + entryPointsL1Handler, + abi: '[]', // empty ABI string + }); + + const result = formatDeclareTransaction(params); + + expect(result).toStrictEqual(expected); + }); +}); diff --git a/packages/get-starknet/src/utils/formatter.ts b/packages/get-starknet/src/utils/formatter.ts new file mode 100644 index 00000000..8f0a9195 --- /dev/null +++ b/packages/get-starknet/src/utils/formatter.ts @@ -0,0 +1,69 @@ +/* eslint-disable @typescript-eslint/naming-convention, camelcase */ +import type { Abi, Call, DeclareContractPayload } from 'starknet'; +import type { AddDeclareTransactionParameters, Call as CallGetStarknetV4 } from 'starknet-types-07'; + +/** + * Converts an array of calls from either the `CallGetStarknetV4[]` format + * or the standard `Call[]` format into the standard `Call[]` format. If the input + * calls are already in the correct format, no changes are made. + * + * The function ensures that: + * - `contract_address` from `CallGetStarknetV4` is renamed to `contractAddress` if needed. + * - `entry_point` from `CallGetStarknetV4` is renamed to `entrypoint` if needed. + * - `calldata` is set to an empty array if undefined. + * + * @param calls - The array of `Call` objects, either in `CallGetStarknetV4` or `Call`. + * @returns The array of formatted calls in the `Call[]` format. + */ +export const formatCalls = (calls: Call[] | CallGetStarknetV4[]): Call[] => { + return calls.map((call) => { + const contractAddress = 'contract_address' in call ? call.contract_address : call.contractAddress; + const entrypoint = 'entry_point' in call ? call.entry_point : call.entrypoint; + const { calldata } = call; + + return { + contractAddress, + entrypoint, + calldata, + }; + }); +}; + +/** + * Converts `AddDeclareTransactionParameters` into `DeclareContractPayload` format. + * + * The function ensures that: + * - `compiled_class_hash` is mapped to `compiledClassHash`. + * - `class_hash` is optional and is mapped to `classHash`. + * - `contract_class` is converted into the expected `CompiledSierra` structure. + * + * @param params - The object of `AddDeclareTransactionParameters`. + * @returns The object in `DeclareContractPayload` format. + */ +export const formatDeclareTransaction = (params: AddDeclareTransactionParameters): DeclareContractPayload => { + const { compiled_class_hash, class_hash, contract_class } = params; + + return { + compiledClassHash: compiled_class_hash, + classHash: class_hash, + contract: { + sierra_program: contract_class.sierra_program, + contract_class_version: contract_class.contract_class_version, + entry_points_by_type: { + CONSTRUCTOR: contract_class?.entry_points_by_type?.CONSTRUCTOR.map((ep) => ({ + selector: ep.selector, + function_idx: ep.function_idx, + })), + EXTERNAL: contract_class?.entry_points_by_type?.EXTERNAL.map((ep) => ({ + selector: ep.selector, + function_idx: ep.function_idx, + })), + L1_HANDLER: contract_class?.entry_points_by_type?.L1_HANDLER.map((ep) => ({ + selector: ep.selector, + function_idx: ep.function_idx, + })), + }, + abi: contract_class.abi as unknown as Abi, // Directly passing the string as `any` + }, + }; +}; diff --git a/packages/get-starknet/src/utils/index.ts b/packages/get-starknet/src/utils/index.ts new file mode 100644 index 00000000..e2c82525 --- /dev/null +++ b/packages/get-starknet/src/utils/index.ts @@ -0,0 +1 @@ +export * from './rpc'; diff --git a/packages/get-starknet/src/utils/rpc.ts b/packages/get-starknet/src/utils/rpc.ts new file mode 100644 index 00000000..b3923a4f --- /dev/null +++ b/packages/get-starknet/src/utils/rpc.ts @@ -0,0 +1,37 @@ +import type { RpcMessage, RpcTypeToMessageMap } from 'get-starknet-core'; + +import type { MetaMaskSnap } from '../snap'; +import type { MetaMaskSnapWallet } from '../wallet'; +import { createStarkError } from './error'; + +export type IStarknetWalletRpc = { + execute( + params: RpcTypeToMessageMap[Rpc]['params'], + ): Promise; +}; + +export abstract class StarknetWalletRpc implements IStarknetWalletRpc { + protected snap: MetaMaskSnap; + + protected wallet: MetaMaskSnapWallet; + + constructor(wallet: MetaMaskSnapWallet) { + this.snap = wallet.snap; + this.wallet = wallet; + } + + async execute( + params?: RpcTypeToMessageMap[Rpc]['params'], + ): Promise { + try { + await this.wallet.init(false); + return await this.handleRequest(params); + } catch (error) { + throw createStarkError(error?.data?.walletRpcError?.code); + } + } + + abstract handleRequest( + params: RpcTypeToMessageMap[Rpc]['params'], + ): Promise; +} diff --git a/packages/get-starknet/src/wallet.test.ts b/packages/get-starknet/src/wallet.test.ts new file mode 100644 index 00000000..ad72a3de --- /dev/null +++ b/packages/get-starknet/src/wallet.test.ts @@ -0,0 +1,154 @@ +import { Mutex } from 'async-mutex'; +import { Provider } from 'starknet'; + +import { SepoliaNetwork, mockWalletInit, createWallet } from './__tests__/helper'; +import { MetaMaskAccount } from './accounts'; +import { WalletSupportedSpecs } from './rpcs'; +import type { AccContract, Network } from './type'; + +describe('MetaMaskSnapWallet', () => { + describe('enable', () => { + it('returns an account address', async () => { + const expectedAccountAddress = '0x04882a372da3dfe1c53170ad75893832469bf87b62b13e84662565c4a88f25cd'; // in hex + mockWalletInit({ + address: expectedAccountAddress, + }); + + const wallet = createWallet(); + const [address] = await wallet.enable(); + + expect(address).toStrictEqual(expectedAccountAddress); + }); + + it('throws `Unable to recover accounts` error if the account address not return from the Snap', async () => { + const { recoverDefaultAccountSpy } = mockWalletInit({}); + recoverDefaultAccountSpy.mockResolvedValue({} as unknown as AccContract); + + const wallet = createWallet(); + + await expect(wallet.enable()).rejects.toThrow('Unable to recover accounts'); + }); + }); + + describe('init', () => { + it('installs the snap and set the properties', async () => { + const expectedAccountAddress = '0x04882a372da3dfe1c53170ad75893832469bf87b62b13e84662565c4a88f25cd'; // in hex + + const { installSpy } = mockWalletInit({ + address: expectedAccountAddress, + }); + + const wallet = createWallet(); + await wallet.init(); + + expect(installSpy).toHaveBeenCalled(); + expect(wallet.isConnected).toBe(true); + expect(wallet.selectedAddress).toStrictEqual(expectedAccountAddress); + expect(wallet.chainId).toStrictEqual(SepoliaNetwork.chainId); + expect(wallet.provider).toBeDefined(); + expect(wallet.account).toBeDefined(); + }); + + it('does not create the lock if the `createLock` param is false', async () => { + const runExclusiveSpy = jest.spyOn(Mutex.prototype, 'runExclusive'); + runExclusiveSpy.mockReturnThis(); + mockWalletInit({}); + + const wallet = createWallet(); + await wallet.init(false); + + expect(runExclusiveSpy).not.toHaveBeenCalled(); + }); + + it('throw `Snap is not installed` error if the snap is not able to install', async () => { + mockWalletInit({ install: false }); + + const wallet = createWallet(); + await expect(wallet.init()).rejects.toThrow('Snap is not installed'); + }); + + it('throw `Unable to find the selected network` error if the network is not return from snap', async () => { + mockWalletInit({ currentNetwork: null as unknown as Network }); + + const wallet = createWallet(); + await expect(wallet.init()).rejects.toThrow('Unable to find the selected network'); + }); + }); + + describe('account', () => { + it('returns an account object', async () => { + mockWalletInit({}); + + const wallet = createWallet(); + await wallet.enable(); + + expect(wallet.account).toBeInstanceOf(MetaMaskAccount); + }); + + it('throw `Address is not set` error if the init has not execute', async () => { + const wallet = createWallet(); + + expect(() => wallet.account).toThrow('Address is not set'); + }); + }); + + describe('provider', () => { + it('returns an provider object', async () => { + mockWalletInit({}); + + const wallet = createWallet(); + await wallet.enable(); + + expect(wallet.provider).toBeInstanceOf(Provider); + }); + + it('throw `Network is not set` error if the init has not execute', async () => { + const wallet = createWallet(); + + expect(() => wallet.provider).toThrow('Network is not set'); + }); + }); + + describe('request', () => { + it('executes a request', async () => { + const spy = jest.spyOn(WalletSupportedSpecs.prototype, 'execute'); + spy.mockReturnThis(); + + const wallet = createWallet(); + await wallet.request({ type: 'wallet_supportedSpecs' }); + + expect(spy).toHaveBeenCalled(); + }); + + it('throws `WalletRpcError` if the request method does not exist', async () => { + const wallet = createWallet(); + // force the 'invalid_method' as a correct type of the request to test the error + await expect(wallet.request({ type: 'invalid_method' as unknown as 'wallet_supportedSpecs' })).rejects.toThrow( + 'Method not supported', + ); + }); + }); + + describe('isPreauthorized', () => { + it('returns true', async () => { + const wallet = createWallet(); + expect(await wallet.isPreauthorized()).toBe(true); + }); + }); + + describe('on', () => { + it('throws `Method not supported` error', async () => { + const wallet = createWallet(); + + expect(() => wallet.on()).toThrow('Method not supported'); + }); + }); + + describe('off', () => { + it('throws `Method not supported` error', async () => { + const wallet = createWallet(); + + expect(() => wallet.off()).toThrow('Method not supported'); + }); + }); +}); diff --git a/packages/get-starknet/src/wallet.ts b/packages/get-starknet/src/wallet.ts index a08f1b56..3c313368 100644 --- a/packages/get-starknet/src/wallet.ts +++ b/packages/get-starknet/src/wallet.ts @@ -1,20 +1,31 @@ -import type { IStarknetWindowObject } from 'get-starknet-core'; -import { - type AddStarknetChainParameters, - type RpcMessage, - type SwitchStarknetChainParameter, - type WalletEvents, - type WatchAssetParameters, -} from 'get-starknet-core'; +import type { MutexInterface } from 'async-mutex'; +import { Mutex } from 'async-mutex'; +import { type RpcMessage, type WalletEvents, type StarknetWindowObject } from 'get-starknet-core'; import type { AccountInterface, ProviderInterface } from 'starknet'; import { Provider } from 'starknet'; import { MetaMaskAccount } from './accounts'; +import { RpcMethod, WalletIconMetaData } from './constants'; +import { + WalletSupportedSpecs, + WalletSupportedWalletApi, + WalletSwitchStarknetChain, + WalletDeploymentData, + WalletRequestAccount, + WalletAddInvokeTransaction, + WalletRequestChainId, + WalletWatchAsset, + WalletSignTypedData, + WalletGetPermissions, + WalletAddDeclareTransaction, +} from './rpcs'; import { MetaMaskSigner } from './signer'; import { MetaMaskSnap } from './snap'; -import type { MetaMaskProvider } from './type'; +import type { MetaMaskProvider, Network } from './type'; +import type { IStarknetWalletRpc } from './utils'; +import { WalletRpcError, WalletRpcErrorCode } from './utils/error'; -export class MetaMaskSnapWallet implements IStarknetWindowObject { +export class MetaMaskSnapWallet implements StarknetWindowObject { id: string; name: string; @@ -23,80 +34,74 @@ export class MetaMaskSnapWallet implements IStarknetWindowObject { icon: string; - account?: AccountInterface | undefined; + isConnected: boolean; + + snap: MetaMaskSnap; + + metamaskProvider: MetaMaskProvider; - provider?: ProviderInterface | undefined; + #rpcHandlers: Map; - selectedAddress?: string | undefined; + #account: AccountInterface | undefined; - chainId?: string | undefined; + #provider: ProviderInterface | undefined; - isConnected: boolean; + #selectedAddress: string; - snap: MetaMaskSnap; + #chainId: string; - metamaskProvider: MetaMaskProvider; + #network: Network; - static readonly #cairoVersion = '0'; + lock: MutexInterface; // eslint-disable-next-line @typescript-eslint/naming-convention, no-restricted-globals - static readonly #SNAPI_ID = process.env.SNAP_ID ?? 'npm:@consensys/starknet-snap'; + static readonly snapId = process.env.SNAP_ID ?? 'npm:@consensys/starknet-snap'; constructor(metamaskProvider: MetaMaskProvider, snapVersion = '*') { this.id = 'metamask'; this.name = 'Metamask'; - this.version = 'v1.0.0'; - this.icon = `data:image/svg+xml;utf8;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMTIiIGhlaWdodD0iMTg5IiB2aWV3Qm94PSIwIDAgMjEyIDE4OSI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cG9seWdvbiBmaWxsPSIjQ0RCREIyIiBwb2ludHM9IjYwLjc1IDE3My4yNSA4OC4zMTMgMTgwLjU2MyA4OC4zMTMgMTcxIDkwLjU2MyAxNjguNzUgMTA2LjMxMyAxNjguNzUgMTA2LjMxMyAxODAgMTA2LjMxMyAxODcuODc1IDg5LjQzOCAxODcuODc1IDY4LjYyNSAxNzguODc1Ii8+PHBvbHlnb24gZmlsbD0iI0NEQkRCMiIgcG9pbnRzPSIxMDUuNzUgMTczLjI1IDEzMi43NSAxODAuNTYzIDEzMi43NSAxNzEgMTM1IDE2OC43NSAxNTAuNzUgMTY4Ljc1IDE1MC43NSAxODAgMTUwLjc1IDE4Ny44NzUgMTMzLjg3NSAxODcuODc1IDExMy4wNjMgMTc4Ljg3NSIgdHJhbnNmb3JtPSJtYXRyaXgoLTEgMCAwIDEgMjU2LjUgMCkiLz48cG9seWdvbiBmaWxsPSIjMzkzOTM5IiBwb2ludHM9IjkwLjU2MyAxNTIuNDM4IDg4LjMxMyAxNzEgOTEuMTI1IDE2OC43NSAxMjAuMzc1IDE2OC43NSAxMjMuNzUgMTcxIDEyMS41IDE1Mi40MzggMTE3IDE0OS42MjUgOTQuNSAxNTAuMTg4Ii8+PHBvbHlnb24gZmlsbD0iI0Y4OUMzNSIgcG9pbnRzPSI3NS4zNzUgMjcgODguODc1IDU4LjUgOTUuMDYzIDE1MC4xODggMTE3IDE1MC4xODggMTIzLjc1IDU4LjUgMTM2LjEyNSAyNyIvPjxwb2x5Z29uIGZpbGw9IiNGODlEMzUiIHBvaW50cz0iMTYuMzEzIDk2LjE4OCAuNTYzIDE0MS43NSAzOS45MzggMTM5LjUgNjUuMjUgMTM5LjUgNjUuMjUgMTE5LjgxMyA2NC4xMjUgNzkuMzEzIDU4LjUgODMuODEzIi8+PHBvbHlnb24gZmlsbD0iI0Q4N0MzMCIgcG9pbnRzPSI0Ni4xMjUgMTAxLjI1IDkyLjI1IDEwMi4zNzUgODcuMTg4IDEyNiA2NS4yNSAxMjAuMzc1Ii8+PHBvbHlnb24gZmlsbD0iI0VBOEQzQSIgcG9pbnRzPSI0Ni4xMjUgMTAxLjgxMyA2NS4yNSAxMTkuODEzIDY1LjI1IDEzNy44MTMiLz48cG9seWdvbiBmaWxsPSIjRjg5RDM1IiBwb2ludHM9IjY1LjI1IDEyMC4zNzUgODcuNzUgMTI2IDk1LjA2MyAxNTAuMTg4IDkwIDE1MyA2NS4yNSAxMzguMzc1Ii8+PHBvbHlnb24gZmlsbD0iI0VCOEYzNSIgcG9pbnRzPSI2NS4yNSAxMzguMzc1IDYwLjc1IDE3My4yNSA5MC41NjMgMTUyLjQzOCIvPjxwb2x5Z29uIGZpbGw9IiNFQThFM0EiIHBvaW50cz0iOTIuMjUgMTAyLjM3NSA5NS4wNjMgMTUwLjE4OCA4Ni42MjUgMTI1LjcxOSIvPjxwb2x5Z29uIGZpbGw9IiNEODdDMzAiIHBvaW50cz0iMzkuMzc1IDEzOC45MzggNjUuMjUgMTM4LjM3NSA2MC43NSAxNzMuMjUiLz48cG9seWdvbiBmaWxsPSIjRUI4RjM1IiBwb2ludHM9IjEyLjkzOCAxODguNDM4IDYwLjc1IDE3My4yNSAzOS4zNzUgMTM4LjkzOCAuNTYzIDE0MS43NSIvPjxwb2x5Z29uIGZpbGw9IiNFODgyMUUiIHBvaW50cz0iODguODc1IDU4LjUgNjQuNjg4IDc4Ljc1IDQ2LjEyNSAxMDEuMjUgOTIuMjUgMTAyLjkzOCIvPjxwb2x5Z29uIGZpbGw9IiNERkNFQzMiIHBvaW50cz0iNjAuNzUgMTczLjI1IDkwLjU2MyAxNTIuNDM4IDg4LjMxMyAxNzAuNDM4IDg4LjMxMyAxODAuNTYzIDY4LjA2MyAxNzYuNjI1Ii8+PHBvbHlnb24gZmlsbD0iI0RGQ0VDMyIgcG9pbnRzPSIxMjEuNSAxNzMuMjUgMTUwLjc1IDE1Mi40MzggMTQ4LjUgMTcwLjQzOCAxNDguNSAxODAuNTYzIDEyOC4yNSAxNzYuNjI1IiB0cmFuc2Zvcm09Im1hdHJpeCgtMSAwIDAgMSAyNzIuMjUgMCkiLz48cG9seWdvbiBmaWxsPSIjMzkzOTM5IiBwb2ludHM9IjcwLjMxMyAxMTIuNSA2NC4xMjUgMTI1LjQzOCA4Ni4wNjMgMTE5LjgxMyIgdHJhbnNmb3JtPSJtYXRyaXgoLTEgMCAwIDEgMTUwLjE4OCAwKSIvPjxwb2x5Z29uIGZpbGw9IiNFODhGMzUiIHBvaW50cz0iMTIuMzc1IC41NjMgODguODc1IDU4LjUgNzUuOTM4IDI3Ii8+PHBhdGggZmlsbD0iIzhFNUEzMCIgZD0iTTEyLjM3NTAwMDIsMC41NjI1MDAwMDggTDIuMjUwMDAwMDMsMzEuNTAwMDAwNSBMNy44NzUwMDAxMiw2NS4yNTAwMDEgTDMuOTM3NTAwMDYsNjcuNTAwMDAxIEw5LjU2MjUwMDE0LDcyLjU2MjUgTDUuMDYyNTAwMDgsNzYuNTAwMDAxMSBMMTEuMjUsODIuMTI1MDAxMiBMNy4zMTI1MDAxMSw4NS41MDAwMDEzIEwxNi4zMTI1MDAyLDk2Ljc1MDAwMTQgTDU4LjUwMDAwMDksODMuODEyNTAxMiBDNzkuMTI1MDAxMiw2Ny4zMTI1MDA0IDg5LjI1MDAwMTMsNTguODc1MDAwMyA4OC44NzUwMDEzLDU4LjUwMDAwMDkgQzg4LjUwMDAwMTMsNTguMTI1MDAwOSA2My4wMDAwMDA5LDM4LjgxMjUwMDYgMTIuMzc1MDAwMiwwLjU2MjUwMDAwOCBaIi8+PGcgdHJhbnNmb3JtPSJtYXRyaXgoLTEgMCAwIDEgMjExLjUgMCkiPjxwb2x5Z29uIGZpbGw9IiNGODlEMzUiIHBvaW50cz0iMTYuMzEzIDk2LjE4OCAuNTYzIDE0MS43NSAzOS45MzggMTM5LjUgNjUuMjUgMTM5LjUgNjUuMjUgMTE5LjgxMyA2NC4xMjUgNzkuMzEzIDU4LjUgODMuODEzIi8+PHBvbHlnb24gZmlsbD0iI0Q4N0MzMCIgcG9pbnRzPSI0Ni4xMjUgMTAxLjI1IDkyLjI1IDEwMi4zNzUgODcuMTg4IDEyNiA2NS4yNSAxMjAuMzc1Ii8+PHBvbHlnb24gZmlsbD0iI0VBOEQzQSIgcG9pbnRzPSI0Ni4xMjUgMTAxLjgxMyA2NS4yNSAxMTkuODEzIDY1LjI1IDEzNy44MTMiLz48cG9seWdvbiBmaWxsPSIjRjg5RDM1IiBwb2ludHM9IjY1LjI1IDEyMC4zNzUgODcuNzUgMTI2IDk1LjA2MyAxNTAuMTg4IDkwIDE1MyA2NS4yNSAxMzguMzc1Ii8+PHBvbHlnb24gZmlsbD0iI0VCOEYzNSIgcG9pbnRzPSI2NS4yNSAxMzguMzc1IDYwLjc1IDE3My4yNSA5MCAxNTMiLz48cG9seWdvbiBmaWxsPSIjRUE4RTNBIiBwb2ludHM9IjkyLjI1IDEwMi4zNzUgOTUuMDYzIDE1MC4xODggODYuNjI1IDEyNS43MTkiLz48cG9seWdvbiBmaWxsPSIjRDg3QzMwIiBwb2ludHM9IjM5LjM3NSAxMzguOTM4IDY1LjI1IDEzOC4zNzUgNjAuNzUgMTczLjI1Ii8+PHBvbHlnb24gZmlsbD0iI0VCOEYzNSIgcG9pbnRzPSIxMi45MzggMTg4LjQzOCA2MC43NSAxNzMuMjUgMzkuMzc1IDEzOC45MzggLjU2MyAxNDEuNzUiLz48cG9seWdvbiBmaWxsPSIjRTg4MjFFIiBwb2ludHM9Ijg4Ljg3NSA1OC41IDY0LjY4OCA3OC43NSA0Ni4xMjUgMTAxLjI1IDkyLjI1IDEwMi45MzgiLz48cG9seWdvbiBmaWxsPSIjMzkzOTM5IiBwb2ludHM9IjcwLjMxMyAxMTIuNSA2NC4xMjUgMTI1LjQzOCA4Ni4wNjMgMTE5LjgxMyIgdHJhbnNmb3JtPSJtYXRyaXgoLTEgMCAwIDEgMTUwLjE4OCAwKSIvPjxwb2x5Z29uIGZpbGw9IiNFODhGMzUiIHBvaW50cz0iMTIuMzc1IC41NjMgODguODc1IDU4LjUgNzUuOTM4IDI3Ii8+PHBhdGggZmlsbD0iIzhFNUEzMCIgZD0iTTEyLjM3NTAwMDIsMC41NjI1MDAwMDggTDIuMjUwMDAwMDMsMzEuNTAwMDAwNSBMNy44NzUwMDAxMiw2NS4yNTAwMDEgTDMuOTM3NTAwMDYsNjcuNTAwMDAxIEw5LjU2MjUwMDE0LDcyLjU2MjUgTDUuMDYyNTAwMDgsNzYuNTAwMDAxMSBMMTEuMjUsODIuMTI1MDAxMiBMNy4zMTI1MDAxMSw4NS41MDAwMDEzIEwxNi4zMTI1MDAyLDk2Ljc1MDAwMTQgTDU4LjUwMDAwMDksODMuODEyNTAxMiBDNzkuMTI1MDAxMiw2Ny4zMTI1MDA0IDg5LjI1MDAwMTMsNTguODc1MDAwMyA4OC44NzUwMDEzLDU4LjUwMDAwMDkgQzg4LjUwMDAwMTMsNTguMTI1MDAwOSA2My4wMDAwMDA5LDM4LjgxMjUwMDYgMTIuMzc1MDAwMiwwLjU2MjUwMDAwOCBaIi8+PC9nPjwvZz48L3N2Zz4=`; + this.version = 'v2.0.0'; + this.icon = WalletIconMetaData; + this.lock = new Mutex(); this.metamaskProvider = metamaskProvider; - this.provider = undefined; - this.chainId = undefined; - this.account = undefined; - this.selectedAddress = undefined; + this.snap = new MetaMaskSnap(MetaMaskSnapWallet.snapId, snapVersion, this.metamaskProvider); this.isConnected = false; - this.snap = new MetaMaskSnap(MetaMaskSnapWallet.#SNAPI_ID, snapVersion, this.metamaskProvider); + + this.#rpcHandlers = new Map([ + [RpcMethod.WalletSwitchStarknetChain, new WalletSwitchStarknetChain(this)], + [RpcMethod.WalletSupportedSpecs, new WalletSupportedSpecs()], + [RpcMethod.WalletDeploymentData, new WalletDeploymentData(this)], + [RpcMethod.WalletSupportedWalletApi, new WalletSupportedWalletApi()], + [RpcMethod.WalletRequestAccounts, new WalletRequestAccount(this)], + [RpcMethod.WalletRequestChainId, new WalletRequestChainId(this)], + [RpcMethod.WalletAddInvokeTransaction, new WalletAddInvokeTransaction(this)], + [RpcMethod.WalletWatchAsset, new WalletWatchAsset(this)], + [RpcMethod.WalletSignTypedData, new WalletSignTypedData(this)], + [RpcMethod.WalletGetPermissions, new WalletGetPermissions()], + [RpcMethod.WalletAddDeclareTransaction, new WalletAddDeclareTransaction(this)], + ]); } + /** + * Execute the Wallet RPC request. + * It will call the corresponding RPC handler based on the request type. + * + * @param call - The RPC request object. + * @returns The corresponding RPC response. + */ async request(call: Omit): Promise { - if (call.type === 'wallet_switchStarknetChain') { - const params = call.params as SwitchStarknetChainParameter; - const result = await this.snap.switchNetwork(params.chainId); - if (result) { - await this.enable(); - } - return result as unknown as Data['result']; - } + const { type, params } = call; - if (call.type === 'wallet_addStarknetChain') { - const params = call.params as AddStarknetChainParameters; - const currentNetwork = await this.#getNetwork(); - if (currentNetwork?.chainId === params.chainId) { - return true as unknown as Data['result']; - } - const result = await this.snap.addStarknetChain( - params.chainName, - params.chainId, - params.rpcUrls ? params.rpcUrls[0] : '', - params.blockExplorerUrls ? params.blockExplorerUrls[0] : '', - ); - return result as unknown as Data['result']; - } + const handler = this.#rpcHandlers.get(type); - if (call.type === 'wallet_watchAsset') { - const params = call.params as WatchAssetParameters; - const result = - (await this.snap.watchAsset( - params.options.address, - params.options.name as unknown as string, - params.options.symbol as unknown as string, - params.options.decimals as unknown as number, - )) ?? false; - return result as unknown as Data['result']; + if (handler !== undefined) { + return await handler.execute(params); } - throw new Error(`Method ${call.type} not implemented`); + throw new WalletRpcError(`Method not supported`, WalletRpcErrorCode.Unknown); } - async #getNetwork() { + async #getNetwork(): Promise { return await this.snap.getCurrentNetwork(); } @@ -110,33 +115,91 @@ export class MetaMaskSnapWallet implements IStarknetWindowObject { return accountResponse.address; } - async #getRPCProvider(network: { chainId: string; nodeUrl: string }) { - return new Provider({ - nodeUrl: network.nodeUrl, - }); + get account() { + if (!this.#account) { + if (!this.selectedAddress) { + throw new Error('Address is not set'); + } + + const signer = new MetaMaskSigner(this.snap, this.selectedAddress); + + this.#account = new MetaMaskAccount(this.snap, this.provider, this.selectedAddress, signer); + } + return this.#account; } - async #getAccountInstance(address: string, provider: ProviderInterface) { - const signer = new MetaMaskSigner(this.snap, address); + get provider(): ProviderInterface { + if (!this.#provider) { + if (!this.#network) { + throw new Error('Network is not set'); + } - return new MetaMaskAccount(this.snap, provider, address, signer, MetaMaskSnapWallet.#cairoVersion); + this.#provider = new Provider({ + nodeUrl: this.#network.nodeUrl, + }); + } + return this.#provider; } - async enable() { - await this.snap.installIfNot(); - this.isConnected = true; + get selectedAddress(): string { + return this.#selectedAddress; + } + + get chainId(): string { + return this.#chainId; + } + + /** + * Initializes the wallet by fetching the network and account information. + * and sets the network, address, account object and provider object. + * + * @param createLock - The flag to enable/disable the mutex lock. Default is true. + */ + async init(createLock = true) { + if (createLock) { + await this.lock.runExclusive(async () => { + await this.#init(); + }); + } else { + await this.#init(); + } + } + + async #init() { + // Always reject any request if the snap is not installed + if (!(await this.snap.installIfNot())) { + throw new Error('Snap is not installed'); + } + const network = await this.#getNetwork(); if (!network) { - throw new Error('Current network not found'); + throw new Error('Unable to find the selected network'); } - this.chainId = network.chainId; - this.selectedAddress = await this.#getWalletAddress(this.chainId); - if (!this.selectedAddress) { - throw new Error('Address not found'); + + if (!this.#network || network.chainId !== this.#network.chainId) { + // address is depends on network, if network changes, address will update + this.#selectedAddress = await this.#getWalletAddress(network.chainId); + // provider is depends on network.nodeUrl, if network changes, set provider to undefine for reinitialization + this.#provider = undefined; + // account is depends on address and provider, if network changes, address will update, + // hence set account to undefine for reinitialization + this.#account = undefined; } - this.provider = await this.#getRPCProvider(network); - this.account = await this.#getAccountInstance(this.selectedAddress, this.provider); + this.#network = network; + this.#chainId = network.chainId; + this.isConnected = true; + } + + /** + * Initializes the `MetaMaskSnapWallet` object and retrieves an array of addresses derived from Snap. + * Currently, the array contains only one address, but it is returned as an array to + * accommodate potential support for multiple addresses in the future. + * + * @returns An array of address. + */ + async enable() { + await this.init(); return [this.selectedAddress]; } diff --git a/yarn.lock b/yarn.lock index 8b2a3824..1b021ddb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2192,6 +2192,7 @@ __metadata: "@metamask/eslint-config-typescript": ^12.1.0 "@typescript-eslint/eslint-plugin": ^5.42.1 "@typescript-eslint/parser": ^5.42.1 + async-mutex: ^0.3.2 dotenv: ^16.4.5 eslint: ^8.45.0 eslint-config-prettier: ^8.5.0 @@ -2201,11 +2202,13 @@ __metadata: eslint-plugin-n: ^15.7.0 eslint-plugin-prettier: ^4.2.1 eslint-plugin-promise: ^6.1.1 - get-starknet-core: ^3.2.0 + get-starknet-core: ^4.0.0 + jest: ^29.5.0 prettier: ^2.7.1 rimraf: ^3.0.2 serve: 14.2.1 starknet: 6.11.0 + ts-jest: ^29.1.0 ts-loader: ^9.5.1 typescript: ^4.6.3 webpack: ^5.91.0 @@ -4797,22 +4800,6 @@ __metadata: languageName: node linkType: hard -"@module-federation/runtime@npm:^0.1.2": - version: 0.1.21 - resolution: "@module-federation/runtime@npm:0.1.21" - dependencies: - "@module-federation/sdk": 0.1.21 - checksum: ce4de8515b54f1cd07a3c7c4cbd35fea163294b9fb24be10827872f3ebb62cd5c289f3602efe4149d963282739f79b51947afa039ee6f36be7f66dea83d590fc - languageName: node - linkType: hard - -"@module-federation/sdk@npm:0.1.21": - version: 0.1.21 - resolution: "@module-federation/sdk@npm:0.1.21" - checksum: 6856dcfe2ef5ae939890b82010aaad911fa6c4330a05f290ae054c316c9b532d3691456a1f9e176fe05f1df2d6f2d8c7e0c842ca5648a0fd7abf270e44ed9ecb - languageName: node - linkType: hard - "@mrmlnc/readdir-enhanced@npm:^2.2.1": version: 2.2.1 resolution: "@mrmlnc/readdir-enhanced@npm:2.2.1" @@ -15348,21 +15335,7 @@ __metadata: languageName: node linkType: hard -"get-starknet-core@npm:^3.2.0": - version: 3.3.0 - resolution: "get-starknet-core@npm:3.3.0" - dependencies: - "@module-federation/runtime": ^0.1.2 - peerDependencies: - starknet: ^5.18.0 - peerDependenciesMeta: - starknet: - optional: false - checksum: d8dd7a905170adffa7cde712a34a99f1e8231858366ef3b3117e109aed2cb3031b16156e285daeecf8466cd4523deeb95b6b1d26245d7ab14607c45851ac9ecd - languageName: node - linkType: hard - -"get-starknet-core@npm:^4.0.0-next.3": +"get-starknet-core@npm:^4.0.0, get-starknet-core@npm:^4.0.0-next.3": version: 4.0.0 resolution: "get-starknet-core@npm:4.0.0" dependencies: From a834beb4bc0bb42f2f21b50f1cbb5a961e938b1e Mon Sep 17 00:00:00 2001 From: khanti42 Date: Wed, 23 Oct 2024 09:01:31 +0200 Subject: [PATCH 21/50] chore: fix missing access to requestParams in call to revamped rpc (#401) --- packages/starknet-snap/src/index.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/starknet-snap/src/index.ts b/packages/starknet-snap/src/index.ts index 6c71914b..a33e66c9 100644 --- a/packages/starknet-snap/src/index.ts +++ b/packages/starknet-snap/src/index.ts @@ -185,7 +185,7 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { case 'starkNet_signDeclareTransaction': return await signDeclareTransaction.execute( - apiParams as unknown as SignDeclareTransactionParams, + apiParams.requestParams as unknown as SignDeclareTransactionParams, ); case 'starkNet_signDeployAccountTransaction': @@ -196,7 +196,7 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { case 'starkNet_verifySignedMessage': return await verifySignature.execute( - apiParams as unknown as VerifySignatureParams, + apiParams.requestParams as unknown as VerifySignatureParams, ); case 'starkNet_getErc20TokenBalance': @@ -278,7 +278,7 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { case 'starkNet_declareContract': return await declareContract.execute( - apiParams as unknown as DeclareContractParams, + apiParams.requestParams as unknown as DeclareContractParams, ); case 'starkNet_getStarkName': @@ -286,7 +286,7 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { case 'starkNet_getDeploymentData': return await getDeploymentData.execute( - apiParams as unknown as GetDeploymentDataParams, + apiParams.requestParams as unknown as GetDeploymentDataParams, ); default: From bd6b66d2cba8382711adbe6a6312f295e22c64b6 Mon Sep 17 00:00:00 2001 From: khanti42 Date: Wed, 23 Oct 2024 09:40:28 +0200 Subject: [PATCH 22/50] chore(get-starknet): remove `Method not supported` exception for "`on` " and "`off`" event handlers (#402) * chore: fix missing access to requestParams in call to revamped rpc * chore: fix on off event should not throw * Update packages/get-starknet/src/wallet.test.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/get-starknet/src/wallet.test.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> --------- Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> --- packages/get-starknet/src/wallet.test.ts | 8 ++++---- packages/get-starknet/src/wallet.ts | 11 ++++++----- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/get-starknet/src/wallet.test.ts b/packages/get-starknet/src/wallet.test.ts index ad72a3de..135f4bb0 100644 --- a/packages/get-starknet/src/wallet.test.ts +++ b/packages/get-starknet/src/wallet.test.ts @@ -137,18 +137,18 @@ describe('MetaMaskSnapWallet', () => { }); describe('on', () => { - it('throws `Method not supported` error', async () => { + it('does nothing and not throw any error', async () => { const wallet = createWallet(); - expect(() => wallet.on()).toThrow('Method not supported'); + expect(() => wallet.on('accountsChanged', jest.fn())).not.toThrow(); }); }); describe('off', () => { - it('throws `Method not supported` error', async () => { + it('does nothing and not throw any error', async () => { const wallet = createWallet(); - expect(() => wallet.off()).toThrow('Method not supported'); + expect(() => wallet.off('accountsChanged', jest.fn())).not.toThrow(); }); }); }); diff --git a/packages/get-starknet/src/wallet.ts b/packages/get-starknet/src/wallet.ts index 3c313368..63d2fe60 100644 --- a/packages/get-starknet/src/wallet.ts +++ b/packages/get-starknet/src/wallet.ts @@ -1,6 +1,7 @@ import type { MutexInterface } from 'async-mutex'; import { Mutex } from 'async-mutex'; -import { type RpcMessage, type WalletEvents, type StarknetWindowObject } from 'get-starknet-core'; +import type { WalletEventHandlers } from 'get-starknet-core'; +import { type RpcMessage, type StarknetWindowObject } from 'get-starknet-core'; import type { AccountInterface, ProviderInterface } from 'starknet'; import { Provider } from 'starknet'; @@ -208,12 +209,12 @@ export class MetaMaskSnapWallet implements StarknetWindowObject { } // eslint-disable-next-line @typescript-eslint/no-unused-vars - on() { - throw new Error('Method not supported'); + on(_event: Event, _handleEvent: WalletEventHandlers[Event]): void { + // No operation for now } // eslint-disable-next-line @typescript-eslint/no-unused-vars - off() { - throw new Error('Method not supported'); + off(_event: Event, _handleEvent?: WalletEventHandlers[Event]): void { + // No operation for now } } From 774cfb1d5b979ae75a3cfa86fa9b44fa112ddf59 Mon Sep 17 00:00:00 2001 From: khanti42 Date: Wed, 23 Oct 2024 15:40:38 +0200 Subject: [PATCH 23/50] fix(get-starknet): fix undefined params handling in `formatters` utils (#403) * chore: fix missing access to requestParams in call to revamped rpc * chore: fix on off event should not throw * chore: fix formatter when undefined input * chore: handle comments * chore: fix comments * chore: fix comments * chore: fix comments * chore: fix comments * chore: remove dep from starknet-types-07 * Update packages/get-starknet/src/utils/formatter.test.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/get-starknet/src/utils/formatter.test.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/get-starknet/src/utils/formatter.test.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/get-starknet/src/utils/formatter.test.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> --------- Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> --- packages/get-starknet/src/rpcs/add-declare.ts | 4 +- packages/get-starknet/src/rpcs/add-invoke.ts | 4 +- .../get-starknet/src/utils/formatter.test.ts | 51 +++++++++++++++++-- packages/get-starknet/src/utils/formatter.ts | 40 +++++++-------- 4 files changed, 68 insertions(+), 31 deletions(-) diff --git a/packages/get-starknet/src/rpcs/add-declare.ts b/packages/get-starknet/src/rpcs/add-declare.ts index af2ce2e7..06d7fd96 100644 --- a/packages/get-starknet/src/rpcs/add-declare.ts +++ b/packages/get-starknet/src/rpcs/add-declare.ts @@ -4,11 +4,11 @@ import { formatDeclareTransaction } from '../utils/formatter'; import { StarknetWalletRpc } from '../utils/rpc'; export type WalletAddDeclareTransactionMethod = 'wallet_addDeclareTransaction'; -type Params = RpcTypeToMessageMap[WalletAddDeclareTransactionMethod]['params']; +export type AddDeclareParams = RpcTypeToMessageMap[WalletAddDeclareTransactionMethod]['params']; type Result = RpcTypeToMessageMap[WalletAddDeclareTransactionMethod]['result']; export class WalletAddDeclareTransaction extends StarknetWalletRpc { - async handleRequest(params: Params): Promise { + async handleRequest(params: AddDeclareParams): Promise { return await this.snap.declare({ senderAddress: this.wallet.selectedAddress, contractPayload: formatDeclareTransaction(params), diff --git a/packages/get-starknet/src/rpcs/add-invoke.ts b/packages/get-starknet/src/rpcs/add-invoke.ts index 585dc676..ca415203 100644 --- a/packages/get-starknet/src/rpcs/add-invoke.ts +++ b/packages/get-starknet/src/rpcs/add-invoke.ts @@ -4,11 +4,11 @@ import { formatCalls } from '../utils/formatter'; import { StarknetWalletRpc } from '../utils/rpc'; export type WalletAddInvokeTransactionMethod = 'wallet_addInvokeTransaction'; -type Params = RpcTypeToMessageMap[WalletAddInvokeTransactionMethod]['params']; +export type AddInvokeTransactionParams = RpcTypeToMessageMap[WalletAddInvokeTransactionMethod]['params']; type Result = RpcTypeToMessageMap[WalletAddInvokeTransactionMethod]['result']; export class WalletAddInvokeTransaction extends StarknetWalletRpc { - async handleRequest(params: Params): Promise { + async handleRequest(params: AddInvokeTransactionParams): Promise { const { calls } = params; return await this.snap.execute({ address: this.wallet.selectedAddress, diff --git a/packages/get-starknet/src/utils/formatter.test.ts b/packages/get-starknet/src/utils/formatter.test.ts index a63bf1ff..16829ab5 100644 --- a/packages/get-starknet/src/utils/formatter.test.ts +++ b/packages/get-starknet/src/utils/formatter.test.ts @@ -60,6 +60,12 @@ describe('formatCalls', () => { expect(result).toStrictEqual(expected); }); + + it('returns undefined if the `calls` is undefined', () => { + const result = formatCalls(undefined as unknown as any); + + expect(result).toBeUndefined(); + }); }); /* eslint-disable @typescript-eslint/naming-convention */ @@ -119,11 +125,48 @@ describe('formatDeclareTransaction', () => { expect(result).toStrictEqual(expected); }); - it('remains `class_hash` undefined if it is undefined', () => { - const params = generateDeclareTransactionParams({ classHash: undefined }); - const expected = generateExpectedDeclareTransactionPayload({ classHash: undefined }); + it('returns undefined if the `AddDeclareParams` is undefined', () => { + const result = formatDeclareTransaction(undefined as any); - const result = formatDeclareTransaction(params); + expect(result).toBeUndefined(); + }); + + it.each([ + { + fieldName: 'compiled_class_hash', + paramKey: 'compiledClassHash', + }, + { + fieldName: 'class_hash', + paramKey: 'classHash', + }, + { + fieldName: 'contract_class', + paramKey: 'contract', + }, + ])('remains undefined if the field - $fieldName is undefined', ({ fieldName, paramKey }) => { + const params = generateDeclareTransactionParams(); + + // Dynamically set the field to undefined + params[fieldName] = undefined as any; + const expected = generateExpectedDeclareTransactionPayload(); + + // Dynamically set the expected field to undefined + expected[paramKey] = undefined as any; + + const result = formatDeclareTransaction(params as unknown as any); + + expect(result).toStrictEqual(expected); + }); + + it('returns undefined if the `AddDeclareParams` is {}', () => { + const expected = { + classHash: undefined, + compiledClassHash: undefined, + contract: undefined, + }; + + const result = formatDeclareTransaction({} as unknown as any); expect(result).toStrictEqual(expected); }); diff --git a/packages/get-starknet/src/utils/formatter.ts b/packages/get-starknet/src/utils/formatter.ts index 8f0a9195..bdf15b01 100644 --- a/packages/get-starknet/src/utils/formatter.ts +++ b/packages/get-starknet/src/utils/formatter.ts @@ -1,7 +1,9 @@ /* eslint-disable @typescript-eslint/naming-convention, camelcase */ -import type { Abi, Call, DeclareContractPayload } from 'starknet'; -import type { AddDeclareTransactionParameters, Call as CallGetStarknetV4 } from 'starknet-types-07'; +import type { Call, DeclareContractPayload } from 'starknet'; +import type { AddDeclareParams, AddInvokeTransactionParams } from '../rpcs'; + +type CallGetStarknetV4 = AddInvokeTransactionParams['calls'][number]; /** * Converts an array of calls from either the `CallGetStarknetV4[]` format * or the standard `Call[]` format into the standard `Call[]` format. If the input @@ -16,6 +18,9 @@ import type { AddDeclareTransactionParameters, Call as CallGetStarknetV4 } from * @returns The array of formatted calls in the `Call[]` format. */ export const formatCalls = (calls: Call[] | CallGetStarknetV4[]): Call[] => { + if (calls === undefined || !Array.isArray(calls)) { + return undefined as unknown as Call[]; + } return calls.map((call) => { const contractAddress = 'contract_address' in call ? call.contract_address : call.contractAddress; const entrypoint = 'entry_point' in call ? call.entry_point : call.entrypoint; @@ -40,30 +45,19 @@ export const formatCalls = (calls: Call[] | CallGetStarknetV4[]): Call[] => { * @param params - The object of `AddDeclareTransactionParameters`. * @returns The object in `DeclareContractPayload` format. */ -export const formatDeclareTransaction = (params: AddDeclareTransactionParameters): DeclareContractPayload => { - const { compiled_class_hash, class_hash, contract_class } = params; +export const formatDeclareTransaction = (params: AddDeclareParams): DeclareContractPayload => { + if (params === undefined) { + return undefined as unknown as DeclareContractPayload; + } + const { + compiled_class_hash = undefined, + class_hash = undefined, + contract_class = undefined as unknown as any, + } = params; return { compiledClassHash: compiled_class_hash, classHash: class_hash, - contract: { - sierra_program: contract_class.sierra_program, - contract_class_version: contract_class.contract_class_version, - entry_points_by_type: { - CONSTRUCTOR: contract_class?.entry_points_by_type?.CONSTRUCTOR.map((ep) => ({ - selector: ep.selector, - function_idx: ep.function_idx, - })), - EXTERNAL: contract_class?.entry_points_by_type?.EXTERNAL.map((ep) => ({ - selector: ep.selector, - function_idx: ep.function_idx, - })), - L1_HANDLER: contract_class?.entry_points_by_type?.L1_HANDLER.map((ep) => ({ - selector: ep.selector, - function_idx: ep.function_idx, - })), - }, - abi: contract_class.abi as unknown as Abi, // Directly passing the string as `any` - }, + contract: contract_class, }; }; From 90f94a55d8853da25e2b1120fb2aeefb7cbac6b6 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 25 Oct 2024 16:37:35 +0800 Subject: [PATCH 24/50] chore: add utils to manage snap ui (#404) --- packages/starknet-snap/src/config.ts | 14 ++ .../starknet-snap/src/utils/explorer.test.ts | 27 ++++ packages/starknet-snap/src/utils/explorer.ts | 30 ++++ packages/starknet-snap/src/utils/index.ts | 2 + packages/starknet-snap/src/utils/snap-ui.ts | 130 ++++++++++++++++++ .../starknet-snap/src/utils/string.test.ts | 45 +++++- packages/starknet-snap/src/utils/string.ts | 42 ++++++ 7 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 packages/starknet-snap/src/utils/explorer.test.ts create mode 100644 packages/starknet-snap/src/utils/explorer.ts create mode 100644 packages/starknet-snap/src/utils/snap-ui.ts diff --git a/packages/starknet-snap/src/config.ts b/packages/starknet-snap/src/config.ts index 16e79c77..f92c0935 100644 --- a/packages/starknet-snap/src/config.ts +++ b/packages/starknet-snap/src/config.ts @@ -1,3 +1,5 @@ +import { constants } from 'starknet'; + import type { Erc20Token, Network } from './types/snapState'; import { SnapEnv, @@ -20,6 +22,9 @@ export type SnapConfig = { defaultNetwork: Network; availableNetworks: Network[]; preloadTokens: Erc20Token[]; + explorer: { + [key: string]: string; + }; }; export const Config: SnapConfig = { @@ -35,6 +40,15 @@ export const Config: SnapConfig = { STARKNET_SEPOLIA_TESTNET_NETWORK, ], + explorer: { + [constants.StarknetChainId.SN_MAIN]: + // eslint-disable-next-line no-template-curly-in-string + 'https://voyager.online/contract/${address}', + [constants.StarknetChainId.SN_SEPOLIA]: + // eslint-disable-next-line no-template-curly-in-string + 'https://sepolia.voyager.online/contract/${address}', + }, + preloadTokens: [ ETHER_MAINNET, ETHER_SEPOLIA_TESTNET, diff --git a/packages/starknet-snap/src/utils/explorer.test.ts b/packages/starknet-snap/src/utils/explorer.test.ts new file mode 100644 index 00000000..ed3ba208 --- /dev/null +++ b/packages/starknet-snap/src/utils/explorer.test.ts @@ -0,0 +1,27 @@ +import { constants } from 'starknet'; + +import { getExplorerUrl } from './explorer'; + +describe('getExplorerUrl', () => { + const address = + '0x074aaeb168bbd155d41290e6be09d80c9e937ee3d775eac19519a2fcc76fc61c'; + + it('returns a sepolia testnet explorer url', () => { + const result = getExplorerUrl( + address, + constants.StarknetChainId.SN_SEPOLIA, + ); + expect(result).toBe(`https://sepolia.voyager.online/contract/${address}`); + }); + + it('returns a mainnet explorer url', () => { + const result = getExplorerUrl(address, constants.StarknetChainId.SN_MAIN); + expect(result).toBe(`https://voyager.online/contract/${address}`); + }); + + it('throws `Invalid Chain ID` error if the given Chain ID is not support', () => { + expect(() => getExplorerUrl(address, 'some Chain ID')).toThrow( + 'Invalid Chain ID', + ); + }); +}); diff --git a/packages/starknet-snap/src/utils/explorer.ts b/packages/starknet-snap/src/utils/explorer.ts new file mode 100644 index 00000000..d3c64380 --- /dev/null +++ b/packages/starknet-snap/src/utils/explorer.ts @@ -0,0 +1,30 @@ +import { constants } from 'starknet'; + +import { Config } from '../config'; + +/** + * Gets the explorer URL for a given address and Chain ID. + * + * @param address - The address to get the explorer URL for. + * @param chainId - The Chain ID. + * @returns The explorer URL as a string. + * @throws An error if an invalid scope is provided. + */ +export function getExplorerUrl(address: string, chainId: string): string { + switch (chainId) { + case constants.StarknetChainId.SN_MAIN: + return Config.explorer[constants.StarknetChainId.SN_MAIN].replace( + // eslint-disable-next-line no-template-curly-in-string + '${address}', + address, + ); + case constants.StarknetChainId.SN_SEPOLIA: + return Config.explorer[constants.StarknetChainId.SN_SEPOLIA].replace( + // eslint-disable-next-line no-template-curly-in-string + '${address}', + address, + ); + default: + throw new Error('Invalid Chain ID'); + } +} diff --git a/packages/starknet-snap/src/utils/index.ts b/packages/starknet-snap/src/utils/index.ts index b43b60e6..d4a57fa1 100644 --- a/packages/starknet-snap/src/utils/index.ts +++ b/packages/starknet-snap/src/utils/index.ts @@ -9,4 +9,6 @@ export * from './snap-state'; export * from './url'; export * from './string'; export * from './token'; +export * from './snap-ui'; +export * from './explorer'; // TODO: add other utils diff --git a/packages/starknet-snap/src/utils/snap-ui.ts b/packages/starknet-snap/src/utils/snap-ui.ts new file mode 100644 index 00000000..0aaafacb --- /dev/null +++ b/packages/starknet-snap/src/utils/snap-ui.ts @@ -0,0 +1,130 @@ +import { divider, heading, row, text } from '@metamask/snaps-sdk'; + +import { getExplorerUrl } from './explorer'; +import { toJson } from './serializer'; +import { shortenAddress } from './string'; + +/** + * Build a row component. + * + * @param params - The parameters. + * @param params.label - The label of the row component. + * @param params.value - The value of the row component. + * @returns A row component. + */ +export function rowUI({ label, value }: { label: string; value: string }) { + return row( + label, + text({ + value, + markdown: false, + }), + ); +} + +/** + * Build a row component with the address. + * + * @param params - The parameters. + * @param params.label - The label. + * @param params.address - The address. + * @param [params.chainId] - The chain ID, when the chain ID is set, a exploder URL markdown will be generated. + * @param [params.shortern] - Whether to shorten the address. Default is true. + * @returns A row component with the address. + */ +export function addressUI({ + label, + address, + chainId, + shortern = true, +}: { + label: string; + address: string; + chainId?: string; + shortern?: boolean; +}) { + let value = address; + + if (shortern) { + value = shortenAddress(address); + } + + if (chainId) { + value = `[${value}](${getExplorerUrl(address, chainId)})`; + } + return rowUI({ + label, + value, + }); +} + +/** + * Build a row component with the network name. + * + * @param params - The parameters. + * @param params.networkName - The network name. + * @returns A row component with the network name. + */ +export function networkUI({ networkName }: { networkName: string }) { + return rowUI({ + label: 'Network', + value: networkName, + }); +} + +/** + * Build a heading component. + * + * @param value - The header. + * @returns A heading component. + */ +export function headerUI(value: string) { + return heading(value); +} + +/** + * Build a divider component + * + * @returns A divider component. + */ +export function dividerUI() { + return divider(); +} + +/** + * Build a row component with the signer address. + * + * @param params - The parameters. + * @param params.address - The signer address. + * @param params.chainId - The chain ID. + * @returns A row component with the signer address. + */ +export function signerUI({ + address, + chainId, +}: { + address: string; + chainId: string; +}) { + return addressUI({ + label: 'Signer Address', + address, + chainId, + shortern: true, + }); +} + +/** + * Build a row component with the JSON data. + * + * @param params - The parameters. + * @param params.data - The JSON data. + * @param params.label - The label. + * @returns A row component with the JSON data. + */ +export function jsonDataUI({ data, label }: { data: any; label: string }) { + return rowUI({ + label, + value: toJson(data), + }); +} diff --git a/packages/starknet-snap/src/utils/string.test.ts b/packages/starknet-snap/src/utils/string.test.ts index 212e6335..6c639fd5 100644 --- a/packages/starknet-snap/src/utils/string.test.ts +++ b/packages/starknet-snap/src/utils/string.test.ts @@ -1,4 +1,9 @@ -import { isAsciiString, isValidAsciiStrField } from './string'; +import { + isAsciiString, + isValidAsciiStrField, + replaceMiddleChar, + shortenAddress, +} from './string'; describe('isAsciiString', () => { it('returns true for a ASCII string', () => { @@ -33,3 +38,41 @@ describe('isValidAsciiStrField', () => { }, ); }); + +describe('replaceMiddleChar', () => { + const str = + '0x074aaeb168bbd155d41290e6be09d80c9e937ee3d775eac19519a2fcc76fc61c'; + it('replaces the middle of a string', () => { + expect(replaceMiddleChar(str, 5, 3)).toBe('0x074...61c'); + }); + + it('does not replace if the string is empty', () => { + expect(replaceMiddleChar('', 5, 3)).toBe(''); + }); + + it('throws `Indexes must be positives` error if headLength or tailLength is negative value', () => { + expect(() => replaceMiddleChar(str, -1, 20)).toThrow( + 'Indexes must be positives', + ); + expect(() => replaceMiddleChar(str, 20, -10)).toThrow( + 'Indexes must be positives', + ); + }); + + it('throws `Indexes out of bounds` error if headLength + tailLength is out of bounds', () => { + expect(() => replaceMiddleChar(str, 100, 0)).toThrow( + 'Indexes out of bounds', + ); + expect(() => replaceMiddleChar(str, 0, 100)).toThrow( + 'Indexes out of bounds', + ); + }); +}); + +describe('shortenAddress', () => { + const str = + '0x074aaeb168bbd155d41290e6be09d80c9e937ee3d775eac19519a2fcc76fc61c'; + it('shorten an address', () => { + expect(shortenAddress(str)).toBe('0x074...c61c'); + }); +}); diff --git a/packages/starknet-snap/src/utils/string.ts b/packages/starknet-snap/src/utils/string.ts index 50d64a8a..4b6c8310 100644 --- a/packages/starknet-snap/src/utils/string.ts +++ b/packages/starknet-snap/src/utils/string.ts @@ -18,3 +18,45 @@ export function isValidAsciiStrField(value: string, maxLength: number) { isAsciiString(value) && value.trim().length > 0 && value.length <= maxLength ); } + +/** + * Replaces the middle characters of a string with a given string. + * + * @param str - The string to replace. + * @param headLength - The length of the head of the string that should not be replaced. + * @param tailLength - The length of the tail of the string that should not be replaced. + * @param replaceStr - The string to replace the middle characters with. Default is '...'. + * @returns The formatted string. + * @throws An error if the given headLength and tailLength cannot be replaced. + */ +export function replaceMiddleChar( + str: string, + headLength: number, + tailLength: number, + replaceStr = '...', +) { + if (!str) { + return str; + } + // Enforces indexes to be positive to avoid parameter swapping in `.substring` + if (headLength < 0 || tailLength < 0) { + throw new Error('Indexes must be positives'); + } + // Check upper bound (using + is safe here, since we know that both lengths are positives) + if (headLength + tailLength > str.length) { + throw new Error('Indexes out of bounds'); + } + return `${str.substring(0, headLength)}${replaceStr}${str.substring( + str.length - tailLength, + )}`; +} + +/** + * Format the address in shorten string. + * + * @param address - The address to format. + * @returns The formatted address. + */ +export function shortenAddress(address: string) { + return replaceMiddleChar(address, 5, 4); +} From a77fedebcc3674549b117eb865d500c6d5209c7f Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 25 Oct 2024 17:06:14 +0800 Subject: [PATCH 25/50] refactor(SNAP): apply the centralise snap ui component into the RPCs (#405) * chore: add utils to manage snap ui * chore: adopt centralise snap ui component into the rpcs * chore: remove contract and casm from declare * fix: execute txn dialog --------- Co-authored-by: khanti42 --- .../src/rpcs/__tests__/helper.ts | 24 ++++ .../src/rpcs/declare-contract.test.ts | 10 +- .../src/rpcs/declare-contract.ts | 107 +++++----------- .../starknet-snap/src/rpcs/execute-txn.ts | 116 ++++++++---------- .../src/rpcs/sign-declare-transaction.test.ts | 37 ++---- .../src/rpcs/sign-declare-transaction.ts | 42 +++---- .../src/rpcs/sign-message.test.ts | 26 +--- .../starknet-snap/src/rpcs/sign-message.ts | 29 ++--- .../src/rpcs/sign-transaction.test.ts | 35 ++---- .../src/rpcs/sign-transaction.ts | 40 +++--- .../src/rpcs/switch-network.test.ts | 31 ++--- .../starknet-snap/src/rpcs/switch-network.ts | 30 ++--- .../src/rpcs/watch-asset.test.ts | 10 +- .../starknet-snap/src/rpcs/watch-asset.ts | 57 ++++++--- 14 files changed, 250 insertions(+), 344 deletions(-) diff --git a/packages/starknet-snap/src/rpcs/__tests__/helper.ts b/packages/starknet-snap/src/rpcs/__tests__/helper.ts index 70c08cd8..40b9b6ee 100644 --- a/packages/starknet-snap/src/rpcs/__tests__/helper.ts +++ b/packages/starknet-snap/src/rpcs/__tests__/helper.ts @@ -4,6 +4,7 @@ import type { constants } from 'starknet'; import type { StarknetAccount } from '../../__tests__/helper'; import { generateAccounts, generateRandomValue } from '../../__tests__/helper'; import type { SnapState } from '../../types/snapState'; +import { getExplorerUrl, shortenAddress, toJson } from '../../utils'; import * as snapHelper from '../../utils/snap'; import * as snapUtils from '../../utils/snapUtils'; import * as starknetUtils from '../../utils/starknetUtils'; @@ -84,6 +85,29 @@ export const buildDividerComponent = () => { }; }; +export const buildAddressComponent = ( + label: string, + address: string, + chainId: string, +) => { + return buildRowComponent( + label, + `[${shortenAddress(address)}](${getExplorerUrl(address, chainId)})`, + ); +}; + +export const buildSignerComponent = (value: string, chainId: string) => { + return buildAddressComponent('Signer Address', value, chainId); +}; + +export const buildNetworkComponent = (chainName: string) => { + return buildRowComponent('Network', chainName); +}; + +export const buildJsonDataComponent = (label: string, data: any) => { + return buildRowComponent(label, toJson(data)); +}; + /** * * @param min diff --git a/packages/starknet-snap/src/rpcs/declare-contract.test.ts b/packages/starknet-snap/src/rpcs/declare-contract.test.ts index 4f916a9b..d745397c 100644 --- a/packages/starknet-snap/src/rpcs/declare-contract.test.ts +++ b/packages/starknet-snap/src/rpcs/declare-contract.test.ts @@ -3,7 +3,7 @@ import type { Abi, UniversalDetails } from 'starknet'; import { constants } from 'starknet'; import type { Infer } from 'superstruct'; -import { toJson, type DeclareContractPayloadStruct } from '../utils'; +import { type DeclareContractPayloadStruct } from '../utils'; import { STARKNET_SEPOLIA_TESTNET_NETWORK } from '../utils/constants'; import { UserRejectedOpError, @@ -13,7 +13,9 @@ import { import * as starknetUtils from '../utils/starknetUtils'; import { buildDividerComponent, + buildNetworkComponent, buildRowComponent, + buildSignerComponent, generateRandomFee, mockAccount, prepareConfirmDialog, @@ -213,11 +215,9 @@ describe('DeclareContractRpc', () => { type: 'heading', value: 'Do you want to sign this transaction?', }, - buildRowComponent('Signer Address', account.address), + buildSignerComponent(account.address, network.chainId), buildDividerComponent(), - buildRowComponent('Network', network.name), - buildDividerComponent(), - buildRowComponent('Contract', toJson(payload.contract)), + buildNetworkComponent(network.name), buildDividerComponent(), buildRowComponent('Compiled Class Hash', payload.compiledClassHash ?? ''), buildDividerComponent(), diff --git a/packages/starknet-snap/src/rpcs/declare-contract.ts b/packages/starknet-snap/src/rpcs/declare-contract.ts index 533549cb..03d06bba 100644 --- a/packages/starknet-snap/src/rpcs/declare-contract.ts +++ b/packages/starknet-snap/src/rpcs/declare-contract.ts @@ -1,5 +1,4 @@ import type { Component } from '@metamask/snaps-sdk'; -import { heading, divider, row, text } from '@metamask/snaps-sdk'; import convert from 'ethereum-unit-converter'; import type { Infer } from 'superstruct'; import { assign, object, optional, string } from 'superstruct'; @@ -12,7 +11,11 @@ import { UniversalDetailsStruct, confirmDialog, AccountRpcController, - toJson, + signerUI, + networkUI, + rowUI, + dividerUI, + headerUI, } from '../utils'; import { UserRejectedOpError } from '../utils/exceptions'; import { declareContract as declareContractUtil } from '../utils/starknetUtils'; @@ -103,98 +106,52 @@ export class DeclareContractRpc extends AccountRpcController< protected async getDeclareContractConsensus(params: DeclareContractParams) { const { payload, details, address } = params; const components: Component[] = []; - components.push(heading('Do you want to sign this transaction?')); + components.push(headerUI('Do you want to sign this transaction?')); components.push( - row( - 'Signer Address', - text({ - value: address, - markdown: false, - }), - ), + signerUI({ + address, + chainId: this.network.chainId, + }), ); - components.push(divider()); - + components.push(dividerUI()); components.push( - row( - 'Network', - text({ - value: this.network.name, - markdown: false, - }), - ), + networkUI({ + networkName: this.network.name, + }), ); - if (payload.contract) { - components.push(divider()); - const contractDetails = - typeof payload.contract === 'string' - ? payload.contract - : toJson(payload.contract); - components.push( - row( - 'Contract', - text({ - value: contractDetails, - markdown: false, - }), - ), - ); - } + const { compiledClassHash, classHash } = payload; - if (payload.compiledClassHash) { - components.push(divider()); + if (compiledClassHash) { + components.push(dividerUI()); components.push( - row( - 'Compiled Class Hash', - text({ - value: payload.compiledClassHash, - markdown: false, - }), - ), - ); - } - - if (payload.classHash) { - components.push(divider()); - components.push( - row( - 'Class Hash', - text({ - value: payload.classHash, - markdown: false, - }), - ), + rowUI({ + label: 'Compiled Class Hash', + value: compiledClassHash, + }), ); } - if (payload.casm) { - const casmDetails = toJson(payload.casm); - components.push(divider()); + if (classHash) { + components.push(dividerUI()); components.push( - row( - 'Casm', - text({ - value: casmDetails, - markdown: false, - }), - ), + rowUI({ + label: 'Class Hash', + value: classHash, + }), ); } if (details?.maxFee) { const maxFeeInEth = convert(details.maxFee, 'wei', 'ether'); - components.push(divider()); + components.push(dividerUI()); components.push( - row( - 'Max Fee (ETH)', - text({ - value: maxFeeInEth, - markdown: false, - }), - ), + rowUI({ + label: 'Max Fee (ETH)', + value: maxFeeInEth, + }), ); } diff --git a/packages/starknet-snap/src/rpcs/execute-txn.ts b/packages/starknet-snap/src/rpcs/execute-txn.ts index 25dbc2ee..9e6d6108 100644 --- a/packages/starknet-snap/src/rpcs/execute-txn.ts +++ b/packages/starknet-snap/src/rpcs/execute-txn.ts @@ -1,5 +1,4 @@ import type { Component, Json } from '@metamask/snaps-sdk'; -import { heading, row, text, divider } from '@metamask/snaps-sdk'; import convert from 'ethereum-unit-converter'; import type { Call, Calldata } from 'starknet'; import { constants, TransactionStatus, TransactionType } from 'starknet'; @@ -20,6 +19,13 @@ import { UniversalDetailsStruct, CallsStruct, mapDeprecatedParams, + addressUI, + signerUI, + networkUI, + jsonDataUI, + dividerUI, + headerUI, + rowUI, } from '../utils'; import { UserRejectedOpError } from '../utils/exceptions'; import { logger } from '../utils/logger'; @@ -214,6 +220,7 @@ export class ExecuteTxnRpc extends AccountRpcController< maxFee: string, version?: constants.TRANSACTION_VERSION, ) { + const { name: chainName, chainId } = this.network; const callsArray = Array.isArray(calls) ? calls : [calls]; const components: Component[] = []; @@ -222,72 +229,59 @@ export class ExecuteTxnRpc extends AccountRpcController< ? FeeToken.STRK : FeeToken.ETH; + components.push(headerUI('Do you want to sign this transaction?')); components.push( - row( - 'Signer Address', - text({ - value: address, - markdown: false, - }), - ), + signerUI({ + address, + chainId, + }), ); // Display a message to indicate the signed transaction will include an account deployment if (!accountDeployed) { - components.push(heading(`The account will be deployed`)); - components.push(divider()); + components.push(headerUI(`The account will be deployed`)); } + components.push(dividerUI()); components.push( - row( - `Estimated Gas Fee (${feeToken})`, - text({ - value: convert(maxFee, 'wei', 'ether'), - markdown: false, - }), - ), + rowUI({ + label: `Estimated Gas Fee (${feeToken})`, + value: convert(maxFee, 'wei', 'ether'), + }), ); + components.push(dividerUI()); components.push( - row( - 'Network', - text({ - value: this.network.name, - markdown: false, - }), - ), + networkUI({ + networkName: chainName, + }), ); - components.push(divider()); // Iterate over each call in the calls array for (const call of callsArray) { const { contractAddress, calldata, entrypoint } = call; - + components.push(dividerUI()); components.push( - row( - 'Contract', - text({ - value: contractAddress, - markdown: false, - }), - ), + addressUI({ + label: 'Contract', + address: contractAddress, + chainId, + }), ); components.push( - row( - 'Call Data', - text({ - value: JSON.stringify(calldata, null, 2), - markdown: false, - }), - ), + jsonDataUI({ + label: 'Call Data', + data: calldata, + }), ); // If the contract is an ERC20 token and the function is 'transfer', display sender, recipient, and amount const token = await this.tokenStateManager.getToken({ address: contractAddress, - chainId: this.network.chainId, + chainId, }); + if (token && entrypoint === 'transfer' && calldata) { try { const senderAddress = address; @@ -301,29 +295,24 @@ export class ExecuteTxnRpc extends AccountRpcController< Number(calldata[1]) * Math.pow(10, -1 * token.decimals) ).toFixed(token.decimals); } - + components.push(dividerUI()); components.push( - row( - 'Sender Address', - text({ - value: senderAddress, - markdown: false, - }), - ), - row( - 'Recipient Address', - text({ - value: recipientAddress, - markdown: false, - }), - ), - row( - `Amount (${token.symbol})`, - text({ - value: amount, - markdown: false, - }), - ), + addressUI({ + label: 'Sender Address', + address: senderAddress, + chainId, + }), + dividerUI(), + addressUI({ + label: 'Recipient Address', + address: recipientAddress, + chainId, + }), + dividerUI(), + rowUI({ + label: `Amount (${token.symbol})`, + value: amount, + }), ); } catch (error) { logger.warn( @@ -332,7 +321,6 @@ export class ExecuteTxnRpc extends AccountRpcController< ); } } - components.push(divider()); } return await confirmDialog(components); diff --git a/packages/starknet-snap/src/rpcs/sign-declare-transaction.test.ts b/packages/starknet-snap/src/rpcs/sign-declare-transaction.test.ts index 73defc9b..a36f639c 100644 --- a/packages/starknet-snap/src/rpcs/sign-declare-transaction.test.ts +++ b/packages/starknet-snap/src/rpcs/sign-declare-transaction.test.ts @@ -2,7 +2,6 @@ import type { DeclareSignerDetails } from 'starknet'; import { constants } from 'starknet'; import type { SnapState } from '../types/snapState'; -import { toJson } from '../utils'; import { STARKNET_SEPOLIA_TESTNET_NETWORK } from '../utils/constants'; import { UserRejectedOpError, @@ -13,6 +12,9 @@ import { mockAccount, prepareMockAccount, prepareConfirmDialog, + buildSignerComponent, + buildNetworkComponent, + buildJsonDataComponent, } from './__tests__/helper'; import { signDeclareTransaction } from './sign-declare-transaction'; import type { SignDeclareTransactionParams } from './sign-declare-transaction'; @@ -67,44 +69,21 @@ describe('signDeclareTransaction', () => { it('renders confirmation dialog', async () => { const chainId = constants.StarknetChainId.SN_SEPOLIA; const account = await mockAccount(chainId); + const { address } = account; prepareMockAccount(account, state); const { confirmDialogSpy } = prepareConfirmDialog(); - const request = createRequest(chainId, account.address); + const request = createRequest(chainId, address); await signDeclareTransaction.execute(request); const calls = confirmDialogSpy.mock.calls[0][0]; expect(calls).toStrictEqual([ { type: 'heading', value: 'Do you want to sign this transaction?' }, - { - type: 'row', - label: 'Network', - value: { - value: STARKNET_SEPOLIA_TESTNET_NETWORK.name, - markdown: false, - type: 'text', - }, - }, - { - type: 'row', - label: 'Signer Address', - value: { - value: account.address, - markdown: false, - type: 'text', - }, - }, - { - type: 'row', - label: 'Declare Transaction Details', - value: { - value: toJson(request.details), - markdown: false, - type: 'text', - }, - }, + buildSignerComponent(address, chainId), + buildNetworkComponent(STARKNET_SEPOLIA_TESTNET_NETWORK.name), + buildJsonDataComponent('Declare Transaction Details', request.details), ]); }); diff --git a/packages/starknet-snap/src/rpcs/sign-declare-transaction.ts b/packages/starknet-snap/src/rpcs/sign-declare-transaction.ts index 9a9d92e1..775dcd09 100644 --- a/packages/starknet-snap/src/rpcs/sign-declare-transaction.ts +++ b/packages/starknet-snap/src/rpcs/sign-declare-transaction.ts @@ -1,5 +1,4 @@ import type { Component } from '@metamask/snaps-sdk'; -import { heading, row, text } from '@metamask/snaps-sdk'; import type { DeclareSignerDetails } from 'starknet'; import type { Infer } from 'superstruct'; import { array, object, string, assign } from 'superstruct'; @@ -7,11 +6,14 @@ import { array, object, string, assign } from 'superstruct'; import { confirmDialog, AddressStruct, - toJson, BaseRequestStruct, AccountRpcController, DeclareSignDetailsStruct, mapDeprecatedParams, + signerUI, + networkUI, + jsonDataUI, + headerUI, } from '../utils'; import { UserRejectedOpError } from '../utils/exceptions'; import { signDeclareTransaction as signDeclareTransactionUtil } from '../utils/starknetUtils'; @@ -96,34 +98,26 @@ export class SignDeclareTransactionRpc extends AccountRpcController< details: Infer, ) { const components: Component[] = []; - components.push(heading('Do you want to sign this transaction?')); + components.push(headerUI('Do you want to sign this transaction?')); + components.push( - row( - 'Network', - text({ - value: this.network.name, - markdown: false, - }), - ), + signerUI({ + address: details.senderAddress, + chainId: this.network.chainId, + }), ); + components.push( - row( - 'Signer Address', - text({ - value: details.senderAddress, - markdown: false, - }), - ), + networkUI({ + networkName: this.network.name, + }), ); components.push( - row( - 'Declare Transaction Details', - text({ - value: toJson(details), - markdown: false, - }), - ), + jsonDataUI({ + label: 'Declare Transaction Details', + data: details, + }), ); return await confirmDialog(components); diff --git a/packages/starknet-snap/src/rpcs/sign-message.test.ts b/packages/starknet-snap/src/rpcs/sign-message.test.ts index 1e219ffe..9b34d629 100644 --- a/packages/starknet-snap/src/rpcs/sign-message.test.ts +++ b/packages/starknet-snap/src/rpcs/sign-message.test.ts @@ -2,7 +2,6 @@ import { constants } from 'starknet'; import typedDataExample from '../__tests__/fixture/typedDataExample.json'; import type { SnapState } from '../types/snapState'; -import { toJson } from '../utils'; import { STARKNET_SEPOLIA_TESTNET_NETWORK } from '../utils/constants'; import { UserRejectedOpError, @@ -13,6 +12,8 @@ import { mockAccount, prepareMockAccount, prepareConfirmDialog, + buildSignerComponent, + buildJsonDataComponent, } from './__tests__/helper'; import { signMessage } from './sign-message'; import type { SignMessageParams } from './sign-message'; @@ -52,13 +53,14 @@ describe('signMessage', () => { it('renders confirmation dialog', async () => { const account = await mockAccount(constants.StarknetChainId.SN_SEPOLIA); + const { address, chainId } = account; prepareMockAccount(account, state); const { confirmDialogSpy } = prepareConfirmDialog(); const request = { chainId: constants.StarknetChainId.SN_SEPOLIA, - address: account.address, + address, typedDataMessage: typedDataExample, enableAuthorize: true, }; @@ -68,24 +70,8 @@ describe('signMessage', () => { const calls = confirmDialogSpy.mock.calls[0][0]; expect(calls).toStrictEqual([ { type: 'heading', value: 'Do you want to sign this message?' }, - { - type: 'row', - label: 'Message', - value: { - value: toJson(typedDataExample), - markdown: false, - type: 'text', - }, - }, - { - type: 'row', - label: 'Signer Address', - value: { - value: account.address, - markdown: false, - type: 'text', - }, - }, + buildSignerComponent(address, chainId), + buildJsonDataComponent('Message', typedDataExample), ]); }); diff --git a/packages/starknet-snap/src/rpcs/sign-message.ts b/packages/starknet-snap/src/rpcs/sign-message.ts index 79d8d692..80112ca3 100644 --- a/packages/starknet-snap/src/rpcs/sign-message.ts +++ b/packages/starknet-snap/src/rpcs/sign-message.ts @@ -1,17 +1,18 @@ import type { Component } from '@metamask/snaps-sdk'; -import { heading, row, text } from '@metamask/snaps-sdk'; import type { Infer } from 'superstruct'; import { array, object, string, assign } from 'superstruct'; import { confirmDialog, AddressStruct, - toJson, TypeDataStruct, AuthorizableStruct, BaseRequestStruct, AccountRpcController, mapDeprecatedParams, + signerUI, + jsonDataUI, + headerUI, } from '../utils'; import { UserRejectedOpError } from '../utils/exceptions'; import { signMessage as signMessageUtil } from '../utils/starknetUtils'; @@ -95,24 +96,18 @@ export class SignMessageRpc extends AccountRpcController< address: string, ) { const components: Component[] = []; - components.push(heading('Do you want to sign this message?')); + components.push(headerUI('Do you want to sign this message?')); components.push( - row( - 'Message', - text({ - value: toJson(typedDataMessage), - markdown: false, - }), - ), + signerUI({ + address, + chainId: this.network.chainId, + }), ); components.push( - row( - 'Signer Address', - text({ - value: address, - markdown: false, - }), - ), + jsonDataUI({ + label: 'Message', + data: typedDataMessage, + }), ); return await confirmDialog(components); diff --git a/packages/starknet-snap/src/rpcs/sign-transaction.test.ts b/packages/starknet-snap/src/rpcs/sign-transaction.test.ts index 0d4026a2..5200545a 100644 --- a/packages/starknet-snap/src/rpcs/sign-transaction.test.ts +++ b/packages/starknet-snap/src/rpcs/sign-transaction.test.ts @@ -3,7 +3,6 @@ import { constants } from 'starknet'; import transactionExample from '../__tests__/fixture/transactionExample.json'; // Assuming you have a similar fixture import type { SnapState } from '../types/snapState'; -import { toJson } from '../utils'; import { STARKNET_SEPOLIA_TESTNET_NETWORK } from '../utils/constants'; import { UserRejectedOpError, @@ -14,6 +13,9 @@ import { mockAccount, prepareMockAccount, prepareConfirmDialog, + buildNetworkComponent, + buildSignerComponent, + buildJsonDataComponent, } from './__tests__/helper'; import { signTransaction } from './sign-transaction'; import type { SignTransactionParams } from './sign-transaction'; @@ -67,6 +69,7 @@ describe('signTransaction', () => { it('renders confirmation dialog', async () => { const chainId = constants.StarknetChainId.SN_SEPOLIA; const account = await mockAccount(chainId); + const { address } = account; prepareMockAccount(account, state); const { confirmDialogSpy } = prepareConfirmDialog(); const request = createRequestParam(chainId, account.address, true); @@ -76,33 +79,9 @@ describe('signTransaction', () => { const calls = confirmDialogSpy.mock.calls[0][0]; expect(calls).toStrictEqual([ { type: 'heading', value: 'Do you want to sign this transaction?' }, - { - type: 'row', - label: 'Network', - value: { - value: STARKNET_SEPOLIA_TESTNET_NETWORK.name, - markdown: false, - type: 'text', - }, - }, - { - type: 'row', - label: 'Signer Address', - value: { - value: account.address, - markdown: false, - type: 'text', - }, - }, - { - type: 'row', - label: 'Transactions', - value: { - value: toJson(transactionExample.transactions), - markdown: false, - type: 'text', - }, - }, + buildSignerComponent(address, chainId), + buildNetworkComponent(STARKNET_SEPOLIA_TESTNET_NETWORK.name), + buildJsonDataComponent('Transaction', transactionExample.transactions), ]); }); diff --git a/packages/starknet-snap/src/rpcs/sign-transaction.ts b/packages/starknet-snap/src/rpcs/sign-transaction.ts index 592ec8e9..ccb61009 100644 --- a/packages/starknet-snap/src/rpcs/sign-transaction.ts +++ b/packages/starknet-snap/src/rpcs/sign-transaction.ts @@ -1,5 +1,4 @@ import type { DialogResult } from '@metamask/snaps-sdk'; -import { heading, row, text } from '@metamask/snaps-sdk'; import type { Call, InvocationsSignerDetails } from 'starknet'; import type { Infer } from 'superstruct'; import { array, object, string, assign, any } from 'superstruct'; @@ -11,8 +10,11 @@ import { BaseRequestStruct, AccountRpcController, CallDataStruct, - toJson, mapDeprecatedParams, + jsonDataUI, + signerUI, + networkUI, + headerUI, } from '../utils'; import { UserRejectedOpError } from '../utils/exceptions'; import { signTransactions } from '../utils/starknetUtils'; @@ -104,28 +106,18 @@ export class SignTransactionRpc extends AccountRpcController< transactions: Call[], ): Promise { return await confirmDialog([ - heading('Do you want to sign this transaction?'), - row( - 'Network', - text({ - value: this.network.name, - markdown: false, - }), - ), - row( - 'Signer Address', - text({ - value: address, - markdown: false, - }), - ), - row( - 'Transactions', - text({ - value: toJson(transactions), - markdown: false, - }), - ), + headerUI('Do you want to sign this transaction?'), + signerUI({ + address, + chainId: this.network.chainId, + }), + networkUI({ + networkName: this.network.name, + }), + jsonDataUI({ + label: 'Transaction', + data: transactions, + }), ]); } } diff --git a/packages/starknet-snap/src/rpcs/switch-network.test.ts b/packages/starknet-snap/src/rpcs/switch-network.test.ts index 5058a6c0..d01d6d36 100644 --- a/packages/starknet-snap/src/rpcs/switch-network.test.ts +++ b/packages/starknet-snap/src/rpcs/switch-network.test.ts @@ -12,7 +12,12 @@ import { InvalidRequestParamsError, UserRejectedOpError, } from '../utils/exceptions'; -import { prepareConfirmDialog } from './__tests__/helper'; +import { + buildDividerComponent, + buildNetworkComponent, + buildRowComponent, + prepareConfirmDialog, +} from './__tests__/helper'; import { switchNetwork } from './switch-network'; import type { SwitchNetworkParams } from './switch-network'; @@ -123,27 +128,9 @@ describe('switchNetwork', () => { expect(confirmDialogSpy).toHaveBeenCalledWith([ { type: 'heading', value: 'Do you want to switch to this network?' }, - { - type: 'row', - label: 'Chain Name', - value: { - value: requestNetwork.name, - markdown: false, - type: 'text', - }, - }, - { - type: 'divider', - }, - { - type: 'row', - label: 'Chain ID', - value: { - value: requestNetwork.chainId, - markdown: false, - type: 'text', - }, - }, + buildNetworkComponent(requestNetwork.name), + buildDividerComponent(), + buildRowComponent('Chain ID', requestNetwork.chainId), ]); }); diff --git a/packages/starknet-snap/src/rpcs/switch-network.ts b/packages/starknet-snap/src/rpcs/switch-network.ts index 61bc0fbf..12ac65b8 100644 --- a/packages/starknet-snap/src/rpcs/switch-network.ts +++ b/packages/starknet-snap/src/rpcs/switch-network.ts @@ -1,5 +1,4 @@ import type { Component } from '@metamask/snaps-sdk'; -import { divider, heading, row, text } from '@metamask/snaps-sdk'; import type { Infer } from 'superstruct'; import { assign, boolean } from 'superstruct'; @@ -9,6 +8,10 @@ import { AuthorizableStruct, BaseRequestStruct, RpcController, + networkUI, + rowUI, + dividerUI, + headerUI, } from '../utils'; import { InvalidNetworkError, UserRejectedOpError } from '../utils/exceptions'; @@ -98,25 +101,18 @@ export class SwitchNetworkRpc extends RpcController< networkChainId: string, ) { const components: Component[] = []; - components.push(heading('Do you want to switch to this network?')); + components.push(headerUI('Do you want to switch to this network?')); components.push( - row( - 'Chain Name', - text({ - value: networkName, - markdown: false, - }), - ), + networkUI({ + networkName, + }), ); - components.push(divider()); + components.push(dividerUI()); components.push( - row( - 'Chain ID', - text({ - value: networkChainId, - markdown: false, - }), - ), + rowUI({ + label: 'Chain ID', + value: networkChainId, + }), ); return await confirmDialog(components); diff --git a/packages/starknet-snap/src/rpcs/watch-asset.test.ts b/packages/starknet-snap/src/rpcs/watch-asset.test.ts index 2569ca7e..c38bce01 100644 --- a/packages/starknet-snap/src/rpcs/watch-asset.test.ts +++ b/packages/starknet-snap/src/rpcs/watch-asset.test.ts @@ -12,7 +12,9 @@ import { UserRejectedOpError, } from '../utils/exceptions'; import { + buildAddressComponent, buildDividerComponent, + buildNetworkComponent, buildRowComponent, prepareConfirmDialog, } from './__tests__/helper'; @@ -109,9 +111,13 @@ describe('WatchAssetRpc', () => { expect(confirmDialogSpy).toHaveBeenCalledWith([ { type: 'heading', value: 'Do you want to add this token?' }, - buildRowComponent('Network', network.name), + buildNetworkComponent(network.name), buildDividerComponent(), - buildRowComponent('Token Address', request.tokenAddress), + buildAddressComponent( + 'Token Address', + request.tokenAddress, + network.chainId, + ), buildDividerComponent(), buildRowComponent('Token Name', request.tokenName), buildDividerComponent(), diff --git a/packages/starknet-snap/src/rpcs/watch-asset.ts b/packages/starknet-snap/src/rpcs/watch-asset.ts index f011efcb..39b364fc 100644 --- a/packages/starknet-snap/src/rpcs/watch-asset.ts +++ b/packages/starknet-snap/src/rpcs/watch-asset.ts @@ -1,5 +1,4 @@ import type { Component } from '@metamask/snaps-sdk'; -import { divider, heading, row, text } from '@metamask/snaps-sdk'; import type { Infer } from 'superstruct'; import { assign, boolean, min, number, object, optional } from 'superstruct'; @@ -14,6 +13,11 @@ import { TokenNameStruct, TokenSymbolStruct, isPreloadedToken, + addressUI, + networkUI, + rowUI, + dividerUI, + headerUI, } from '../utils'; import { DEFAULT_DECIMAL_PLACES } from '../utils/constants'; import { @@ -131,7 +135,13 @@ export class WatchAssetRpc extends RpcController< const erc20Token: Erc20Token = this.buildErc20Token(params); const network = await this.getNetworkFromChainId(chainId); - if (!(await this.getWatchAssetConsensus(network.name, erc20Token))) { + if ( + !(await this.getWatchAssetConsensus( + network.name, + network.chainId, + erc20Token, + )) + ) { throw new UserRejectedOpError() as unknown as Error; } @@ -142,18 +152,27 @@ export class WatchAssetRpc extends RpcController< protected async getWatchAssetConsensus( networkName: string, + chainId: string, erc20Token: Erc20Token, ) { const { address, name, symbol, decimals } = erc20Token; - const componentPairs = [ + const componentPairs: { + label?: string; + value?: string; + component?: Component; + }[] = [ { - label: 'Network', - value: networkName, + component: networkUI({ + networkName, + }), }, { - label: 'Token Address', - value: address, + component: addressUI({ + label: 'Token Address', + address, + chainId, + }), }, { label: 'Token Name', @@ -168,20 +187,24 @@ export class WatchAssetRpc extends RpcController< value: decimals.toString(), }, ]; + const components: Component[] = []; - components.push(heading('Do you want to add this token?')); - componentPairs.forEach(({ label, value }, idx) => { - components.push( - row( - label, - text({ + components.push(headerUI('Do you want to add this token?')); + + componentPairs.forEach(({ label, value, component }, idx) => { + if (component) { + components.push(component); + } else if (label && value) { + components.push( + rowUI({ + label, value, - markdown: false, }), - ), - ); + ); + } + if (idx < componentPairs.length - 1) { - components.push(divider()); + components.push(dividerUI()); } }); return await confirmDialog(components); From abdd77daa4e24f409ad63b4b8ed26e61350f8402 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 30 Oct 2024 08:55:10 +0000 Subject: [PATCH 26/50] chore: release main (#367) * chore: release main * chore: lint --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> --- .release-please-manifest.json | 6 +++--- packages/get-starknet/CHANGELOG.md | 14 ++++++++++++++ packages/get-starknet/package.json | 2 +- packages/starknet-snap/CHANGELOG.md | 18 ++++++++++++++++++ .../openrpc/starknet_snap_api_openrpc.json | 2 +- packages/starknet-snap/package.json | 2 +- packages/starknet-snap/snap.manifest.json | 2 +- packages/wallet-ui/CHANGELOG.md | 7 +++++++ packages/wallet-ui/package.json | 2 +- 9 files changed, 47 insertions(+), 8 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 505d10cd..b064d956 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,5 +1,5 @@ { - "packages/starknet-snap": "2.10.1", - "packages/wallet-ui": "1.24.0", - "packages/get-starknet": "1.2.0" + "packages/starknet-snap": "2.11.0", + "packages/wallet-ui": "1.24.1", + "packages/get-starknet": "1.3.0" } \ No newline at end of file diff --git a/packages/get-starknet/CHANGELOG.md b/packages/get-starknet/CHANGELOG.md index 2787809c..c710c61f 100644 --- a/packages/get-starknet/CHANGELOG.md +++ b/packages/get-starknet/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [1.3.0](https://github.com/Consensys/starknet-snap/compare/get-starknet-v1.2.0...get-starknet-v1.3.0) (2024-10-28) + + +### Features + +* Add backward compatible with get-starknet v3 ([#400](https://github.com/Consensys/starknet-snap/issues/400)) ([8bf8463](https://github.com/Consensys/starknet-snap/commit/8bf8463e0aefd3eb9e59f3cbcb44493e5de2fb5f)) +* Add get-starknet v4 support ([#400](https://github.com/Consensys/starknet-snap/issues/400)) ([8bf8463](https://github.com/Consensys/starknet-snap/commit/8bf8463e0aefd3eb9e59f3cbcb44493e5de2fb5f)) + + +### Bug Fixes + +* Fix address not update when network change ([#366](https://github.com/Consensys/starknet-snap/issues/366)) ([c96f75e](https://github.com/Consensys/starknet-snap/commit/c96f75eb6c95b76513e3a0488d7ccdb3d59e5a71)) +* Remove 'Method not supported' exception in 'on' and 'off' event handlers for get-starknet v4 compatibility ([#402](https://github.com/Consensys/starknet-snap/issues/402)) ([bd6b66d](https://github.com/Consensys/starknet-snap/commit/bd6b66d2cba8382711adbe6a6312f295e22c64b6)) + ## [1.2.0](https://github.com/Consensys/starknet-snap/compare/get-starknet-v1.1.0...get-starknet-v1.2.0) (2024-09-20) diff --git a/packages/get-starknet/package.json b/packages/get-starknet/package.json index 8e39e95b..35fbabc8 100644 --- a/packages/get-starknet/package.json +++ b/packages/get-starknet/package.json @@ -1,6 +1,6 @@ { "name": "@consensys/get-starknet", - "version": "1.2.0", + "version": "1.3.0", "license": "(Apache-2.0 OR MIT)", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/starknet-snap/CHANGELOG.md b/packages/starknet-snap/CHANGELOG.md index 6b29a3e1..8109724c 100644 --- a/packages/starknet-snap/CHANGELOG.md +++ b/packages/starknet-snap/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [2.11.0](https://github.com/Consensys/starknet-snap/compare/starknet-snap-v2.10.1...starknet-snap-v2.11.0) (2024-10-28) + + +### Features + +* Add custom error code to meet get-starknet v4 exception format ([#374](https://github.com/Consensys/starknet-snap/issues/374)) ([e61eb8b](https://github.com/Consensys/starknet-snap/commit/e61eb8bb4b7b1e2af50ed02bbdd4dac517867710)) +* Add new RPC `starkNet_getDeploymentData` to support get-starknet v4 ([#381](https://github.com/Consensys/starknet-snap/issues/381)) ([a034bcf](https://github.com/Consensys/starknet-snap/commit/a034bcfb3b60242559e57f7ffbba9a7359444f1f)) +* Add UI divider and hyperlink the address to the explorer on the UI dialog ([#405](https://github.com/Consensys/starknet-snap/issues/405), [#404](https://github.com/Consensys/starknet-snap/issues/404)) ([a77fede](https://github.com/Consensys/starknet-snap/commit/a77fedebcc3674549b117eb865d500c6d5209c7f)) +* Refactor RPC `starkNet_addErc20Token` to have superstruct validation ([#388](https://github.com/Consensys/starknet-snap/issues/388)) ([157b5ad](https://github.com/Consensys/starknet-snap/commit/157b5ad2930fe4dfa0c154596c942c295d9c4d99)) +* Refactor RPC `starkNet_declareContract` to have superstruct validation ([#398](https://github.com/Consensys/starknet-snap/issues/398)) ([5617ccf](https://github.com/Consensys/starknet-snap/commit/5617ccf85af58943313ef81bf3a03deed0c4eb0f)) +* Refactor RPC `starkNet_switchNetwork` to have superstruct validation ([#369](https://github.com/Consensys/starknet-snap/issues/369), [#373](https://github.com/Consensys/starknet-snap/issues/373), [#368](https://github.com/Consensys/starknet-snap/issues/368)) ([d0384bf](https://github.com/Consensys/starknet-snap/commit/d0384bf9c9476c2168586cf7dc48fe6adb965bcb)) + + +### Bug Fixes + +* Fix incorrect params passed to RPCs `starkNet_signDeclareTransaction`, `starkNet_verifySignedMessage`, `starkNet_declareContract` and `starkNet_getDeploymentData` ([#401](https://github.com/Consensys/starknet-snap/issues/401)) ([a834beb](https://github.com/Consensys/starknet-snap/commit/a834beb4bc0bb42f2f21b50f1cbb5a961e938b1e)) +* Fix RPC `starkNet_executeTxn` storing in-correct state data if the params `calls` is not an array ([#376](https://github.com/Consensys/starknet-snap/issues/376)) ([508b958](https://github.com/Consensys/starknet-snap/commit/508b9584b534bd93235296fd36328fbaaa52334b)) + ## [2.10.1](https://github.com/Consensys/starknet-snap/compare/starknet-snap-v2.10.0...starknet-snap-v2.10.1) (2024-10-02) diff --git a/packages/starknet-snap/openrpc/starknet_snap_api_openrpc.json b/packages/starknet-snap/openrpc/starknet_snap_api_openrpc.json index 68422415..c596193b 100644 --- a/packages/starknet-snap/openrpc/starknet_snap_api_openrpc.json +++ b/packages/starknet-snap/openrpc/starknet_snap_api_openrpc.json @@ -1,7 +1,7 @@ { "openrpc": "1.0.0-rc1", "info": { - "version": "2.10.1", + "version": "2.11.0", "title": "Starknet MetaMask Snap API", "license": {} }, diff --git a/packages/starknet-snap/package.json b/packages/starknet-snap/package.json index e797fe2c..c134d97a 100644 --- a/packages/starknet-snap/package.json +++ b/packages/starknet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@consensys/starknet-snap", - "version": "2.10.1", + "version": "2.11.0", "keywords": [], "repository": { "type": "git", diff --git a/packages/starknet-snap/snap.manifest.json b/packages/starknet-snap/snap.manifest.json index f6726679..9583616a 100644 --- a/packages/starknet-snap/snap.manifest.json +++ b/packages/starknet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "2.10.1", + "version": "2.11.0", "description": "Manage Starknet accounts and assets with MetaMask.", "proposedName": "Starknet", "repository": { diff --git a/packages/wallet-ui/CHANGELOG.md b/packages/wallet-ui/CHANGELOG.md index 8b60d9b9..076fc9ef 100644 --- a/packages/wallet-ui/CHANGELOG.md +++ b/packages/wallet-ui/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +## [1.24.1](https://github.com/Consensys/starknet-snap/compare/wallet-ui-v1.24.0...wallet-ui-v1.24.1) (2024-10-28) + + +### Bug Fixes + +* Remove Snap dependency on Wallet UI package ([#397](https://github.com/Consensys/starknet-snap/issues/397)) ([c9c1aaf](https://github.com/Consensys/starknet-snap/commit/c9c1aafb45120b7d0767337f300e1ab6ff277ab3)) + ## [1.24.0](https://github.com/Consensys/starknet-snap/compare/wallet-ui-v1.23.0...wallet-ui-v1.24.0) (2024-09-20) diff --git a/packages/wallet-ui/package.json b/packages/wallet-ui/package.json index 1024ccd0..889dbe10 100644 --- a/packages/wallet-ui/package.json +++ b/packages/wallet-ui/package.json @@ -1,6 +1,6 @@ { "name": "wallet-ui", - "version": "1.24.0", + "version": "1.24.1", "private": true, "homepage": "/starknet", "license": "(Apache-2.0 OR MIT)", From e508475dcac331ac650886370ac5cc643b94095b Mon Sep 17 00:00:00 2001 From: khanti42 Date: Mon, 4 Nov 2024 13:47:53 +0100 Subject: [PATCH 27/50] chore: remove ending slash on localSnapId to ensure compatibility with get-starknet env var (#409) --- packages/wallet-ui/src/services/useStarkNetSnap.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wallet-ui/src/services/useStarkNetSnap.ts b/packages/wallet-ui/src/services/useStarkNetSnap.ts index 911f350b..59741906 100644 --- a/packages/wallet-ui/src/services/useStarkNetSnap.ts +++ b/packages/wallet-ui/src/services/useStarkNetSnap.ts @@ -48,7 +48,7 @@ export const useStarkNetSnap = () => { ); const snapId = process.env.REACT_APP_SNAP_ID ? process.env.REACT_APP_SNAP_ID - : 'local:http://localhost:8081/'; + : 'local:http://localhost:8081'; const snapVersion = process.env.REACT_APP_SNAP_VERSION ? process.env.REACT_APP_SNAP_VERSION : '*'; From 0c8468af99cc3a936ffc993a1ff5ca2386f32c64 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 6 Nov 2024 16:25:10 +0800 Subject: [PATCH 28/50] chore: add transaction request statemgr (#412) --- .../starknet-snap/src/__tests__/helper.ts | 61 +++++- .../src/state/__tests__/helper.ts | 10 +- .../src/state/request-state-manager.test.ts | 173 ++++++++++++++++++ .../src/state/request-state-manager.ts | 123 +++++++++++++ .../starknet-snap/src/state/state-manager.ts | 5 + packages/starknet-snap/src/types/snapState.ts | 16 ++ 6 files changed, 386 insertions(+), 2 deletions(-) create mode 100644 packages/starknet-snap/src/state/request-state-manager.test.ts create mode 100644 packages/starknet-snap/src/state/request-state-manager.ts diff --git a/packages/starknet-snap/src/__tests__/helper.ts b/packages/starknet-snap/src/__tests__/helper.ts index 895c913f..d97cb911 100644 --- a/packages/starknet-snap/src/__tests__/helper.ts +++ b/packages/starknet-snap/src/__tests__/helper.ts @@ -15,13 +15,20 @@ import { TransactionExecutionStatus, TransactionType, } from 'starknet'; +import { v4 as uuidv4 } from 'uuid'; -import type { AccContract, Transaction } from '../types/snapState'; +import type { + AccContract, + Transaction, + TransactionRequest, +} from '../types/snapState'; import { ACCOUNT_CLASS_HASH, ACCOUNT_CLASS_HASH_LEGACY, + ETHER_MAINNET, PRELOADED_TOKENS, PROXY_CONTRACT_HASH, + STRK_MAINNET, } from '../utils/constants'; import { grindKey } from '../utils/keyPair'; @@ -285,6 +292,58 @@ export function generateTransactions({ return transactions.sort((a, b) => b.timestamp - a.timestamp); } +export function generateTransactionRequests({ + chainId, + address, + contractAddresses = PRELOADED_TOKENS.map((token) => token.address), + cnt = 1, +}: { + chainId: constants.StarknetChainId; + address: string; + contractAddresses?: string[]; + cnt?: number; +}): TransactionRequest[] { + const feeTokens = [STRK_MAINNET, ETHER_MAINNET]; + const request = { + chainId: chainId, + id: '', + interfaceId: '', + type: '', + signer: '', + maxFee: '', + calls: [], + feeToken: '', + }; + const requests: TransactionRequest[] = []; + + for (let i = 0; i < cnt; i++) { + requests.push({ + ...request, + id: uuidv4(), + interfaceId: uuidv4(), + type: TransactionType.INVOKE, + signer: address, + maxFee: '100', + feeToken: + feeTokens[Math.floor(generateRandomValue() * feeTokens.length)].symbol, + calls: [ + { + contractAddress: + contractAddresses[ + Math.floor(generateRandomValue() * contractAddresses.length) + ], + calldata: CallData.compile({ + to: address, + amount: '1', + }), + entrypoint: 'transfer', + }, + ], + }); + } + + return requests; +} /** * Method to generate a mock estimate fee response. * diff --git a/packages/starknet-snap/src/state/__tests__/helper.ts b/packages/starknet-snap/src/state/__tests__/helper.ts index a84338f0..68d47bc3 100644 --- a/packages/starknet-snap/src/state/__tests__/helper.ts +++ b/packages/starknet-snap/src/state/__tests__/helper.ts @@ -1,7 +1,12 @@ import type { constants } from 'starknet'; import { generateAccounts, type StarknetAccount } from '../../__tests__/helper'; -import type { Erc20Token, Network, Transaction } from '../../types/snapState'; +import type { + Erc20Token, + Network, + Transaction, + TransactionRequest, +} from '../../types/snapState'; import * as snapHelper from '../../utils/snap'; jest.mock('../../utils/snap'); @@ -20,12 +25,14 @@ export const mockState = async ({ networks, transactions, currentNetwork, + transactionRequests, }: { accounts?: StarknetAccount[]; tokens?: Erc20Token[]; networks?: Network[]; transactions?: Transaction[]; currentNetwork?: Network; + transactionRequests?: TransactionRequest[]; }) => { const getDataSpy = jest.spyOn(snapHelper, 'getStateData'); const setDataSpy = jest.spyOn(snapHelper, 'setStateData'); @@ -35,6 +42,7 @@ export const mockState = async ({ networks: networks ?? [], transactions: transactions ?? [], currentNetwork, + transactionRequests: transactionRequests ?? [], }; getDataSpy.mockResolvedValue(state); return { diff --git a/packages/starknet-snap/src/state/request-state-manager.test.ts b/packages/starknet-snap/src/state/request-state-manager.test.ts new file mode 100644 index 00000000..04531eb8 --- /dev/null +++ b/packages/starknet-snap/src/state/request-state-manager.test.ts @@ -0,0 +1,173 @@ +import { constants } from 'starknet'; + +import { generateTransactionRequests } from '../__tests__/helper'; +import type { TransactionRequest } from '../types/snapState'; +import { mockAcccounts, mockState } from './__tests__/helper'; +import { TransactionRequestStateManager } from './request-state-manager'; +import { StateManagerError } from './state-manager'; + +describe('TransactionRequestStateManager', () => { + const getChainId = () => constants.StarknetChainId.SN_SEPOLIA; + + const prepareMockData = async () => { + const chainId = getChainId(); + const accounts = await mockAcccounts(chainId, 1); + const transactionRequests = generateTransactionRequests({ + chainId, + address: accounts[0].address, + cnt: 10, + }); + + const { state, setDataSpy, getDataSpy } = await mockState({ + transactionRequests, + }); + + return { + state, + setDataSpy, + getDataSpy, + account: accounts[0], + transactionRequests, + }; + }; + + const getNewEntity = (address) => { + const chainId = getChainId(); + const transactionRequests = generateTransactionRequests({ + chainId, + address, + cnt: 1, + }); + + return transactionRequests[0]; + }; + + const getUpdateEntity = (request: TransactionRequest) => { + return { + ...request, + maxFee: '999999', + }; + }; + + describe('getTransactionRequest', () => { + it('returns the transaction request', async () => { + const { + transactionRequests: [transactionRequest], + } = await prepareMockData(); + + const stateManager = new TransactionRequestStateManager(); + const result = await stateManager.getTransactionRequest({ + requestId: transactionRequest.id, + }); + + expect(result).toStrictEqual(transactionRequest); + }); + + it('finds the request by interfaceId', async () => { + const { + transactionRequests: [transactionRequest], + } = await prepareMockData(); + + const stateManager = new TransactionRequestStateManager(); + const result = await stateManager.getTransactionRequest({ + interfaceId: transactionRequest.interfaceId, + }); + + expect(result).toStrictEqual(transactionRequest); + }); + + it('returns null if the transaction request can not be found', async () => { + await prepareMockData(); + + const stateManager = new TransactionRequestStateManager(); + + const result = await stateManager.getTransactionRequest({ + requestId: 'something', + }); + expect(result).toBeNull(); + }); + + it('throws a `At least one search condition must be provided` error if no search criteria given', async () => { + const stateManager = new TransactionRequestStateManager(); + + await expect(stateManager.getTransactionRequest({})).rejects.toThrow( + 'At least one search condition must be provided', + ); + }); + }); + + describe('upsertTransactionRequest', () => { + it('updates the transaction request if the transaction request found', async () => { + const { + state, + transactionRequests: [transactionRequest], + } = await prepareMockData(); + const entity = getUpdateEntity(transactionRequest); + + const stateManager = new TransactionRequestStateManager(); + await stateManager.upsertTransactionRequest(entity); + + expect( + state.transactionRequests.find( + (req) => req.id === transactionRequest.id, + ), + ).toStrictEqual(entity); + }); + + it('add a new transaction request if the transaction request does not found', async () => { + const { state, account } = await prepareMockData(); + const entity = getNewEntity(account.address); + const orgLength = state.transactionRequests.length; + + const stateManager = new TransactionRequestStateManager(); + await stateManager.upsertTransactionRequest(entity); + + expect(state.transactionRequests).toHaveLength(orgLength + 1); + expect( + state.transactionRequests.find((req) => req.id === entity.id), + ).toStrictEqual(entity); + }); + + it('throws a `StateManagerError` error if an error was thrown', async () => { + const { account, setDataSpy } = await prepareMockData(); + const entity = getNewEntity(account.address); + setDataSpy.mockRejectedValue(new Error('Error')); + + const stateManager = new TransactionRequestStateManager(); + + await expect( + stateManager.upsertTransactionRequest(entity), + ).rejects.toThrow(StateManagerError); + }); + }); + + describe('removeTransactionRequests', () => { + it('removes the request', async () => { + const { + transactionRequests: [{ id }], + state, + } = await prepareMockData(); + const stateManager = new TransactionRequestStateManager(); + + await stateManager.removeTransactionRequest(id); + + expect( + state.transactionRequests.filter((req) => req.id === id), + ).toStrictEqual([]); + }); + + it('throws a `StateManagerError` error if an error was thrown', async () => { + const { + transactionRequests: [{ id }], + setDataSpy, + } = await prepareMockData(); + setDataSpy.mockRejectedValue(new Error('Error')); + + const stateManager = new TransactionRequestStateManager(); + + await expect(stateManager.removeTransactionRequest(id)).rejects.toThrow( + StateManagerError, + ); + }); + }); +}); diff --git a/packages/starknet-snap/src/state/request-state-manager.ts b/packages/starknet-snap/src/state/request-state-manager.ts new file mode 100644 index 00000000..1baa0a37 --- /dev/null +++ b/packages/starknet-snap/src/state/request-state-manager.ts @@ -0,0 +1,123 @@ +import type { TransactionRequest, SnapState } from '../types/snapState'; +import { logger } from '../utils'; +import type { IFilter } from './filter'; +import { StringFllter } from './filter'; +import { StateManager, StateManagerError } from './state-manager'; + +export type ITransactionRequestFilter = IFilter; + +export class IdFilter + extends StringFllter + implements ITransactionRequestFilter +{ + dataKey = 'id'; +} + +export class InterfaceIdFilter + extends StringFllter + implements ITransactionRequestFilter +{ + dataKey = 'interfaceId'; +} + +export class TransactionRequestStateManager extends StateManager { + protected getCollection(state: SnapState): TransactionRequest[] { + return state.transactionRequests ?? []; + } + + protected updateEntity( + dataInState: TransactionRequest, + data: TransactionRequest, + ): void { + // This is the only field that can be updated + dataInState.maxFee = data.maxFee; + dataInState.feeToken = data.feeToken; + } + + /** + * Finds a `TransactionRequest` object based on the given requestId or interfaceId. + * + * @param param - The param object. + * @param param.requestId - The requestId to search for. + * @param param.interfaceId - The interfaceId to search for. + * @param [state] - The optional SnapState object. + * @returns A Promise that resolves with the `TransactionRequest` object if found, or null if not found. + */ + async getTransactionRequest( + { + requestId, + interfaceId, + }: { + requestId?: string; + interfaceId?: string; + }, + state?: SnapState, + ): Promise { + const filters: ITransactionRequestFilter[] = []; + if (requestId) { + filters.push(new IdFilter([requestId])); + } + if (interfaceId) { + filters.push(new InterfaceIdFilter([interfaceId])); + } + if (filters.length === 0) { + throw new StateManagerError( + 'At least one search condition must be provided', + ); + } + return await this.find(filters, state); + } + + /** + * Upsert a `TransactionRequest` in the state with the given data. + * + * @param data - The `TransactionRequest` object. + * @returns A Promise that resolves when the upsert is complete. + */ + async upsertTransactionRequest(data: TransactionRequest): Promise { + try { + await this.update(async (state: SnapState) => { + const dataInState = await this.getTransactionRequest( + { + requestId: data.id, + }, + state, + ); + + if (dataInState === null) { + this.getCollection(state)?.push(data); + } else { + this.updateEntity(dataInState, data); + } + }); + } catch (error) { + throw new StateManagerError(error.message); + } + } + + /** + * Removes the `TransactionRequest` objects in the state with the given requestId. + * + * @param requestId - The requestId to search for. + * @returns A Promise that resolves when the remove is complete. + */ + async removeTransactionRequest(requestId: string): Promise { + try { + await this.update(async (state: SnapState) => { + const sizeOfTransactionRequests = this.getCollection(state).length; + + state.transactionRequests = this.getCollection(state).filter((req) => { + return req.id !== requestId; + }); + + // Check if the TransactionRequest was removed + if (sizeOfTransactionRequests === this.getCollection(state).length) { + // If the TransactionRequest does not exist, log a warning instead of throwing an error + logger.warn(`TransactionRequest with id ${requestId} does not exist`); + } + }); + } catch (error) { + throw new StateManagerError(error.message); + } + } +} diff --git a/packages/starknet-snap/src/state/state-manager.ts b/packages/starknet-snap/src/state/state-manager.ts index 7bee3e56..81b7a997 100644 --- a/packages/starknet-snap/src/state/state-manager.ts +++ b/packages/starknet-snap/src/state/state-manager.ts @@ -14,9 +14,14 @@ export abstract class StateManager extends SnapStateManager { erc20Tokens: [], networks: [], transactions: [], + transactionRequests: [], }; } + if (!state.transactionRequests) { + state.transactionRequests = []; + } + if (!state.accContracts) { state.accContracts = []; } diff --git a/packages/starknet-snap/src/types/snapState.ts b/packages/starknet-snap/src/types/snapState.ts index a1ae56e7..6b312de3 100644 --- a/packages/starknet-snap/src/types/snapState.ts +++ b/packages/starknet-snap/src/types/snapState.ts @@ -7,6 +7,22 @@ export type SnapState = { networks: Network[]; transactions: Transaction[]; currentNetwork?: Network; + transactionRequests?: TransactionRequest[]; +}; + +export type TransactionRequest = { + id: string; + interfaceId: string; + type: string; + signer: string; + chainId: string; + maxFee: string; + calls: { + contractAddress: string; + calldata: RawCalldata; + entrypoint: string; + }[]; + feeToken: string; }; export type AccContract = { From abfc0e52cc5c9c4fc7ec7e04a9ff667acbf99813 Mon Sep 17 00:00:00 2001 From: khanti42 Date: Thu, 21 Nov 2024 12:11:57 +0100 Subject: [PATCH 29/50] feat: add jsx support and interactive fee token selection in StarkNet Snap (#429) * chore: add jsx support in snap (#415) * chore: add jsx support in snap * chore: fix comment * chore: update yarn.lock * feat: add common jsx component (#417) * chore: add jsx support in snap * chore: add jsx support in snap * feat: jsx support management * feat: common jsx components and fragments * chore: fix test and lint * chore: rollback jsx support detection not here * chore: fix comment * chore: fix comments * chore: fix comments * chore: update yarn.lock * chore: update yarn.lock * chore: add react-dom types * chore: fix comments * chore: rebase wallet-ui changes happening elsewhere * feat: add fee token selection interactive UI (#418) * chore: add jsx support in snap * chore: add jsx support in snap * feat: jsx support management * feat: common jsx components and fragments * chore: fix test and lint * feat: add interactive-ui for execute txn * fix: add mutex in jsx support detection mechanism * chore: fixture request addapted * chore: ensure test pass * chore: lint * chore: remove console.log * chore: rollback jsx support detection not here * chore: rollback index.tsx * chore: fix comment * chore: fix comments * chore: fix comments * chore: update yarn.lock * chore: update yarn.lock * chore: update yarn.lock * chore: rebase wallet-ui changes happening elsewhere * chore: fix comments * chore: fix comments * chore: fix comments * chore: fix comments * chore: fix comments * chore: fix comments * chore: removed utils * fix: implement comments * fix: implement comments * chore: fix comments * Update packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/utils.test.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/types/snapState.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/utils.test.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/utils.test.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * chore: fix comments * chore: fix comments * chore: fix comments * chore: lint --------- Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * feat: adopt fee token selection dialog in RPC `starkNet_executeTxn` (#419) * chore: add jsx support in snap * chore: add jsx support in snap * feat: jsx support management * feat: common jsx components and fragments * chore: fix test and lint * feat: add interactive-ui for execute txn * fix: add mutex in jsx support detection mechanism * chore: fixture request addapted * chore: ensure test pass * chore: lint * feat: add interactive-ui in execute txn * chore: remove console.log * chore: missing helper in tests * chore: lint * chore: rollback jsx support detection not here * chore: rollback index.tsx * chore: fix comment * chore: fix comments * chore: fix comments * chore: update yarn.lock * chore: update yarn.lock * feat: update wallet-ui message * chore: update yarn.lock * chore: rebase wallet-ui changes happening elsewhere * chore: rebase wallet-ui changes happening elsewhere * chore: fix comments * chore: fix comments * fix: formatter-utils * chore: fix comments * chore: fix comments * chore: lint * chore: lint * chore: fix comments * chore: fix comments * chore: fix comments * chore: fix comments * chore: removed utils * fix: implement comments * fix: implement comments * chore: fix comments * chore: fix comments * Update packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/utils.test.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/types/snapState.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/utils.test.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/utils.test.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * chore: fix comments * chore: fix comments * chore: fix comments * chore: fix comments * chore: fix comments * chore: lint * chore: lint * chore: fix comments * chore: fix comments --------- Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * chore: fix tests * feat: add jsx support detection (#416) * chore: add jsx support in snap * chore: add jsx support in snap * feat: jsx support management * chore: fix test and lint * fix: add mutex in jsx support detection mechanism * chore: ensure test pass * feat: new init state manager class to manage state init and support check * fix: wait for hooks in request handler * chore: lint * fix: set jsx support to true before showing dialog * chore: fix comment * fix: moved ping pong * chore: lint * chore: rollback state * chore: lint * chore: fix comments * fix: test suits * feat: add event listener for fee token selection (#420) * chore: add jsx support in snap * chore: add jsx support in snap * feat: jsx support management * feat: common jsx components and fragments * chore: fix test and lint * feat: add interactive-ui for execute txn * fix: add mutex in jsx support detection mechanism * chore: fixture request addapted * chore: ensure test pass * chore: lint * feat: add interactive-ui in execute txn * chore: remove console.log * chore: missing helper in tests * chore: lint * feat: event-handler in index.tsx * feat: event controller * feat: error handling and tests suits for event controller * fix: test suits * fix: signer in fee-token-selector * chore: rollback jsx support detection not here * chore: rollback index.tsx * chore: lint * chore: fix comment * chore: fix comments * chore: fix comments * chore: update yarn.lock * chore: update yarn.lock * feat: update wallet-ui message * chore: update yarn.lock * chore: rebase wallet-ui changes happening elsewhere * chore: rebase wallet-ui changes happening elsewhere * chore: fix comments * chore: fix comments * fix: formatter-utils * chore: fix comments * chore: fix comments * chore: lint * chore: lint * chore: fix comments * chore: fix comments * chore: fix comments * chore: fix comments * chore: lint * chore: lint * chore: removed utils * feat: add comments in user-input classes * fix: implement comments * fix: implement comments * chore: fix comments * chore: fix comments * Update packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/utils.test.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/types/snapState.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/utils.test.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/utils.test.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * chore: fix comments * chore: fix comments * chore: fix comments * chore: fix comments * chore: fix comments * chore: lint * chore: lint * chore: fix rebase * chore: fix comments * chore: fix comments * chore: fix comments * chore: rebase * fix: removed user-input controller abstract class and derived ones * chore: rollback execute-txn * chore: update * chore: fix comments * chore: fix comments * chore: refine the code * fix: add execution test * fix: update execute txn test --------- Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/state/state-manager.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/index.test.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/types/snapState.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/index.test.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/index.test.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/index.test.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * chore: lint --------- Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> --- packages/starknet-snap/.eslintrc.js | 9 +- packages/starknet-snap/package.json | 8 +- packages/starknet-snap/snap.config.ts | 2 +- .../starknet-snap/src/__tests__/helper.ts | 17 +- .../src/{index.test.ts => index.test.tsx} | 0 .../starknet-snap/src/{index.ts => index.tsx} | 101 +++--- packages/starknet-snap/src/on-home-page.ts | 4 +- .../src/rpcs/__tests__/helper.ts | 36 ++ .../src/rpcs/execute-txn.test.ts | 64 +++- .../starknet-snap/src/rpcs/execute-txn.ts | 323 +++++++----------- .../src/state/request-state-manager.ts | 3 +- packages/starknet-snap/src/types/snapState.ts | 37 +- .../src/ui/components/ExecuteTxnUI.tsx | 140 ++++++++ .../starknet-snap/src/ui/components/index.tsx | 1 + .../user-input-event-controller.ts | 203 +++++++++++ .../src/ui/fragments/AddressUI.tsx | 39 +++ .../starknet-snap/src/ui/fragments/Amount.tsx | 29 ++ .../src/ui/fragments/FeeTokenSelector.tsx | 52 +++ .../src/ui/fragments/JsonDataUI.tsx | 24 ++ .../src/ui/fragments/LoadingUI.tsx | 20 ++ .../src/ui/fragments/NetworkUI.tsx | 21 ++ .../src/ui/fragments/SignerUI.tsx | 28 ++ .../starknet-snap/src/ui/fragments/index.tsx | 4 + packages/starknet-snap/src/ui/types.ts | 7 + packages/starknet-snap/src/ui/utils.test.tsx | 241 +++++++++++++ packages/starknet-snap/src/ui/utils.tsx | 210 ++++++++++++ .../starknet-snap/src/utils/__mocks__/snap.ts | 2 + .../starknet-snap/src/utils/exceptions.ts | 6 + .../src/utils/formatter-utils.test.ts | 144 +++++++- .../src/utils/formatter-utils.ts | 56 +++ packages/starknet-snap/src/utils/snap-ui.ts | 43 ++- packages/starknet-snap/src/utils/snap.test.ts | 17 + packages/starknet-snap/src/utils/snap.ts | 18 + packages/starknet-snap/tsconfig.json | 4 +- yarn.lock | 170 ++++++++- 35 files changed, 1810 insertions(+), 273 deletions(-) rename packages/starknet-snap/src/{index.test.ts => index.test.tsx} (100%) rename packages/starknet-snap/src/{index.ts => index.tsx} (84%) create mode 100644 packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx create mode 100644 packages/starknet-snap/src/ui/components/index.tsx create mode 100644 packages/starknet-snap/src/ui/controllers/user-input-event-controller.ts create mode 100644 packages/starknet-snap/src/ui/fragments/AddressUI.tsx create mode 100644 packages/starknet-snap/src/ui/fragments/Amount.tsx create mode 100644 packages/starknet-snap/src/ui/fragments/FeeTokenSelector.tsx create mode 100644 packages/starknet-snap/src/ui/fragments/JsonDataUI.tsx create mode 100644 packages/starknet-snap/src/ui/fragments/LoadingUI.tsx create mode 100644 packages/starknet-snap/src/ui/fragments/NetworkUI.tsx create mode 100644 packages/starknet-snap/src/ui/fragments/SignerUI.tsx create mode 100644 packages/starknet-snap/src/ui/fragments/index.tsx create mode 100644 packages/starknet-snap/src/ui/types.ts create mode 100644 packages/starknet-snap/src/ui/utils.test.tsx create mode 100644 packages/starknet-snap/src/ui/utils.tsx diff --git a/packages/starknet-snap/.eslintrc.js b/packages/starknet-snap/.eslintrc.js index 064abf0d..6388e179 100644 --- a/packages/starknet-snap/.eslintrc.js +++ b/packages/starknet-snap/.eslintrc.js @@ -19,10 +19,17 @@ module.exports = { 'jsdoc/require-returns': 'off', 'jsdoc/require-param-description': 'off', 'jsdoc/match-description': 'off', + // This allows importing the `Text` JSX component. + '@typescript-eslint/no-shadow': [ + 'error', + { + allow: ['Text'], + }, + ], }, }, { - files: ['*.test.ts'], + files: ['*.test.ts', '*.test.tsx'], extends: ['@metamask/eslint-config-jest'], rules: { '@typescript-eslint/no-shadow': [ diff --git a/packages/starknet-snap/package.json b/packages/starknet-snap/package.json index c134d97a..807678fd 100644 --- a/packages/starknet-snap/package.json +++ b/packages/starknet-snap/package.json @@ -22,9 +22,9 @@ "cover:report": "nyc report --reporter=lcov --reporter=text", "jest": "jest --passWithNoTests", "lint": "yarn lint:eslint && yarn lint:misc --check", - "lint:eslint": "eslint . --cache --ext js,ts", + "lint:eslint": "eslint . --cache --ext js,ts,tsx", "lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write", - "lint:misc": "prettier '**/*.ts' '**/*.json' '**/*.md' '!CHANGELOG.md' --ignore-path .gitignore", + "lint:misc": "prettier '**/*.ts' '**/*.tsx' '**/*.json' '**/*.md' '!CHANGELOG.md' --ignore-path .gitignore", "serve": "mm-snap serve", "start": "mm-snap watch", "test": "yarn run test:unit && yarn run cover:report && yarn run jest", @@ -39,7 +39,7 @@ }, "dependencies": { "@metamask/key-tree": "9.0.0", - "@metamask/snaps-sdk": "^4.0.0", + "@metamask/snaps-sdk": "^6.1.1", "@metamask/utils": "^9.1.0", "async-mutex": "^0.3.2", "ethereum-unit-converter": "^0.0.17", @@ -59,6 +59,8 @@ "@metamask/snaps-jest": "^8.2.0", "@types/chai": "^4.3.1", "@types/chai-as-promised": "^7.1.5", + "@types/react": "18.2.4", + "@types/react-dom": "18.2.4", "@types/sinon": "^10.0.11", "@types/sinon-chai": "^3.2.8", "@typescript-eslint/eslint-plugin": "^5.42.1", diff --git a/packages/starknet-snap/snap.config.ts b/packages/starknet-snap/snap.config.ts index 056840ca..7d8bb771 100644 --- a/packages/starknet-snap/snap.config.ts +++ b/packages/starknet-snap/snap.config.ts @@ -6,7 +6,7 @@ require('dotenv').config(); const config: SnapConfig = { bundler: 'webpack', - input: resolve(__dirname, 'src/index.ts'), + input: resolve(__dirname, 'src/index.tsx'), server: { port: 8081, }, diff --git a/packages/starknet-snap/src/__tests__/helper.ts b/packages/starknet-snap/src/__tests__/helper.ts index d97cb911..6eed3806 100644 --- a/packages/starknet-snap/src/__tests__/helper.ts +++ b/packages/starknet-snap/src/__tests__/helper.ts @@ -322,9 +322,11 @@ export function generateTransactionRequests({ id: uuidv4(), interfaceId: uuidv4(), type: TransactionType.INVOKE, + networkName: 'Sepolia', signer: address, + addressIndex: 0, maxFee: '100', - feeToken: + selectedFeeToken: feeTokens[Math.floor(generateRandomValue() * feeTokens.length)].symbol, calls: [ { @@ -339,6 +341,19 @@ export function generateTransactionRequests({ entrypoint: 'transfer', }, ], + includeDeploy: false, + resourceBounds: [ + { + l1_gas: { + max_amount: '0', + max_price_per_unit: '0', + }, + l2_gas: { + max_amount: '0', + max_price_per_unit: '0', + }, + }, + ], }); } diff --git a/packages/starknet-snap/src/index.test.ts b/packages/starknet-snap/src/index.test.tsx similarity index 100% rename from packages/starknet-snap/src/index.test.ts rename to packages/starknet-snap/src/index.test.tsx diff --git a/packages/starknet-snap/src/index.ts b/packages/starknet-snap/src/index.tsx similarity index 84% rename from packages/starknet-snap/src/index.ts rename to packages/starknet-snap/src/index.tsx index a33e66c9..0d8f4585 100644 --- a/packages/starknet-snap/src/index.ts +++ b/packages/starknet-snap/src/index.tsx @@ -3,8 +3,12 @@ import type { OnHomePageHandler, OnInstallHandler, OnUpdateHandler, + OnUserInputHandler, + UserInputEvent, + InterfaceContext, } from '@metamask/snaps-sdk'; -import { panel, text, MethodNotFoundError } from '@metamask/snaps-sdk'; +import { MethodNotFoundError } from '@metamask/snaps-sdk'; +import { Box, Link, Text } from '@metamask/snaps-sdk/jsx'; import { addNetwork } from './addNetwork'; import { Config } from './config'; @@ -58,8 +62,15 @@ import type { ApiRequestParams, } from './types/snapApi'; import type { SnapState } from './types/snapState'; +import { UserInputEventController } from './ui/controllers/user-input-event-controller'; import { upgradeAccContract } from './upgradeAccContract'; -import { getDappUrl, isSnapRpcError } from './utils'; +import { + ensureJsxSupport, + getDappUrl, + getStateData, + isSnapRpcError, + setStateData, +} from './utils'; import { CAIRO_VERSION_LEGACY, PRELOADED_TOKENS, @@ -93,12 +104,7 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { } // TODO: this will causing racing condition, need to be fixed - let state: SnapState = await snap.request({ - method: 'snap_manageState', - params: { - operation: 'get', - }, - }); + let state: SnapState = await getStateData(); if (!state) { state = { accContracts: [], @@ -107,13 +113,7 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { transactions: [], }; // initialize state if empty and set default data - await snap.request({ - method: 'snap_manageState', - params: { - operation: 'update', - newState: state, - }, - }); + await setStateData(state); } // TODO: this can be remove, after state manager is implemented @@ -307,41 +307,52 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { }; export const onInstall: OnInstallHandler = async () => { - const component = panel([ - text('Your MetaMask wallet is now compatible with Starknet!'), - text( - `To manage your Starknet account and send and receive funds, visit the [companion dapp for Starknet](${getDappUrl()}).`, - ), - ]); - - await snap.request({ - method: 'snap_dialog', - params: { - type: 'alert', - content: component, - }, - }); + await ensureJsxSupport( + + Your MetaMask wallet is now compatible with Starknet! + + To manage your Starknet account and send and receive funds, visit the{' '} + companion dapp for Starknet. + + , + ); }; export const onUpdate: OnUpdateHandler = async () => { - const component = panel([ - text('Features released with this update:'), - text( - 'Support STRK token for the gas fee in sending transaction and estimating fee.', - ), - text('Default network changed to mainnet.'), - text('Support for multiple consecutive transactions.'), - ]); - - await snap.request({ - method: 'snap_dialog', - params: { - type: 'alert', - content: component, - }, - }); + await ensureJsxSupport( + + Your Starknet Snap is now up-to-date ! + + As usual, to manage your Starknet account and send and receive funds, + visit the companion dapp for Starknet. + + , + ); }; export const onHomePage: OnHomePageHandler = async () => { return await homePageController.execute(); }; + +/** + * Handle incoming user events coming from the MetaMask clients open interfaces. + * + * @param params - The event parameters. + * @param params.id - The Snap interface ID where the event was fired. + * @param params.event - The event object containing the event type, name, and + * value. + * @param params.context + * @see https://docs.metamask.io/snaps/reference/exports/#onuserinput + */ +export const onUserInput: OnUserInputHandler = async ({ + id, + event, + context, +}: { + id: string; + event: UserInputEvent; + context: InterfaceContext | null; +}): Promise => { + const controller = new UserInputEventController(id, event, context); + await controller.handleEvent(); +}; diff --git a/packages/starknet-snap/src/on-home-page.ts b/packages/starknet-snap/src/on-home-page.ts index d84fa7ee..55c37c2f 100644 --- a/packages/starknet-snap/src/on-home-page.ts +++ b/packages/starknet-snap/src/on-home-page.ts @@ -50,7 +50,7 @@ export class HomePageController { const balance = await this.getBalance(network, address); - return this.buildComponenets(address, network, balance); + return this.buildComponents(address, network, balance); } catch (error) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions logger.error('Failed to execute onHomePage', toJson(error)); @@ -97,7 +97,7 @@ export class HomePageController { ); } - protected buildComponenets( + protected buildComponents( address: string, network: Network, balance: string, diff --git a/packages/starknet-snap/src/rpcs/__tests__/helper.ts b/packages/starknet-snap/src/rpcs/__tests__/helper.ts index 40b9b6ee..5f3f2665 100644 --- a/packages/starknet-snap/src/rpcs/__tests__/helper.ts +++ b/packages/starknet-snap/src/rpcs/__tests__/helper.ts @@ -3,12 +3,34 @@ import type { constants } from 'starknet'; import type { StarknetAccount } from '../../__tests__/helper'; import { generateAccounts, generateRandomValue } from '../../__tests__/helper'; +import { TransactionRequestStateManager } from '../../state/request-state-manager'; import type { SnapState } from '../../types/snapState'; import { getExplorerUrl, shortenAddress, toJson } from '../../utils'; import * as snapHelper from '../../utils/snap'; import * as snapUtils from '../../utils/snapUtils'; import * as starknetUtils from '../../utils/starknetUtils'; +export const mockTransactionRequestStateManager = () => { + const upsertTransactionRequestSpy = jest.spyOn( + TransactionRequestStateManager.prototype, + 'upsertTransactionRequest', + ); + const getTransactionRequestSpy = jest.spyOn( + TransactionRequestStateManager.prototype, + 'getTransactionRequest', + ); + const removeTransactionRequestSpy = jest.spyOn( + TransactionRequestStateManager.prototype, + 'removeTransactionRequest', + ); + + return { + upsertTransactionRequestSpy, + getTransactionRequestSpy, + removeTransactionRequestSpy, + }; +}; + /** * * @param chainId @@ -58,6 +80,20 @@ export function prepareConfirmDialog() { }; } +/** + * + */ +export function prepareConfirmDialogInteractiveUI() { + const confirmDialogSpy = jest.spyOn( + snapHelper, + 'createInteractiveConfirmDialog', + ); + confirmDialogSpy.mockResolvedValue(true); + return { + confirmDialogSpy, + }; +} + /** * */ diff --git a/packages/starknet-snap/src/rpcs/execute-txn.test.ts b/packages/starknet-snap/src/rpcs/execute-txn.test.ts index f9498055..d49419fc 100644 --- a/packages/starknet-snap/src/rpcs/execute-txn.test.ts +++ b/packages/starknet-snap/src/rpcs/execute-txn.test.ts @@ -14,7 +14,8 @@ import { executeTxn as executeTxnUtil } from '../utils/starknetUtils'; import { generateRandomFee, mockAccount, - prepareConfirmDialog, + mockTransactionRequestStateManager, + prepareConfirmDialogInteractiveUI, prepareMockAccount, } from './__tests__/helper'; import type { ExecuteTxnParams } from './execute-txn'; @@ -35,7 +36,7 @@ const prepareMockExecuteTxn = async ( networks: [STARKNET_SEPOLIA_TESTNET_NETWORK], transactions: [], }; - const { confirmDialogSpy } = prepareConfirmDialog(); + const { confirmDialogSpy } = prepareConfirmDialogInteractiveUI(); const account = await mockAccount(constants.StarknetChainId.SN_SEPOLIA); prepareMockAccount(account, state); @@ -88,6 +89,7 @@ const prepareMockExecuteTxn = async ( executeTxnUtilSpy, getEstimatedFeesSpy, getEstimatedFeesRepsMock, + ...mockTransactionRequestStateManager(), }; }; @@ -100,6 +102,8 @@ describe('ExecuteTxn', () => { executeTxnRespMock, getEstimatedFeesSpy, getEstimatedFeesRepsMock, + upsertTransactionRequestSpy, + getTransactionRequestSpy, request, } = await prepareMockExecuteTxn( calls.hash, @@ -126,6 +130,8 @@ describe('ExecuteTxn', () => { ); expect(getEstimatedFeesSpy).toHaveBeenCalled(); expect(createAccountSpy).not.toHaveBeenCalled(); + expect(upsertTransactionRequestSpy).toHaveBeenCalled(); + expect(getTransactionRequestSpy).toHaveBeenCalled(); }); it.each([ @@ -235,6 +241,60 @@ describe('ExecuteTxn', () => { }, ); + it('throws `Failed to retrieve the updated transaction request` error the transaction request can not retrieve after confirmation', async () => { + const calls = callsExamples.multipleCalls; + const { getTransactionRequestSpy, request } = await prepareMockExecuteTxn( + calls.hash, + calls.calls, + calls.details, + true, + ); + + getTransactionRequestSpy.mockResolvedValue(null); + + await expect(executeTxn.execute(request)).rejects.toThrow( + 'Failed to retrieve the updated transaction request', + ); + }); + + it.each([ + { + executeTxnResult: callsExamples.multipleCalls.hash, + testCase: 'the transaction executed successfully', + }, + { + // Simulate the case where the transaction execution failed and does not return a transaction hash + // An error `Failed to execute transaction` will be thrown in this case + executeTxnResult: '', + testCase: 'the transaction failed to execute', + }, + ])( + 'removes the transaction request from state if $testCase.', + async ({ executeTxnResult }) => { + const calls = callsExamples.multipleCalls; + const { executeTxnUtilSpy, removeTransactionRequestSpy, request } = + await prepareMockExecuteTxn( + executeTxnResult, + calls.calls, + calls.details, + true, + ); + + executeTxnUtilSpy.mockResolvedValue({ + // eslint-disable-next-line @typescript-eslint/naming-convention + transaction_hash: executeTxnResult, + }); + + try { + await executeTxn.execute(request); + } catch (error) { + // eslint-disable-next-line no-empty + } finally { + expect(removeTransactionRequestSpy).toHaveBeenCalled(); + } + }, + ); + it('throws UserRejectedOpError if user cancels execution', async () => { const calls = callsExamples.multipleCalls; const { request, confirmDialogSpy } = await prepareMockExecuteTxn( diff --git a/packages/starknet-snap/src/rpcs/execute-txn.ts b/packages/starknet-snap/src/rpcs/execute-txn.ts index 9e6d6108..db31752c 100644 --- a/packages/starknet-snap/src/rpcs/execute-txn.ts +++ b/packages/starknet-snap/src/rpcs/execute-txn.ts @@ -1,34 +1,30 @@ -import type { Component, Json } from '@metamask/snaps-sdk'; -import convert from 'ethereum-unit-converter'; +import { type Json } from '@metamask/snaps-sdk'; import type { Call, Calldata } from 'starknet'; import { constants, TransactionStatus, TransactionType } from 'starknet'; import type { Infer } from 'superstruct'; import { object, string, assign, optional, any } from 'superstruct'; +import { v4 as uuidv4 } from 'uuid'; import { AccountStateManager } from '../state/account-state-manager'; +import { TransactionRequestStateManager } from '../state/request-state-manager'; import { TokenStateManager } from '../state/token-state-manager'; import { TransactionStateManager } from '../state/transaction-state-manager'; import { FeeToken } from '../types/snapApi'; +import type { TransactionRequest } from '../types/snapState'; import { VoyagerTransactionType, type Transaction } from '../types/snapState'; +import { generateExecuteTxnFlow } from '../ui/utils'; import type { AccountRpcControllerOptions } from '../utils'; import { AddressStruct, BaseRequestStruct, AccountRpcController, - confirmDialog, UniversalDetailsStruct, CallsStruct, mapDeprecatedParams, - addressUI, - signerUI, - networkUI, - jsonDataUI, - dividerUI, - headerUI, - rowUI, + createInteractiveConfirmDialog, + callToTransactionReqCall, } from '../utils'; import { UserRejectedOpError } from '../utils/exceptions'; -import { logger } from '../utils/logger'; import { createAccount, executeTxn as executeTxnUtil, @@ -63,6 +59,8 @@ export class ExecuteTxnRpc extends AccountRpcController< > { protected txnStateManager: TransactionStateManager; + protected reqStateManager: TransactionRequestStateManager; + protected accStateManager: AccountStateManager; protected tokenStateManager: TokenStateManager; @@ -74,6 +72,7 @@ export class ExecuteTxnRpc extends AccountRpcController< constructor(options?: AccountRpcControllerOptions) { super(options); this.txnStateManager = new TransactionStateManager(); + this.reqStateManager = new TransactionRequestStateManager(); this.accStateManager = new AccountStateManager(); this.tokenStateManager = new TokenStateManager(); } @@ -110,88 +109,143 @@ export class ExecuteTxnRpc extends AccountRpcController< protected async handleRequest( params: ExecuteTxnParams, ): Promise { - const { address, calls, abis, details } = params; - const { privateKey, publicKey } = this.account; - - const { includeDeploy, suggestedMaxFee, estimateResults } = - await getEstimatedFees( - this.network, - address, - privateKey, - publicKey, - [ - { - type: TransactionType.INVOKE, - payload: calls, - }, - ], - details, + const requestId = uuidv4(); + + try { + const { address, calls, abis, details } = params; + const { privateKey, publicKey } = this.account; + const callsArray = Array.isArray(calls) ? calls : [calls]; + + const { includeDeploy, suggestedMaxFee, estimateResults } = + await getEstimatedFees( + this.network, + address, + privateKey, + publicKey, + [ + { + type: TransactionType.INVOKE, + payload: calls, + }, + ], + details, + ); + + const accountDeployed = !includeDeploy; + const version = + details?.version as unknown as constants.TRANSACTION_VERSION; + + const formattedCalls = await Promise.all( + callsArray.map(async (call) => + callToTransactionReqCall( + call, + this.network.chainId, + address, + this.tokenStateManager, + ), + ), ); - const accountDeployed = !includeDeploy; - const version = - details?.version as unknown as constants.TRANSACTION_VERSION; + const request: TransactionRequest = { + chainId: this.network.chainId, + networkName: this.network.name, + id: requestId, + interfaceId: '', + type: TransactionType.INVOKE, + signer: address, + addressIndex: this.account.addressIndex, + maxFee: suggestedMaxFee, + calls: formattedCalls, + resourceBounds: estimateResults.map((result) => result.resourceBounds), + selectedFeeToken: + version === constants.TRANSACTION_VERSION.V3 + ? FeeToken.STRK + : FeeToken.ETH, + includeDeploy, + }; - if ( - !(await this.getExecuteTxnConsensus( - address, - accountDeployed, - calls, - suggestedMaxFee, - version, - )) - ) { - throw new UserRejectedOpError() as unknown as Error; - } + const interfaceId = await generateExecuteTxnFlow(request); - if (!accountDeployed) { - await createAccount({ - network: this.network, - address, - publicKey, - privateKey, - waitMode: false, - callback: async (contractAddress: string, transactionHash: string) => { - await this.updateAccountAsDeploy(contractAddress, transactionHash); - }, - version, + request.interfaceId = interfaceId; + + await this.reqStateManager.upsertTransactionRequest(request); + + if (!(await createInteractiveConfirmDialog(interfaceId))) { + throw new UserRejectedOpError() as unknown as Error; + } + + // Retrieve the updated transaction request, + // the transaction request may have been updated during the confirmation process. + const updatedRequest = await this.reqStateManager.getTransactionRequest({ + requestId, }); - } - const resourceBounds = estimateResults.map( - (result) => result.resourceBounds, - ); + if (!updatedRequest) { + throw new Error('Failed to retrieve the updated transaction request'); + } - const executeTxnResp = await executeTxnUtil( - this.network, - address, - privateKey, - calls, - abis, - { + if (!accountDeployed) { + await createAccount({ + network: this.network, + address, + publicKey, + privateKey, + waitMode: false, + callback: async ( + contractAddress: string, + transactionHash: string, + ) => { + await this.updateAccountAsDeploy(contractAddress, transactionHash); + }, + version: + updatedRequest.selectedFeeToken === FeeToken.STRK + ? constants.TRANSACTION_VERSION.V3 + : constants.TRANSACTION_VERSION.V1, + }); + } + + const invocationDetails = { ...details, // Aways repect the input, unless the account is not deployed // TODO: we may also need to increment the nonce base on the input, if the account is not deployed nonce: accountDeployed ? details?.nonce : 1, - maxFee: suggestedMaxFee, - resourceBounds: resourceBounds[resourceBounds.length - 1], - }, - ); + maxFee: updatedRequest.maxFee, + resourceBounds: + updatedRequest.resourceBounds[ + updatedRequest.resourceBounds.length - 1 + ], + version: + updatedRequest.selectedFeeToken === FeeToken.STRK + ? constants.TRANSACTION_VERSION.V3 + : constants.TRANSACTION_VERSION.V1, + }; + + const executeTxnResp = await executeTxnUtil( + this.network, + address, + privateKey, + calls, + abis, + invocationDetails, + ); - if (!executeTxnResp?.transaction_hash) { - throw new Error('Failed to execute transaction'); - } + if (!executeTxnResp?.transaction_hash) { + throw new Error('Failed to execute transaction'); + } - // Since the RPC supports the `calls` parameter either as a single `call` object or an array of `call` objects, - // and the current state data structure does not yet support multiple `call` objects in a single transaction, - // we need to convert `calls` into a single `call` object as a temporary fix. - const call = Array.isArray(calls) ? calls[0] : calls; + // Since the RPC supports the `calls` parameter either as a single `call` object or an array of `call` objects, + // and the current state data structure does not yet support multiple `call` objects in a single transaction, + // we need to convert `calls` into a single `call` object as a temporary fix. + const call = Array.isArray(calls) ? calls[0] : calls; - await this.txnStateManager.addTransaction( - this.createInvokeTxn(address, executeTxnResp.transaction_hash, call), - ); + await this.txnStateManager.addTransaction( + this.createInvokeTxn(address, executeTxnResp.transaction_hash, call), + ); - return executeTxnResp; + return executeTxnResp; + } finally { + await this.reqStateManager.removeTransactionRequest(requestId); + } } protected async updateAccountAsDeploy( @@ -213,119 +267,6 @@ export class ExecuteTxnRpc extends AccountRpcController< }); } - protected async getExecuteTxnConsensus( - address: string, - accountDeployed: boolean, - calls: Call[] | Call, - maxFee: string, - version?: constants.TRANSACTION_VERSION, - ) { - const { name: chainName, chainId } = this.network; - const callsArray = Array.isArray(calls) ? calls : [calls]; - - const components: Component[] = []; - const feeToken: FeeToken = - version === constants.TRANSACTION_VERSION.V3 - ? FeeToken.STRK - : FeeToken.ETH; - - components.push(headerUI('Do you want to sign this transaction?')); - components.push( - signerUI({ - address, - chainId, - }), - ); - - // Display a message to indicate the signed transaction will include an account deployment - if (!accountDeployed) { - components.push(headerUI(`The account will be deployed`)); - } - - components.push(dividerUI()); - components.push( - rowUI({ - label: `Estimated Gas Fee (${feeToken})`, - value: convert(maxFee, 'wei', 'ether'), - }), - ); - - components.push(dividerUI()); - components.push( - networkUI({ - networkName: chainName, - }), - ); - - // Iterate over each call in the calls array - for (const call of callsArray) { - const { contractAddress, calldata, entrypoint } = call; - components.push(dividerUI()); - components.push( - addressUI({ - label: 'Contract', - address: contractAddress, - chainId, - }), - ); - - components.push( - jsonDataUI({ - label: 'Call Data', - data: calldata, - }), - ); - - // If the contract is an ERC20 token and the function is 'transfer', display sender, recipient, and amount - const token = await this.tokenStateManager.getToken({ - address: contractAddress, - chainId, - }); - - if (token && entrypoint === 'transfer' && calldata) { - try { - const senderAddress = address; - const recipientAddress = calldata[0]; // Assuming the first element in calldata is the recipient - let amount = ''; - - if ([3, 6, 9, 12, 15, 18].includes(token.decimals)) { - amount = convert(calldata[1], -1 * token.decimals, 'ether'); - } else { - amount = ( - Number(calldata[1]) * Math.pow(10, -1 * token.decimals) - ).toFixed(token.decimals); - } - components.push(dividerUI()); - components.push( - addressUI({ - label: 'Sender Address', - address: senderAddress, - chainId, - }), - dividerUI(), - addressUI({ - label: 'Recipient Address', - address: recipientAddress, - chainId, - }), - dividerUI(), - rowUI({ - label: `Amount (${token.symbol})`, - value: amount, - }), - ); - } catch (error) { - logger.warn( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `error found in amount conversion: ${error}`, - ); - } - } - } - - return await confirmDialog(components); - } - protected createDeployTxn( address: string, transactionHash: string, diff --git a/packages/starknet-snap/src/state/request-state-manager.ts b/packages/starknet-snap/src/state/request-state-manager.ts index 1baa0a37..e5c851cc 100644 --- a/packages/starknet-snap/src/state/request-state-manager.ts +++ b/packages/starknet-snap/src/state/request-state-manager.ts @@ -31,7 +31,8 @@ export class TransactionRequestStateManager extends StateManager['resourceBounds']; + export type TransactionRequest = { id: string; interfaceId: string; - type: string; + type: StarknetTransactionType; signer: string; + addressIndex: number; chainId: string; + networkName: string; maxFee: string; - calls: { - contractAddress: string; - calldata: RawCalldata; - entrypoint: string; - }[]; - feeToken: string; + calls: FormattedCallData[]; + resourceBounds: ResourceBounds[]; + selectedFeeToken: string; + includeDeploy: boolean; }; export type AccContract = { diff --git a/packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx b/packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx new file mode 100644 index 00000000..bbcb6c8b --- /dev/null +++ b/packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx @@ -0,0 +1,140 @@ +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; +import { + Box, + Container, + Section, + Text, + Icon, + Divider, +} from '@metamask/snaps-sdk/jsx'; + +import type { FeeToken } from '../../types/snapApi'; +import type { FormattedCallData } from '../../types/snapState'; +import { DEFAULT_DECIMAL_PLACES } from '../../utils/constants'; +import { AddressUI, JsonDataUI, NetworkUI, SignerUI } from '../fragments'; +import { Amount } from '../fragments/Amount'; +import { FeeTokenSelector } from '../fragments/FeeTokenSelector'; +import { accumulateTotals } from '../utils'; + +/** + * The form errors. + * + * @property to - The error for the receiving address. + * @property amount - The error for the amount. + * @property fees - The error for the fees. + */ +export type ExecuteTxnUIErrors = { + fees?: string; +}; + +export type ExecuteTxnUIProps = { + signer: string; + chainId: string; + networkName: string; + maxFee: string; + calls: FormattedCallData[]; + selectedFeeToken: string; + includeDeploy: boolean; + errors?: ExecuteTxnUIErrors; +}; + +/** + * A component for executing transactions, providing details and options to configure the transaction. + * + * @param props - The component props. + * @param props.signer - The signer for the transaction. + * @param props.chainId - The ID of the chain for the transaction. + * @param props.networkName - The ID of the chain for the transaction. + * @param props.maxFee - The maximum fee allowed for the transaction. + * @param props.calls - The calls involved in the transaction. + * @param props.selectedFeeToken - The token used for fees. + * @param props.includeDeploy - Whether to include account deployment in the transaction. + * @param [props.errors] - The object contains the error message for fee token selection. + * @returns The ExecuteTxnUI component. + */ +export const ExecuteTxnUI: SnapComponent = ({ + signer, + chainId, + networkName, + maxFee, + calls, + selectedFeeToken, + includeDeploy, + errors, +}) => { + // Calculate the totals using the helper + const tokenTotals = accumulateTotals(calls, maxFee, selectedFeeToken); + + return ( + + +
+ + +
+ + {/* Loop through each call and render based on `tokenTransferData` */} + {calls.map((call) => ( +
+ + {call.tokenTransferData ? ( +
+ + +
+ ) : ( + + )} +
+ ))} + +
+ + + + + + {Object.entries(tokenTotals).map( + ([tokenSymbol, { amount, decimals }]) => ( + + ), + )} + + {includeDeploy ? : null} + {includeDeploy ? ( + + + The account will be deployed with this transaction + + ) : null} +
+
+
+ ); +}; diff --git a/packages/starknet-snap/src/ui/components/index.tsx b/packages/starknet-snap/src/ui/components/index.tsx new file mode 100644 index 00000000..3d1f64d3 --- /dev/null +++ b/packages/starknet-snap/src/ui/components/index.tsx @@ -0,0 +1 @@ +export * from './ExecuteTxnUI'; diff --git a/packages/starknet-snap/src/ui/controllers/user-input-event-controller.ts b/packages/starknet-snap/src/ui/controllers/user-input-event-controller.ts new file mode 100644 index 00000000..631ab5e4 --- /dev/null +++ b/packages/starknet-snap/src/ui/controllers/user-input-event-controller.ts @@ -0,0 +1,203 @@ +import type { + InputChangeEvent, + InterfaceContext, + UserInputEvent, +} from '@metamask/snaps-sdk'; +import { UserInputEventType } from '@metamask/snaps-sdk'; +import { constants, ec, num as numUtils, TransactionType } from 'starknet'; + +import { NetworkStateManager } from '../../state/network-state-manager'; +import { TransactionRequestStateManager } from '../../state/request-state-manager'; +import { TokenStateManager } from '../../state/token-state-manager'; +import { FeeToken } from '../../types/snapApi'; +import type { Network, TransactionRequest } from '../../types/snapState'; +import { getBip44Deriver, logger } from '../../utils'; +import { InsufficientFundsError } from '../../utils/exceptions'; +import { getAddressKey } from '../../utils/keyPair'; +import { getEstimatedFees } from '../../utils/starknetUtils'; +import { + hasSufficientFundsForFee, + renderLoading, + updateExecuteTxnFlow, +} from '../utils'; + +const FeeTokenSelectorEventKey = { + FeeTokenChange: `feeTokenSelector_${UserInputEventType.InputChangeEvent}`, +} as const; + +type FeeTokenSelectorEventKey = + (typeof FeeTokenSelectorEventKey)[keyof typeof FeeTokenSelectorEventKey]; + +export class UserInputEventController { + context: InterfaceContext | null; + + event: UserInputEvent; + + eventId: string; + + reqStateMgr: TransactionRequestStateManager; + + networkStateMgr: NetworkStateManager; + + tokenStateMgr: TokenStateManager; + + constructor( + eventId: string, + event: UserInputEvent, + context: InterfaceContext | null, + ) { + this.event = event; + this.context = context; + this.eventId = eventId; + this.reqStateMgr = new TransactionRequestStateManager(); + this.networkStateMgr = new NetworkStateManager(); + this.tokenStateMgr = new TokenStateManager(); + } + + async handleEvent() { + try { + const request = this.context?.request as TransactionRequest; + + if ( + !(await this.reqStateMgr.getTransactionRequest({ + requestId: request.id, + })) + ) { + throw new Error('Transaction request not found'); + } + + await renderLoading(this.eventId); + + const eventKey = `${this.event.name ?? ''}_${this.event.type}`; + + switch (eventKey) { + case FeeTokenSelectorEventKey.FeeTokenChange: + await this.handleFeeTokenChange(); + break; + default: + break; + } + } catch (error) { + logger.error('onUserInput error:', error); + throw error; + } + } + + protected async deriveAccount(index: number) { + const deriver = await getBip44Deriver(); + const { addressKey } = await getAddressKey(deriver, index); + const publicKey = ec.starkCurve.getStarkKey(addressKey); + const privateKey = numUtils.toHex(addressKey); + return { + publicKey, + privateKey, + }; + } + + protected feeTokenToTransactionVersion(feeToken: FeeToken) { + return feeToken === FeeToken.STRK + ? constants.TRANSACTION_VERSION.V3 + : undefined; + } + + protected async getNetwork(chainId: string): Promise { + const network = await this.networkStateMgr.getNetwork({ chainId }); + + if (!network) { + throw new Error('Network not found'); + } + + return network; + } + + protected async getTokenAddress( + chainId: string, + feeToken: FeeToken, + ): Promise { + const token = + feeToken === FeeToken.STRK + ? await this.tokenStateMgr.getStrkToken({ + chainId, + }) + : await this.tokenStateMgr.getEthToken({ + chainId, + }); + + if (!token) { + throw new Error('Token not found'); + } + + return token.address; + } + + protected async handleFeeTokenChange() { + const request = this.context?.request as TransactionRequest; + const { addressIndex, calls, signer, chainId } = request; + const feeToken = (this.event as InputChangeEvent) + .value as unknown as FeeToken; + + try { + const network = await this.getNetwork(chainId); + + const { publicKey, privateKey } = await this.deriveAccount(addressIndex); + + const requestTxnVersion = this.feeTokenToTransactionVersion(feeToken); + + const { includeDeploy, suggestedMaxFee, estimateResults } = + await getEstimatedFees( + network, + signer, + privateKey, + publicKey, + [ + { + type: TransactionType.INVOKE, + payload: calls.map((call) => ({ + calldata: call.calldata, + contractAddress: call.contractAddress, + entrypoint: call.entrypoint, + })), + }, + ], + { + version: requestTxnVersion, + }, + ); + + if ( + !(await hasSufficientFundsForFee({ + address: signer, + network, + calls, + feeTokenAddress: await this.getTokenAddress( + network.chainId, + feeToken, + ), + suggestedMaxFee, + })) + ) { + throw new InsufficientFundsError(); + } + + request.maxFee = suggestedMaxFee; + request.selectedFeeToken = feeToken; + request.includeDeploy = includeDeploy; + request.resourceBounds = estimateResults.map( + (result) => result.resourceBounds, + ); + + await updateExecuteTxnFlow(this.eventId, request); + await this.reqStateMgr.upsertTransactionRequest(request); + } catch (error) { + const errorMessage = + error instanceof InsufficientFundsError + ? `Not enough ${feeToken} to pay for fee` + : 'Fail to calculate the fees'; + + // On failure, display ExecuteTxnUI with an error message + await updateExecuteTxnFlow(this.eventId, request, { + errors: { fees: errorMessage }, + }); + } + } +} diff --git a/packages/starknet-snap/src/ui/fragments/AddressUI.tsx b/packages/starknet-snap/src/ui/fragments/AddressUI.tsx new file mode 100644 index 00000000..23229624 --- /dev/null +++ b/packages/starknet-snap/src/ui/fragments/AddressUI.tsx @@ -0,0 +1,39 @@ +import { Link, Row, Text, type SnapComponent } from '@metamask/snaps-sdk/jsx'; + +import { getExplorerUrl, shortenAddress } from '../../utils'; + +export type AddressUIProps = { + label: string; + address: string; + chainId?: string; + shortern?: boolean; +}; + +/** + * Builds a row component with the address, displaying an icon if provided, otherwise showing the address as text or a link. + * If both `svgIcon` and `chainId` are provided, the icon is wrapped in a link. + * + * @param params - The parameters. + * @param params.label - The label. + * @param params.address - The address. + * @param [params.chainId] - The chain ID; if set, an explorer URL link will be generated. + * @param [params.shortern] - Whether to shorten the address. Default is true. + * @returns A row component with the address or icon. + */ +export const AddressUI: SnapComponent = ({ + label, + address, + chainId, + shortern = true, +}: AddressUIProps) => { + const displayValue = shortern ? shortenAddress(address) : address; + return ( + + {chainId ? ( + {displayValue} + ) : ( + {displayValue} + )} + + ); +}; diff --git a/packages/starknet-snap/src/ui/fragments/Amount.tsx b/packages/starknet-snap/src/ui/fragments/Amount.tsx new file mode 100644 index 00000000..c051a5c9 --- /dev/null +++ b/packages/starknet-snap/src/ui/fragments/Amount.tsx @@ -0,0 +1,29 @@ +import { Row, Text, type SnapComponent } from '@metamask/snaps-sdk/jsx'; +import { formatUnits } from 'ethers/lib/utils'; + +export type AmountProps = { + label: string; + amount: string; // Bigint representatio + decimals: number; + symbol: string; +}; +/** + * Build a row component with the JSON data. + * + * @param params - The parameters. + * @param params.label - The label. + * @param params.amount + * @param params.decimals + * @param params.symbol + * @returns A row component with the JSON data. + */ +export const Amount: SnapComponent = ({ + label, + amount, + decimals, + symbol, +}: AmountProps) => ( + + {`${formatUnits(amount, decimals)} ${symbol}`} + +); diff --git a/packages/starknet-snap/src/ui/fragments/FeeTokenSelector.tsx b/packages/starknet-snap/src/ui/fragments/FeeTokenSelector.tsx new file mode 100644 index 00000000..c8aca08f --- /dev/null +++ b/packages/starknet-snap/src/ui/fragments/FeeTokenSelector.tsx @@ -0,0 +1,52 @@ +import { + Card, + Field, + Form, + Selector, + SelectorOption, + type SnapComponent, +} from '@metamask/snaps-sdk/jsx'; + +import { FeeToken, FeeTokenUnit } from '../../types/snapApi'; + +/** + * The props for the {@link FeeTokenSelector} component. + * + * @property selectedToken - The currently selected fee token. + */ +export type FeeTokenSelectorProps = { + selectedToken: FeeToken; + error?: string; +}; + +/** + * A component that allows the user to select the fee token. + * + * @param props - The component props. + * @param props.selectedToken - The currently selected fee token. + * @param [props.error] - The error message for fee token selection. + * @returns The FeeTokenSelector component. + */ +export const FeeTokenSelector: SnapComponent = ({ + selectedToken, + error, +}) => { + return ( +
+ + + + + + + + + + +
+ ); +}; diff --git a/packages/starknet-snap/src/ui/fragments/JsonDataUI.tsx b/packages/starknet-snap/src/ui/fragments/JsonDataUI.tsx new file mode 100644 index 00000000..61d47f8b --- /dev/null +++ b/packages/starknet-snap/src/ui/fragments/JsonDataUI.tsx @@ -0,0 +1,24 @@ +import { Row, Text, type SnapComponent } from '@metamask/snaps-sdk/jsx'; + +import { toJson } from '../../utils'; + +export type JsonDataUIProps = { + label: string; + data: any; +}; +/** + * Build a row component with the JSON data. + * + * @param params - The parameters. + * @param params.data - The JSON data. + * @param params.label - The label. + * @returns A row component with the JSON data. + */ +export const JsonDataUI: SnapComponent = ({ + label, + data, +}: JsonDataUIProps) => ( + + {toJson(data)} + +); diff --git a/packages/starknet-snap/src/ui/fragments/LoadingUI.tsx b/packages/starknet-snap/src/ui/fragments/LoadingUI.tsx new file mode 100644 index 00000000..7fd1fdd2 --- /dev/null +++ b/packages/starknet-snap/src/ui/fragments/LoadingUI.tsx @@ -0,0 +1,20 @@ +import { + Heading, + Box, + Spinner, + type SnapComponent, +} from '@metamask/snaps-sdk/jsx'; + +/** + * Builds a loading UI component. + * + * @returns A loading component. + */ +export const LoadingUI: SnapComponent = () => { + return ( + + please wait... + + + ); +}; diff --git a/packages/starknet-snap/src/ui/fragments/NetworkUI.tsx b/packages/starknet-snap/src/ui/fragments/NetworkUI.tsx new file mode 100644 index 00000000..088b27ca --- /dev/null +++ b/packages/starknet-snap/src/ui/fragments/NetworkUI.tsx @@ -0,0 +1,21 @@ +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; +import { Row, Text } from '@metamask/snaps-sdk/jsx'; + +export type NetworkUIProps = { + networkName: string; +}; + +/** + * Build a row component with the network name. + * + * @param params - The parameters. + * @param params.networkName - The network name. + * @returns A row component with the network name. + */ +export const NetworkUI: SnapComponent = ({ + networkName, +}: NetworkUIProps) => ( + + {networkName} + +); diff --git a/packages/starknet-snap/src/ui/fragments/SignerUI.tsx b/packages/starknet-snap/src/ui/fragments/SignerUI.tsx new file mode 100644 index 00000000..d55a7c6c --- /dev/null +++ b/packages/starknet-snap/src/ui/fragments/SignerUI.tsx @@ -0,0 +1,28 @@ +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; + +import { AddressUI } from './AddressUI'; + +export type SignerUIProps = { + address: string; + chainId: string; +}; + +/** + * Build a row component with the signer address. + * + * @param params - The parameters. + * @param params.address - The signer address. + * @param params.chainId - The chain ID. + * @returns A row component with the signer address. + */ +export const SignerUI: SnapComponent = ({ + address, + chainId, +}: SignerUIProps) => ( + +); diff --git a/packages/starknet-snap/src/ui/fragments/index.tsx b/packages/starknet-snap/src/ui/fragments/index.tsx new file mode 100644 index 00000000..c6100fdb --- /dev/null +++ b/packages/starknet-snap/src/ui/fragments/index.tsx @@ -0,0 +1,4 @@ +export * from './JsonDataUI'; +export * from './AddressUI'; +export * from './NetworkUI'; +export * from './SignerUI'; diff --git a/packages/starknet-snap/src/ui/types.ts b/packages/starknet-snap/src/ui/types.ts new file mode 100644 index 00000000..9f4d42a5 --- /dev/null +++ b/packages/starknet-snap/src/ui/types.ts @@ -0,0 +1,7 @@ +export type TokenTotals = Record< + string, + { + amount: bigint; // Use BigInt for precise calculations + decimals: number; + } +>; diff --git a/packages/starknet-snap/src/ui/utils.test.tsx b/packages/starknet-snap/src/ui/utils.test.tsx new file mode 100644 index 00000000..f0b98500 --- /dev/null +++ b/packages/starknet-snap/src/ui/utils.test.tsx @@ -0,0 +1,241 @@ +import type { constants } from 'starknet'; + +import { generateAccounts } from '../__tests__/helper'; +import type { Erc20Token, FormattedCallData } from '../types/snapState'; +import { + DEFAULT_DECIMAL_PLACES, + BlockIdentifierEnum, + ETHER_MAINNET, + ETHER_SEPOLIA_TESTNET, + STARKNET_SEPOLIA_TESTNET_NETWORK, + USDC_SEPOLIA_TESTNET, +} from '../utils/constants'; +import * as starknetUtils from '../utils/starknetUtils'; +import { accumulateTotals, hasSufficientFundsForFee } from './utils'; + +describe('accumulateTotals', () => { + const mockCalls = (overrides = [{}]) => + [ + { + tokenTransferData: { + amount: '1000000000000000000', // 1 ETH as string BigInt + symbol: 'ETH', + decimals: 18, + ...overrides[0], + }, + }, + { + tokenTransferData: { + amount: '500000000000000000', // 0.5 ETH as string BigInt + symbol: 'ETH', + decimals: 18, + ...overrides[1], + }, + }, + { + tokenTransferData: { + amount: '2000000000000000000', // 2 STRK as string BigInt + symbol: 'STRK', + decimals: 18, + ...overrides[2], + }, + }, + ] as FormattedCallData[]; + + const mockMaxFee = '100000000000000000'; // 0.1 token fee + + it.each([ + { + selectedFeeToken: 'ETH', + expectedResult: { + ETH: { + amount: BigInt('1600000000000000000'), // 1 + 0.5 + 0.1 ETH + decimals: 18, + }, + STRK: { + amount: BigInt('2000000000000000000'), // 2 STRK + decimals: 18, + }, + }, + }, + { + selectedFeeToken: 'STRK', + expectedResult: { + ETH: { + amount: BigInt('1500000000000000000'), // 1 + 0.5 ETH + decimals: 18, + }, + STRK: { + amount: BigInt('2100000000000000000'), // 2 + 0.1 STRK + decimals: 18, + }, + }, + }, + ])( + 'sums up transfer amounts for $selectedFeeToken', + ({ selectedFeeToken, expectedResult }) => { + const calls = mockCalls(); + + const result = accumulateTotals(calls, mockMaxFee, selectedFeeToken); + + expect(result).toStrictEqual(expectedResult); + }, + ); + + it('creates a new token entry if the fee token was not part of calls', () => { + const calls = mockCalls(); + const selectedFeeToken = 'STRK'; + + const result = accumulateTotals(calls, mockMaxFee, selectedFeeToken); + + expect(result).toStrictEqual({ + ETH: { + amount: BigInt('1500000000000000000'), // 1 + 0.5 ETH + decimals: 18, + }, + STRK: { + amount: BigInt('2100000000000000000'), // 2 + 0.1 STRK + decimals: 18, + }, + }); + }); + + it('handles no calls gracefully', () => { + const calls = []; + const selectedFeeToken = 'ETH'; + + const result = accumulateTotals(calls, mockMaxFee, selectedFeeToken); + + expect(result).toStrictEqual({ + ETH: { + amount: BigInt('100000000000000000'), // 0.1 ETH (fee only) + decimals: DEFAULT_DECIMAL_PLACES, + }, + }); + }); +}); + +describe('hasSufficientFundsForFee', () => { + const prepareSpy = () => { + const getBalanceSpy = jest.spyOn(starknetUtils, 'getBalance'); + return { getBalanceSpy }; + }; + + const generateFormattedCallData = ( + cnt: number, + { + token = ETHER_MAINNET, + amount = '1000', + senderAddress = '', + recipientAddress = '', + }: { + token?: Erc20Token; + amount?: string; + senderAddress?: string; + recipientAddress?: string; + }, + ): FormattedCallData[] => { + const calls: FormattedCallData[] = []; + for (let i = 0; i < cnt; i++) { + calls.push({ + entrypoint: 'transfer', + contractAddress: token.address, + tokenTransferData: { + amount, + senderAddress, + recipientAddress, + decimals: token.decimals, + symbol: token.symbol, + }, + }); + } + return calls; + }; + + const prepareExecution = async ({ + calls, + maxFee = '1000', + feeToken = ETHER_SEPOLIA_TESTNET, + }: { + calls: FormattedCallData[]; + maxFee?: string; + feeToken?: Erc20Token; + }) => { + const network = STARKNET_SEPOLIA_TESTNET_NETWORK; + const [{ address }] = await generateAccounts( + network.chainId as unknown as constants.StarknetChainId, + 1, + ); + + return { + feeTokenAddress: feeToken.address, + suggestedMaxFee: maxFee, + network, + address, + calls, + }; + }; + + it.each([ + { + calls: generateFormattedCallData(1, { + amount: '1500', + token: ETHER_SEPOLIA_TESTNET, + }), + feeToken: ETHER_SEPOLIA_TESTNET, + tokenInCalls: ETHER_SEPOLIA_TESTNET, + }, + { + calls: generateFormattedCallData(1, { + amount: '1500', + token: USDC_SEPOLIA_TESTNET, + }), + feeToken: ETHER_SEPOLIA_TESTNET, + tokenInCalls: USDC_SEPOLIA_TESTNET, + }, + { + calls: [], + feeToken: ETHER_SEPOLIA_TESTNET, + tokenInCalls: USDC_SEPOLIA_TESTNET, + }, + ])( + 'returns true if the fee token balance covers both the calls and fee - feeToken: $feeToken.name, callData length: $calls.length, tokenInCalls: $tokenInCalls.name', + async ({ calls, feeToken }) => { + const { getBalanceSpy } = prepareSpy(); + + getBalanceSpy.mockResolvedValueOnce('3000'); // Mock fee token balance + + const args = await prepareExecution({ + calls, + feeToken, + }); + + const result = await hasSufficientFundsForFee(args); + + expect(result).toBe(true); + expect(getBalanceSpy).toHaveBeenCalledWith( + args.address, + args.feeTokenAddress, + args.network, + BlockIdentifierEnum.Pending, + ); + }, + ); + + it.each(['2000', '0'])( + 'returns false when balance for fee token is insufficient - balance: %s', + async (balance) => { + const { getBalanceSpy } = prepareSpy(); + + getBalanceSpy.mockResolvedValueOnce(balance); // Mock fee token balance + + const args = await prepareExecution({ + calls: generateFormattedCallData(1, { amount: '1500' }), + }); + + const result = await hasSufficientFundsForFee(args); + + expect(result).toBe(false); + }, + ); +}); diff --git a/packages/starknet-snap/src/ui/utils.tsx b/packages/starknet-snap/src/ui/utils.tsx new file mode 100644 index 00000000..2ee82ae2 --- /dev/null +++ b/packages/starknet-snap/src/ui/utils.tsx @@ -0,0 +1,210 @@ +import type { + FormattedCallData, + Network, + TransactionRequest, +} from '../types/snapState'; +import { + BlockIdentifierEnum, + DEFAULT_DECIMAL_PLACES, +} from '../utils/constants'; +import { getBalance } from '../utils/starknetUtils'; +import type { ExecuteTxnUIErrors } from './components'; +import { ExecuteTxnUI } from './components'; +import { LoadingUI } from './fragments/LoadingUI'; +import type { TokenTotals } from './types'; + +/** + * Accumulate the total amount for all tokens involved in calls and fees. + * + * @param calls - The array of FormattedCallData object. + * @param maxFee - The maximum fee. + * @param selectedFeeToken - The selected token symbol for fees. + * @returns The accumulated totals for each token. + */ +export const accumulateTotals = ( + calls: FormattedCallData[], + maxFee: string, + selectedFeeToken: string, +): TokenTotals => { + return calls.reduce( + (acc, call) => { + if (call.tokenTransferData) { + const amount = BigInt(call.tokenTransferData.amount); // Convert to BigInt + if (!acc[call.tokenTransferData.symbol]) { + acc[call.tokenTransferData.symbol] = { + amount: BigInt(0), + decimals: call.tokenTransferData.decimals, + }; + } + acc[call.tokenTransferData.symbol].amount += amount; + } + return acc; + }, + { + // We derive decimals based on the fee token. Currently, both supported fee tokens, ETH and STRK, use the standard 18 decimals. + // Therefore, we use DEFAULT_DECIMAL_PLACES set to 18 here. If additional fee tokens with different decimals are introduced, + // this logic should be updated to handle token-specific decimals dynamically. + [selectedFeeToken]: { + amount: BigInt(maxFee), + decimals: DEFAULT_DECIMAL_PLACES, + }, + }, + ); +}; + +/** + * Generate the interface for a ExecuteTxnUI. + * + * @param request - The `TransactionRequest` object. + * @returns A Promise that resolves to the interface ID generated by the Snap request. + * The ID can be used for tracking or referencing the created interface. + */ +export async function generateExecuteTxnFlow( + request: TransactionRequest, // Request must match props and include an `id` +) { + const { + signer, + chainId, + networkName, + maxFee, + calls, + selectedFeeToken, + includeDeploy, + } = request; + + return await snap.request({ + method: 'snap_createInterface', + params: { + ui: ( + + ), + context: { + request, + }, + }, + }); +} + +/** + * Update the interface for the ExecuteTxnUI. + * + * @param id - The Interface Id to update. + * @param request - The `TransactionRequest` object. + * @param [errors] - Optional partial props for error handling or overrides. + * @param [errors.errors] - The error object for the ExecuteTxnUI. + */ +export async function updateExecuteTxnFlow( + id: string, + request: TransactionRequest, + errors?: { errors: ExecuteTxnUIErrors }, +) { + const { + signer, + chainId, + networkName, + maxFee, + calls, + selectedFeeToken, + includeDeploy, + } = request; + + await snap.request({ + method: 'snap_updateInterface', + params: { + id, + ui: ( + + ), + }, + }); +} + +/** + * Update the interface with the provided JSX. + * + * @param id - The Interface Id to update. + * @param ui - The JSX element to update the interface with. + */ +export async function updateInterface( + id: string, + ui: JSX.Element, +): Promise { + await snap.request({ + method: 'snap_updateInterface', + params: { + id, + ui, + }, + }); +} + +/** + * Render a loading interface. + * + * @param id - The Interface Id to update. + */ +export async function renderLoading(id: string): Promise { + await updateInterface(id, ); +} + +/** + * Verify if the fee token balance covers both the calls and fee. + * + * @param params - The parameters for the function. + * @param params.feeTokenAddress - The address of the fee token. + * @param params.suggestedMaxFee - The suggested maximum fee. + * @param params.network - The `Network` object. + * @param params.address - The address to check the balance for. + * @param params.calls - The array of `FormattedCallData` objects. + * @returns A Promise that resolves to a boolean indicating if the balance is sufficient. + */ +export async function hasSufficientFundsForFee({ + feeTokenAddress, + suggestedMaxFee, + network, + address, + calls, +}: { + feeTokenAddress: string; + suggestedMaxFee: string; + network: Network; + address: string; + calls: FormattedCallData[]; +}) { + const balanceForFeeToken = BigInt( + await getBalance( + address, + feeTokenAddress, + network, + BlockIdentifierEnum.Pending, + ), + ); + + // Calculate total STRK or ETH amounts from `calls` + const totalSpendForFeeToken = calls.reduce((acc, call) => { + const { tokenTransferData, contractAddress } = call; + if (tokenTransferData && contractAddress === feeTokenAddress) { + return acc + BigInt(tokenTransferData.amount); // Return the updated accumulator + } + return acc; // Return the current accumulator if the condition is not met + }, BigInt(suggestedMaxFee)); // Initial value + + return totalSpendForFeeToken <= balanceForFeeToken; +} diff --git a/packages/starknet-snap/src/utils/__mocks__/snap.ts b/packages/starknet-snap/src/utils/__mocks__/snap.ts index 75f5e80b..8f0328e9 100644 --- a/packages/starknet-snap/src/utils/__mocks__/snap.ts +++ b/packages/starknet-snap/src/utils/__mocks__/snap.ts @@ -4,6 +4,8 @@ export const getBip44Deriver = jest.fn(); export const confirmDialog = jest.fn(); +export const createInteractiveConfirmDialog = jest.fn(); + export const alertDialog = jest.fn(); export const getStateData = jest.fn(); diff --git a/packages/starknet-snap/src/utils/exceptions.ts b/packages/starknet-snap/src/utils/exceptions.ts index c4c8c59a..840a4e3a 100644 --- a/packages/starknet-snap/src/utils/exceptions.ts +++ b/packages/starknet-snap/src/utils/exceptions.ts @@ -72,3 +72,9 @@ export class TokenIsPreloadedError extends SnapError { ); } } + +export class InsufficientFundsError extends SnapError { + constructor(message?: string) { + super(message ?? 'Insufficient Funds'); + } +} diff --git a/packages/starknet-snap/src/utils/formatter-utils.test.ts b/packages/starknet-snap/src/utils/formatter-utils.test.ts index 4e6dc850..35301df3 100644 --- a/packages/starknet-snap/src/utils/formatter-utils.test.ts +++ b/packages/starknet-snap/src/utils/formatter-utils.test.ts @@ -1,4 +1,17 @@ -import { mapDeprecatedParams } from './formatter-utils'; +import { constants } from 'starknet'; + +import { singleCall } from '../__tests__/fixture/callsExamples.json'; +import { generateAccounts } from '../__tests__/helper'; +import { TokenStateManager } from '../state/token-state-manager'; +import type { Erc20Token } from '../types/snapState'; +import { ETHER_SEPOLIA_TESTNET } from './constants'; +import { + callToTransactionReqCall, + mapDeprecatedParams, +} from './formatter-utils'; +import { logger } from './logger'; + +jest.mock('./logger'); describe('mapDeprecatedParams', () => { it('maps deprecated parameters to their new equivalents', () => { @@ -65,3 +78,132 @@ describe('mapDeprecatedParams', () => { expect(requestParams).toStrictEqual(expected); }); }); + +describe('callToTransactionReqCall', () => { + const chainId = constants.StarknetChainId.SN_SEPOLIA; + + const mockGetToken = async (tokenData: Erc20Token | null) => { + const getTokenSpy = jest.spyOn(TokenStateManager.prototype, 'getToken'); + // Mock getToken method to return the provided tokenData + getTokenSpy.mockResolvedValue(tokenData); + + return { + getTokenSpy, + }; + }; + + const getSenderAndRecipient = async () => { + const [{ address }, { address: receipientAddress }] = + await generateAccounts(chainId, 2); + return { + senderAddress: address, + recipientAddress: receipientAddress, + }; + }; + + it('returns a formatted `call` object without `tokenTransferData` if no ERC20 transfer calldata is present.', async () => { + const call = singleCall.calls; + const { senderAddress } = await getSenderAndRecipient(); + + // The getToken method should not be called, so we prepare the spy with null + const { getTokenSpy } = await mockGetToken(null); + + const result = await callToTransactionReqCall( + call, + chainId, + senderAddress, + new TokenStateManager(), + ); + + expect(getTokenSpy).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ + contractAddress: call.contractAddress, + calldata: call.calldata, + entrypoint: call.entrypoint, + }); + }); + + it('returns a formatted `call` object without `tokenTransferData` if the Erc20Token can not be found.', async () => { + const { senderAddress, recipientAddress } = await getSenderAndRecipient(); + const call = { + ...singleCall.calls, + entrypoint: 'transfer', + calldata: [recipientAddress, '1000'], + }; + + // Simulate the case where the token can not be found + await mockGetToken(null); + + const result = await callToTransactionReqCall( + call, + chainId, + senderAddress, + new TokenStateManager(), + ); + + expect(result).toStrictEqual({ + contractAddress: call.contractAddress, + calldata: call.calldata, + entrypoint: call.entrypoint, + }); + }); + + it('returns a formatted `call` object without `tokenTransferData` if the calldata is not in the expected format', async () => { + const { senderAddress } = await getSenderAndRecipient(); + const call = { ...singleCall.calls, entrypoint: 'transfer', calldata: [] }; + const loggerSpy = jest.spyOn(logger, 'warn'); + + await mockGetToken(ETHER_SEPOLIA_TESTNET); + + const result = await callToTransactionReqCall( + call, + chainId, + senderAddress, + new TokenStateManager(), + ); + + expect(loggerSpy).toHaveBeenCalled(); + expect(result).toStrictEqual({ + contractAddress: call.contractAddress, + calldata: call.calldata, + entrypoint: call.entrypoint, + }); + }); + + it('returns a formatted `call` object with `tokenTransferData` if ERC20 transfer calldata is present', async () => { + const { senderAddress, recipientAddress } = await getSenderAndRecipient(); + const transferAmt = '1000'; + const call = { + ...singleCall.calls, + entrypoint: 'transfer', + calldata: [recipientAddress, transferAmt], + }; + const token = ETHER_SEPOLIA_TESTNET; + + const { getTokenSpy } = await mockGetToken(token); + + const result = await callToTransactionReqCall( + call, + chainId, + senderAddress, + new TokenStateManager(), + ); + + expect(getTokenSpy).toHaveBeenCalledWith({ + address: call.contractAddress, + chainId, + }); + expect(result).toStrictEqual({ + contractAddress: call.contractAddress, + calldata: call.calldata, + entrypoint: call.entrypoint, + tokenTransferData: { + senderAddress, + recipientAddress, + amount: transferAmt, + symbol: token.symbol, + decimals: token.decimals, + }, + }); + }); +}); diff --git a/packages/starknet-snap/src/utils/formatter-utils.ts b/packages/starknet-snap/src/utils/formatter-utils.ts index c91e1488..f031fa1f 100644 --- a/packages/starknet-snap/src/utils/formatter-utils.ts +++ b/packages/starknet-snap/src/utils/formatter-utils.ts @@ -1,3 +1,11 @@ +import type { Call } from 'starknet'; +import { assert } from 'superstruct'; + +import type { TokenStateManager } from '../state/token-state-manager'; +import type { FormattedCallData } from '../types/snapState'; +import { logger } from './logger'; +import { AddressStruct, NumberStringStruct } from './superstruct'; + export const hexToString = (hexStr) => { let str = ''; for (let i = 0; i < hexStr.length; i += 2) { @@ -37,3 +45,51 @@ export const mapDeprecatedParams = ( } }); }; + +export const callToTransactionReqCall = async ( + call: Call, + chainId: string, + address: string, + tokenStateManager: TokenStateManager, +): Promise => { + const { contractAddress, calldata, entrypoint } = call; + // Base data object for each call, with transfer fields left as optional + const formattedCall: FormattedCallData = { + contractAddress, + calldata: calldata as string[], + entrypoint, + }; + + // Check if the entrypoint is 'transfer' and the populate transfer fields + if (entrypoint === 'transfer' && calldata) { + try { + const token = await tokenStateManager.getToken({ + address: contractAddress, + chainId, + }); + + if (token) { + const senderAddress = address; + + // ensure the data is in correct format, + // if an error occur, it will catch and not to format it + assert(calldata[0], AddressStruct); + assert(calldata[1], NumberStringStruct); + const recipientAddress = calldata[0]; // Assuming calldata[0] is the recipient address + const amount = calldata[1]; + // Populate transfer-specific fields + formattedCall.tokenTransferData = { + senderAddress, + recipientAddress, + amount: typeof amount === 'number' ? amount.toString() : amount, + symbol: token.symbol, + decimals: token.decimals, + }; + } + } catch (error) { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + logger.warn(`Error in amount conversion: ${error.message}`); + } + } + return formattedCall; +}; diff --git a/packages/starknet-snap/src/utils/snap-ui.ts b/packages/starknet-snap/src/utils/snap-ui.ts index 0aaafacb..eb655ac0 100644 --- a/packages/starknet-snap/src/utils/snap-ui.ts +++ b/packages/starknet-snap/src/utils/snap-ui.ts @@ -1,9 +1,50 @@ -import { divider, heading, row, text } from '@metamask/snaps-sdk'; +import type { Component } from '@metamask/snaps-sdk'; +import { divider, heading, panel, row, text } from '@metamask/snaps-sdk'; import { getExplorerUrl } from './explorer'; import { toJson } from './serializer'; import { shortenAddress } from './string'; +export const updateRequiredMetaMaskComponent = () => { + return panel([ + text( + 'You need to update your MetaMask to latest version to use this snap.', + ), + ]); +}; + +/** + * Ensures that JSX support is available in the MetaMask environment by attempting to render a component within a snap dialog. + * If MetaMask does not support JSX, an alert message is shown prompting the user to update MetaMask. + * + * @param component - The JSX component to display in the snap dialog. + * + * The function performs the following steps: + * 1. Tries to render the provided component using a `snap_dialog` method. + * 2. On success, it updates the `requireMMUpgrade` flag in the snap's state to `false`, indicating that JSX is supported. + * 3. If an error occurs (likely due to outdated MetaMask), it displays an alert dialog prompting the user to update MetaMask. + */ +export const ensureJsxSupport = async (component: Component): Promise => { + try { + // Try rendering the JSX component to test compatibility + await snap.request({ + method: 'snap_dialog', + params: { + type: 'alert', + content: component, + }, + }); + } catch { + await snap.request({ + method: 'snap_dialog', + params: { + type: 'alert', + content: updateRequiredMetaMaskComponent(), + }, + }); + } +}; + /** * Build a row component. * diff --git a/packages/starknet-snap/src/utils/snap.test.ts b/packages/starknet-snap/src/utils/snap.test.ts index 8fa99749..dfbeb7ea 100644 --- a/packages/starknet-snap/src/utils/snap.test.ts +++ b/packages/starknet-snap/src/utils/snap.test.ts @@ -22,6 +22,23 @@ describe('getBip44Deriver', () => { }); }); +describe('createInteractiveConfirmDialog', () => { + it('calls snap_dialog', async () => { + const spy = jest.spyOn(snapUtil.getProvider(), 'request'); + const interfaceId = 'test'; + + await snapUtil.createInteractiveConfirmDialog(interfaceId); + + expect(spy).toHaveBeenCalledWith({ + method: 'snap_dialog', + params: { + type: 'confirmation', + id: interfaceId, + }, + }); + }); +}); + describe('confirmDialog', () => { it('calls snap_dialog', async () => { const spy = jest.spyOn(snapUtil.getProvider(), 'request'); diff --git a/packages/starknet-snap/src/utils/snap.ts b/packages/starknet-snap/src/utils/snap.ts index 49856f57..6d9bb14c 100644 --- a/packages/starknet-snap/src/utils/snap.ts +++ b/packages/starknet-snap/src/utils/snap.ts @@ -29,6 +29,24 @@ export async function getBip44Deriver(): Promise { return getBIP44AddressKeyDeriver(bip44Node); } +/** + * Displays a confirmation dialog with the specified interface id. + * + * @param interfaceId - A string representing the id of the interface. + * @returns A Promise that resolves to the result of the dialog. + */ +export async function createInteractiveConfirmDialog( + interfaceId: string, +): Promise { + return snap.request({ + method: 'snap_dialog', + params: { + type: DialogType.Confirmation, + id: interfaceId, + }, + }); +} + /** * Displays a confirmation dialog with the specified components. * diff --git a/packages/starknet-snap/tsconfig.json b/packages/starknet-snap/tsconfig.json index 0fbd5d41..fc70d5dd 100644 --- a/packages/starknet-snap/tsconfig.json +++ b/packages/starknet-snap/tsconfig.json @@ -2,11 +2,13 @@ "compilerOptions": { "target": "ES2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, "module": "commonjs" /* Specify what module code is generated. */, + "jsx": "react-jsx", + "jsxImportSource": "@metamask/snaps-sdk", "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */, "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, "skipLibCheck": true /* Skip type checking all .d.ts files. */, "resolveJsonModule": true /* lets us import JSON modules from within TypeScript modules. */, "strictNullChecks": true /* Enable strict null checks. */ }, - "include": ["**/*.ts"] + "include": ["**/*.ts", "**/*.tsx"] } diff --git a/yarn.lock b/yarn.lock index 1b021ddb..31e64ef0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2229,10 +2229,12 @@ __metadata: "@metamask/key-tree": 9.0.0 "@metamask/snaps-cli": ^6.2.1 "@metamask/snaps-jest": ^8.2.0 - "@metamask/snaps-sdk": ^4.0.0 + "@metamask/snaps-sdk": ^6.1.1 "@metamask/utils": ^9.1.0 "@types/chai": ^4.3.1 "@types/chai-as-promised": ^7.1.5 + "@types/react": 18.2.4 + "@types/react-dom": 18.2.4 "@types/sinon": ^10.0.11 "@types/sinon-chai": ^3.2.8 "@typescript-eslint/eslint-plugin": ^5.42.1 @@ -4269,6 +4271,17 @@ __metadata: languageName: node linkType: hard +"@metamask/json-rpc-engine@npm:^10.0.1": + version: 10.0.1 + resolution: "@metamask/json-rpc-engine@npm:10.0.1" + dependencies: + "@metamask/rpc-errors": ^7.0.1 + "@metamask/safe-event-emitter": ^3.0.0 + "@metamask/utils": ^10.0.0 + checksum: 277c68cf0036d62c9a1528e9d7e55e000233d02a55fb652edcc16b6149631346d34fe3fefaab13bc55377405e79293afdde5b6e3b61d49a2ce125ca50d7eafe1 + languageName: node + linkType: hard + "@metamask/json-rpc-engine@npm:^8.0.1, @metamask/json-rpc-engine@npm:^8.0.2": version: 8.0.2 resolution: "@metamask/json-rpc-engine@npm:8.0.2" @@ -4315,6 +4328,18 @@ __metadata: languageName: node linkType: hard +"@metamask/json-rpc-middleware-stream@npm:^8.0.5": + version: 8.0.5 + resolution: "@metamask/json-rpc-middleware-stream@npm:8.0.5" + dependencies: + "@metamask/json-rpc-engine": ^10.0.1 + "@metamask/safe-event-emitter": ^3.0.0 + "@metamask/utils": ^10.0.0 + readable-stream: ^3.6.2 + checksum: 4ac3d537bad1ab039bb1b42fb35113fe9a98bd89339155a0f759a086b957e5717ea1e75bdd340defd2b25f5886e07ab130235a63a1b8e627f8cb32a3020622c9 + languageName: node + linkType: hard + "@metamask/key-tree@npm:9.0.0": version: 9.0.0 resolution: "@metamask/key-tree@npm:9.0.0" @@ -4342,6 +4367,19 @@ __metadata: languageName: node linkType: hard +"@metamask/key-tree@npm:^9.1.2": + version: 9.1.2 + resolution: "@metamask/key-tree@npm:9.1.2" + dependencies: + "@metamask/scure-bip39": ^2.1.1 + "@metamask/utils": ^9.0.0 + "@noble/curves": ^1.2.0 + "@noble/hashes": ^1.3.2 + "@scure/base": ^1.0.0 + checksum: eb60bdbfa1806c2f248bf2602cd242e21b0fbe8bbb00ec97c3891739956a81e26c0dae125282a6207dbbe0643e727ff3574067b48210a0b01f12aae7b3159b77 + languageName: node + linkType: hard + "@metamask/number-to-bn@npm:^1.7.1": version: 1.7.1 resolution: "@metamask/number-to-bn@npm:1.7.1" @@ -4445,6 +4483,27 @@ __metadata: languageName: node linkType: hard +"@metamask/providers@npm:^18.1.1": + version: 18.1.1 + resolution: "@metamask/providers@npm:18.1.1" + dependencies: + "@metamask/json-rpc-engine": ^10.0.1 + "@metamask/json-rpc-middleware-stream": ^8.0.5 + "@metamask/object-multiplex": ^2.0.0 + "@metamask/rpc-errors": ^7.0.1 + "@metamask/safe-event-emitter": ^3.1.1 + "@metamask/utils": ^10.0.0 + detect-browser: ^5.2.0 + extension-port-stream: ^4.1.0 + fast-deep-equal: ^3.1.3 + is-stream: ^2.0.0 + readable-stream: ^3.6.2 + peerDependencies: + webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 + checksum: ca28bab03d7b67fd1e4fccf28045bd465a961c946b1f3e2464d6201730ec8c50970eb4a48d373bd3a7ac0bda471da604b71aaf5f22eae3c342a82e5b07134e91 + languageName: node + linkType: hard + "@metamask/rpc-errors@npm:^6.0.0, @metamask/rpc-errors@npm:^6.2.1": version: 6.3.0 resolution: "@metamask/rpc-errors@npm:6.3.0" @@ -4455,6 +4514,16 @@ __metadata: languageName: node linkType: hard +"@metamask/rpc-errors@npm:^7.0.1": + version: 7.0.1 + resolution: "@metamask/rpc-errors@npm:7.0.1" + dependencies: + "@metamask/utils": ^10.0.0 + fast-safe-stringify: ^2.0.6 + checksum: 20b300d26550c667a635eb5f97784c80d86c0b765433a32a9bced5b4c2a05a783cf2cd3a2bfe2aca6382181f53458bd2e7dc1bbb02e28005d3b4d0f3a46ca3ac + languageName: node + linkType: hard + "@metamask/safe-event-emitter@npm:^3.0.0, @metamask/safe-event-emitter@npm:^3.1.1": version: 3.1.1 resolution: "@metamask/safe-event-emitter@npm:3.1.1" @@ -4668,20 +4737,6 @@ __metadata: languageName: node linkType: hard -"@metamask/snaps-sdk@npm:^4.0.0": - version: 4.4.2 - resolution: "@metamask/snaps-sdk@npm:4.4.2" - dependencies: - "@metamask/key-tree": ^9.1.1 - "@metamask/providers": ^17.0.0 - "@metamask/rpc-errors": ^6.2.1 - "@metamask/utils": ^8.3.0 - fast-xml-parser: ^4.3.4 - superstruct: ^1.0.3 - checksum: 2ff3949cee3b6c5a580304a02191f3ec7fb049460c2ff89b1731f24b215baf5f9c08834a0b2b703ff43e3b74ede387386e22a96810b50be106bb029b180c44ce - languageName: node - linkType: hard - "@metamask/snaps-sdk@npm:^6.0.0": version: 6.0.0 resolution: "@metamask/snaps-sdk@npm:6.0.0" @@ -4695,6 +4750,19 @@ __metadata: languageName: node linkType: hard +"@metamask/snaps-sdk@npm:^6.1.1": + version: 6.10.0 + resolution: "@metamask/snaps-sdk@npm:6.10.0" + dependencies: + "@metamask/key-tree": ^9.1.2 + "@metamask/providers": ^18.1.1 + "@metamask/rpc-errors": ^7.0.1 + "@metamask/superstruct": ^3.1.0 + "@metamask/utils": ^10.0.0 + checksum: b389fe350e85d8ce0974ee10c0789ff1daa843efeacec234726de227a02a3937e13cf81d181855c8b00563dc42e519467ac9b5401af40bf601b91c8648302855 + languageName: node + linkType: hard + "@metamask/snaps-utils@npm:^7.0.1, @metamask/snaps-utils@npm:^7.7.0": version: 7.7.0 resolution: "@metamask/snaps-utils@npm:7.7.0" @@ -4752,6 +4820,23 @@ __metadata: languageName: node linkType: hard +"@metamask/utils@npm:^10.0.0": + version: 10.0.1 + resolution: "@metamask/utils@npm:10.0.1" + dependencies: + "@ethereumjs/tx": ^4.2.0 + "@metamask/superstruct": ^3.1.0 + "@noble/hashes": ^1.3.1 + "@scure/base": ^1.1.3 + "@types/debug": ^4.1.7 + debug: ^4.3.4 + pony-cause: ^2.1.10 + semver: ^7.5.4 + uuid: ^9.0.1 + checksum: 4c350c7a1c881c6af446319942392e6eb62411bff9c512166d816d39702c7b4926a982ebfd56ada317f9332a5416b3211c09e022674cee8272228658977ba851 + languageName: node + linkType: hard + "@metamask/utils@npm:^6.0.1": version: 6.2.0 resolution: "@metamask/utils@npm:6.2.0" @@ -4783,6 +4868,23 @@ __metadata: languageName: node linkType: hard +"@metamask/utils@npm:^9.0.0": + version: 9.3.0 + resolution: "@metamask/utils@npm:9.3.0" + dependencies: + "@ethereumjs/tx": ^4.2.0 + "@metamask/superstruct": ^3.1.0 + "@noble/hashes": ^1.3.1 + "@scure/base": ^1.1.3 + "@types/debug": ^4.1.7 + debug: ^4.3.4 + pony-cause: ^2.1.10 + semver: ^7.5.4 + uuid: ^9.0.1 + checksum: f720b0f7bdd46054aa88d15a9702e1de6d7200a1ca1d4f6bc48761b039f1bbffb46ac88bc87fe79e66128c196d424f3b9ef071b3cb4b40139223786d56da35e0 + languageName: node + linkType: hard + "@metamask/utils@npm:^9.1.0": version: 9.1.0 resolution: "@metamask/utils@npm:9.1.0" @@ -7849,6 +7951,15 @@ __metadata: languageName: node linkType: hard +"@types/react-dom@npm:18.2.4": + version: 18.2.4 + resolution: "@types/react-dom@npm:18.2.4" + dependencies: + "@types/react": "*" + checksum: 8301f35cf1cbfec8c723e9477aecf87774e3c168bd457d353b23c45064737213d3e8008b067c6767b7b08e4f2b3823ee239242a6c225fc91e7f8725ef8734124 + languageName: node + linkType: hard + "@types/react-dom@npm:^18.0.0, @types/react-dom@npm:^18.0.5": version: 18.3.0 resolution: "@types/react-dom@npm:18.3.0" @@ -7877,6 +7988,17 @@ __metadata: languageName: node linkType: hard +"@types/react@npm:18.2.4": + version: 18.2.4 + resolution: "@types/react@npm:18.2.4" + dependencies: + "@types/prop-types": "*" + "@types/scheduler": "*" + csstype: ^3.0.2 + checksum: d920fc93832fe50d5e8175a0ba233086c97a9e238ff7327c8319b8dec57409618f491d6f71be2374c3132f40a8fc428b3e406c1e2a5f1dc32ccd6d47051786d2 + languageName: node + linkType: hard + "@types/resolve@npm:1.17.1": version: 1.17.1 resolution: "@types/resolve@npm:1.17.1" @@ -7900,6 +8022,13 @@ __metadata: languageName: node linkType: hard +"@types/scheduler@npm:*": + version: 0.23.0 + resolution: "@types/scheduler@npm:0.23.0" + checksum: 874d753aa65c17760dfc460a91e6df24009bde37bfd427a031577b30262f7770c1b8f71a21366c7dbc76111967384cf4090a31d65315155180ef14bd7acccb32 + languageName: node + linkType: hard + "@types/semver@npm:^7.3.10, @types/semver@npm:^7.3.12": version: 7.5.8 resolution: "@types/semver@npm:7.5.8" @@ -14556,6 +14685,17 @@ __metadata: languageName: node linkType: hard +"extension-port-stream@npm:^4.1.0": + version: 4.2.0 + resolution: "extension-port-stream@npm:4.2.0" + dependencies: + readable-stream: ^3.6.2 || ^4.4.2 + peerDependencies: + webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 + checksum: 85559c82e3f3aa21462e234b30b7d53872708893664cd03f2f848af556cf0730cf2243b089efc9d40bbe9a4f73bd8fd19684db5a985329b0c4402b4f2fe26358 + languageName: node + linkType: hard + "extglob@npm:^2.0.4": version: 2.0.4 resolution: "extglob@npm:2.0.4" From 3debd475b1a2f2d77c0100233db2b483f0405384 Mon Sep 17 00:00:00 2001 From: khanti42 Date: Thu, 21 Nov 2024 15:35:38 +0100 Subject: [PATCH 30/50] feat: replace rpc dialogs with jsx (#422) * chore: add jsx support in snap (#415) * chore: add jsx support in snap * chore: fix comment * chore: update yarn.lock * feat: add common jsx component (#417) * chore: add jsx support in snap * chore: add jsx support in snap * feat: jsx support management * feat: common jsx components and fragments * chore: fix test and lint * chore: rollback jsx support detection not here * chore: fix comment * chore: fix comments * chore: fix comments * chore: update yarn.lock * chore: update yarn.lock * chore: add react-dom types * chore: fix comments * chore: rebase wallet-ui changes happening elsewhere * feat: add fee token selection interactive UI (#418) * chore: add jsx support in snap * chore: add jsx support in snap * feat: jsx support management * feat: common jsx components and fragments * chore: fix test and lint * feat: add interactive-ui for execute txn * fix: add mutex in jsx support detection mechanism * chore: fixture request addapted * chore: ensure test pass * chore: lint * chore: remove console.log * chore: rollback jsx support detection not here * chore: rollback index.tsx * chore: fix comment * chore: fix comments * chore: fix comments * chore: update yarn.lock * chore: update yarn.lock * chore: update yarn.lock * chore: rebase wallet-ui changes happening elsewhere * chore: fix comments * chore: fix comments * chore: fix comments * chore: fix comments * chore: fix comments * chore: fix comments * chore: removed utils * fix: implement comments * fix: implement comments * chore: fix comments * Update packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/utils.test.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/types/snapState.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/utils.test.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/utils.test.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * chore: fix comments * chore: fix comments * chore: fix comments * chore: lint --------- Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * feat: adopt fee token selection dialog in RPC `starkNet_executeTxn` (#419) * chore: add jsx support in snap * chore: add jsx support in snap * feat: jsx support management * feat: common jsx components and fragments * chore: fix test and lint * feat: add interactive-ui for execute txn * fix: add mutex in jsx support detection mechanism * chore: fixture request addapted * chore: ensure test pass * chore: lint * feat: add interactive-ui in execute txn * chore: remove console.log * chore: missing helper in tests * chore: lint * chore: rollback jsx support detection not here * chore: rollback index.tsx * chore: fix comment * chore: fix comments * chore: fix comments * chore: update yarn.lock * chore: update yarn.lock * feat: update wallet-ui message * chore: update yarn.lock * chore: rebase wallet-ui changes happening elsewhere * chore: rebase wallet-ui changes happening elsewhere * chore: fix comments * chore: fix comments * fix: formatter-utils * chore: fix comments * chore: fix comments * chore: lint * chore: lint * chore: fix comments * chore: fix comments * chore: fix comments * chore: fix comments * chore: removed utils * fix: implement comments * fix: implement comments * chore: fix comments * chore: fix comments * Update packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/utils.test.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/types/snapState.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/utils.test.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/utils.test.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * chore: fix comments * chore: fix comments * chore: fix comments * chore: fix comments * chore: fix comments * chore: lint * chore: lint * chore: fix comments * chore: fix comments --------- Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * chore: fix tests * feat: replace rpc dialogs with jsx * feat: add sign-message in jsx * feat: add jsx support detection (#416) * chore: add jsx support in snap * chore: add jsx support in snap * feat: jsx support management * chore: fix test and lint * fix: add mutex in jsx support detection mechanism * chore: ensure test pass * feat: new init state manager class to manage state init and support check * fix: wait for hooks in request handler * chore: lint * fix: set jsx support to true before showing dialog * chore: fix comment * fix: moved ping pong * chore: lint * chore: rollback state * chore: lint * chore: fix comments * fix: test suits * fix: use functional component * feat: add event listener for fee token selection (#420) * chore: add jsx support in snap * chore: add jsx support in snap * feat: jsx support management * feat: common jsx components and fragments * chore: fix test and lint * feat: add interactive-ui for execute txn * fix: add mutex in jsx support detection mechanism * chore: fixture request addapted * chore: ensure test pass * chore: lint * feat: add interactive-ui in execute txn * chore: remove console.log * chore: missing helper in tests * chore: lint * feat: event-handler in index.tsx * feat: event controller * feat: error handling and tests suits for event controller * fix: test suits * fix: signer in fee-token-selector * chore: rollback jsx support detection not here * chore: rollback index.tsx * chore: lint * chore: fix comment * chore: fix comments * chore: fix comments * chore: update yarn.lock * chore: update yarn.lock * feat: update wallet-ui message * chore: update yarn.lock * chore: rebase wallet-ui changes happening elsewhere * chore: rebase wallet-ui changes happening elsewhere * chore: fix comments * chore: fix comments * fix: formatter-utils * chore: fix comments * chore: fix comments * chore: lint * chore: lint * chore: fix comments * chore: fix comments * chore: fix comments * chore: fix comments * chore: lint * chore: lint * chore: removed utils * feat: add comments in user-input classes * fix: implement comments * fix: implement comments * chore: fix comments * chore: fix comments * Update packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/utils.test.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/types/snapState.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/utils.test.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/utils.test.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * chore: fix comments * chore: fix comments * chore: fix comments * chore: fix comments * chore: fix comments * chore: lint * chore: lint * chore: fix rebase * chore: fix comments * chore: fix comments * chore: fix comments * chore: rebase * fix: removed user-input controller abstract class and derived ones * chore: rollback execute-txn * chore: update * chore: fix comments * chore: fix comments * chore: refine the code * fix: add execution test * fix: update execute txn test --------- Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * chore: lint * chore: docstring * chore: lint * chore: docstring * chore: lint * chore: lint * chore: lint --------- Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> --- .../src/rpcs/__tests__/helper.ts | 86 +++++++++++ .../src/rpcs/display-private-key.test.ts | 31 ++-- .../src/rpcs/display-private-key.ts | 19 +-- .../src/rpcs/sign-declare-transaction.test.ts | 24 ++- .../src/rpcs/sign-declare-transaction.ts | 45 ++---- .../src/rpcs/sign-message.test.ts | 24 ++- .../starknet-snap/src/rpcs/sign-message.ts | 34 +---- .../src/rpcs/sign-transaction.test.ts | 27 ++-- .../src/rpcs/sign-transaction.ts | 39 +---- .../src/rpcs/switch-network.test.ts | 21 +-- .../starknet-snap/src/rpcs/switch-network.ts | 40 +---- .../src/rpcs/watch-asset.test.ts | 38 ++--- .../starknet-snap/src/rpcs/watch-asset.ts | 78 +--------- .../src/ui/components/DisplayPrivateKeyUI.tsx | 47 ++++++ .../components/SignDeclareTransactionUI.tsx | 41 +++++ .../src/ui/components/SignMessageUI.tsx | 37 +++++ .../src/ui/components/SignTransactionUI.tsx | 41 +++++ .../src/ui/components/SwitchNetworkUI.tsx | 33 +++++ .../src/ui/components/WatchAssetUI.tsx | 62 ++++++++ packages/starknet-snap/src/ui/utils.tsx | 140 ++++++++++++++++++ 20 files changed, 588 insertions(+), 319 deletions(-) create mode 100644 packages/starknet-snap/src/ui/components/DisplayPrivateKeyUI.tsx create mode 100644 packages/starknet-snap/src/ui/components/SignDeclareTransactionUI.tsx create mode 100644 packages/starknet-snap/src/ui/components/SignMessageUI.tsx create mode 100644 packages/starknet-snap/src/ui/components/SignTransactionUI.tsx create mode 100644 packages/starknet-snap/src/ui/components/SwitchNetworkUI.tsx create mode 100644 packages/starknet-snap/src/ui/components/WatchAssetUI.tsx diff --git a/packages/starknet-snap/src/rpcs/__tests__/helper.ts b/packages/starknet-snap/src/rpcs/__tests__/helper.ts index 5f3f2665..8d20363f 100644 --- a/packages/starknet-snap/src/rpcs/__tests__/helper.ts +++ b/packages/starknet-snap/src/rpcs/__tests__/helper.ts @@ -5,6 +5,7 @@ import type { StarknetAccount } from '../../__tests__/helper'; import { generateAccounts, generateRandomValue } from '../../__tests__/helper'; import { TransactionRequestStateManager } from '../../state/request-state-manager'; import type { SnapState } from '../../types/snapState'; +import * as snapUiUtils from '../../ui/utils'; import { getExplorerUrl, shortenAddress, toJson } from '../../utils'; import * as snapHelper from '../../utils/snap'; import * as snapUtils from '../../utils/snapUtils'; @@ -80,6 +81,91 @@ export function prepareConfirmDialog() { }; } +/** + * + */ +export function prepareRenderWatchAssetUI() { + const confirmDialogSpy = jest.spyOn(snapUiUtils, 'renderWatchAssetUI'); + confirmDialogSpy.mockResolvedValue(true); + return { + confirmDialogSpy, + }; +} + +/** + * + */ +export function prepareRenderSwitchNetworkUI() { + const confirmDialogSpy = jest.spyOn(snapUiUtils, 'renderSwitchNetworkUI'); + confirmDialogSpy.mockResolvedValue(true); + return { + confirmDialogSpy, + }; +} + +/** + * + */ +export function prepareRenderSignMessageUI() { + const confirmDialogSpy = jest.spyOn(snapUiUtils, 'renderSignMessageUI'); + confirmDialogSpy.mockResolvedValue(true); + return { + confirmDialogSpy, + }; +} + +/** + * + */ +export function prepareRenderSignTransactionUI() { + const confirmDialogSpy = jest.spyOn(snapUiUtils, 'renderSignTransactionUI'); + confirmDialogSpy.mockResolvedValue(true); + return { + confirmDialogSpy, + }; +} + +/** + * + */ +export function prepareRenderSignDeclareTransactionUI() { + const confirmDialogSpy = jest.spyOn( + snapUiUtils, + 'renderSignDeclareTransactionUI', + ); + confirmDialogSpy.mockResolvedValue(true); + return { + confirmDialogSpy, + }; +} + +/** + * + */ +export function prepareRenderDisplayPrivateKeyConfirmUI() { + const confirmDialogSpy = jest.spyOn( + snapUiUtils, + 'renderDisplayPrivateKeyConfirmUI', + ); + confirmDialogSpy.mockResolvedValue(true); + return { + confirmDialogSpy, + }; +} + +/** + * + */ +export function prepareRenderDisplayPrivateKeyAlertUI() { + const alertDialogSpy = jest.spyOn( + snapUiUtils, + 'renderDisplayPrivateKeyAlertUI', + ); + return { + alertDialogSpy, + }; +} + /** * */ diff --git a/packages/starknet-snap/src/rpcs/display-private-key.test.ts b/packages/starknet-snap/src/rpcs/display-private-key.test.ts index fc6a17f8..aa0aac00 100644 --- a/packages/starknet-snap/src/rpcs/display-private-key.test.ts +++ b/packages/starknet-snap/src/rpcs/display-private-key.test.ts @@ -8,9 +8,9 @@ import { } from '../utils/exceptions'; import { mockAccount, - prepareAlertDialog, prepareMockAccount, - prepareConfirmDialog, + prepareRenderDisplayPrivateKeyAlertUI, + prepareRenderDisplayPrivateKeyConfirmUI, } from './__tests__/helper'; import { displayPrivateKey } from './display-private-key'; import type { DisplayPrivateKeyParams } from './display-private-key'; @@ -40,49 +40,36 @@ describe('displayPrivateKey', () => { const chainId = constants.StarknetChainId.SN_SEPOLIA; const account = await mockAccount(chainId); prepareMockAccount(account, state); - prepareConfirmDialog(); - const { alertDialogSpy } = prepareAlertDialog(); + prepareRenderDisplayPrivateKeyConfirmUI(); + const { alertDialogSpy } = prepareRenderDisplayPrivateKeyAlertUI(); const request = createRequestParam(chainId, account.address); await displayPrivateKey.execute(request); - expect(alertDialogSpy).toHaveBeenCalledTimes(1); - - const calls = alertDialogSpy.mock.calls[0][0]; - - expect(calls).toStrictEqual([ - { type: 'text', value: 'Starknet Account Private Key' }, - { type: 'copyable', value: account.privateKey }, - ]); + expect(alertDialogSpy).toHaveBeenCalledWith(account.privateKey); }); it('renders confirmation dialog', async () => { const chainId = constants.StarknetChainId.SN_SEPOLIA; const account = await mockAccount(chainId); prepareMockAccount(account, state); - const { confirmDialogSpy } = prepareConfirmDialog(); - prepareAlertDialog(); + const { confirmDialogSpy } = prepareRenderDisplayPrivateKeyConfirmUI(); + prepareRenderDisplayPrivateKeyAlertUI(); const request = createRequestParam(chainId, account.address); await displayPrivateKey.execute(request); expect(confirmDialogSpy).toHaveBeenCalledTimes(1); - - const calls = confirmDialogSpy.mock.calls[0][0]; - - expect(calls).toStrictEqual([ - { type: 'text', value: 'Do you want to export your private key?' }, - ]); }); it('throws `UserRejectedOpError` if user denies the operation', async () => { const chainId = constants.StarknetChainId.SN_SEPOLIA; const account = await mockAccount(chainId); prepareMockAccount(account, state); - const { confirmDialogSpy } = prepareConfirmDialog(); - prepareAlertDialog(); + const { confirmDialogSpy } = prepareRenderDisplayPrivateKeyConfirmUI(); + prepareRenderDisplayPrivateKeyAlertUI(); confirmDialogSpy.mockResolvedValue(false); diff --git a/packages/starknet-snap/src/rpcs/display-private-key.ts b/packages/starknet-snap/src/rpcs/display-private-key.ts index b1dd38ef..bef32db8 100644 --- a/packages/starknet-snap/src/rpcs/display-private-key.ts +++ b/packages/starknet-snap/src/rpcs/display-private-key.ts @@ -1,11 +1,12 @@ -import { copyable, text } from '@metamask/snaps-sdk'; import { type Infer, object, literal, assign } from 'superstruct'; +import { + renderDisplayPrivateKeyAlertUI, + renderDisplayPrivateKeyConfirmUI, +} from '../ui/utils'; import { AccountRpcController, AddressStruct, - confirmDialog, - alertDialog, BaseRequestStruct, } from '../utils'; import { UserRejectedOpError } from '../utils/exceptions'; @@ -56,19 +57,11 @@ export class DisplayPrivateKeyRpc extends AccountRpcController< // eslint-disable-next-line @typescript-eslint/no-unused-vars params: DisplayPrivateKeyParams, ): Promise { - const confirmComponents = [text('Do you want to export your private key?')]; - - if (!(await confirmDialog(confirmComponents))) { + if (!(await renderDisplayPrivateKeyConfirmUI())) { throw new UserRejectedOpError() as unknown as Error; } - const alertComponents = [ - text('Starknet Account Private Key'), - copyable(this.account.privateKey), - ]; - - await alertDialog(alertComponents); - + await renderDisplayPrivateKeyAlertUI(this.account.privateKey); return null; } } diff --git a/packages/starknet-snap/src/rpcs/sign-declare-transaction.test.ts b/packages/starknet-snap/src/rpcs/sign-declare-transaction.test.ts index a36f639c..37db7d59 100644 --- a/packages/starknet-snap/src/rpcs/sign-declare-transaction.test.ts +++ b/packages/starknet-snap/src/rpcs/sign-declare-transaction.test.ts @@ -11,10 +11,7 @@ import * as starknetUtils from '../utils/starknetUtils'; import { mockAccount, prepareMockAccount, - prepareConfirmDialog, - buildSignerComponent, - buildNetworkComponent, - buildJsonDataComponent, + prepareRenderSignDeclareTransactionUI, } from './__tests__/helper'; import { signDeclareTransaction } from './sign-declare-transaction'; import type { SignDeclareTransactionParams } from './sign-declare-transaction'; @@ -52,7 +49,7 @@ describe('signDeclareTransaction', () => { const account = await mockAccount(chainId); prepareMockAccount(account, state); - prepareConfirmDialog(); + prepareRenderSignDeclareTransactionUI(); const request = createRequest(chainId, account.address); @@ -72,19 +69,18 @@ describe('signDeclareTransaction', () => { const { address } = account; prepareMockAccount(account, state); - const { confirmDialogSpy } = prepareConfirmDialog(); + const { confirmDialogSpy } = prepareRenderSignDeclareTransactionUI(); const request = createRequest(chainId, address); await signDeclareTransaction.execute(request); - const calls = confirmDialogSpy.mock.calls[0][0]; - expect(calls).toStrictEqual([ - { type: 'heading', value: 'Do you want to sign this transaction?' }, - buildSignerComponent(address, chainId), - buildNetworkComponent(STARKNET_SEPOLIA_TESTNET_NETWORK.name), - buildJsonDataComponent('Declare Transaction Details', request.details), - ]); + expect(confirmDialogSpy).toHaveBeenCalledWith({ + senderAddress: address, + chainId, + networkName: STARKNET_SEPOLIA_TESTNET_NETWORK.name, + declareTransactions: request.details, + }); }); it('throws `UserRejectedOpError` if user denied the operation', async () => { @@ -92,7 +88,7 @@ describe('signDeclareTransaction', () => { const account = await mockAccount(chainId); prepareMockAccount(account, state); - const { confirmDialogSpy } = prepareConfirmDialog(); + const { confirmDialogSpy } = prepareRenderSignDeclareTransactionUI(); confirmDialogSpy.mockResolvedValue(false); diff --git a/packages/starknet-snap/src/rpcs/sign-declare-transaction.ts b/packages/starknet-snap/src/rpcs/sign-declare-transaction.ts index 775dcd09..aa1fbf15 100644 --- a/packages/starknet-snap/src/rpcs/sign-declare-transaction.ts +++ b/packages/starknet-snap/src/rpcs/sign-declare-transaction.ts @@ -1,19 +1,14 @@ -import type { Component } from '@metamask/snaps-sdk'; import type { DeclareSignerDetails } from 'starknet'; import type { Infer } from 'superstruct'; import { array, object, string, assign } from 'superstruct'; +import { renderSignDeclareTransactionUI } from '../ui/utils'; import { - confirmDialog, AddressStruct, BaseRequestStruct, AccountRpcController, DeclareSignDetailsStruct, mapDeprecatedParams, - signerUI, - networkUI, - jsonDataUI, - headerUI, } from '../utils'; import { UserRejectedOpError } from '../utils/exceptions'; import { signDeclareTransaction as signDeclareTransactionUtil } from '../utils/starknetUtils'; @@ -84,7 +79,14 @@ export class SignDeclareTransactionRpc extends AccountRpcController< params: SignDeclareTransactionParams, ): Promise { const { details } = params; - if (!(await this.getSignDeclareTransactionConsensus(details))) { + if ( + !(await renderSignDeclareTransactionUI({ + senderAddress: details.senderAddress, + networkName: this.network.name, + chainId: this.network.chainId, + declareTransactions: details, + })) + ) { throw new UserRejectedOpError() as unknown as Error; } @@ -93,35 +95,6 @@ export class SignDeclareTransactionRpc extends AccountRpcController< details as unknown as DeclareSignerDetails, )) as unknown as SignDeclareTransactionResponse; } - - protected async getSignDeclareTransactionConsensus( - details: Infer, - ) { - const components: Component[] = []; - components.push(headerUI('Do you want to sign this transaction?')); - - components.push( - signerUI({ - address: details.senderAddress, - chainId: this.network.chainId, - }), - ); - - components.push( - networkUI({ - networkName: this.network.name, - }), - ); - - components.push( - jsonDataUI({ - label: 'Declare Transaction Details', - data: details, - }), - ); - - return await confirmDialog(components); - } } export const signDeclareTransaction = new SignDeclareTransactionRpc(); diff --git a/packages/starknet-snap/src/rpcs/sign-message.test.ts b/packages/starknet-snap/src/rpcs/sign-message.test.ts index 9b34d629..24a3c4ae 100644 --- a/packages/starknet-snap/src/rpcs/sign-message.test.ts +++ b/packages/starknet-snap/src/rpcs/sign-message.test.ts @@ -11,9 +11,7 @@ import * as starknetUtils from '../utils/starknetUtils'; import { mockAccount, prepareMockAccount, - prepareConfirmDialog, - buildSignerComponent, - buildJsonDataComponent, + prepareRenderSignMessageUI, } from './__tests__/helper'; import { signMessage } from './sign-message'; import type { SignMessageParams } from './sign-message'; @@ -33,7 +31,7 @@ describe('signMessage', () => { const account = await mockAccount(constants.StarknetChainId.SN_SEPOLIA); prepareMockAccount(account, state); - prepareConfirmDialog(); + prepareRenderSignMessageUI(); const expectedResult = await starknetUtils.signMessage( account.privateKey, @@ -56,30 +54,28 @@ describe('signMessage', () => { const { address, chainId } = account; prepareMockAccount(account, state); - const { confirmDialogSpy } = prepareConfirmDialog(); + const { confirmDialogSpy } = prepareRenderSignMessageUI(); const request = { - chainId: constants.StarknetChainId.SN_SEPOLIA, + chainId: chainId as constants.StarknetChainId, address, typedDataMessage: typedDataExample, enableAuthorize: true, }; await signMessage.execute(request); - - const calls = confirmDialogSpy.mock.calls[0][0]; - expect(calls).toStrictEqual([ - { type: 'heading', value: 'Do you want to sign this message?' }, - buildSignerComponent(address, chainId), - buildJsonDataComponent('Message', typedDataExample), - ]); + expect(confirmDialogSpy).toHaveBeenCalledWith({ + address, + chainId, + typedDataMessage: typedDataExample, + }); }); it('throws `UserRejectedOpError` if user denied the operation', async () => { const account = await mockAccount(constants.StarknetChainId.SN_SEPOLIA); prepareMockAccount(account, state); - const { confirmDialogSpy } = prepareConfirmDialog(); + const { confirmDialogSpy } = prepareRenderSignMessageUI(); confirmDialogSpy.mockResolvedValue(false); diff --git a/packages/starknet-snap/src/rpcs/sign-message.ts b/packages/starknet-snap/src/rpcs/sign-message.ts index 80112ca3..6b4387cb 100644 --- a/packages/starknet-snap/src/rpcs/sign-message.ts +++ b/packages/starknet-snap/src/rpcs/sign-message.ts @@ -1,18 +1,14 @@ -import type { Component } from '@metamask/snaps-sdk'; import type { Infer } from 'superstruct'; import { array, object, string, assign } from 'superstruct'; +import { renderSignMessageUI } from '../ui/utils'; import { - confirmDialog, AddressStruct, TypeDataStruct, AuthorizableStruct, BaseRequestStruct, AccountRpcController, mapDeprecatedParams, - signerUI, - jsonDataUI, - headerUI, } from '../utils'; import { UserRejectedOpError } from '../utils/exceptions'; import { signMessage as signMessageUtil } from '../utils/starknetUtils'; @@ -79,7 +75,11 @@ export class SignMessageRpc extends AccountRpcController< // Get Starknet expected not to show the confirm dialog, therefore, `enableAuthorize` will set to false to bypass the confirmation // TODO: enableAuthorize should set default to true enableAuthorize && - !(await this.getSignMessageConsensus(typedDataMessage, address)) + !(await renderSignMessageUI({ + address, + typedDataMessage, + chainId: this.network.chainId, + })) ) { throw new UserRejectedOpError() as unknown as Error; } @@ -90,28 +90,6 @@ export class SignMessageRpc extends AccountRpcController< address, ); } - - protected async getSignMessageConsensus( - typedDataMessage: Infer, - address: string, - ) { - const components: Component[] = []; - components.push(headerUI('Do you want to sign this message?')); - components.push( - signerUI({ - address, - chainId: this.network.chainId, - }), - ); - components.push( - jsonDataUI({ - label: 'Message', - data: typedDataMessage, - }), - ); - - return await confirmDialog(components); - } } export const signMessage = new SignMessageRpc(); diff --git a/packages/starknet-snap/src/rpcs/sign-transaction.test.ts b/packages/starknet-snap/src/rpcs/sign-transaction.test.ts index 5200545a..66dbdc7c 100644 --- a/packages/starknet-snap/src/rpcs/sign-transaction.test.ts +++ b/packages/starknet-snap/src/rpcs/sign-transaction.test.ts @@ -12,10 +12,7 @@ import * as starknetUtils from '../utils/starknetUtils'; import { mockAccount, prepareMockAccount, - prepareConfirmDialog, - buildNetworkComponent, - buildSignerComponent, - buildJsonDataComponent, + prepareRenderSignTransactionUI, } from './__tests__/helper'; import { signTransaction } from './sign-transaction'; import type { SignTransactionParams } from './sign-transaction'; @@ -52,7 +49,7 @@ describe('signTransaction', () => { const chainId = constants.StarknetChainId.SN_SEPOLIA; const account = await mockAccount(chainId); prepareMockAccount(account, state); - prepareConfirmDialog(); + prepareRenderSignTransactionUI(); const request = createRequestParam(chainId, account.address); const expectedResult = await starknetUtils.signTransactions( @@ -71,25 +68,23 @@ describe('signTransaction', () => { const account = await mockAccount(chainId); const { address } = account; prepareMockAccount(account, state); - const { confirmDialogSpy } = prepareConfirmDialog(); + const { confirmDialogSpy } = prepareRenderSignTransactionUI(); const request = createRequestParam(chainId, account.address, true); await signTransaction.execute(request); - - const calls = confirmDialogSpy.mock.calls[0][0]; - expect(calls).toStrictEqual([ - { type: 'heading', value: 'Do you want to sign this transaction?' }, - buildSignerComponent(address, chainId), - buildNetworkComponent(STARKNET_SEPOLIA_TESTNET_NETWORK.name), - buildJsonDataComponent('Transaction', transactionExample.transactions), - ]); + expect(confirmDialogSpy).toHaveBeenCalledWith({ + senderAddress: address, + chainId, + networkName: STARKNET_SEPOLIA_TESTNET_NETWORK.name, + transactions: request.transactions, + }); }); it('does not render the confirmation dialog if enableAuthorize is false', async () => { const chainId = constants.StarknetChainId.SN_SEPOLIA; const account = await mockAccount(chainId); prepareMockAccount(account, state); - const { confirmDialogSpy } = prepareConfirmDialog(); + const { confirmDialogSpy } = prepareRenderSignTransactionUI(); const request = createRequestParam(chainId, account.address, false); await signTransaction.execute(request); @@ -101,7 +96,7 @@ describe('signTransaction', () => { const chainId = constants.StarknetChainId.SN_SEPOLIA; const account = await mockAccount(chainId); prepareMockAccount(account, state); - const { confirmDialogSpy } = prepareConfirmDialog(); + const { confirmDialogSpy } = prepareRenderSignTransactionUI(); confirmDialogSpy.mockResolvedValue(false); const request = createRequestParam(chainId, account.address, true); diff --git a/packages/starknet-snap/src/rpcs/sign-transaction.ts b/packages/starknet-snap/src/rpcs/sign-transaction.ts index ccb61009..24b152e9 100644 --- a/packages/starknet-snap/src/rpcs/sign-transaction.ts +++ b/packages/starknet-snap/src/rpcs/sign-transaction.ts @@ -1,20 +1,15 @@ -import type { DialogResult } from '@metamask/snaps-sdk'; -import type { Call, InvocationsSignerDetails } from 'starknet'; +import type { InvocationsSignerDetails } from 'starknet'; import type { Infer } from 'superstruct'; import { array, object, string, assign, any } from 'superstruct'; +import { renderSignTransactionUI } from '../ui/utils'; import { - confirmDialog, AddressStruct, AuthorizableStruct, BaseRequestStruct, AccountRpcController, CallDataStruct, mapDeprecatedParams, - jsonDataUI, - signerUI, - networkUI, - headerUI, } from '../utils'; import { UserRejectedOpError } from '../utils/exceptions'; import { signTransactions } from '../utils/starknetUtils'; @@ -86,10 +81,12 @@ export class SignTransactionRpc extends AccountRpcController< // Get Starknet expected not to show the confirm dialog, therefore, `enableAuthorize` will set to false to bypass the confirmation // TODO: enableAuthorize should set default to true enableAuthorize && - !(await this.getSignTransactionConsensus( - address, - transactions as unknown as Call[], - )) + !(await renderSignTransactionUI({ + senderAddress: address, + networkName: this.network.name, + chainId: this.network.chainId, + transactions, + })) ) { throw new UserRejectedOpError() as unknown as Error; } @@ -100,26 +97,6 @@ export class SignTransactionRpc extends AccountRpcController< params.transactionsDetail as unknown as InvocationsSignerDetails, )) as SignTransactionResponse; } - - protected async getSignTransactionConsensus( - address: string, - transactions: Call[], - ): Promise { - return await confirmDialog([ - headerUI('Do you want to sign this transaction?'), - signerUI({ - address, - chainId: this.network.chainId, - }), - networkUI({ - networkName: this.network.name, - }), - jsonDataUI({ - label: 'Transaction', - data: transactions, - }), - ]); - } } export const signTransaction = new SignTransactionRpc(); diff --git a/packages/starknet-snap/src/rpcs/switch-network.test.ts b/packages/starknet-snap/src/rpcs/switch-network.test.ts index d01d6d36..cca82729 100644 --- a/packages/starknet-snap/src/rpcs/switch-network.test.ts +++ b/packages/starknet-snap/src/rpcs/switch-network.test.ts @@ -12,12 +12,7 @@ import { InvalidRequestParamsError, UserRejectedOpError, } from '../utils/exceptions'; -import { - buildDividerComponent, - buildNetworkComponent, - buildRowComponent, - prepareConfirmDialog, -} from './__tests__/helper'; +import { prepareRenderSwitchNetworkUI } from './__tests__/helper'; import { switchNetwork } from './switch-network'; import type { SwitchNetworkParams } from './switch-network'; @@ -121,17 +116,15 @@ describe('switchNetwork', () => { currentNetwork, network: requestNetwork, }); - const { confirmDialogSpy } = prepareConfirmDialog(); + const { confirmDialogSpy } = prepareRenderSwitchNetworkUI(); const request = createRequestParam(requestNetwork.chainId, true); await switchNetwork.execute(request); - expect(confirmDialogSpy).toHaveBeenCalledWith([ - { type: 'heading', value: 'Do you want to switch to this network?' }, - buildNetworkComponent(requestNetwork.name), - buildDividerComponent(), - buildRowComponent('Chain ID', requestNetwork.chainId), - ]); + expect(confirmDialogSpy).toHaveBeenCalledWith({ + chainId: requestNetwork.chainId, + name: requestNetwork.name, + }); }); it('throws `UserRejectedRequestError` if user denied the operation', async () => { @@ -141,7 +134,7 @@ describe('switchNetwork', () => { currentNetwork, network: requestNetwork, }); - const { confirmDialogSpy } = prepareConfirmDialog(); + const { confirmDialogSpy } = prepareRenderSwitchNetworkUI(); confirmDialogSpy.mockResolvedValue(false); const request = createRequestParam(requestNetwork.chainId, true); diff --git a/packages/starknet-snap/src/rpcs/switch-network.ts b/packages/starknet-snap/src/rpcs/switch-network.ts index 12ac65b8..ae94048e 100644 --- a/packages/starknet-snap/src/rpcs/switch-network.ts +++ b/packages/starknet-snap/src/rpcs/switch-network.ts @@ -1,18 +1,9 @@ -import type { Component } from '@metamask/snaps-sdk'; import type { Infer } from 'superstruct'; import { assign, boolean } from 'superstruct'; import { NetworkStateManager } from '../state/network-state-manager'; -import { - confirmDialog, - AuthorizableStruct, - BaseRequestStruct, - RpcController, - networkUI, - rowUI, - dividerUI, - headerUI, -} from '../utils'; +import { renderSwitchNetworkUI } from '../ui/utils'; +import { AuthorizableStruct, BaseRequestStruct, RpcController } from '../utils'; import { InvalidNetworkError, UserRejectedOpError } from '../utils/exceptions'; export const SwitchNetworkRequestStruct = assign( @@ -85,7 +76,10 @@ export class SwitchNetworkRpc extends RpcController< // Get Starknet expected show the confirm dialog, while the companion doesnt needed, // therefore, `enableAuthorize` is to enable/disable the confirmation enableAuthorize && - !(await this.getSwitchNetworkConsensus(network.name, network.chainId)) + !(await renderSwitchNetworkUI({ + name: network.name, + chainId: network.chainId, + })) ) { throw new UserRejectedOpError() as unknown as Error; } @@ -95,28 +89,6 @@ export class SwitchNetworkRpc extends RpcController< return true; }); } - - protected async getSwitchNetworkConsensus( - networkName: string, - networkChainId: string, - ) { - const components: Component[] = []; - components.push(headerUI('Do you want to switch to this network?')); - components.push( - networkUI({ - networkName, - }), - ); - components.push(dividerUI()); - components.push( - rowUI({ - label: 'Chain ID', - value: networkChainId, - }), - ); - - return await confirmDialog(components); - } } export const switchNetwork = new SwitchNetworkRpc(); diff --git a/packages/starknet-snap/src/rpcs/watch-asset.test.ts b/packages/starknet-snap/src/rpcs/watch-asset.test.ts index c38bce01..8572b407 100644 --- a/packages/starknet-snap/src/rpcs/watch-asset.test.ts +++ b/packages/starknet-snap/src/rpcs/watch-asset.test.ts @@ -11,13 +11,7 @@ import { InvalidNetworkError, UserRejectedOpError, } from '../utils/exceptions'; -import { - buildAddressComponent, - buildDividerComponent, - buildNetworkComponent, - buildRowComponent, - prepareConfirmDialog, -} from './__tests__/helper'; +import { prepareRenderWatchAssetUI } from './__tests__/helper'; import type { WatchAssetParams } from './watch-asset'; import { watchAsset } from './watch-asset'; @@ -77,7 +71,7 @@ describe('WatchAssetRpc', () => { const request = createRequest({ chainId: network.chainId as unknown as constants.StarknetChainId, }); - const { confirmDialogSpy } = prepareConfirmDialog(); + const { confirmDialogSpy } = prepareRenderWatchAssetUI(); const { getNetworkSpy } = mockNetworkStateManager({ network, }); @@ -108,23 +102,17 @@ describe('WatchAssetRpc', () => { }); await watchAsset.execute(request); - - expect(confirmDialogSpy).toHaveBeenCalledWith([ - { type: 'heading', value: 'Do you want to add this token?' }, - buildNetworkComponent(network.name), - buildDividerComponent(), - buildAddressComponent( - 'Token Address', - request.tokenAddress, - network.chainId, - ), - buildDividerComponent(), - buildRowComponent('Token Name', request.tokenName), - buildDividerComponent(), - buildRowComponent('Token Symbol', request.tokenSymbol), - buildDividerComponent(), - buildRowComponent('Token Decimals', request.tokenDecimals.toString()), - ]); + expect(confirmDialogSpy).toHaveBeenCalledWith({ + chainId: network.chainId, + networkName: network.name, + token: { + address: request.tokenAddress, + chainId: request.chainId, + decimals: request.tokenDecimals, + name: request.tokenName, + symbol: request.tokenSymbol, + }, + }); }); it('throws `InvalidNetworkError` if the network can not be found', async () => { diff --git a/packages/starknet-snap/src/rpcs/watch-asset.ts b/packages/starknet-snap/src/rpcs/watch-asset.ts index 39b364fc..aa825d4e 100644 --- a/packages/starknet-snap/src/rpcs/watch-asset.ts +++ b/packages/starknet-snap/src/rpcs/watch-asset.ts @@ -1,23 +1,17 @@ -import type { Component } from '@metamask/snaps-sdk'; import type { Infer } from 'superstruct'; import { assign, boolean, min, number, object, optional } from 'superstruct'; import { NetworkStateManager } from '../state/network-state-manager'; import { TokenStateManager } from '../state/token-state-manager'; import type { Erc20Token, Network } from '../types/snapState'; +import { renderWatchAssetUI } from '../ui/utils'; import { - confirmDialog, BaseRequestStruct, RpcController, AddressStruct, TokenNameStruct, TokenSymbolStruct, isPreloadedToken, - addressUI, - networkUI, - rowUI, - dividerUI, - headerUI, } from '../utils'; import { DEFAULT_DECIMAL_PLACES } from '../utils/constants'; import { @@ -136,11 +130,11 @@ export class WatchAssetRpc extends RpcController< const network = await this.getNetworkFromChainId(chainId); if ( - !(await this.getWatchAssetConsensus( - network.name, - network.chainId, - erc20Token, - )) + !(await renderWatchAssetUI({ + networkName: network.name, + chainId: network.chainId, + token: erc20Token, + })) ) { throw new UserRejectedOpError() as unknown as Error; } @@ -149,66 +143,6 @@ export class WatchAssetRpc extends RpcController< return true; } - - protected async getWatchAssetConsensus( - networkName: string, - chainId: string, - erc20Token: Erc20Token, - ) { - const { address, name, symbol, decimals } = erc20Token; - - const componentPairs: { - label?: string; - value?: string; - component?: Component; - }[] = [ - { - component: networkUI({ - networkName, - }), - }, - { - component: addressUI({ - label: 'Token Address', - address, - chainId, - }), - }, - { - label: 'Token Name', - value: name, - }, - { - label: 'Token Symbol', - value: symbol, - }, - { - label: 'Token Decimals', - value: decimals.toString(), - }, - ]; - - const components: Component[] = []; - components.push(headerUI('Do you want to add this token?')); - - componentPairs.forEach(({ label, value, component }, idx) => { - if (component) { - components.push(component); - } else if (label && value) { - components.push( - rowUI({ - label, - value, - }), - ); - } - - if (idx < componentPairs.length - 1) { - components.push(dividerUI()); - } - }); - return await confirmDialog(components); - } } export const watchAsset = new WatchAssetRpc(); diff --git a/packages/starknet-snap/src/ui/components/DisplayPrivateKeyUI.tsx b/packages/starknet-snap/src/ui/components/DisplayPrivateKeyUI.tsx new file mode 100644 index 00000000..ca895f08 --- /dev/null +++ b/packages/starknet-snap/src/ui/components/DisplayPrivateKeyUI.tsx @@ -0,0 +1,47 @@ +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; +import { Box, Icon, Text, Heading, Copyable } from '@metamask/snaps-sdk/jsx'; + +/** + * Builds a UI component to confirm the action of revealing the private key. + * + * @returns A JSX component prompting the user to confirm revealing their private key. + */ +export const DisplayPrivateKeyDialogUI: SnapComponent = () => { + return ( + + Are you sure you want to reveal your private key? + + + + Confirming this action will display your private key. Ensure you are + in a secure environment. + + + + ); +}; + +export type DisplayPrivateKeyAlertUIProps = { + privateKey: string; +}; + +/** + * Builds a UI component to display the private key securely. + * + * @param options - The options to configure the component. + * @param options.privateKey - The private key to be displayed. + * @returns A JSX component for securely displaying the private key with a copyable option. + */ +export const DisplayPrivateKeyAlertUI: SnapComponent< + DisplayPrivateKeyAlertUIProps +> = ({ privateKey }) => { + return ( + + Starknet Account Private Key + + Below is your Starknet Account private key. Keep it confidential. + + + + ); +}; diff --git a/packages/starknet-snap/src/ui/components/SignDeclareTransactionUI.tsx b/packages/starknet-snap/src/ui/components/SignDeclareTransactionUI.tsx new file mode 100644 index 00000000..4765b16c --- /dev/null +++ b/packages/starknet-snap/src/ui/components/SignDeclareTransactionUI.tsx @@ -0,0 +1,41 @@ +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; +import { Box, Heading, Section } from '@metamask/snaps-sdk/jsx'; +import type { Infer } from 'superstruct'; + +import type { DeclareSignDetailsStruct } from '../../utils'; +import { AddressUI, JsonDataUI, NetworkUI } from '../fragments'; + +export type SignDeclareTransactionUIProps = { + senderAddress: string; + networkName: string; + chainId: string; + declareTransactions: Infer; +}; + +/** + * Builds a UI component for confirming the signing of a Declare transaction. + * + * @param options - The options to configure the component. + * @param options.senderAddress - The address of the sender initiating the transaction. + * @param options.networkName - The name of the blockchain network where the transaction will occur. + * @param options.chainId - The chain ID of the blockchain network. + * @param options.declareTransactions - The details of the Declare transaction. + * @returns A JSX component for the user to review and confirm the Declare transaction signing. + */ +export const SignDeclareTransactionUI: SnapComponent< + SignDeclareTransactionUIProps +> = ({ senderAddress, networkName, chainId, declareTransactions }) => { + return ( + + Do you want to sign this transaction? +
+ + + +
+
+ ); +}; diff --git a/packages/starknet-snap/src/ui/components/SignMessageUI.tsx b/packages/starknet-snap/src/ui/components/SignMessageUI.tsx new file mode 100644 index 00000000..cbde85cc --- /dev/null +++ b/packages/starknet-snap/src/ui/components/SignMessageUI.tsx @@ -0,0 +1,37 @@ +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; +import { Box, Heading, Section } from '@metamask/snaps-sdk/jsx'; +import type { Infer } from 'superstruct'; + +import type { TypeDataStruct } from '../../utils'; +import { JsonDataUI, SignerUI } from '../fragments'; + +export type SignMessageUIProps = { + address: string; + chainId: string; + typedDataMessage: Infer; +}; + +/** + * Builds a UI component for confirming the signing of a message. + * + * @param options - The options to configure the component. + * @param options.address - The address of the signer. + * @param options.chainId - The chain ID of the blockchain network. + * @param options.typedDataMessage - The typed data message to be signed. + * @returns A JSX component for the user to review and confirm the message signing. + */ +export const SignMessageUI: SnapComponent = ({ + address, + chainId, + typedDataMessage, +}) => { + return ( + + Do you want to sign this message? +
+ + +
+
+ ); +}; diff --git a/packages/starknet-snap/src/ui/components/SignTransactionUI.tsx b/packages/starknet-snap/src/ui/components/SignTransactionUI.tsx new file mode 100644 index 00000000..a2a3f8cc --- /dev/null +++ b/packages/starknet-snap/src/ui/components/SignTransactionUI.tsx @@ -0,0 +1,41 @@ +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; +import { Box, Heading, Section } from '@metamask/snaps-sdk/jsx'; +import type { Infer } from 'superstruct'; + +import type { CallDataStruct } from '../../utils'; +import { JsonDataUI, AddressUI, NetworkUI } from '../fragments'; + +export type SignTransactionUIProps = { + senderAddress: string; + networkName: string; + chainId: string; + transactions: Infer[]; +}; + +/** + * Builds a UI component for confirming the signing of a transaction. + * + * @param options - The options to configure the component. + * @param options.senderAddress - The address of the sender initiating the transaction. + * @param options.networkName - The name of the blockchain network where the transaction will occur. + * @param options.chainId - The chain ID of the blockchain network. + * @param options.transactions - An array of transactions Call. + * @returns A JSX component for the user to review and confirm the transaction signing. + */ +export const SignTransactionUI: SnapComponent = ({ + senderAddress, + networkName, + chainId, + transactions, +}) => { + return ( + + Do you want to sign this transaction? +
+ + + +
+
+ ); +}; diff --git a/packages/starknet-snap/src/ui/components/SwitchNetworkUI.tsx b/packages/starknet-snap/src/ui/components/SwitchNetworkUI.tsx new file mode 100644 index 00000000..82cf6e4c --- /dev/null +++ b/packages/starknet-snap/src/ui/components/SwitchNetworkUI.tsx @@ -0,0 +1,33 @@ +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; +import { Box, Heading, Copyable, Bold } from '@metamask/snaps-sdk/jsx'; + +import { NetworkUI } from '../fragments'; + +export type SwitchNetworkUIProps = { + name: string; + chainId: string; +}; + +/** + * Builds a UI component for confirming a network switch. + * + * @param options - The options to configure the component. + * @param options.name - The name of the blockchain network to switch to. + * @param options.chainId - The chain ID of the target blockchain network. + * @returns A JSX component for the user to confirm the network switch. + */ +export const SwitchNetworkUI: SnapComponent = ({ + name, + chainId, +}) => { + return ( + + Do you want to switch to this network? + + + Chain ID + + + + ); +}; diff --git a/packages/starknet-snap/src/ui/components/WatchAssetUI.tsx b/packages/starknet-snap/src/ui/components/WatchAssetUI.tsx new file mode 100644 index 00000000..fad88f7e --- /dev/null +++ b/packages/starknet-snap/src/ui/components/WatchAssetUI.tsx @@ -0,0 +1,62 @@ +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; +import { + Box, + Divider, + Heading, + Section, + Text, + Row, +} from '@metamask/snaps-sdk/jsx'; + +import type { Erc20Token } from '../../types/snapState'; +import { AddressUI, NetworkUI } from '../fragments'; + +export type WatchAssetUIProps = { + networkName: string; + chainId: string; + token: Erc20Token; +}; + +/** + * Builds a UI component for confirming the addition of an ERC-20 token. + * + * @param options - The options to configure the component. + * @param options.networkName - The name of the blockchain network (e.g., Ethereum, Binance Smart Chain). + * @param options.chainId - The chain ID of the blockchain network. + * @param options.token - The ERC-20 token details, including its name, symbol, address, and decimals. + * @returns A JSX component for the user to confirm adding the token. + */ +export const WatchAssetUI: SnapComponent = ({ + networkName, + chainId, + token, +}) => { + const { name, symbol, address, decimals } = token; + return ( + + Do you want to add this token? + + +
+ +
+ {name ? ( + + {name} + + ) : null} + {symbol ? ( + + {symbol} + + ) : null} + {decimals !== null && ( + + {decimals.toString()} + + )} +
+
+
+ ); +}; diff --git a/packages/starknet-snap/src/ui/utils.tsx b/packages/starknet-snap/src/ui/utils.tsx index 2ee82ae2..0526881d 100644 --- a/packages/starknet-snap/src/ui/utils.tsx +++ b/packages/starknet-snap/src/ui/utils.tsx @@ -1,3 +1,6 @@ +import type { DialogResult } from '@metamask/snaps-sdk'; +import { DialogType } from '@metamask/snaps-sdk'; + import type { FormattedCallData, Network, @@ -10,9 +13,146 @@ import { import { getBalance } from '../utils/starknetUtils'; import type { ExecuteTxnUIErrors } from './components'; import { ExecuteTxnUI } from './components'; +import { + DisplayPrivateKeyAlertUI, + DisplayPrivateKeyDialogUI, +} from './components/DisplayPrivateKeyUI'; +import type { SignDeclareTransactionUIProps } from './components/SignDeclareTransactionUI'; +import { SignDeclareTransactionUI } from './components/SignDeclareTransactionUI'; +import type { SignMessageUIProps } from './components/SignMessageUI'; +import { SignMessageUI } from './components/SignMessageUI'; +import type { SignTransactionUIProps } from './components/SignTransactionUI'; +import { SignTransactionUI } from './components/SignTransactionUI'; +import type { SwitchNetworkUIProps } from './components/SwitchNetworkUI'; +import { SwitchNetworkUI } from './components/SwitchNetworkUI'; +import type { WatchAssetUIProps } from './components/WatchAssetUI'; +import { WatchAssetUI } from './components/WatchAssetUI'; import { LoadingUI } from './fragments/LoadingUI'; import type { TokenTotals } from './types'; +/** + * Renders a confirmation dialog for adding a token to the wallet. + * + * @param props - The properties for the WatchAssetUI component. + * @returns A promise that resolves to the user's decision in the dialog. + */ +export async function renderWatchAssetUI( + props: WatchAssetUIProps, +): Promise { + return await snap.request({ + method: 'snap_dialog', + params: { + type: DialogType.Confirmation, + content: , + }, + }); +} + +/** + * Renders a confirmation dialog for switching to a different network. + * + * @param props - The properties for the SwitchNetworkUI component. + * @returns A promise that resolves to the user's decision in the dialog. + */ +export async function renderSwitchNetworkUI( + props: SwitchNetworkUIProps, +): Promise { + return await snap.request({ + method: 'snap_dialog', + params: { + type: DialogType.Confirmation, + content: , + }, + }); +} + +/** + * Renders a confirmation dialog for signing a transaction. + * + * @param props - The properties for the SignTransactionUI component. + * @returns A promise that resolves to the user's decision in the dialog. + */ +export async function renderSignTransactionUI( + props: SignTransactionUIProps, +): Promise { + return await snap.request({ + method: 'snap_dialog', + params: { + type: DialogType.Confirmation, + content: , + }, + }); +} + +/** + * Renders a confirmation dialog for signing a message. + * + * @param props - The properties for the SignMessageUI component. + * @returns A promise that resolves to the user's decision in the dialog. + */ +export async function renderSignMessageUI( + props: SignMessageUIProps, +): Promise { + return await snap.request({ + method: 'snap_dialog', + params: { + type: DialogType.Confirmation, + content: , + }, + }); +} + +/** + * Renders a confirmation dialog for signing a Declare transaction. + * + * @param props - The properties for the SignDeclareTransactionUI component. + * @returns A promise that resolves to the user's decision in the dialog. + */ +export async function renderSignDeclareTransactionUI( + props: SignDeclareTransactionUIProps, +): Promise { + return await snap.request({ + method: 'snap_dialog', + params: { + type: DialogType.Confirmation, + content: , + }, + }); +} + +/** + * Renders a confirmation dialog asking the user to confirm displaying their private key. + * + * @returns A promise that resolves to the user's decision in the dialog. + */ +export async function renderDisplayPrivateKeyConfirmUI(): Promise { + return await snap.request({ + method: 'snap_dialog', + params: { + type: DialogType.Confirmation, + content: , + }, + }); +} + +/** + * Renders an alert dialog displaying the user's private key securely. + * + * @param privateKey - The private key to display in the alert dialog. + * @returns A promise that resolves when the dialog is dismissed. + */ +export async function renderDisplayPrivateKeyAlertUI( + privateKey: string, +): Promise { + await snap.request({ + method: 'snap_dialog', + params: { + type: DialogType.Alert, + content: , + }, + }); +} + /** * Accumulate the total amount for all tokens involved in calls and fees. * From 9f43a228e844ab200984a0b5a1f8ff7bb0d8288d Mon Sep 17 00:00:00 2001 From: khanti42 Date: Fri, 22 Nov 2024 09:12:41 +0100 Subject: [PATCH 31/50] fix: wallet-ui asset icon management (#428) * fix: wallet-ui asset icon management * chore: fix comments --- .../wallet-ui/src/assets/images/usdc-icon.svg | 5 ++++ .../wallet-ui/src/assets/images/usdt-icon.svg | 25 +++++++++++++++++++ packages/wallet-ui/src/assets/types/assets.ts | 25 +++++++++---------- .../AssetListItem/AssetListItem.view.tsx | 2 +- 4 files changed, 43 insertions(+), 14 deletions(-) create mode 100644 packages/wallet-ui/src/assets/images/usdc-icon.svg create mode 100644 packages/wallet-ui/src/assets/images/usdt-icon.svg diff --git a/packages/wallet-ui/src/assets/images/usdc-icon.svg b/packages/wallet-ui/src/assets/images/usdc-icon.svg new file mode 100644 index 00000000..697dfda9 --- /dev/null +++ b/packages/wallet-ui/src/assets/images/usdc-icon.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/packages/wallet-ui/src/assets/images/usdt-icon.svg b/packages/wallet-ui/src/assets/images/usdt-icon.svg new file mode 100644 index 00000000..6746ba37 --- /dev/null +++ b/packages/wallet-ui/src/assets/images/usdt-icon.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/wallet-ui/src/assets/types/assets.ts b/packages/wallet-ui/src/assets/types/assets.ts index 4743793f..f4aadb3c 100644 --- a/packages/wallet-ui/src/assets/types/assets.ts +++ b/packages/wallet-ui/src/assets/types/assets.ts @@ -2,19 +2,18 @@ import ethIcon from '../images/eth-icon.svg'; import starknetIcon from '../images/starknet-icon.svg'; import daiIcon from '../images/dai-icon.svg'; import aaveIcon from '../images/aave-icon.svg'; +import usdcIcon from '../images/usdc-icon.svg'; +import usdtIcon from '../images/usdt-icon.svg'; -export const assetIcons = [ethIcon, daiIcon, aaveIcon]; -export enum AssetIconsIndex { - Ether = 0, - DAI = 1, - AAVE = 2, -} - -export const getAssetIcon = (assetName: string): string => { - let iconIndex = -1; - if (Object.keys(AssetIconsIndex).indexOf(assetName) !== -1) { - iconIndex = AssetIconsIndex[assetName as keyof typeof AssetIconsIndex]; - } +export const assetIcons: Record = { + ETH: ethIcon, + DAI: daiIcon, + AAVE: aaveIcon, + USDC: usdcIcon, + USDT: usdtIcon, + STRK: starknetIcon, // Default to starknetIcon for STRK +}; - return assetName ? assetIcons[iconIndex] : starknetIcon; +export const getAssetIcon = (assetSymbol: string): string => { + return assetIcons[assetSymbol] || starknetIcon; // Use starknetIcon as fallback }; diff --git a/packages/wallet-ui/src/components/ui/molecule/AssetsList/AssetListItem/AssetListItem.view.tsx b/packages/wallet-ui/src/components/ui/molecule/AssetsList/AssetListItem/AssetListItem.view.tsx index a7928240..cfc51277 100644 --- a/packages/wallet-ui/src/components/ui/molecule/AssetsList/AssetListItem/AssetListItem.view.tsx +++ b/packages/wallet-ui/src/components/ui/molecule/AssetsList/AssetListItem/AssetListItem.view.tsx @@ -28,7 +28,7 @@ export const AssetListItemView = ({ From fe2b8a53e3fb791880dba397ab8632afc1e31090 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 25 Nov 2024 16:39:29 +0800 Subject: [PATCH 32/50] chore: add unit test for `UserInputEventController` (#432) * chore: add unit test for input controller * chore: remove unused mock * chore: update test * chore: update mock test * chore: update type --- .../starknet-snap/src/__tests__/helper.ts | 43 +- .../src/rpcs/__tests__/helper.ts | 22 - .../src/rpcs/estimate-fee.test.ts | 4 +- .../src/rpcs/execute-txn.test.ts | 6 +- .../src/state/__tests__/helper.ts | 51 ++ .../user-input-event-controller.test.ts | 465 ++++++++++++++++++ .../user-input-event-controller.ts | 3 +- .../src/utils/starknetUtils.test.ts | 4 +- 8 files changed, 565 insertions(+), 33 deletions(-) create mode 100644 packages/starknet-snap/src/ui/controllers/user-input-event-controller.test.ts diff --git a/packages/starknet-snap/src/__tests__/helper.ts b/packages/starknet-snap/src/__tests__/helper.ts index 6eed3806..f0b6f319 100644 --- a/packages/starknet-snap/src/__tests__/helper.ts +++ b/packages/starknet-snap/src/__tests__/helper.ts @@ -2,6 +2,8 @@ import { BIP44CoinTypeNode, getBIP44AddressKeyDeriver, } from '@metamask/key-tree'; +import type { UserInputEvent } from '@metamask/snaps-sdk'; +import { UserInputEventType } from '@metamask/snaps-sdk'; import { generateMnemonic } from 'bip39'; import { getRandomValues } from 'crypto'; import type { constants, EstimateFee } from 'starknet'; @@ -17,6 +19,7 @@ import { } from 'starknet'; import { v4 as uuidv4 } from 'uuid'; +import { FeeToken } from '../types/snapApi'; import type { AccContract, Transaction, @@ -83,7 +86,7 @@ export async function generateBip44Entropy( * @returns An array of StarknetAccount object. */ export async function generateAccounts( - network: constants.StarknetChainId, + network: constants.StarknetChainId | string, cnt: number = 1, cairoVersion = '1', mnemonic?: string, @@ -298,7 +301,7 @@ export function generateTransactionRequests({ contractAddresses = PRELOADED_TOKENS.map((token) => token.address), cnt = 1, }: { - chainId: constants.StarknetChainId; + chainId: constants.StarknetChainId | string; address: string; contractAddresses?: string[]; cnt?: number; @@ -359,12 +362,13 @@ export function generateTransactionRequests({ return requests; } + /** * Method to generate a mock estimate fee response. * * @returns An array containing a mock EstimateFee object. */ -export function getEstimateFees() { +export function generateEstimateFeesResponse() { return [ { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -393,3 +397,36 @@ export function getEstimateFees() { } as unknown as EstimateFee, ]; } + +/** + * Method to generate a mock input event. + * + * @param params - The parameter for generate the mock input event. + * @param params.transactionRequest - The transaction request object. + * @param [params.eventValue] - The value of the event. + * @param [params.eventType] - The type of the event. + * @param [params.eventName] - The name of the event. + * @returns An array containing a mock input event object. + */ +export function generateInputEvent({ + transactionRequest, + eventValue = FeeToken.ETH, + eventType = UserInputEventType.InputChangeEvent, + eventName = 'feeTokenSelector', +}: { + transactionRequest: TransactionRequest; + eventValue?: string; + eventType?: UserInputEventType; + eventName?: string; +}) { + return { + event: { + name: eventName, + type: eventType, + value: eventValue, + } as unknown as UserInputEvent, + context: { + request: transactionRequest, + }, + }; +} diff --git a/packages/starknet-snap/src/rpcs/__tests__/helper.ts b/packages/starknet-snap/src/rpcs/__tests__/helper.ts index 8d20363f..0aee47be 100644 --- a/packages/starknet-snap/src/rpcs/__tests__/helper.ts +++ b/packages/starknet-snap/src/rpcs/__tests__/helper.ts @@ -3,7 +3,6 @@ import type { constants } from 'starknet'; import type { StarknetAccount } from '../../__tests__/helper'; import { generateAccounts, generateRandomValue } from '../../__tests__/helper'; -import { TransactionRequestStateManager } from '../../state/request-state-manager'; import type { SnapState } from '../../types/snapState'; import * as snapUiUtils from '../../ui/utils'; import { getExplorerUrl, shortenAddress, toJson } from '../../utils'; @@ -11,27 +10,6 @@ import * as snapHelper from '../../utils/snap'; import * as snapUtils from '../../utils/snapUtils'; import * as starknetUtils from '../../utils/starknetUtils'; -export const mockTransactionRequestStateManager = () => { - const upsertTransactionRequestSpy = jest.spyOn( - TransactionRequestStateManager.prototype, - 'upsertTransactionRequest', - ); - const getTransactionRequestSpy = jest.spyOn( - TransactionRequestStateManager.prototype, - 'getTransactionRequest', - ); - const removeTransactionRequestSpy = jest.spyOn( - TransactionRequestStateManager.prototype, - 'removeTransactionRequest', - ); - - return { - upsertTransactionRequestSpy, - getTransactionRequestSpy, - removeTransactionRequestSpy, - }; -}; - /** * * @param chainId diff --git a/packages/starknet-snap/src/rpcs/estimate-fee.test.ts b/packages/starknet-snap/src/rpcs/estimate-fee.test.ts index 87e1234a..9ab8a2af 100644 --- a/packages/starknet-snap/src/rpcs/estimate-fee.test.ts +++ b/packages/starknet-snap/src/rpcs/estimate-fee.test.ts @@ -2,7 +2,7 @@ import type { Invocations } from 'starknet'; import { constants, TransactionType } from 'starknet'; import type { Infer } from 'superstruct'; -import { getEstimateFees } from '../__tests__/helper'; +import { generateEstimateFeesResponse } from '../__tests__/helper'; import { FeeTokenUnit } from '../types/snapApi'; import { STARKNET_SEPOLIA_TESTNET_NETWORK } from '../utils/constants'; import { InvalidRequestParamsError } from '../utils/exceptions'; @@ -45,7 +45,7 @@ const prepareMockEstimateFee = ({ details: { version }, } as unknown as EstimateFeeParams; - const estimateResults = getEstimateFees(); + const estimateResults = generateEstimateFeesResponse(); const estimateBulkFeeRespMock = { suggestedMaxFee: BigInt(1000000000000000).toString(10), diff --git a/packages/starknet-snap/src/rpcs/execute-txn.test.ts b/packages/starknet-snap/src/rpcs/execute-txn.test.ts index d49419fc..81f6d64a 100644 --- a/packages/starknet-snap/src/rpcs/execute-txn.test.ts +++ b/packages/starknet-snap/src/rpcs/execute-txn.test.ts @@ -2,7 +2,8 @@ import type { UniversalDetails, Call, InvokeFunctionResponse } from 'starknet'; import { constants } from 'starknet'; import callsExamples from '../__tests__/fixture/callsExamples.json'; // Assuming you have a similar fixture -import { getEstimateFees } from '../__tests__/helper'; +import { generateEstimateFeesResponse } from '../__tests__/helper'; +import { mockTransactionRequestStateManager } from '../state/__tests__/helper'; import type { FeeTokenUnit } from '../types/snapApi'; import { STARKNET_SEPOLIA_TESTNET_NETWORK } from '../utils/constants'; import { @@ -14,7 +15,6 @@ import { executeTxn as executeTxnUtil } from '../utils/starknetUtils'; import { generateRandomFee, mockAccount, - mockTransactionRequestStateManager, prepareConfirmDialogInteractiveUI, prepareMockAccount, } from './__tests__/helper'; @@ -53,7 +53,7 @@ const prepareMockExecuteTxn = async ( transaction_hash: transactionHash, }; - const estimateResults = getEstimateFees(); + const estimateResults = generateEstimateFeesResponse(); const getEstimatedFeesRepsMock = { suggestedMaxFee: generateRandomFee('1000000000000000', '2000000000000000'), diff --git a/packages/starknet-snap/src/state/__tests__/helper.ts b/packages/starknet-snap/src/state/__tests__/helper.ts index 68d47bc3..6f31e09c 100644 --- a/packages/starknet-snap/src/state/__tests__/helper.ts +++ b/packages/starknet-snap/src/state/__tests__/helper.ts @@ -7,7 +7,14 @@ import type { Transaction, TransactionRequest, } from '../../types/snapState'; +import { + ETHER_SEPOLIA_TESTNET, + STRK_SEPOLIA_TESTNET, +} from '../../utils/constants'; import * as snapHelper from '../../utils/snap'; +import { NetworkStateManager } from '../network-state-manager'; +import { TransactionRequestStateManager } from '../request-state-manager'; +import { TokenStateManager } from '../token-state-manager'; jest.mock('../../utils/snap'); jest.mock('../../utils/logger'); @@ -51,3 +58,47 @@ export const mockState = async ({ state, }; }; + +export const mockTokenStateManager = () => { + const getEthTokenSpy = jest.spyOn(TokenStateManager.prototype, 'getEthToken'); + const getStrkTokenSpy = jest.spyOn( + TokenStateManager.prototype, + 'getStrkToken', + ); + getStrkTokenSpy.mockResolvedValue(STRK_SEPOLIA_TESTNET); + getEthTokenSpy.mockResolvedValue(ETHER_SEPOLIA_TESTNET); + + return { + getEthTokenSpy, + getStrkTokenSpy, + }; +}; + +export const mockTransactionRequestStateManager = () => { + const upsertTransactionRequestSpy = jest.spyOn( + TransactionRequestStateManager.prototype, + 'upsertTransactionRequest', + ); + const getTransactionRequestSpy = jest.spyOn( + TransactionRequestStateManager.prototype, + 'getTransactionRequest', + ); + const removeTransactionRequestSpy = jest.spyOn( + TransactionRequestStateManager.prototype, + 'removeTransactionRequest', + ); + + return { + upsertTransactionRequestSpy, + getTransactionRequestSpy, + removeTransactionRequestSpy, + }; +}; + +export const mockNetworkStateManager = (network: Network) => { + const getNetworkSpy = jest.spyOn(NetworkStateManager.prototype, 'getNetwork'); + getNetworkSpy.mockResolvedValue(network); + return { + getNetworkSpy, + }; +}; diff --git a/packages/starknet-snap/src/ui/controllers/user-input-event-controller.test.ts b/packages/starknet-snap/src/ui/controllers/user-input-event-controller.test.ts new file mode 100644 index 00000000..17270a35 --- /dev/null +++ b/packages/starknet-snap/src/ui/controllers/user-input-event-controller.test.ts @@ -0,0 +1,465 @@ +import type { InterfaceContext, UserInputEvent } from '@metamask/snaps-sdk'; +import { constants, ec, num as numUtils, TransactionType } from 'starknet'; + +import type { StarknetAccount } from '../../__tests__/helper'; +import { + generateAccounts, + generateTransactionRequests, + generateEstimateFeesResponse, + generateInputEvent, +} from '../../__tests__/helper'; +import { + mockTransactionRequestStateManager, + mockNetworkStateManager, + mockTokenStateManager, +} from '../../state/__tests__/helper'; +import { FeeToken, FeeTokenUnit } from '../../types/snapApi'; +import type { Erc20Token } from '../../types/snapState'; +import { + ETHER_SEPOLIA_TESTNET, + STARKNET_TESTNET_NETWORK, + STRK_SEPOLIA_TESTNET, +} from '../../utils/constants'; +import * as keyPairUtils from '../../utils/keyPair'; +import * as StarknetUtils from '../../utils/starknetUtils'; +import * as UiUtils from '../utils'; +import { UserInputEventController } from './user-input-event-controller'; + +jest.mock('../../utils/logger'); + +class MockUserInputEventController extends UserInputEventController { + async deriveAccount(index: number) { + return super.deriveAccount(index); + } + + feeTokenToTransactionVersion(feeToken: FeeToken) { + return super.feeTokenToTransactionVersion(feeToken); + } + + async getTokenAddress(chainId: string, feeToken: FeeToken) { + return super.getTokenAddress(chainId, feeToken); + } + + async getNetwork(chainId: string) { + return super.getNetwork(chainId); + } + + async handleFeeTokenChange() { + return super.handleFeeTokenChange(); + } +} + +describe('UserInputEventController', () => { + const createMockController = ({ + eventId = 'mock-event-id', + event = {} as UserInputEvent, + context = {} as InterfaceContext, + }: { + eventId?: string; + event?: UserInputEvent; + context?: InterfaceContext; + }) => { + return new MockUserInputEventController(eventId, event, context); + }; + + const mockKeyPairUtils = ({ addressKey, index }) => { + const getAddressKeySpy = jest.spyOn(keyPairUtils, 'getAddressKey'); + getAddressKeySpy.mockResolvedValue({ + addressKey, + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + derivationPath: `m / bip32:${9004}' / bip32:${0}' / bip32:${0}' / bip32:${index}'`, + }); + return { + getAddressKeySpy, + }; + }; + + const mockDeriveAccount = (account: StarknetAccount) => { + const deriveAccountSpy = jest.spyOn( + MockUserInputEventController.prototype, + 'deriveAccount', + ); + deriveAccountSpy.mockResolvedValue({ + publicKey: account.publicKey, + privateKey: account.privateKey, + }); + return { + deriveAccountSpy, + }; + }; + + const mockEstimateFee = (feeToken: FeeToken) => { + const getEstimatedFeesSpy = jest.spyOn(StarknetUtils, 'getEstimatedFees'); + const mockEstimateFeeResponse = generateEstimateFeesResponse(); + const concatedFee = StarknetUtils.addFeesFromAllTransactions( + mockEstimateFeeResponse, + ); + + const mockGetEstimatedFeesResponse = { + suggestedMaxFee: concatedFee.suggestedMaxFee.toString(10), + overallFee: concatedFee.overall_fee.toString(10), + unit: FeeTokenUnit[feeToken], + includeDeploy: true, + estimateResults: mockEstimateFeeResponse, + }; + + getEstimatedFeesSpy.mockResolvedValue(mockGetEstimatedFeesResponse); + + return { + getEstimatedFeesSpy, + mockGetEstimatedFeesResponse, + }; + }; + + const mockUpdateExecuteTxnFlow = () => { + const updateExecuteTxnFlowSpy = jest.spyOn(UiUtils, 'updateExecuteTxnFlow'); + updateExecuteTxnFlowSpy.mockReturnThis(); + return { + updateExecuteTxnFlowSpy, + }; + }; + + const mockHasSufficientFundsForFee = (result = true) => { + const hasSufficientFundsForFeeSpy = jest.spyOn( + UiUtils, + 'hasSufficientFundsForFee', + ); + hasSufficientFundsForFeeSpy.mockResolvedValue(result); + + return { + hasSufficientFundsForFeeSpy, + }; + }; + + const mockHandleFeeTokenChange = () => { + const handleFeeTokenChangeSpy = jest.spyOn( + MockUserInputEventController.prototype, + 'handleFeeTokenChange', + ); + handleFeeTokenChangeSpy.mockReturnThis(); + return { + handleFeeTokenChangeSpy, + }; + }; + + describe('deriveAccount', () => { + it('returns the privateKey and Public of the derived account', async () => { + const { chainId } = STARKNET_TESTNET_NETWORK; + const [account] = await generateAccounts(chainId, 1); + + const addressKey = account.privateKey; + mockKeyPairUtils({ addressKey, index: 0 }); + + const publicKey = ec.starkCurve.getStarkKey(addressKey); + const privateKey = numUtils.toHex(addressKey); + + const controller = createMockController({}); + const result = await controller.deriveAccount(0); + expect(result).toStrictEqual({ publicKey, privateKey }); + }); + }); + + describe('feeTokenToTransactionVersion', () => { + it.each([ + { + feeToken: FeeToken.STRK, + transactionVersion: constants.TRANSACTION_VERSION.V3, + }, + { + feeToken: FeeToken.ETH, + transactionVersion: undefined, + }, + ])( + 'returns transaction version $transactionVersion if the fee token is $feeToken', + ({ feeToken, transactionVersion }) => { + const controller = createMockController({}); + expect(controller.feeTokenToTransactionVersion(feeToken)).toStrictEqual( + transactionVersion, + ); + }, + ); + }); + + describe('getTokenAddress', () => { + it.each([ + { + feeToken: FeeToken.STRK, + token: STRK_SEPOLIA_TESTNET, + }, + { + feeToken: FeeToken.ETH, + token: ETHER_SEPOLIA_TESTNET, + }, + { + feeToken: undefined, + token: ETHER_SEPOLIA_TESTNET, + }, + ])( + 'returns the $token.name address for the fee token is $feeToken', + async ({ feeToken, token }) => { + const { chainId } = STARKNET_TESTNET_NETWORK; + mockTokenStateManager(); + + const controller = createMockController({}); + // feeToken could be undefined, so we have to force to cast it as FeeToken + const result = await controller.getTokenAddress( + chainId, + feeToken as unknown as FeeToken, + ); + + expect(result).toStrictEqual(token.address); + }, + ); + + it('throws `Token not found` error if the token is not found', async () => { + const { chainId } = STARKNET_TESTNET_NETWORK; + const { getEthTokenSpy } = mockTokenStateManager(); + getEthTokenSpy.mockResolvedValue(null); + + const controller = createMockController({}); + await expect( + controller.getTokenAddress(chainId, FeeToken.ETH), + ).rejects.toThrow('Token not found'); + }); + }); + + describe('getNetwork', () => { + it('returns the network with the given chainId', async () => { + const network = STARKNET_TESTNET_NETWORK; + mockNetworkStateManager(network); + + const controller = createMockController({}); + const result = await controller.getNetwork(network.chainId); + + expect(result).toStrictEqual(network); + }); + + it('throws `Network not found` error if the network is not found', async () => { + const network = STARKNET_TESTNET_NETWORK; + const { getNetworkSpy } = mockNetworkStateManager(network); + getNetworkSpy.mockResolvedValue(null); + + const controller = createMockController({}); + await expect(controller.getNetwork(network.chainId)).rejects.toThrow( + 'Network not found', + ); + }); + }); + + describe('handleEvent', () => { + const prepareHandleEvent = async () => { + const { chainId } = STARKNET_TESTNET_NETWORK; + const [account] = await generateAccounts(chainId, 1); + const [transactionRequest] = generateTransactionRequests({ + chainId, + address: account.address, + }); + + const event = generateInputEvent({ + transactionRequest, + eventValue: FeeToken.STRK, + }); + const { getTransactionRequestSpy } = mockTransactionRequestStateManager(); + getTransactionRequestSpy.mockResolvedValue(transactionRequest); + + const { handleFeeTokenChangeSpy } = mockHandleFeeTokenChange(); + + const controller = createMockController(event); + + return { + controller, + getTransactionRequestSpy, + handleFeeTokenChangeSpy, + transactionRequest, + event, + }; + }; + + it('calls `handleFeeTokenChange` if the event key is `FeeTokenSelectorEventKey.FeeTokenChange`', async () => { + const { + controller, + getTransactionRequestSpy, + handleFeeTokenChangeSpy, + transactionRequest, + } = await prepareHandleEvent(); + await controller.handleEvent(); + + expect(getTransactionRequestSpy).toHaveBeenCalledWith({ + requestId: transactionRequest.id, + }); + expect(handleFeeTokenChangeSpy).toHaveBeenCalledTimes(1); + }); + + it('throws `Transaction request not found` error if the transaction request not found', async () => { + const { controller, getTransactionRequestSpy } = + await prepareHandleEvent(); + getTransactionRequestSpy.mockResolvedValue(null); + + await expect(controller.handleEvent()).rejects.toThrow( + 'Transaction request not found', + ); + }); + + it.each([undefined, 'other-event'])( + 'does nothing if the event key is not `FeeTokenSelectorEventKey.FeeTokenChange` - event name: %s', + async (eventName) => { + const { handleFeeTokenChangeSpy, event } = await prepareHandleEvent(); + + event.event.name = eventName; + const controller = createMockController(event); + await controller.handleEvent(); + + expect(handleFeeTokenChangeSpy).toHaveBeenCalledTimes(0); + }, + ); + }); + + describe('handleFeeTokenChange', () => { + const prepareHandleFeeTokenChange = async ( + feeToken: FeeToken = FeeToken.STRK, + ) => { + const network = STARKNET_TESTNET_NETWORK; + const { chainId } = network; + + const [account] = await generateAccounts(chainId, 1); + const [transactionRequest] = generateTransactionRequests({ + chainId, + address: account.address, + }); + + const event = generateInputEvent({ + transactionRequest, + eventValue: feeToken, + }); + + mockNetworkStateManager(network); + mockDeriveAccount(account); + mockTokenStateManager(); + + return { + ...mockHasSufficientFundsForFee(), + ...mockUpdateExecuteTxnFlow(), + ...mockEstimateFee(feeToken), + ...mockTransactionRequestStateManager(), + event, + transactionRequest, + account, + network, + feeToken, + }; + }; + + it.each([STRK_SEPOLIA_TESTNET, ETHER_SEPOLIA_TESTNET])( + 'updates the transaction request with the updated estimated fee: feeToken - %symbol', + async (token: Erc20Token) => { + const feeToken = FeeToken[token.symbol]; + const { + event, + account, + network, + getEstimatedFeesSpy, + hasSufficientFundsForFeeSpy, + updateExecuteTxnFlowSpy, + mockGetEstimatedFeesResponse, + upsertTransactionRequestSpy, + transactionRequest, + } = await prepareHandleFeeTokenChange(feeToken); + const feeTokenAddress = token.address; + const { signer, calls } = transactionRequest; + const { publicKey, privateKey, address } = account; + const { suggestedMaxFee } = mockGetEstimatedFeesResponse; + + const controller = createMockController(event); + await controller.handleFeeTokenChange(); + + expect(getEstimatedFeesSpy).toHaveBeenCalledWith( + network, + signer, + privateKey, + publicKey, + [ + { + type: TransactionType.INVOKE, + payload: calls.map((call) => ({ + calldata: call.calldata, + contractAddress: call.contractAddress, + entrypoint: call.entrypoint, + })), + }, + ], + { + version: controller.feeTokenToTransactionVersion(feeToken), + }, + ); + expect(hasSufficientFundsForFeeSpy).toHaveBeenCalledWith({ + address, + network, + calls, + feeTokenAddress, + suggestedMaxFee, + }); + // transactionRequest will be pass by reference, so we can use this to check the updated value + expect(transactionRequest.maxFee).toStrictEqual(suggestedMaxFee); + expect(updateExecuteTxnFlowSpy).toHaveBeenCalledWith( + controller.eventId, + transactionRequest, + ); + expect(upsertTransactionRequestSpy).toHaveBeenCalledWith( + transactionRequest, + ); + }, + ); + + it('updates the transaction request with an insufficient funds error message if the account balance is insufficient to cover the fee.', async () => { + const { + event, + hasSufficientFundsForFeeSpy, + transactionRequest, + updateExecuteTxnFlowSpy, + upsertTransactionRequestSpy, + feeToken, + } = await prepareHandleFeeTokenChange(); + hasSufficientFundsForFeeSpy.mockResolvedValue(false); + + const controller = createMockController(event); + await controller.handleFeeTokenChange(); + + expect(upsertTransactionRequestSpy).not.toHaveBeenCalled(); + expect(updateExecuteTxnFlowSpy).toHaveBeenCalledWith( + controller.eventId, + transactionRequest, + { + errors: { + fees: `Not enough ${feeToken} to pay for fee`, + }, + }, + ); + }); + + it('updates the transaction request with an general error message if other error was thrown.', async () => { + const { + event, + hasSufficientFundsForFeeSpy, + transactionRequest, + updateExecuteTxnFlowSpy, + upsertTransactionRequestSpy, + } = await prepareHandleFeeTokenChange(); + // Simulate an error thrown to test the error handling + hasSufficientFundsForFeeSpy.mockRejectedValue(false); + + const controller = createMockController(event); + await controller.handleFeeTokenChange(); + + expect(upsertTransactionRequestSpy).not.toHaveBeenCalled(); + expect(updateExecuteTxnFlowSpy).toHaveBeenCalledWith( + controller.eventId, + transactionRequest, + { + errors: { + fees: `Fail to calculate the fees`, + }, + }, + ); + }); + }); +}); diff --git a/packages/starknet-snap/src/ui/controllers/user-input-event-controller.ts b/packages/starknet-snap/src/ui/controllers/user-input-event-controller.ts index 631ab5e4..191f48fc 100644 --- a/packages/starknet-snap/src/ui/controllers/user-input-event-controller.ts +++ b/packages/starknet-snap/src/ui/controllers/user-input-event-controller.ts @@ -60,7 +60,7 @@ export class UserInputEventController { if ( !(await this.reqStateMgr.getTransactionRequest({ - requestId: request.id, + requestId: request?.id, })) ) { throw new Error('Transaction request not found'); @@ -165,6 +165,7 @@ export class UserInputEventController { ); if ( + // TODO: we should create a payment controller class to handle this !(await hasSufficientFundsForFee({ address: signer, network, diff --git a/packages/starknet-snap/src/utils/starknetUtils.test.ts b/packages/starknet-snap/src/utils/starknetUtils.test.ts index 45d882fd..307729d3 100644 --- a/packages/starknet-snap/src/utils/starknetUtils.test.ts +++ b/packages/starknet-snap/src/utils/starknetUtils.test.ts @@ -1,7 +1,7 @@ import type { EstimateFee, Invocations } from 'starknet'; import { constants, TransactionType } from 'starknet'; -import { getEstimateFees } from '../__tests__/helper'; +import { generateEstimateFeesResponse } from '../__tests__/helper'; import { mockAccount, prepareMockAccount } from '../rpcs/__tests__/helper'; import { FeeTokenUnit } from '../types/snapApi'; import type { SnapState } from '../types/snapState'; @@ -40,7 +40,7 @@ describe('getEstimatedFees', () => { const accountDeployedSpy = jest.spyOn(starknetUtils, 'isAccountDeployed'); accountDeployedSpy.mockResolvedValue(deployed); - const estimateResults = getEstimateFees(); + const estimateResults = generateEstimateFeesResponse(); const { resourceBounds } = estimateResults[0]; const estimateFeeResp = { From 08640d399fbf47f08bb276b5c84611bedbb41757 Mon Sep 17 00:00:00 2001 From: khanti42 Date: Mon, 25 Nov 2024 10:40:24 +0100 Subject: [PATCH 33/50] chore: set get-starknet Snap's version through env variable (#435) * chore: fix get-starknet snap version through env variable * chore: add SNAP_VERSION env set in webpack config * chore: pr comments handled * chore: fix lint * chore: updated workflow to use SNAP_VERSION for get-starknet --- .github/workflows/deploy.yml | 2 +- packages/get-starknet/src/wallet.ts | 16 ++++++++++++++-- packages/get-starknet/webpack.config.build.js | 1 + 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a32de511..1e1e5dc7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -111,7 +111,7 @@ jobs: echo "Building Get Starknet with GET_STARKNET_PUBLIC_PATH=$GET_STARKNET_PUBLIC_PATH" - GET_STARKNET_PUBLIC_PATH=$GET_STARKNET_PUBLIC_PATH yarn workspace @consensys/get-starknet build + SNAP_VERSION="${VERSION}" GET_STARKNET_PUBLIC_PATH=$GET_STARKNET_PUBLIC_PATH yarn workspace @consensys/get-starknet build env: SNAP_ENV: ${{ needs.prepare-deployment.outputs.ENV }} VERSION: ${{ needs.prepare-deployment.outputs.VERSION }} diff --git a/packages/get-starknet/src/wallet.ts b/packages/get-starknet/src/wallet.ts index 63d2fe60..1c7ea081 100644 --- a/packages/get-starknet/src/wallet.ts +++ b/packages/get-starknet/src/wallet.ts @@ -58,14 +58,26 @@ export class MetaMaskSnapWallet implements StarknetWindowObject { // eslint-disable-next-line @typescript-eslint/naming-convention, no-restricted-globals static readonly snapId = process.env.SNAP_ID ?? 'npm:@consensys/starknet-snap'; - constructor(metamaskProvider: MetaMaskProvider, snapVersion = '*') { + // eslint-disable-next-line @typescript-eslint/naming-convention, no-restricted-globals + static readonly snapVersion = process.env.SNAP_VERSION ?? '*'; + + /** + * Initializes a new instance of the MetaMaskSnapWallet class. + * + * The Snap version is now enforced globally via a static `snapVersion` property, + * this ensures consistent versioning across all instances and removes the need for consumers to specify it. + * + * @param metamaskProvider - The MetaMask Wallet Provider. + * @param _snapVersion - The `_snapVersion` parameter remains to maintain compatibility with existing usage. + */ + constructor(metamaskProvider: MetaMaskProvider, _snapVersion = '*') { this.id = 'metamask'; this.name = 'Metamask'; this.version = 'v2.0.0'; this.icon = WalletIconMetaData; this.lock = new Mutex(); this.metamaskProvider = metamaskProvider; - this.snap = new MetaMaskSnap(MetaMaskSnapWallet.snapId, snapVersion, this.metamaskProvider); + this.snap = new MetaMaskSnap(MetaMaskSnapWallet.snapId, MetaMaskSnapWallet.snapVersion, this.metamaskProvider); this.isConnected = false; this.#rpcHandlers = new Map([ diff --git a/packages/get-starknet/webpack.config.build.js b/packages/get-starknet/webpack.config.build.js index 82e570e1..22412e2b 100644 --- a/packages/get-starknet/webpack.config.build.js +++ b/packages/get-starknet/webpack.config.build.js @@ -23,6 +23,7 @@ module.exports = (env) => plugins: [ new webpack.DefinePlugin({ 'process.env.SNAP_ID': JSON.stringify(process.env.SNAP_ID || 'npm:@consensys/starknet-snap'), + 'process.env.SNAP_VERSION': JSON.stringify(process.env.SNAP_VERSION || '*'), }), new ModuleFederationPlugin({ name: 'MetaMaskStarknetSnapWallet', From d7d2151e18a0141c19f73804b84c553a39decf01 Mon Sep 17 00:00:00 2001 From: khanti42 Date: Mon, 25 Nov 2024 12:09:28 +0100 Subject: [PATCH 34/50] fix: improve Fee Token Selection UX with Disclaimer and Error Messaging (#433) * chore: add unit test for input controller * chore: remove unused mock * chore: update test * chore: update mock test * chore: update type * fix: improve readability on fee token selection and fallback mechanism * chore: handle pr comment review * fix: rollback request state in case of error in input-event-controller * chore: fix comments --------- Co-authored-by: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> --- .../starknet-snap/src/__tests__/helper.ts | 4 +++ .../user-input-event-controller.test.ts | 33 ++++++++++++------- .../user-input-event-controller.ts | 26 ++++++++++++--- .../src/ui/fragments/FeeTokenSelector.tsx | 5 +++ 4 files changed, 52 insertions(+), 16 deletions(-) diff --git a/packages/starknet-snap/src/__tests__/helper.ts b/packages/starknet-snap/src/__tests__/helper.ts index f0b6f319..6e63fc8b 100644 --- a/packages/starknet-snap/src/__tests__/helper.ts +++ b/packages/starknet-snap/src/__tests__/helper.ts @@ -22,6 +22,7 @@ import { v4 as uuidv4 } from 'uuid'; import { FeeToken } from '../types/snapApi'; import type { AccContract, + Erc20Token, Transaction, TransactionRequest, } from '../types/snapState'; @@ -298,11 +299,13 @@ export function generateTransactions({ export function generateTransactionRequests({ chainId, address, + selectedFeeToken, contractAddresses = PRELOADED_TOKENS.map((token) => token.address), cnt = 1, }: { chainId: constants.StarknetChainId | string; address: string; + selectedFeeToken?: Erc20Token; contractAddresses?: string[]; cnt?: number; }): TransactionRequest[] { @@ -330,6 +333,7 @@ export function generateTransactionRequests({ addressIndex: 0, maxFee: '100', selectedFeeToken: + selectedFeeToken?.symbol ?? feeTokens[Math.floor(generateRandomValue() * feeTokens.length)].symbol, calls: [ { diff --git a/packages/starknet-snap/src/ui/controllers/user-input-event-controller.test.ts b/packages/starknet-snap/src/ui/controllers/user-input-event-controller.test.ts index 17270a35..18209137 100644 --- a/packages/starknet-snap/src/ui/controllers/user-input-event-controller.test.ts +++ b/packages/starknet-snap/src/ui/controllers/user-input-event-controller.test.ts @@ -317,6 +317,7 @@ describe('UserInputEventController', () => { describe('handleFeeTokenChange', () => { const prepareHandleFeeTokenChange = async ( feeToken: FeeToken = FeeToken.STRK, + selectedFeeToken?: Erc20Token, ) => { const network = STARKNET_TESTNET_NETWORK; const { chainId } = network; @@ -325,6 +326,7 @@ describe('UserInputEventController', () => { const [transactionRequest] = generateTransactionRequests({ chainId, address: account.address, + selectedFeeToken, }); const event = generateInputEvent({ @@ -353,6 +355,10 @@ describe('UserInputEventController', () => { 'updates the transaction request with the updated estimated fee: feeToken - %symbol', async (token: Erc20Token) => { const feeToken = FeeToken[token.symbol]; + const selectedFeeToken = + token.symbol === FeeToken.ETH + ? STRK_SEPOLIA_TESTNET + : ETHER_SEPOLIA_TESTNET; const { event, account, @@ -363,7 +369,7 @@ describe('UserInputEventController', () => { mockGetEstimatedFeesResponse, upsertTransactionRequestSpy, transactionRequest, - } = await prepareHandleFeeTokenChange(feeToken); + } = await prepareHandleFeeTokenChange(feeToken, selectedFeeToken); const feeTokenAddress = token.address; const { signer, calls } = transactionRequest; const { publicKey, privateKey, address } = account; @@ -404,9 +410,10 @@ describe('UserInputEventController', () => { controller.eventId, transactionRequest, ); - expect(upsertTransactionRequestSpy).toHaveBeenCalledWith( - transactionRequest, - ); + expect(upsertTransactionRequestSpy).toHaveBeenCalledWith({ + ...transactionRequest, + selectedFeeToken: feeToken, + }); }, ); @@ -430,33 +437,37 @@ describe('UserInputEventController', () => { transactionRequest, { errors: { - fees: `Not enough ${feeToken} to pay for fee`, + fees: `Not enough ${feeToken} to pay for fee, switching back to ${transactionRequest.selectedFeeToken}`, }, }, ); }); - it('updates the transaction request with an general error message if other error was thrown.', async () => { + it('rollback the transaction request and show a general error message if other error was thrown.', async () => { const { event, - hasSufficientFundsForFeeSpy, transactionRequest, updateExecuteTxnFlowSpy, upsertTransactionRequestSpy, } = await prepareHandleFeeTokenChange(); // Simulate an error thrown to test the error handling - hasSufficientFundsForFeeSpy.mockRejectedValue(false); + upsertTransactionRequestSpy.mockRejectedValue(new Error('Failed!')); + const rollbackSnapshot = { + maxFee: transactionRequest.maxFee, + selectedFeeToken: transactionRequest.selectedFeeToken, + includeDeploy: transactionRequest.includeDeploy, + resourceBounds: [...transactionRequest.resourceBounds], + }; const controller = createMockController(event); await controller.handleFeeTokenChange(); - expect(upsertTransactionRequestSpy).not.toHaveBeenCalled(); expect(updateExecuteTxnFlowSpy).toHaveBeenCalledWith( controller.eventId, - transactionRequest, + { ...transactionRequest, ...rollbackSnapshot }, { errors: { - fees: `Fail to calculate the fees`, + fees: `Failed to calculate the fees, switching back to ${transactionRequest.selectedFeeToken}`, }, }, ); diff --git a/packages/starknet-snap/src/ui/controllers/user-input-event-controller.ts b/packages/starknet-snap/src/ui/controllers/user-input-event-controller.ts index 191f48fc..28520697 100644 --- a/packages/starknet-snap/src/ui/controllers/user-input-event-controller.ts +++ b/packages/starknet-snap/src/ui/controllers/user-input-event-controller.ts @@ -130,8 +130,20 @@ export class UserInputEventController { return token.address; } + protected createRollbackSnapshot( + request: TransactionRequest, + ): Partial { + return { + maxFee: request.maxFee, + selectedFeeToken: request.selectedFeeToken, + includeDeploy: request.includeDeploy, + resourceBounds: [...request.resourceBounds], + }; + } + protected async handleFeeTokenChange() { const request = this.context?.request as TransactionRequest; + const rollbackSnapshot = this.createRollbackSnapshot(request); const { addressIndex, calls, signer, chainId } = request; const feeToken = (this.event as InputChangeEvent) .value as unknown as FeeToken; @@ -192,13 +204,17 @@ export class UserInputEventController { } catch (error) { const errorMessage = error instanceof InsufficientFundsError - ? `Not enough ${feeToken} to pay for fee` - : 'Fail to calculate the fees'; + ? `Not enough ${feeToken} to pay for fee, switching back to ${request.selectedFeeToken}` + : `Failed to calculate the fees, switching back to ${request.selectedFeeToken}`; // On failure, display ExecuteTxnUI with an error message - await updateExecuteTxnFlow(this.eventId, request, { - errors: { fees: errorMessage }, - }); + await updateExecuteTxnFlow( + this.eventId, + { ...request, ...rollbackSnapshot }, + { + errors: { fees: errorMessage }, + }, + ); } } } diff --git a/packages/starknet-snap/src/ui/fragments/FeeTokenSelector.tsx b/packages/starknet-snap/src/ui/fragments/FeeTokenSelector.tsx index c8aca08f..8adcefff 100644 --- a/packages/starknet-snap/src/ui/fragments/FeeTokenSelector.tsx +++ b/packages/starknet-snap/src/ui/fragments/FeeTokenSelector.tsx @@ -3,6 +3,7 @@ import { Field, Form, Selector, + Text, SelectorOption, type SnapComponent, } from '@metamask/snaps-sdk/jsx'; @@ -47,6 +48,10 @@ export const FeeTokenSelector: SnapComponent = ({ + + If the chosen token has no funds, the system will automatically switch + to a funded token. + ); }; From ffaeb1bdf78ed46c7a5ace91432672c1506eefe4 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 25 Nov 2024 21:59:46 +0800 Subject: [PATCH 35/50] chore: update test for `UserInputEventController` (#436) * chore: update input event controller test * fix: lint --- .../starknet-snap/src/__tests__/helper.ts | 13 +-- .../user-input-event-controller.test.ts | 104 +++++++++++++----- .../user-input-event-controller.ts | 20 ++-- 3 files changed, 87 insertions(+), 50 deletions(-) diff --git a/packages/starknet-snap/src/__tests__/helper.ts b/packages/starknet-snap/src/__tests__/helper.ts index 6e63fc8b..732ca50f 100644 --- a/packages/starknet-snap/src/__tests__/helper.ts +++ b/packages/starknet-snap/src/__tests__/helper.ts @@ -22,17 +22,14 @@ import { v4 as uuidv4 } from 'uuid'; import { FeeToken } from '../types/snapApi'; import type { AccContract, - Erc20Token, Transaction, TransactionRequest, } from '../types/snapState'; import { ACCOUNT_CLASS_HASH, ACCOUNT_CLASS_HASH_LEGACY, - ETHER_MAINNET, PRELOADED_TOKENS, PROXY_CONTRACT_HASH, - STRK_MAINNET, } from '../utils/constants'; import { grindKey } from '../utils/keyPair'; @@ -299,17 +296,16 @@ export function generateTransactions({ export function generateTransactionRequests({ chainId, address, - selectedFeeToken, + selectedFeeTokens = Object.values(FeeToken), contractAddresses = PRELOADED_TOKENS.map((token) => token.address), cnt = 1, }: { chainId: constants.StarknetChainId | string; address: string; - selectedFeeToken?: Erc20Token; + selectedFeeTokens?: FeeToken[]; contractAddresses?: string[]; cnt?: number; }): TransactionRequest[] { - const feeTokens = [STRK_MAINNET, ETHER_MAINNET]; const request = { chainId: chainId, id: '', @@ -333,8 +329,9 @@ export function generateTransactionRequests({ addressIndex: 0, maxFee: '100', selectedFeeToken: - selectedFeeToken?.symbol ?? - feeTokens[Math.floor(generateRandomValue() * feeTokens.length)].symbol, + selectedFeeTokens[ + Math.floor(generateRandomValue() * selectedFeeTokens.length) + ], calls: [ { contractAddress: diff --git a/packages/starknet-snap/src/ui/controllers/user-input-event-controller.test.ts b/packages/starknet-snap/src/ui/controllers/user-input-event-controller.test.ts index 18209137..05b54532 100644 --- a/packages/starknet-snap/src/ui/controllers/user-input-event-controller.test.ts +++ b/packages/starknet-snap/src/ui/controllers/user-input-event-controller.test.ts @@ -315,23 +315,47 @@ describe('UserInputEventController', () => { }); describe('handleFeeTokenChange', () => { + type PrepareHandleFeeTokenChangeArg = { + feeToken: { + // The fee token that we change from + changeFrom: FeeToken; + // The fee token that we change to + changeTo: FeeToken; + }; + }; + const prepareHandleFeeTokenChange = async ( - feeToken: FeeToken = FeeToken.STRK, - selectedFeeToken?: Erc20Token, + arg: PrepareHandleFeeTokenChangeArg = { + feeToken: { + changeFrom: FeeToken.STRK, + changeTo: FeeToken.ETH, + }, + }, ) => { const network = STARKNET_TESTNET_NETWORK; const { chainId } = network; + const { feeToken } = arg; const [account] = await generateAccounts(chainId, 1); const [transactionRequest] = generateTransactionRequests({ chainId, address: account.address, - selectedFeeToken, + selectedFeeTokens: [feeToken.changeFrom], }); + // Create a copy of the original transaction request, for testing if the transaction request is updated / rolled back + const originalTransactionRequest = { + ...transactionRequest, + // Since only `maxFee`, `selectedFeeToken`, `includeDeploy` and `resourceBounds` has been updated, hence we only need to copy these fields + maxFee: transactionRequest.maxFee, + selectedFeeToken: transactionRequest.selectedFeeToken, + includeDeploy: transactionRequest.includeDeploy, + resourceBounds: [...transactionRequest.resourceBounds], + }; + const event = generateInputEvent({ transactionRequest, - eventValue: feeToken, + eventValue: feeToken.changeTo, }); mockNetworkStateManager(network); @@ -341,24 +365,23 @@ describe('UserInputEventController', () => { return { ...mockHasSufficientFundsForFee(), ...mockUpdateExecuteTxnFlow(), - ...mockEstimateFee(feeToken), + ...mockEstimateFee(feeToken.changeTo), ...mockTransactionRequestStateManager(), event, + originalTransactionRequest, transactionRequest, account, network, - feeToken, + feeToken: feeToken.changeTo, }; }; it.each([STRK_SEPOLIA_TESTNET, ETHER_SEPOLIA_TESTNET])( - 'updates the transaction request with the updated estimated fee: feeToken - %symbol', + 'updates the transaction request with the updated estimated fee: feeToken - $symbol', async (token: Erc20Token) => { - const feeToken = FeeToken[token.symbol]; - const selectedFeeToken = - token.symbol === FeeToken.ETH - ? STRK_SEPOLIA_TESTNET - : ETHER_SEPOLIA_TESTNET; + const feeTokenChangeTo = FeeToken[token.symbol]; + const feeTokenChangeFrom = + token.symbol === FeeToken.ETH ? FeeToken.STRK : FeeToken.ETH; const { event, account, @@ -369,7 +392,13 @@ describe('UserInputEventController', () => { mockGetEstimatedFeesResponse, upsertTransactionRequestSpy, transactionRequest, - } = await prepareHandleFeeTokenChange(feeToken, selectedFeeToken); + } = await prepareHandleFeeTokenChange({ + feeToken: { + changeFrom: feeTokenChangeFrom, + changeTo: feeTokenChangeTo, + }, + }); + const feeTokenAddress = token.address; const { signer, calls } = transactionRequest; const { publicKey, privateKey, address } = account; @@ -394,7 +423,7 @@ describe('UserInputEventController', () => { }, ], { - version: controller.feeTokenToTransactionVersion(feeToken), + version: controller.feeTokenToTransactionVersion(feeTokenChangeTo), }, ); expect(hasSufficientFundsForFeeSpy).toHaveBeenCalledWith({ @@ -410,9 +439,10 @@ describe('UserInputEventController', () => { controller.eventId, transactionRequest, ); + // Make sure the `selectedFeeToken` transaction request has been updated expect(upsertTransactionRequestSpy).toHaveBeenCalledWith({ ...transactionRequest, - selectedFeeToken: feeToken, + selectedFeeToken: feeTokenChangeTo, }); }, ); @@ -421,53 +451,67 @@ describe('UserInputEventController', () => { const { event, hasSufficientFundsForFeeSpy, - transactionRequest, + originalTransactionRequest, updateExecuteTxnFlowSpy, upsertTransactionRequestSpy, feeToken, - } = await prepareHandleFeeTokenChange(); + } = await prepareHandleFeeTokenChange({ + feeToken: { + changeFrom: FeeToken.STRK, + changeTo: FeeToken.ETH, + }, + }); hasSufficientFundsForFeeSpy.mockResolvedValue(false); const controller = createMockController(event); await controller.handleFeeTokenChange(); expect(upsertTransactionRequestSpy).not.toHaveBeenCalled(); + expect(feeToken).not.toStrictEqual( + originalTransactionRequest.selectedFeeToken, + ); + // If the account balance is insufficient to cover the fee, the transaction request should be rolled back or not updated. expect(updateExecuteTxnFlowSpy).toHaveBeenCalledWith( controller.eventId, - transactionRequest, + originalTransactionRequest, { errors: { - fees: `Not enough ${feeToken} to pay for fee, switching back to ${transactionRequest.selectedFeeToken}`, + fees: `Not enough ${feeToken} to pay for fee, switching back to ${originalTransactionRequest.selectedFeeToken}`, }, }, ); }); - it('rollback the transaction request and show a general error message if other error was thrown.', async () => { + it('rollbacks the transaction request with a general error message if another Error was thrown.', async () => { const { event, - transactionRequest, + originalTransactionRequest, updateExecuteTxnFlowSpy, upsertTransactionRequestSpy, - } = await prepareHandleFeeTokenChange(); + feeToken, + } = await prepareHandleFeeTokenChange({ + feeToken: { + changeFrom: FeeToken.STRK, + changeTo: FeeToken.ETH, + }, + }); + // Simulate an error thrown to test the error handling upsertTransactionRequestSpy.mockRejectedValue(new Error('Failed!')); - const rollbackSnapshot = { - maxFee: transactionRequest.maxFee, - selectedFeeToken: transactionRequest.selectedFeeToken, - includeDeploy: transactionRequest.includeDeploy, - resourceBounds: [...transactionRequest.resourceBounds], - }; const controller = createMockController(event); await controller.handleFeeTokenChange(); + expect(feeToken).not.toStrictEqual( + originalTransactionRequest.selectedFeeToken, + ); + // if any Error was thrown, the transaction request should be rolled back or not updated. expect(updateExecuteTxnFlowSpy).toHaveBeenCalledWith( controller.eventId, - { ...transactionRequest, ...rollbackSnapshot }, + originalTransactionRequest, { errors: { - fees: `Failed to calculate the fees, switching back to ${transactionRequest.selectedFeeToken}`, + fees: `Failed to calculate the fees, switching back to ${originalTransactionRequest.selectedFeeToken}`, }, }, ); diff --git a/packages/starknet-snap/src/ui/controllers/user-input-event-controller.ts b/packages/starknet-snap/src/ui/controllers/user-input-event-controller.ts index 28520697..f9224a61 100644 --- a/packages/starknet-snap/src/ui/controllers/user-input-event-controller.ts +++ b/packages/starknet-snap/src/ui/controllers/user-input-event-controller.ts @@ -132,8 +132,9 @@ export class UserInputEventController { protected createRollbackSnapshot( request: TransactionRequest, - ): Partial { + ): TransactionRequest { return { + ...request, maxFee: request.maxFee, selectedFeeToken: request.selectedFeeToken, includeDeploy: request.includeDeploy, @@ -143,7 +144,7 @@ export class UserInputEventController { protected async handleFeeTokenChange() { const request = this.context?.request as TransactionRequest; - const rollbackSnapshot = this.createRollbackSnapshot(request); + const originalRequest = this.createRollbackSnapshot(request); const { addressIndex, calls, signer, chainId } = request; const feeToken = (this.event as InputChangeEvent) .value as unknown as FeeToken; @@ -204,17 +205,12 @@ export class UserInputEventController { } catch (error) { const errorMessage = error instanceof InsufficientFundsError - ? `Not enough ${feeToken} to pay for fee, switching back to ${request.selectedFeeToken}` - : `Failed to calculate the fees, switching back to ${request.selectedFeeToken}`; - + ? `Not enough ${feeToken} to pay for fee, switching back to ${originalRequest.selectedFeeToken}` + : `Failed to calculate the fees, switching back to ${originalRequest.selectedFeeToken}`; // On failure, display ExecuteTxnUI with an error message - await updateExecuteTxnFlow( - this.eventId, - { ...request, ...rollbackSnapshot }, - { - errors: { fees: errorMessage }, - }, - ); + await updateExecuteTxnFlow(this.eventId, originalRequest, { + errors: { fees: errorMessage }, + }); } } } From 6a0236bfede26ad2387cff62fb774ceeb819e6b0 Mon Sep 17 00:00:00 2001 From: khanti42 Date: Mon, 25 Nov 2024 16:13:49 +0100 Subject: [PATCH 36/50] chore: two steps CI CD deployment (#434) * chore: two steps CI/CD deployment * chore: remove env and stagging * chore: fix comments * chore: fix workflow restore-build from cache paths * chore: remove deploy for now --- .../workflows/deploy-wallet-get-starknet.yml | 82 +++++++++++++ .github/workflows/publish-npm.yml | 116 ++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 .github/workflows/deploy-wallet-get-starknet.yml create mode 100644 .github/workflows/publish-npm.yml diff --git a/.github/workflows/deploy-wallet-get-starknet.yml b/.github/workflows/deploy-wallet-get-starknet.yml new file mode 100644 index 00000000..7300d2b4 --- /dev/null +++ b/.github/workflows/deploy-wallet-get-starknet.yml @@ -0,0 +1,82 @@ +name: Deploy Wallet-UI and Get-starknet + +on: + workflow_dispatch: +jobs: + prepare-deployment: + environment: production + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + ref: ${{ github.sha }} + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: 20.x + - name: Prepare Parameters + id: prepare_parameters + run: | + BASE=$(node -p "require('./packages/starknet-snap/package.json').version") + ENV=production + + { + echo "AWS_CLOUDFRONT_DISTRIBUTIONS_ID=${{ vars.AWS_CLOUDFRONT_DISTRIBUTIONS_ID }}" + echo "AWS_S3_GET_STARKNET_URL=${{ vars.AWS_S3_GET_STARKNET_URL }}" + echo "AWS_S3_URL=${{ vars.AWS_S3_URL }}" + echo "GET_STARKNET_PUBLIC_PATH=${{ vars.GET_STARKNET_PUBLIC_PATH }}" + echo "VERSION=${BASE}" + echo "TAG=latest" + echo "ENV=prod" + } >> "$GITHUB_OUTPUT" + outputs: + VERSION: ${{ steps.prepare_parameters.outputs.VERSION }} + TAG: ${{ steps.prepare_parameters.outputs.TAG }} + ENV: ${{ steps.prepare_parameters.outputs.ENV }} + AWS_S3_GET_STARKNET_URL: ${{ steps.prepare_parameters.outputs.AWS_S3_GET_STARKNET_URL }} + AWS_CLOUDFRONT_DISTRIBUTIONS_ID: ${{ steps.prepare_parameters.outputs.AWS_CLOUDFRONT_DISTRIBUTIONS_ID }} + 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 }}-UI_N_GET_STARKNET + + install-build: + needs: + - prepare-deployment + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + ref: ${{ github.sha }} + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: 20.x + - name: Install + run: | + yarn --no-immutable + yarn allow-scripts + - name: Build Snap + run: | + echo "Building UI with version $VERSION" + + REACT_APP_SNAP_VERSION="${VERSION}" yarn workspace wallet-ui build + + echo "Building Get Starknet with GET_STARKNET_PUBLIC_PATH=$GET_STARKNET_PUBLIC_PATH" + + SNAP_VERSION="${VERSION}$" GET_STARKNET_PUBLIC_PATH=$GET_STARKNET_PUBLIC_PATH yarn workspace @consensys/get-starknet build + env: + VERSION: ${{ needs.prepare-deployment.outputs.VERSION }} + GET_STARKNET_PUBLIC_PATH: ${{ needs.prepare-deployment.outputs.GET_STARKNET_PUBLIC_PATH }} + - name: Cache Build + uses: actions/cache@v3 + id: cache + with: + path: | + ./packages/get-starknet/dist/webpack + ./packages/wallet-ui/build + ./node_modules/.yarn-state.yml + key: ${{ needs.prepare-deployment.outputs.CACHE_KEY }} \ No newline at end of file diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml new file mode 100644 index 00000000..e58a152b --- /dev/null +++ b/.github/workflows/publish-npm.yml @@ -0,0 +1,116 @@ +name: Publish NPM + +on: + workflow_dispatch: +jobs: + prepare-deployment: + environment: production + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + ref: ${{ github.sha }} + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: 20.x + - name: Prepare Parameters + id: prepare_parameters + run: | + BASE=$(node -p "require('./packages/starknet-snap/package.json').version") + ENV=production + + { + echo "VERSION=${BASE}" + echo "TAG=latest" + echo "ENV=prod" + echo "LOG_LEVEL=0" + } >> "$GITHUB_OUTPUT" + + outputs: + VERSION: ${{ steps.prepare_parameters.outputs.VERSION }} + TAG: ${{ steps.prepare_parameters.outputs.TAG }} + ENV: ${{ steps.prepare_parameters.outputs.ENV }} + CACHE_KEY: ${{ github.sha }}-${{ steps.prepare_parameters.outputs.ENV }}-SANP + LOG_LEVEL: ${{ steps.prepare_parameters.outputs.LOG_LEVEL }} + + install-build: + needs: + - prepare-deployment + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + ref: ${{ github.sha }} + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: 20.x + - name: Install + run: | + yarn --no-immutable + yarn allow-scripts + - name: Build Snap + run: | + echo "Building Snap with version $VERSION" + + npm --prefix ./packages/starknet-snap version --new-version "$VERSION" --no-git-tag-version --allow-same-version + + yarn workspace @consensys/starknet-snap build + + BUILD_VERSION=$(node -p "require('./packages/starknet-snap/package.json').version") + + if [[ "$VERSION" != "$BUILD_VERSION" ]]; then + echo "Version mismatch" + exit 1 + fi + env: + SNAP_ENV: ${{ needs.prepare-deployment.outputs.ENV }} + VERSION: ${{ needs.prepare-deployment.outputs.VERSION }} + LOG_LEVEL: ${{ needs.prepare-deployment.outputs.LOG_LEVEL }} + VOYAGER_API_KEY: ${{ secrets.VOYAGER_API_KEY }} + ALCHEMY_API_KEY: ${{ secrets.ALCHEMY_API_KEY }} + - name: Cache Build + uses: actions/cache@v3 + id: cache + with: + path: | + ./packages/starknet-snap/package.json + ./packages/starknet-snap/dist + ./packages/starknet-snap/snap.manifest.json + ./node_modules/.yarn-state.yml + key: ${{ needs.prepare-deployment.outputs.CACHE_KEY }} + + publish-npm-dry-run: + runs-on: ubuntu-latest + needs: + - prepare-deployment + - install-build + steps: + - uses: actions/checkout@v3 + with: + ref: ${{ github.sha }} + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: 20.x + - name: Restore Cached Build + uses: actions/cache@v3 + id: restore-build + with: + # add /packages/snap/snap.manifest.json to include an updated shasum from build due to version update in auto PR + path: | + ./packages/starknet-snap/package.json + ./packages/starknet-snap/dist + ./packages/starknet-snap/snap.manifest.json + ./node_modules/.yarn-state.yml + key: ${{ needs.prepare-deployment.outputs.CACHE_KEY }} + - name: Dry Run Publish + run: | + npm pack ./packages/starknet-snap --tag "$TAG" --access public + env: + TAG: ${{ needs.prepare-deployment.outputs.TAG }} \ No newline at end of file From f6cde302f491f6f2bd4322ce996a699e046fe9ee Mon Sep 17 00:00:00 2001 From: khanti42 Date: Tue, 26 Nov 2024 11:37:47 +0100 Subject: [PATCH 37/50] feat: update wallet-ui unsupported MM version management (#424) * feat: update wallet-ui message * feat: detect mm version and show modal if needed * Update packages/wallet-ui/src/hooks/useHasMetamask.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/wallet-ui/src/hooks/useHasMetamask.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * chore: fix comments * chore: fix comments * fix: separate message in MetaMask upgrade required or Snap update required * chore: fix sonarcloud * chore: lint * chore: text * chore: lint * Update packages/wallet-ui/src/components/ui/organism/MinVersionModal/MinVersionModal.view.tsx * Update packages/wallet-ui/src/components/ui/organism/MinVersionModal/MinVersionModal.view.tsx * chore: re-run checks * chore: re-run checks --------- Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> --- packages/wallet-ui/src/App.tsx | 5 +- .../MinVersionModal/MinVersionModal.style.ts | 8 +++ .../MinVersionModal/MinVersionModal.view.tsx | 60 +++++++++++++------ .../wallet-ui/src/hooks/useHasMetamask.ts | 22 ++++++- packages/wallet-ui/src/utils/constants.ts | 2 + 5 files changed, 77 insertions(+), 20 deletions(-) diff --git a/packages/wallet-ui/src/App.tsx b/packages/wallet-ui/src/App.tsx index 7728ef8b..d0cee58f 100644 --- a/packages/wallet-ui/src/App.tsx +++ b/packages/wallet-ui/src/App.tsx @@ -81,7 +81,10 @@ function App() { > - + ({ margin-bottom: 32px; `; +export const MetaMaskLogo = styled.img.attrs(() => ({ + src: metamaskSrc, +}))` + width: 32px; + height: 32px; +`; + export const Title = styled.div` text-align: center; font-size: ${(props) => props.theme.typography.h3.fontSize}; diff --git a/packages/wallet-ui/src/components/ui/organism/MinVersionModal/MinVersionModal.view.tsx b/packages/wallet-ui/src/components/ui/organism/MinVersionModal/MinVersionModal.view.tsx index 6ed188a6..79f9771f 100644 --- a/packages/wallet-ui/src/components/ui/organism/MinVersionModal/MinVersionModal.view.tsx +++ b/packages/wallet-ui/src/components/ui/organism/MinVersionModal/MinVersionModal.view.tsx @@ -1,32 +1,56 @@ +import { MIN_METAMASK_VERSION } from 'utils/constants'; import { Description, + MetaMaskLogo, StarknetLogo, Title, Wrapper, } from './MinVersionModal.style'; +import { useHasMetamask } from 'hooks/useHasMetamask'; +import { ConnectButton } from '../ConnectModal/ConnectModal.style'; export const MinVersionModalView = () => { + const { metaMaskUpgradeRequired } = useHasMetamask(); return ( - A new version of the Starknet Snap is available - - To use this dapp, please install the latest version by following those - steps: -
    -
  • - Delete the current version in MetaMask by going in Settings {'>'}{' '} - Snaps {'>'} @consensys/starknet-snap {'>'} See details {'>'} Remove - Snap -
  • -
  • Refresh the page
  • -
  • - Click on connect, the new version will be proposed for installation. -
  • -
- Note: Your account will be automatically recovered. Future upgrades will - be managed automatically -
+ {metaMaskUpgradeRequired ? ( + <> + An upgrade of MetaMask is needed to use this dApp +
+ + Please update to MetaMask Version {MIN_METAMASK_VERSION} or higher. + +
+ + } onClick={() => {}}> + Go to MetaMask Website + + + + ) : ( + <> + A new version of the Starknet Snap is available + + To use this dapp, please install the latest version by following + those steps: +
    +
  • + Delete the current version in MetaMask by going in Settings{' '} + {'>'} Snaps {'>'} @consensys/starknet-snap {'>'} See details{' '} + {'>'} Remove Snap +
  • +
  • Refresh the page
  • +
  • + Click on connect, the new version will be proposed for + installation. +
  • +
+ Note: Your account will be automatically recovered. Future upgrades + will be managed automatically +
+ + )}
); }; diff --git a/packages/wallet-ui/src/hooks/useHasMetamask.ts b/packages/wallet-ui/src/hooks/useHasMetamask.ts index 3b229ee3..32923de1 100644 --- a/packages/wallet-ui/src/hooks/useHasMetamask.ts +++ b/packages/wallet-ui/src/hooks/useHasMetamask.ts @@ -1,7 +1,10 @@ import { useEffect, useState } from 'react'; import { useAppDispatch } from 'hooks/redux'; import { setProvider } from 'slices/walletSlice'; +import { setMinVersionModalVisible } from 'slices/modalSlice'; import { enableLoadingWithMessage, disableLoading } from 'slices/UISlice'; +import { MIN_METAMASK_VERSION } from 'utils/constants'; +import semver from 'semver/preload'; interface MetaMaskProvider { isMetaMask: boolean; @@ -89,17 +92,23 @@ async function detectMetamaskSupport(windowObject: Window & typeof globalThis) { export const useHasMetamask = () => { const dispatch = useAppDispatch(); const [hasMetamask, setHasMetamask] = useState(null); + const [metaMaskUpgradeRequired, setMetaMaskUpgradeRequired] = useState< + boolean | null + >(null); useEffect(() => { const init = async () => { try { dispatch(enableLoadingWithMessage('Detecting Metamask...')); const provider = await detectMetamaskSupport(window); - // Use the new detection method if (provider && (await isSupportSnap(provider))) { dispatch(setProvider(provider)); setHasMetamask(provider != null); + if (await isMetaMaskUpgradeRequired(provider)) { + dispatch(setMinVersionModalVisible(true)); + setMetaMaskUpgradeRequired(true); + } } else { dispatch(setProvider(null)); setHasMetamask(false); @@ -116,9 +125,20 @@ export const useHasMetamask = () => { return { hasMetamask, + metaMaskUpgradeRequired, }; }; +const isMetaMaskUpgradeRequired = async (provider: any) => { + const clientVersion = await provider.request({ + method: 'web3_clientVersion', + params: [], + }); + const versionMatch = clientVersion.match(/MetaMask\/v(\d+\.\d+\.\d+)/); + const currentVersion = versionMatch[1]; + return semver.lt(currentVersion, MIN_METAMASK_VERSION); +}; + const isSupportSnap = async (provider: any) => { try { await provider.request({ diff --git a/packages/wallet-ui/src/utils/constants.ts b/packages/wallet-ui/src/utils/constants.ts index 310f56f0..76d33c55 100644 --- a/packages/wallet-ui/src/utils/constants.ts +++ b/packages/wallet-ui/src/utils/constants.ts @@ -64,3 +64,5 @@ export const MIN_ACC_CONTRACT_VERSION = [0, 3, 0]; export const DUMMY_ADDRESS = '0xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'; export const DEFAULT_FEE_TOKEN = FeeToken.ETH; + +export const MIN_METAMASK_VERSION = '12.5.0'; From 4ee8763a81186339f6cb4d10357b5da1e31395b5 Mon Sep 17 00:00:00 2001 From: khanti42 Date: Tue, 26 Nov 2024 16:07:57 +0100 Subject: [PATCH 38/50] chore: ci cd split pipeline enabled dev and prod (#438) * chore: ci-cd pipeline split * chore: ci-cd dev pipeline for test * chore: fix lint * chore: activate prod build * chore: fix comments --- .../workflows/deploy-wallet-get-starknet.yml | 104 ++++++++++++++++-- .github/workflows/publish-npm.yml | 37 ++++++- 2 files changed, 129 insertions(+), 12 deletions(-) diff --git a/.github/workflows/deploy-wallet-get-starknet.yml b/.github/workflows/deploy-wallet-get-starknet.yml index 7300d2b4..84a3a639 100644 --- a/.github/workflows/deploy-wallet-get-starknet.yml +++ b/.github/workflows/deploy-wallet-get-starknet.yml @@ -2,6 +2,7 @@ name: Deploy Wallet-UI and Get-starknet on: workflow_dispatch: + jobs: prepare-deployment: environment: production @@ -20,21 +21,17 @@ jobs: id: prepare_parameters run: | BASE=$(node -p "require('./packages/starknet-snap/package.json').version") - ENV=production - + { + echo "VERSION=${BASE}" + echo "ENV=prod" echo "AWS_CLOUDFRONT_DISTRIBUTIONS_ID=${{ vars.AWS_CLOUDFRONT_DISTRIBUTIONS_ID }}" echo "AWS_S3_GET_STARKNET_URL=${{ vars.AWS_S3_GET_STARKNET_URL }}" echo "AWS_S3_URL=${{ vars.AWS_S3_URL }}" echo "GET_STARKNET_PUBLIC_PATH=${{ vars.GET_STARKNET_PUBLIC_PATH }}" - echo "VERSION=${BASE}" - echo "TAG=latest" - echo "ENV=prod" } >> "$GITHUB_OUTPUT" outputs: VERSION: ${{ steps.prepare_parameters.outputs.VERSION }} - TAG: ${{ steps.prepare_parameters.outputs.TAG }} - ENV: ${{ steps.prepare_parameters.outputs.ENV }} AWS_S3_GET_STARKNET_URL: ${{ steps.prepare_parameters.outputs.AWS_S3_GET_STARKNET_URL }} AWS_CLOUDFRONT_DISTRIBUTIONS_ID: ${{ steps.prepare_parameters.outputs.AWS_CLOUDFRONT_DISTRIBUTIONS_ID }} AWS_S3_URL: ${{ steps.prepare_parameters.outputs.AWS_S3_URL }} @@ -67,7 +64,7 @@ jobs: echo "Building Get Starknet with GET_STARKNET_PUBLIC_PATH=$GET_STARKNET_PUBLIC_PATH" - SNAP_VERSION="${VERSION}$" GET_STARKNET_PUBLIC_PATH=$GET_STARKNET_PUBLIC_PATH yarn workspace @consensys/get-starknet build + SNAP_VERSION="${VERSION}" GET_STARKNET_PUBLIC_PATH=$GET_STARKNET_PUBLIC_PATH yarn workspace @consensys/get-starknet build env: VERSION: ${{ needs.prepare-deployment.outputs.VERSION }} GET_STARKNET_PUBLIC_PATH: ${{ needs.prepare-deployment.outputs.GET_STARKNET_PUBLIC_PATH }} @@ -79,4 +76,93 @@ jobs: ./packages/get-starknet/dist/webpack ./packages/wallet-ui/build ./node_modules/.yarn-state.yml - key: ${{ needs.prepare-deployment.outputs.CACHE_KEY }} \ No newline at end of file + key: ${{ needs.prepare-deployment.outputs.CACHE_KEY }} + + deploy-wallet-ui: + runs-on: ubuntu-latest + needs: + - prepare-deployment + - install-build + steps: + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: eu-central-1 + - uses: actions/checkout@v3 + with: + ref: ${{ github.sha }} + - name: Restore Cached Build + uses: actions/cache@v3 + id: restore-build + with: + path: | + ./packages/get-starknet/dist/webpack + ./packages/wallet-ui/build + ./node_modules/.yarn-state.yml + key: ${{ needs.prepare-deployment.outputs.CACHE_KEY }} + - name: Deploy to AWS + run: | + echo "Deployed Dapp to : $AWS_S3_URL" + aws s3 sync ./packages/wallet-ui/build "$AWS_S3_URL" + env: + AWS_S3_URL: ${{ needs.prepare-deployment.outputs.AWS_S3_URL }} + + deploy-get-starknet: + runs-on: ubuntu-latest + needs: + - prepare-deployment + - install-build + steps: + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: eu-central-1 + - uses: actions/checkout@v3 + with: + ref: ${{ github.sha }} + - name: Restore Cached Build + uses: actions/cache@v3 + id: restore-build + with: + path: | + ./packages/get-starknet/dist/webpack + ./packages/wallet-ui/build + ./node_modules/.yarn-state.yml + key: ${{ needs.prepare-deployment.outputs.CACHE_KEY }} + - name: Deploy to AWS + run: | + echo "Deployed get Starknet to : $AWS_S3_GET_STARKNET_URL" + aws s3 sync ./packages/get-starknet/dist/webpack "$AWS_S3_GET_STARKNET_URL" + env: + AWS_S3_GET_STARKNET_URL: ${{ needs.prepare-deployment.outputs.AWS_S3_GET_STARKNET_URL }} + + invalid-aws-cdn-cache: + runs-on: ubuntu-latest + needs: + - deploy-wallet-ui + - deploy-get-starknet + - prepare-deployment + steps: + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: eu-central-1 + - name: Invalid AWS CDN Cache + run: | + echo "Distribution ID : $AWS_CLOUDFRONT_DISTRIBUTIONS_ID" + echo "$AWS_CLOUDFRONT_DISTRIBUTIONS_ID" | tr ',' '\n' | while read -r DISTRIBUTIONS_ID + do + echo "Processing Distribution ID : $DISTRIBUTIONS_ID" + INVALIDED_ID="$(aws cloudfront create-invalidation --distribution-id "$DISTRIBUTIONS_ID" --paths "/starknet/*" | grep Id | awk -F'"' '{ print $4}')" + echo "Waiting for invalidation $INVALIDED_ID" + aws cloudfront wait invalidation-completed --id "$INVALIDED_ID" --distribution-id "$DISTRIBUTIONS_ID" + echo "Invalidation $INVALIDED_ID completed" + done + env: + AWS_CLOUDFRONT_DISTRIBUTIONS_ID: ${{ needs.prepare-deployment.outputs.AWS_CLOUDFRONT_DISTRIBUTIONS_ID }} diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index e58a152b..a0d80a05 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -2,6 +2,7 @@ name: Publish NPM on: workflow_dispatch: + jobs: prepare-deployment: environment: production @@ -20,8 +21,7 @@ jobs: id: prepare_parameters run: | BASE=$(node -p "require('./packages/starknet-snap/package.json').version") - ENV=production - + { echo "VERSION=${BASE}" echo "TAG=latest" @@ -113,4 +113,35 @@ jobs: run: | npm pack ./packages/starknet-snap --tag "$TAG" --access public env: - TAG: ${{ needs.prepare-deployment.outputs.TAG }} \ No newline at end of file + TAG: ${{ needs.prepare-deployment.outputs.TAG }} + + publish-npm: + runs-on: ubuntu-latest + needs: + - publish-npm-dry-run + - prepare-deployment + steps: + - uses: actions/checkout@v3 + with: + ref: ${{ github.sha }} + - uses: actions/setup-node@v3 + with: + node-version: '20.x' + registry-url: 'https://registry.npmjs.org' + - name: Restore Cached Build + uses: actions/cache@v3 + id: restore-build + with: + # add /packages/snap/snap.manifest.json to include an updated shasum from build due to version update in auto PR + path: | + ./packages/starknet-snap/package.json + ./packages/starknet-snap/dist + ./packages/starknet-snap/snap.manifest.json + ./node_modules/.yarn-state.yml + key: ${{ needs.prepare-deployment.outputs.CACHE_KEY }} + - name: Run Publish + run: | + npm publish ./packages/starknet-snap --tag "$TAG" --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + TAG: ${{ needs.prepare-deployment.outputs.TAG }} From 02490cd44a8efa20572d056e2761f42324af6608 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 26 Nov 2024 15:11:42 +0000 Subject: [PATCH 39/50] chore: release main (#430) * chore: release main * chore: fix comments * chore: fix comments * chore: lint + prettier --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Florin Dzeladini --- .release-please-manifest.json | 4 ++-- packages/starknet-snap/CHANGELOG.md | 11 +++++++++++ .../openrpc/starknet_snap_api_openrpc.json | 2 +- packages/starknet-snap/package.json | 2 +- packages/starknet-snap/snap.manifest.json | 2 +- packages/wallet-ui/CHANGELOG.md | 12 ++++++++++++ packages/wallet-ui/package.json | 2 +- 7 files changed, 29 insertions(+), 6 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index b064d956..81d07fe2 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,5 +1,5 @@ { - "packages/starknet-snap": "2.11.0", - "packages/wallet-ui": "1.24.1", + "packages/starknet-snap": "3.0.0", + "packages/wallet-ui": "1.25.0", "packages/get-starknet": "1.3.0" } \ No newline at end of file diff --git a/packages/starknet-snap/CHANGELOG.md b/packages/starknet-snap/CHANGELOG.md index 8109724c..6495e7ca 100644 --- a/packages/starknet-snap/CHANGELOG.md +++ b/packages/starknet-snap/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [3.0.0](https://github.com/Consensys/starknet-snap/compare/starknet-snap-v2.11.0...starknet-snap-v3.0.0) (2024-11-26) + + +### ⚠ BREAKING CHANGES + +* Enable JSX support, 1) Allow users to select the fee token in the execute transaction dialog (#417, #418, #419, #420). 2) Add JSX support and detection (#415, #416). 3) Replace RPC dialogs with JSX-based components ([#422](https://github.com/Consensys/starknet-snap/issues/422)) + +### Features + +* Enable JSX support, 1) Allow users to select the fee token in the execute transaction dialog ([#417](https://github.com/Consensys/starknet-snap/issues/417), [#418](https://github.com/Consensys/starknet-snap/issues/418), [#419](https://github.com/Consensys/starknet-snap/issues/419), [#420](https://github.com/Consensys/starknet-snap/issues/420)). 2) Add JSX support and detection ([#415](https://github.com/Consensys/starknet-snap/issues/415), [#416](https://github.com/Consensys/starknet-snap/issues/416)). 3) Replace RPC dialogs with JSX-based components ([#422](https://github.com/Consensys/starknet-snap/issues/422)) ([abfc0e5](https://github.com/Consensys/starknet-snap/commit/abfc0e52cc5c9c4fc7ec7e04a9ff667acbf99813)) + ## [2.11.0](https://github.com/Consensys/starknet-snap/compare/starknet-snap-v2.10.1...starknet-snap-v2.11.0) (2024-10-28) diff --git a/packages/starknet-snap/openrpc/starknet_snap_api_openrpc.json b/packages/starknet-snap/openrpc/starknet_snap_api_openrpc.json index c596193b..72c853b6 100644 --- a/packages/starknet-snap/openrpc/starknet_snap_api_openrpc.json +++ b/packages/starknet-snap/openrpc/starknet_snap_api_openrpc.json @@ -1,7 +1,7 @@ { "openrpc": "1.0.0-rc1", "info": { - "version": "2.11.0", + "version": "3.0.0", "title": "Starknet MetaMask Snap API", "license": {} }, diff --git a/packages/starknet-snap/package.json b/packages/starknet-snap/package.json index 807678fd..5ebff7bb 100644 --- a/packages/starknet-snap/package.json +++ b/packages/starknet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@consensys/starknet-snap", - "version": "2.11.0", + "version": "3.0.0", "keywords": [], "repository": { "type": "git", diff --git a/packages/starknet-snap/snap.manifest.json b/packages/starknet-snap/snap.manifest.json index 9583616a..e7d0c275 100644 --- a/packages/starknet-snap/snap.manifest.json +++ b/packages/starknet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "2.11.0", + "version": "3.0.0", "description": "Manage Starknet accounts and assets with MetaMask.", "proposedName": "Starknet", "repository": { diff --git a/packages/wallet-ui/CHANGELOG.md b/packages/wallet-ui/CHANGELOG.md index 076fc9ef..0414c885 100644 --- a/packages/wallet-ui/CHANGELOG.md +++ b/packages/wallet-ui/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +## [1.25.0](https://github.com/Consensys/starknet-snap/compare/wallet-ui-v1.24.1...wallet-ui-v1.25.0) (2024-11-26) + + +### Features + +* Add minimum MetaMask version requirements detection. ([#424](https://github.com/Consensys/starknet-snap/issues/424)) ([f6cde30](https://github.com/Consensys/starknet-snap/commit/f6cde302f491f6f2bd4322ce996a699e046fe9ee)) + + +### Bug Fixes + +* Add missing asset icon for `USDC`, `USDT` and `STRK` token ([#428](https://github.com/Consensys/starknet-snap/issues/428)) ([9f43a22](https://github.com/Consensys/starknet-snap/commit/9f43a228e844ab200984a0b5a1f8ff7bb0d8288d)) + ## [1.24.1](https://github.com/Consensys/starknet-snap/compare/wallet-ui-v1.24.0...wallet-ui-v1.24.1) (2024-10-28) diff --git a/packages/wallet-ui/package.json b/packages/wallet-ui/package.json index 889dbe10..e82ef186 100644 --- a/packages/wallet-ui/package.json +++ b/packages/wallet-ui/package.json @@ -1,6 +1,6 @@ { "name": "wallet-ui", - "version": "1.24.1", + "version": "1.25.0", "private": true, "homepage": "/starknet", "license": "(Apache-2.0 OR MIT)", From fbfcb54abd8f97b01e2e514c84c86ddf0086fbca Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 29 Nov 2024 15:48:28 +0800 Subject: [PATCH 40/50] chore: remove legacy API (#442) --- packages/starknet-snap/src/estimateFee.ts | 142 ------ packages/starknet-snap/src/estimateFees.ts | 51 -- packages/starknet-snap/src/index.tsx | 14 - packages/starknet-snap/src/sendTransaction.ts | 191 -------- .../test/src/estimateFee.test.ts | 273 ----------- .../test/src/estimateFees.test.ts | 117 ----- .../test/src/sendTransaction.test.ts | 458 ------------------ 7 files changed, 1246 deletions(-) delete mode 100644 packages/starknet-snap/src/estimateFee.ts delete mode 100644 packages/starknet-snap/src/estimateFees.ts delete mode 100644 packages/starknet-snap/src/sendTransaction.ts delete mode 100644 packages/starknet-snap/test/src/estimateFee.test.ts delete mode 100644 packages/starknet-snap/test/src/estimateFees.test.ts delete mode 100644 packages/starknet-snap/test/src/sendTransaction.test.ts diff --git a/packages/starknet-snap/src/estimateFee.ts b/packages/starknet-snap/src/estimateFee.ts deleted file mode 100644 index 624381b3..00000000 --- a/packages/starknet-snap/src/estimateFee.ts +++ /dev/null @@ -1,142 +0,0 @@ -import type { Invocations } from 'starknet'; -import { TransactionType } from 'starknet'; - -import { - FeeTokenUnit, - type ApiParamsWithKeyDeriver, - type EstimateFeeRequestParams, -} from './types/snapApi'; -import { ACCOUNT_CLASS_HASH } from './utils/constants'; -import { logger } from './utils/logger'; -import { toJson } from './utils/serializer'; -import { - getNetworkFromChainId, - verifyIfAccountNeedUpgradeOrDeploy, -} from './utils/snapUtils'; -import { - validateAndParseAddress, - getKeysFromAddress, - getCallDataArray, - getAccContractAddressAndCallData, - estimateFeeBulk, - addFeesFromAllTransactions, - isAccountDeployed, -} from './utils/starknetUtils'; - -/** - * - * @param params - */ -export async function estimateFee(params: ApiParamsWithKeyDeriver) { - try { - const { state, keyDeriver, requestParams } = params; - const requestParamsObj = requestParams as EstimateFeeRequestParams; - const { contractAddress } = requestParamsObj; - const { contractFuncName } = requestParamsObj; - const contractCallData = getCallDataArray( - requestParamsObj.contractCallData as unknown as string, - ); - const { senderAddress } = requestParamsObj; - const network = getNetworkFromChainId(state, requestParamsObj.chainId); - - if ( - !contractAddress || - !requestParamsObj.senderAddress || - !contractFuncName - ) { - throw new Error( - `The given contract address, sender address, and function name need to be non-empty string, got: ${toJson( - requestParamsObj, - )}`, - ); - } - - try { - validateAndParseAddress(contractAddress); - } catch (error) { - throw new Error( - `The given contract address is invalid: ${contractAddress}`, - ); - } - try { - validateAndParseAddress(senderAddress); - } catch (error) { - throw new Error(`The given sender address is invalid: ${senderAddress}`); - } - - const { privateKey: senderPrivateKey, publicKey } = - await getKeysFromAddress(keyDeriver, network, state, senderAddress); - - await verifyIfAccountNeedUpgradeOrDeploy( - network, - senderAddress, - publicKey, - false, - ); - - const txnInvocation = { - contractAddress, - entrypoint: contractFuncName, - calldata: contractCallData, - }; - - logger.log(`estimateFee:\ntxnInvocation: ${toJson(txnInvocation)}`); - - // Estimate deploy account fee if the signer has not been deployed yet - const accountDeployed = await isAccountDeployed(network, senderAddress); - let bulkTransactions: Invocations = [ - { - type: TransactionType.INVOKE, - payload: txnInvocation, - }, - ]; - if (!accountDeployed) { - const { callData } = getAccContractAddressAndCallData(publicKey); - const deployAccountpayload = { - classHash: ACCOUNT_CLASS_HASH, - contractAddress: senderAddress, - constructorCalldata: callData, - addressSalt: publicKey, - }; - - bulkTransactions = [ - { - type: TransactionType.DEPLOY_ACCOUNT, - payload: deployAccountpayload, - }, - { - type: TransactionType.INVOKE, - payload: txnInvocation, - }, - ]; - } - - const estimateBulkFeeResp = await estimateFeeBulk( - network, - senderAddress, - senderPrivateKey, - bulkTransactions, - ); - logger.log( - `estimateFee:\nestimateFeeBulk estimateBulkFeeResp: ${toJson( - estimateBulkFeeResp, - )}`, - ); - const estimateFeeResp = addFeesFromAllTransactions(estimateBulkFeeResp); - - logger.log(`estimateFee:\nestimateFeeResp: ${toJson(estimateFeeResp)}`); - - const resp = { - suggestedMaxFee: estimateFeeResp.suggestedMaxFee.toString(10), - overallFee: estimateFeeResp.overall_fee.toString(10), - unit: FeeTokenUnit.ETH, - includeDeploy: !accountDeployed, - }; - logger.log(`estimateFee:\nresp: ${toJson(resp)}`); - - return resp; - } catch (error) { - logger.error(`Problem found:`, error); - throw error; - } -} diff --git a/packages/starknet-snap/src/estimateFees.ts b/packages/starknet-snap/src/estimateFees.ts deleted file mode 100644 index 0303b3a9..00000000 --- a/packages/starknet-snap/src/estimateFees.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { - ApiParamsWithKeyDeriver, - EstimateFeesRequestParams, -} from './types/snapApi'; -import { logger } from './utils/logger'; -import { toJson } from './utils/serializer'; -import { getNetworkFromChainId } from './utils/snapUtils'; -import { getKeysFromAddress, estimateFeeBulk } from './utils/starknetUtils'; - -/** - * - * @param params - */ -export async function estimateFees(params: ApiParamsWithKeyDeriver) { - try { - const { state, keyDeriver, requestParams } = params; - const requestParamsObj = requestParams as EstimateFeesRequestParams; - - logger.log(`estimateFees params: ${toJson(requestParamsObj, 2)}`); - - const { senderAddress } = requestParamsObj; - const network = getNetworkFromChainId(state, requestParamsObj.chainId); - const { privateKey: senderPrivateKey } = await getKeysFromAddress( - keyDeriver, - network, - state, - senderAddress, - ); - - const fees = await estimateFeeBulk( - network, - senderAddress, - senderPrivateKey, - requestParamsObj.invocations, - requestParamsObj.invocationDetails, - ); - - return fees.map((fee) => ({ - // eslint-disable-next-line @typescript-eslint/naming-convention - overall_fee: fee.overall_fee.toString(10) || '0', - // eslint-disable-next-line @typescript-eslint/naming-convention - gas_consumed: fee.gas_consumed.toString(10) || '0', - // eslint-disable-next-line @typescript-eslint/naming-convention - gas_price: fee.gas_price.toString(10) || '0', - suggestedMaxFee: fee.suggestedMaxFee.toString(10) || '0', - })); - } catch (error) { - logger.error(`Problem found:`, error); - throw error; - } -} diff --git a/packages/starknet-snap/src/index.tsx b/packages/starknet-snap/src/index.tsx index 0d8f4585..af3b7b3f 100644 --- a/packages/starknet-snap/src/index.tsx +++ b/packages/starknet-snap/src/index.tsx @@ -14,7 +14,6 @@ import { addNetwork } from './addNetwork'; import { Config } from './config'; import { createAccount } from './createAccount'; import { estimateAccDeployFee } from './estimateAccountDeployFee'; -import { estimateFees } from './estimateFees'; import { extractPublicKey } from './extractPublicKey'; import { getCurrentNetwork } from './getCurrentNetwork'; import { getErc20TokenBalance } from './getErc20TokenBalance'; @@ -54,7 +53,6 @@ import { getDeploymentData, watchAsset, } from './rpcs'; -import { sendTransaction } from './sendTransaction'; import { signDeployAccountTransaction } from './signDeployAccountTransaction'; import type { ApiParams, @@ -205,12 +203,6 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { case 'starkNet_getTransactionStatus': return await getTransactionStatus(apiParams); - case 'starkNet_sendTransaction': - apiParams.keyDeriver = await getAddressKeyDeriver(snap); - return await sendTransaction( - apiParams as unknown as ApiParamsWithKeyDeriver, - ); - case 'starkNet_getValue': return await getValue(apiParams); @@ -264,12 +256,6 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { apiParams.requestParams as unknown as ExecuteTxnParams, ); - case 'starkNet_estimateFees': - apiParams.keyDeriver = await getAddressKeyDeriver(snap); - return await estimateFees( - apiParams as unknown as ApiParamsWithKeyDeriver, - ); - case 'starkNet_upgradeAccContract': apiParams.keyDeriver = await getAddressKeyDeriver(snap); return upgradeAccContract( diff --git a/packages/starknet-snap/src/sendTransaction.ts b/packages/starknet-snap/src/sendTransaction.ts deleted file mode 100644 index b06eb25c..00000000 --- a/packages/starknet-snap/src/sendTransaction.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { heading, panel, DialogType } from '@metamask/snaps-sdk'; -import { num as numUtils, constants } from 'starknet'; - -import { createAccount } from './createAccount'; -import { estimateFee } from './estimateFee'; -import type { - ApiParamsWithKeyDeriver, - SendTransactionRequestParams, -} from './types/snapApi'; -import type { Transaction } from './types/snapState'; -import { TransactionStatus, VoyagerTransactionType } from './types/snapState'; -import { logger } from './utils/logger'; -import { toJson } from './utils/serializer'; -import { - getNetworkFromChainId, - getSendTxnText, - upsertTransaction, -} from './utils/snapUtils'; -import { - validateAndParseAddress, - getKeysFromAddress, - getCallDataArray, - executeTxn, - isAccountDeployed, - isUpgradeRequired, -} from './utils/starknetUtils'; - -/** - * - * @param params - */ -export async function sendTransaction(params: ApiParamsWithKeyDeriver) { - try { - const { state, wallet, saveMutex, keyDeriver, requestParams } = params; - const requestParamsObj = requestParams as SendTransactionRequestParams; - - if ( - !requestParamsObj.contractAddress || - !requestParamsObj.senderAddress || - !requestParamsObj.contractFuncName - ) { - throw new Error( - `The given contract address, sender address, and function name need to be non-empty string, got: ${toJson( - requestParamsObj, - )}`, - ); - } - - try { - validateAndParseAddress(requestParamsObj.contractAddress); - } catch (error) { - throw new Error( - `The given contract address is invalid: ${requestParamsObj.contractAddress}`, - ); - } - try { - validateAndParseAddress(requestParamsObj.senderAddress); - } catch (error) { - throw new Error( - `The given sender address is invalid: ${requestParamsObj.senderAddress}`, - ); - } - - const { contractAddress } = requestParamsObj; - const { contractFuncName } = requestParamsObj; - const contractCallData = getCallDataArray( - requestParamsObj.contractCallData as unknown as string, - ); - const { senderAddress } = requestParamsObj; - const network = getNetworkFromChainId(state, requestParamsObj.chainId); - - if (await isUpgradeRequired(network, senderAddress)) { - throw new Error('Upgrade required'); - } - - const { privateKey: senderPrivateKey, addressIndex } = - await getKeysFromAddress(keyDeriver, network, state, senderAddress); - let maxFee = requestParamsObj.maxFee - ? numUtils.toBigInt(requestParamsObj.maxFee) - : constants.ZERO; - if (maxFee === constants.ZERO) { - const { suggestedMaxFee } = await estimateFee(params); - maxFee = numUtils.toBigInt(suggestedMaxFee); - } - - const signingTxnComponents = getSendTxnText( - state, - contractAddress, - contractFuncName, - contractCallData, - senderAddress, - maxFee, - network, - ); - const response = await wallet.request({ - method: 'snap_dialog', - params: { - type: DialogType.Confirmation, - content: panel([ - heading('Do you want to sign this transaction ?'), - ...signingTxnComponents, - ]), - }, - }); - if (!response) { - return false; - } - - const txnInvocation = { - contractAddress, - entrypoint: contractFuncName, - calldata: contractCallData, - }; - - logger.log( - `sendTransaction:\ntxnInvocation: ${toJson( - txnInvocation, - )}\nmaxFee: ${maxFee.toString()}}`, - ); - - const accountDeployed = await isAccountDeployed(network, senderAddress); - if (!accountDeployed) { - // Deploy account before sending the transaction - logger.log( - 'sendTransaction:\nFirst transaction : send deploy transaction', - ); - const createAccountApiParams = { - state, - wallet: params.wallet, - saveMutex: params.saveMutex, - keyDeriver, - requestParams: { - addressIndex, - deploy: true, - chainId: requestParamsObj.chainId, - }, - }; - await createAccount(createAccountApiParams, true, true); - } - - // In case this is the first transaction we assign a nonce of 1 to make sure it does after the deploy transaction - const nonceSendTransaction = accountDeployed ? undefined : 1; - const txnResp = await executeTxn( - network, - senderAddress, - senderPrivateKey, - txnInvocation, - undefined, - { - maxFee, - nonce: nonceSendTransaction, - }, - ); - - logger.log(`sendTransaction:\ntxnResp: ${toJson(txnResp)}`); - - if (txnResp.transaction_hash) { - const txn: Transaction = { - txnHash: txnResp.transaction_hash, - txnType: VoyagerTransactionType.INVOKE, - chainId: network.chainId, - senderAddress, - contractAddress, - contractFuncName, - contractCallData: contractCallData.map( - (data: numUtils.BigNumberish) => { - try { - return numUtils.toHex(numUtils.toBigInt(data)); - } catch (error) { - // data is already send to chain, hence we should not throw error - return '0x0'; - } - }, - ), - finalityStatus: TransactionStatus.RECEIVED, - executionStatus: TransactionStatus.RECEIVED, - status: '', // DEPRECATED LATER - failureReason: '', - eventIds: [], - timestamp: Math.floor(Date.now() / 1000), - }; - - await upsertTransaction(txn, wallet, saveMutex); - } - - return txnResp; - } catch (error) { - logger.error(`Problem found:`, error); - throw error; - } -} diff --git a/packages/starknet-snap/test/src/estimateFee.test.ts b/packages/starknet-snap/test/src/estimateFee.test.ts deleted file mode 100644 index 05bb2289..00000000 --- a/packages/starknet-snap/test/src/estimateFee.test.ts +++ /dev/null @@ -1,273 +0,0 @@ -import chai, { expect } from 'chai'; -import sinon from 'sinon'; -import sinonChai from 'sinon-chai'; -import { WalletMock } from '../wallet.mock.test'; -import * as utils from '../../src/utils/starknetUtils'; -import { estimateFee } from '../../src/estimateFee'; -import { SnapState } from '../../src/types/snapState'; -import { - ACCOUNT_CLASS_HASH, - STARKNET_MAINNET_NETWORK, -} from '../../src/utils/constants'; -import { getAddressKeyDeriver } from '../../src/utils/keyPair'; -import { - account2, - Cairo1Account1, - estimateDeployFeeResp4, - estimateFeeResp, - getBip44EntropyStub, - getBalanceResp, -} from '../constants.test'; -import { Mutex } from 'async-mutex'; -import { - ApiParamsWithKeyDeriver, - EstimateFeeRequestParams, -} from '../../src/types/snapApi'; -import { TransactionType } from 'starknet'; -import { UpgradeRequiredError } from '../../src/utils/exceptions'; - -chai.use(sinonChai); -const sandbox = sinon.createSandbox(); - -describe('Test function: estimateFee', function () { - const walletStub = new WalletMock(); - - const state: SnapState = { - accContracts: [], - erc20Tokens: [], - networks: [STARKNET_MAINNET_NETWORK], - transactions: [], - }; - const requestObject: EstimateFeeRequestParams = { - contractAddress: - '0x07394cbe418daa16e42b87ba67372d4ab4a5df0b05c6e554d158458ce245bc10', - contractFuncName: 'balanceOf', - contractCallData: - '0x7426b2da7a8978e0d472d64f15f984d658226cb55a4fd8aa7689688a7eab37b', - senderAddress: account2.address, - }; - let apiParams: ApiParamsWithKeyDeriver; - - beforeEach(async function () { - walletStub.rpcStubs.snap_getBip44Entropy.callsFake(getBip44EntropyStub); - apiParams = { - state, - requestParams: requestObject, - wallet: walletStub, - saveMutex: new Mutex(), - keyDeriver: await getAddressKeyDeriver(walletStub), - }; - - sandbox.stub(utils, 'callContract').resolves(getBalanceResp); - sandbox - .stub(utils, 'getAccContractAddressAndCallDataLegacy') - .resolves(account2.address); - }); - - afterEach(function () { - walletStub.reset(); - sandbox.restore(); - }); - - describe('when request param validation fail', function () { - let invalidRequest = Object.assign({}, requestObject); - - afterEach(async function () { - invalidRequest = Object.assign({}, requestObject); - }); - - it('should throw an error if the function name is undefined', async function () { - invalidRequest.contractFuncName = undefined as unknown as string; - apiParams.requestParams = invalidRequest; - let result; - try { - result = await estimateFee(apiParams); - } catch (err) { - result = err; - } finally { - expect(result).to.be.an('Error'); - } - }); - - it('should throw an error if the contract address is invalid', async function () { - invalidRequest.contractAddress = 'wrongAddress'; - apiParams.requestParams = invalidRequest; - let result; - try { - result = await estimateFee(apiParams); - } catch (err) { - result = err; - } finally { - expect(result).to.be.an('Error'); - } - }); - - it('should throw an error if the sender address is invalid', async function () { - invalidRequest.senderAddress = 'wrongAddress'; - apiParams.requestParams = invalidRequest; - let result; - try { - result = await estimateFee(apiParams); - } catch (err) { - result = err; - } finally { - expect(result).to.be.an('Error'); - } - }); - }); - - 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 () { - apiParams.requestParams = Object.assign({}, requestObject); - }); - - describe('when account require upgrade', function () { - let validateAccountRequireUpgradeOrDeployStub: sinon.SinonStub; - beforeEach(async function () { - validateAccountRequireUpgradeOrDeployStub = sandbox - .stub(utils, 'validateAccountRequireUpgradeOrDeploy') - .throws(new UpgradeRequiredError('Upgrade Required')); - }); - - it('should throw error if upgrade required', async function () { - let result; - try { - result = await estimateFee(apiParams); - } catch (err) { - result = err; - } finally { - expect( - validateAccountRequireUpgradeOrDeployStub, - ).to.have.been.calledOnceWith( - STARKNET_MAINNET_NETWORK, - account2.address, - account2.publicKey, - ); - expect(result).to.be.an('Error'); - expect(result.message).to.equal('Upgrade Required'); - } - }); - }); - - describe('when account is not require upgrade', function () { - let estimateFeeBulkStub: sinon.SinonStub; - - beforeEach(async function () { - sandbox.stub(utils, 'isUpgradeRequired').resolves(false); - apiParams.requestParams = { - ...apiParams.requestParams, - senderAddress: Cairo1Account1.address, - }; - }); - - describe('when account is deployed', function () { - beforeEach(async function () { - estimateFeeBulkStub = sandbox - .stub(utils, 'estimateFeeBulk') - .resolves([estimateFeeResp]); - sandbox - .stub(utils, 'validateAccountRequireUpgradeOrDeploy') - .resolvesThis(); - }); - - it('should estimate the fee correctly', async function () { - const result = await estimateFee(apiParams); - expect(result.suggestedMaxFee).to.be.eq( - estimateFeeResp.suggestedMaxFee.toString(10), - ); - expect(estimateFeeBulkStub).callCount(1); - }); - }); - - describe('when account is not deployed', function () { - beforeEach(async function () { - sandbox - .stub(utils, 'validateAccountRequireUpgradeOrDeploy') - .resolvesThis(); - sandbox.stub(utils, 'isAccountDeployed').resolves(false); - }); - - it('should estimate the fee including deploy txn correctly', async function () { - estimateFeeBulkStub = sandbox - .stub(utils, 'estimateFeeBulk') - .resolves([estimateFeeResp, estimateDeployFeeResp4]); - const expectedSuggestedMaxFee = - estimateDeployFeeResp4.suggestedMaxFee + - estimateFeeResp.suggestedMaxFee; - const result = await estimateFee(apiParams); - - const { privateKey, publicKey } = await utils.getKeysFromAddress( - apiParams.keyDeriver, - STARKNET_MAINNET_NETWORK, - state, - Cairo1Account1.address, - ); - const { callData } = - utils.getAccContractAddressAndCallData(publicKey); - const apiRequest = - apiParams.requestParams as EstimateFeeRequestParams; - - const expectedBulkTransaction = [ - { - type: TransactionType.DEPLOY_ACCOUNT, - payload: { - classHash: ACCOUNT_CLASS_HASH, - contractAddress: Cairo1Account1.address, - constructorCalldata: callData, - addressSalt: publicKey, - }, - }, - { - type: TransactionType.INVOKE, - payload: { - contractAddress: apiRequest.contractAddress, - entrypoint: apiRequest.contractFuncName, - calldata: utils.getCallDataArray( - apiRequest.contractCallData as unknown as string, - ), - }, - }, - ]; - - expect(result.suggestedMaxFee).to.be.eq( - expectedSuggestedMaxFee.toString(10), - ); - expect(estimateFeeBulkStub).callCount(1); - expect(estimateFeeBulkStub).to.be.calledWith( - STARKNET_MAINNET_NETWORK, - Cairo1Account1.address, - privateKey, - expectedBulkTransaction, - ); - }); - - it('should throw error if estimateFee failed', async function () { - estimateFeeBulkStub = sandbox - .stub(utils, 'estimateFeeBulk') - .throws('Error'); - apiParams.requestParams = requestObject; - - let result; - try { - await estimateFee(apiParams); - } catch (err) { - result = err; - } finally { - expect(result).to.be.an('Error'); - expect(estimateFeeBulkStub).callCount(1); - } - }); - }); - }); - }); -}); diff --git a/packages/starknet-snap/test/src/estimateFees.test.ts b/packages/starknet-snap/test/src/estimateFees.test.ts deleted file mode 100644 index 4e688ee3..00000000 --- a/packages/starknet-snap/test/src/estimateFees.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import chai, { expect } from 'chai'; -import sinon from 'sinon'; -import sinonChai from 'sinon-chai'; -import { WalletMock } from '../wallet.mock.test'; -import { SnapState } from '../../src/types/snapState'; -import { estimateFees } from '../../src/estimateFees'; -import { STARKNET_SEPOLIA_TESTNET_NETWORK } from '../../src/utils/constants'; -import { - account2, - estimateDeployFeeResp2, - estimateDeployFeeResp3, - getBip44EntropyStub, -} from '../constants.test'; -import { getAddressKeyDeriver } from '../../src/utils/keyPair'; -import * as utils from '../../src/utils/starknetUtils'; -import { Mutex } from 'async-mutex'; -import { ApiParamsWithKeyDeriver } from '../../src/types/snapApi'; -import { TransactionType } from 'starknet'; -chai.use(sinonChai); -const sandbox = sinon.createSandbox(); - -describe('Test function: estimateFees', function () { - this.timeout(5000); - const walletStub = new WalletMock(); - const state: SnapState = { - accContracts: [account2], - erc20Tokens: [], - networks: [STARKNET_SEPOLIA_TESTNET_NETWORK], - transactions: [], - }; - let apiParams: ApiParamsWithKeyDeriver; - - beforeEach(async function () { - walletStub.rpcStubs.snap_getBip44Entropy.callsFake(getBip44EntropyStub); - apiParams = { - state, - requestParams: {}, - wallet: walletStub, - saveMutex: new Mutex(), - keyDeriver: await getAddressKeyDeriver(walletStub), - }; - }); - - afterEach(function () { - walletStub.reset(); - sandbox.restore(); - }); - - it('should estimate fees correctly', async function () { - const feeResult = [estimateDeployFeeResp2, estimateDeployFeeResp3]; - sandbox.stub(utils, 'estimateFeeBulk').resolves(feeResult); - apiParams.requestParams = { - senderAddress: account2.address, - chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, - invocations: [ - { - type: TransactionType.INVOKE, - payload: { - entrypoint: 'transfer', - contractAddress: - '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7', - calldata: [ - '1697416752243704114657612983658108968471303240361660550219082009242042413588', - '1', - '0', - ], - }, - }, - ], - invocationsDetails: { - nonce: '1', - }, - }; - const expectedResult = feeResult.map((fee) => ({ - overall_fee: fee.overall_fee.toString(10) || '0', - gas_consumed: fee.gas_consumed.toString(10) || '0', - gas_price: fee.gas_price.toString(10) || '0', - suggestedMaxFee: fee.suggestedMaxFee.toString(10) || '0', - })); - - const result = await estimateFees(apiParams); - - expect(result).to.eql(expectedResult); - }); - - it('should throw error if estimateFee failed', async function () { - sandbox.stub(utils, 'estimateFeeBulk').throws(new Error()); - apiParams.requestParams = { - senderAddress: account2.address, - chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, - invocations: [ - { - type: TransactionType.INVOKE, - payload: { - entrypoint: 'transfer', - contractAddress: - '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7', - calldata: [ - '1697416752243704114657612983658108968471303240361660550219082009242042413588', - '1', - '0', - ], - }, - }, - ], - }; - - let result; - try { - await estimateFees(apiParams); - } catch (err) { - result = err; - } finally { - expect(result).to.be.an('Error'); - } - }); -}); diff --git a/packages/starknet-snap/test/src/sendTransaction.test.ts b/packages/starknet-snap/test/src/sendTransaction.test.ts deleted file mode 100644 index 809c7a69..00000000 --- a/packages/starknet-snap/test/src/sendTransaction.test.ts +++ /dev/null @@ -1,458 +0,0 @@ -import chai, { expect } from 'chai'; -import chaiAsPromised from 'chai-as-promised'; -import sinon from 'sinon'; -import sinonChai from 'sinon-chai'; -import { WalletMock } from '../wallet.mock.test'; -import * as utils from '../../src/utils/starknetUtils'; -import * as snapUtils from '../../src/utils/snapUtils'; -import { SnapState } from '../../src/types/snapState'; -import { sendTransaction } from '../../src/sendTransaction'; -import * as estimateFeeSnap from '../../src/estimateFee'; -import { - STARKNET_MAINNET_NETWORK, - STARKNET_SEPOLIA_TESTNET_NETWORK, -} from '../../src/utils/constants'; -import { - account1, - createAccountProxyResp, - estimateDeployFeeResp, - estimateFeeResp, - getBalanceResp, - getBip44EntropyStub, - sendTransactionFailedResp, - sendTransactionResp, - token2, - token3, - unfoundUserAddress, - Cairo1Account1, -} from '../constants.test'; -import { getAddressKeyDeriver } from '../../src/utils/keyPair'; -import { Mutex } from 'async-mutex'; -import { - ApiParamsWithKeyDeriver, - FeeTokenUnit, - SendTransactionRequestParams, -} from '../../src/types/snapApi'; -import { GetTransactionReceiptResponse } from 'starknet'; - -chai.use(sinonChai); -chai.use(chaiAsPromised); -const sandbox = sinon.createSandbox(); - -describe('Test function: sendTransaction', function () { - this.timeout(5000); - const walletStub = new WalletMock(); - const state: SnapState = { - accContracts: [], - erc20Tokens: [token2, token3], - networks: [STARKNET_MAINNET_NETWORK, STARKNET_SEPOLIA_TESTNET_NETWORK], - transactions: [], - }; - let apiParams: ApiParamsWithKeyDeriver; - - const requestObject: SendTransactionRequestParams = { - contractAddress: - '0x07394cbe418daa16e42b87ba67372d4ab4a5df0b05c6e554d158458ce245bc10', - contractFuncName: 'transfer', - contractCallData: - '0x0256d8f49882cc9366037415f48fa9fd2b5b7344ded7573ebfcef7c90e3e6b75,100000000000000000000,0', - senderAddress: account1.address, - chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, - }; - - beforeEach(async function () { - walletStub.rpcStubs.snap_getBip44Entropy.callsFake(getBip44EntropyStub); - apiParams = { - state, - requestParams: { - chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, - }, - wallet: walletStub, - saveMutex: new Mutex(), - keyDeriver: await getAddressKeyDeriver(walletStub), - }; - }); - - afterEach(function () { - walletStub.reset(); - sandbox.restore(); - }); - - describe('when request param validation fail', function () { - let invalidRequest: SendTransactionRequestParams = Object.assign( - {}, - requestObject, - ); - - afterEach(function () { - invalidRequest = Object.assign({}, requestObject); - apiParams.requestParams = requestObject; - }); - - it('should show error when request contractAddress is not given', async function () { - invalidRequest.contractAddress = undefined as unknown as string; - apiParams.requestParams = invalidRequest; - let result; - try { - result = await sendTransaction(apiParams); - } catch (err) { - result = err; - } finally { - expect(result).to.be.an('Error'); - expect(result.message).to.be.include( - 'The given contract address, sender address, and function name need to be non-empty string', - ); - } - }); - - it('should show error when request contractAddress is invalid', async function () { - invalidRequest.contractAddress = '0x0'; - apiParams.requestParams = invalidRequest; - let result; - try { - result = await sendTransaction(apiParams); - } catch (err) { - result = err; - } finally { - expect(result).to.be.an('Error'); - expect(result.message).to.be.include( - 'The given contract address is invalid', - ); - } - }); - - it('should show error when request senderAddress is not given', async function () { - invalidRequest.senderAddress = undefined as unknown as string; - apiParams.requestParams = invalidRequest; - let result; - try { - result = await sendTransaction(apiParams); - } catch (err) { - result = err; - } finally { - expect(result).to.be.an('Error'); - expect(result.message).to.be.include( - 'The given contract address, sender address, and function name need to be non-empty string', - ); - } - }); - - it('should show error when request contractAddress is invalid', async function () { - invalidRequest.senderAddress = '0x0'; - apiParams.requestParams = invalidRequest; - let result; - try { - result = await sendTransaction(apiParams); - } catch (err) { - result = err; - } finally { - expect(result).to.be.an('Error'); - expect(result.message).to.be.include( - 'The given sender address is invalid', - ); - } - }); - - it('should show error when request contractFuncName is not given', async function () { - invalidRequest.contractFuncName = undefined as unknown as string; - apiParams.requestParams = invalidRequest; - let result; - try { - result = await sendTransaction(apiParams); - } catch (err) { - result = err; - } finally { - expect(result).to.be.an('Error'); - expect(result.message).to.be.include( - 'The given contract address, sender address, and function name need to be non-empty string', - ); - } - }); - - it('should show error when request network not found', async function () { - invalidRequest.chainId = '0x0'; - apiParams.requestParams = invalidRequest; - let result; - try { - result = await sendTransaction(apiParams); - } catch (err) { - result = err; - } finally { - expect(result).to.be.an('Error'); - } - }); - }); - - describe('when request param validation pass', function () { - beforeEach(async function () { - apiParams.requestParams = Object.assign({}, requestObject); - }); - - afterEach(async function () { - apiParams.requestParams = Object.assign({}, requestObject); - }); - - describe('when require upgrade checking fail', function () { - it('should throw error', async function () { - const isUpgradeRequiredStub = sandbox - .stub(utils, 'isUpgradeRequired') - .throws('network error'); - let result; - try { - result = await sendTransaction(apiParams); - } catch (err) { - result = err; - } finally { - expect(isUpgradeRequiredStub).to.have.been.calledOnceWith( - STARKNET_SEPOLIA_TESTNET_NETWORK, - account1.address, - ); - expect(result).to.be.an('Error'); - } - }); - }); - - describe('when account require upgrade', function () { - let isUpgradeRequiredStub: sinon.SinonStub; - beforeEach(async function () { - isUpgradeRequiredStub = sandbox - .stub(utils, 'isUpgradeRequired') - .resolves(true); - }); - - it('should throw error if upgrade required', async function () { - let result; - try { - result = await sendTransaction(apiParams); - } catch (err) { - result = err; - } finally { - expect(isUpgradeRequiredStub).to.have.been.calledOnceWith( - STARKNET_SEPOLIA_TESTNET_NETWORK, - account1.address, - ); - expect(result).to.be.an('Error'); - } - }); - }); - - describe('when account do not require upgrade', function () { - let executeTxnResp; - let executeTxnStub: sinon.SinonStub; - beforeEach(async function () { - apiParams.requestParams = { - ...apiParams.requestParams, - senderAddress: Cairo1Account1.address, - }; - sandbox.stub(utils, 'isUpgradeRequired').resolves(false); - sandbox.stub(estimateFeeSnap, 'estimateFee').resolves({ - suggestedMaxFee: estimateFeeResp.suggestedMaxFee.toString(10), - overallFee: estimateFeeResp.overall_fee.toString(10), - unit: FeeTokenUnit.ETH, - includeDeploy: true, - }); - executeTxnResp = sendTransactionResp; - executeTxnStub = sandbox - .stub(utils, 'executeTxn') - .resolves(executeTxnResp); - walletStub.rpcStubs.snap_manageState.resolves(state); - walletStub.rpcStubs.snap_dialog.resolves(true); - sandbox - .stub(utils, 'waitForTransaction') - .resolves({} as unknown as GetTransactionReceiptResponse); - }); - - describe('when account is deployed', function () { - beforeEach(async function () { - sandbox.stub(utils, 'isAccountDeployed').resolves(true); - }); - - it('should send a transaction for transferring 10 tokens correctly', async function () { - const result = await sendTransaction(apiParams); - expect(walletStub.rpcStubs.snap_dialog).to.have.been.calledOnce; - expect(walletStub.rpcStubs.snap_manageState).to.have.been.called; - expect(result).to.be.eql(sendTransactionResp); - }); - - it('should send a transaction for transferring 10 tokens but not update snap state if transaction_hash is missing from response', async function () { - executeTxnStub.restore(); - executeTxnStub = sandbox - .stub(utils, 'executeTxn') - .resolves(sendTransactionFailedResp); - const result = await sendTransaction(apiParams); - expect(walletStub.rpcStubs.snap_dialog).to.have.been.calledOnce; - expect(walletStub.rpcStubs.snap_manageState).not.to.have.been.called; - expect(result).to.be.eql(sendTransactionFailedResp); - }); - - it('should send a transaction with given max fee for transferring 10 tokens correctly', async function () { - const apiRequest = - apiParams.requestParams as SendTransactionRequestParams; - apiRequest.maxFee = '15135825227039'; - const result = await sendTransaction(apiParams); - expect(walletStub.rpcStubs.snap_dialog).to.have.been.calledOnce; - expect(walletStub.rpcStubs.snap_manageState).to.have.been.called; - expect(result).to.be.eql(sendTransactionResp); - }); - - it('should send a transfer transaction for empty call data', async function () { - const apiRequest = - apiParams.requestParams as SendTransactionRequestParams; - apiRequest.contractCallData = undefined; - const result = await sendTransaction(apiParams); - expect(walletStub.rpcStubs.snap_dialog).to.have.been.calledOnce; - expect(walletStub.rpcStubs.snap_manageState).to.have.been.called; - expect(result).to.be.eql(sendTransactionResp); - }); - - it('should send a transaction for empty call data', async function () { - const apiRequest = - apiParams.requestParams as SendTransactionRequestParams; - apiRequest.contractCallData = undefined; - apiRequest.contractFuncName = 'get_signer'; - const result = await sendTransaction(apiParams); - expect(walletStub.rpcStubs.snap_dialog).to.have.been.calledOnce; - expect(walletStub.rpcStubs.snap_manageState).to.have.been.called; - expect(result).to.be.eql(sendTransactionResp); - }); - - it('should send a transaction for transferring 10 tokens from an unfound user correctly', async function () { - const apiRequest = - apiParams.requestParams as SendTransactionRequestParams; - apiRequest.senderAddress = unfoundUserAddress; - const result = await sendTransaction(apiParams); - expect(walletStub.rpcStubs.snap_dialog).to.have.been.calledOnce; - expect(walletStub.rpcStubs.snap_manageState).to.have.been.called; - expect(result).to.be.eql(sendTransactionResp); - }); - - it('should throw error if upsertTransaction failed', async function () { - sandbox.stub(snapUtils, 'upsertTransaction').throws(new Error()); - let result; - try { - await sendTransaction(apiParams); - } catch (err) { - result = err; - } finally { - expect(result).to.be.an('Error'); - } - }); - - it('should return false if user rejected to sign the transaction', async function () { - walletStub.rpcStubs.snap_dialog.resolves(false); - const result = await sendTransaction(apiParams); - expect(walletStub.rpcStubs.snap_dialog).to.have.been.calledOnce; - expect(walletStub.rpcStubs.snap_manageState).not.to.have.been.called; - expect(result).to.be.eql(false); - }); - - it('should use heading, text and copyable component', async function () { - executeTxnResp = sendTransactionFailedResp; - sandbox.stub(utils, 'getSigner').callsFake(async () => { - return account1.publicKey; - }); - const requestObject: SendTransactionRequestParams = { - contractAddress: account1.address, - contractFuncName: 'get_signer', - contractCallData: '**foo**', - senderAddress: account1.address, - chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, - }; - apiParams.requestParams = requestObject; - await sendTransaction(apiParams); - const expectedDialogParams = { - type: 'confirmation', - content: { - type: 'panel', - children: [ - { - type: 'heading', - value: 'Do you want to sign this transaction ?', - }, - { - type: 'text', - value: `**Signer Address:**`, - }, - { - type: 'copyable', - value: - '0x04882a372da3dfe1c53170ad75893832469bf87b62b13e84662565c4a88f25cd', - }, - { - type: 'text', - value: `**Contract:**`, - }, - { - type: 'copyable', - value: - '0x04882a372da3dfe1c53170ad75893832469bf87b62b13e84662565c4a88f25cd', - }, - { - type: 'text', - value: `**Call Data:**`, - }, - { - type: 'copyable', - value: '[**foo**]', - }, - { - type: 'text', - value: `**Estimated Gas Fee(ETH):**`, - }, - { - type: 'copyable', - value: '0.000022702500105945', - }, - { - type: 'text', - value: `**Network:**`, - }, - { - type: 'copyable', - value: 'Sepolia Testnet', - }, - ], - }, - }; - expect(walletStub.rpcStubs.snap_dialog).to.have.been.calledOnce; - expect(walletStub.rpcStubs.snap_dialog).to.have.been.calledWith( - expectedDialogParams, - ); - }); - }); - - describe('when account is not deployed', function () { - beforeEach(async function () { - sandbox.stub(utils, 'isAccountDeployed').resolves(false); - }); - - it('send a transaction for transferring 10 tokens and a transaction for deploy correctly', async function () { - sandbox.stub(utils, 'deployAccount').callsFake(async () => { - return createAccountProxyResp; - }); - sandbox.stub(utils, 'getBalance').callsFake(async () => { - return getBalanceResp[0]; - }); - sandbox - .stub(utils, 'estimateAccountDeployFee') - .callsFake(async () => { - return estimateDeployFeeResp; - }); - const requestObject: SendTransactionRequestParams = { - contractAddress: - '0x07394cbe418daa16e42b87ba67372d4ab4a5df0b05c6e554d158458ce245bc10', - contractFuncName: 'transfer', - contractCallData: - '0x0256d8f49882cc9366037415f48fa9fd2b5b7344ded7573ebfcef7c90e3e6b75,100000000000000000000,0', - senderAddress: account1.address, - }; - apiParams.requestParams = requestObject; - const result = await sendTransaction(apiParams); - expect(walletStub.rpcStubs.snap_dialog).to.have.been.calledOnce; - expect(walletStub.rpcStubs.snap_manageState).to.have.been.called; - expect(result).to.be.eql(sendTransactionResp); - }); - }); - }); - }); -}); From 2a37d5047226ac2ead536a67dd32e628997a58d3 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 29 Nov 2024 15:52:15 +0800 Subject: [PATCH 41/50] chore: add chain rpc controller (#443) --- .../abstract/chain-rpc-controller.test.ts | 48 +++++++++++++++++++ .../src/rpcs/abstract/chain-rpc-controller.ts | 48 +++++++++++++++++++ .../src/state/__tests__/helper.ts | 2 +- 3 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 packages/starknet-snap/src/rpcs/abstract/chain-rpc-controller.test.ts create mode 100644 packages/starknet-snap/src/rpcs/abstract/chain-rpc-controller.ts diff --git a/packages/starknet-snap/src/rpcs/abstract/chain-rpc-controller.test.ts b/packages/starknet-snap/src/rpcs/abstract/chain-rpc-controller.test.ts new file mode 100644 index 00000000..b4a4d505 --- /dev/null +++ b/packages/starknet-snap/src/rpcs/abstract/chain-rpc-controller.test.ts @@ -0,0 +1,48 @@ +import { string } from 'superstruct'; + +import { mockNetworkStateManager } from '../../state/__tests__/helper'; +import { STARKNET_SEPOLIA_TESTNET_NETWORK } from '../../utils/constants'; +import { InvalidNetworkError } from '../../utils/exceptions'; +import { BaseRequestStruct } from '../../utils/superstruct'; +import { ChainRpcController } from './chain-rpc-controller'; + +describe('ChainRpcController', () => { + type Request = { chainId: string }; + class MockRpc extends ChainRpcController { + protected requestStruct = BaseRequestStruct; + + protected responseStruct = string(); + + // Set it to public to be able to spy on it + async handleRequest(params: Request) { + return `tested with ${params.chainId}`; + } + } + + it('executes request', async () => { + const network = STARKNET_SEPOLIA_TESTNET_NETWORK; + const { getNetworkSpy } = mockNetworkStateManager(network); + const { chainId } = network; + + const rpc = new MockRpc(); + const result = await rpc.execute({ + chainId, + }); + + expect(getNetworkSpy).toHaveBeenCalledWith({ chainId }); + expect(result).toBe(`tested with ${chainId}`); + }); + + it('throws `InvalidNetworkError` error if the given chainId not found.', async () => { + const network = STARKNET_SEPOLIA_TESTNET_NETWORK; + mockNetworkStateManager(null); + const { chainId } = network; + + const rpc = new MockRpc(); + await expect( + rpc.execute({ + chainId, + }), + ).rejects.toThrow(InvalidNetworkError); + }); +}); diff --git a/packages/starknet-snap/src/rpcs/abstract/chain-rpc-controller.ts b/packages/starknet-snap/src/rpcs/abstract/chain-rpc-controller.ts new file mode 100644 index 00000000..1042f8f3 --- /dev/null +++ b/packages/starknet-snap/src/rpcs/abstract/chain-rpc-controller.ts @@ -0,0 +1,48 @@ +import type { Json } from '@metamask/snaps-sdk'; + +import { NetworkStateManager } from '../../state/network-state-manager'; +import type { Network } from '../../types/snapState'; +import { InvalidNetworkError } from '../../utils/exceptions'; +import { RpcController } from '../../utils/rpc'; + +/** + * A base class for all RPC controllers that require a chainId to be provided in the request parameters. + * + * @template Request - The expected structure of the request parameters that contains the chainId property. + * @template Response - The expected structure of the response. + * @augments RpcController - The base class for all RPC controllers. + * @class ChainRpcController + */ +export abstract class ChainRpcController< + Request extends { + chainId: string; + }, + Response extends Json, +> extends RpcController { + protected network: Network; + + protected networkStateMgr: NetworkStateManager; + + constructor() { + super(); + this.networkStateMgr = new NetworkStateManager(); + } + + protected async getNetwork(chainId: string): Promise { + const network = await this.networkStateMgr.getNetwork({ chainId }); + // if the network is not in the list of networks that we support, we throw an error + if (!network) { + throw new InvalidNetworkError() as unknown as Error; + } + + return network; + } + + protected async preExecute(params: Request): Promise { + await super.preExecute(params); + + const { chainId } = params; + + this.network = await this.getNetwork(chainId); + } +} diff --git a/packages/starknet-snap/src/state/__tests__/helper.ts b/packages/starknet-snap/src/state/__tests__/helper.ts index 6f31e09c..05044039 100644 --- a/packages/starknet-snap/src/state/__tests__/helper.ts +++ b/packages/starknet-snap/src/state/__tests__/helper.ts @@ -95,7 +95,7 @@ export const mockTransactionRequestStateManager = () => { }; }; -export const mockNetworkStateManager = (network: Network) => { +export const mockNetworkStateManager = (network: Network | null) => { const getNetworkSpy = jest.spyOn(NetworkStateManager.prototype, 'getNetwork'); getNetworkSpy.mockResolvedValue(network); return { From 6d972dc595313c222551c088a23b107c5d5eaf6b Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 29 Nov 2024 17:19:29 +0800 Subject: [PATCH 42/50] chore: relocate base RPC controllers (#444) * chore: add chain rpc controller * chore: relocate base rpc controller --- .../abstract/account-rpc-controller.test.ts | 126 +++++++++++++++ .../rpcs/abstract/account-rpc-controller.ts | 86 ++++++++++ .../rpcs/abstract/base-rpc-controller.test.ts | 26 +++ .../src/rpcs/abstract/base-rpc-controller.ts | 51 ++++++ .../src/rpcs/abstract/chain-rpc-controller.ts | 2 +- .../src/rpcs/declare-contract.ts | 2 +- .../src/rpcs/display-private-key.ts | 7 +- .../starknet-snap/src/rpcs/estimate-fee.ts | 2 +- .../starknet-snap/src/rpcs/execute-txn.ts | 4 +- .../src/rpcs/get-deployment-data.ts | 8 +- .../src/rpcs/sign-declare-transaction.ts | 2 +- .../starknet-snap/src/rpcs/sign-message.ts | 2 +- .../src/rpcs/sign-transaction.ts | 2 +- .../starknet-snap/src/rpcs/switch-network.ts | 3 +- .../src/rpcs/verify-signature.ts | 8 +- .../starknet-snap/src/rpcs/watch-asset.ts | 2 +- packages/starknet-snap/src/utils/rpc.test.ts | 152 +----------------- packages/starknet-snap/src/utils/rpc.ts | 119 -------------- 18 files changed, 309 insertions(+), 295 deletions(-) create mode 100644 packages/starknet-snap/src/rpcs/abstract/account-rpc-controller.test.ts create mode 100644 packages/starknet-snap/src/rpcs/abstract/account-rpc-controller.ts create mode 100644 packages/starknet-snap/src/rpcs/abstract/base-rpc-controller.test.ts create mode 100644 packages/starknet-snap/src/rpcs/abstract/base-rpc-controller.ts diff --git a/packages/starknet-snap/src/rpcs/abstract/account-rpc-controller.test.ts b/packages/starknet-snap/src/rpcs/abstract/account-rpc-controller.test.ts new file mode 100644 index 00000000..6a64ca91 --- /dev/null +++ b/packages/starknet-snap/src/rpcs/abstract/account-rpc-controller.test.ts @@ -0,0 +1,126 @@ +import { constants } from 'starknet'; +import { object, string } from 'superstruct'; +import type { Infer } from 'superstruct'; + +import type { StarknetAccount } from '../../__tests__/helper'; +import { generateAccounts } from '../../__tests__/helper'; +import type { SnapState } from '../../types/snapState'; +import { STARKNET_SEPOLIA_TESTNET_NETWORK } from '../../utils/constants'; +import * as snapHelper from '../../utils/snap'; +import * as snapUtils from '../../utils/snapUtils'; +import * as starknetUtils from '../../utils/starknetUtils'; +import { AccountRpcController } from './account-rpc-controller'; + +jest.mock('../../utils/snap'); +jest.mock('../../utils/logger'); + +describe('AccountRpcController', () => { + const state: SnapState = { + accContracts: [], + erc20Tokens: [], + networks: [STARKNET_SEPOLIA_TESTNET_NETWORK], + transactions: [], + }; + + const RequestStruct = object({ + address: string(), + chainId: string(), + }); + + type Request = Infer; + + class MockAccountRpc extends AccountRpcController { + protected requestStruct = RequestStruct; + + protected responseStruct = string(); + + // Set it to public to be able to spy on it + async handleRequest(param: Request) { + return `done ${param.address} and ${param.chainId}`; + } + } + + const mockAccount = async (network: constants.StarknetChainId) => { + const accounts = await generateAccounts(network, 1); + return accounts[0]; + }; + + const prepareExecute = async (account: StarknetAccount) => { + const verifyIfAccountNeedUpgradeOrDeploySpy = jest.spyOn( + snapUtils, + 'verifyIfAccountNeedUpgradeOrDeploy', + ); + + const getKeysFromAddressSpy = jest.spyOn( + starknetUtils, + 'getKeysFromAddress', + ); + + const getStateDataSpy = jest.spyOn(snapHelper, 'getStateData'); + + getStateDataSpy.mockResolvedValue(state); + + getKeysFromAddressSpy.mockResolvedValue({ + privateKey: account.privateKey, + publicKey: account.publicKey, + addressIndex: account.addressIndex, + derivationPath: account.derivationPath as unknown as any, + }); + + verifyIfAccountNeedUpgradeOrDeploySpy.mockReturnThis(); + + return { + getKeysFromAddressSpy, + getStateDataSpy, + verifyIfAccountNeedUpgradeOrDeploySpy, + }; + }; + + it('executes request', async () => { + const chainId = constants.StarknetChainId.SN_SEPOLIA; + const account = await mockAccount(chainId); + await prepareExecute(account); + const rpc = new MockAccountRpc(); + + const result = await rpc.execute({ + address: account.address, + chainId, + }); + + expect(result).toBe(`done ${account.address} and ${chainId}`); + }); + + it('fetchs account before execute', async () => { + const chainId = constants.StarknetChainId.SN_SEPOLIA; + const account = await mockAccount(chainId); + const { getKeysFromAddressSpy } = await prepareExecute(account); + const rpc = new MockAccountRpc(); + + await rpc.execute({ address: account.address, chainId }); + + expect(getKeysFromAddressSpy).toHaveBeenCalled(); + }); + + it.each([true, false])( + `assign verifyIfAccountNeedUpgradeOrDeploy's argument "showAlert" to %s if the constructor option 'showInvalidAccountAlert' is set to %s`, + async (showInvalidAccountAlert: boolean) => { + const chainId = constants.StarknetChainId.SN_SEPOLIA; + const account = await mockAccount(chainId); + const { verifyIfAccountNeedUpgradeOrDeploySpy } = await prepareExecute( + account, + ); + const rpc = new MockAccountRpc({ + showInvalidAccountAlert, + }); + + await rpc.execute({ address: account.address, chainId }); + + expect(verifyIfAccountNeedUpgradeOrDeploySpy).toHaveBeenCalledWith( + expect.any(Object), + account.address, + account.publicKey, + showInvalidAccountAlert, + ); + }, + ); +}); diff --git a/packages/starknet-snap/src/rpcs/abstract/account-rpc-controller.ts b/packages/starknet-snap/src/rpcs/abstract/account-rpc-controller.ts new file mode 100644 index 00000000..d02ed372 --- /dev/null +++ b/packages/starknet-snap/src/rpcs/abstract/account-rpc-controller.ts @@ -0,0 +1,86 @@ +import type { getBIP44ChangePathString } from '@metamask/key-tree/dist/types/utils'; +import type { Json } from '@metamask/snaps-sdk'; + +import type { Network, SnapState } from '../../types/snapState'; +import { getBip44Deriver, getStateData } from '../../utils'; +import { + getNetworkFromChainId, + verifyIfAccountNeedUpgradeOrDeploy, +} from '../../utils/snapUtils'; +import { getKeysFromAddress } from '../../utils/starknetUtils'; +import { RpcController } from './base-rpc-controller'; + +export type AccountRpcParams = { + chainId: string; + address: string; +}; + +// TODO: the Account object should move into a account manager for generate account +export type Account = { + privateKey: string; + publicKey: string; + addressIndex: number; + // This is the derivation path of the address, it is used in `getNextAddressIndex` to find the account in state where matching the same derivation path + derivationPath: ReturnType; +}; + +export type AccountRpcControllerOptions = { + showInvalidAccountAlert: boolean; +}; + +/** + * A base class for rpc controllers that require account discovery. + * + * @template Request - The expected structure of the request parameters. + * @template Response - The expected structure of the response. + * @class AccountRpcController + */ +export abstract class AccountRpcController< + Request extends AccountRpcParams, + Response extends Json, +> extends RpcController { + protected account: Account; + + protected network: Network; + + protected options: AccountRpcControllerOptions; + + protected defaultOptions: AccountRpcControllerOptions = { + showInvalidAccountAlert: true, + }; + + constructor(options?: AccountRpcControllerOptions) { + super(); + this.options = Object.assign({}, this.defaultOptions, options); + } + + protected async preExecute(params: Request): Promise { + await super.preExecute(params); + + const { chainId, address } = params; + const { showInvalidAccountAlert } = this.options; + + const deriver = await getBip44Deriver(); + // TODO: Instead of getting the state directly, we should implement state management to consolidate the state fetching + const state = await getStateData(); + + // TODO: getNetworkFromChainId from state is still needed, due to it is supporting in get-starknet at this moment + this.network = getNetworkFromChainId(state, chainId); + + // TODO: This method should be refactored to get the account from an account manager + this.account = await getKeysFromAddress( + deriver, + this.network, + state, + address, + ); + + // TODO: rename this method to verifyAccount + await verifyIfAccountNeedUpgradeOrDeploy( + this.network, + address, + this.account.publicKey, + showInvalidAccountAlert, + ); + } +} diff --git a/packages/starknet-snap/src/rpcs/abstract/base-rpc-controller.test.ts b/packages/starknet-snap/src/rpcs/abstract/base-rpc-controller.test.ts new file mode 100644 index 00000000..4646af23 --- /dev/null +++ b/packages/starknet-snap/src/rpcs/abstract/base-rpc-controller.test.ts @@ -0,0 +1,26 @@ +import { string } from 'superstruct'; + +import { RpcController } from './base-rpc-controller'; + +jest.mock('../../utils/logger'); + +describe('RpcController', () => { + class MockRpc extends RpcController { + protected requestStruct = string(); + + protected responseStruct = string(); + + // Set it to public to be able to spy on it + async handleRequest(params: string) { + return `done ${params}`; + } + } + + it('executes request', async () => { + const rpc = new MockRpc(); + + const result = await rpc.execute('test'); + + expect(result).toBe('done test'); + }); +}); diff --git a/packages/starknet-snap/src/rpcs/abstract/base-rpc-controller.ts b/packages/starknet-snap/src/rpcs/abstract/base-rpc-controller.ts new file mode 100644 index 00000000..1a4c7914 --- /dev/null +++ b/packages/starknet-snap/src/rpcs/abstract/base-rpc-controller.ts @@ -0,0 +1,51 @@ +import type { Json } from '@metamask/snaps-sdk'; +import type { Struct } from 'superstruct'; + +import { logger, validateRequest, validateResponse } from '../../utils'; + +/** + * A base class for rpc controllers. + * + * @template Request - The expected structure of the request parameters. + * @template Response - The expected structure of the response. + * @class RpcController + */ +export abstract class RpcController< + Request extends Json, + Response extends Json, +> { + /** + * Superstruct for the request. + */ + protected abstract requestStruct: Struct; + + /** + * Superstruct for the response. + */ + protected abstract responseStruct: Struct; + + protected abstract handleRequest(params: Request): Promise; + + protected async preExecute(params: Request): Promise { + logger.info(`Request: ${JSON.stringify(params)}`); + validateRequest(params, this.requestStruct); + } + + protected async postExecute(response: Response): Promise { + logger.info(`Response: ${JSON.stringify(response)}`); + validateResponse(response, this.responseStruct); + } + + /** + * A method to execute the rpc method. + * + * @param params - An struct contains the require parameter for the request. + * @returns A promise that resolves to an json. + */ + async execute(params: Request): Promise { + await this.preExecute(params); + const resp = await this.handleRequest(params); + await this.postExecute(resp); + return resp; + } +} diff --git a/packages/starknet-snap/src/rpcs/abstract/chain-rpc-controller.ts b/packages/starknet-snap/src/rpcs/abstract/chain-rpc-controller.ts index 1042f8f3..c873591d 100644 --- a/packages/starknet-snap/src/rpcs/abstract/chain-rpc-controller.ts +++ b/packages/starknet-snap/src/rpcs/abstract/chain-rpc-controller.ts @@ -3,7 +3,7 @@ import type { Json } from '@metamask/snaps-sdk'; import { NetworkStateManager } from '../../state/network-state-manager'; import type { Network } from '../../types/snapState'; import { InvalidNetworkError } from '../../utils/exceptions'; -import { RpcController } from '../../utils/rpc'; +import { RpcController } from './base-rpc-controller'; /** * A base class for all RPC controllers that require a chainId to be provided in the request parameters. diff --git a/packages/starknet-snap/src/rpcs/declare-contract.ts b/packages/starknet-snap/src/rpcs/declare-contract.ts index 03d06bba..c6a7b48d 100644 --- a/packages/starknet-snap/src/rpcs/declare-contract.ts +++ b/packages/starknet-snap/src/rpcs/declare-contract.ts @@ -10,7 +10,6 @@ import { mapDeprecatedParams, UniversalDetailsStruct, confirmDialog, - AccountRpcController, signerUI, networkUI, rowUI, @@ -19,6 +18,7 @@ import { } from '../utils'; import { UserRejectedOpError } from '../utils/exceptions'; import { declareContract as declareContractUtil } from '../utils/starknetUtils'; +import { AccountRpcController } from './abstract/account-rpc-controller'; // Define the DeclareContractRequestStruct export const DeclareContractRequestStruct = assign( diff --git a/packages/starknet-snap/src/rpcs/display-private-key.ts b/packages/starknet-snap/src/rpcs/display-private-key.ts index bef32db8..3a625a6e 100644 --- a/packages/starknet-snap/src/rpcs/display-private-key.ts +++ b/packages/starknet-snap/src/rpcs/display-private-key.ts @@ -4,12 +4,9 @@ import { renderDisplayPrivateKeyAlertUI, renderDisplayPrivateKeyConfirmUI, } from '../ui/utils'; -import { - AccountRpcController, - AddressStruct, - BaseRequestStruct, -} from '../utils'; +import { AddressStruct, BaseRequestStruct } from '../utils'; import { UserRejectedOpError } from '../utils/exceptions'; +import { AccountRpcController } from './abstract/account-rpc-controller'; export const DisplayPrivateKeyRequestStruct = assign( object({ diff --git a/packages/starknet-snap/src/rpcs/estimate-fee.ts b/packages/starknet-snap/src/rpcs/estimate-fee.ts index e36df47c..b7217d14 100644 --- a/packages/starknet-snap/src/rpcs/estimate-fee.ts +++ b/packages/starknet-snap/src/rpcs/estimate-fee.ts @@ -6,11 +6,11 @@ import { FeeTokenUnit } from '../types/snapApi'; import { AddressStruct, BaseRequestStruct, - AccountRpcController, UniversalDetailsStruct, InvocationsStruct, } from '../utils'; import { getEstimatedFees } from '../utils/starknetUtils'; +import { AccountRpcController } from './abstract/account-rpc-controller'; export const EstimateFeeRequestStruct = assign( object({ diff --git a/packages/starknet-snap/src/rpcs/execute-txn.ts b/packages/starknet-snap/src/rpcs/execute-txn.ts index db31752c..75a6eeab 100644 --- a/packages/starknet-snap/src/rpcs/execute-txn.ts +++ b/packages/starknet-snap/src/rpcs/execute-txn.ts @@ -13,11 +13,9 @@ import { FeeToken } from '../types/snapApi'; import type { TransactionRequest } from '../types/snapState'; import { VoyagerTransactionType, type Transaction } from '../types/snapState'; import { generateExecuteTxnFlow } from '../ui/utils'; -import type { AccountRpcControllerOptions } from '../utils'; import { AddressStruct, BaseRequestStruct, - AccountRpcController, UniversalDetailsStruct, CallsStruct, mapDeprecatedParams, @@ -30,6 +28,8 @@ import { executeTxn as executeTxnUtil, getEstimatedFees, } from '../utils/starknetUtils'; +import type { AccountRpcControllerOptions } from './abstract/account-rpc-controller'; +import { AccountRpcController } from './abstract/account-rpc-controller'; export const ExecuteTxnRequestStruct = assign( object({ diff --git a/packages/starknet-snap/src/rpcs/get-deployment-data.ts b/packages/starknet-snap/src/rpcs/get-deployment-data.ts index 8206db31..ac8fcc29 100644 --- a/packages/starknet-snap/src/rpcs/get-deployment-data.ts +++ b/packages/starknet-snap/src/rpcs/get-deployment-data.ts @@ -1,18 +1,14 @@ import type { Infer } from 'superstruct'; import { object, string, assign, array } from 'superstruct'; -import { - AddressStruct, - BaseRequestStruct, - AccountRpcController, - CairoVersionStruct, -} from '../utils'; +import { AddressStruct, BaseRequestStruct, CairoVersionStruct } from '../utils'; import { ACCOUNT_CLASS_HASH, CAIRO_VERSION } from '../utils/constants'; import { AccountAlreadyDeployedError } from '../utils/exceptions'; import { getDeployAccountCallData, isAccountDeployed, } from '../utils/starknetUtils'; +import { AccountRpcController } from './abstract/account-rpc-controller'; export const GetDeploymentDataRequestStruct = assign( object({ diff --git a/packages/starknet-snap/src/rpcs/sign-declare-transaction.ts b/packages/starknet-snap/src/rpcs/sign-declare-transaction.ts index aa1fbf15..cc5d6cfe 100644 --- a/packages/starknet-snap/src/rpcs/sign-declare-transaction.ts +++ b/packages/starknet-snap/src/rpcs/sign-declare-transaction.ts @@ -6,12 +6,12 @@ import { renderSignDeclareTransactionUI } from '../ui/utils'; import { AddressStruct, BaseRequestStruct, - AccountRpcController, DeclareSignDetailsStruct, mapDeprecatedParams, } from '../utils'; import { UserRejectedOpError } from '../utils/exceptions'; import { signDeclareTransaction as signDeclareTransactionUtil } from '../utils/starknetUtils'; +import { AccountRpcController } from './abstract/account-rpc-controller'; export const SignDeclareTransactionRequestStruct = assign( object({ diff --git a/packages/starknet-snap/src/rpcs/sign-message.ts b/packages/starknet-snap/src/rpcs/sign-message.ts index 6b4387cb..794634ab 100644 --- a/packages/starknet-snap/src/rpcs/sign-message.ts +++ b/packages/starknet-snap/src/rpcs/sign-message.ts @@ -7,11 +7,11 @@ import { TypeDataStruct, AuthorizableStruct, BaseRequestStruct, - AccountRpcController, mapDeprecatedParams, } from '../utils'; import { UserRejectedOpError } from '../utils/exceptions'; import { signMessage as signMessageUtil } from '../utils/starknetUtils'; +import { AccountRpcController } from './abstract/account-rpc-controller'; export const SignMessageRequestStruct = assign( object({ diff --git a/packages/starknet-snap/src/rpcs/sign-transaction.ts b/packages/starknet-snap/src/rpcs/sign-transaction.ts index 24b152e9..d9954e3b 100644 --- a/packages/starknet-snap/src/rpcs/sign-transaction.ts +++ b/packages/starknet-snap/src/rpcs/sign-transaction.ts @@ -7,12 +7,12 @@ import { AddressStruct, AuthorizableStruct, BaseRequestStruct, - AccountRpcController, CallDataStruct, mapDeprecatedParams, } from '../utils'; import { UserRejectedOpError } from '../utils/exceptions'; import { signTransactions } from '../utils/starknetUtils'; +import { AccountRpcController } from './abstract/account-rpc-controller'; export const SignTransactionRequestStruct = assign( object({ diff --git a/packages/starknet-snap/src/rpcs/switch-network.ts b/packages/starknet-snap/src/rpcs/switch-network.ts index ae94048e..bb039885 100644 --- a/packages/starknet-snap/src/rpcs/switch-network.ts +++ b/packages/starknet-snap/src/rpcs/switch-network.ts @@ -3,8 +3,9 @@ import { assign, boolean } from 'superstruct'; import { NetworkStateManager } from '../state/network-state-manager'; import { renderSwitchNetworkUI } from '../ui/utils'; -import { AuthorizableStruct, BaseRequestStruct, RpcController } from '../utils'; +import { AuthorizableStruct, BaseRequestStruct } from '../utils'; import { InvalidNetworkError, UserRejectedOpError } from '../utils/exceptions'; +import { RpcController } from './abstract/base-rpc-controller'; export const SwitchNetworkRequestStruct = assign( AuthorizableStruct, diff --git a/packages/starknet-snap/src/rpcs/verify-signature.ts b/packages/starknet-snap/src/rpcs/verify-signature.ts index 5ce845e9..0eefd6fa 100644 --- a/packages/starknet-snap/src/rpcs/verify-signature.ts +++ b/packages/starknet-snap/src/rpcs/verify-signature.ts @@ -2,13 +2,9 @@ import { HexStruct } from '@metamask/utils'; import type { Infer } from 'superstruct'; import { object, assign, boolean, array } from 'superstruct'; -import { - AddressStruct, - TypeDataStruct, - BaseRequestStruct, - AccountRpcController, -} from '../utils'; +import { AddressStruct, TypeDataStruct, BaseRequestStruct } from '../utils'; import { verifyTypedDataMessageSignature } from '../utils/starknetUtils'; +import { AccountRpcController } from './abstract/account-rpc-controller'; export const VerifySignatureRequestStruct = assign( object({ diff --git a/packages/starknet-snap/src/rpcs/watch-asset.ts b/packages/starknet-snap/src/rpcs/watch-asset.ts index aa825d4e..d3582e11 100644 --- a/packages/starknet-snap/src/rpcs/watch-asset.ts +++ b/packages/starknet-snap/src/rpcs/watch-asset.ts @@ -7,7 +7,6 @@ import type { Erc20Token, Network } from '../types/snapState'; import { renderWatchAssetUI } from '../ui/utils'; import { BaseRequestStruct, - RpcController, AddressStruct, TokenNameStruct, TokenSymbolStruct, @@ -20,6 +19,7 @@ import { UserRejectedOpError, } from '../utils/exceptions'; import { getValidNumber } from '../utils/snapUtils'; +import { RpcController } from './abstract/base-rpc-controller'; export const WatchAssetRequestStruct = assign( object({ diff --git a/packages/starknet-snap/src/utils/rpc.test.ts b/packages/starknet-snap/src/utils/rpc.test.ts index d8f01ad7..1880bb72 100644 --- a/packages/starknet-snap/src/utils/rpc.test.ts +++ b/packages/starknet-snap/src/utils/rpc.test.ts @@ -1,24 +1,10 @@ -import { constants } from 'starknet'; -import { object, string } from 'superstruct'; -import type { Struct, Infer } from 'superstruct'; +import { object } from 'superstruct'; +import type { Struct } from 'superstruct'; -import type { StarknetAccount } from '../__tests__/helper'; -import { generateAccounts } from '../__tests__/helper'; -import type { SnapState } from '../types/snapState'; -import { STARKNET_SEPOLIA_TESTNET_NETWORK } from './constants'; import { InvalidRequestParamsError, UnknownError } from './exceptions'; -import { - AccountRpcController, - RpcController, - validateRequest, - validateResponse, -} from './rpc'; -import * as snapHelper from './snap'; -import * as snapUtils from './snapUtils'; -import * as starknetUtils from './starknetUtils'; +import { validateRequest, validateResponse } from './rpc'; import { AddressStruct } from './superstruct'; -jest.mock('./snap'); jest.mock('./logger'); const validateStruct = object({ @@ -65,135 +51,3 @@ describe('validateResponse', () => { ).toThrow(new UnknownError('Invalid Response')); }); }); - -describe('RpcController', () => { - class MockRpc extends RpcController { - protected requestStruct = string(); - - protected responseStruct = string(); - - // Set it to public to be able to spy on it - async handleRequest(params: string) { - return `done ${params}`; - } - } - - it('executes request', async () => { - const rpc = new MockRpc(); - - const result = await rpc.execute('test'); - - expect(result).toBe('done test'); - }); -}); - -describe('AccountRpcController', () => { - const state: SnapState = { - accContracts: [], - erc20Tokens: [], - networks: [STARKNET_SEPOLIA_TESTNET_NETWORK], - transactions: [], - }; - - const RequestStruct = object({ - address: string(), - chainId: string(), - }); - - type Request = Infer; - - class MockAccountRpc extends AccountRpcController { - protected requestStruct = RequestStruct; - - protected responseStruct = string(); - - // Set it to public to be able to spy on it - async handleRequest(param: Request) { - return `done ${param.address} and ${param.chainId}`; - } - } - - const mockAccount = async (network: constants.StarknetChainId) => { - const accounts = await generateAccounts(network, 1); - return accounts[0]; - }; - - const prepareExecute = async (account: StarknetAccount) => { - const verifyIfAccountNeedUpgradeOrDeploySpy = jest.spyOn( - snapUtils, - 'verifyIfAccountNeedUpgradeOrDeploy', - ); - - const getKeysFromAddressSpy = jest.spyOn( - starknetUtils, - 'getKeysFromAddress', - ); - - const getStateDataSpy = jest.spyOn(snapHelper, 'getStateData'); - - getStateDataSpy.mockResolvedValue(state); - - getKeysFromAddressSpy.mockResolvedValue({ - privateKey: account.privateKey, - publicKey: account.publicKey, - addressIndex: account.addressIndex, - derivationPath: account.derivationPath as unknown as any, - }); - - verifyIfAccountNeedUpgradeOrDeploySpy.mockReturnThis(); - - return { - getKeysFromAddressSpy, - getStateDataSpy, - verifyIfAccountNeedUpgradeOrDeploySpy, - }; - }; - - it('executes request', async () => { - const chainId = constants.StarknetChainId.SN_SEPOLIA; - const account = await mockAccount(chainId); - await prepareExecute(account); - const rpc = new MockAccountRpc(); - - const result = await rpc.execute({ - address: account.address, - chainId, - }); - - expect(result).toBe(`done ${account.address} and ${chainId}`); - }); - - it('fetchs account before execute', async () => { - const chainId = constants.StarknetChainId.SN_SEPOLIA; - const account = await mockAccount(chainId); - const { getKeysFromAddressSpy } = await prepareExecute(account); - const rpc = new MockAccountRpc(); - - await rpc.execute({ address: account.address, chainId }); - - expect(getKeysFromAddressSpy).toHaveBeenCalled(); - }); - - it.each([true, false])( - `assign verifyIfAccountNeedUpgradeOrDeploy's argument "showAlert" to %s if the constructor option 'showInvalidAccountAlert' is set to %s`, - async (showInvalidAccountAlert: boolean) => { - const chainId = constants.StarknetChainId.SN_SEPOLIA; - const account = await mockAccount(chainId); - const { verifyIfAccountNeedUpgradeOrDeploySpy } = await prepareExecute( - account, - ); - const rpc = new MockAccountRpc({ - showInvalidAccountAlert, - }); - - await rpc.execute({ address: account.address, chainId }); - - expect(verifyIfAccountNeedUpgradeOrDeploySpy).toHaveBeenCalledWith( - expect.any(Object), - account.address, - account.publicKey, - showInvalidAccountAlert, - ); - }, - ); -}); diff --git a/packages/starknet-snap/src/utils/rpc.ts b/packages/starknet-snap/src/utils/rpc.ts index 4d88e347..518b102a 100644 --- a/packages/starknet-snap/src/utils/rpc.ts +++ b/packages/starknet-snap/src/utils/rpc.ts @@ -1,17 +1,7 @@ -import type { getBIP44ChangePathString } from '@metamask/key-tree/dist/types/utils'; -import type { Json } from '@metamask/snaps-sdk'; import type { Struct } from 'superstruct'; import { assert } from 'superstruct'; -import type { Network, SnapState } from '../types/snapState'; import { InvalidRequestParamsError, UnknownError } from './exceptions'; -import { logger } from './logger'; -import { getBip44Deriver, getStateData } from './snap'; -import { - getNetworkFromChainId, - verifyIfAccountNeedUpgradeOrDeploy, -} from './snapUtils'; -import { getKeysFromAddress } from './starknetUtils'; /** * Validates that the request parameters conform to the expected structure defined by the provided struct. @@ -44,112 +34,3 @@ export function validateResponse(response: Params, struct: Struct) { throw new UnknownError('Invalid Response') as unknown as Error; } } - -export abstract class RpcController< - Request extends Json, - Response extends Json, -> { - /** - * Superstruct for the request. - */ - protected abstract requestStruct: Struct; - - /** - * Superstruct for the response. - */ - protected abstract responseStruct: Struct; - - protected abstract handleRequest(params: Request): Promise; - - protected async preExecute(params: Request): Promise { - logger.info(`Request: ${JSON.stringify(params)}`); - validateRequest(params, this.requestStruct); - } - - protected async postExecute(response: Response): Promise { - logger.info(`Response: ${JSON.stringify(response)}`); - validateResponse(response, this.responseStruct); - } - - /** - * A method to execute the rpc method. - * - * @param params - An struct contains the require parameter for the request. - * @returns A promise that resolves to an json. - */ - async execute(params: Request): Promise { - await this.preExecute(params); - const resp = await this.handleRequest(params); - await this.postExecute(resp); - return resp; - } -} - -// TODO: the Type should be moved to a common place -export type AccountRpcParams = { - chainId: string; - address: string; -}; - -// TODO: the Account object should move into a account manager for generate account -export type Account = { - privateKey: string; - publicKey: string; - addressIndex: number; - // This is the derivation path of the address, it is used in `getNextAddressIndex` to find the account in state where matching the same derivation path - derivationPath: ReturnType; -}; - -export type AccountRpcControllerOptions = { - showInvalidAccountAlert: boolean; -}; - -export abstract class AccountRpcController< - Request extends AccountRpcParams, - Response extends Json, -> extends RpcController { - protected account: Account; - - protected network: Network; - - protected options: AccountRpcControllerOptions; - - protected defaultOptions: AccountRpcControllerOptions = { - showInvalidAccountAlert: true, - }; - - constructor(options?: AccountRpcControllerOptions) { - super(); - this.options = Object.assign({}, this.defaultOptions, options); - } - - protected async preExecute(params: Request): Promise { - await super.preExecute(params); - - const { chainId, address } = params; - const { showInvalidAccountAlert } = this.options; - - const deriver = await getBip44Deriver(); - // TODO: Instead of getting the state directly, we should implement state management to consolidate the state fetching - const state = await getStateData(); - - // TODO: getNetworkFromChainId from state is still needed, due to it is supporting in get-starknet at this moment - this.network = getNetworkFromChainId(state, chainId); - - // TODO: This method should be refactored to get the account from an account manager - this.account = await getKeysFromAddress( - deriver, - this.network, - state, - address, - ); - - // TODO: rename this method to verifyAccount - await verifyIfAccountNeedUpgradeOrDeploy( - this.network, - address, - this.account.publicKey, - showInvalidAccountAlert, - ); - } -} From 464d65c0e3d181efe76048d7dbfffc2f88492ecd Mon Sep 17 00:00:00 2001 From: khanti42 Date: Mon, 2 Dec 2024 03:44:14 +0100 Subject: [PATCH 43/50] chore: update chunkFilename (#445) --- packages/get-starknet/webpack.config.build.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/get-starknet/webpack.config.build.js b/packages/get-starknet/webpack.config.build.js index 22412e2b..727c05dd 100644 --- a/packages/get-starknet/webpack.config.build.js +++ b/packages/get-starknet/webpack.config.build.js @@ -18,6 +18,8 @@ module.exports = (env) => merge(common, { mode: 'production', output: { + filename: '[name].[contenthash].js?v=[fullhash]', // Appends a cache-busting query string + chunkFilename: '[name].[contenthash].js?v=[fullhash]', // For dynamically imported chunks publicPath: process.env.GET_STARKNET_PUBLIC_PATH || 'https://snaps.consensys.io/starknet/get-starknet/v1/', }, plugins: [ From ed699ff8d9f4e26503e78bcfd15ef57145e7b55f Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 2 Dec 2024 12:52:23 +0800 Subject: [PATCH 44/50] chore: re-structure starkscan type --- .../starknet-snap/src/chain/api-client.ts | 129 +++++++++++ .../src/chain/data-client/starkscan.test.ts | 8 +- .../src/chain/data-client/starkscan.ts | 218 +++++++++--------- .../src/chain/data-client/starkscan.type.ts | 46 ++++ 4 files changed, 287 insertions(+), 114 deletions(-) create mode 100644 packages/starknet-snap/src/chain/api-client.ts create mode 100644 packages/starknet-snap/src/chain/data-client/starkscan.type.ts diff --git a/packages/starknet-snap/src/chain/api-client.ts b/packages/starknet-snap/src/chain/api-client.ts new file mode 100644 index 00000000..4bb2d4a8 --- /dev/null +++ b/packages/starknet-snap/src/chain/api-client.ts @@ -0,0 +1,129 @@ +import type { Json } from '@metamask/snaps-sdk'; +import { logger } from 'ethers'; +import type { Struct } from 'superstruct'; +import { mask } from 'superstruct'; + +export enum HttpMethod { + Get = 'GET', + Post = 'POST', +} + +export type HttpHeaders = Record; + +export type HttpRequest = { + url: string; + method: HttpMethod; + headers: HttpHeaders; + body?: string; +}; + +export type HttpResponse = globalThis.Response; + +export abstract class ApiClient { + /** + * The name of the API Client. + */ + abstract apiClientName: string; + + /** + * An internal method called internally by `submitRequest()` to verify and convert the HTTP response to the expected API response. + * + * @param response - The HTTP response to verify and convert. + * @returns A promise that resolves to the API response. + */ + protected async getResponse( + response: HttpResponse, + ): Promise { + try { + return (await response.json()) as unknown as ApiResponse; + } catch (error) { + throw new Error( + 'API response error: response body can not be deserialised.', + ); + } + } + + /** + * An internal method used to build the `HttpRequest` object. + * + * @param params - The request parameters. + * @param params.method - The HTTP method (GET or POST). + * @param params.headers - The HTTP headers. + * @param params.url - The request URL. + * @param [params.body] - The request body (optional). + * @returns A `HttpRequest` object. + */ + protected buildHttpRequest({ + method, + headers = {}, + url, + body, + }: { + method: HttpMethod; + headers?: HttpHeaders; + url: string; + body?: Json; + }): HttpRequest { + const request = { + url, + method, + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + body: + method === HttpMethod.Post && body ? JSON.stringify(body) : undefined, + }; + + return request; + } + + /** + * An internal method used to submit the API request. + * + * @param params - The request parameters. + * @param [params.requestName] - The name of the request (optional). + * @param params.request - The `HttpRequest` object. + * @param params.responseStruct - The superstruct used to verify the API response. + * @returns A promise that resolves to a JSON object. + */ + protected async submitHttpRequest({ + requestName = '', + request, + responseStruct, + }: { + requestName?: string; + request: HttpRequest; + responseStruct: Struct; + }): Promise { + const logPrefix = `[${this.apiClientName}.${requestName}]`; + + try { + logger.debug(`${logPrefix} request: ${request.method}`); // Log HTTP method being used. + + const fetchRequest = { + method: request.method, + headers: request.headers, + body: request.body, + }; + + const httpResponse = await fetch(request.url, fetchRequest); + + const jsonResponse = await this.getResponse(httpResponse); + + logger.debug(`${logPrefix} response:`, JSON.stringify(jsonResponse)); + + // Safeguard to identify if the response has some unexpected changes from the API client + mask(jsonResponse, responseStruct, `Unexpected response from API client`); + + return jsonResponse; + } catch (error) { + logger.info( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `${logPrefix} error: ${error.message}`, + ); + + throw error; + } + } +} diff --git a/packages/starknet-snap/src/chain/data-client/starkscan.test.ts b/packages/starknet-snap/src/chain/data-client/starkscan.test.ts index abcc3aab..06e3be27 100644 --- a/packages/starknet-snap/src/chain/data-client/starkscan.test.ts +++ b/packages/starknet-snap/src/chain/data-client/starkscan.test.ts @@ -9,8 +9,8 @@ import { STARKNET_MAINNET_NETWORK, STARKNET_SEPOLIA_TESTNET_NETWORK, } from '../../utils/constants'; -import type { StarkScanOptions } from './starkscan'; -import { StarkScanClient, type StarkScanTransaction } from './starkscan'; +import type { StarkScanOptions, StarkScanTransaction } from './starkscan.type'; +import { StarkScanClient } from './starkscan'; describe('StarkScanClient', () => { class MockStarkScanClient extends StarkScanClient { @@ -22,8 +22,8 @@ describe('StarkScanClient', () => { return super.baseUrl; } - async get(url: string): Promise { - return super.get(url); + async submitGetApiRequest(request): Promise { + return await super.submitGetApiRequest(request); } } diff --git a/packages/starknet-snap/src/chain/data-client/starkscan.ts b/packages/starknet-snap/src/chain/data-client/starkscan.ts index 12db0cb1..82d03af2 100644 --- a/packages/starknet-snap/src/chain/data-client/starkscan.ts +++ b/packages/starknet-snap/src/chain/data-client/starkscan.ts @@ -1,74 +1,23 @@ import { TransactionType, - type TransactionFinalityStatus, - type TransactionExecutionStatus, constants, } from 'starknet'; +import { Struct } from 'superstruct'; import type { Network, Transaction, TranscationAccountCall, } from '../../types/snapState'; +import { type StarkScanAccountCall, type StarkScanTransaction, type StarkScanOptions, StarkScanTransactionsResponseStruct, StarkScanTransactionsResponse } from './starkscan.type'; import type { IDataClient } from '../data-client'; +import { ApiClient, HttpHeaders, HttpMethod, HttpResponse } from '../api-client'; + +export class StarkScanClient extends ApiClient implements IDataClient { + apiClientName = 'StarkScanClient'; + + protected limit: number = 100; -/* eslint-disable */ -export type StarkScanTransaction = { - transaction_hash: string; - block_hash: string; - block_number: number; - transaction_index: number; - transaction_status: string; - transaction_finality_status: TransactionExecutionStatus; - transaction_execution_status: TransactionFinalityStatus; - transaction_type: TransactionType; - version: number; - signature: string[]; - max_fee: string; - actual_fee: string; - nonce: string; - contract_address: string | null; - entry_point_selector: string | null; - entry_point_type: string | null; - calldata: string[]; - class_hash: string | null; - sender_address: string | null; - constructor_calldata: string[] | null; - contract_address_salt: string | null; - timestamp: number; - entry_point_selector_name: string; - number_of_events: number; - revert_error: string | null; - account_calls: StarkScanAccountCall[]; -}; - -export type StarkScanAccountCall = { - block_hash: string; - block_number: number; - transaction_hash: string; - caller_address: string; - contract_address: string; - calldata: string[]; - result: string[]; - timestamp: number; - call_type: string; - class_hash: string; - selector: string; - entry_point_type: string; - selector_name: string; -}; - -export type StarkScanTransactionsResponse = { - next_url: string | null; - data: StarkScanTransaction[]; -}; - -export type StarkScanOptions = { - apiKey: string; -}; -/* eslint-enable */ - -export class StarkScanClient implements IDataClient { protected network: Network; protected options: StarkScanOptions; @@ -76,6 +25,7 @@ export class StarkScanClient implements IDataClient { protected deploySelectorName = 'constructor'; constructor(network: Network, options: StarkScanOptions) { + super(); this.network = network; this.options = options; } @@ -95,22 +45,42 @@ export class StarkScanClient implements IDataClient { return `${this.baseUrl}${endpoint}`; } - protected getCredential(): Record { + protected getHttpHeaders(): HttpHeaders { return { 'x-api-key': this.options.apiKey, }; } - protected async get(url: string): Promise { - const response = await fetch(url, { - method: 'GET', - headers: this.getCredential(), - }); - - if (!response.ok) { - throw new Error(`Failed to fetch data: ${response.statusText}`); + protected async getResponse( + response: HttpResponse, + ): Promise { + // For successful requests, Simplehash will return a 200 status code. + // Any other status code should be considered an error. + if (response.status !== 200) { + throw new Error(`API response error`); } - return response.json() as unknown as Resp; + + return await super.getResponse(response); + } + + protected async submitGetApiRequest({ + apiUrl, + responseStruct, + requestName, + }: { + apiUrl: string; + responseStruct: Struct; + requestName: string; + }): Promise { + return await super.submitHttpRequest({ + request: this.buildHttpRequest({ + method: HttpMethod.Get, + url: this.getApiUrl(apiUrl), + headers: this.getHttpHeaders(), + }), + responseStruct, + requestName, + }); } /** @@ -118,29 +88,32 @@ export class StarkScanClient implements IDataClient { * The transactions are fetched in descending order and it will include the deploy transaction. * * @param address - The address of the contract to fetch the transactions for. - * @param tillTo - The timestamp to fetch the transactions until. + * @param to - The timestamp to fetch the transactions until. * @returns A Promise that resolve an array of Transaction object. */ async getTransactions( address: string, - tillTo: number, + to: number, ): Promise { - let apiUrl = this.getApiUrl( - `/transactions?contract_address=${address}&order_by=desc&limit=100`, - ); + let apiUrl = this.getApiUrl(`/transactions?contract_address=${address}&order_by=desc&limit=${this.limit}`); const txs: Transaction[] = []; let deployTxFound = false; let process = true; let timestamp = 0; - // Fetch the transactions if: - // - the timestamp is greater than the `tillTo` AND + // Scan the transactions in descending order by timestamp + // Include the transaction if: + // - it's timestamp is greater than the `tillTo` AND // - there is an next data to fetch - while (process && (timestamp === 0 || timestamp >= tillTo)) { + while (process && (timestamp === 0 || timestamp >= to)) { process = false; - const result = await this.get(apiUrl); + const result = await this.submitGetApiRequest({ + apiUrl, + responseStruct: StarkScanTransactionsResponseStruct, + requestName: 'getTransactions', + }); for (const data of result.data) { const tx = this.toTransaction(data); @@ -154,7 +127,7 @@ export class StarkScanClient implements IDataClient { // If the timestamp is smaller than the `tillTo` // We don't need those records // But if the record is an deploy transaction, we should include it to reduce the number of requests - if (timestamp >= tillTo || isDeployTx) { + if (timestamp >= to || isDeployTx) { txs.push(tx); } } @@ -165,7 +138,8 @@ export class StarkScanClient implements IDataClient { } } - // If the deploy transaction is not found from above traverse, we need to fetch it separately + // If no deploy transaction found, + // we scan the transactions in asc order by timestamp, as deploy transaction is usually the first transaction if (!deployTxFound) { const deployTx = await this.getDeployTransaction(address); deployTx && txs.push(deployTx); @@ -183,11 +157,13 @@ export class StarkScanClient implements IDataClient { async getDeployTransaction(address: string): Promise { // Fetch the first 5 transactions to find the deploy transaction // The deploy transaction usually is the first transaction from the list - const apiUrl = this.getApiUrl( - `/transactions?contract_address=${address}&order_by=asc&limit=5`, - ); + const apiUrl = this.getApiUrl(`/transactions?contract_address=${address}&order_by=asc&limit=5`); - const result = await this.get(apiUrl); + const result = await this.submitGetApiRequest({ + apiUrl, + responseStruct: StarkScanTransactionsResponseStruct, + requestName: 'getTransactions' + }); for (const data of result.data) { if (this.isDeployTransaction(data)) { @@ -202,43 +178,65 @@ export class StarkScanClient implements IDataClient { return tx.transaction_type === TransactionType.DEPLOY_ACCOUNT; } - protected isFundTransferTransaction(call: StarkScanAccountCall): boolean { - return call.selector_name === 'transfer'; + protected isFundTransferTransaction(entrypoint: string): boolean { + return entrypoint === 'transfer'; } - protected toTransaction(tx: StarkScanTransaction): Transaction { - let sender = tx.sender_address ?? ''; + protected getContractAddress(tx: StarkScanTransaction): string { + // backfill the contract address if it is null + return tx.contract_address ?? ''; + } - // account_calls representing the calls to invoke from the account contract, it can be multiple - const accountCalls = this.toAccountCall(tx.account_calls); + protected getSenderAddress(tx: StarkScanTransaction): string { + let sender = tx.sender_address; - // eslint-disable-next-line no-negated-condition if (this.isDeployTransaction(tx)) { - // In case of deploy transaction, the contract address is the sender address + // if it is a deploy transaction, the contract address is the sender address sender = tx.contract_address as unknown as string; } - /* eslint-disable */ + // backfill the sender address if it is null + return sender ?? ''; + } + + protected toTransaction(tx: StarkScanTransaction): Transaction { + /* eslint-disable @typescript-eslint/naming-convention */ + + const { + transaction_hash: txnHash, + transaction_type: txnType, + timestamp, + transaction_finality_status: finalityStatus, + transaction_execution_status: executionStatus, + max_fee: maxFee, + actual_fee: actualFee, + revert_error: failureReason, + account_calls: calls + } = tx; + + // account_calls representing the calls to invoke from the account contract, it can be multiple + // If the transaction is a deploy transaction, the account_calls is a empty array + const accountCalls = this.toAccountCall(calls); + return { - txnHash: tx.transaction_hash, - txnType: tx.transaction_type, + txnHash, + txnType, chainId: this.network.chainId, - senderAddress: sender, - - // In case of deploy transaction, the contract address is the sender address, else it will be empty string - contractAddress: tx.contract_address ?? '', - // TODO: when multiple calls are supported, we move this to accountCalls + senderAddress: this.getSenderAddress(tx), + timestamp, + finalityStatus, + executionStatus, + maxFee, + actualFee, + contractAddress: this.getContractAddress(tx), + accountCalls, + // the entry point selector name is moved to accountCalls contractFuncName: '', - // TODO: when multiple calls are supported, we move this to accountCalls - contractCallData: tx.calldata ?? [], - timestamp: tx.timestamp, - finalityStatus: tx.transaction_finality_status, - executionStatus: tx.transaction_execution_status, - failureReason: tx.revert_error ?? '', - maxFee: tx.max_fee, - actualFee: tx.actual_fee, - accountCalls: accountCalls, + // the account call data is moved to accountCalls + contractCallData: [], + failureReason: failureReason ?? '', }; + /* eslint-enable */ } @@ -270,7 +268,7 @@ export class StarkScanClient implements IDataClient { contractCallData, }; - if (this.isFundTransferTransaction(accountCallArg)) { + if (this.isFundTransferTransaction(contractFuncName)) { accountCall.recipient = accountCallArg.calldata[0]; accountCall.amount = accountCallArg.calldata[1]; } diff --git a/packages/starknet-snap/src/chain/data-client/starkscan.type.ts b/packages/starknet-snap/src/chain/data-client/starkscan.type.ts new file mode 100644 index 00000000..44282e0a --- /dev/null +++ b/packages/starknet-snap/src/chain/data-client/starkscan.type.ts @@ -0,0 +1,46 @@ +import { TransactionExecutionStatus, TransactionFinalityStatus, TransactionType } from "starknet"; +import { array, Infer, nullable, number, object, string, enums } from "superstruct"; + +/* eslint-disable @typescript-eslint/naming-convention */ +const NullableStringStruct = nullable(string()); +const NullableStringArrayStruct = nullable(array(string())); + +export const StarkScanAccountCallStruct = object({ + contract_address: string(), + calldata: array(string()), + selector_name: string(), +}); + +export const StarkScanTransactionStruct = object({ + transaction_hash: string(), + transaction_finality_status: enums(Object.values(TransactionFinalityStatus)), + transaction_execution_status: enums(Object.values(TransactionExecutionStatus)), + transaction_type: enums(Object.values(TransactionType)), + // The transaction version, 1 or 3, where 3 represents the fee will be paid in STRK + version: number(), + max_fee: NullableStringStruct, + actual_fee: NullableStringStruct, + nonce: NullableStringStruct, + contract_address: NullableStringStruct, + calldata: NullableStringArrayStruct, + sender_address: NullableStringStruct, + timestamp: number(), + revert_error: NullableStringStruct, + account_calls: array(StarkScanAccountCallStruct), +}); + +export type StarkScanAccountCall = Infer; + +export type StarkScanTransaction = Infer; + +export const StarkScanTransactionsResponseStruct = object({ + next_url: nullable(string()), + data: array(StarkScanTransactionStruct) +}); + +export type StarkScanTransactionsResponse = Infer, + +export type StarkScanOptions = { + apiKey: string, +} +/* eslint-enable */ \ No newline at end of file From 93adb3680d25a22b803803bb03e25af603f25238 Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 2 Dec 2024 15:56:01 +0800 Subject: [PATCH 45/50] chore: add test coverage --- .../starknet-snap/src/__tests__/helper.ts | 2 +- .../starknet-snap/src/chain/api-client.ts | 11 +- .../src/chain/data-client/starkscan.test.ts | 250 ++++++++++-------- .../src/chain/data-client/starkscan.ts | 91 +++---- .../src/chain/data-client/starkscan.type.ts | 61 +++-- packages/starknet-snap/src/types/snapState.ts | 33 ++- 6 files changed, 251 insertions(+), 197 deletions(-) diff --git a/packages/starknet-snap/src/__tests__/helper.ts b/packages/starknet-snap/src/__tests__/helper.ts index 33d5364e..8ef6580c 100644 --- a/packages/starknet-snap/src/__tests__/helper.ts +++ b/packages/starknet-snap/src/__tests__/helper.ts @@ -22,7 +22,7 @@ import { v4 as uuidv4 } from 'uuid'; import type { StarkScanTransaction, StarkScanTransactionsResponse, -} from '../chain/data-client/starkscan'; +} from '../chain/data-client/starkscan.type'; import { FeeToken } from '../types/snapApi'; import type { AccContract, diff --git a/packages/starknet-snap/src/chain/api-client.ts b/packages/starknet-snap/src/chain/api-client.ts index 4bb2d4a8..253af4fe 100644 --- a/packages/starknet-snap/src/chain/api-client.ts +++ b/packages/starknet-snap/src/chain/api-client.ts @@ -1,8 +1,9 @@ import type { Json } from '@metamask/snaps-sdk'; -import { logger } from 'ethers'; import type { Struct } from 'superstruct'; import { mask } from 'superstruct'; +import { logger } from '../utils/logger'; + export enum HttpMethod { Get = 'GET', Post = 'POST', @@ -31,7 +32,7 @@ export abstract class ApiClient { * @param response - The HTTP response to verify and convert. * @returns A promise that resolves to the API response. */ - protected async getResponse( + protected async parseResponse( response: HttpResponse, ): Promise { try { @@ -79,7 +80,7 @@ export abstract class ApiClient { } /** - * An internal method used to submit the API request. + * An internal method used to send a HTTP request. * * @param params - The request parameters. * @param [params.requestName] - The name of the request (optional). @@ -87,7 +88,7 @@ export abstract class ApiClient { * @param params.responseStruct - The superstruct used to verify the API response. * @returns A promise that resolves to a JSON object. */ - protected async submitHttpRequest({ + protected async sendHttpRequest({ requestName = '', request, responseStruct, @@ -109,7 +110,7 @@ export abstract class ApiClient { const httpResponse = await fetch(request.url, fetchRequest); - const jsonResponse = await this.getResponse(httpResponse); + const jsonResponse = await this.parseResponse(httpResponse); logger.debug(`${logPrefix} response:`, JSON.stringify(jsonResponse)); diff --git a/packages/starknet-snap/src/chain/data-client/starkscan.test.ts b/packages/starknet-snap/src/chain/data-client/starkscan.test.ts index 06e3be27..33fdfb0b 100644 --- a/packages/starknet-snap/src/chain/data-client/starkscan.test.ts +++ b/packages/starknet-snap/src/chain/data-client/starkscan.test.ts @@ -9,8 +9,16 @@ import { STARKNET_MAINNET_NETWORK, STARKNET_SEPOLIA_TESTNET_NETWORK, } from '../../utils/constants'; -import type { StarkScanOptions, StarkScanTransaction } from './starkscan.type'; +import { InvalidNetworkError } from '../../utils/exceptions'; import { StarkScanClient } from './starkscan'; +import { + StarkScanTransactionsResponseStruct, + type StarkScanOptions, + type StarkScanTransaction, + type StarkScanTransactionsResponse, +} from './starkscan.type'; + +jest.mock('../../utils/logger'); describe('StarkScanClient', () => { class MockStarkScanClient extends StarkScanClient { @@ -22,8 +30,12 @@ describe('StarkScanClient', () => { return super.baseUrl; } - async submitGetApiRequest(request): Promise { - return await super.submitGetApiRequest(request); + async sendApiRequest(request): Promise { + return await super.sendApiRequest(request); + } + + getSenderAddress(tx: StarkScanTransaction): string { + return super.getSenderAddress(tx); } } @@ -61,7 +73,36 @@ describe('StarkScanClient', () => { return account; }; - const mSecsFor24Hours = 1000 * 60 * 60 * 24; + const mockApiSuccess = ({ + fetchSpy, + // eslint-disable-next-line @typescript-eslint/naming-convention + response = { data: [], next_url: null }, + }: { + fetchSpy: jest.SpyInstance; + response?: StarkScanTransactionsResponse; + }) => { + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(response), + }); + }; + + const mockApiFailure = ({ fetchSpy }: { fetchSpy: jest.SpyInstance }) => { + fetchSpy.mockResolvedValueOnce({ + ok: false, + statusText: 'error', + }); + }; + + const mockTxByType = (txnType: TransactionType, address: string) => { + const mockResponse = generateStarkScanTranscations({ + address, + txnTypes: [txnType], + cnt: 1, + }); + const tx = mockResponse.data[0]; + return tx; + }; describe('baseUrl', () => { it.each([ @@ -84,7 +125,7 @@ describe('StarkScanClient', () => { }, ); - it('throws `Invalid Network` error if the chain id is invalid', () => { + it('throws `InvalidNetworkError` if the chain id is invalid', () => { const invalidNetwork: Network = { name: 'Invalid Network', chainId: '0x534e5f474f45524c49', @@ -97,30 +138,35 @@ describe('StarkScanClient', () => { network: invalidNetwork, }); - expect(() => client.baseUrl).toThrow('Invalid Network'); + expect(() => client.baseUrl).toThrow(InvalidNetworkError); }); }); - describe('get', () => { + describe('sendApiRequest', () => { + const mockRequest = () => { + return { + apiUrl: `/url`, + responseStruct: StarkScanTransactionsResponseStruct, + requestName: 'getTransactions', + }; + }; + it('fetches data', async () => { const { fetchSpy } = createMockFetch(); - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue({ data: 'data' }), - }); + // eslint-disable-next-line @typescript-eslint/naming-convention + const expectedResponse = { data: [], next_url: null }; + mockApiSuccess({ fetchSpy, response: expectedResponse }); const client = createMockClient(); - const result = await client.get(`${client.baseUrl}/url`); + const result = await client.sendApiRequest(mockRequest()); - expect(result).toStrictEqual({ data: 'data' }); + expect(result).toStrictEqual(expectedResponse); }); - it('append api key to header', async () => { + it('appends a api key to header', async () => { const { fetchSpy } = createMockFetch(); - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue({ data: 'data' }), - }); + mockApiSuccess({ fetchSpy }); + const apiKey = 'ABCDEFG-API-KEY'; const client = createMockClient({ @@ -128,32 +174,32 @@ describe('StarkScanClient', () => { apiKey, }, }); - await client.get(`${client.baseUrl}/url`); + await client.sendApiRequest(mockRequest()); - expect(fetchSpy).toHaveBeenCalledWith(`${client.baseUrl}/url`, { + expect(fetchSpy).toHaveBeenCalledWith(`/url`, { method: 'GET', + body: undefined, headers: { + 'Content-Type': 'application/json', 'x-api-key': apiKey, }, }); }); - it('throws `Failed to fetch data` error if the response.ok is falsy', async () => { + it('throws `API response error: response body can not be deserialised.` error if the response.ok is falsy', async () => { const { fetchSpy } = createMockFetch(); - fetchSpy.mockResolvedValueOnce({ - ok: false, - statusText: 'error', - }); + mockApiFailure({ fetchSpy }); const client = createMockClient(); - - await expect(client.get(`${client.baseUrl}/url`)).rejects.toThrow( - `Failed to fetch data: error`, + await expect(client.sendApiRequest(mockRequest())).rejects.toThrow( + `API response error: response body can not be deserialised.`, ); }); }); describe('getTransactions', () => { + const mSecsFor24Hours = 1000 * 60 * 60 * 24; + const getFromAndToTimestamp = (tillToInDay: number) => { const from = Math.floor(Date.now() / 1000); const to = from - tillToInDay * 24 * 60 * 60; @@ -171,12 +217,8 @@ describe('StarkScanClient', () => { const mockResponse = generateStarkScanTranscations({ address: account.address, startFrom: from, - timestampReduction: mSecsFor24Hours, - }); - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), }); + mockApiSuccess({ fetchSpy, response: mockResponse }); const client = createMockClient(); const result = await client.getTransactions(account.address, to); @@ -200,26 +242,10 @@ describe('StarkScanClient', () => { const account = await mockAccount(); const { fetchSpy } = createMockFetch(); const { to } = getFromAndToTimestamp(5); - // generate 0 transactions - const mockInvokeResponse = generateStarkScanTranscations({ - address: account.address, - cnt: 0, - txnTypes: [TransactionType.INVOKE], - }); - // generate 0 transactions - const mockDeployResponse = generateStarkScanTranscations({ - address: account.address, - cnt: 0, - txnTypes: [TransactionType.INVOKE], - }); - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(mockInvokeResponse), - }); - fetchSpy.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockDeployResponse), - }); + // mock the get invoke transactions response with empty data + mockApiSuccess({ fetchSpy }); + // mock the get deploy transaction response with empty data + mockApiSuccess({ fetchSpy }); const client = createMockClient(); const result = await client.getTransactions(account.address, to); @@ -232,81 +258,75 @@ describe('StarkScanClient', () => { const { fetchSpy } = createMockFetch(); // generate the to timestamp which is 100 days ago const { to } = getFromAndToTimestamp(100); - // generate 10 invoke transactions within 100 days if the timestamp is not provided const mockPage1Response = generateStarkScanTranscations({ address: account.address, txnTypes: [TransactionType.INVOKE], cnt: 10, }); - // generate another 10 invoke + deploy transactions within 100 days if the timestamp is not provided const mockPage2Response = generateStarkScanTranscations({ address: account.address, cnt: 10, }); const firstPageUrl = `https://api-sepolia.starkscan.co/api/v0/transactions?contract_address=${account.address}&order_by=desc&limit=100`; const nextPageUrl = `https://api-sepolia.starkscan.co/api/v0/transactions?contract_address=${account.address}&order_by=desc&cursor=MTcyNDc1OTQwNzAwMDAwNjAwMDAwMA%3D%3D`; - const fetchOptions = { - method: 'GET', - headers: { - 'x-api-key': 'api-key', - }, - }; - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue({ + // mock the first page response, which contains the next_url + mockApiSuccess({ + fetchSpy, + response: { data: mockPage1Response.data, // eslint-disable-next-line @typescript-eslint/naming-convention next_url: nextPageUrl, - }), - }); - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(mockPage2Response), + }, }); + // mock the send page response + mockApiSuccess({ fetchSpy, response: mockPage2Response }); const client = createMockClient(); await client.getTransactions(account.address, to); expect(fetchSpy).toHaveBeenCalledTimes(2); - expect(fetchSpy).toHaveBeenNthCalledWith(1, firstPageUrl, fetchOptions); - expect(fetchSpy).toHaveBeenNthCalledWith(2, nextPageUrl, fetchOptions); + expect(fetchSpy).toHaveBeenNthCalledWith( + 1, + firstPageUrl, + expect.any(Object), + ); + expect(fetchSpy).toHaveBeenNthCalledWith( + 2, + nextPageUrl, + expect.any(Object), + ); }); it('fetchs the deploy transaction if it is not present', async () => { const account = await mockAccount(); const { fetchSpy } = createMockFetch(); + // generate the to timestamp which is 5 days ago const { from, to } = getFromAndToTimestamp(5); - // generate 10 invoke transactions + // generate 10 invoke transactions, and 1 day time gap between each transaction const mockInvokeResponse = generateStarkScanTranscations({ address: account.address, startFrom: from, timestampReduction: mSecsFor24Hours, txnTypes: [TransactionType.INVOKE], }); - // generate 5 invoke transactions + deploy transactions + // generate another 5 invoke transactions + deploy transactions for testing the fallback case const mockDeployResponse = generateStarkScanTranscations({ address: account.address, // generate transactions that start from 100 days ago, to ensure not overlap with above invoke transactions - startFrom: from - mSecsFor24Hours * 100, + startFrom: mSecsFor24Hours * 100, timestampReduction: mSecsFor24Hours, txnTypes: [TransactionType.INVOKE, TransactionType.DEPLOY_ACCOUNT], cnt: 5, }); - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(mockInvokeResponse), - }); - fetchSpy.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockDeployResponse), - }); + mockApiSuccess({ fetchSpy, response: mockInvokeResponse }); + mockApiSuccess({ fetchSpy, response: mockDeployResponse }); const client = createMockClient(); // We only fetch the transactions from the last 5 days const result = await client.getTransactions(account.address, to); - // However the result should include a deploy transaction, even the deploy transaction is not in the last 5 days + // The result should include a deploy transaction, even it is not from the last 5 days expect( result.find((tx) => tx.txnType === TransactionType.DEPLOY_ACCOUNT), ).toBeDefined(); @@ -314,16 +334,6 @@ describe('StarkScanClient', () => { }); describe('toTransaction', () => { - const mockTxByType = (txnType: TransactionType, address: string) => { - const mockResponse = generateStarkScanTranscations({ - address, - txnTypes: [txnType], - cnt: 1, - }); - const tx = mockResponse.data[0]; - return tx; - }; - it('converts an invoke type starkscan transaction to a transaction object', async () => { const account = await mockAccount(); const mockTx = mockTxByType(TransactionType.INVOKE, account.address); @@ -343,8 +353,6 @@ describe('StarkScanClient', () => { chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, senderAddress: account.address, contractAddress: '', - contractFuncName: '', - contractCallData: mockTx.calldata, timestamp: mockTx.timestamp, finalityStatus: mockTx.transaction_finality_status, executionStatus: mockTx.transaction_execution_status, @@ -362,6 +370,7 @@ describe('StarkScanClient', () => { }, ], }, + version: 'V2', }); }); @@ -381,8 +390,6 @@ describe('StarkScanClient', () => { chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, senderAddress: account.address, contractAddress: account.address, - contractFuncName: '', - contractCallData: [], timestamp: mockTx.timestamp, finalityStatus: mockTx.transaction_finality_status, executionStatus: mockTx.transaction_execution_status, @@ -390,6 +397,7 @@ describe('StarkScanClient', () => { maxFee: mockTx.max_fee, actualFee: mockTx.actual_fee, accountCalls: null, + version: 'V2', }); }); }); @@ -403,10 +411,7 @@ describe('StarkScanClient', () => { address: account.address, cnt: 5, }); - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); + mockApiSuccess({ fetchSpy, response: mockResponse }); const client = createMockClient(); const result = await client.getDeployTransaction(account.address); @@ -424,10 +429,7 @@ describe('StarkScanClient', () => { cnt: 1, txnTypes: [TransactionType.INVOKE], }); - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); + mockApiSuccess({ fetchSpy, response: mockResponse }); const client = createMockClient(); const result = await client.getDeployTransaction(account.address); @@ -435,4 +437,42 @@ describe('StarkScanClient', () => { expect(result).toBeNull(); }); }); + + describe('getSenderAddress', () => { + const prepareMockTx = async (transactionType = TransactionType.INVOKE) => { + const account = await mockAccount(); + const mockTx = mockTxByType(transactionType, account.address); + return mockTx; + }; + + it('returns the sender address', async () => { + const mockTx = await prepareMockTx(); + + const client = createMockClient(); + expect(client.getSenderAddress(mockTx)).toStrictEqual( + mockTx.sender_address, + ); + }); + + it('returns the contract address if it is a deploy transaction', async () => { + const mockTx = await prepareMockTx(TransactionType.DEPLOY_ACCOUNT); + + const client = createMockClient(); + expect(client.getSenderAddress(mockTx)).toStrictEqual( + mockTx.contract_address, + ); + }); + + it('returns an empty string if the sender address is null', async () => { + const mockTx = await prepareMockTx(); + + const client = createMockClient(); + expect( + client.getSenderAddress({ + ...mockTx, + sender_address: null, + }), + ).toBe(''); + }); + }); }); diff --git a/packages/starknet-snap/src/chain/data-client/starkscan.ts b/packages/starknet-snap/src/chain/data-client/starkscan.ts index 82d03af2..98eab071 100644 --- a/packages/starknet-snap/src/chain/data-client/starkscan.ts +++ b/packages/starknet-snap/src/chain/data-client/starkscan.ts @@ -1,22 +1,27 @@ -import { - TransactionType, - constants, -} from 'starknet'; -import { Struct } from 'superstruct'; +import { TransactionType, constants } from 'starknet'; +import type { Struct } from 'superstruct'; import type { Network, Transaction, TranscationAccountCall, } from '../../types/snapState'; -import { type StarkScanAccountCall, type StarkScanTransaction, type StarkScanOptions, StarkScanTransactionsResponseStruct, StarkScanTransactionsResponse } from './starkscan.type'; +import { InvalidNetworkError } from '../../utils/exceptions'; +import type { HttpHeaders } from '../api-client'; +import { ApiClient, HttpMethod } from '../api-client'; import type { IDataClient } from '../data-client'; -import { ApiClient, HttpHeaders, HttpMethod, HttpResponse } from '../api-client'; +import type { StarkScanTransactionsResponse } from './starkscan.type'; +import { + type StarkScanAccountCall, + type StarkScanTransaction, + type StarkScanOptions, + StarkScanTransactionsResponseStruct, +} from './starkscan.type'; export class StarkScanClient extends ApiClient implements IDataClient { apiClientName = 'StarkScanClient'; - protected limit: number = 100; + protected limit = 100; protected network: Network; @@ -37,7 +42,7 @@ export class StarkScanClient extends ApiClient implements IDataClient { case constants.StarknetChainId.SN_MAIN: return 'https://api.starkscan.co/api/v0'; default: - throw new Error(`Invalid Network`); + throw new InvalidNetworkError(); } } @@ -51,19 +56,7 @@ export class StarkScanClient extends ApiClient implements IDataClient { }; } - protected async getResponse( - response: HttpResponse, - ): Promise { - // For successful requests, Simplehash will return a 200 status code. - // Any other status code should be considered an error. - if (response.status !== 200) { - throw new Error(`API response error`); - } - - return await super.getResponse(response); - } - - protected async submitGetApiRequest({ + protected async sendApiRequest({ apiUrl, responseStruct, requestName, @@ -72,10 +65,10 @@ export class StarkScanClient extends ApiClient implements IDataClient { responseStruct: Struct; requestName: string; }): Promise { - return await super.submitHttpRequest({ + return await super.sendHttpRequest({ request: this.buildHttpRequest({ method: HttpMethod.Get, - url: this.getApiUrl(apiUrl), + url: apiUrl, headers: this.getHttpHeaders(), }), responseStruct, @@ -91,11 +84,10 @@ export class StarkScanClient extends ApiClient implements IDataClient { * @param to - The timestamp to fetch the transactions until. * @returns A Promise that resolve an array of Transaction object. */ - async getTransactions( - address: string, - to: number, - ): Promise { - let apiUrl = this.getApiUrl(`/transactions?contract_address=${address}&order_by=desc&limit=${this.limit}`); + async getTransactions(address: string, to: number): Promise { + let apiUrl = this.getApiUrl( + `/transactions?contract_address=${address}&order_by=desc&limit=${this.limit}`, + ); const txs: Transaction[] = []; let deployTxFound = false; @@ -109,7 +101,7 @@ export class StarkScanClient extends ApiClient implements IDataClient { while (process && (timestamp === 0 || timestamp >= to)) { process = false; - const result = await this.submitGetApiRequest({ + const result = await this.sendApiRequest({ apiUrl, responseStruct: StarkScanTransactionsResponseStruct, requestName: 'getTransactions', @@ -124,9 +116,15 @@ export class StarkScanClient extends ApiClient implements IDataClient { } timestamp = tx.timestamp; - // If the timestamp is smaller than the `tillTo` - // We don't need those records - // But if the record is an deploy transaction, we should include it to reduce the number of requests + // Only include the records that newer than or equal to the `to` timestamp from the same batch of result + // If there is an deploy transaction from the result, it should included too. + // e.g + // to: 1000 + // [ + // { timestamp: 1100, transaction_type: "invoke" }, <-- include + // { timestamp: 900, transaction_type: "invoke" }, <-- exclude + // { timestamp: 100, transaction_type: "deploy" } <-- include + // ] if (timestamp >= to || isDeployTx) { txs.push(tx); } @@ -138,8 +136,9 @@ export class StarkScanClient extends ApiClient implements IDataClient { } } - // If no deploy transaction found, - // we scan the transactions in asc order by timestamp, as deploy transaction is usually the first transaction + // In case no deploy transaction found from above, + // then scan the transactions in asc order by timestamp, + // the deploy transaction should usually be the first transaction from the list if (!deployTxFound) { const deployTx = await this.getDeployTransaction(address); deployTx && txs.push(deployTx); @@ -157,12 +156,14 @@ export class StarkScanClient extends ApiClient implements IDataClient { async getDeployTransaction(address: string): Promise { // Fetch the first 5 transactions to find the deploy transaction // The deploy transaction usually is the first transaction from the list - const apiUrl = this.getApiUrl(`/transactions?contract_address=${address}&order_by=asc&limit=5`); + const apiUrl = this.getApiUrl( + `/transactions?contract_address=${address}&order_by=asc&limit=5`, + ); - const result = await this.submitGetApiRequest({ + const result = await this.sendApiRequest({ apiUrl, responseStruct: StarkScanTransactionsResponseStruct, - requestName: 'getTransactions' + requestName: 'getTransactions', }); for (const data of result.data) { @@ -201,7 +202,6 @@ export class StarkScanClient extends ApiClient implements IDataClient { protected toTransaction(tx: StarkScanTransaction): Transaction { /* eslint-disable @typescript-eslint/naming-convention */ - const { transaction_hash: txnHash, transaction_type: txnType, @@ -211,7 +211,7 @@ export class StarkScanClient extends ApiClient implements IDataClient { max_fee: maxFee, actual_fee: actualFee, revert_error: failureReason, - account_calls: calls + account_calls: calls, } = tx; // account_calls representing the calls to invoke from the account contract, it can be multiple @@ -230,24 +230,21 @@ export class StarkScanClient extends ApiClient implements IDataClient { actualFee, contractAddress: this.getContractAddress(tx), accountCalls, - // the entry point selector name is moved to accountCalls - contractFuncName: '', - // the account call data is moved to accountCalls - contractCallData: [], failureReason: failureReason ?? '', + version: 'V2', }; /* eslint-enable */ } protected toAccountCall( - calls: StarkScanAccountCall[], + accountCalls: StarkScanAccountCall[], ): Record | null { - if (!calls || calls.length === 0) { + if (!accountCalls || accountCalls.length === 0) { return null; } - return calls.reduce( + return accountCalls.reduce( ( data: Record, accountCallArg: StarkScanAccountCall, diff --git a/packages/starknet-snap/src/chain/data-client/starkscan.type.ts b/packages/starknet-snap/src/chain/data-client/starkscan.type.ts index 44282e0a..a54272d4 100644 --- a/packages/starknet-snap/src/chain/data-client/starkscan.type.ts +++ b/packages/starknet-snap/src/chain/data-client/starkscan.type.ts @@ -1,32 +1,39 @@ -import { TransactionExecutionStatus, TransactionFinalityStatus, TransactionType } from "starknet"; -import { array, Infer, nullable, number, object, string, enums } from "superstruct"; +import { + TransactionExecutionStatus, + TransactionFinalityStatus, + TransactionType, +} from 'starknet'; +import type { Infer } from 'superstruct'; +import { array, nullable, number, object, string, enums } from 'superstruct'; /* eslint-disable @typescript-eslint/naming-convention */ const NullableStringStruct = nullable(string()); const NullableStringArrayStruct = nullable(array(string())); export const StarkScanAccountCallStruct = object({ - contract_address: string(), - calldata: array(string()), - selector_name: string(), + contract_address: string(), + calldata: array(string()), + selector_name: string(), }); export const StarkScanTransactionStruct = object({ - transaction_hash: string(), - transaction_finality_status: enums(Object.values(TransactionFinalityStatus)), - transaction_execution_status: enums(Object.values(TransactionExecutionStatus)), - transaction_type: enums(Object.values(TransactionType)), - // The transaction version, 1 or 3, where 3 represents the fee will be paid in STRK - version: number(), - max_fee: NullableStringStruct, - actual_fee: NullableStringStruct, - nonce: NullableStringStruct, - contract_address: NullableStringStruct, - calldata: NullableStringArrayStruct, - sender_address: NullableStringStruct, - timestamp: number(), - revert_error: NullableStringStruct, - account_calls: array(StarkScanAccountCallStruct), + transaction_hash: string(), + transaction_finality_status: enums(Object.values(TransactionFinalityStatus)), + transaction_execution_status: enums( + Object.values(TransactionExecutionStatus), + ), + transaction_type: enums(Object.values(TransactionType)), + // The transaction version, 1 or 3, where 3 represents the fee will be paid in STRK + version: number(), + max_fee: NullableStringStruct, + actual_fee: NullableStringStruct, + nonce: NullableStringStruct, + contract_address: NullableStringStruct, + calldata: NullableStringArrayStruct, + sender_address: NullableStringStruct, + timestamp: number(), + revert_error: NullableStringStruct, + account_calls: array(StarkScanAccountCallStruct), }); export type StarkScanAccountCall = Infer; @@ -34,13 +41,15 @@ export type StarkScanAccountCall = Infer; export type StarkScanTransaction = Infer; export const StarkScanTransactionsResponseStruct = object({ - next_url: nullable(string()), - data: array(StarkScanTransactionStruct) + next_url: nullable(string()), + data: array(StarkScanTransactionStruct), }); -export type StarkScanTransactionsResponse = Infer, +export type StarkScanTransactionsResponse = Infer< + typeof StarkScanTransactionsResponseStruct +>; export type StarkScanOptions = { - apiKey: string, -} -/* eslint-enable */ \ No newline at end of file + apiKey: string; +}; +/* eslint-enable */ diff --git a/packages/starknet-snap/src/types/snapState.ts b/packages/starknet-snap/src/types/snapState.ts index f5202b56..8f163df8 100644 --- a/packages/starknet-snap/src/types/snapState.ts +++ b/packages/starknet-snap/src/types/snapState.ts @@ -127,32 +127,39 @@ export type TranscationAccountCall = { amount?: string; }; -export type Transaction = { +export type LegacyTransaction = { txnHash: string; // in hex - // TEMP: add StarkNetTransactionType as optional to support the legacy data - txnType: VoyagerTransactionType | string | StarkNetTransactionType; + txnType: VoyagerTransactionType | string; chainId: string; // in hex - // TODO: rename it to address to sync with the same naming convention in the AccContract senderAddress: string; // in hex contractAddress: string; // in hex contractFuncName: string; contractCallData: RawCalldata; status?: TransactionStatus | string; - // TEMP: add TransactionFinalityStatus as optional to support the legacy data - executionStatus?: TransactionStatus | string | TransactionFinalityStatus; - // TEMP: add TransactionExecutionStatus as optional to support the legacy data - finalityStatus?: TransactionStatus | string | TransactionExecutionStatus; - failureReason?: string; - // TEMP: add it as optional to support the legacy data - eventIds?: string[]; + executionStatus?: TransactionStatus | string; + finalityStatus?: TransactionStatus | string; + failureReason: string; + eventIds: string[]; timestamp: number; +}; - // New fields - // TEMP: put those new fields as optional to support the legacy data +export type V2Transaction = { + txnHash: string; // in hex + txnType: StarkNetTransactionType; + chainId: string; // in hex + senderAddress: string; // in hex + contractAddress: string; // in hex + executionStatus?: TransactionExecutionStatus | string; + finalityStatus?: TransactionFinalityStatus | string; + failureReason: string; + timestamp: number; maxFee?: string | null; actualFee?: string | null; // using Record to support O(1) searching accountCalls?: Record | null; + version: 'V2'; }; +export type Transaction = LegacyTransaction | V2Transaction; + /* eslint-disable */ From dadb4935be0ac7184ef9a5169b41bac68c77b6d5 Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 2 Dec 2024 16:32:10 +0800 Subject: [PATCH 46/50] chore: factory and config --- packages/starknet-snap/.env.example | 3 +++ .../src/chain/data-client/starkscan.test.ts | 1 + packages/starknet-snap/src/config.ts | 16 ++++++++++++ .../starknet-snap/src/utils/factory.test.ts | 22 ++++++++++++++++ packages/starknet-snap/src/utils/factory.ts | 25 +++++++++++++++++++ 5 files changed, 67 insertions(+) create mode 100644 packages/starknet-snap/src/utils/factory.test.ts create mode 100644 packages/starknet-snap/src/utils/factory.ts diff --git a/packages/starknet-snap/.env.example b/packages/starknet-snap/.env.example index c5b657e0..b2098771 100644 --- a/packages/starknet-snap/.env.example +++ b/packages/starknet-snap/.env.example @@ -6,6 +6,9 @@ SNAP_ENV=dev # Description: Environment variables for API key of VOYAGER # Required: false VOYAGER_API_KEY= +# Description: Environment variables for API key of STARKSCAN +# Required: false +STARKSCAN_API_KEY= # Description: Environment variables for API key of ALCHEMY # Required: false ALCHEMY_API_KEY= diff --git a/packages/starknet-snap/src/chain/data-client/starkscan.test.ts b/packages/starknet-snap/src/chain/data-client/starkscan.test.ts index 33fdfb0b..24dd6302 100644 --- a/packages/starknet-snap/src/chain/data-client/starkscan.test.ts +++ b/packages/starknet-snap/src/chain/data-client/starkscan.test.ts @@ -470,6 +470,7 @@ describe('StarkScanClient', () => { expect( client.getSenderAddress({ ...mockTx, + // eslint-disable-next-line @typescript-eslint/naming-convention sender_address: null, }), ).toBe(''); diff --git a/packages/starknet-snap/src/config.ts b/packages/starknet-snap/src/config.ts index f92c0935..abe5caa9 100644 --- a/packages/starknet-snap/src/config.ts +++ b/packages/starknet-snap/src/config.ts @@ -25,8 +25,17 @@ export type SnapConfig = { explorer: { [key: string]: string; }; + dataClient: { + [key: string]: { + apiKey: string | undefined; + }; + }; }; +export enum DataClient { + STARKSCAN = 'starkscan', +} + export const Config: SnapConfig = { // eslint-disable-next-line no-restricted-globals logLevel: process.env.LOG_LEVEL ?? LogLevel.OFF.valueOf().toString(), @@ -49,6 +58,13 @@ export const Config: SnapConfig = { 'https://sepolia.voyager.online/contract/${address}', }, + dataClient: { + [DataClient.STARKSCAN]: { + // eslint-disable-next-line no-restricted-globals + apiKey: process.env.STARKSCAN_API_KEY, + }, + }, + preloadTokens: [ ETHER_MAINNET, ETHER_SEPOLIA_TESTNET, diff --git a/packages/starknet-snap/src/utils/factory.test.ts b/packages/starknet-snap/src/utils/factory.test.ts new file mode 100644 index 00000000..12466fac --- /dev/null +++ b/packages/starknet-snap/src/utils/factory.test.ts @@ -0,0 +1,22 @@ +import { StarkScanClient } from '../chain/data-client/starkscan'; +import { Config, DataClient } from '../config'; +import { STARKNET_SEPOLIA_TESTNET_NETWORK } from './constants'; +import { createStarkScanClient } from './factory'; + +describe('createStarkScanClient', () => { + const config = Config.dataClient[DataClient.STARKSCAN]; + + it('creates a StarkScan client', () => { + config.apiKey = 'API_KEY'; + expect( + createStarkScanClient(STARKNET_SEPOLIA_TESTNET_NETWORK), + ).toBeInstanceOf(StarkScanClient); + config.apiKey = undefined; + }); + + it('throws `Missing StarkScan API key` error if the StarkScan API key is missing', () => { + expect(() => + createStarkScanClient(STARKNET_SEPOLIA_TESTNET_NETWORK), + ).toThrow('Missing StarkScan API key'); + }); +}); diff --git a/packages/starknet-snap/src/utils/factory.ts b/packages/starknet-snap/src/utils/factory.ts new file mode 100644 index 00000000..41811241 --- /dev/null +++ b/packages/starknet-snap/src/utils/factory.ts @@ -0,0 +1,25 @@ +import type { IDataClient } from '../chain/data-client'; +import { StarkScanClient } from '../chain/data-client/starkscan'; +import { Config, DataClient } from '../config'; +import type { Network } from '../types/snapState'; + +/** + * Create a StarkScan client. + * + * @param network - The network to create the data client for. + * @returns The StarkScan client. + * @throws Error if the StarkScan API key is missing. + */ +export function createStarkScanClient(network: Network): IDataClient { + const { apiKey } = Config.dataClient[DataClient.STARKSCAN]; + + if (!apiKey) { + throw new Error('Missing StarkScan API key'); + } + + const dataClient = new StarkScanClient(network, { + apiKey, + }); + + return dataClient; +} From b21a5d28a8529493f93a8f8e43ade7190a34e5ca Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 2 Dec 2024 17:00:21 +0800 Subject: [PATCH 47/50] chore: add backward compatibility for transactions type --- packages/starknet-snap/src/types/snapState.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/starknet-snap/src/types/snapState.ts b/packages/starknet-snap/src/types/snapState.ts index 8f163df8..b66841fd 100644 --- a/packages/starknet-snap/src/types/snapState.ts +++ b/packages/starknet-snap/src/types/snapState.ts @@ -160,6 +160,7 @@ export type V2Transaction = { version: 'V2'; }; -export type Transaction = LegacyTransaction | V2Transaction; +// for backward compatibility before StarkScan implmented in get transactions +export type Transaction = LegacyTransaction | (V2Transaction & { status?: TransactionStatus | string }); /* eslint-disable */ From 07f0232142f2502d1fc29bc1583c3ece3210723b Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 2 Dec 2024 17:01:17 +0800 Subject: [PATCH 48/50] chore: add comment --- packages/starknet-snap/src/types/snapState.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/starknet-snap/src/types/snapState.ts b/packages/starknet-snap/src/types/snapState.ts index b66841fd..d400e5a0 100644 --- a/packages/starknet-snap/src/types/snapState.ts +++ b/packages/starknet-snap/src/types/snapState.ts @@ -160,7 +160,7 @@ export type V2Transaction = { version: 'V2'; }; -// for backward compatibility before StarkScan implmented in get transactions +// FIXME: temp solution for backward compatibility before StarkScan implemented in get transactions export type Transaction = LegacyTransaction | (V2Transaction & { status?: TransactionStatus | string }); /* eslint-disable */ From 7a26c70d98683de150c27f775ff053fc1b4279d9 Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 2 Dec 2024 17:02:03 +0800 Subject: [PATCH 49/50] chore: lint --- packages/starknet-snap/src/types/snapState.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/starknet-snap/src/types/snapState.ts b/packages/starknet-snap/src/types/snapState.ts index d400e5a0..ea6cbf1b 100644 --- a/packages/starknet-snap/src/types/snapState.ts +++ b/packages/starknet-snap/src/types/snapState.ts @@ -161,6 +161,8 @@ export type V2Transaction = { }; // FIXME: temp solution for backward compatibility before StarkScan implemented in get transactions -export type Transaction = LegacyTransaction | (V2Transaction & { status?: TransactionStatus | string }); +export type Transaction = + | LegacyTransaction + | (V2Transaction & { status?: TransactionStatus | string }); /* eslint-disable */ From 804a2bd567f51ecc5d9536e5bd1755836b83364b Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 4 Dec 2024 11:19:14 +0800 Subject: [PATCH 50/50] chore: resolve review comment --- .../starknet-snap/src/__tests__/helper.ts | 2 +- .../src/chain/data-client/starkscan.test.ts | 24 ++++++++++--------- .../src/chain/data-client/starkscan.ts | 6 +++-- packages/starknet-snap/src/types/snapState.ts | 4 +++- 4 files changed, 21 insertions(+), 15 deletions(-) diff --git a/packages/starknet-snap/src/__tests__/helper.ts b/packages/starknet-snap/src/__tests__/helper.ts index 8ef6580c..31bbf1f9 100644 --- a/packages/starknet-snap/src/__tests__/helper.ts +++ b/packages/starknet-snap/src/__tests__/helper.ts @@ -380,7 +380,7 @@ export function generateTransactionRequests({ * @param params.cnt - Number of transaction to generate. * @returns An array of transaction object. */ -export function generateStarkScanTranscations({ +export function generateStarkScanTransactions({ address, startFrom = Date.now(), timestampReduction = 100, diff --git a/packages/starknet-snap/src/chain/data-client/starkscan.test.ts b/packages/starknet-snap/src/chain/data-client/starkscan.test.ts index 24dd6302..61594dc4 100644 --- a/packages/starknet-snap/src/chain/data-client/starkscan.test.ts +++ b/packages/starknet-snap/src/chain/data-client/starkscan.test.ts @@ -2,7 +2,7 @@ import { TransactionType, constants } from 'starknet'; import { generateAccounts, - generateStarkScanTranscations, + generateStarkScanTransactions, } from '../../__tests__/helper'; import type { Network, Transaction } from '../../types/snapState'; import { @@ -95,7 +95,7 @@ describe('StarkScanClient', () => { }; const mockTxByType = (txnType: TransactionType, address: string) => { - const mockResponse = generateStarkScanTranscations({ + const mockResponse = generateStarkScanTransactions({ address, txnTypes: [txnType], cnt: 1, @@ -214,7 +214,7 @@ describe('StarkScanClient', () => { const { fetchSpy } = createMockFetch(); const { from, to } = getFromAndToTimestamp(5); // generate 10 invoke transactions - const mockResponse = generateStarkScanTranscations({ + const mockResponse = generateStarkScanTransactions({ address: account.address, startFrom: from, }); @@ -258,12 +258,12 @@ describe('StarkScanClient', () => { const { fetchSpy } = createMockFetch(); // generate the to timestamp which is 100 days ago const { to } = getFromAndToTimestamp(100); - const mockPage1Response = generateStarkScanTranscations({ + const mockPage1Response = generateStarkScanTransactions({ address: account.address, txnTypes: [TransactionType.INVOKE], cnt: 10, }); - const mockPage2Response = generateStarkScanTranscations({ + const mockPage2Response = generateStarkScanTransactions({ address: account.address, cnt: 10, }); @@ -304,14 +304,14 @@ describe('StarkScanClient', () => { // generate the to timestamp which is 5 days ago const { from, to } = getFromAndToTimestamp(5); // generate 10 invoke transactions, and 1 day time gap between each transaction - const mockInvokeResponse = generateStarkScanTranscations({ + const mockInvokeResponse = generateStarkScanTransactions({ address: account.address, startFrom: from, timestampReduction: mSecsFor24Hours, txnTypes: [TransactionType.INVOKE], }); // generate another 5 invoke transactions + deploy transactions for testing the fallback case - const mockDeployResponse = generateStarkScanTranscations({ + const mockDeployResponse = generateStarkScanTransactions({ address: account.address, // generate transactions that start from 100 days ago, to ensure not overlap with above invoke transactions startFrom: mSecsFor24Hours * 100, @@ -370,7 +370,8 @@ describe('StarkScanClient', () => { }, ], }, - version: 'V2', + version: mockTx.version, + dataVersion: 'V2', }); }); @@ -397,7 +398,8 @@ describe('StarkScanClient', () => { maxFee: mockTx.max_fee, actualFee: mockTx.actual_fee, accountCalls: null, - version: 'V2', + version: mockTx.version, + dataVersion: 'V2', }); }); }); @@ -407,7 +409,7 @@ describe('StarkScanClient', () => { const account = await mockAccount(); const { fetchSpy } = createMockFetch(); // generate 5 invoke transactions with deploy transaction - const mockResponse = generateStarkScanTranscations({ + const mockResponse = generateStarkScanTransactions({ address: account.address, cnt: 5, }); @@ -424,7 +426,7 @@ describe('StarkScanClient', () => { const account = await mockAccount(); const { fetchSpy } = createMockFetch(); // generate 5 invoke transactions with deploy transaction - const mockResponse = generateStarkScanTranscations({ + const mockResponse = generateStarkScanTransactions({ address: account.address, cnt: 1, txnTypes: [TransactionType.INVOKE], diff --git a/packages/starknet-snap/src/chain/data-client/starkscan.ts b/packages/starknet-snap/src/chain/data-client/starkscan.ts index 98eab071..d1dac1e6 100644 --- a/packages/starknet-snap/src/chain/data-client/starkscan.ts +++ b/packages/starknet-snap/src/chain/data-client/starkscan.ts @@ -81,7 +81,7 @@ export class StarkScanClient extends ApiClient implements IDataClient { * The transactions are fetched in descending order and it will include the deploy transaction. * * @param address - The address of the contract to fetch the transactions for. - * @param to - The timestamp to fetch the transactions until. + * @param to - The filter includes transactions with a timestamp that is >= a specified value, but the deploy transaction is always included regardless of its timestamp. * @returns A Promise that resolve an array of Transaction object. */ async getTransactions(address: string, to: number): Promise { @@ -212,6 +212,7 @@ export class StarkScanClient extends ApiClient implements IDataClient { actual_fee: actualFee, revert_error: failureReason, account_calls: calls, + version, } = tx; // account_calls representing the calls to invoke from the account contract, it can be multiple @@ -231,7 +232,8 @@ export class StarkScanClient extends ApiClient implements IDataClient { contractAddress: this.getContractAddress(tx), accountCalls, failureReason: failureReason ?? '', - version: 'V2', + version, + dataVersion: 'V2', }; /* eslint-enable */ diff --git a/packages/starknet-snap/src/types/snapState.ts b/packages/starknet-snap/src/types/snapState.ts index ea6cbf1b..5be52899 100644 --- a/packages/starknet-snap/src/types/snapState.ts +++ b/packages/starknet-snap/src/types/snapState.ts @@ -157,7 +157,9 @@ export type V2Transaction = { actualFee?: string | null; // using Record to support O(1) searching accountCalls?: Record | null; - version: 'V2'; + version: number; + // Snap data Version to support backward compatibility , migration. + dataVersion: 'V2'; }; // FIXME: temp solution for backward compatibility before StarkScan implemented in get transactions