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();