refactor(App): gets core functionality running via react hooks

This commit is contained in:
Ben Kremer 2022-02-03 18:03:25 +01:00
parent 5301fd10e4
commit 6b040dbfe1
3 changed files with 869 additions and 2 deletions

View File

@ -1,2 +1,3 @@
REACT_APP_PROJECT_ID=e899c82be21d4acca2c8aec45e893598 REACT_APP_PROJECT_ID=39bc93c4affb2e20cb6f8d36ca107dd9
REACT_APP_RELAY_URL=wss://relay.dev.walletconnect.com

View File

@ -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<Client>();
const [uri, setUri] = useState("");
const [session, setSession] = useState<SessionTypes.Created>();
const [accounts, setAccounts] = useState<string[]>([]);
const [pairings, setPairings] = useState<string[]>([]);
const [result, setResult] = useState<{
method: string;
valid: boolean;
} | null>();
const [balances, setBalances] = useState<AccountBalances>({});
const [chainData, setChainData] = useState<ChainNamespaces>({});
const [chains, setChains] = useState<string[]>([]);
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 <PairingModal pairings={client.pairing.values} connect={connect} />;
case "request":
return <RequestModal pending={pending} result={result} />;
case "ping":
return <PingModal pending={pending} result={result} />;
default:
return null;
}
};
const renderContent = () => {
const chainOptions = isTestnet ? DEFAULT_TEST_CHAINS : DEFAULT_MAIN_CHAINS;
return !accounts.length && !Object.keys(balances).length ? (
<SLanding center>
<Banner />
<h6>
<span>{`Using v${process.env.REACT_APP_VERSION || "2.0.0-beta"}`}</span>
</h6>
<SButtonContainer>
<h6>Select chains:</h6>
<SToggleContainer>
<p>Testnets Only?</p>
<Toggle active={isTestnet} onClick={toggleTestnets} />
</SToggleContainer>
{chainOptions.map(chainId => (
<Blockchain
key={chainId}
chainId={chainId}
chainData={chainData}
onClick={handleChainSelectionClick}
active={chains.includes(chainId)}
/>
))}
<SConnectButton left onClick={onConnect} fetching={fetching} disabled={!chains.length}>
{"Connect"}
</SConnectButton>
</SButtonContainer>
</SLanding>
) : (
<SAccountsContainer>
<h3>Accounts</h3>
<SAccounts>
{accounts.map(account => {
const [namespace, reference, address] = account.split(":");
const chainId = `${namespace}:${reference}`;
return (
<Blockchain
key={account}
active={true}
chainData={chainData}
fetching={fetching}
address={address}
chainId={chainId}
balances={balances}
// TODO:
actions={getBlockchainActions(chainId)}
/>
);
})}
</SAccounts>
</SAccountsContainer>
);
};
return (
<SLayout>
<Column maxWidth={1000} spanHeight>
<Header ping={ping} disconnect={disconnect} session={session} />
<SContent>{loading ? "Loading..." : renderContent()}</SContent>
</Column>
<Modal show={!!modal} closeModal={closeModal}>
{renderModal()}
</Modal>
</SLayout>
);
}

View File

@ -3,6 +3,7 @@ import * as ReactDOM from "react-dom";
import { createGlobalStyle } from "styled-components"; import { createGlobalStyle } from "styled-components";
import App from "./App"; import App from "./App";
import HooksApp from "./HooksApp";
import { globalStyle } from "./styles"; import { globalStyle } from "./styles";
const GlobalStyle = createGlobalStyle` const GlobalStyle = createGlobalStyle`
${globalStyle} ${globalStyle}
@ -18,7 +19,7 @@ declare global {
ReactDOM.render( ReactDOM.render(
<> <>
<GlobalStyle /> <GlobalStyle />
<App /> <HooksApp />
</>, </>,
document.getElementById("root"), document.getElementById("root"),
); );