From 7eeefd4f654aedee8d6cc08ced86a94ca5ff4cc5 Mon Sep 17 00:00:00 2001 From: Ben Kremer Date: Fri, 4 Feb 2022 20:48:43 +0100 Subject: [PATCH] refactor: creates standalone JsonRpcContext --- dapps/react-dapp-v2/src/HooksApp.tsx | 447 ++--------------- .../src/contexts/JsonRpcContext.tsx | 453 ++++++++++++++++++ dapps/react-dapp-v2/src/index.tsx | 5 +- 3 files changed, 489 insertions(+), 416 deletions(-) create mode 100644 dapps/react-dapp-v2/src/contexts/JsonRpcContext.tsx diff --git a/dapps/react-dapp-v2/src/HooksApp.tsx b/dapps/react-dapp-v2/src/HooksApp.tsx index 67470df..8622e27 100644 --- a/dapps/react-dapp-v2/src/HooksApp.tsx +++ b/dapps/react-dapp-v2/src/HooksApp.tsx @@ -1,9 +1,6 @@ -import React, { useEffect, useState } from "react"; +import React, { useState } from "react"; -import * as encoding from "@walletconnect/encoding"; -import { apiGetChainNamespace, ChainsMap } from "caip-api"; -import { formatDirectSignDoc, stringifySignDocValues } from "cosmos-wallet"; -import { BigNumber } from "ethers"; +// import { formatDirectSignDoc, stringifySignDocValues } from "cosmos-wallet"; import Banner from "./components/Banner"; import Blockchain from "./components/Blockchain"; @@ -11,18 +8,7 @@ import Column from "./components/Column"; import Header from "./components/Header"; import Modal from "./components/Modal"; import { DEFAULT_MAIN_CHAINS, DEFAULT_TEST_CHAINS } from "./constants"; -import { - AccountAction, - eip712, - hashPersonalMessage, - hashTypedDataMessage, - verifySignature, - formatTestTransaction, - ChainNamespaces, - setInitialStateTestnet, - getInitialStateTestnet, - getAllChainNamespaces, -} from "./helpers"; +import { AccountAction, setInitialStateTestnet, getInitialStateTestnet } from "./helpers"; import Toggle from "./components/Toggle"; import RequestModal from "./modals/RequestModal"; import PairingModal from "./modals/PairingModal"; @@ -38,27 +24,13 @@ import { SToggleContainer, } from "./components/app"; import { useWalletConnectClient } from "./contexts/ClientContext"; - -interface FormattedRpcResponse { - method: string; - address: string; - valid: boolean; - result: string; -} +import { useJsonRpc } from "./contexts/JsonRpcContext"; export default function App() { - const [pending, setPending] = useState(false); const [isTestnet, setIsTestnet] = useState(getInitialStateTestnet()); const [modal, setModal] = useState(""); - const [result, setResult] = useState<{ - method: string; - valid: boolean; - } | null>(); - - const [chainData, setChainData] = useState({}); - const closeModal = () => setModal(""); const openPairingModal = () => setModal("pairing"); const openPingModal = () => setModal("ping"); @@ -77,9 +49,15 @@ export default function App() { setChains, } = useWalletConnectClient(); - useEffect(() => { - loadChainData(); - }, []); + const { + chainData, + ping, + testSendTransaction, + testSignPersonalMessage, + testSignTypedData, + isRpcRequestPending, + rpcResult, + } = useJsonRpc(); const onConnect = () => { if (typeof client === "undefined") { @@ -91,399 +69,38 @@ export default function App() { connect(); }; - const loadChainData = async () => { - const namespaces = getAllChainNamespaces(); - const chainData: ChainNamespaces = {}; - await Promise.all( - namespaces.map(async namespace => { - let chains: ChainsMap | undefined; - try { - chains = await apiGetChainNamespace(namespace); - } catch (e) { - // ignore error - } - if (typeof chains !== "undefined") { - chainData[namespace] = chains; - } - }), - ); - setChainData(chainData); + const onPing = async () => { + openPingModal(); + await ping(); }; - // ----- EVM RPC ----- - - const createJsonRpcRequestHandler = - (rpcRequest: (...requestArgs: [any]) => Promise) => - async (chainId: string) => { - if (typeof client === "undefined") { - throw new Error("WalletConnect is not initialized"); - } - if (typeof session === "undefined") { - throw new Error("Session is not connected"); - } - - try { - setPending(true); - const result = await rpcRequest(chainId); - setResult(result); - } catch (err) { - console.error(err); - setResult(null); - } finally { - setPending(false); - } - }; - - const ping = async () => { - if (typeof client === "undefined") { - throw new Error("WalletConnect is not initialized"); - } - if (typeof session === "undefined") { - throw new Error("Session is not connected"); - } - - try { - setPending(true); - openPingModal(); - - let valid = false; - - try { - await client.session.ping(session.topic); - valid = true; - } catch (e) { - valid = false; - } - - // display result - setResult({ - method: "ping", - valid, - }); - } catch (e) { - console.error(e); - setResult(null); - } finally { - setPending(false); - } + const onSendTransaction = async (chainId: string) => { + openRequestModal(); + await testSendTransaction(chainId); }; - const testSendTransaction = createJsonRpcRequestHandler(async (chainId: string) => { - // get ethereum address - const account = accounts.find(account => account.startsWith(chainId)); - if (account === undefined) throw new Error("Account is not found"); - const address = account.split(":").pop(); - if (address === undefined) throw new Error("Address is invalid"); - - // open modal + const onSignPersonalMessage = async (chainId: string) => { openRequestModal(); - - const tx = await formatTestTransaction(account); - - const balance = BigNumber.from(balances[account][0].balance || "0"); - if (balance.lt(BigNumber.from(tx.gasPrice).mul(tx.gasLimit))) { - return { - method: "eth_sendTransaction", - address, - valid: false, - result: "Insufficient funds for intrinsic transaction cost", - }; - } - - const result: string = await client!.request({ - topic: session!.topic, - chainId, - request: { - method: "eth_sendTransaction", - params: [tx], - }, - }); - - // format displayed result - return { - method: "eth_sendTransaction", - address, - valid: true, - result, - }; - }); - - const testSignPersonalMessage = createJsonRpcRequestHandler(async (chainId: string) => { - // test message - const message = `My email is john@doe.com - ${Date.now()}`; - - // encode message (hex) - const hexMsg = encoding.utf8ToHex(message, true); - - // get ethereum address - const account = accounts.find(account => account.startsWith(chainId)); - if (account === undefined) throw new Error("Account is not found"); - const address = account.split(":").pop(); - if (address === undefined) throw new Error("Address is invalid"); - - // personal_sign params - const params = [hexMsg, address]; - - // open modal - openRequestModal(); - - // send message - const result: string = await client!.request({ - topic: session!.topic, - chainId, - request: { - method: "personal_sign", - params, - }, - }); - - // split chainId - const [namespace, reference] = chainId.split(":"); - - const targetChainData = chainData[namespace][reference]; - - if (typeof targetChainData === "undefined") { - throw new Error(`Missing chain data for chainId: ${chainId}`); - } - - const rpcUrl = targetChainData.rpc[0]; - - // verify signature - const hash = hashPersonalMessage(message); - const valid = await verifySignature(address, result, hash, rpcUrl); - - // format displayed result - return { - method: "personal_sign", - address, - valid, - result, - }; - }); - - const testSignTypedData = createJsonRpcRequestHandler(async (chainId: string) => { - // test message - const message = JSON.stringify(eip712.example); - - // get ethereum address - const account = accounts.find(account => account.startsWith(chainId)); - if (account === undefined) throw new Error("Account is not found"); - const address = account.split(":").pop(); - if (address === undefined) throw new Error("Address is invalid"); - - // eth_signTypedData params - const params = [address, message]; - - // open modal - openRequestModal(); - - // send message - const result = await client!.request({ - topic: session!.topic, - chainId, - request: { - method: "eth_signTypedData", - params, - }, - }); - - // split chainId - const [namespace, reference] = chainId.split(":"); - - const targetChainData = chainData[namespace][reference]; - - if (typeof targetChainData === "undefined") { - throw new Error(`Missing chain data for chainId: ${chainId}`); - } - - const rpcUrl = targetChainData.rpc[0]; - - // verify signature - const hash = hashTypedDataMessage(message); - const valid = await verifySignature(address, result, hash, rpcUrl); - - // format displayed result - return { - method: "eth_signTypedData", - address, - valid, - result, - }; - }); - - // ------ COSMOS RPC ------ - - const testSignDirect = async (chainId: string) => { - if (typeof client === "undefined") { - throw new Error("WalletConnect is not initialized"); - } - if (typeof session === "undefined") { - throw new Error("Session is not connected"); - } - - try { - // test direct sign doc inputs - const inputs = { - fee: [{ amount: "2000", denom: "ucosm" }], - pubkey: "AgSEjOuOr991QlHCORRmdE5ahVKeyBrmtgoYepCpQGOW", - gasLimit: 200000, - accountNumber: 1, - sequence: 1, - bodyBytes: - "0a90010a1c2f636f736d6f732e62616e6b2e763162657461312e4d736753656e6412700a2d636f736d6f7331706b707472653766646b6c366766727a6c65736a6a766878686c63337234676d6d6b38727336122d636f736d6f7331717970717870713971637273737a673270767871367273307a716733797963356c7a763778751a100a0575636f736d120731323334353637", - authInfoBytes: - "0a500a460a1f2f636f736d6f732e63727970746f2e736563703235366b312e5075624b657912230a21034f04181eeba35391b858633a765c4a0c189697b40d216354d50890d350c7029012040a020801180112130a0d0a0575636f736d12043230303010c09a0c", - }; - - // split chainId - const [namespace, reference] = chainId.split(":"); - - // format sign doc - const signDoc = formatDirectSignDoc( - inputs.fee, - inputs.pubkey, - inputs.gasLimit, - inputs.accountNumber, - inputs.sequence, - inputs.bodyBytes, - reference, - ); - - // get cosmos address - const account = accounts.find(account => account.startsWith(chainId)); - if (account === undefined) throw new Error("Account is not found"); - const address = account.split(":").pop(); - if (address === undefined) throw new Error("Address is invalid"); - - // cosmos_signDirect params - const params = { - signerAddress: address, - signDoc: stringifySignDocValues(signDoc), - }; - - // open modal - openRequestModal(); - - // send message - const result = await client.request({ - topic: session.topic, - chainId, - request: { - method: "cosmos_signDirect", - params, - }, - }); - - const targetChainData = chainData[namespace][reference]; - - if (typeof targetChainData === "undefined") { - throw new Error(`Missing chain data for chainId: ${chainId}`); - } - - // TODO: check if valid - const valid = true; - - // format displayed result - const formattedResult = { - method: "cosmos_signDirect", - address, - valid, - result: result.signature.signature, - }; - - // display result - setResult(formattedResult); - } catch (e) { - console.error(e); - setResult(null); - } finally { - setPending(false); - } + await testSignPersonalMessage(chainId); }; - const testSignAmino = async (chainId: string) => { - if (typeof client === "undefined") { - throw new Error("WalletConnect is not initialized"); - } - if (typeof session === "undefined") { - throw new Error("Session is not connected"); - } - - try { - // split chainId - const [namespace, reference] = chainId.split(":"); - - // test amino sign doc - const signDoc = { - msgs: [], - fee: { amount: [], gas: "23" }, - chain_id: "foochain", - memo: "hello, world", - account_number: "7", - sequence: "54", - }; - - // get cosmos address - const account = accounts.find(account => account.startsWith(chainId)); - if (account === undefined) throw new Error("Account is not found"); - const address = account.split(":").pop(); - if (address === undefined) throw new Error("Address is invalid"); - - // cosmos_signAmino params - const params = { signerAddress: address, signDoc }; - - // open modal - openRequestModal(); - - // send message - const result = await client.request({ - topic: session.topic, - chainId, - request: { - method: "cosmos_signAmino", - params, - }, - }); - - const targetChainData = chainData[namespace][reference]; - - if (typeof targetChainData === "undefined") { - throw new Error(`Missing chain data for chainId: ${chainId}`); - } - - // TODO: check if valid - const valid = true; - - // format displayed result - const formattedResult = { - method: "cosmos_signAmino", - address, - valid, - result: result.signature.signature, - }; - - // display result - setResult(formattedResult); - } catch (e) { - console.error(e); - setResult(null); - } finally { - setPending(false); - } + const onSignTypedData = async (chainId: string) => { + openRequestModal(); + await testSignTypedData(chainId); }; const getEthereumActions = (): AccountAction[] => { return [ - { method: "eth_sendTransaction", callback: testSendTransaction }, - { method: "personal_sign", callback: testSignPersonalMessage }, - { method: "eth_signTypedData", callback: testSignTypedData }, + { method: "eth_sendTransaction", callback: onSendTransaction }, + { method: "personal_sign", callback: onSignPersonalMessage }, + { method: "eth_signTypedData", callback: onSignTypedData }, ]; }; const getCosmosActions = (): AccountAction[] => { return [ - { method: "cosmos_signDirect", callback: testSignDirect }, - { method: "cosmos_signAmino", callback: testSignAmino }, + // { method: "cosmos_signDirect", callback: testSignDirect }, + // { method: "cosmos_signAmino", callback: testSignAmino }, ]; }; @@ -522,9 +139,9 @@ export default function App() { } return ; case "request": - return ; + return ; case "ping": - return ; + return ; default: return null; } @@ -586,7 +203,7 @@ export default function App() { return ( -
+
{loading ? "Loading..." : renderContent()} diff --git a/dapps/react-dapp-v2/src/contexts/JsonRpcContext.tsx b/dapps/react-dapp-v2/src/contexts/JsonRpcContext.tsx new file mode 100644 index 0000000..857f1e9 --- /dev/null +++ b/dapps/react-dapp-v2/src/contexts/JsonRpcContext.tsx @@ -0,0 +1,453 @@ +import { BigNumber } from "ethers"; +import { createContext, ReactNode, useContext, useEffect, useState } from "react"; +import * as encoding from "@walletconnect/encoding"; + +import { + ChainNamespaces, + eip712, + formatTestTransaction, + getAllChainNamespaces, + hashPersonalMessage, + hashTypedDataMessage, + verifySignature, +} from "../helpers"; +import { useWalletConnectClient } from "./ClientContext"; +import { apiGetChainNamespace, ChainsMap } from "caip-api"; + +/** + * Types + */ +interface IFormattedRpcResponse { + method: string; + address: string; + valid: boolean; + result: string; +} + +interface IRpcResult { + method: string; + valid: boolean; +} + +interface IContext { + ping: () => Promise; + testSendTransaction: (chainId: string) => Promise; + testSignPersonalMessage: (chainId: string) => Promise; + testSignTypedData: (chainId: string) => Promise; + chainData: ChainNamespaces; + rpcResult?: IRpcResult | null; + isRpcRequestPending: boolean; +} + +/** + * Context + */ +export const JsonRpcContext = createContext({} as IContext); + +/** + * Provider + */ +export function JsonRpcContextProvider({ children }: { children: ReactNode | ReactNode[] }) { + const [pending, setPending] = useState(false); + const [result, setResult] = useState(); + const [chainData, setChainData] = useState({}); + + const { client, session, accounts, balances } = useWalletConnectClient(); + + useEffect(() => { + loadChainData(); + }, []); + + const loadChainData = async () => { + const namespaces = getAllChainNamespaces(); + const chainData: ChainNamespaces = {}; + await Promise.all( + namespaces.map(async namespace => { + let chains: ChainsMap | undefined; + try { + chains = await apiGetChainNamespace(namespace); + } catch (e) { + // ignore error + } + if (typeof chains !== "undefined") { + chainData[namespace] = chains; + } + }), + ); + setChainData(chainData); + }; + + const _createJsonRpcRequestHandler = + (rpcRequest: (...requestArgs: [any]) => Promise) => + async (chainId: string) => { + if (typeof client === "undefined") { + throw new Error("WalletConnect is not initialized"); + } + if (typeof session === "undefined") { + throw new Error("Session is not connected"); + } + + try { + setPending(true); + const result = await rpcRequest(chainId); + setResult(result); + } catch (err) { + console.error(err); + setResult(null); + } finally { + setPending(false); + } + }; + + const ping = async () => { + if (typeof client === "undefined") { + throw new Error("WalletConnect is not initialized"); + } + if (typeof session === "undefined") { + throw new Error("Session is not connected"); + } + + try { + setPending(true); + + let valid = false; + + try { + await client.session.ping(session.topic); + valid = true; + } catch (e) { + valid = false; + } + + // display result + setResult({ + method: "ping", + valid, + }); + } catch (e) { + console.error(e); + setResult(null); + } finally { + setPending(false); + } + }; + + const testSendTransaction = _createJsonRpcRequestHandler(async (chainId: string) => { + // get ethereum address + const account = accounts.find(account => account.startsWith(chainId)); + if (account === undefined) throw new Error("Account is not found"); + const address = account.split(":").pop(); + if (address === undefined) throw new Error("Address is invalid"); + + const tx = await formatTestTransaction(account); + + const balance = BigNumber.from(balances[account][0].balance || "0"); + if (balance.lt(BigNumber.from(tx.gasPrice).mul(tx.gasLimit))) { + return { + method: "eth_sendTransaction", + address, + valid: false, + result: "Insufficient funds for intrinsic transaction cost", + }; + } + + const result: string = await client!.request({ + topic: session!.topic, + chainId, + request: { + method: "eth_sendTransaction", + params: [tx], + }, + }); + + // format displayed result + return { + method: "eth_sendTransaction", + address, + valid: true, + result, + }; + }); + + const testSignPersonalMessage = _createJsonRpcRequestHandler(async (chainId: string) => { + // test message + const message = `My email is john@doe.com - ${Date.now()}`; + + // encode message (hex) + const hexMsg = encoding.utf8ToHex(message, true); + + // get ethereum address + const account = accounts.find(account => account.startsWith(chainId)); + if (account === undefined) throw new Error("Account is not found"); + const address = account.split(":").pop(); + if (address === undefined) throw new Error("Address is invalid"); + + // personal_sign params + const params = [hexMsg, address]; + + // send message + const result: string = await client!.request({ + topic: session!.topic, + chainId, + request: { + method: "personal_sign", + params, + }, + }); + + // split chainId + const [namespace, reference] = chainId.split(":"); + + const targetChainData = chainData[namespace][reference]; + + if (typeof targetChainData === "undefined") { + throw new Error(`Missing chain data for chainId: ${chainId}`); + } + + const rpcUrl = targetChainData.rpc[0]; + + // verify signature + const hash = hashPersonalMessage(message); + const valid = await verifySignature(address, result, hash, rpcUrl); + + // format displayed result + return { + method: "personal_sign", + address, + valid, + result, + }; + }); + + const testSignTypedData = _createJsonRpcRequestHandler(async (chainId: string) => { + // test message + const message = JSON.stringify(eip712.example); + + // get ethereum address + const account = accounts.find(account => account.startsWith(chainId)); + if (account === undefined) throw new Error("Account is not found"); + const address = account.split(":").pop(); + if (address === undefined) throw new Error("Address is invalid"); + + // eth_signTypedData params + const params = [address, message]; + + // send message + const result = await client!.request({ + topic: session!.topic, + chainId, + request: { + method: "eth_signTypedData", + params, + }, + }); + + // split chainId + const [namespace, reference] = chainId.split(":"); + + const targetChainData = chainData[namespace][reference]; + + if (typeof targetChainData === "undefined") { + throw new Error(`Missing chain data for chainId: ${chainId}`); + } + + const rpcUrl = targetChainData.rpc[0]; + + // verify signature + const hash = hashTypedDataMessage(message); + const valid = await verifySignature(address, result, hash, rpcUrl); + + // format displayed result + return { + method: "eth_signTypedData", + address, + valid, + result, + }; + }); + + return ( + + {children} + + ); +} + +export function useJsonRpc() { + const context = useContext(JsonRpcContext); + if (context === undefined) { + throw new Error("useJsonRpc must be used within a JsonRpcContextProvider"); + } + return context; +} + +// ------ COSMOS RPC ------ + +// const testSignDirect = async (chainId: string) => { +// if (typeof client === "undefined") { +// throw new Error("WalletConnect is not initialized"); +// } +// if (typeof session === "undefined") { +// throw new Error("Session is not connected"); +// } + +// try { +// // test direct sign doc inputs +// const inputs = { +// fee: [{ amount: "2000", denom: "ucosm" }], +// pubkey: "AgSEjOuOr991QlHCORRmdE5ahVKeyBrmtgoYepCpQGOW", +// gasLimit: 200000, +// accountNumber: 1, +// sequence: 1, +// bodyBytes: +// "0a90010a1c2f636f736d6f732e62616e6b2e763162657461312e4d736753656e6412700a2d636f736d6f7331706b707472653766646b6c366766727a6c65736a6a766878686c63337234676d6d6b38727336122d636f736d6f7331717970717870713971637273737a673270767871367273307a716733797963356c7a763778751a100a0575636f736d120731323334353637", +// authInfoBytes: +// "0a500a460a1f2f636f736d6f732e63727970746f2e736563703235366b312e5075624b657912230a21034f04181eeba35391b858633a765c4a0c189697b40d216354d50890d350c7029012040a020801180112130a0d0a0575636f736d12043230303010c09a0c", +// }; + +// // split chainId +// const [namespace, reference] = chainId.split(":"); + +// // format sign doc +// const signDoc = formatDirectSignDoc( +// inputs.fee, +// inputs.pubkey, +// inputs.gasLimit, +// inputs.accountNumber, +// inputs.sequence, +// inputs.bodyBytes, +// reference, +// ); + +// // get cosmos address +// const account = accounts.find(account => account.startsWith(chainId)); +// if (account === undefined) throw new Error("Account is not found"); +// const address = account.split(":").pop(); +// if (address === undefined) throw new Error("Address is invalid"); + +// // cosmos_signDirect params +// const params = { +// signerAddress: address, +// signDoc: stringifySignDocValues(signDoc), +// }; + +// // open modal +// openRequestModal(); + +// // send message +// const result = await client.request({ +// topic: session.topic, +// chainId, +// request: { +// method: "cosmos_signDirect", +// params, +// }, +// }); + +// const targetChainData = chainData[namespace][reference]; + +// if (typeof targetChainData === "undefined") { +// throw new Error(`Missing chain data for chainId: ${chainId}`); +// } + +// // TODO: check if valid +// const valid = true; + +// // format displayed result +// const formattedResult = { +// method: "cosmos_signDirect", +// address, +// valid, +// result: result.signature.signature, +// }; + +// // display result +// setResult(formattedResult); +// } catch (e) { +// console.error(e); +// setResult(null); +// } finally { +// setPending(false); +// } +// }; + +// const testSignAmino = async (chainId: string) => { +// if (typeof client === "undefined") { +// throw new Error("WalletConnect is not initialized"); +// } +// if (typeof session === "undefined") { +// throw new Error("Session is not connected"); +// } + +// try { +// // split chainId +// const [namespace, reference] = chainId.split(":"); + +// // test amino sign doc +// const signDoc = { +// msgs: [], +// fee: { amount: [], gas: "23" }, +// chain_id: "foochain", +// memo: "hello, world", +// account_number: "7", +// sequence: "54", +// }; + +// // get cosmos address +// const account = accounts.find(account => account.startsWith(chainId)); +// if (account === undefined) throw new Error("Account is not found"); +// const address = account.split(":").pop(); +// if (address === undefined) throw new Error("Address is invalid"); + +// // cosmos_signAmino params +// const params = { signerAddress: address, signDoc }; + +// // open modal +// openRequestModal(); + +// // send message +// const result = await client.request({ +// topic: session.topic, +// chainId, +// request: { +// method: "cosmos_signAmino", +// params, +// }, +// }); + +// const targetChainData = chainData[namespace][reference]; + +// if (typeof targetChainData === "undefined") { +// throw new Error(`Missing chain data for chainId: ${chainId}`); +// } + +// // TODO: check if valid +// const valid = true; + +// // format displayed result +// const formattedResult = { +// method: "cosmos_signAmino", +// address, +// valid, +// result: result.signature.signature, +// }; + +// // display result +// setResult(formattedResult); +// } catch (e) { +// console.error(e); +// setResult(null); +// } finally { +// setPending(false); +// } +// }; diff --git a/dapps/react-dapp-v2/src/index.tsx b/dapps/react-dapp-v2/src/index.tsx index 535ba1b..3de3bd7 100644 --- a/dapps/react-dapp-v2/src/index.tsx +++ b/dapps/react-dapp-v2/src/index.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import * as ReactDOM from "react-dom"; import { createGlobalStyle } from "styled-components"; import { ClientContextProvider } from "./contexts/ClientContext"; +import { JsonRpcContextProvider } from "./contexts/JsonRpcContext"; import HooksApp from "./HooksApp"; import { globalStyle } from "./styles"; @@ -20,7 +21,9 @@ ReactDOM.render( <> - + + + , document.getElementById("root"),