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] 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: