diff --git a/packages/starknet-snap/jest.setup.ts b/packages/starknet-snap/jest.setup.ts index cb6c4a57..c1c7b0fd 100644 --- a/packages/starknet-snap/jest.setup.ts +++ b/packages/starknet-snap/jest.setup.ts @@ -1,4 +1,4 @@ -import { MockSnapProvider } from './src/__mocks__/snap-provider.mock'; +import { MockSnapProvider } from './test/snap-provider.mock'; // eslint-disable-next-line no-restricted-globals const globalAny: any = global; diff --git a/packages/starknet-snap/package.json b/packages/starknet-snap/package.json index 9dd71d74..1abb080f 100644 --- a/packages/starknet-snap/package.json +++ b/packages/starknet-snap/package.json @@ -60,6 +60,7 @@ "@types/sinon-chai": "^3.2.8", "@typescript-eslint/eslint-plugin": "^5.42.1", "@typescript-eslint/parser": "^5.42.1", + "bip39": "^3.1.0", "chai": "^4.3.6", "chai-as-promised": "^7.1.1", "cross-env": "^7.0.3", diff --git a/packages/starknet-snap/src/__mocks__/snap-provider.mock.ts b/packages/starknet-snap/src/__mocks__/snap-provider.mock.ts deleted file mode 100644 index ccaa5eef..00000000 --- a/packages/starknet-snap/src/__mocks__/snap-provider.mock.ts +++ /dev/null @@ -1,42 +0,0 @@ -export type SnapProvider = { - registerRpcMessageHandler: (fn) => unknown; - request(options: { - method: string; - params?: { [key: string]: unknown } | unknown[]; - }): unknown; -}; - -export class MockSnapProvider implements SnapProvider { - public readonly registerRpcMessageHandler = jest.fn(); - - public readonly requestStub = jest.fn(); - /* eslint-disable */ - public readonly rpcStubs = { - snap_getBip32Entropy: jest.fn(), - snap_getBip44Entropy: jest.fn(), - snap_dialog: jest.fn(), - snap_manageState: jest.fn(), - }; - /* eslint-disable */ - - /** - * Calls this.requestStub or this.rpcStubs[req.method], if the method has - * a dedicated stub. - * @param args - * @param args.method - * @param args.params - */ - public request(args: { - method: string; - params: { [key: string]: unknown } | unknown[]; - }): unknown { - const { method, params } = args; - if (Object.hasOwnProperty.call(this.rpcStubs, method)) { - if (Array.isArray(params)) { - return this.rpcStubs[method](...params); - } - return this.rpcStubs[method](params); - } - return this.requestStub(args); - } -} diff --git a/packages/starknet-snap/src/index.test.ts b/packages/starknet-snap/src/index.test.ts index ef3d423f..ac900027 100644 --- a/packages/starknet-snap/src/index.test.ts +++ b/packages/starknet-snap/src/index.test.ts @@ -1,11 +1,23 @@ -import { onRpcRequest } from '.'; +import { constants } from 'starknet'; + +import { onRpcRequest, onHomePage } from '.'; +import { manageStateSpy } from '../test/snap-provider.mock'; +import { generateAccounts, type StarknetAccount } from '../test/utils'; 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 * as keyPairUtils from './utils/keyPair'; import { LogLevel, logger } from './utils/logger'; +import * as starknetUtils from './utils/starknetUtils'; jest.mock('./utils/logger'); -describe('onRpcRequest', function () { +describe('onRpcRequest', () => { const createMockSpy = () => { const createAccountSpy = jest.spyOn(createAccountApi, 'createAccount'); const keyPairSpy = jest.spyOn(keyPairUtils, 'getAddressKeyDeriver'); @@ -29,7 +41,7 @@ describe('onRpcRequest', function () { }; }; - it('processes request successfully', async function () { + it('processes request successfully', async () => { const { createAccountSpy, keyPairSpy, getLogLevelSpy } = createMockSpy(); createAccountSpy.mockReturnThis(); @@ -42,7 +54,7 @@ describe('onRpcRequest', function () { expect(createAccountSpy).toHaveBeenCalledTimes(1); }); - it('throws `Unable to execute the rpc request` error if an error has thrown and LogLevel is 0', async function () { + it('throws `Unable to execute the rpc request` error if an error has thrown and LogLevel is 0', async () => { const { createAccountSpy, keyPairSpy, getLogLevelSpy } = createMockSpy(); createAccountSpy.mockRejectedValue(new Error('Custom Error')); @@ -80,3 +92,155 @@ describe('onRpcRequest', function () { }, ); }); + +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_SEPOLIA_TESTNET_NETWORK.name, + }, + }, + { + type: 'row', + label: 'Balance', + value: { + type: 'text', + value: '0.000000000000001 ETH', + }, + }, + { type: 'divider' }, + { + type: 'text', + value: + 'Visit the [companion dapp for Starknet](https://snaps.consensys.io/starknet) to manage your account.', + }, + ], + }, + }); + }); + + it('renders 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'); + + 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: 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('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/test/snap-provider.mock.ts b/packages/starknet-snap/test/snap-provider.mock.ts new file mode 100644 index 00000000..69e5233e --- /dev/null +++ b/packages/starknet-snap/test/snap-provider.mock.ts @@ -0,0 +1,56 @@ +import { BIP44CoinTypeNode } from '@metamask/key-tree'; +import { generateMnemonic } from 'bip39'; +import { generateBip44Entropy } from './utils'; + +export type SnapProvider = { + registerRpcMessageHandler: (fn) => unknown; + request(options: { + method: string; + params?: { [key: string]: unknown } | unknown[]; + }): unknown; +}; + +export const manageStateSpy = jest.fn(); + +export const dialogSpy = jest.fn(); + +export const requestSpy = jest.fn(); + +export class MockSnapProvider implements SnapProvider { + public readonly registerRpcMessageHandler = jest.fn(); + + /* eslint-disable */ + + async getBip44Entropy() { + return await generateBip44Entropy(generateMnemonic()); + } + + public readonly rpcSpys = { + snap_getBip32Entropy: jest.fn(), + snap_getBip44Entropy: this.getBip44Entropy, + snap_dialog: dialogSpy, + snap_manageState: manageStateSpy, + }; + /* eslint-disable */ + + /** + * Calls requestSpy or this.rpcSpys[req.method], if the method has + * a dedicated spy. + * @param args + * @param args.method + * @param args.params + */ + public request(args: { + method: string; + params: { [key: string]: unknown } | unknown[]; + }): unknown { + const { method, params } = args; + if (Object.hasOwnProperty.call(this.rpcSpys, method)) { + if (Array.isArray(params)) { + return this.rpcSpys[method](...params); + } + return this.rpcSpys[method](params); + } + return requestSpy(args); + } +} diff --git a/packages/starknet-snap/test/utils.ts b/packages/starknet-snap/test/utils.ts new file mode 100644 index 00000000..d3efed5b --- /dev/null +++ b/packages/starknet-snap/test/utils.ts @@ -0,0 +1,123 @@ +import { generateMnemonic } from 'bip39'; +import { + ec, + constants, + CallData, + hash, + type Calldata, + num as numUtils, +} from 'starknet'; +import { + BIP44CoinTypeNode, + getBIP44AddressKeyDeriver, +} from '@metamask/key-tree'; +import { AccContract } from '../src/types/snapState'; +import { + ACCOUNT_CLASS_HASH, + ACCOUNT_CLASS_HASH_LEGACY, + PROXY_CONTRACT_HASH, +} from '../src/utils/constants'; +import { grindKey } from '../src/utils/keyPair'; + +/* eslint-disable */ +export type StarknetAccount = AccContract & { + privateKey: string; +}; + +/* eslint-disable */ + +/** + * Method to generate Bip44 Entropy. + * + * @param mnemonic - The random mnemonic of the wallet. + * @param coinType - The coin type of the bip44, default is 9004 - Starknet Coin. + * @returns An Bip44 Node. + */ +export async function generateBip44Entropy( + mnemonic: string, + coinType: number = 9004, +) { + return await BIP44CoinTypeNode.fromDerivationPath([ + `bip39:${mnemonic}`, + "bip32:44'", + `bip32:${coinType}'`, + ]); +} + +/** + * Method to generate starknet account. + * + * @param network - Starknet Chain Id. + * @param cnt - Number of accounts to generate. + * @param cairoVersion - Cairo version of the generated accounts. + * @returns An array of StarknetAccount object. + */ +export async function generateAccounts( + network: constants.StarknetChainId, + cnt: number = 1, + cairoVersion = '1', + mnemonic?: string, +) { + const accounts: StarknetAccount[] = []; + let mnemonicString = mnemonic; + if (!mnemonicString) { + mnemonicString = generateMnemonic(); + } + + for (let i = 0; i < cnt; i++) { + // simulate the bip44 entropy generation + const node = await generateBip44Entropy(mnemonicString); + const keyDeriver = await getBIP44AddressKeyDeriver(node); + const { privateKey } = await keyDeriver(i); + + if (!privateKey) { + throw new Error('Private key is not defined'); + } + + // simulate the same flow in code base + const addressKey = grindKey(privateKey); + const pubKey = ec.starkCurve.getStarkKey(addressKey); + + let address = ''; + let callData: Calldata; + let accountClassHash: string; + + if (cairoVersion === '1') { + callData = CallData.compile({ + signer: pubKey, + guardian: '0', + }); + accountClassHash = ACCOUNT_CLASS_HASH; + } else { + callData = CallData.compile({ + implementation: ACCOUNT_CLASS_HASH_LEGACY, + selector: hash.getSelectorFromName('initialize'), + calldata: CallData.compile({ signer: pubKey, guardian: '0' }), + }); + accountClassHash = PROXY_CONTRACT_HASH; + } + + address = hash.calculateContractAddressFromHash( + pubKey, + accountClassHash, + callData, + 0, + ); + + if (address.length < 66) { + address = address.replace('0x', `0x${'0'.repeat(66 - address.length)}`); + } + + accounts.push({ + addressSalt: pubKey, + privateKey: numUtils.toHex(addressKey), + publicKey: pubKey, + address: address, + addressIndex: i, + derivationPath: keyDeriver.path, + deployTxnHash: '', + chainId: network, + }); + } + return accounts; +} diff --git a/yarn.lock b/yarn.lock index d562515f..398c76f8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2216,7 +2216,7 @@ __metadata: "@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=9f1103&locator=wallet-ui%40workspace%3Apackages%2Fwallet-ui" + resolution: "@consensys/starknet-snap@file:../starknet-snap#../starknet-snap::hash=bdb700&locator=wallet-ui%40workspace%3Apackages%2Fwallet-ui" dependencies: "@metamask/key-tree": 9.0.0 "@metamask/snaps-sdk": ^4.0.0 @@ -2225,7 +2225,7 @@ __metadata: ethers: ^5.5.1 starknet: 6.11.0 starknet_v4.22.0: "npm:starknet@4.22.0" - checksum: f517ec66c4ac9d0073711e4ca6a92bfd6cf2fc0ab288d2e438dd748ca0f1729292d205810f9d3677a67d9668de6e2cec4eb9c26193e8f01fdf085bc725674a39 + checksum: f3c17dee4fca9fc6e7826279be325900be78e13207ee0fb6230d5d15091e6f9189f01972f17fff7f4ec15a6d86fb08c23fec5589c7ce1992fb0fefdfb2c085ce languageName: node linkType: hard @@ -2249,6 +2249,7 @@ __metadata: "@typescript-eslint/eslint-plugin": ^5.42.1 "@typescript-eslint/parser": ^5.42.1 async-mutex: ^0.3.2 + bip39: ^3.1.0 chai: ^4.3.6 chai-as-promised: ^7.1.1 cross-env: ^7.0.3 @@ -5013,7 +5014,7 @@ __metadata: languageName: node linkType: hard -"@noble/hashes@npm:1.4.0, @noble/hashes@npm:^1.0.0, @noble/hashes@npm:^1.1.2, @noble/hashes@npm:^1.3.1, @noble/hashes@npm:^1.3.2, @noble/hashes@npm:^1.4.0, @noble/hashes@npm:~1.4.0": +"@noble/hashes@npm:1.4.0, @noble/hashes@npm:^1.0.0, @noble/hashes@npm:^1.1.2, @noble/hashes@npm:^1.2.0, @noble/hashes@npm:^1.3.1, @noble/hashes@npm:^1.3.2, @noble/hashes@npm:^1.4.0, @noble/hashes@npm:~1.4.0": version: 1.4.0 resolution: "@noble/hashes@npm:1.4.0" checksum: 8ba816ae26c90764b8c42493eea383716396096c5f7ba6bea559993194f49d80a73c081f315f4c367e51bd2d5891700bcdfa816b421d24ab45b41cb03e4f3342 @@ -10080,6 +10081,15 @@ __metadata: languageName: node linkType: hard +"bip39@npm:^3.1.0": + version: 3.1.0 + resolution: "bip39@npm:3.1.0" + dependencies: + "@noble/hashes": ^1.2.0 + checksum: 1224e763ffc6b097052ed8abd57f0e521ad6d31f1645be0d0a15f4417c13f8461f00e47e9cf7c8c784bd533f4fb1ee3ab020f258c7df45ee5dc722b4b0336cfc + languageName: node + linkType: hard + "bl@npm:^4.1.0": version: 4.1.0 resolution: "bl@npm:4.1.0"