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",
"assert": "^2.1.0",
"buffer": "^6.0.3",
"canonical-json": "^0.0.4",
"notistack": "^3.0.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",

View File

@ -1,18 +1,21 @@
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 SignWithEthereum from "./pages/SignWithEthereum";
import SignWithCosmos from "./pages/SignWithCosmos";
import PageNotFound from "./pages/PageNotFound";
import SignPageLayout from "./layout/SignPageLayout";
function App() {
return (
<Router>
<Routes>
<Route path="/" element={<ConnectWallet />} />
<Route path="/sign-with-ethereum" element={<SignWithEthereum />} />
<Route path="/sign-with-cosmos/:ethAddress/:cosmosAddress/:ethSignature" element={<SignWithCosmos />} />
<Route element={<SignPageLayout />} >
<Route path="/sign-with-ethereum" element={<SignWithEthereum />} />
<Route path="/sign-with-cosmos/:ethAddress/:cosmosAddress/:ethSignature" element={<SignWithCosmos />} />
</Route>
<Route path="*" element={<PageNotFound />} />
</Routes>
</Router>

View File

@ -5,11 +5,13 @@ import React, {
ReactNode,
useState,
useEffect,
useCallback,
} from "react";
import SignClient from "@walletconnect/sign-client";
import { WalletConnectModal } from "@walletconnect/modal";
import { SessionTypes } from "@walletconnect/types";
import { getSdkError } from "@walletconnect/utils";
const PROJECT_ID = process.env.REACT_APP_WALLET_CONNECT_ID;
assert(PROJECT_ID, "Wallet connect project id not provided");
@ -18,12 +20,16 @@ interface ContextValue {
connect: () => Promise<void>;
session: SessionTypes.Struct | null;
signClient: SignClient | undefined;
checkPersistedState: (client: SignClient)=> Promise<void>
disconnect: () => Promise<void>;
}
const walletConnectContext = createContext<ContextValue>({
connect: () => Promise.resolve(),
session: null,
signClient: undefined,
checkPersistedState: ()=> Promise.resolve(),
disconnect: () => Promise.resolve(),
});
const web3Modal = new WalletConnectModal({
@ -40,7 +46,28 @@ export const WalletConnectProvider = ({
const [signClient, setSignClient] = useState<SignClient>();
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({
projectId: PROJECT_ID,
metadata: {
@ -52,7 +79,8 @@ export const WalletConnectProvider = ({
});
setSignClient(signClient);
};
await checkPersistedState(signClient)
}, [checkPersistedState])
const connect = async () => {
if (!signClient) {
@ -88,7 +116,7 @@ export const WalletConnectProvider = ({
if (!signClient) {
createClient();
}
}, [signClient]);
}, [signClient, createClient]);
return (
<walletConnectContext.Provider
@ -96,6 +124,8 @@ export const WalletConnectProvider = ({
connect,
session,
signClient,
checkPersistedState,
disconnect
}}
>
{children}
@ -108,11 +138,15 @@ export const useWalletConnectContext = () => {
connect,
session,
signClient,
checkPersistedState,
disconnect
} = useContext(walletConnectContext);
return {
connect,
session,
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 { useNavigate } from 'react-router-dom'
import { Typography, Button, Box, Grid, Avatar } from '@mui/material';
import { useWalletConnectContext } from "../context/WalletConnectContext";
// TODO: Connect wallet should not be accessible if session already exists
const ConnectWallet = () => {
const { connect } = useWalletConnectContext();
const { connect, session } = useWalletConnectContext();
const navigate = useNavigate()
if (session) {
navigate("/sign-with-ethereum")
}
const handler = async () => {
await connect();
navigate("/sign-with-ethereum")
}
return (
<div>
<h1>Connect wallet </h1>
<button onClick={handler}>Connect</button>
</div>
<Grid container spacing={2}>
<Grid item xs={2}>
</Grid>
<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 { useParams } from "react-router-dom";
import { SnackbarProvider, enqueueSnackbar } from "notistack";
import canonicalStringify from "canonical-json";
import { Modal, Button, Typography, Box } from "@mui/material";
import Alert from "@mui/material/Alert";
import CheckIcon from "@mui/icons-material/Check";
import {
Button,
Dialog,
DialogContent,
DialogActions,
Box,
Typography,
} from "@mui/material";
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 { session, signClient } = useWalletConnectContext();
const { ethAddress, cosmosAddress, ethSignature } = useParams();
const [openModal, setOpenModal] = useState(false);
const [result, setResult] = useState("");
const [cosmosSignature, setCosmosSignature] = useState("");
const message = useMemo(() => {
return JSON.stringify(
const displayAttestation = useMemo(() => {
return canonicalStringify(
{
ethAddress,
ethSignature,
text: "Attested by ethereum key",
payload: {
msg: "Onboarding my Azimuth ID onto UrbitChain",
address: ethAddress,
payload: {
msg: "Onboarding my cosmos validator onto UrbitChain",
address: cosmosAddress,
},
},
signatures: [cosmosSignature, ethSignature],
},
null,
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 () => {
try {
@ -62,8 +77,8 @@ const SignWithCosmos = () => {
params,
},
});
setOpenModal(false);
setResult(signedMessage.signature);
setOpenModal(true);
setCosmosSignature(signedMessage.signature);
}
} catch (error) {
console.log("err in signing ", error);
@ -73,57 +88,69 @@ const SignWithCosmos = () => {
};
return (
<div>
<h1>Sign using Cosmos key</h1>
<p>Cosmos account: {cosmosAddress}</p>
<Button
onClick={() => {
setOpenModal(true);
<Box
style={{
display: "flex",
flexDirection: "column",
marginTop: "100px",
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
</Button>
<Modal
<pre style={{ whiteSpace: "pre-wrap", margin: 0 }}>{message} </pre>
</Box>
<Box>
<Button
variant="contained"
onClick={() => {
signCosmos();
}}
>
Sign with cosmos
</Button>
</Box>
<Dialog
open={openModal}
onClose={() => setOpenModal(false)}
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description"
onClose={() => {
setOpenModal(false);
}}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
maxWidth="md"
>
<Box sx={style}>
<Typography id="modal-modal-title" variant="h6" component="h2">
Sign with Cosmos
</Typography>
<Typography id="modal-modal-description">Message to sign:</Typography>
<Typography id="modal-modal-description" sx={{ mt: 2 }}>
{message}
</Typography>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
width: "100%",
<DialogContent>
Attested message to be broadcasted on chain
<Box
sx={{
backgroundColor: "lightgray",
padding: 3,
wordWrap: "break-word",
}}
>
<Button
onClick={() => {
signCosmos();
}}
>
Sign
</Button>
<Button onClick={() => setOpenModal(false)}>X</Button>
</div>
</Box>
</Modal>
<pre style={{ whiteSpace: "pre-wrap", margin: 0 }}>
{displayAttestation}{" "}
</pre>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpenModal(false)}>Close</Button>
</DialogActions>
</Dialog>
{result && (
<Alert icon={<CheckIcon fontSize="inherit" />} severity="success">
Signed message ({result}) will be broadcasted to the chain
</Alert>
)}
<SnackbarProvider />
</div>
</Box>
);
};

View File

@ -1,148 +1,185 @@
import React, { useState, useMemo } from "react";
import assert from "assert";
import React, { useState, useMemo, useEffect, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { SnackbarProvider, enqueueSnackbar } from "notistack";
import canonicalStringify from "canonical-json";
import {
Modal,
Button,
Typography,
Box,
Select,
MenuItem,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Box,
Typography,
} from "@mui/material";
import { utf8ToHex } from "@walletconnect/encoding";
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 = () => {
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 [ethAddress, setEthAddress] = useState("");
const [ethSignature, setEthSignature] = useState("");
const [cosmosAddress, setCosmosAddress] = useState("");
const [openModal, setOpenModal] = useState(false);
const message = useMemo(() => {
return {
cosmosAddress,
text: "Onboarding azimuth ID onto urbit chain",
msg: "Onboarding my cosmos validator onto UrbitChain",
address: cosmosAddress,
};
}, [cosmosAddress]);
const signEth = async () => {
try {
const jsonMessage = JSON.stringify(message, null, 2);
const hexMsg = utf8ToHex(jsonMessage, true);
const ethSignature: string = await signClient!.request({
topic: session!.topic,
chainId: "eip155:1",
request: {
method: "personal_sign",
params: [hexMsg, ethAddress],
},
});
navigate(
`/sign-with-cosmos/${ethAddress}/${cosmosAddress}/${ethSignature}`
);
} catch (error) {
console.log("err in signing ", error);
setOpenModal(false);
enqueueSnackbar("Error signing message", { variant: "error" })
if (session && signClient) {
try {
const jsonMessage = canonicalStringify(message);
const hexMsg = utf8ToHex(jsonMessage, true);
const ethSignature: string = await signClient!.request({
topic: session!.topic,
chainId: "eip155:1",
request: {
method: "personal_sign",
params: [hexMsg, ethAddress],
},
});
setEthSignature(ethSignature);
setOpenModal(true);
} catch (error) {
console.log("err in signing ", error);
setOpenModal(false);
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 (
<div>
<h1>Connected</h1>
<p>Session id: {session.topic}</p>
<p>Select Cosmos accounts: </p>
<Select
labelId="demo-simple-select-label"
id="demo-simple-select"
value={cosmosAddress}
label="Cosmos address"
onChange={(e: any) => {
setCosmosAddress(e.target.value);
}}
style={{ maxWidth: "500px", display: "block" }}
>
{session.namespaces.cosmos.accounts.map((address, index) => (
<MenuItem value={address.split(":")[2]} key={index}>
{address.split(":")[2]}
</MenuItem>
))}
</Select>
<p>Select Ethereum account: </p>
<Select
labelId="demo-simple-select-label"
id="demo-simple-select"
value={ethAddress}
label="Ethereum address"
onChange={(e: any) => {
setEthAddress(e.target.value);
}}
style={{ maxWidth: "500px", display: "block" }}
>
{session.namespaces.eip155.accounts.map((address, index) => (
<MenuItem value={address.split(":")[2]} key={index}>
{address.split(":")[2]}
</MenuItem>
))}
</Select>
{session ? (
<Box
style={{
display: "flex",
flexDirection: "column",
marginTop: "100px",
gap: "10px",
}}
>
<Typography variant="h5">Sign with ethereum key</Typography>
<Typography variant="body1">Select Cosmos account:</Typography>
<Select
labelId="demo-simple-select-label"
id="demo-simple-select"
value={cosmosAddress}
label="Cosmos address"
onChange={(e: any) => {
setCosmosAddress(e.target.value);
}}
style={{ maxWidth: "600px", display: "block" }}
>
{session?.namespaces.cosmos.accounts.map((address, index) => (
<MenuItem value={address.split(":")[2]} key={index}>
{address.split(":")[2]}
</MenuItem>
))}
</Select>
<Typography variant="body1">Select Ethereum account: </Typography>
<Select
labelId="demo-simple-select-label"
id="demo-simple-select"
value={ethAddress}
label="Ethereum address"
onChange={(e: any) => {
setEthAddress(e.target.value);
}}
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
onClick={() => {
setOpenModal(true);
}}
disabled={!Boolean(ethAddress)}
>
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%",
{ Boolean(ethAddress) && (<Box
sx={{
backgroundColor: "lightgray",
padding: 3,
wordWrap: "break-word",
}}
>
<Button onClick={signEth}>Sign</Button>
<Button onClick={() => setOpenModal(false)}>Close</Button>
</div>
<pre style={{ whiteSpace: "pre-wrap", margin: 0 }}>{JSON.stringify(message, null, 2)} </pre>
</Box>)}
<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>
</Modal>
<SnackbarProvider />
) : (
<>Loading...</>
)}
</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"
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:
version "2.4.0"
resolved "https://registry.yarnpkg.com/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz#db64066c6422eed2e08cc14b986ca43796dbc6d4"