From abfc0e52cc5c9c4fc7ec7e04a9ff667acbf99813 Mon Sep 17 00:00:00 2001 From: khanti42 Date: Thu, 21 Nov 2024 12:11:57 +0100 Subject: [PATCH] feat: add jsx support and interactive fee token selection in StarkNet Snap (#429) * chore: add jsx support in snap (#415) * chore: add jsx support in snap * chore: fix comment * chore: update yarn.lock * feat: add common jsx component (#417) * chore: add jsx support in snap * chore: add jsx support in snap * feat: jsx support management * feat: common jsx components and fragments * chore: fix test and lint * chore: rollback jsx support detection not here * chore: fix comment * chore: fix comments * chore: fix comments * chore: update yarn.lock * chore: update yarn.lock * chore: add react-dom types * chore: fix comments * chore: rebase wallet-ui changes happening elsewhere * feat: add fee token selection interactive UI (#418) * chore: add jsx support in snap * chore: add jsx support in snap * feat: jsx support management * feat: common jsx components and fragments * chore: fix test and lint * feat: add interactive-ui for execute txn * fix: add mutex in jsx support detection mechanism * chore: fixture request addapted * chore: ensure test pass * chore: lint * chore: remove console.log * chore: rollback jsx support detection not here * chore: rollback index.tsx * chore: fix comment * chore: fix comments * chore: fix comments * chore: update yarn.lock * chore: update yarn.lock * chore: update yarn.lock * chore: rebase wallet-ui changes happening elsewhere * chore: fix comments * chore: fix comments * chore: fix comments * chore: fix comments * chore: fix comments * chore: fix comments * chore: removed utils * fix: implement comments * fix: implement comments * chore: fix comments * Update packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/utils.test.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/types/snapState.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/utils.test.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/utils.test.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * chore: fix comments * chore: fix comments * chore: fix comments * chore: lint --------- Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * feat: adopt fee token selection dialog in RPC `starkNet_executeTxn` (#419) * chore: add jsx support in snap * chore: add jsx support in snap * feat: jsx support management * feat: common jsx components and fragments * chore: fix test and lint * feat: add interactive-ui for execute txn * fix: add mutex in jsx support detection mechanism * chore: fixture request addapted * chore: ensure test pass * chore: lint * feat: add interactive-ui in execute txn * chore: remove console.log * chore: missing helper in tests * chore: lint * chore: rollback jsx support detection not here * chore: rollback index.tsx * chore: fix comment * chore: fix comments * chore: fix comments * chore: update yarn.lock * chore: update yarn.lock * feat: update wallet-ui message * chore: update yarn.lock * chore: rebase wallet-ui changes happening elsewhere * chore: rebase wallet-ui changes happening elsewhere * chore: fix comments * chore: fix comments * fix: formatter-utils * chore: fix comments * chore: fix comments * chore: lint * chore: lint * chore: fix comments * chore: fix comments * chore: fix comments * chore: fix comments * chore: removed utils * fix: implement comments * fix: implement comments * chore: fix comments * chore: fix comments * Update packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/utils.test.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/types/snapState.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/utils.test.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/utils.test.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * chore: fix comments * chore: fix comments * chore: fix comments * chore: fix comments * chore: fix comments * chore: lint * chore: lint * chore: fix comments * chore: fix comments --------- Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * chore: fix tests * feat: add jsx support detection (#416) * chore: add jsx support in snap * chore: add jsx support in snap * feat: jsx support management * chore: fix test and lint * fix: add mutex in jsx support detection mechanism * chore: ensure test pass * feat: new init state manager class to manage state init and support check * fix: wait for hooks in request handler * chore: lint * fix: set jsx support to true before showing dialog * chore: fix comment * fix: moved ping pong * chore: lint * chore: rollback state * chore: lint * chore: fix comments * fix: test suits * feat: add event listener for fee token selection (#420) * chore: add jsx support in snap * chore: add jsx support in snap * feat: jsx support management * feat: common jsx components and fragments * chore: fix test and lint * feat: add interactive-ui for execute txn * fix: add mutex in jsx support detection mechanism * chore: fixture request addapted * chore: ensure test pass * chore: lint * feat: add interactive-ui in execute txn * chore: remove console.log * chore: missing helper in tests * chore: lint * feat: event-handler in index.tsx * feat: event controller * feat: error handling and tests suits for event controller * fix: test suits * fix: signer in fee-token-selector * chore: rollback jsx support detection not here * chore: rollback index.tsx * chore: lint * chore: fix comment * chore: fix comments * chore: fix comments * chore: update yarn.lock * chore: update yarn.lock * feat: update wallet-ui message * chore: update yarn.lock * chore: rebase wallet-ui changes happening elsewhere * chore: rebase wallet-ui changes happening elsewhere * chore: fix comments * chore: fix comments * fix: formatter-utils * chore: fix comments * chore: fix comments * chore: lint * chore: lint * chore: fix comments * chore: fix comments * chore: fix comments * chore: fix comments * chore: lint * chore: lint * chore: removed utils * feat: add comments in user-input classes * fix: implement comments * fix: implement comments * chore: fix comments * chore: fix comments * Update packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/utils.test.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/types/snapState.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/utils.test.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/ui/utils.test.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * chore: fix comments * chore: fix comments * chore: fix comments * chore: fix comments * chore: fix comments * chore: lint * chore: lint * chore: fix rebase * chore: fix comments * chore: fix comments * chore: fix comments * chore: rebase * fix: removed user-input controller abstract class and derived ones * chore: rollback execute-txn * chore: update * chore: fix comments * chore: fix comments * chore: refine the code * fix: add execution test * fix: update execute txn test --------- Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/state/state-manager.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/index.test.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/types/snapState.ts Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/index.test.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/index.test.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * Update packages/starknet-snap/src/index.test.tsx Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * chore: lint --------- Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> --- packages/starknet-snap/.eslintrc.js | 9 +- packages/starknet-snap/package.json | 8 +- packages/starknet-snap/snap.config.ts | 2 +- .../starknet-snap/src/__tests__/helper.ts | 17 +- .../src/{index.test.ts => index.test.tsx} | 0 .../starknet-snap/src/{index.ts => index.tsx} | 101 +++--- packages/starknet-snap/src/on-home-page.ts | 4 +- .../src/rpcs/__tests__/helper.ts | 36 ++ .../src/rpcs/execute-txn.test.ts | 64 +++- .../starknet-snap/src/rpcs/execute-txn.ts | 323 +++++++----------- .../src/state/request-state-manager.ts | 3 +- packages/starknet-snap/src/types/snapState.ts | 37 +- .../src/ui/components/ExecuteTxnUI.tsx | 140 ++++++++ .../starknet-snap/src/ui/components/index.tsx | 1 + .../user-input-event-controller.ts | 203 +++++++++++ .../src/ui/fragments/AddressUI.tsx | 39 +++ .../starknet-snap/src/ui/fragments/Amount.tsx | 29 ++ .../src/ui/fragments/FeeTokenSelector.tsx | 52 +++ .../src/ui/fragments/JsonDataUI.tsx | 24 ++ .../src/ui/fragments/LoadingUI.tsx | 20 ++ .../src/ui/fragments/NetworkUI.tsx | 21 ++ .../src/ui/fragments/SignerUI.tsx | 28 ++ .../starknet-snap/src/ui/fragments/index.tsx | 4 + packages/starknet-snap/src/ui/types.ts | 7 + packages/starknet-snap/src/ui/utils.test.tsx | 241 +++++++++++++ packages/starknet-snap/src/ui/utils.tsx | 210 ++++++++++++ .../starknet-snap/src/utils/__mocks__/snap.ts | 2 + .../starknet-snap/src/utils/exceptions.ts | 6 + .../src/utils/formatter-utils.test.ts | 144 +++++++- .../src/utils/formatter-utils.ts | 56 +++ packages/starknet-snap/src/utils/snap-ui.ts | 43 ++- packages/starknet-snap/src/utils/snap.test.ts | 17 + packages/starknet-snap/src/utils/snap.ts | 18 + packages/starknet-snap/tsconfig.json | 4 +- yarn.lock | 170 ++++++++- 35 files changed, 1810 insertions(+), 273 deletions(-) rename packages/starknet-snap/src/{index.test.ts => index.test.tsx} (100%) rename packages/starknet-snap/src/{index.ts => index.tsx} (84%) create mode 100644 packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx create mode 100644 packages/starknet-snap/src/ui/components/index.tsx create mode 100644 packages/starknet-snap/src/ui/controllers/user-input-event-controller.ts create mode 100644 packages/starknet-snap/src/ui/fragments/AddressUI.tsx create mode 100644 packages/starknet-snap/src/ui/fragments/Amount.tsx create mode 100644 packages/starknet-snap/src/ui/fragments/FeeTokenSelector.tsx create mode 100644 packages/starknet-snap/src/ui/fragments/JsonDataUI.tsx create mode 100644 packages/starknet-snap/src/ui/fragments/LoadingUI.tsx create mode 100644 packages/starknet-snap/src/ui/fragments/NetworkUI.tsx create mode 100644 packages/starknet-snap/src/ui/fragments/SignerUI.tsx create mode 100644 packages/starknet-snap/src/ui/fragments/index.tsx create mode 100644 packages/starknet-snap/src/ui/types.ts create mode 100644 packages/starknet-snap/src/ui/utils.test.tsx create mode 100644 packages/starknet-snap/src/ui/utils.tsx diff --git a/packages/starknet-snap/.eslintrc.js b/packages/starknet-snap/.eslintrc.js index 064abf0d..6388e179 100644 --- a/packages/starknet-snap/.eslintrc.js +++ b/packages/starknet-snap/.eslintrc.js @@ -19,10 +19,17 @@ module.exports = { 'jsdoc/require-returns': 'off', 'jsdoc/require-param-description': 'off', 'jsdoc/match-description': 'off', + // This allows importing the `Text` JSX component. + '@typescript-eslint/no-shadow': [ + 'error', + { + allow: ['Text'], + }, + ], }, }, { - files: ['*.test.ts'], + files: ['*.test.ts', '*.test.tsx'], extends: ['@metamask/eslint-config-jest'], rules: { '@typescript-eslint/no-shadow': [ diff --git a/packages/starknet-snap/package.json b/packages/starknet-snap/package.json index c134d97a..807678fd 100644 --- a/packages/starknet-snap/package.json +++ b/packages/starknet-snap/package.json @@ -22,9 +22,9 @@ "cover:report": "nyc report --reporter=lcov --reporter=text", "jest": "jest --passWithNoTests", "lint": "yarn lint:eslint && yarn lint:misc --check", - "lint:eslint": "eslint . --cache --ext js,ts", + "lint:eslint": "eslint . --cache --ext js,ts,tsx", "lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write", - "lint:misc": "prettier '**/*.ts' '**/*.json' '**/*.md' '!CHANGELOG.md' --ignore-path .gitignore", + "lint:misc": "prettier '**/*.ts' '**/*.tsx' '**/*.json' '**/*.md' '!CHANGELOG.md' --ignore-path .gitignore", "serve": "mm-snap serve", "start": "mm-snap watch", "test": "yarn run test:unit && yarn run cover:report && yarn run jest", @@ -39,7 +39,7 @@ }, "dependencies": { "@metamask/key-tree": "9.0.0", - "@metamask/snaps-sdk": "^4.0.0", + "@metamask/snaps-sdk": "^6.1.1", "@metamask/utils": "^9.1.0", "async-mutex": "^0.3.2", "ethereum-unit-converter": "^0.0.17", @@ -59,6 +59,8 @@ "@metamask/snaps-jest": "^8.2.0", "@types/chai": "^4.3.1", "@types/chai-as-promised": "^7.1.5", + "@types/react": "18.2.4", + "@types/react-dom": "18.2.4", "@types/sinon": "^10.0.11", "@types/sinon-chai": "^3.2.8", "@typescript-eslint/eslint-plugin": "^5.42.1", diff --git a/packages/starknet-snap/snap.config.ts b/packages/starknet-snap/snap.config.ts index 056840ca..7d8bb771 100644 --- a/packages/starknet-snap/snap.config.ts +++ b/packages/starknet-snap/snap.config.ts @@ -6,7 +6,7 @@ require('dotenv').config(); const config: SnapConfig = { bundler: 'webpack', - input: resolve(__dirname, 'src/index.ts'), + input: resolve(__dirname, 'src/index.tsx'), server: { port: 8081, }, diff --git a/packages/starknet-snap/src/__tests__/helper.ts b/packages/starknet-snap/src/__tests__/helper.ts index d97cb911..6eed3806 100644 --- a/packages/starknet-snap/src/__tests__/helper.ts +++ b/packages/starknet-snap/src/__tests__/helper.ts @@ -322,9 +322,11 @@ export function generateTransactionRequests({ id: uuidv4(), interfaceId: uuidv4(), type: TransactionType.INVOKE, + networkName: 'Sepolia', signer: address, + addressIndex: 0, maxFee: '100', - feeToken: + selectedFeeToken: feeTokens[Math.floor(generateRandomValue() * feeTokens.length)].symbol, calls: [ { @@ -339,6 +341,19 @@ export function generateTransactionRequests({ entrypoint: 'transfer', }, ], + includeDeploy: false, + resourceBounds: [ + { + l1_gas: { + max_amount: '0', + max_price_per_unit: '0', + }, + l2_gas: { + max_amount: '0', + max_price_per_unit: '0', + }, + }, + ], }); } diff --git a/packages/starknet-snap/src/index.test.ts b/packages/starknet-snap/src/index.test.tsx similarity index 100% rename from packages/starknet-snap/src/index.test.ts rename to packages/starknet-snap/src/index.test.tsx diff --git a/packages/starknet-snap/src/index.ts b/packages/starknet-snap/src/index.tsx similarity index 84% rename from packages/starknet-snap/src/index.ts rename to packages/starknet-snap/src/index.tsx index a33e66c9..0d8f4585 100644 --- a/packages/starknet-snap/src/index.ts +++ b/packages/starknet-snap/src/index.tsx @@ -3,8 +3,12 @@ import type { OnHomePageHandler, OnInstallHandler, OnUpdateHandler, + OnUserInputHandler, + UserInputEvent, + InterfaceContext, } from '@metamask/snaps-sdk'; -import { panel, text, MethodNotFoundError } from '@metamask/snaps-sdk'; +import { MethodNotFoundError } from '@metamask/snaps-sdk'; +import { Box, Link, Text } from '@metamask/snaps-sdk/jsx'; import { addNetwork } from './addNetwork'; import { Config } from './config'; @@ -58,8 +62,15 @@ import type { ApiRequestParams, } from './types/snapApi'; import type { SnapState } from './types/snapState'; +import { UserInputEventController } from './ui/controllers/user-input-event-controller'; import { upgradeAccContract } from './upgradeAccContract'; -import { getDappUrl, isSnapRpcError } from './utils'; +import { + ensureJsxSupport, + getDappUrl, + getStateData, + isSnapRpcError, + setStateData, +} from './utils'; import { CAIRO_VERSION_LEGACY, PRELOADED_TOKENS, @@ -93,12 +104,7 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { } // TODO: this will causing racing condition, need to be fixed - let state: SnapState = await snap.request({ - method: 'snap_manageState', - params: { - operation: 'get', - }, - }); + let state: SnapState = await getStateData(); if (!state) { state = { accContracts: [], @@ -107,13 +113,7 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { transactions: [], }; // initialize state if empty and set default data - await snap.request({ - method: 'snap_manageState', - params: { - operation: 'update', - newState: state, - }, - }); + await setStateData(state); } // TODO: this can be remove, after state manager is implemented @@ -307,41 +307,52 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { }; export const onInstall: OnInstallHandler = async () => { - const component = panel([ - text('Your MetaMask wallet is now compatible with Starknet!'), - text( - `To manage your Starknet account and send and receive funds, visit the [companion dapp for Starknet](${getDappUrl()}).`, - ), - ]); - - await snap.request({ - method: 'snap_dialog', - params: { - type: 'alert', - content: component, - }, - }); + await ensureJsxSupport( + + Your MetaMask wallet is now compatible with Starknet! + + To manage your Starknet account and send and receive funds, visit the{' '} + companion dapp for Starknet. + + , + ); }; export const onUpdate: OnUpdateHandler = async () => { - const component = panel([ - text('Features released with this update:'), - text( - 'Support STRK token for the gas fee in sending transaction and estimating fee.', - ), - text('Default network changed to mainnet.'), - text('Support for multiple consecutive transactions.'), - ]); - - await snap.request({ - method: 'snap_dialog', - params: { - type: 'alert', - content: component, - }, - }); + await ensureJsxSupport( + + Your Starknet Snap is now up-to-date ! + + As usual, to manage your Starknet account and send and receive funds, + visit the companion dapp for Starknet. + + , + ); }; export const onHomePage: OnHomePageHandler = async () => { return await homePageController.execute(); }; + +/** + * Handle incoming user events coming from the MetaMask clients open interfaces. + * + * @param params - The event parameters. + * @param params.id - The Snap interface ID where the event was fired. + * @param params.event - The event object containing the event type, name, and + * value. + * @param params.context + * @see https://docs.metamask.io/snaps/reference/exports/#onuserinput + */ +export const onUserInput: OnUserInputHandler = async ({ + id, + event, + context, +}: { + id: string; + event: UserInputEvent; + context: InterfaceContext | null; +}): Promise => { + const controller = new UserInputEventController(id, event, context); + await controller.handleEvent(); +}; diff --git a/packages/starknet-snap/src/on-home-page.ts b/packages/starknet-snap/src/on-home-page.ts index d84fa7ee..55c37c2f 100644 --- a/packages/starknet-snap/src/on-home-page.ts +++ b/packages/starknet-snap/src/on-home-page.ts @@ -50,7 +50,7 @@ export class HomePageController { const balance = await this.getBalance(network, address); - return this.buildComponenets(address, network, balance); + return this.buildComponents(address, network, balance); } catch (error) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions logger.error('Failed to execute onHomePage', toJson(error)); @@ -97,7 +97,7 @@ export class HomePageController { ); } - protected buildComponenets( + protected buildComponents( address: string, network: Network, balance: string, diff --git a/packages/starknet-snap/src/rpcs/__tests__/helper.ts b/packages/starknet-snap/src/rpcs/__tests__/helper.ts index 40b9b6ee..5f3f2665 100644 --- a/packages/starknet-snap/src/rpcs/__tests__/helper.ts +++ b/packages/starknet-snap/src/rpcs/__tests__/helper.ts @@ -3,12 +3,34 @@ import type { constants } from 'starknet'; import type { StarknetAccount } from '../../__tests__/helper'; import { generateAccounts, generateRandomValue } from '../../__tests__/helper'; +import { TransactionRequestStateManager } from '../../state/request-state-manager'; import type { SnapState } from '../../types/snapState'; import { getExplorerUrl, shortenAddress, toJson } from '../../utils'; import * as snapHelper from '../../utils/snap'; import * as snapUtils from '../../utils/snapUtils'; import * as starknetUtils from '../../utils/starknetUtils'; +export const mockTransactionRequestStateManager = () => { + const upsertTransactionRequestSpy = jest.spyOn( + TransactionRequestStateManager.prototype, + 'upsertTransactionRequest', + ); + const getTransactionRequestSpy = jest.spyOn( + TransactionRequestStateManager.prototype, + 'getTransactionRequest', + ); + const removeTransactionRequestSpy = jest.spyOn( + TransactionRequestStateManager.prototype, + 'removeTransactionRequest', + ); + + return { + upsertTransactionRequestSpy, + getTransactionRequestSpy, + removeTransactionRequestSpy, + }; +}; + /** * * @param chainId @@ -58,6 +80,20 @@ export function prepareConfirmDialog() { }; } +/** + * + */ +export function prepareConfirmDialogInteractiveUI() { + const confirmDialogSpy = jest.spyOn( + snapHelper, + 'createInteractiveConfirmDialog', + ); + confirmDialogSpy.mockResolvedValue(true); + return { + confirmDialogSpy, + }; +} + /** * */ diff --git a/packages/starknet-snap/src/rpcs/execute-txn.test.ts b/packages/starknet-snap/src/rpcs/execute-txn.test.ts index f9498055..d49419fc 100644 --- a/packages/starknet-snap/src/rpcs/execute-txn.test.ts +++ b/packages/starknet-snap/src/rpcs/execute-txn.test.ts @@ -14,7 +14,8 @@ import { executeTxn as executeTxnUtil } from '../utils/starknetUtils'; import { generateRandomFee, mockAccount, - prepareConfirmDialog, + mockTransactionRequestStateManager, + prepareConfirmDialogInteractiveUI, prepareMockAccount, } from './__tests__/helper'; import type { ExecuteTxnParams } from './execute-txn'; @@ -35,7 +36,7 @@ const prepareMockExecuteTxn = async ( networks: [STARKNET_SEPOLIA_TESTNET_NETWORK], transactions: [], }; - const { confirmDialogSpy } = prepareConfirmDialog(); + const { confirmDialogSpy } = prepareConfirmDialogInteractiveUI(); const account = await mockAccount(constants.StarknetChainId.SN_SEPOLIA); prepareMockAccount(account, state); @@ -88,6 +89,7 @@ const prepareMockExecuteTxn = async ( executeTxnUtilSpy, getEstimatedFeesSpy, getEstimatedFeesRepsMock, + ...mockTransactionRequestStateManager(), }; }; @@ -100,6 +102,8 @@ describe('ExecuteTxn', () => { executeTxnRespMock, getEstimatedFeesSpy, getEstimatedFeesRepsMock, + upsertTransactionRequestSpy, + getTransactionRequestSpy, request, } = await prepareMockExecuteTxn( calls.hash, @@ -126,6 +130,8 @@ describe('ExecuteTxn', () => { ); expect(getEstimatedFeesSpy).toHaveBeenCalled(); expect(createAccountSpy).not.toHaveBeenCalled(); + expect(upsertTransactionRequestSpy).toHaveBeenCalled(); + expect(getTransactionRequestSpy).toHaveBeenCalled(); }); it.each([ @@ -235,6 +241,60 @@ describe('ExecuteTxn', () => { }, ); + it('throws `Failed to retrieve the updated transaction request` error the transaction request can not retrieve after confirmation', async () => { + const calls = callsExamples.multipleCalls; + const { getTransactionRequestSpy, request } = await prepareMockExecuteTxn( + calls.hash, + calls.calls, + calls.details, + true, + ); + + getTransactionRequestSpy.mockResolvedValue(null); + + await expect(executeTxn.execute(request)).rejects.toThrow( + 'Failed to retrieve the updated transaction request', + ); + }); + + it.each([ + { + executeTxnResult: callsExamples.multipleCalls.hash, + testCase: 'the transaction executed successfully', + }, + { + // Simulate the case where the transaction execution failed and does not return a transaction hash + // An error `Failed to execute transaction` will be thrown in this case + executeTxnResult: '', + testCase: 'the transaction failed to execute', + }, + ])( + 'removes the transaction request from state if $testCase.', + async ({ executeTxnResult }) => { + const calls = callsExamples.multipleCalls; + const { executeTxnUtilSpy, removeTransactionRequestSpy, request } = + await prepareMockExecuteTxn( + executeTxnResult, + calls.calls, + calls.details, + true, + ); + + executeTxnUtilSpy.mockResolvedValue({ + // eslint-disable-next-line @typescript-eslint/naming-convention + transaction_hash: executeTxnResult, + }); + + try { + await executeTxn.execute(request); + } catch (error) { + // eslint-disable-next-line no-empty + } finally { + expect(removeTransactionRequestSpy).toHaveBeenCalled(); + } + }, + ); + it('throws UserRejectedOpError if user cancels execution', async () => { const calls = callsExamples.multipleCalls; const { request, confirmDialogSpy } = await prepareMockExecuteTxn( diff --git a/packages/starknet-snap/src/rpcs/execute-txn.ts b/packages/starknet-snap/src/rpcs/execute-txn.ts index 9e6d6108..db31752c 100644 --- a/packages/starknet-snap/src/rpcs/execute-txn.ts +++ b/packages/starknet-snap/src/rpcs/execute-txn.ts @@ -1,34 +1,30 @@ -import type { Component, Json } from '@metamask/snaps-sdk'; -import convert from 'ethereum-unit-converter'; +import { type Json } from '@metamask/snaps-sdk'; import type { Call, Calldata } from 'starknet'; import { constants, TransactionStatus, TransactionType } from 'starknet'; import type { Infer } from 'superstruct'; import { object, string, assign, optional, any } from 'superstruct'; +import { v4 as uuidv4 } from 'uuid'; import { AccountStateManager } from '../state/account-state-manager'; +import { TransactionRequestStateManager } from '../state/request-state-manager'; import { TokenStateManager } from '../state/token-state-manager'; import { TransactionStateManager } from '../state/transaction-state-manager'; import { FeeToken } from '../types/snapApi'; +import type { TransactionRequest } from '../types/snapState'; import { VoyagerTransactionType, type Transaction } from '../types/snapState'; +import { generateExecuteTxnFlow } from '../ui/utils'; import type { AccountRpcControllerOptions } from '../utils'; import { AddressStruct, BaseRequestStruct, AccountRpcController, - confirmDialog, UniversalDetailsStruct, CallsStruct, mapDeprecatedParams, - addressUI, - signerUI, - networkUI, - jsonDataUI, - dividerUI, - headerUI, - rowUI, + createInteractiveConfirmDialog, + callToTransactionReqCall, } from '../utils'; import { UserRejectedOpError } from '../utils/exceptions'; -import { logger } from '../utils/logger'; import { createAccount, executeTxn as executeTxnUtil, @@ -63,6 +59,8 @@ export class ExecuteTxnRpc extends AccountRpcController< > { protected txnStateManager: TransactionStateManager; + protected reqStateManager: TransactionRequestStateManager; + protected accStateManager: AccountStateManager; protected tokenStateManager: TokenStateManager; @@ -74,6 +72,7 @@ export class ExecuteTxnRpc extends AccountRpcController< constructor(options?: AccountRpcControllerOptions) { super(options); this.txnStateManager = new TransactionStateManager(); + this.reqStateManager = new TransactionRequestStateManager(); this.accStateManager = new AccountStateManager(); this.tokenStateManager = new TokenStateManager(); } @@ -110,88 +109,143 @@ export class ExecuteTxnRpc extends AccountRpcController< protected async handleRequest( params: ExecuteTxnParams, ): Promise { - const { address, calls, abis, details } = params; - const { privateKey, publicKey } = this.account; - - const { includeDeploy, suggestedMaxFee, estimateResults } = - await getEstimatedFees( - this.network, - address, - privateKey, - publicKey, - [ - { - type: TransactionType.INVOKE, - payload: calls, - }, - ], - details, + const requestId = uuidv4(); + + try { + const { address, calls, abis, details } = params; + const { privateKey, publicKey } = this.account; + const callsArray = Array.isArray(calls) ? calls : [calls]; + + const { includeDeploy, suggestedMaxFee, estimateResults } = + await getEstimatedFees( + this.network, + address, + privateKey, + publicKey, + [ + { + type: TransactionType.INVOKE, + payload: calls, + }, + ], + details, + ); + + const accountDeployed = !includeDeploy; + const version = + details?.version as unknown as constants.TRANSACTION_VERSION; + + const formattedCalls = await Promise.all( + callsArray.map(async (call) => + callToTransactionReqCall( + call, + this.network.chainId, + address, + this.tokenStateManager, + ), + ), ); - const accountDeployed = !includeDeploy; - const version = - details?.version as unknown as constants.TRANSACTION_VERSION; + const request: TransactionRequest = { + chainId: this.network.chainId, + networkName: this.network.name, + id: requestId, + interfaceId: '', + type: TransactionType.INVOKE, + signer: address, + addressIndex: this.account.addressIndex, + maxFee: suggestedMaxFee, + calls: formattedCalls, + resourceBounds: estimateResults.map((result) => result.resourceBounds), + selectedFeeToken: + version === constants.TRANSACTION_VERSION.V3 + ? FeeToken.STRK + : FeeToken.ETH, + includeDeploy, + }; - if ( - !(await this.getExecuteTxnConsensus( - address, - accountDeployed, - calls, - suggestedMaxFee, - version, - )) - ) { - throw new UserRejectedOpError() as unknown as Error; - } + const interfaceId = await generateExecuteTxnFlow(request); - if (!accountDeployed) { - await createAccount({ - network: this.network, - address, - publicKey, - privateKey, - waitMode: false, - callback: async (contractAddress: string, transactionHash: string) => { - await this.updateAccountAsDeploy(contractAddress, transactionHash); - }, - version, + request.interfaceId = interfaceId; + + await this.reqStateManager.upsertTransactionRequest(request); + + if (!(await createInteractiveConfirmDialog(interfaceId))) { + throw new UserRejectedOpError() as unknown as Error; + } + + // Retrieve the updated transaction request, + // the transaction request may have been updated during the confirmation process. + const updatedRequest = await this.reqStateManager.getTransactionRequest({ + requestId, }); - } - const resourceBounds = estimateResults.map( - (result) => result.resourceBounds, - ); + if (!updatedRequest) { + throw new Error('Failed to retrieve the updated transaction request'); + } - const executeTxnResp = await executeTxnUtil( - this.network, - address, - privateKey, - calls, - abis, - { + if (!accountDeployed) { + await createAccount({ + network: this.network, + address, + publicKey, + privateKey, + waitMode: false, + callback: async ( + contractAddress: string, + transactionHash: string, + ) => { + await this.updateAccountAsDeploy(contractAddress, transactionHash); + }, + version: + updatedRequest.selectedFeeToken === FeeToken.STRK + ? constants.TRANSACTION_VERSION.V3 + : constants.TRANSACTION_VERSION.V1, + }); + } + + const invocationDetails = { ...details, // Aways repect the input, unless the account is not deployed // TODO: we may also need to increment the nonce base on the input, if the account is not deployed nonce: accountDeployed ? details?.nonce : 1, - maxFee: suggestedMaxFee, - resourceBounds: resourceBounds[resourceBounds.length - 1], - }, - ); + maxFee: updatedRequest.maxFee, + resourceBounds: + updatedRequest.resourceBounds[ + updatedRequest.resourceBounds.length - 1 + ], + version: + updatedRequest.selectedFeeToken === FeeToken.STRK + ? constants.TRANSACTION_VERSION.V3 + : constants.TRANSACTION_VERSION.V1, + }; + + const executeTxnResp = await executeTxnUtil( + this.network, + address, + privateKey, + calls, + abis, + invocationDetails, + ); - if (!executeTxnResp?.transaction_hash) { - throw new Error('Failed to execute transaction'); - } + if (!executeTxnResp?.transaction_hash) { + throw new Error('Failed to execute transaction'); + } - // Since the RPC supports the `calls` parameter either as a single `call` object or an array of `call` objects, - // and the current state data structure does not yet support multiple `call` objects in a single transaction, - // we need to convert `calls` into a single `call` object as a temporary fix. - const call = Array.isArray(calls) ? calls[0] : calls; + // Since the RPC supports the `calls` parameter either as a single `call` object or an array of `call` objects, + // and the current state data structure does not yet support multiple `call` objects in a single transaction, + // we need to convert `calls` into a single `call` object as a temporary fix. + const call = Array.isArray(calls) ? calls[0] : calls; - await this.txnStateManager.addTransaction( - this.createInvokeTxn(address, executeTxnResp.transaction_hash, call), - ); + await this.txnStateManager.addTransaction( + this.createInvokeTxn(address, executeTxnResp.transaction_hash, call), + ); - return executeTxnResp; + return executeTxnResp; + } finally { + await this.reqStateManager.removeTransactionRequest(requestId); + } } protected async updateAccountAsDeploy( @@ -213,119 +267,6 @@ export class ExecuteTxnRpc extends AccountRpcController< }); } - protected async getExecuteTxnConsensus( - address: string, - accountDeployed: boolean, - calls: Call[] | Call, - maxFee: string, - version?: constants.TRANSACTION_VERSION, - ) { - const { name: chainName, chainId } = this.network; - const callsArray = Array.isArray(calls) ? calls : [calls]; - - const components: Component[] = []; - const feeToken: FeeToken = - version === constants.TRANSACTION_VERSION.V3 - ? FeeToken.STRK - : FeeToken.ETH; - - components.push(headerUI('Do you want to sign this transaction?')); - components.push( - signerUI({ - address, - chainId, - }), - ); - - // Display a message to indicate the signed transaction will include an account deployment - if (!accountDeployed) { - components.push(headerUI(`The account will be deployed`)); - } - - components.push(dividerUI()); - components.push( - rowUI({ - label: `Estimated Gas Fee (${feeToken})`, - value: convert(maxFee, 'wei', 'ether'), - }), - ); - - components.push(dividerUI()); - components.push( - networkUI({ - networkName: chainName, - }), - ); - - // Iterate over each call in the calls array - for (const call of callsArray) { - const { contractAddress, calldata, entrypoint } = call; - components.push(dividerUI()); - components.push( - addressUI({ - label: 'Contract', - address: contractAddress, - chainId, - }), - ); - - components.push( - jsonDataUI({ - label: 'Call Data', - data: calldata, - }), - ); - - // If the contract is an ERC20 token and the function is 'transfer', display sender, recipient, and amount - const token = await this.tokenStateManager.getToken({ - address: contractAddress, - chainId, - }); - - if (token && entrypoint === 'transfer' && calldata) { - try { - const senderAddress = address; - const recipientAddress = calldata[0]; // Assuming the first element in calldata is the recipient - let amount = ''; - - if ([3, 6, 9, 12, 15, 18].includes(token.decimals)) { - amount = convert(calldata[1], -1 * token.decimals, 'ether'); - } else { - amount = ( - Number(calldata[1]) * Math.pow(10, -1 * token.decimals) - ).toFixed(token.decimals); - } - components.push(dividerUI()); - components.push( - addressUI({ - label: 'Sender Address', - address: senderAddress, - chainId, - }), - dividerUI(), - addressUI({ - label: 'Recipient Address', - address: recipientAddress, - chainId, - }), - dividerUI(), - rowUI({ - label: `Amount (${token.symbol})`, - value: amount, - }), - ); - } catch (error) { - logger.warn( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `error found in amount conversion: ${error}`, - ); - } - } - } - - return await confirmDialog(components); - } - protected createDeployTxn( address: string, transactionHash: string, diff --git a/packages/starknet-snap/src/state/request-state-manager.ts b/packages/starknet-snap/src/state/request-state-manager.ts index 1baa0a37..e5c851cc 100644 --- a/packages/starknet-snap/src/state/request-state-manager.ts +++ b/packages/starknet-snap/src/state/request-state-manager.ts @@ -31,7 +31,8 @@ export class TransactionRequestStateManager extends StateManager['resourceBounds']; + export type TransactionRequest = { id: string; interfaceId: string; - type: string; + type: StarknetTransactionType; signer: string; + addressIndex: number; chainId: string; + networkName: string; maxFee: string; - calls: { - contractAddress: string; - calldata: RawCalldata; - entrypoint: string; - }[]; - feeToken: string; + calls: FormattedCallData[]; + resourceBounds: ResourceBounds[]; + selectedFeeToken: string; + includeDeploy: boolean; }; export type AccContract = { diff --git a/packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx b/packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx new file mode 100644 index 00000000..bbcb6c8b --- /dev/null +++ b/packages/starknet-snap/src/ui/components/ExecuteTxnUI.tsx @@ -0,0 +1,140 @@ +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; +import { + Box, + Container, + Section, + Text, + Icon, + Divider, +} from '@metamask/snaps-sdk/jsx'; + +import type { FeeToken } from '../../types/snapApi'; +import type { FormattedCallData } from '../../types/snapState'; +import { DEFAULT_DECIMAL_PLACES } from '../../utils/constants'; +import { AddressUI, JsonDataUI, NetworkUI, SignerUI } from '../fragments'; +import { Amount } from '../fragments/Amount'; +import { FeeTokenSelector } from '../fragments/FeeTokenSelector'; +import { accumulateTotals } from '../utils'; + +/** + * The form errors. + * + * @property to - The error for the receiving address. + * @property amount - The error for the amount. + * @property fees - The error for the fees. + */ +export type ExecuteTxnUIErrors = { + fees?: string; +}; + +export type ExecuteTxnUIProps = { + signer: string; + chainId: string; + networkName: string; + maxFee: string; + calls: FormattedCallData[]; + selectedFeeToken: string; + includeDeploy: boolean; + errors?: ExecuteTxnUIErrors; +}; + +/** + * A component for executing transactions, providing details and options to configure the transaction. + * + * @param props - The component props. + * @param props.signer - The signer for the transaction. + * @param props.chainId - The ID of the chain for the transaction. + * @param props.networkName - The ID of the chain for the transaction. + * @param props.maxFee - The maximum fee allowed for the transaction. + * @param props.calls - The calls involved in the transaction. + * @param props.selectedFeeToken - The token used for fees. + * @param props.includeDeploy - Whether to include account deployment in the transaction. + * @param [props.errors] - The object contains the error message for fee token selection. + * @returns The ExecuteTxnUI component. + */ +export const ExecuteTxnUI: SnapComponent = ({ + signer, + chainId, + networkName, + maxFee, + calls, + selectedFeeToken, + includeDeploy, + errors, +}) => { + // Calculate the totals using the helper + const tokenTotals = accumulateTotals(calls, maxFee, selectedFeeToken); + + return ( + + +
+ + +
+ + {/* Loop through each call and render based on `tokenTransferData` */} + {calls.map((call) => ( +
+ + {call.tokenTransferData ? ( +
+ + +
+ ) : ( + + )} +
+ ))} + +
+ + + + + + {Object.entries(tokenTotals).map( + ([tokenSymbol, { amount, decimals }]) => ( + + ), + )} + + {includeDeploy ? : null} + {includeDeploy ? ( + + + The account will be deployed with this transaction + + ) : null} +
+
+
+ ); +}; diff --git a/packages/starknet-snap/src/ui/components/index.tsx b/packages/starknet-snap/src/ui/components/index.tsx new file mode 100644 index 00000000..3d1f64d3 --- /dev/null +++ b/packages/starknet-snap/src/ui/components/index.tsx @@ -0,0 +1 @@ +export * from './ExecuteTxnUI'; diff --git a/packages/starknet-snap/src/ui/controllers/user-input-event-controller.ts b/packages/starknet-snap/src/ui/controllers/user-input-event-controller.ts new file mode 100644 index 00000000..631ab5e4 --- /dev/null +++ b/packages/starknet-snap/src/ui/controllers/user-input-event-controller.ts @@ -0,0 +1,203 @@ +import type { + InputChangeEvent, + InterfaceContext, + UserInputEvent, +} from '@metamask/snaps-sdk'; +import { UserInputEventType } from '@metamask/snaps-sdk'; +import { constants, ec, num as numUtils, TransactionType } from 'starknet'; + +import { NetworkStateManager } from '../../state/network-state-manager'; +import { TransactionRequestStateManager } from '../../state/request-state-manager'; +import { TokenStateManager } from '../../state/token-state-manager'; +import { FeeToken } from '../../types/snapApi'; +import type { Network, TransactionRequest } from '../../types/snapState'; +import { getBip44Deriver, logger } from '../../utils'; +import { InsufficientFundsError } from '../../utils/exceptions'; +import { getAddressKey } from '../../utils/keyPair'; +import { getEstimatedFees } from '../../utils/starknetUtils'; +import { + hasSufficientFundsForFee, + renderLoading, + updateExecuteTxnFlow, +} from '../utils'; + +const FeeTokenSelectorEventKey = { + FeeTokenChange: `feeTokenSelector_${UserInputEventType.InputChangeEvent}`, +} as const; + +type FeeTokenSelectorEventKey = + (typeof FeeTokenSelectorEventKey)[keyof typeof FeeTokenSelectorEventKey]; + +export class UserInputEventController { + context: InterfaceContext | null; + + event: UserInputEvent; + + eventId: string; + + reqStateMgr: TransactionRequestStateManager; + + networkStateMgr: NetworkStateManager; + + tokenStateMgr: TokenStateManager; + + constructor( + eventId: string, + event: UserInputEvent, + context: InterfaceContext | null, + ) { + this.event = event; + this.context = context; + this.eventId = eventId; + this.reqStateMgr = new TransactionRequestStateManager(); + this.networkStateMgr = new NetworkStateManager(); + this.tokenStateMgr = new TokenStateManager(); + } + + async handleEvent() { + try { + const request = this.context?.request as TransactionRequest; + + if ( + !(await this.reqStateMgr.getTransactionRequest({ + requestId: request.id, + })) + ) { + throw new Error('Transaction request not found'); + } + + await renderLoading(this.eventId); + + const eventKey = `${this.event.name ?? ''}_${this.event.type}`; + + switch (eventKey) { + case FeeTokenSelectorEventKey.FeeTokenChange: + await this.handleFeeTokenChange(); + break; + default: + break; + } + } catch (error) { + logger.error('onUserInput error:', error); + throw error; + } + } + + protected async deriveAccount(index: number) { + const deriver = await getBip44Deriver(); + const { addressKey } = await getAddressKey(deriver, index); + const publicKey = ec.starkCurve.getStarkKey(addressKey); + const privateKey = numUtils.toHex(addressKey); + return { + publicKey, + privateKey, + }; + } + + protected feeTokenToTransactionVersion(feeToken: FeeToken) { + return feeToken === FeeToken.STRK + ? constants.TRANSACTION_VERSION.V3 + : undefined; + } + + protected async getNetwork(chainId: string): Promise { + const network = await this.networkStateMgr.getNetwork({ chainId }); + + if (!network) { + throw new Error('Network not found'); + } + + return network; + } + + protected async getTokenAddress( + chainId: string, + feeToken: FeeToken, + ): Promise { + const token = + feeToken === FeeToken.STRK + ? await this.tokenStateMgr.getStrkToken({ + chainId, + }) + : await this.tokenStateMgr.getEthToken({ + chainId, + }); + + if (!token) { + throw new Error('Token not found'); + } + + return token.address; + } + + protected async handleFeeTokenChange() { + const request = this.context?.request as TransactionRequest; + const { addressIndex, calls, signer, chainId } = request; + const feeToken = (this.event as InputChangeEvent) + .value as unknown as FeeToken; + + try { + const network = await this.getNetwork(chainId); + + const { publicKey, privateKey } = await this.deriveAccount(addressIndex); + + const requestTxnVersion = this.feeTokenToTransactionVersion(feeToken); + + const { includeDeploy, suggestedMaxFee, estimateResults } = + await getEstimatedFees( + network, + signer, + privateKey, + publicKey, + [ + { + type: TransactionType.INVOKE, + payload: calls.map((call) => ({ + calldata: call.calldata, + contractAddress: call.contractAddress, + entrypoint: call.entrypoint, + })), + }, + ], + { + version: requestTxnVersion, + }, + ); + + if ( + !(await hasSufficientFundsForFee({ + address: signer, + network, + calls, + feeTokenAddress: await this.getTokenAddress( + network.chainId, + feeToken, + ), + suggestedMaxFee, + })) + ) { + throw new InsufficientFundsError(); + } + + request.maxFee = suggestedMaxFee; + request.selectedFeeToken = feeToken; + request.includeDeploy = includeDeploy; + request.resourceBounds = estimateResults.map( + (result) => result.resourceBounds, + ); + + await updateExecuteTxnFlow(this.eventId, request); + await this.reqStateMgr.upsertTransactionRequest(request); + } catch (error) { + const errorMessage = + error instanceof InsufficientFundsError + ? `Not enough ${feeToken} to pay for fee` + : 'Fail to calculate the fees'; + + // On failure, display ExecuteTxnUI with an error message + await updateExecuteTxnFlow(this.eventId, request, { + errors: { fees: errorMessage }, + }); + } + } +} diff --git a/packages/starknet-snap/src/ui/fragments/AddressUI.tsx b/packages/starknet-snap/src/ui/fragments/AddressUI.tsx new file mode 100644 index 00000000..23229624 --- /dev/null +++ b/packages/starknet-snap/src/ui/fragments/AddressUI.tsx @@ -0,0 +1,39 @@ +import { Link, Row, Text, type SnapComponent } from '@metamask/snaps-sdk/jsx'; + +import { getExplorerUrl, shortenAddress } from '../../utils'; + +export type AddressUIProps = { + label: string; + address: string; + chainId?: string; + shortern?: boolean; +}; + +/** + * Builds a row component with the address, displaying an icon if provided, otherwise showing the address as text or a link. + * If both `svgIcon` and `chainId` are provided, the icon is wrapped in a link. + * + * @param params - The parameters. + * @param params.label - The label. + * @param params.address - The address. + * @param [params.chainId] - The chain ID; if set, an explorer URL link will be generated. + * @param [params.shortern] - Whether to shorten the address. Default is true. + * @returns A row component with the address or icon. + */ +export const AddressUI: SnapComponent = ({ + label, + address, + chainId, + shortern = true, +}: AddressUIProps) => { + const displayValue = shortern ? shortenAddress(address) : address; + return ( + + {chainId ? ( + {displayValue} + ) : ( + {displayValue} + )} + + ); +}; diff --git a/packages/starknet-snap/src/ui/fragments/Amount.tsx b/packages/starknet-snap/src/ui/fragments/Amount.tsx new file mode 100644 index 00000000..c051a5c9 --- /dev/null +++ b/packages/starknet-snap/src/ui/fragments/Amount.tsx @@ -0,0 +1,29 @@ +import { Row, Text, type SnapComponent } from '@metamask/snaps-sdk/jsx'; +import { formatUnits } from 'ethers/lib/utils'; + +export type AmountProps = { + label: string; + amount: string; // Bigint representatio + decimals: number; + symbol: string; +}; +/** + * Build a row component with the JSON data. + * + * @param params - The parameters. + * @param params.label - The label. + * @param params.amount + * @param params.decimals + * @param params.symbol + * @returns A row component with the JSON data. + */ +export const Amount: SnapComponent = ({ + label, + amount, + decimals, + symbol, +}: AmountProps) => ( + + {`${formatUnits(amount, decimals)} ${symbol}`} + +); diff --git a/packages/starknet-snap/src/ui/fragments/FeeTokenSelector.tsx b/packages/starknet-snap/src/ui/fragments/FeeTokenSelector.tsx new file mode 100644 index 00000000..c8aca08f --- /dev/null +++ b/packages/starknet-snap/src/ui/fragments/FeeTokenSelector.tsx @@ -0,0 +1,52 @@ +import { + Card, + Field, + Form, + Selector, + SelectorOption, + type SnapComponent, +} from '@metamask/snaps-sdk/jsx'; + +import { FeeToken, FeeTokenUnit } from '../../types/snapApi'; + +/** + * The props for the {@link FeeTokenSelector} component. + * + * @property selectedToken - The currently selected fee token. + */ +export type FeeTokenSelectorProps = { + selectedToken: FeeToken; + error?: string; +}; + +/** + * A component that allows the user to select the fee token. + * + * @param props - The component props. + * @param props.selectedToken - The currently selected fee token. + * @param [props.error] - The error message for fee token selection. + * @returns The FeeTokenSelector component. + */ +export const FeeTokenSelector: SnapComponent = ({ + selectedToken, + error, +}) => { + return ( +
+ + + + + + + + + + +
+ ); +}; diff --git a/packages/starknet-snap/src/ui/fragments/JsonDataUI.tsx b/packages/starknet-snap/src/ui/fragments/JsonDataUI.tsx new file mode 100644 index 00000000..61d47f8b --- /dev/null +++ b/packages/starknet-snap/src/ui/fragments/JsonDataUI.tsx @@ -0,0 +1,24 @@ +import { Row, Text, type SnapComponent } from '@metamask/snaps-sdk/jsx'; + +import { toJson } from '../../utils'; + +export type JsonDataUIProps = { + label: string; + data: any; +}; +/** + * Build a row component with the JSON data. + * + * @param params - The parameters. + * @param params.data - The JSON data. + * @param params.label - The label. + * @returns A row component with the JSON data. + */ +export const JsonDataUI: SnapComponent = ({ + label, + data, +}: JsonDataUIProps) => ( + + {toJson(data)} + +); diff --git a/packages/starknet-snap/src/ui/fragments/LoadingUI.tsx b/packages/starknet-snap/src/ui/fragments/LoadingUI.tsx new file mode 100644 index 00000000..7fd1fdd2 --- /dev/null +++ b/packages/starknet-snap/src/ui/fragments/LoadingUI.tsx @@ -0,0 +1,20 @@ +import { + Heading, + Box, + Spinner, + type SnapComponent, +} from '@metamask/snaps-sdk/jsx'; + +/** + * Builds a loading UI component. + * + * @returns A loading component. + */ +export const LoadingUI: SnapComponent = () => { + return ( + + please wait... + + + ); +}; diff --git a/packages/starknet-snap/src/ui/fragments/NetworkUI.tsx b/packages/starknet-snap/src/ui/fragments/NetworkUI.tsx new file mode 100644 index 00000000..088b27ca --- /dev/null +++ b/packages/starknet-snap/src/ui/fragments/NetworkUI.tsx @@ -0,0 +1,21 @@ +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; +import { Row, Text } from '@metamask/snaps-sdk/jsx'; + +export type NetworkUIProps = { + networkName: string; +}; + +/** + * Build a row component with the network name. + * + * @param params - The parameters. + * @param params.networkName - The network name. + * @returns A row component with the network name. + */ +export const NetworkUI: SnapComponent = ({ + networkName, +}: NetworkUIProps) => ( + + {networkName} + +); diff --git a/packages/starknet-snap/src/ui/fragments/SignerUI.tsx b/packages/starknet-snap/src/ui/fragments/SignerUI.tsx new file mode 100644 index 00000000..d55a7c6c --- /dev/null +++ b/packages/starknet-snap/src/ui/fragments/SignerUI.tsx @@ -0,0 +1,28 @@ +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; + +import { AddressUI } from './AddressUI'; + +export type SignerUIProps = { + address: string; + chainId: string; +}; + +/** + * Build a row component with the signer address. + * + * @param params - The parameters. + * @param params.address - The signer address. + * @param params.chainId - The chain ID. + * @returns A row component with the signer address. + */ +export const SignerUI: SnapComponent = ({ + address, + chainId, +}: SignerUIProps) => ( + +); diff --git a/packages/starknet-snap/src/ui/fragments/index.tsx b/packages/starknet-snap/src/ui/fragments/index.tsx new file mode 100644 index 00000000..c6100fdb --- /dev/null +++ b/packages/starknet-snap/src/ui/fragments/index.tsx @@ -0,0 +1,4 @@ +export * from './JsonDataUI'; +export * from './AddressUI'; +export * from './NetworkUI'; +export * from './SignerUI'; diff --git a/packages/starknet-snap/src/ui/types.ts b/packages/starknet-snap/src/ui/types.ts new file mode 100644 index 00000000..9f4d42a5 --- /dev/null +++ b/packages/starknet-snap/src/ui/types.ts @@ -0,0 +1,7 @@ +export type TokenTotals = Record< + string, + { + amount: bigint; // Use BigInt for precise calculations + decimals: number; + } +>; diff --git a/packages/starknet-snap/src/ui/utils.test.tsx b/packages/starknet-snap/src/ui/utils.test.tsx new file mode 100644 index 00000000..f0b98500 --- /dev/null +++ b/packages/starknet-snap/src/ui/utils.test.tsx @@ -0,0 +1,241 @@ +import type { constants } from 'starknet'; + +import { generateAccounts } from '../__tests__/helper'; +import type { Erc20Token, FormattedCallData } from '../types/snapState'; +import { + DEFAULT_DECIMAL_PLACES, + BlockIdentifierEnum, + ETHER_MAINNET, + ETHER_SEPOLIA_TESTNET, + STARKNET_SEPOLIA_TESTNET_NETWORK, + USDC_SEPOLIA_TESTNET, +} from '../utils/constants'; +import * as starknetUtils from '../utils/starknetUtils'; +import { accumulateTotals, hasSufficientFundsForFee } from './utils'; + +describe('accumulateTotals', () => { + const mockCalls = (overrides = [{}]) => + [ + { + tokenTransferData: { + amount: '1000000000000000000', // 1 ETH as string BigInt + symbol: 'ETH', + decimals: 18, + ...overrides[0], + }, + }, + { + tokenTransferData: { + amount: '500000000000000000', // 0.5 ETH as string BigInt + symbol: 'ETH', + decimals: 18, + ...overrides[1], + }, + }, + { + tokenTransferData: { + amount: '2000000000000000000', // 2 STRK as string BigInt + symbol: 'STRK', + decimals: 18, + ...overrides[2], + }, + }, + ] as FormattedCallData[]; + + const mockMaxFee = '100000000000000000'; // 0.1 token fee + + it.each([ + { + selectedFeeToken: 'ETH', + expectedResult: { + ETH: { + amount: BigInt('1600000000000000000'), // 1 + 0.5 + 0.1 ETH + decimals: 18, + }, + STRK: { + amount: BigInt('2000000000000000000'), // 2 STRK + decimals: 18, + }, + }, + }, + { + selectedFeeToken: 'STRK', + expectedResult: { + ETH: { + amount: BigInt('1500000000000000000'), // 1 + 0.5 ETH + decimals: 18, + }, + STRK: { + amount: BigInt('2100000000000000000'), // 2 + 0.1 STRK + decimals: 18, + }, + }, + }, + ])( + 'sums up transfer amounts for $selectedFeeToken', + ({ selectedFeeToken, expectedResult }) => { + const calls = mockCalls(); + + const result = accumulateTotals(calls, mockMaxFee, selectedFeeToken); + + expect(result).toStrictEqual(expectedResult); + }, + ); + + it('creates a new token entry if the fee token was not part of calls', () => { + const calls = mockCalls(); + const selectedFeeToken = 'STRK'; + + const result = accumulateTotals(calls, mockMaxFee, selectedFeeToken); + + expect(result).toStrictEqual({ + ETH: { + amount: BigInt('1500000000000000000'), // 1 + 0.5 ETH + decimals: 18, + }, + STRK: { + amount: BigInt('2100000000000000000'), // 2 + 0.1 STRK + decimals: 18, + }, + }); + }); + + it('handles no calls gracefully', () => { + const calls = []; + const selectedFeeToken = 'ETH'; + + const result = accumulateTotals(calls, mockMaxFee, selectedFeeToken); + + expect(result).toStrictEqual({ + ETH: { + amount: BigInt('100000000000000000'), // 0.1 ETH (fee only) + decimals: DEFAULT_DECIMAL_PLACES, + }, + }); + }); +}); + +describe('hasSufficientFundsForFee', () => { + const prepareSpy = () => { + const getBalanceSpy = jest.spyOn(starknetUtils, 'getBalance'); + return { getBalanceSpy }; + }; + + const generateFormattedCallData = ( + cnt: number, + { + token = ETHER_MAINNET, + amount = '1000', + senderAddress = '', + recipientAddress = '', + }: { + token?: Erc20Token; + amount?: string; + senderAddress?: string; + recipientAddress?: string; + }, + ): FormattedCallData[] => { + const calls: FormattedCallData[] = []; + for (let i = 0; i < cnt; i++) { + calls.push({ + entrypoint: 'transfer', + contractAddress: token.address, + tokenTransferData: { + amount, + senderAddress, + recipientAddress, + decimals: token.decimals, + symbol: token.symbol, + }, + }); + } + return calls; + }; + + const prepareExecution = async ({ + calls, + maxFee = '1000', + feeToken = ETHER_SEPOLIA_TESTNET, + }: { + calls: FormattedCallData[]; + maxFee?: string; + feeToken?: Erc20Token; + }) => { + const network = STARKNET_SEPOLIA_TESTNET_NETWORK; + const [{ address }] = await generateAccounts( + network.chainId as unknown as constants.StarknetChainId, + 1, + ); + + return { + feeTokenAddress: feeToken.address, + suggestedMaxFee: maxFee, + network, + address, + calls, + }; + }; + + it.each([ + { + calls: generateFormattedCallData(1, { + amount: '1500', + token: ETHER_SEPOLIA_TESTNET, + }), + feeToken: ETHER_SEPOLIA_TESTNET, + tokenInCalls: ETHER_SEPOLIA_TESTNET, + }, + { + calls: generateFormattedCallData(1, { + amount: '1500', + token: USDC_SEPOLIA_TESTNET, + }), + feeToken: ETHER_SEPOLIA_TESTNET, + tokenInCalls: USDC_SEPOLIA_TESTNET, + }, + { + calls: [], + feeToken: ETHER_SEPOLIA_TESTNET, + tokenInCalls: USDC_SEPOLIA_TESTNET, + }, + ])( + 'returns true if the fee token balance covers both the calls and fee - feeToken: $feeToken.name, callData length: $calls.length, tokenInCalls: $tokenInCalls.name', + async ({ calls, feeToken }) => { + const { getBalanceSpy } = prepareSpy(); + + getBalanceSpy.mockResolvedValueOnce('3000'); // Mock fee token balance + + const args = await prepareExecution({ + calls, + feeToken, + }); + + const result = await hasSufficientFundsForFee(args); + + expect(result).toBe(true); + expect(getBalanceSpy).toHaveBeenCalledWith( + args.address, + args.feeTokenAddress, + args.network, + BlockIdentifierEnum.Pending, + ); + }, + ); + + it.each(['2000', '0'])( + 'returns false when balance for fee token is insufficient - balance: %s', + async (balance) => { + const { getBalanceSpy } = prepareSpy(); + + getBalanceSpy.mockResolvedValueOnce(balance); // Mock fee token balance + + const args = await prepareExecution({ + calls: generateFormattedCallData(1, { amount: '1500' }), + }); + + const result = await hasSufficientFundsForFee(args); + + expect(result).toBe(false); + }, + ); +}); diff --git a/packages/starknet-snap/src/ui/utils.tsx b/packages/starknet-snap/src/ui/utils.tsx new file mode 100644 index 00000000..2ee82ae2 --- /dev/null +++ b/packages/starknet-snap/src/ui/utils.tsx @@ -0,0 +1,210 @@ +import type { + FormattedCallData, + Network, + TransactionRequest, +} from '../types/snapState'; +import { + BlockIdentifierEnum, + DEFAULT_DECIMAL_PLACES, +} from '../utils/constants'; +import { getBalance } from '../utils/starknetUtils'; +import type { ExecuteTxnUIErrors } from './components'; +import { ExecuteTxnUI } from './components'; +import { LoadingUI } from './fragments/LoadingUI'; +import type { TokenTotals } from './types'; + +/** + * Accumulate the total amount for all tokens involved in calls and fees. + * + * @param calls - The array of FormattedCallData object. + * @param maxFee - The maximum fee. + * @param selectedFeeToken - The selected token symbol for fees. + * @returns The accumulated totals for each token. + */ +export const accumulateTotals = ( + calls: FormattedCallData[], + maxFee: string, + selectedFeeToken: string, +): TokenTotals => { + return calls.reduce( + (acc, call) => { + if (call.tokenTransferData) { + const amount = BigInt(call.tokenTransferData.amount); // Convert to BigInt + if (!acc[call.tokenTransferData.symbol]) { + acc[call.tokenTransferData.symbol] = { + amount: BigInt(0), + decimals: call.tokenTransferData.decimals, + }; + } + acc[call.tokenTransferData.symbol].amount += amount; + } + return acc; + }, + { + // We derive decimals based on the fee token. Currently, both supported fee tokens, ETH and STRK, use the standard 18 decimals. + // Therefore, we use DEFAULT_DECIMAL_PLACES set to 18 here. If additional fee tokens with different decimals are introduced, + // this logic should be updated to handle token-specific decimals dynamically. + [selectedFeeToken]: { + amount: BigInt(maxFee), + decimals: DEFAULT_DECIMAL_PLACES, + }, + }, + ); +}; + +/** + * Generate the interface for a ExecuteTxnUI. + * + * @param request - The `TransactionRequest` object. + * @returns A Promise that resolves to the interface ID generated by the Snap request. + * The ID can be used for tracking or referencing the created interface. + */ +export async function generateExecuteTxnFlow( + request: TransactionRequest, // Request must match props and include an `id` +) { + const { + signer, + chainId, + networkName, + maxFee, + calls, + selectedFeeToken, + includeDeploy, + } = request; + + return await snap.request({ + method: 'snap_createInterface', + params: { + ui: ( + + ), + context: { + request, + }, + }, + }); +} + +/** + * Update the interface for the ExecuteTxnUI. + * + * @param id - The Interface Id to update. + * @param request - The `TransactionRequest` object. + * @param [errors] - Optional partial props for error handling or overrides. + * @param [errors.errors] - The error object for the ExecuteTxnUI. + */ +export async function updateExecuteTxnFlow( + id: string, + request: TransactionRequest, + errors?: { errors: ExecuteTxnUIErrors }, +) { + const { + signer, + chainId, + networkName, + maxFee, + calls, + selectedFeeToken, + includeDeploy, + } = request; + + await snap.request({ + method: 'snap_updateInterface', + params: { + id, + ui: ( + + ), + }, + }); +} + +/** + * Update the interface with the provided JSX. + * + * @param id - The Interface Id to update. + * @param ui - The JSX element to update the interface with. + */ +export async function updateInterface( + id: string, + ui: JSX.Element, +): Promise { + await snap.request({ + method: 'snap_updateInterface', + params: { + id, + ui, + }, + }); +} + +/** + * Render a loading interface. + * + * @param id - The Interface Id to update. + */ +export async function renderLoading(id: string): Promise { + await updateInterface(id, ); +} + +/** + * Verify if the fee token balance covers both the calls and fee. + * + * @param params - The parameters for the function. + * @param params.feeTokenAddress - The address of the fee token. + * @param params.suggestedMaxFee - The suggested maximum fee. + * @param params.network - The `Network` object. + * @param params.address - The address to check the balance for. + * @param params.calls - The array of `FormattedCallData` objects. + * @returns A Promise that resolves to a boolean indicating if the balance is sufficient. + */ +export async function hasSufficientFundsForFee({ + feeTokenAddress, + suggestedMaxFee, + network, + address, + calls, +}: { + feeTokenAddress: string; + suggestedMaxFee: string; + network: Network; + address: string; + calls: FormattedCallData[]; +}) { + const balanceForFeeToken = BigInt( + await getBalance( + address, + feeTokenAddress, + network, + BlockIdentifierEnum.Pending, + ), + ); + + // Calculate total STRK or ETH amounts from `calls` + const totalSpendForFeeToken = calls.reduce((acc, call) => { + const { tokenTransferData, contractAddress } = call; + if (tokenTransferData && contractAddress === feeTokenAddress) { + return acc + BigInt(tokenTransferData.amount); // Return the updated accumulator + } + return acc; // Return the current accumulator if the condition is not met + }, BigInt(suggestedMaxFee)); // Initial value + + return totalSpendForFeeToken <= balanceForFeeToken; +} diff --git a/packages/starknet-snap/src/utils/__mocks__/snap.ts b/packages/starknet-snap/src/utils/__mocks__/snap.ts index 75f5e80b..8f0328e9 100644 --- a/packages/starknet-snap/src/utils/__mocks__/snap.ts +++ b/packages/starknet-snap/src/utils/__mocks__/snap.ts @@ -4,6 +4,8 @@ export const getBip44Deriver = jest.fn(); export const confirmDialog = jest.fn(); +export const createInteractiveConfirmDialog = jest.fn(); + export const alertDialog = jest.fn(); export const getStateData = jest.fn(); diff --git a/packages/starknet-snap/src/utils/exceptions.ts b/packages/starknet-snap/src/utils/exceptions.ts index c4c8c59a..840a4e3a 100644 --- a/packages/starknet-snap/src/utils/exceptions.ts +++ b/packages/starknet-snap/src/utils/exceptions.ts @@ -72,3 +72,9 @@ export class TokenIsPreloadedError extends SnapError { ); } } + +export class InsufficientFundsError extends SnapError { + constructor(message?: string) { + super(message ?? 'Insufficient Funds'); + } +} diff --git a/packages/starknet-snap/src/utils/formatter-utils.test.ts b/packages/starknet-snap/src/utils/formatter-utils.test.ts index 4e6dc850..35301df3 100644 --- a/packages/starknet-snap/src/utils/formatter-utils.test.ts +++ b/packages/starknet-snap/src/utils/formatter-utils.test.ts @@ -1,4 +1,17 @@ -import { mapDeprecatedParams } from './formatter-utils'; +import { constants } from 'starknet'; + +import { singleCall } from '../__tests__/fixture/callsExamples.json'; +import { generateAccounts } from '../__tests__/helper'; +import { TokenStateManager } from '../state/token-state-manager'; +import type { Erc20Token } from '../types/snapState'; +import { ETHER_SEPOLIA_TESTNET } from './constants'; +import { + callToTransactionReqCall, + mapDeprecatedParams, +} from './formatter-utils'; +import { logger } from './logger'; + +jest.mock('./logger'); describe('mapDeprecatedParams', () => { it('maps deprecated parameters to their new equivalents', () => { @@ -65,3 +78,132 @@ describe('mapDeprecatedParams', () => { expect(requestParams).toStrictEqual(expected); }); }); + +describe('callToTransactionReqCall', () => { + const chainId = constants.StarknetChainId.SN_SEPOLIA; + + const mockGetToken = async (tokenData: Erc20Token | null) => { + const getTokenSpy = jest.spyOn(TokenStateManager.prototype, 'getToken'); + // Mock getToken method to return the provided tokenData + getTokenSpy.mockResolvedValue(tokenData); + + return { + getTokenSpy, + }; + }; + + const getSenderAndRecipient = async () => { + const [{ address }, { address: receipientAddress }] = + await generateAccounts(chainId, 2); + return { + senderAddress: address, + recipientAddress: receipientAddress, + }; + }; + + it('returns a formatted `call` object without `tokenTransferData` if no ERC20 transfer calldata is present.', async () => { + const call = singleCall.calls; + const { senderAddress } = await getSenderAndRecipient(); + + // The getToken method should not be called, so we prepare the spy with null + const { getTokenSpy } = await mockGetToken(null); + + const result = await callToTransactionReqCall( + call, + chainId, + senderAddress, + new TokenStateManager(), + ); + + expect(getTokenSpy).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ + contractAddress: call.contractAddress, + calldata: call.calldata, + entrypoint: call.entrypoint, + }); + }); + + it('returns a formatted `call` object without `tokenTransferData` if the Erc20Token can not be found.', async () => { + const { senderAddress, recipientAddress } = await getSenderAndRecipient(); + const call = { + ...singleCall.calls, + entrypoint: 'transfer', + calldata: [recipientAddress, '1000'], + }; + + // Simulate the case where the token can not be found + await mockGetToken(null); + + const result = await callToTransactionReqCall( + call, + chainId, + senderAddress, + new TokenStateManager(), + ); + + expect(result).toStrictEqual({ + contractAddress: call.contractAddress, + calldata: call.calldata, + entrypoint: call.entrypoint, + }); + }); + + it('returns a formatted `call` object without `tokenTransferData` if the calldata is not in the expected format', async () => { + const { senderAddress } = await getSenderAndRecipient(); + const call = { ...singleCall.calls, entrypoint: 'transfer', calldata: [] }; + const loggerSpy = jest.spyOn(logger, 'warn'); + + await mockGetToken(ETHER_SEPOLIA_TESTNET); + + const result = await callToTransactionReqCall( + call, + chainId, + senderAddress, + new TokenStateManager(), + ); + + expect(loggerSpy).toHaveBeenCalled(); + expect(result).toStrictEqual({ + contractAddress: call.contractAddress, + calldata: call.calldata, + entrypoint: call.entrypoint, + }); + }); + + it('returns a formatted `call` object with `tokenTransferData` if ERC20 transfer calldata is present', async () => { + const { senderAddress, recipientAddress } = await getSenderAndRecipient(); + const transferAmt = '1000'; + const call = { + ...singleCall.calls, + entrypoint: 'transfer', + calldata: [recipientAddress, transferAmt], + }; + const token = ETHER_SEPOLIA_TESTNET; + + const { getTokenSpy } = await mockGetToken(token); + + const result = await callToTransactionReqCall( + call, + chainId, + senderAddress, + new TokenStateManager(), + ); + + expect(getTokenSpy).toHaveBeenCalledWith({ + address: call.contractAddress, + chainId, + }); + expect(result).toStrictEqual({ + contractAddress: call.contractAddress, + calldata: call.calldata, + entrypoint: call.entrypoint, + tokenTransferData: { + senderAddress, + recipientAddress, + amount: transferAmt, + symbol: token.symbol, + decimals: token.decimals, + }, + }); + }); +}); diff --git a/packages/starknet-snap/src/utils/formatter-utils.ts b/packages/starknet-snap/src/utils/formatter-utils.ts index c91e1488..f031fa1f 100644 --- a/packages/starknet-snap/src/utils/formatter-utils.ts +++ b/packages/starknet-snap/src/utils/formatter-utils.ts @@ -1,3 +1,11 @@ +import type { Call } from 'starknet'; +import { assert } from 'superstruct'; + +import type { TokenStateManager } from '../state/token-state-manager'; +import type { FormattedCallData } from '../types/snapState'; +import { logger } from './logger'; +import { AddressStruct, NumberStringStruct } from './superstruct'; + export const hexToString = (hexStr) => { let str = ''; for (let i = 0; i < hexStr.length; i += 2) { @@ -37,3 +45,51 @@ export const mapDeprecatedParams = ( } }); }; + +export const callToTransactionReqCall = async ( + call: Call, + chainId: string, + address: string, + tokenStateManager: TokenStateManager, +): Promise => { + const { contractAddress, calldata, entrypoint } = call; + // Base data object for each call, with transfer fields left as optional + const formattedCall: FormattedCallData = { + contractAddress, + calldata: calldata as string[], + entrypoint, + }; + + // Check if the entrypoint is 'transfer' and the populate transfer fields + if (entrypoint === 'transfer' && calldata) { + try { + const token = await tokenStateManager.getToken({ + address: contractAddress, + chainId, + }); + + if (token) { + const senderAddress = address; + + // ensure the data is in correct format, + // if an error occur, it will catch and not to format it + assert(calldata[0], AddressStruct); + assert(calldata[1], NumberStringStruct); + const recipientAddress = calldata[0]; // Assuming calldata[0] is the recipient address + const amount = calldata[1]; + // Populate transfer-specific fields + formattedCall.tokenTransferData = { + senderAddress, + recipientAddress, + amount: typeof amount === 'number' ? amount.toString() : amount, + symbol: token.symbol, + decimals: token.decimals, + }; + } + } catch (error) { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + logger.warn(`Error in amount conversion: ${error.message}`); + } + } + return formattedCall; +}; diff --git a/packages/starknet-snap/src/utils/snap-ui.ts b/packages/starknet-snap/src/utils/snap-ui.ts index 0aaafacb..eb655ac0 100644 --- a/packages/starknet-snap/src/utils/snap-ui.ts +++ b/packages/starknet-snap/src/utils/snap-ui.ts @@ -1,9 +1,50 @@ -import { divider, heading, row, text } from '@metamask/snaps-sdk'; +import type { Component } from '@metamask/snaps-sdk'; +import { divider, heading, panel, row, text } from '@metamask/snaps-sdk'; import { getExplorerUrl } from './explorer'; import { toJson } from './serializer'; import { shortenAddress } from './string'; +export const updateRequiredMetaMaskComponent = () => { + return panel([ + text( + 'You need to update your MetaMask to latest version to use this snap.', + ), + ]); +}; + +/** + * Ensures that JSX support is available in the MetaMask environment by attempting to render a component within a snap dialog. + * If MetaMask does not support JSX, an alert message is shown prompting the user to update MetaMask. + * + * @param component - The JSX component to display in the snap dialog. + * + * The function performs the following steps: + * 1. Tries to render the provided component using a `snap_dialog` method. + * 2. On success, it updates the `requireMMUpgrade` flag in the snap's state to `false`, indicating that JSX is supported. + * 3. If an error occurs (likely due to outdated MetaMask), it displays an alert dialog prompting the user to update MetaMask. + */ +export const ensureJsxSupport = async (component: Component): Promise => { + try { + // Try rendering the JSX component to test compatibility + await snap.request({ + method: 'snap_dialog', + params: { + type: 'alert', + content: component, + }, + }); + } catch { + await snap.request({ + method: 'snap_dialog', + params: { + type: 'alert', + content: updateRequiredMetaMaskComponent(), + }, + }); + } +}; + /** * Build a row component. * diff --git a/packages/starknet-snap/src/utils/snap.test.ts b/packages/starknet-snap/src/utils/snap.test.ts index 8fa99749..dfbeb7ea 100644 --- a/packages/starknet-snap/src/utils/snap.test.ts +++ b/packages/starknet-snap/src/utils/snap.test.ts @@ -22,6 +22,23 @@ describe('getBip44Deriver', () => { }); }); +describe('createInteractiveConfirmDialog', () => { + it('calls snap_dialog', async () => { + const spy = jest.spyOn(snapUtil.getProvider(), 'request'); + const interfaceId = 'test'; + + await snapUtil.createInteractiveConfirmDialog(interfaceId); + + expect(spy).toHaveBeenCalledWith({ + method: 'snap_dialog', + params: { + type: 'confirmation', + id: interfaceId, + }, + }); + }); +}); + describe('confirmDialog', () => { it('calls snap_dialog', async () => { const spy = jest.spyOn(snapUtil.getProvider(), 'request'); diff --git a/packages/starknet-snap/src/utils/snap.ts b/packages/starknet-snap/src/utils/snap.ts index 49856f57..6d9bb14c 100644 --- a/packages/starknet-snap/src/utils/snap.ts +++ b/packages/starknet-snap/src/utils/snap.ts @@ -29,6 +29,24 @@ export async function getBip44Deriver(): Promise { return getBIP44AddressKeyDeriver(bip44Node); } +/** + * Displays a confirmation dialog with the specified interface id. + * + * @param interfaceId - A string representing the id of the interface. + * @returns A Promise that resolves to the result of the dialog. + */ +export async function createInteractiveConfirmDialog( + interfaceId: string, +): Promise { + return snap.request({ + method: 'snap_dialog', + params: { + type: DialogType.Confirmation, + id: interfaceId, + }, + }); +} + /** * Displays a confirmation dialog with the specified components. * diff --git a/packages/starknet-snap/tsconfig.json b/packages/starknet-snap/tsconfig.json index 0fbd5d41..fc70d5dd 100644 --- a/packages/starknet-snap/tsconfig.json +++ b/packages/starknet-snap/tsconfig.json @@ -2,11 +2,13 @@ "compilerOptions": { "target": "ES2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, "module": "commonjs" /* Specify what module code is generated. */, + "jsx": "react-jsx", + "jsxImportSource": "@metamask/snaps-sdk", "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */, "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, "skipLibCheck": true /* Skip type checking all .d.ts files. */, "resolveJsonModule": true /* lets us import JSON modules from within TypeScript modules. */, "strictNullChecks": true /* Enable strict null checks. */ }, - "include": ["**/*.ts"] + "include": ["**/*.ts", "**/*.tsx"] } diff --git a/yarn.lock b/yarn.lock index 1b021ddb..31e64ef0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2229,10 +2229,12 @@ __metadata: "@metamask/key-tree": 9.0.0 "@metamask/snaps-cli": ^6.2.1 "@metamask/snaps-jest": ^8.2.0 - "@metamask/snaps-sdk": ^4.0.0 + "@metamask/snaps-sdk": ^6.1.1 "@metamask/utils": ^9.1.0 "@types/chai": ^4.3.1 "@types/chai-as-promised": ^7.1.5 + "@types/react": 18.2.4 + "@types/react-dom": 18.2.4 "@types/sinon": ^10.0.11 "@types/sinon-chai": ^3.2.8 "@typescript-eslint/eslint-plugin": ^5.42.1 @@ -4269,6 +4271,17 @@ __metadata: languageName: node linkType: hard +"@metamask/json-rpc-engine@npm:^10.0.1": + version: 10.0.1 + resolution: "@metamask/json-rpc-engine@npm:10.0.1" + dependencies: + "@metamask/rpc-errors": ^7.0.1 + "@metamask/safe-event-emitter": ^3.0.0 + "@metamask/utils": ^10.0.0 + checksum: 277c68cf0036d62c9a1528e9d7e55e000233d02a55fb652edcc16b6149631346d34fe3fefaab13bc55377405e79293afdde5b6e3b61d49a2ce125ca50d7eafe1 + languageName: node + linkType: hard + "@metamask/json-rpc-engine@npm:^8.0.1, @metamask/json-rpc-engine@npm:^8.0.2": version: 8.0.2 resolution: "@metamask/json-rpc-engine@npm:8.0.2" @@ -4315,6 +4328,18 @@ __metadata: languageName: node linkType: hard +"@metamask/json-rpc-middleware-stream@npm:^8.0.5": + version: 8.0.5 + resolution: "@metamask/json-rpc-middleware-stream@npm:8.0.5" + dependencies: + "@metamask/json-rpc-engine": ^10.0.1 + "@metamask/safe-event-emitter": ^3.0.0 + "@metamask/utils": ^10.0.0 + readable-stream: ^3.6.2 + checksum: 4ac3d537bad1ab039bb1b42fb35113fe9a98bd89339155a0f759a086b957e5717ea1e75bdd340defd2b25f5886e07ab130235a63a1b8e627f8cb32a3020622c9 + languageName: node + linkType: hard + "@metamask/key-tree@npm:9.0.0": version: 9.0.0 resolution: "@metamask/key-tree@npm:9.0.0" @@ -4342,6 +4367,19 @@ __metadata: languageName: node linkType: hard +"@metamask/key-tree@npm:^9.1.2": + version: 9.1.2 + resolution: "@metamask/key-tree@npm:9.1.2" + dependencies: + "@metamask/scure-bip39": ^2.1.1 + "@metamask/utils": ^9.0.0 + "@noble/curves": ^1.2.0 + "@noble/hashes": ^1.3.2 + "@scure/base": ^1.0.0 + checksum: eb60bdbfa1806c2f248bf2602cd242e21b0fbe8bbb00ec97c3891739956a81e26c0dae125282a6207dbbe0643e727ff3574067b48210a0b01f12aae7b3159b77 + languageName: node + linkType: hard + "@metamask/number-to-bn@npm:^1.7.1": version: 1.7.1 resolution: "@metamask/number-to-bn@npm:1.7.1" @@ -4445,6 +4483,27 @@ __metadata: languageName: node linkType: hard +"@metamask/providers@npm:^18.1.1": + version: 18.1.1 + resolution: "@metamask/providers@npm:18.1.1" + dependencies: + "@metamask/json-rpc-engine": ^10.0.1 + "@metamask/json-rpc-middleware-stream": ^8.0.5 + "@metamask/object-multiplex": ^2.0.0 + "@metamask/rpc-errors": ^7.0.1 + "@metamask/safe-event-emitter": ^3.1.1 + "@metamask/utils": ^10.0.0 + detect-browser: ^5.2.0 + extension-port-stream: ^4.1.0 + fast-deep-equal: ^3.1.3 + is-stream: ^2.0.0 + readable-stream: ^3.6.2 + peerDependencies: + webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 + checksum: ca28bab03d7b67fd1e4fccf28045bd465a961c946b1f3e2464d6201730ec8c50970eb4a48d373bd3a7ac0bda471da604b71aaf5f22eae3c342a82e5b07134e91 + languageName: node + linkType: hard + "@metamask/rpc-errors@npm:^6.0.0, @metamask/rpc-errors@npm:^6.2.1": version: 6.3.0 resolution: "@metamask/rpc-errors@npm:6.3.0" @@ -4455,6 +4514,16 @@ __metadata: languageName: node linkType: hard +"@metamask/rpc-errors@npm:^7.0.1": + version: 7.0.1 + resolution: "@metamask/rpc-errors@npm:7.0.1" + dependencies: + "@metamask/utils": ^10.0.0 + fast-safe-stringify: ^2.0.6 + checksum: 20b300d26550c667a635eb5f97784c80d86c0b765433a32a9bced5b4c2a05a783cf2cd3a2bfe2aca6382181f53458bd2e7dc1bbb02e28005d3b4d0f3a46ca3ac + languageName: node + linkType: hard + "@metamask/safe-event-emitter@npm:^3.0.0, @metamask/safe-event-emitter@npm:^3.1.1": version: 3.1.1 resolution: "@metamask/safe-event-emitter@npm:3.1.1" @@ -4668,20 +4737,6 @@ __metadata: languageName: node linkType: hard -"@metamask/snaps-sdk@npm:^4.0.0": - version: 4.4.2 - resolution: "@metamask/snaps-sdk@npm:4.4.2" - dependencies: - "@metamask/key-tree": ^9.1.1 - "@metamask/providers": ^17.0.0 - "@metamask/rpc-errors": ^6.2.1 - "@metamask/utils": ^8.3.0 - fast-xml-parser: ^4.3.4 - superstruct: ^1.0.3 - checksum: 2ff3949cee3b6c5a580304a02191f3ec7fb049460c2ff89b1731f24b215baf5f9c08834a0b2b703ff43e3b74ede387386e22a96810b50be106bb029b180c44ce - languageName: node - linkType: hard - "@metamask/snaps-sdk@npm:^6.0.0": version: 6.0.0 resolution: "@metamask/snaps-sdk@npm:6.0.0" @@ -4695,6 +4750,19 @@ __metadata: languageName: node linkType: hard +"@metamask/snaps-sdk@npm:^6.1.1": + version: 6.10.0 + resolution: "@metamask/snaps-sdk@npm:6.10.0" + dependencies: + "@metamask/key-tree": ^9.1.2 + "@metamask/providers": ^18.1.1 + "@metamask/rpc-errors": ^7.0.1 + "@metamask/superstruct": ^3.1.0 + "@metamask/utils": ^10.0.0 + checksum: b389fe350e85d8ce0974ee10c0789ff1daa843efeacec234726de227a02a3937e13cf81d181855c8b00563dc42e519467ac9b5401af40bf601b91c8648302855 + languageName: node + linkType: hard + "@metamask/snaps-utils@npm:^7.0.1, @metamask/snaps-utils@npm:^7.7.0": version: 7.7.0 resolution: "@metamask/snaps-utils@npm:7.7.0" @@ -4752,6 +4820,23 @@ __metadata: languageName: node linkType: hard +"@metamask/utils@npm:^10.0.0": + version: 10.0.1 + resolution: "@metamask/utils@npm:10.0.1" + dependencies: + "@ethereumjs/tx": ^4.2.0 + "@metamask/superstruct": ^3.1.0 + "@noble/hashes": ^1.3.1 + "@scure/base": ^1.1.3 + "@types/debug": ^4.1.7 + debug: ^4.3.4 + pony-cause: ^2.1.10 + semver: ^7.5.4 + uuid: ^9.0.1 + checksum: 4c350c7a1c881c6af446319942392e6eb62411bff9c512166d816d39702c7b4926a982ebfd56ada317f9332a5416b3211c09e022674cee8272228658977ba851 + languageName: node + linkType: hard + "@metamask/utils@npm:^6.0.1": version: 6.2.0 resolution: "@metamask/utils@npm:6.2.0" @@ -4783,6 +4868,23 @@ __metadata: languageName: node linkType: hard +"@metamask/utils@npm:^9.0.0": + version: 9.3.0 + resolution: "@metamask/utils@npm:9.3.0" + dependencies: + "@ethereumjs/tx": ^4.2.0 + "@metamask/superstruct": ^3.1.0 + "@noble/hashes": ^1.3.1 + "@scure/base": ^1.1.3 + "@types/debug": ^4.1.7 + debug: ^4.3.4 + pony-cause: ^2.1.10 + semver: ^7.5.4 + uuid: ^9.0.1 + checksum: f720b0f7bdd46054aa88d15a9702e1de6d7200a1ca1d4f6bc48761b039f1bbffb46ac88bc87fe79e66128c196d424f3b9ef071b3cb4b40139223786d56da35e0 + languageName: node + linkType: hard + "@metamask/utils@npm:^9.1.0": version: 9.1.0 resolution: "@metamask/utils@npm:9.1.0" @@ -7849,6 +7951,15 @@ __metadata: languageName: node linkType: hard +"@types/react-dom@npm:18.2.4": + version: 18.2.4 + resolution: "@types/react-dom@npm:18.2.4" + dependencies: + "@types/react": "*" + checksum: 8301f35cf1cbfec8c723e9477aecf87774e3c168bd457d353b23c45064737213d3e8008b067c6767b7b08e4f2b3823ee239242a6c225fc91e7f8725ef8734124 + languageName: node + linkType: hard + "@types/react-dom@npm:^18.0.0, @types/react-dom@npm:^18.0.5": version: 18.3.0 resolution: "@types/react-dom@npm:18.3.0" @@ -7877,6 +7988,17 @@ __metadata: languageName: node linkType: hard +"@types/react@npm:18.2.4": + version: 18.2.4 + resolution: "@types/react@npm:18.2.4" + dependencies: + "@types/prop-types": "*" + "@types/scheduler": "*" + csstype: ^3.0.2 + checksum: d920fc93832fe50d5e8175a0ba233086c97a9e238ff7327c8319b8dec57409618f491d6f71be2374c3132f40a8fc428b3e406c1e2a5f1dc32ccd6d47051786d2 + languageName: node + linkType: hard + "@types/resolve@npm:1.17.1": version: 1.17.1 resolution: "@types/resolve@npm:1.17.1" @@ -7900,6 +8022,13 @@ __metadata: languageName: node linkType: hard +"@types/scheduler@npm:*": + version: 0.23.0 + resolution: "@types/scheduler@npm:0.23.0" + checksum: 874d753aa65c17760dfc460a91e6df24009bde37bfd427a031577b30262f7770c1b8f71a21366c7dbc76111967384cf4090a31d65315155180ef14bd7acccb32 + languageName: node + linkType: hard + "@types/semver@npm:^7.3.10, @types/semver@npm:^7.3.12": version: 7.5.8 resolution: "@types/semver@npm:7.5.8" @@ -14556,6 +14685,17 @@ __metadata: languageName: node linkType: hard +"extension-port-stream@npm:^4.1.0": + version: 4.2.0 + resolution: "extension-port-stream@npm:4.2.0" + dependencies: + readable-stream: ^3.6.2 || ^4.4.2 + peerDependencies: + webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 + checksum: 85559c82e3f3aa21462e234b30b7d53872708893664cd03f2f848af556cf0730cf2243b089efc9d40bbe9a4f73bd8fd19684db5a985329b0c4402b4f2fe26358 + languageName: node + linkType: hard + "extglob@npm:^2.0.4": version: 2.0.4 resolution: "extglob@npm:2.0.4"