From 540624884e3e0e5d5b40357182b226a98907c225 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Ribo=CC=81?= Date: Wed, 17 Sep 2025 16:07:32 +0200 Subject: [PATCH 01/11] fix: implement UI for multichain playground --- package.json | 3 +- playground/multichain-react/.depcheckrc.json | 13 + playground/multichain-react/.editorconfig | 9 + playground/multichain-react/.env.example | 5 + playground/multichain-react/.gitattributes | 8 + playground/multichain-react/.gitignore | 81 + playground/multichain-react/CHANGELOG.md | 9 + playground/multichain-react/README.md | 42 + playground/multichain-react/craco.config.js | 23 + playground/multichain-react/index.js | 3 + playground/multichain-react/package.json | 101 + playground/multichain-react/postcss.config.js | 6 + .../multichain-react/public/alt-fox.svg | 1 + playground/multichain-react/public/index.html | 13 + playground/multichain-react/public/robots.txt | 3 + playground/multichain-react/scripts/get.sh | 24 + .../multichain-react/scripts/prepack.sh | 11 + playground/multichain-react/src/App.test.tsx | 9 + playground/multichain-react/src/App.tsx | 126 + .../src/components/DynamicInputs.tsx | 62 + .../src/components/FeaturedNetworks.tsx | 41 + .../src/components/ScopeCard.tsx | 395 ++++ .../src/components/WalletList.tsx | 69 + .../multichain-react/src/constants/index.ts | 3 + .../multichain-react/src/constants/methods.ts | 167 ++ .../src/constants/networks.ts | 20 + .../src/helpers/AddressHelpers.ts | 14 + .../multichain-react/src/helpers/IdHelpers.ts | 9 + .../src/helpers/JsonHelpers.ts | 40 + .../src/helpers/MethodInvocationHelpers.ts | 152 ++ .../src/helpers/solana-method-signatures.ts | 127 + playground/multichain-react/src/index.css | 27 + playground/multichain-react/src/index.tsx | 12 + playground/multichain-react/src/logo.svg | 1 + playground/multichain-react/src/sdk/index.ts | 1 + playground/multichain-react/src/sdk/useSDK.ts | 105 + playground/multichain-react/src/setupTests.ts | 6 + .../multichain-react/tailwind.config.js | 10 + .../multichain-react/tsconfig.build.json | 24 + playground/multichain-react/tsconfig.json | 18 + yarn.lock | 2059 ++++++++++++++++- 41 files changed, 3786 insertions(+), 66 deletions(-) create mode 100644 playground/multichain-react/.depcheckrc.json create mode 100644 playground/multichain-react/.editorconfig create mode 100644 playground/multichain-react/.env.example create mode 100644 playground/multichain-react/.gitattributes create mode 100644 playground/multichain-react/.gitignore create mode 100644 playground/multichain-react/CHANGELOG.md create mode 100644 playground/multichain-react/README.md create mode 100644 playground/multichain-react/craco.config.js create mode 100644 playground/multichain-react/index.js create mode 100644 playground/multichain-react/package.json create mode 100644 playground/multichain-react/postcss.config.js create mode 100644 playground/multichain-react/public/alt-fox.svg create mode 100644 playground/multichain-react/public/index.html create mode 100644 playground/multichain-react/public/robots.txt create mode 100755 playground/multichain-react/scripts/get.sh create mode 100755 playground/multichain-react/scripts/prepack.sh create mode 100644 playground/multichain-react/src/App.test.tsx create mode 100644 playground/multichain-react/src/App.tsx create mode 100644 playground/multichain-react/src/components/DynamicInputs.tsx create mode 100644 playground/multichain-react/src/components/FeaturedNetworks.tsx create mode 100644 playground/multichain-react/src/components/ScopeCard.tsx create mode 100644 playground/multichain-react/src/components/WalletList.tsx create mode 100644 playground/multichain-react/src/constants/index.ts create mode 100644 playground/multichain-react/src/constants/methods.ts create mode 100644 playground/multichain-react/src/constants/networks.ts create mode 100644 playground/multichain-react/src/helpers/AddressHelpers.ts create mode 100644 playground/multichain-react/src/helpers/IdHelpers.ts create mode 100644 playground/multichain-react/src/helpers/JsonHelpers.ts create mode 100644 playground/multichain-react/src/helpers/MethodInvocationHelpers.ts create mode 100644 playground/multichain-react/src/helpers/solana-method-signatures.ts create mode 100644 playground/multichain-react/src/index.css create mode 100644 playground/multichain-react/src/index.tsx create mode 100644 playground/multichain-react/src/logo.svg create mode 100644 playground/multichain-react/src/sdk/index.ts create mode 100644 playground/multichain-react/src/sdk/useSDK.ts create mode 100644 playground/multichain-react/src/setupTests.ts create mode 100644 playground/multichain-react/tailwind.config.js create mode 100644 playground/multichain-react/tsconfig.build.json create mode 100644 playground/multichain-react/tsconfig.json diff --git a/package.json b/package.json index 96230109d..8016b9992 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "packages/deve2e", "packages/playground-next", "packages/sdk-multichain", - "playground/multichain-node" + "playground/multichain-node", + "playground/multichain-react" ], "scripts": { "build": "yarn install && cd packages/sdk-socket-server-next && yarn install && cd ../.. && yarn workspaces foreach --verbose run build:pre-tsc && yarn workspaces foreach --verbose --topological --parallel --no-private run build && yarn workspaces foreach --verbose run build:post-tsc ", diff --git a/playground/multichain-react/.depcheckrc.json b/playground/multichain-react/.depcheckrc.json new file mode 100644 index 000000000..1b814a9e6 --- /dev/null +++ b/playground/multichain-react/.depcheckrc.json @@ -0,0 +1,13 @@ +{ + "ignores": [ + "@lavamoat/allow-scripts", + "@lavamoat/preinstall-always-fail", + "@metamask/auto-changelog", + "@types/*", + "@yarnpkg/types", + "prettier-plugin-packagejson", + "ts-node", + "typedoc", + "buffer" + ] +} diff --git a/playground/multichain-react/.editorconfig b/playground/multichain-react/.editorconfig new file mode 100644 index 000000000..c6c8b3621 --- /dev/null +++ b/playground/multichain-react/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/playground/multichain-react/.env.example b/playground/multichain-react/.env.example new file mode 100644 index 000000000..83481f084 --- /dev/null +++ b/playground/multichain-react/.env.example @@ -0,0 +1,5 @@ +# Helius RPC API Configuration +# Optional: Helius RPC API key for enhanced Solana RPC performance +# If not provided, the app will fall back to public Solana RPC endpoints +# Get your API key from: https://www.helius.dev/ +REACT_APP_HELIUS_API_KEY=your_helius_api_key_here diff --git a/playground/multichain-react/.gitattributes b/playground/multichain-react/.gitattributes new file mode 100644 index 000000000..524151a23 --- /dev/null +++ b/playground/multichain-react/.gitattributes @@ -0,0 +1,8 @@ +* text=auto + +yarn.lock linguist-generated=false + +# yarn v3 +# See: https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored +/.yarn/releases/** binary +/.yarn/plugins/** binary diff --git a/playground/multichain-react/.gitignore b/playground/multichain-react/.gitignore new file mode 100644 index 000000000..112a181e6 --- /dev/null +++ b/playground/multichain-react/.gitignore @@ -0,0 +1,81 @@ +.DS_Store +dist/ +coverage/ +docs/ +.env + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v3 (w/o zero-install) +# See: https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +build/ diff --git a/playground/multichain-react/CHANGELOG.md b/playground/multichain-react/CHANGELOG.md new file mode 100644 index 000000000..3cab0227d --- /dev/null +++ b/playground/multichain-react/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +[Unreleased]: https://github.com/MetaMask/metamask-sdk/ diff --git a/playground/multichain-react/README.md b/playground/multichain-react/README.md new file mode 100644 index 000000000..4a1163f30 --- /dev/null +++ b/playground/multichain-react/README.md @@ -0,0 +1,42 @@ +# MetaMask Test Dapp Multichain + +A test dapp for the MetaMask Multichain API. + +## Installation + +`yarn` + +## Configuration + +### Environment Variables + +This project supports optional environment variables for enhanced functionality: + +#### Helius RPC API Key (Optional) +For improved Solana RPC performance, you can configure a Helius API key: + +1. Copy the example environment file: + ```bash + cp .env.example .env.local + ``` + +2. Get a free API key from [Helius](https://www.helius.dev/) + +3. Add your API key to `.env.local`: + ``` + REACT_APP_HELIUS_API_KEY=your_actual_api_key_here + ``` + +**Note:** If no Helius API key is provided, the app will automatically fall back to public Solana RPC endpoints. + +## Usage + +To start the development server: + +`yarn start` + +This will launch the test dapp, allowing you to interact with the MetaMask Multichain API. + +## Contributing + +#### This project uses the [MetaMask Module Template](https://github.com/MetaMask/metamask-module-template) diff --git a/playground/multichain-react/craco.config.js b/playground/multichain-react/craco.config.js new file mode 100644 index 000000000..c9fb4250e --- /dev/null +++ b/playground/multichain-react/craco.config.js @@ -0,0 +1,23 @@ +import webpack from 'webpack'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); + +export default { + webpack: { + configure: (webpackConfig) => { + webpackConfig.resolve.fallback = { + ...webpackConfig.resolve.fallback, + buffer: require.resolve('buffer'), + process: require.resolve('process/browser'), + }; + webpackConfig.plugins.push( + new webpack.ProvidePlugin({ + Buffer: ['buffer', 'Buffer'], + process: 'process/browser', + }), + ); + return webpackConfig; + }, + }, +}; diff --git a/playground/multichain-react/index.js b/playground/multichain-react/index.js new file mode 100644 index 000000000..6f0c2f260 --- /dev/null +++ b/playground/multichain-react/index.js @@ -0,0 +1,3 @@ +throw new Error( + 'This package is published to npm so that the static files can be hosted locally for testing. This package cannot be run directly.', +); diff --git a/playground/multichain-react/package.json b/playground/multichain-react/package.json new file mode 100644 index 000000000..bbcd42432 --- /dev/null +++ b/playground/multichain-react/package.json @@ -0,0 +1,101 @@ +{ + "name": "@metamask/sdk-multichain-react", + "publishConfig": { + "access": "restricted", + "registry": "https://registry.npmjs.org/" + }, + "version": "0.15.0", + "description": "A test dapp for multichain api", + "homepage": "https://metamask.github.io/test-dapp-multichain", + "bugs": { + "url": "https://github.com/MetaMask/test-dapp-multichain/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/test-dapp-multichain.git" + }, + "exports": { + ".": "./index.js", + "./package.json": "./package.json" + }, + "main": "./index.js", + "files": [ + "build/" + ], + "scripts": { + "build": "DISABLE_ESLINT_PLUGIN=true craco build", + "eject": "react-scripts eject", + "lint": "yarn biome lint ./src", + "lint:ci": "yarn biome ci ./src", + "lint:fix": "yarn biome format --write ./src", + "lint:changelog": "../../scripts/validate-changelog.sh @metamask/multichain-sdk", + "prepack": "./scripts/prepack.sh", + "start": "DISABLE_ESLINT_PLUGIN=true craco start", + "test": "echo 'no tests'", + "test:ci": "echo 'no tests'" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@biomejs/biome": "2.0.0", + "@craco/craco": "^7.1.0", + "@lavamoat/allow-scripts": "^3.0.4", + "@lavamoat/preinstall-always-fail": "^2.0.0", + "@metamask/api-specs": "^0.14.0", + "@metamask/auto-changelog": "^3.4.3", + "@metamask/utils": "^11.0.0", + "@open-rpc/meta-schema": "^1.14.9", + "@open-rpc/schema-utils-js": "^2.0.5", + "@solana/web3.js": "^1.98.0", + "@testing-library/jest-dom": "^5.17.0", + "@testing-library/react": "^13.4.0", + "@types/chrome": "^0.0.279", + "@types/jest": "^28.1.6", + "@types/node": "^18.18", + "@types/react": "^19.1.8", + "@yarnpkg/types": "^4.0.0-rc.52", + "autoprefixer": "^10.4.21", + "buffer": "^6.0.3", + "depcheck": "^1.4.3", + "jest": "^28.1.3", + "postcss": "^8.5.6", + "prettier": "^2.7.1", + "prettier-plugin-packagejson": "^2.3.0", + "process": "^0.11.10", + "react-scripts": "5.0.1", + "tailwindcss": "^4.1.11", + "ts-node": "^10.7.0", + "typescript": "~4.8.4" + }, + "packageManager": "yarn@4.1.1", + "engines": { + "node": "^18.18 || >=20" + }, + "lavamoat": { + "allowScripts": { + "@lavamoat/preinstall-always-fail": false, + "react-scripts>@pmmmwh/react-refresh-webpack-plugin>core-js-pure": false, + "react-scripts>react-app-polyfill>core-js": false, + "@solana/web3.js>bigint-buffer": false, + "@solana/web3.js>rpc-websockets>bufferutil": false, + "@solana/web3.js>rpc-websockets>utf-8-validate": false, + "@metamask/multichain-sdk>@metamask/mobile-wallet-protocol-dapp-client>@metamask/mobile-wallet-protocol-core>centrifuge>protobufjs": false, + "@metamask/multichain-sdk>@metamask/mobile-wallet-protocol-core>centrifuge>protobufjs": false + } + }, + "dependencies": { + "@metamask/multichain-sdk": "workspace:^", + "react": "^18.3.1", + "react-dom": "^18.3.1" + } +} diff --git a/playground/multichain-react/postcss.config.js b/playground/multichain-react/postcss.config.js new file mode 100644 index 000000000..33ad091d2 --- /dev/null +++ b/playground/multichain-react/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/playground/multichain-react/public/alt-fox.svg b/playground/multichain-react/public/alt-fox.svg new file mode 100644 index 000000000..c2eeab825 --- /dev/null +++ b/playground/multichain-react/public/alt-fox.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/playground/multichain-react/public/index.html b/playground/multichain-react/public/index.html new file mode 100644 index 000000000..38bff9820 --- /dev/null +++ b/playground/multichain-react/public/index.html @@ -0,0 +1,13 @@ + + + + + + + Multichain Test Dapp + + + +
+ + diff --git a/playground/multichain-react/public/robots.txt b/playground/multichain-react/public/robots.txt new file mode 100644 index 000000000..e9e57dc4d --- /dev/null +++ b/playground/multichain-react/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/playground/multichain-react/scripts/get.sh b/playground/multichain-react/scripts/get.sh new file mode 100755 index 000000000..9c988bb8a --- /dev/null +++ b/playground/multichain-react/scripts/get.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +set -e +set -u +set -o pipefail + +if [[ ${RUNNER_DEBUG:-0} == 1 ]]; then + set -x +fi + +KEY="${1}" +OUTPUT="${2}" + +if [[ -z $KEY ]]; then + echo "Error: KEY not specified." + exit 1 +fi + +if [[ -z $OUTPUT ]]; then + echo "Error: OUTPUT not specified." + exit 1 +fi + +echo "$OUTPUT=$(jq --raw-output "$KEY" package.json)" >> "$GITHUB_OUTPUT" diff --git a/playground/multichain-react/scripts/prepack.sh b/playground/multichain-react/scripts/prepack.sh new file mode 100755 index 000000000..ad99af58d --- /dev/null +++ b/playground/multichain-react/scripts/prepack.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +set -e +set -o pipefail + +if [[ -n $SKIP_PREPACK ]]; then + echo "Notice: skipping prepack." + exit 0 +fi + +yarn build diff --git a/playground/multichain-react/src/App.test.tsx b/playground/multichain-react/src/App.test.tsx new file mode 100644 index 000000000..8fdbf8048 --- /dev/null +++ b/playground/multichain-react/src/App.test.tsx @@ -0,0 +1,9 @@ +import { render, screen } from '@testing-library/react'; + +import App from './App'; + +test('renders learn react link', () => { + render(); + const linkElement = screen.getByText(/MetaMask MultiChain/iu); + expect(linkElement).toBeInTheDocument(); +}); diff --git a/playground/multichain-react/src/App.tsx b/playground/multichain-react/src/App.tsx new file mode 100644 index 000000000..ddb0e7952 --- /dev/null +++ b/playground/multichain-react/src/App.tsx @@ -0,0 +1,126 @@ +import React, { useId } from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import type { Scope, SessionData } from '@metamask/multichain-sdk'; +import type { CaipAccountId } from '@metamask/utils'; +import { METAMASK_PROD_CHROME_ID } from './constants'; +import { useSDK } from './sdk'; +import DynamicInputs, { INPUT_LABEL_TYPE } from './components/DynamicInputs'; +import { FEATURED_NETWORKS } from './constants/networks'; +import { ScopeCard } from './components/ScopeCard'; +import { Buffer } from 'buffer'; + +global.Buffer = Buffer; + +function App() { + const id = useId(); + const [customScopes, setCustomScopes] = useState(['eip155:1']); + const [caipAccountIds, setCaipAccountIds] = useState([]); + const [extensionId, setExtensionId] = useState(METAMASK_PROD_CHROME_ID); + + const { + isConnected, + session, + connect: sdkConnect, + disconnect: sdkDisconnect, + } = useSDK({ + extensionId, + }); + + useEffect(() => { + if (session) { + const scopes = Object.keys(session.sessionScopes); + setCustomScopes(scopes); + + // Accumulate all accounts from all scopes + const allAccounts: CaipAccountId[] = []; + for (const scope of scopes) { + const { accounts } = session.sessionScopes[scope as keyof typeof session.sessionScopes] ?? {}; + if (accounts) { + allAccounts.push(...accounts); + } + } + setCaipAccountIds(allAccounts); + } + }, [session]); + + // Check if current scope selection differs from connected session scopes + const scopesHaveChanged = () => { + if (!session) return false; + const sessionScopes = Object.keys(session.sessionScopes); + const currentScopes = customScopes.filter((scope) => scope.length); + + if (sessionScopes.length !== currentScopes.length) return true; + + return !sessionScopes.every((scope) => currentScopes.includes(scope)) || !currentScopes.every((scope) => sessionScopes.includes(scope)); + }; + + const connect = async () => { + try { + const selectedScopesArray = customScopes.filter((scope) => scope.length); + const filteredAccountIds = caipAccountIds.filter((addr) => addr.trim() !== ''); + await sdkConnect(selectedScopesArray as Scope[], filteredAccountIds as CaipAccountId[]); + } catch (error) { + console.error('Error creating session:', error); + } + }; + + const disconnect = useCallback(async () => { + await sdkDisconnect(); + }, [sdkDisconnect]); + + const availableOptions = Object.keys(FEATURED_NETWORKS).reduce<{ name: string; value: string }[]>((all, networkName) => { + const networkCaipValue = FEATURED_NETWORKS[networkName as keyof typeof FEATURED_NETWORKS]; + return [...all, { name: networkName, value: networkCaipValue }]; + }, []); + + return ( +
+
+

MetaMask MultiChain API Test Dapp

+
+ +
+ +
+ {(!isConnected || scopesHaveChanged()) && ( + + )} + {isConnected && ( + + )} +
+
+ {Object.keys(session?.sessionScopes ?? {}).length > 0 && ( +
+

Connected Networks

+
+ {Object.entries(session?.sessionScopes ?? {}).map(([scope, details]) => { + return ; + })} +
+
+ )} +
+
+
+ ); +} + +export default App; diff --git a/playground/multichain-react/src/components/DynamicInputs.tsx b/playground/multichain-react/src/components/DynamicInputs.tsx new file mode 100644 index 000000000..f2344365f --- /dev/null +++ b/playground/multichain-react/src/components/DynamicInputs.tsx @@ -0,0 +1,62 @@ +/* eslint-disable */ + +import type React from 'react'; +import { useCallback } from 'react'; + +export enum INPUT_LABEL_TYPE { + ADDRESS = 'Address', + SCOPE = 'Scope', + CAIP_ACCOUNT_ID = 'CAIP Address', +} + +type DynamicInputsProps = { + inputArray: string[]; + availableOptions: { name: string; value: string }[]; + setInputArray: React.Dispatch>; + label: INPUT_LABEL_TYPE; +}; + +const DynamicInputs: React.FC = ({ inputArray, setInputArray, label, availableOptions }) => { + const handleCheckboxChange = useCallback( + (value: string, isChecked: boolean) => { + if (isChecked) { + // Add to array if not already present + setInputArray((prev) => Array.from(new Set([...prev, value]))); + } else { + // Remove from array + setInputArray((prev) => prev.filter((item) => item !== value)); + } + }, + [setInputArray], + ); + + return ( +
+

{label}s:

+
+ {availableOptions.map((option) => { + const isChecked = inputArray.includes(option.value); + return ( + + ); + })} +
+ {inputArray.length > 0 && ( +
+ Selected: {inputArray.length} {label.toLowerCase()} + {inputArray.length !== 1 ? 's' : ''} +
+ )} +
+ ); +}; + +export default DynamicInputs; diff --git a/playground/multichain-react/src/components/FeaturedNetworks.tsx b/playground/multichain-react/src/components/FeaturedNetworks.tsx new file mode 100644 index 000000000..41fd3650c --- /dev/null +++ b/playground/multichain-react/src/components/FeaturedNetworks.tsx @@ -0,0 +1,41 @@ +/* eslint-disable */ +import type { Scope } from '@metamask/multichain-sdk'; +// biome-ignore lint/style/useImportType: +import React from 'react'; + +import { FEATURED_NETWORKS } from '../constants/networks'; +import { escapeHtmlId } from '../helpers/IdHelpers'; + +type FeaturedNetworksProps = { + selectedScopes: Record; + setSelectedScopes: React.Dispatch>>; + isExternallyConnectableConnected: boolean; +}; + +export const FeaturedNetworks: React.FC = ({ selectedScopes, setSelectedScopes, isExternallyConnectableConnected }) => { + const featuredNetworks = Object.entries(FEATURED_NETWORKS); + return ( +
+ {featuredNetworks.map(([networkName, chainId]) => ( + + ))} +
+ ); +}; diff --git a/playground/multichain-react/src/components/ScopeCard.tsx b/playground/multichain-react/src/components/ScopeCard.tsx new file mode 100644 index 000000000..4ae13c056 --- /dev/null +++ b/playground/multichain-react/src/components/ScopeCard.tsx @@ -0,0 +1,395 @@ +import MetaMaskOpenRPCDocument from '@metamask/api-specs'; +import type { Scope, SessionData } from '@metamask/multichain-sdk'; +import { type CaipAccountId, type CaipChainId, type CaipAccountAddress, parseCaipAccountId, type Json } from '@metamask/utils'; +import type { OpenrpcDocument, MethodObject } from '@open-rpc/meta-schema'; +import { useState, useCallback } from 'react'; +import { METHODS_REQUIRING_PARAM_INJECTION, injectParams } from '../constants/methods'; +import { getNetworkName } from '../constants/networks'; +import { escapeHtmlId } from '../helpers/IdHelpers'; +import { openRPCExampleToJSON, truncateJSON } from '../helpers/JsonHelpers'; +import { generateSolanaMethodExamples } from '../helpers/solana-method-signatures'; +import { extractRequestForStorage, extractRequestParams, normalizeMethodParams, updateInvokeMethodResults } from '../helpers/MethodInvocationHelpers'; +import { useSDK } from '../sdk'; + +const metamaskOpenrpcDocument: OpenrpcDocument = MetaMaskOpenRPCDocument; + +export function ScopeCard({ scope, details }: { scope: Scope; details: SessionData['sessionScopes'][Scope] }) { + const { accounts } = details; + + const setInitialMethodsAndAccounts = useCallback((currentSession: any) => { + const initialSelectedMethods: Record = {}; + const initialSelectedAccounts: Record = {}; + const initialInvokeMethodRequests: Record = {}; + + Object.entries(currentSession.sessionScopes).forEach(([scope, details]: [string, any]) => { + if (details.accounts?.[0]) { + initialSelectedAccounts[scope] = details.accounts[0]; + } + + const getInvokeMethodRequest = (request: unknown) => ({ + method: 'wallet_invokeMethod', + params: { + scope, + request, + }, + }); + + if (scope.startsWith('eip155:')) { + initialSelectedMethods[scope] = 'eth_blockNumber'; + const example = metamaskOpenrpcDocument?.methods.find((method) => (method as MethodObject).name === 'eth_blockNumber'); + const request = openRPCExampleToJSON(example as MethodObject); + const invokeMethodRequest = getInvokeMethodRequest(request); + initialInvokeMethodRequests[scope] = JSON.stringify(invokeMethodRequest, null, 2); + } + }); + setInvokeMethodRequests(initialInvokeMethodRequests); + setSelectedMethods(initialSelectedMethods); + setSelectedAccounts(initialSelectedAccounts); + }, []); + + const handleSessionChangedNotification = useCallback( + // biome-ignore lint/suspicious/noExplicitAny: + (notification: any) => { + if (notification.params?.sessionScopes) { + setInitialMethodsAndAccounts({ + sessionScopes: notification.params.sessionScopes, + }); + } + }, + [setInitialMethodsAndAccounts], + ); + const { invokeMethod } = useSDK({ onSessionChanged: handleSessionChangedNotification }); + + const [invokeMethodResults, setInvokeMethodResults] = useState>>({}); + const [selectedAccount, setSelectedAccount] = useState(accounts?.length ? accounts[0] : undefined); + const [invokeMethodRequests, setInvokeMethodRequests] = useState>({}); + const [selectedAccounts, setSelectedAccounts] = useState>({ + [scope]: accounts?.length ? (accounts[0] ?? null) : null, + }); + const [selectedMethods, setSelectedMethods] = useState>({}); + + const networkName = getNetworkName(scope); + const accountCount = accounts?.length ?? 0; + + const handleUpdateInvokeMethodSolana = async (scope: CaipChainId, address: CaipAccountAddress, method: string) => { + if (!scope.startsWith('solana:')) { + throw new Error('Invalid CAIP chain ID. It must start with "solana:"'); + } + + const solanaExample = await generateSolanaMethodExamples(method, address); + + const defaultRequest = { + method: 'wallet_invokeMethod', + params: { + scope, + request: { + method, + ...solanaExample, + }, + }, + }; + + setInvokeMethodRequests((prev) => ({ + ...prev, + [scope]: JSON.stringify(defaultRequest, null, 2), + })); + }; + const handleMethodSelect = async (evt: React.ChangeEvent, scope: CaipChainId) => { + const selectedMethod = evt.target.value; + setSelectedMethods((prev) => ({ + ...prev, + [scope]: selectedMethod, + })); + + const selectedAddress = selectedAccounts[scope]; + if (!selectedAddress) { + return; + } + + if (scope.startsWith('solana:')) { + await handleUpdateInvokeMethodSolana(scope, parseCaipAccountId(selectedAddress).address, selectedMethod); + } else { + const example = metamaskOpenrpcDocument?.methods.find((method) => (method as MethodObject).name === selectedMethod); + if (example) { + let exampleParams: Json = openRPCExampleToJSON(example as MethodObject); + + if (selectedMethod in METHODS_REQUIRING_PARAM_INJECTION) { + exampleParams = injectParams(selectedMethod, exampleParams, selectedAddress, scope); + } + + const defaultRequest = { + method: 'wallet_invokeMethod', + params: { + scope, + request: exampleParams, + }, + }; + + setInvokeMethodRequests((prev) => ({ + ...prev, + [scope]: JSON.stringify(defaultRequest, null, 2), + })); + } + } + }; + + const handleInvokeMethod = async (scope: Scope, method: string, requestObject?: any) => { + console.log(`🔧 handleInvokeMethod called: ${method} on ${scope}`); + + // Handle missing request gracefully + const scopeRequest = invokeMethodRequests[scope]; + if (!requestObject && !scopeRequest) { + throw new Error(`No request configured for method ${method} on scope ${scope}`); + } + + const finalRequestObject = requestObject ?? JSON.parse(scopeRequest ?? '{}'); + console.log(`📋 Request object:`, finalRequestObject); + + try { + // Extract and normalize parameters + const params = extractRequestParams(finalRequestObject); + console.log(`📤 Calling invokeMethod with params:`, params); + + const paramsArray = normalizeMethodParams(method, params); + console.log(`📤 Normalized params array:`, paramsArray); + + const result = await invokeMethod({ + scope, + request: { + method, + params: paramsArray, + }, + }); + + console.log(`📥 Received result:`, result); + + const request = extractRequestForStorage(finalRequestObject); + setInvokeMethodResults((prev) => { + const newResults = updateInvokeMethodResults(prev, scope, method, result as Json, request); + console.log(`💾 Updated invoke results:`, newResults); + return newResults; + }); + } catch (error) { + console.error('❌ Error invoking method:', error); + + const request = extractRequestForStorage(finalRequestObject); + setInvokeMethodResults((prev) => { + const newResults = updateInvokeMethodResults(prev, scope, method, error as Error, request); + console.log(`💾 Updated invoke results (error):`, newResults); + return newResults; + }); + } + }; + return ( +
+
+

+ {networkName} +

+
+ +
+
+ Accounts: + {accountCount} available +
+ + +
+ + {selectedAccount && ( +
+

Active Account:

+

{parseCaipAccountId(selectedAccount).address}

+
+ )} + +
+
+ Available Methods: + {details.methods?.length ?? 0} available +
+ + +
+ + {selectedMethods[scope] && ( +
+

Selected Method:

+

{selectedMethods[scope]}

+
+ )} + +
+ + + Invoke Method + + + Invoke Method Request + +
+
+ +
+