diff --git a/dapps/react-dapp-v2/.env.local.example b/dapps/react-dapp-v2/.env.local.example index 0756f33..2116fd1 100644 --- a/dapps/react-dapp-v2/.env.local.example +++ b/dapps/react-dapp-v2/.env.local.example @@ -1,2 +1,3 @@ -REACT_APP_PROJECT_ID=e899c82be21d4acca2c8aec45e893598 +REACT_APP_PROJECT_ID=39bc93c4affb2e20cb6f8d36ca107dd9 +REACT_APP_RELAY_URL=wss://relay.dev.walletconnect.com diff --git a/dapps/react-dapp-v2/src/HooksApp.tsx b/dapps/react-dapp-v2/src/HooksApp.tsx new file mode 100644 index 0000000..c8e82ee --- /dev/null +++ b/dapps/react-dapp-v2/src/HooksApp.tsx @@ -0,0 +1,865 @@ +import React, { useEffect, useState } from "react"; +import styled from "styled-components"; + +import Client, { CLIENT_EVENTS } from "@walletconnect/client"; +import QRCodeModal from "@walletconnect/legacy-modal"; +import { PairingTypes, SessionTypes } from "@walletconnect/types"; +import { ERROR, getAppMetadata } from "@walletconnect/utils"; +import * as encoding from "@walletconnect/encoding"; +import { apiGetChainNamespace, ChainsMap } from "caip-api"; +import { formatDirectSignDoc, stringifySignDocValues } from "cosmos-wallet"; +import { BigNumber, constants } from "ethers"; + +import Banner from "./components/Banner"; +import Blockchain from "./components/Blockchain"; +import Button from "./components/Button"; +import Column from "./components/Column"; +import Header from "./components/Header"; +import Modal from "./components/Modal"; +import Wrapper from "./components/Wrapper"; +import { + DEFAULT_APP_METADATA, + DEFAULT_MAIN_CHAINS, + DEFAULT_LOGGER, + DEFAULT_EIP155_METHODS, + DEFAULT_COSMOS_METHODS, + DEFAULT_PROJECT_ID, + DEFAULT_RELAY_URL, + DEFAULT_TEST_CHAINS, + DEFAULT_CHAINS, +} from "./constants"; +import { + apiGetAccountAssets, + AccountAction, + eip712, + hashPersonalMessage, + hashTypedDataMessage, + verifySignature, + AccountBalances, + formatTestTransaction, + ChainNamespaces, + setInitialStateTestnet, + getInitialStateTestnet, +} from "./helpers"; +import { fonts } from "./styles"; +import Toggle from "./components/Toggle"; +import RequestModal from "./modals/RequestModal"; +import PairingModal from "./modals/PairingModal"; +import PingModal from "./modals/PingModal"; +import { setUncaughtExceptionCaptureCallback } from "process"; + +const SLayout = styled.div` + position: relative; + width: 100%; + min-height: 100vh; + text-align: center; +`; + +const SContent = styled(Wrapper as any)` + width: 100%; + height: 100%; + padding: 0 16px; +`; + +const SLanding = styled(Column as any)` + /* height: 600px; */ +`; + +const SButtonContainer = styled(Column as any)` + width: 250px; + margin: 50px 0; +`; + +const SConnectButton = styled(Button as any)` + border-radius: 8px; + font-size: ${fonts.size.medium}; + height: 44px; + width: 100%; + margin: 12px 0; +`; + +const SAccountsContainer = styled(SLanding as any)` + height: 100%; + padding-bottom: 30px; + & h3 { + padding-top: 30px; + } +`; + +const SToggleContainer = styled.div` + width: 100%; + display: flex; + justify-content: center; + align-items: center; + margin: 10px auto; + & > p { + margin-right: 10px; + } +`; + +const SFullWidthContainer = styled.div` + width: 100%; + display: flex; + justify-content: center; + align-items: center; + flex-wrap: wrap; +`; + +const SAccounts = styled(SFullWidthContainer)` + justify-content: space-between; + & > div { + margin: 12px 0; + flex: 1 0 100%; + @media (min-width: 648px) { + flex: 0 1 48%; + } + } +`; + +export default function App() { + const [loading, setLoading] = useState(false); + const [pending, setPending] = useState(false); + const [fetching, setFetching] = useState(false); + const [isTestnet, setIsTestnet] = useState(true); + + const [modal, setModal] = useState(""); + const [client, setClient] = useState(); + const [uri, setUri] = useState(""); + const [session, setSession] = useState(); + const [accounts, setAccounts] = useState([]); + const [pairings, setPairings] = useState([]); + const [result, setResult] = useState<{ + method: string; + valid: boolean; + } | null>(); + const [balances, setBalances] = useState({}); + const [chainData, setChainData] = useState({}); + const [chains, setChains] = useState([]); + + const closeModal = () => setModal(""); + const openPairingModal = () => setModal("pairing"); + const openPingModal = () => setModal("ping"); + const openRequestModal = () => setModal("request"); + + const init = async () => { + setLoading(true); + + try { + await loadChainData(); + + const _client = await Client.init({ + logger: DEFAULT_LOGGER, + relayUrl: DEFAULT_RELAY_URL, + projectId: DEFAULT_PROJECT_ID, + }); + setClient(_client); + subscribeToEvents(_client); + await checkPersistedState(_client); + } catch (err) { + throw err; + } finally { + setLoading(false); + } + }; + + useEffect(() => { + init(); + }, []); + + const subscribeToEvents = (_client: Client) => { + if (typeof _client === "undefined") { + throw new Error("WalletConnect is not initialized"); + } + + _client.on(CLIENT_EVENTS.pairing.proposal, async (proposal: PairingTypes.Proposal) => { + const { uri } = proposal.signal.params; + setUri(uri); + console.log("EVENT", "QR Code Modal open"); + QRCodeModal.open(uri, () => { + console.log("EVENT", "QR Code Modal closed"); + }); + }); + + _client.on(CLIENT_EVENTS.pairing.created, async (proposal: PairingTypes.Settled) => { + if (typeof client === "undefined") return; + setPairings(client.pairing.topics); + }); + + _client.on(CLIENT_EVENTS.session.deleted, (deletedSession: SessionTypes.Settled) => { + if (deletedSession.topic !== session?.topic) return; + console.log("EVENT", "session_deleted"); + // TODO: + // this.resetApp(); + }); + }; + + const checkPersistedState = async (_client: Client) => { + if (typeof _client === "undefined") { + throw new Error("WalletConnect is not initialized"); + } + // populates existing pairings to state + setPairings(_client.pairing.topics); + if (typeof session !== "undefined") return; + // populates existing session to state (assume only the top one) + if (_client.session.topics.length) { + const session = await _client.session.get(_client.session.topics[0]); + const chains = session.state.accounts.map(account => + account.split(":").slice(0, -1).join(":"), + ); + setAccounts(session.state.accounts); + setChains(chains); + onSessionConnected(session); + } + }; + + const connect = async (pairing?: { topic: string }) => { + if (typeof client === "undefined") { + throw new Error("WalletConnect is not initialized"); + } + console.log("connect", pairing); + if (modal === "pairing") { + closeModal(); + } + try { + const supportedNamespaces: string[] = []; + chains.forEach(chainId => { + const [namespace] = chainId.split(":"); + if (!supportedNamespaces.includes(namespace)) { + supportedNamespaces.push(namespace); + } + }); + const methods: string[] = supportedNamespaces + .map(namespace => { + switch (namespace) { + case "eip155": + return DEFAULT_EIP155_METHODS; + case "cosmos": + return DEFAULT_COSMOS_METHODS; + default: + throw new Error(`No default methods for namespace: ${namespace}`); + } + }) + .flat(); + const session = await client.connect({ + metadata: getAppMetadata() || DEFAULT_APP_METADATA, + pairing, + permissions: { + blockchain: { + chains, + }, + jsonrpc: { + methods, + }, + }, + }); + + onSessionConnected(session); + } catch (e) { + console.error(e); + // ignore rejection + } + + // close modal in case it was open + QRCodeModal.close(); + }; + + const disconnect = async () => { + if (typeof client === "undefined") { + throw new Error("WalletConnect is not initialized"); + } + if (typeof session === "undefined") { + throw new Error("Session is not connected"); + } + await client.disconnect({ + topic: session.topic, + reason: ERROR.USER_DISCONNECTED.format(), + }); + }; + + const onConnect = () => { + if (typeof client === "undefined") { + throw new Error("WalletConnect is not initialized"); + } + if (client.pairing.topics.length) { + return openPairingModal(); + } + connect(); + }; + + const onSessionConnected = async (incomingSession: SessionTypes.Settled) => { + setSession(incomingSession); + onSessionUpdate(incomingSession.state.accounts, incomingSession.permissions.blockchain.chains); + }; + + const onSessionUpdate = async (accounts: string[], chains: string[]) => { + setChains(chains); + setAccounts(accounts); + await getAccountBalances(); + }; + + 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 getAccountBalances = async () => { + setFetching(true); + try { + const arr = await Promise.all( + accounts.map(async account => { + const [namespace, reference, address] = account.split(":"); + const chainId = `${namespace}:${reference}`; + const assets = await apiGetAccountAssets(address, chainId); + return { account, assets }; + }), + ); + + const balances: AccountBalances = {}; + arr.forEach(({ account, assets }) => { + balances[account] = assets; + }); + setBalances(balances); + } catch (e) { + console.error(e); + } finally { + setFetching(false); + } + }; + + const getAllNamespaces = () => { + const namespaces: string[] = []; + DEFAULT_CHAINS.forEach(chainId => { + const [namespace] = chainId.split(":"); + if (!namespaces.includes(namespace)) { + namespaces.push(namespace); + } + }); + return namespaces; + }; + + const loadChainData = async () => { + const namespaces = getAllNamespaces(); + 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 testSendTransaction = 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 { + // 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 + 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))) { + const formattedResult = { + method: "eth_sendTransaction", + address, + valid: false, + result: "Insufficient funds for intrinsic transaction cost", + }; + setResult(formattedResult); + return; + } + + const result = await client.request({ + topic: session.topic, + chainId, + request: { + method: "eth_sendTransaction", + params: [tx], + }, + }); + + // format displayed result + const formattedResult = { + method: "eth_sendTransaction", + address, + valid: true, + result, + }; + + // display result + setResult(formattedResult); + } catch (e) { + console.error(e); + setResult(null); + } finally { + setPending(false); + } + }; + + const testSignPersonalMessage = 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 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 = 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 + const formattedResult = { + method: "personal_sign", + address, + valid, + result, + }; + + // display result + setResult(formattedResult); + } catch (e) { + console.error(e); + setResult(null); + } finally { + setPending(false); + } + }; + + const testSignTypedData = 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 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 + const formattedResult = { + method: "eth_signTypedData", + address, + valid, + result, + }; + + // display result + setResult(formattedResult); + } catch (e) { + console.error(e); + setResult(null); + } finally { + setPending(false); + } + }; + + 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); + } + }; + + const getEthereumActions = (): AccountAction[] => { + return [ + { method: "eth_sendTransaction", callback: testSendTransaction }, + { method: "personal_sign", callback: testSignPersonalMessage }, + { method: "eth_signTypedData", callback: testSignTypedData }, + ]; + }; + + const getCosmosActions = (): AccountAction[] => { + return [ + { method: "cosmos_signDirect", callback: testSignDirect }, + { method: "cosmos_signAmino", callback: testSignAmino }, + ]; + }; + + const getBlockchainActions = (chainId: string) => { + const [namespace] = chainId.split(":"); + switch (namespace) { + case "eip155": + return getEthereumActions(); + case "cosmos": + return getCosmosActions(); + default: + break; + } + }; + + const toggleTestnets = () => { + const nextIsTestnetState = !isTestnet; + setIsTestnet(nextIsTestnetState); + // TODO: rename "setLocalStorage..." + setInitialStateTestnet(nextIsTestnetState); + }; + + const handleChainSelectionClick = (chainId: string) => { + if (chains.includes(chainId)) { + setChains(chains.filter(chain => chain !== chainId)); + } else { + setChains([...chains, chainId]); + } + }; + + const renderModal = () => { + switch (modal) { + case "pairing": + if (typeof client === "undefined") { + throw new Error("WalletConnect is not initialized"); + } + return ; + case "request": + return ; + case "ping": + return ; + default: + return null; + } + }; + + const renderContent = () => { + const chainOptions = isTestnet ? DEFAULT_TEST_CHAINS : DEFAULT_MAIN_CHAINS; + return !accounts.length && !Object.keys(balances).length ? ( + + +
+ {`Using v${process.env.REACT_APP_VERSION || "2.0.0-beta"}`} +
+ +
Select chains:
+ +

Testnets Only?

+ +
+ {chainOptions.map(chainId => ( + + ))} + + {"Connect"} + +
+
+ ) : ( + +

Accounts

+ + {accounts.map(account => { + const [namespace, reference, address] = account.split(":"); + const chainId = `${namespace}:${reference}`; + return ( + + ); + })} + +
+ ); + }; + + return ( + + +
+ {loading ? "Loading..." : renderContent()} + + + {renderModal()} + + + ); +} diff --git a/dapps/react-dapp-v2/src/index.tsx b/dapps/react-dapp-v2/src/index.tsx index 372a996..5362741 100644 --- a/dapps/react-dapp-v2/src/index.tsx +++ b/dapps/react-dapp-v2/src/index.tsx @@ -3,6 +3,7 @@ import * as ReactDOM from "react-dom"; import { createGlobalStyle } from "styled-components"; import App from "./App"; +import HooksApp from "./HooksApp"; import { globalStyle } from "./styles"; const GlobalStyle = createGlobalStyle` ${globalStyle} @@ -18,7 +19,7 @@ declare global { ReactDOM.render( <> - + , document.getElementById("root"), );