From aa3579f695bb93e5483d3bdb024a567da6f8d632 Mon Sep 17 00:00:00 2001 From: jdrskr Date: Mon, 4 Dec 2023 17:15:23 +0100 Subject: [PATCH] feat: client holdings. --- .../src/api/auth/strategies/jwt.strategy.ts | 3 +- frontend/src/App.tsx | 32 ++- frontend/src/components/NavigationBar.tsx | 30 ++- frontend/src/constants/Assets.ts | 4 +- frontend/src/context/AuthContext.tsx | 10 +- frontend/src/context/Web3Context.tsx | 137 ++++++++++ frontend/src/pages/admin/pages/OrdersPage.tsx | 254 +++++++++--------- frontend/src/pages/client/ClientPages.tsx | 4 +- .../src/pages/client/pages/HoldingsPage.tsx | 112 ++++++++ .../pages/components/InteractionsWidget.tsx | 22 +- 10 files changed, 436 insertions(+), 172 deletions(-) create mode 100644 frontend/src/context/Web3Context.tsx create mode 100644 frontend/src/pages/client/pages/HoldingsPage.tsx diff --git a/backend/src/api/auth/strategies/jwt.strategy.ts b/backend/src/api/auth/strategies/jwt.strategy.ts index c34a7b4..aad8962 100644 --- a/backend/src/api/auth/strategies/jwt.strategy.ts +++ b/backend/src/api/auth/strategies/jwt.strategy.ts @@ -17,7 +17,8 @@ export class JwtStrategy extends PassportStrategy(Strategy) { async validate(payload: any) { return { userId: payload.userId, - isAdmin: payload.isAdmin + username: payload.username, + isAdmin: payload.isAdmin, }; } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 70fc7c3..1a8558d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -17,6 +17,7 @@ import { ForbiddenPage } from "./pages/errors/ForbiddenPage"; import { ClientPages } from "./pages/client/ClientPages"; import { NavigationBar } from "./components/NavigationBar"; import { DashboardLayout } from "./components/Layout"; +import { Web3ContextProvider } from "./context/Web3Context"; function App() { return ( @@ -32,21 +33,26 @@ function App() { }} > - - - - }> - {AdminPages} - {ClientPages} - {AccountPages} + + + + + }> + {AdminPages} + {ClientPages} + {AccountPages} - - } /> + + } /> + + } + /> - } /> - - - + + + diff --git a/frontend/src/components/NavigationBar.tsx b/frontend/src/components/NavigationBar.tsx index 297ffa3..b050d51 100644 --- a/frontend/src/components/NavigationBar.tsx +++ b/frontend/src/components/NavigationBar.tsx @@ -72,21 +72,31 @@ export const NavigationBar: React.FC = ({ ...props }) => { )} {!authContext.isAdmin && ( - navigate("/client/issue")} - > - Place order - + + navigate("/client/placeOrder")} + > + Place order + + navigate("/client/holdings")} + > + Holdings + + )} { fontSize: 14, }} > - {authContext.userId} + {authContext.username} diff --git a/frontend/src/constants/Assets.ts b/frontend/src/constants/Assets.ts index c5e6f08..a4ba6fb 100644 --- a/frontend/src/constants/Assets.ts +++ b/frontend/src/constants/Assets.ts @@ -11,4 +11,6 @@ export const ASSETS = { name: 'Notas do Tesouro Nacional ', address: process.env.REACT_APP_TOKEN_NTN_ADDRESS } -} \ No newline at end of file +} + +export const SUPPORTED_ASSETS = Object.keys(ASSETS); \ No newline at end of file diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index de2ccfc..dc8717f 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -12,6 +12,7 @@ interface AuthContextPayloadType { isAuthenticated: boolean; userId: string; + username: string; isAdmin: boolean; } @@ -48,7 +49,11 @@ const isTokenValid = () => { return isFuture(new Date((decodedToken.exp || 0) * 1000)); }; -const decodeToken = (): { userId?: string; isAdmin?: boolean } & JWTPayload => { +const decodeToken = (): { + userId?: string; + username?: string; + isAdmin?: boolean; +} & JWTPayload => { const token = localStorage.getItem(LocalStorage.AuthToken); if (!token) { @@ -64,6 +69,7 @@ const defaultContextData: AuthContextPayloadType = { isAuthenticated: isTokenValid(), isAdmin: decodeToken().isAdmin || false, userId: decodeToken().userId || "", + username: decodeToken().username || "", }; const AuthContext = React.createContext( @@ -93,12 +99,14 @@ export const AuthContextProvider: React.FC> = ({ ...prevState, isAuthenticated: true, isAdmin: decoded.isAdmin!, + username: decoded.username!, userId: decoded.userId!, })); return { isAdmin: decoded.isAdmin!, userId: decoded.userId!, + username: decoded.username!, isAuthenticated: true, }; }; diff --git a/frontend/src/context/Web3Context.tsx b/frontend/src/context/Web3Context.tsx new file mode 100644 index 0000000..06dae7e --- /dev/null +++ b/frontend/src/context/Web3Context.tsx @@ -0,0 +1,137 @@ +import React from "react"; +import { ethers } from "ethers"; +import { useSnackbar } from "notistack"; + +// ---- User state types ---- // + +interface Web3ContextValues { + connected: boolean; + provider?: ethers.providers.Web3Provider; + account?: string; + chainId?: number; + signer?: ethers.Signer; +} + +interface Web3ContextFns { + connectWallet: () => Promise; +} + +const Web3Context = React.createContext( + {} as any +); + +export const Web3ContextProvider: React.FC> = ({ + children, +}) => { + const snackbar = useSnackbar(); + + const defaultWeb3ContextData = { + connected: false, + }; + + const [web3ContextState, setWeb3ContextState] = + React.useState(defaultWeb3ContextData); + const connectWallet = async () => { + if (web3ContextState.provider) { + try { + await web3ContextState.provider.send("eth_requestAccounts", []); + const signer = web3ContextState.provider.getSigner(); + const account = await signer.getAddress(); + const network = await web3ContextState.provider.getNetwork(); + + snackbar.enqueueSnackbar("Successfully connected account.", { + variant: "success", + }); + + setWeb3ContextState({ + ...web3ContextState, + signer: signer, + account: account, + chainId: network.chainId, + connected: true, + }); + } catch (e) { + snackbar.enqueueSnackbar("Failed to connect account.", { + variant: "error", + }); + } + } + }; + // endregion + + // region Effects + + React.useEffect(() => { + if ((window as any).ethereum) { + const provider = new ethers.providers.Web3Provider( + (window as any).ethereum, + "any" + ); + + const setAccountChanged = (accounts: any[]) => { + snackbar.enqueueSnackbar("Account change detected.", { + variant: "info", + }); + if (accounts.length > 0) { + setWeb3ContextState((prev) => ({ + ...prev, + account: accounts[0], + })); + } else { + setWeb3ContextState((prev) => ({ + ...prev, + connected: false, + account: undefined, + })); + } + }; + (window as any).ethereum.on("accountsChanged", setAccountChanged); + + const setChainChanged = (chainId: string) => { + snackbar.enqueueSnackbar("Network change detected.", { + variant: "info", + }); + + setWeb3ContextState((prev) => ({ + ...prev, + chainId: parseInt(chainId), + })); + }; + (window as any).ethereum.on("chainChanged", setChainChanged); + + setWeb3ContextState({ + ...web3ContextState, + provider: provider, + }); + + return () => { + (window as any).ethereum?.removeListener( + "accountsChanged", + setAccountChanged + ); + (window as any).ethereum?.removeListener( + "chainChanged", + setChainChanged + ); + }; + } + + return; + }, []); + + // Update the context on graphql data changes + + // endregion + + return ( + + ); +}; + +export const useWeb3Context = () => React.useContext(Web3Context); diff --git a/frontend/src/pages/admin/pages/OrdersPage.tsx b/frontend/src/pages/admin/pages/OrdersPage.tsx index 1e2a145..2d3d0e1 100644 --- a/frontend/src/pages/admin/pages/OrdersPage.tsx +++ b/frontend/src/pages/admin/pages/OrdersPage.tsx @@ -8,16 +8,7 @@ import { AuthenticatedOnly } from "../../../components/AuthenticatedOnly"; import { OrdersDialog } from "./components/OrdersDialog"; import { SettleDialog } from "./components/SettleDialog"; import { OrdersExchange__factory } from "../../../typechain"; - -const supportedTokens = [ - { - name: "Letras Financeiras do Tesouro", - address: "0xD73464667d5F2e15dd0A3C58C3610c39c1b1c2d4" - }, { - name: "Letras do Tesouro Nacional", - address: "0x0d9D5372b5F889bCEcb930b1540f7D1595075177" - } -]; +import { ASSETS, SUPPORTED_ASSETS } from "../../../constants/Assets"; export const OrdersPage: React.FC = () => { const [tokenDetails, setTokenDetails] = React.useState<{ @@ -36,13 +27,13 @@ export const OrdersPage: React.FC = () => { amount: BigNumber; recipient: string; isBuyOrder: boolean; - }[] + }[]; }>; }>(); const [{ signer, address }, setSigner] = React.useState<{ - address?: string, - signer?: ethers.Signer + address?: string; + signer?: ethers.Signer; }>({}); const contract = React.useMemo(() => { @@ -63,7 +54,7 @@ export const OrdersPage: React.FC = () => { setSigner({ signer, - address: await signer.getAddress() + address: await signer.getAddress(), }); }; @@ -75,34 +66,37 @@ export const OrdersPage: React.FC = () => { // Get the token details const details = await contract.availableTokens(tokenAddress); - const epochList: Record> & { - orders: Awaited> - }> = {}; + const epochList: Record< + number, + Awaited> & { + orders: Awaited>; + } + > = {}; await Promise.all( - Array.from(Array(details.currentEpoch.toNumber() + 1)) - .map(async (_, index) => { - epochList[index] = { - ...(await contract.epochDetails(tokenAddress, index)) as any, - orders: (await contract.epochOrders(tokenAddress, index)) - }; - }) - ); - - setTokenDetails({ + Array.from(Array(details.currentEpoch.toNumber() + 1)).map( + async (_, index) => { + epochList[index] = { + ...((await contract.epochDetails(tokenAddress, index)) as any), + orders: await contract.epochOrders(tokenAddress, index), + }; + } + ) + ); + + setTokenDetails({ tokenDetails: { tokenAddress, - currentEpoch: details.currentEpoch + currentEpoch: details.currentEpoch, }, epochList: Object.entries(epochList) .map(([key, value]) => ({ id: Number(key), ...value })) - .sort((a, b) => b.id - a.id) as any + .sort((a, b) => b.id - a.id) as any, }); }; console.log(tokenDetails); - return ( { mt: "64px", display: "flex", flexFlow: "column", - alignItems: "center" + alignItems: "center", }} > {!signer && ( - + )} {signer && ( @@ -126,119 +116,121 @@ export const OrdersPage: React.FC = () => { setTokenDetails(null as any)} > - Select an asset - - - - Connected to {address} + {tokenDetails ? "Go back to asset selection" : "Select an asset"} - {!tokenDetails && supportedTokens.map((supportedToken) => ( - - - {supportedToken.name} - - - ( + - {supportedToken.address} - - - ))} + + {ASSETS[supportedToken as "LFT" | "LFN"].name} + + + + {ASSETS[supportedToken as "LFT" | "LFN"].address} + + + ))} {tokenDetails && ( value ? "Yes" : "No" - }, { - minWidth: 200, - field: "valueBought", - headerName: "Value Bought", - renderCell: ({ value }) => ( - `~${ethers.utils.formatUnits(value, 6)} BRL` - ) - }, { - minWidth: 200, - field: "amountSold", - headerName: "Amount Sold", - renderCell: ({ value }) => ( - `~${ethers.utils.formatUnits(value, 18)} units` - ) - }, { - minWidth: 200, - field: "orders", - headerName: "Orders in settlement", - renderCell: ({ value }) => ( - value.length - ) - }, { - flex: 1, - field: "id", - headerName: "Actions", - renderCell: ({ value, row }) => { - return ( - - - - {!row.settled && ( - - )} - - ); - } - }]} + columns={[ + { + field: "settled", + headerName: "Is Settled", + renderCell: ({ value }) => (value ? "Yes" : "No"), + }, + { + minWidth: 200, + field: "valueBought", + headerName: "Value Bought", + renderCell: ({ value }) => + `~${ethers.utils.formatUnits(value, 6)} BRL`, + }, + { + minWidth: 200, + field: "amountSold", + headerName: "Amount Sold", + renderCell: ({ value }) => + `~${ethers.utils.formatUnits(value, 18)} units`, + }, + { + minWidth: 200, + field: "orders", + headerName: "Orders in settlement", + renderCell: ({ value }) => value.length, + }, + { + flex: 1, + field: "id", + headerName: "Actions", + renderCell: ({ value, row }) => { + return ( + + + + {!row.settled && ( + + )} + + ); + }, + }, + ]} /> )} )} - - - ) - ; + ); }; diff --git a/frontend/src/pages/client/ClientPages.tsx b/frontend/src/pages/client/ClientPages.tsx index 5f3d10f..d769de3 100644 --- a/frontend/src/pages/client/ClientPages.tsx +++ b/frontend/src/pages/client/ClientPages.tsx @@ -1,8 +1,10 @@ import { Route } from "react-router-dom"; import { IssuePage } from "./pages/IssuePage"; +import { HoldingsPage } from "./pages/HoldingsPage"; export const ClientPages = ( - } /> + } /> + } /> ); diff --git a/frontend/src/pages/client/pages/HoldingsPage.tsx b/frontend/src/pages/client/pages/HoldingsPage.tsx new file mode 100644 index 0000000..c545e6e --- /dev/null +++ b/frontend/src/pages/client/pages/HoldingsPage.tsx @@ -0,0 +1,112 @@ +import React from "react"; +import { Box, Button, Typography } from "@mui/material"; +import { AuthenticatedOnly } from "../../../components/AuthenticatedOnly"; +import { ASSETS, SUPPORTED_ASSETS } from "../../../constants/Assets"; +import { BigNumber, ethers } from "ethers"; +import { ERC20Mock__factory } from "../../../typechain"; +import { useWeb3Context } from "../../../context/Web3Context"; + +export const HoldingsPage: React.FC = () => { + const web3Context = useWeb3Context(); + + const [{ signer }, setSigner] = React.useState<{ + address?: string; + signer?: ethers.Signer; + }>({}); + + const [balances, setBalances] = React.useState<{ [key: string]: string }>(); + + const fetchBalances = async () => { + const address = await signer!.getAddress(); + + let x = {}; + for await (const asset of SUPPORTED_ASSETS) { + const contract = new ERC20Mock__factory() + .connect(signer!) + .attach(ASSETS[asset as "LFT" | "LFN"].address! as string); + + const balance = await contract.balanceOf(address); + + console.log(balance.toString()); + + x = { + ...x, + [asset]: balance.div(BigNumber.from(10).pow(18)).toString(), + }; + } + + setBalances(x); + }; + + React.useEffect(() => { + if (web3Context) { + setSigner({ signer: web3Context.signer, address: web3Context.account }); + } + }, [web3Context]); + + React.useEffect(() => { + if (signer) { + fetchBalances(); + } + }, [signer]); + + return ( + + + {!signer && ( + + )} + {SUPPORTED_ASSETS.map((supportedToken) => ( + + + {ASSETS[supportedToken as "LFT" | "LFN"].name} + + + + {ASSETS[supportedToken as "LFT" | "LFN"].address} + + {signer && balances && ( + + Balance: {balances[supportedToken]} {supportedToken} + + )} + + ))} + + + ); +}; diff --git a/frontend/src/pages/client/pages/components/InteractionsWidget.tsx b/frontend/src/pages/client/pages/components/InteractionsWidget.tsx index 1a62eeb..5b635f3 100644 --- a/frontend/src/pages/client/pages/components/InteractionsWidget.tsx +++ b/frontend/src/pages/client/pages/components/InteractionsWidget.tsx @@ -23,6 +23,7 @@ import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useSnackbar } from "notistack"; import { LoadingButton } from "@mui/lab"; +import { useWeb3Context } from "../../../../context/Web3Context"; const Schema = z.object({ amount: z.number().nonnegative().int(), @@ -32,6 +33,7 @@ type SchemaType = z.infer; export const InteractionsWidget: React.FC = () => { const snackbar = useSnackbar(); + const web3Context = useWeb3Context(); const [value, setValue] = React.useState(0); const [asset, setAsset] = React.useState<"LFT" | "LFN" | "NTN-F">("LFT"); @@ -110,19 +112,11 @@ export const InteractionsWidget: React.FC = () => { } }; - const connectWallet = async () => { - const provider = new ethers.providers.Web3Provider( - (window as any).ethereum - ); - - await provider.send("eth_requestAccounts", []); - const signer = provider.getSigner(); - - setSigner({ - signer, - address: await signer.getAddress(), - }); - }; + React.useEffect(() => { + if (web3Context) { + setSigner({ signer: web3Context.signer, address: web3Context.account }); + } + }, [web3Context]); return ( @@ -165,7 +159,7 @@ export const InteractionsWidget: React.FC = () => {