Implement persisting session (#5)

* Add layout to pages

* Persist session

* Style sign with ethereum page

* Refactor modal

* Remove unused styles

* Implement disconnect session functionality

* Add info about wallet in navbar

* Remove unused imports

* Use canonical JSON

* Add line

* Remove buffer from sign with ethereum page

* Display signed message in dialog

* Show modal on signature

* Format cosmos signature

* Add code block style for json messages

* Display signature in first modal

* Add urbit logo for connect wallet page

* Display message for sign with ethereum

* Handle review changes

* Keep icon and text on same line

---------

Co-authored-by: neeraj <neeraj.rtly@gmail.com>
Co-authored-by: Adw8 <adwait@deepstacksoft.com>
This commit is contained in:
Adwait Gharpure 2024-03-22 11:52:53 +05:30 committed by GitHub
parent 5e98e2e25a
commit 6bdaf60ff4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 396 additions and 188 deletions

View File

@ -20,6 +20,7 @@
"@walletconnect/types": "^2.11.3", "@walletconnect/types": "^2.11.3",
"assert": "^2.1.0", "assert": "^2.1.0",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"canonical-json": "^0.0.4",
"notistack": "^3.0.1", "notistack": "^3.0.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",

View File

@ -1,18 +1,21 @@
import React from "react"; import React from "react";
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import ConnectWallet from "./pages/ConnectWallet"; import ConnectWallet from "./pages/ConnectWallet";
import SignWithEthereum from "./pages/SignWithEthereum"; import SignWithEthereum from "./pages/SignWithEthereum";
import SignWithCosmos from "./pages/SignWithCosmos"; import SignWithCosmos from "./pages/SignWithCosmos";
import PageNotFound from "./pages/PageNotFound"; import PageNotFound from "./pages/PageNotFound";
import SignPageLayout from "./layout/SignPageLayout";
function App() { function App() {
return ( return (
<Router> <Router>
<Routes> <Routes>
<Route path="/" element={<ConnectWallet />} /> <Route path="/" element={<ConnectWallet />} />
<Route path="/sign-with-ethereum" element={<SignWithEthereum />} /> <Route element={<SignPageLayout />} >
<Route path="/sign-with-cosmos/:ethAddress/:cosmosAddress/:ethSignature" element={<SignWithCosmos />} /> <Route path="/sign-with-ethereum" element={<SignWithEthereum />} />
<Route path="/sign-with-cosmos/:ethAddress/:cosmosAddress/:ethSignature" element={<SignWithCosmos />} />
</Route>
<Route path="*" element={<PageNotFound />} /> <Route path="*" element={<PageNotFound />} />
</Routes> </Routes>
</Router> </Router>

View File

@ -5,11 +5,13 @@ import React, {
ReactNode, ReactNode,
useState, useState,
useEffect, useEffect,
useCallback,
} from "react"; } from "react";
import SignClient from "@walletconnect/sign-client"; import SignClient from "@walletconnect/sign-client";
import { WalletConnectModal } from "@walletconnect/modal"; import { WalletConnectModal } from "@walletconnect/modal";
import { SessionTypes } from "@walletconnect/types"; import { SessionTypes } from "@walletconnect/types";
import { getSdkError } from "@walletconnect/utils";
const PROJECT_ID = process.env.REACT_APP_WALLET_CONNECT_ID; const PROJECT_ID = process.env.REACT_APP_WALLET_CONNECT_ID;
assert(PROJECT_ID, "Wallet connect project id not provided"); assert(PROJECT_ID, "Wallet connect project id not provided");
@ -18,12 +20,16 @@ interface ContextValue {
connect: () => Promise<void>; connect: () => Promise<void>;
session: SessionTypes.Struct | null; session: SessionTypes.Struct | null;
signClient: SignClient | undefined; signClient: SignClient | undefined;
checkPersistedState: (client: SignClient)=> Promise<void>
disconnect: () => Promise<void>;
} }
const walletConnectContext = createContext<ContextValue>({ const walletConnectContext = createContext<ContextValue>({
connect: () => Promise.resolve(), connect: () => Promise.resolve(),
session: null, session: null,
signClient: undefined, signClient: undefined,
checkPersistedState: ()=> Promise.resolve(),
disconnect: () => Promise.resolve(),
}); });
const web3Modal = new WalletConnectModal({ const web3Modal = new WalletConnectModal({
@ -40,7 +46,28 @@ export const WalletConnectProvider = ({
const [signClient, setSignClient] = useState<SignClient>(); const [signClient, setSignClient] = useState<SignClient>();
const [session, setSession] = useState<SessionTypes.Struct | null>(null); const [session, setSession] = useState<SessionTypes.Struct | null>(null);
const createClient = async () => { const disconnect = useCallback(async () => {
if (signClient && session) {
await signClient.disconnect({
topic: session.topic,
reason: getSdkError("USER_DISCONNECTED"),
});
}
setSession(null)
}, [signClient, session]);
const checkPersistedState = useCallback(async(client: SignClient)=>{
if (client.session.length) {
const lastKeyIndex = client.session.keys.length - 1;
const session = client.session.get(
client.session.keys[lastKeyIndex]
);
setSession(session)
}
}, [])
const createClient = useCallback(async () => {
const signClient = await SignClient.init({ const signClient = await SignClient.init({
projectId: PROJECT_ID, projectId: PROJECT_ID,
metadata: { metadata: {
@ -52,7 +79,8 @@ export const WalletConnectProvider = ({
}); });
setSignClient(signClient); setSignClient(signClient);
}; await checkPersistedState(signClient)
}, [checkPersistedState])
const connect = async () => { const connect = async () => {
if (!signClient) { if (!signClient) {
@ -88,7 +116,7 @@ export const WalletConnectProvider = ({
if (!signClient) { if (!signClient) {
createClient(); createClient();
} }
}, [signClient]); }, [signClient, createClient]);
return ( return (
<walletConnectContext.Provider <walletConnectContext.Provider
@ -96,6 +124,8 @@ export const WalletConnectProvider = ({
connect, connect,
session, session,
signClient, signClient,
checkPersistedState,
disconnect
}} }}
> >
{children} {children}
@ -108,11 +138,15 @@ export const useWalletConnectContext = () => {
connect, connect,
session, session,
signClient, signClient,
checkPersistedState,
disconnect
} = useContext(walletConnectContext); } = useContext(walletConnectContext);
return { return {
connect, connect,
session, session,
signClient, signClient,
checkPersistedState,
disconnect
}; };
}; };

1
src/decs.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare module 'canonical-json'

View File

@ -0,0 +1,76 @@
import React from "react";
import { Outlet, useNavigate } from "react-router-dom";
import {
Toolbar,
IconButton,
Avatar,
Button,
Typography,
Container,
} from "@mui/material";
import { useWalletConnectContext } from "../context/WalletConnectContext";
const SignPageLayout = () => {
const { disconnect, session } = useWalletConnectContext();
const navigate = useNavigate();
const disconnectHandler = async () => {
await disconnect();
navigate("/");
};
return (
<>
<Toolbar variant="dense">
<Avatar
alt="Urbit logo"
src="https://avatars.githubusercontent.com/u/5237680?s=200&v=4"
/>
<IconButton
edge="start"
color="inherit"
aria-label="menu"
sx={{ mr: 2 }}
>
Urbit
</IconButton>
<Button
variant="outlined"
style={{
marginLeft: "auto",
}}
onClick={disconnectHandler}
>
Disconnect
</Button>
</Toolbar>
<Container maxWidth="md">
{session && (
<div style={{ display: "flex", flexDirection: "column" }}>
<div style={{ display: "flex", flexDirection: "row", alignItems: 'flex-end' }}>
<Typography variant="body2">
Connected to: <b> {session.peer.metadata.name}</b>{" "}
</Typography>
<Avatar
variant="square"
alt="Peer logo"
src={session.peer.metadata.icons[0]}
sx={{ width: 20, height: 20, marginLeft: 1, paddingBottom: 0.5 }}
/>
</div>
<Typography variant="body2">
Session ID: <b>{session.topic} </b>
</Typography>
</div>
)}
<Outlet />
</Container>
</>
);
};
export default SignPageLayout;

View File

@ -1,22 +1,46 @@
import React from 'react'; import React from 'react';
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Typography, Button, Box, Grid, Avatar } from '@mui/material';
import { useWalletConnectContext } from "../context/WalletConnectContext"; import { useWalletConnectContext } from "../context/WalletConnectContext";
// TODO: Connect wallet should not be accessible if session already exists
const ConnectWallet = () => { const ConnectWallet = () => {
const { connect } = useWalletConnectContext(); const { connect, session } = useWalletConnectContext();
const navigate = useNavigate() const navigate = useNavigate()
if (session) {
navigate("/sign-with-ethereum")
}
const handler = async () => { const handler = async () => {
await connect(); await connect();
navigate("/sign-with-ethereum") navigate("/sign-with-ethereum")
} }
return ( return (
<div> <Grid container spacing={2}>
<h1>Connect wallet </h1> <Grid item xs={2}>
<button onClick={handler}>Connect</button> </Grid>
</div> <Grid item xs={8}>
<Box display="flex" flexDirection="column" alignItems="center" height="50vh" justifyContent="center" padding={5}>
<Box display="flex" alignItems="center">
<Avatar alt="Urbit logo" src="https://avatars.githubusercontent.com/u/5237680?s=200&v=4" />
<Typography variant="h4" component="h6">
Urbit Onboarding
</Typography>
</Box>
<Typography variant="h6" component="h6" style={{ marginTop: '30px' }}>
Connect wallet
</Typography>
<Button variant="contained" onClick={handler} style={{ marginTop: '20px' }}>
Connect
</Button>
</Box>
</Grid>
<Grid item xs={2}>
</Grid>
</Grid>
); );
}; };

View File

@ -1,44 +1,59 @@
import React, { useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { SnackbarProvider, enqueueSnackbar } from "notistack"; import { SnackbarProvider, enqueueSnackbar } from "notistack";
import canonicalStringify from "canonical-json";
import { Modal, Button, Typography, Box } from "@mui/material"; import {
import Alert from "@mui/material/Alert"; Button,
import CheckIcon from "@mui/icons-material/Check"; Dialog,
DialogContent,
DialogActions,
Box,
Typography,
} from "@mui/material";
import { useWalletConnectContext } from "../context/WalletConnectContext"; import { useWalletConnectContext } from "../context/WalletConnectContext";
const style = {
position: "absolute" as const,
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
overflow: "scroll",
bgcolor: "background.paper",
border: "2px solid #000",
boxShadow: 24,
p: 4,
};
const SignWithCosmos = () => { const SignWithCosmos = () => {
const { session, signClient } = useWalletConnectContext(); const { session, signClient } = useWalletConnectContext();
const { ethAddress, cosmosAddress, ethSignature } = useParams(); const { ethAddress, cosmosAddress, ethSignature } = useParams();
const [openModal, setOpenModal] = useState(false); const [openModal, setOpenModal] = useState(false);
const [result, setResult] = useState(""); const [cosmosSignature, setCosmosSignature] = useState("");
const message = useMemo(() => { const displayAttestation = useMemo(() => {
return JSON.stringify( return canonicalStringify(
{ {
ethAddress, payload: {
ethSignature, msg: "Onboarding my Azimuth ID onto UrbitChain",
text: "Attested by ethereum key", address: ethAddress,
payload: {
msg: "Onboarding my cosmos validator onto UrbitChain",
address: cosmosAddress,
},
},
signatures: [cosmosSignature, ethSignature],
}, },
null, null,
2 2
); );
}, [ethAddress, ethSignature]); }, [ethAddress, cosmosAddress, cosmosSignature, ethSignature]);
const message = useMemo(() => {
return canonicalStringify(
{
msg: "Onboarding my Azimuth ID onto UrbitChain",
address: ethAddress,
payload: {
msg: "Onboarding my cosmos validator onto UrbitChain",
address: cosmosAddress,
},
},
null,
2
);
}, [ethAddress, cosmosAddress]);
const signCosmos = async () => { const signCosmos = async () => {
try { try {
@ -62,8 +77,8 @@ const SignWithCosmos = () => {
params, params,
}, },
}); });
setOpenModal(false); setOpenModal(true);
setResult(signedMessage.signature); setCosmosSignature(signedMessage.signature);
} }
} catch (error) { } catch (error) {
console.log("err in signing ", error); console.log("err in signing ", error);
@ -73,57 +88,69 @@ const SignWithCosmos = () => {
}; };
return ( return (
<div> <Box
<h1>Sign using Cosmos key</h1> style={{
<p>Cosmos account: {cosmosAddress}</p> display: "flex",
<Button flexDirection: "column",
onClick={() => { marginTop: "100px",
setOpenModal(true); gap: "10px",
}}
>
<Typography variant="h5">Sign with cosmos key</Typography>
<Typography variant="body1">Cosmos account: {cosmosAddress}</Typography>
<Typography variant="body1">
Message: <br />
</Typography>
<Box
sx={{
backgroundColor: "lightgray",
padding: 3,
wordWrap: "break-word",
}} }}
> >
Sign with cosmos <pre style={{ whiteSpace: "pre-wrap", margin: 0 }}>{message} </pre>
</Button> </Box>
<Modal
<Box>
<Button
variant="contained"
onClick={() => {
signCosmos();
}}
>
Sign with cosmos
</Button>
</Box>
<Dialog
open={openModal} open={openModal}
onClose={() => setOpenModal(false)} onClose={() => {
aria-labelledby="modal-modal-title" setOpenModal(false);
aria-describedby="modal-modal-description" }}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
maxWidth="md"
> >
<Box sx={style}> <DialogContent>
<Typography id="modal-modal-title" variant="h6" component="h2"> Attested message to be broadcasted on chain
Sign with Cosmos <Box
</Typography> sx={{
<Typography id="modal-modal-description">Message to sign:</Typography> backgroundColor: "lightgray",
<Typography id="modal-modal-description" sx={{ mt: 2 }}> padding: 3,
{message} wordWrap: "break-word",
</Typography>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
width: "100%",
}} }}
> >
<Button <pre style={{ whiteSpace: "pre-wrap", margin: 0 }}>
onClick={() => { {displayAttestation}{" "}
signCosmos(); </pre>
}} </Box>
> </DialogContent>
Sign <DialogActions>
</Button> <Button onClick={() => setOpenModal(false)}>Close</Button>
<Button onClick={() => setOpenModal(false)}>X</Button> </DialogActions>
</div> </Dialog>
</Box>
</Modal>
{result && (
<Alert icon={<CheckIcon fontSize="inherit" />} severity="success">
Signed message ({result}) will be broadcasted to the chain
</Alert>
)}
<SnackbarProvider /> <SnackbarProvider />
</div> </Box>
); );
}; };

View File

@ -1,148 +1,185 @@
import React, { useState, useMemo } from "react"; import React, { useState, useMemo, useEffect, useCallback } from "react";
import assert from "assert";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { SnackbarProvider, enqueueSnackbar } from "notistack"; import { SnackbarProvider, enqueueSnackbar } from "notistack";
import canonicalStringify from "canonical-json";
import { import {
Modal,
Button, Button,
Typography,
Box,
Select, Select,
MenuItem, MenuItem,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Box,
Typography,
} from "@mui/material"; } from "@mui/material";
import { utf8ToHex } from "@walletconnect/encoding"; import { utf8ToHex } from "@walletconnect/encoding";
import { useWalletConnectContext } from "../context/WalletConnectContext"; import { useWalletConnectContext } from "../context/WalletConnectContext";
const style = {
position: "absolute" as const,
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
overflow: "scroll",
bgcolor: "background.paper",
border: "2px solid #000",
boxShadow: 24,
p: 4,
};
const SignWithEthereum = () => { const SignWithEthereum = () => {
window.Buffer = Buffer; const { session, signClient, checkPersistedState } =
useWalletConnectContext();
useEffect(() => {
if (signClient && !session) {
checkPersistedState(signClient);
}
}, [session, signClient, checkPersistedState]);
const { session, signClient } = useWalletConnectContext();
const navigate = useNavigate(); const navigate = useNavigate();
const [ethAddress, setEthAddress] = useState(""); const [ethAddress, setEthAddress] = useState("");
const [ethSignature, setEthSignature] = useState("");
const [cosmosAddress, setCosmosAddress] = useState(""); const [cosmosAddress, setCosmosAddress] = useState("");
const [openModal, setOpenModal] = useState(false); const [openModal, setOpenModal] = useState(false);
const message = useMemo(() => { const message = useMemo(() => {
return { return {
cosmosAddress, msg: "Onboarding my cosmos validator onto UrbitChain",
text: "Onboarding azimuth ID onto urbit chain", address: cosmosAddress,
}; };
}, [cosmosAddress]); }, [cosmosAddress]);
const signEth = async () => { const signEth = async () => {
try { if (session && signClient) {
const jsonMessage = JSON.stringify(message, null, 2); try {
const hexMsg = utf8ToHex(jsonMessage, true); const jsonMessage = canonicalStringify(message);
const ethSignature: string = await signClient!.request({ const hexMsg = utf8ToHex(jsonMessage, true);
topic: session!.topic, const ethSignature: string = await signClient!.request({
chainId: "eip155:1", topic: session!.topic,
request: { chainId: "eip155:1",
method: "personal_sign", request: {
params: [hexMsg, ethAddress], method: "personal_sign",
}, params: [hexMsg, ethAddress],
}); },
navigate( });
`/sign-with-cosmos/${ethAddress}/${cosmosAddress}/${ethSignature}` setEthSignature(ethSignature);
); setOpenModal(true);
} catch (error) { } catch (error) {
console.log("err in signing ", error); console.log("err in signing ", error);
setOpenModal(false); setOpenModal(false);
enqueueSnackbar("Error signing message", { variant: "error" }) enqueueSnackbar("Error signing message", { variant: "error" });
}
} }
}; };
assert(session, "Session not found"); const submitHandler = useCallback(() => {
navigate(
`/sign-with-cosmos/${ethAddress}/${cosmosAddress}/${ethSignature}`
);
setOpenModal(false);
}, [ethAddress, cosmosAddress, ethSignature, navigate]);
return ( return (
<div> <div>
<h1>Connected</h1> {session ? (
<p>Session id: {session.topic}</p> <Box
<p>Select Cosmos accounts: </p> style={{
<Select display: "flex",
labelId="demo-simple-select-label" flexDirection: "column",
id="demo-simple-select" marginTop: "100px",
value={cosmosAddress} gap: "10px",
label="Cosmos address" }}
onChange={(e: any) => { >
setCosmosAddress(e.target.value); <Typography variant="h5">Sign with ethereum key</Typography>
}} <Typography variant="body1">Select Cosmos account:</Typography>
style={{ maxWidth: "500px", display: "block" }} <Select
> labelId="demo-simple-select-label"
{session.namespaces.cosmos.accounts.map((address, index) => ( id="demo-simple-select"
<MenuItem value={address.split(":")[2]} key={index}> value={cosmosAddress}
{address.split(":")[2]} label="Cosmos address"
</MenuItem> onChange={(e: any) => {
))} setCosmosAddress(e.target.value);
</Select> }}
<p>Select Ethereum account: </p> style={{ maxWidth: "600px", display: "block" }}
<Select >
labelId="demo-simple-select-label" {session?.namespaces.cosmos.accounts.map((address, index) => (
id="demo-simple-select" <MenuItem value={address.split(":")[2]} key={index}>
value={ethAddress} {address.split(":")[2]}
label="Ethereum address" </MenuItem>
onChange={(e: any) => { ))}
setEthAddress(e.target.value); </Select>
}} <Typography variant="body1">Select Ethereum account: </Typography>
style={{ maxWidth: "500px", display: "block" }} <Select
> labelId="demo-simple-select-label"
{session.namespaces.eip155.accounts.map((address, index) => ( id="demo-simple-select"
<MenuItem value={address.split(":")[2]} key={index}> value={ethAddress}
{address.split(":")[2]} label="Ethereum address"
</MenuItem> onChange={(e: any) => {
))} setEthAddress(e.target.value);
</Select> }}
style={{ maxWidth: "600px", display: "block" }}
>
{session?.namespaces.eip155.accounts.map((address, index) => (
<MenuItem value={address.split(":")[2]} key={index}>
{address.split(":")[2]}
</MenuItem>
))}
</Select>
<Button { Boolean(ethAddress) && (<Box
onClick={() => { sx={{
setOpenModal(true); backgroundColor: "lightgray",
}} padding: 3,
disabled={!Boolean(ethAddress)} wordWrap: "break-word",
>
Sign using Ethereum key
</Button>
<Modal
open={openModal}
onClose={() => setOpenModal(false)}
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description"
>
<Box sx={style}>
<Typography id="modal-modal-title" variant="h6" component="h2">
Sign using ethereum ({ethAddress})
</Typography>
<Typography id="modal-modal-description">Message to sign:</Typography>
<Typography id="modal-modal-description" sx={{ mt: 2 }}>
{JSON.stringify(message, null, 2)}
</Typography>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
width: "100%",
}} }}
> >
<Button onClick={signEth}>Sign</Button> <pre style={{ whiteSpace: "pre-wrap", margin: 0 }}>{JSON.stringify(message, null, 2)} </pre>
<Button onClick={() => setOpenModal(false)}>Close</Button> </Box>)}
</div> <Box>
<Button
variant="contained"
onClick={signEth}
disabled={!Boolean(ethAddress)}
style={{ marginTop: "20px" }}
>
Sign using Ethereum key
</Button>
</Box>
<Dialog
open={openModal}
onClose={() => {
setOpenModal(false);
}}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
maxWidth="md"
>
<DialogTitle id="alert-dialog-title">
{"Signed message with ethereum key"}
</DialogTitle>
<DialogContent>
<Typography variant="body1" style={{ wordWrap: "break-word" }}>
Signature: {ethSignature}
</Typography>
<Box
sx={{
backgroundColor: "lightgray",
padding: 3,
wordWrap: "break-word",
}}
>
<pre style={{ whiteSpace: "pre-wrap", margin: 0 }}>
{canonicalStringify(message, null, 2)}{" "}
</pre>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={submitHandler} autoFocus>
Sign with cosmos
</Button>
<Button onClick={() => setOpenModal(false)}>Close</Button>
</DialogActions>
</Dialog>
<SnackbarProvider />
</Box> </Box>
</Modal> ) : (
<SnackbarProvider /> <>Loading...</>
)}
</div> </div>
); );
}; };

View File

@ -4100,6 +4100,11 @@ caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001587, caniuse-lite@^1.0.30001591:
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001599.tgz#571cf4f3f1506df9bf41fcbb6d10d5d017817bce" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001599.tgz#571cf4f3f1506df9bf41fcbb6d10d5d017817bce"
integrity sha512-LRAQHZ4yT1+f9LemSMeqdMpMxZcc4RMWdj4tiFe3G8tNkWK+E58g+/tzotb5cU6TbcVJLr4fySiAW7XmxQvZQA== integrity sha512-LRAQHZ4yT1+f9LemSMeqdMpMxZcc4RMWdj4tiFe3G8tNkWK+E58g+/tzotb5cU6TbcVJLr4fySiAW7XmxQvZQA==
canonical-json@^0.0.4:
version "0.0.4"
resolved "https://registry.yarnpkg.com/canonical-json/-/canonical-json-0.0.4.tgz#6579c072c3db5c477ec41dc978fbf2b8f41074a3"
integrity sha512-2sW7x0m/P7dqEnO0O87U7RTVQAaa7MELcd+Jd9FA6CYgYtwJ1TlDWIYMD8nuMkH1KoThsJogqgLyklrt9d/Azw==
case-sensitive-paths-webpack-plugin@^2.4.0: case-sensitive-paths-webpack-plugin@^2.4.0:
version "2.4.0" version "2.4.0"
resolved "https://registry.yarnpkg.com/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz#db64066c6422eed2e08cc14b986ca43796dbc6d4" resolved "https://registry.yarnpkg.com/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz#db64066c6422eed2e08cc14b986ca43796dbc6d4"