diff --git a/frontend/package.json b/frontend/package.json index 21ed583..9286aee 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,8 @@ "@material-design-icons/svg": "^0.14.13", "@metamask/detect-provider": "^2.0.0", "@metamask/jazzicon": "^2.0.0", + "@oasisprotocol/demo-starter-backend": "workspace:^", + "@oasisprotocol/sapphire-paratime": "1.3.2", "ethers": "^6.10.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/frontend/src/components/Input/RevealInput.tsx b/frontend/src/components/Input/RevealInput.tsx new file mode 100644 index 0000000..bc6acdd --- /dev/null +++ b/frontend/src/components/Input/RevealInput.tsx @@ -0,0 +1,4 @@ +import { withReveal } from '../../hoc/withReveal' +import { Input } from './index' + +export const RevealInput = withReveal(Input) diff --git a/frontend/src/components/Input/index.module.css b/frontend/src/components/Input/index.module.css new file mode 100644 index 0000000..8a5baaf --- /dev/null +++ b/frontend/src/components/Input/index.module.css @@ -0,0 +1,67 @@ +.input { + width: 100%; + position: relative; + + input { + width: 100%; + height: 2.8125rem; + font-size: 16px; + font-weight: 700; + line-height: 24px; + padding-inline-start: 24px; + padding-inline-end: 15px; + border-radius: 4px; + min-width: 0; + outline: 2px solid transparent; + outline-offset: 2px; + position: relative; + appearance: none; + transition-property: border; + transition-duration: 100ms; + border: 2px solid var(--brand-blue); + color: var(--brand-extra-dark); + } + + label { + display: block; + position: absolute; + top: 0; + left: 0; + z-index: 2; + background-color: var(--white); + pointer-events: none; + margin-inline-start: 1.5rem; + margin-inline-end: 0.75rem; + margin-top: 0.65625rem; + margin-bottom: 0.65625rem; + font-size: 16px; + font-weight: 500; + line-height: 24px; + color: var(--brand-blue); + transform-origin: left top; + transition-property: transform, font-size, line-height; + transition-duration: 200ms; + } + + input + label, + &:focus-within label { + transform: translateY(-16px); + font-size: 12px; + font-weight: 700; + line-height: 18px; + padding-inline-start: 0.5rem; + padding-inline-end: 0.5rem; + margin-inline-start: 1rem; + margin-top: 0.5rem; + margin-bottom: 0.5rem; + } +} + +.inputDisabled { + pointer-events: none; + cursor: not-allowed; +} + +.inputError { + margin-top: 0.5rem; +} diff --git a/frontend/src/components/Input/index.tsx b/frontend/src/components/Input/index.tsx new file mode 100644 index 0000000..75fcaff --- /dev/null +++ b/frontend/src/components/Input/index.tsx @@ -0,0 +1,40 @@ +import { ChangeEventHandler, FC, useId } from 'react' +import classes from './index.module.css' +import { StringUtils } from '../../utils/string.utils' + +interface Props { + required?: boolean + label?: string + error?: string + className?: string + value?: string + disabled?: boolean + onChange?: (value: string) => void +} + +export const Input: FC = ({ required, label, error, className, disabled, value, onChange }) => { + const id = useId() + + return ( +
+
+ { + onChange?.(value) + }) as ChangeEventHandler + } + autoComplete="off" + disabled={disabled} + className={StringUtils.clsx(disabled ? classes.inputDisabled : undefined)} + /> + +
+ {error &&

{error}

} +
+ ) +} diff --git a/frontend/src/hoc/index.module.css b/frontend/src/hoc/index.module.css new file mode 100644 index 0000000..bf2e2e2 --- /dev/null +++ b/frontend/src/hoc/index.module.css @@ -0,0 +1,20 @@ +.mask { + position: relative; + + &:after { + content: attr(data-label); + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 100%; + height: 100%; + background-color: var(--brand-blue); + color: white; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + border-radius: 4px; + } +} diff --git a/frontend/src/hoc/withReveal.tsx b/frontend/src/hoc/withReveal.tsx new file mode 100644 index 0000000..6994160 --- /dev/null +++ b/frontend/src/hoc/withReveal.tsx @@ -0,0 +1,38 @@ +import { FC, useEffect, useState } from 'react' +import classes from './index.module.css' +import { StringUtils } from '../utils/string.utils' + +type RevealProps = { + reveal: boolean + revealLabel?: string + onRevealChange: (reveal: boolean) => void +} & T + +export const withReveal = + (Component: FC) => + (props: RevealProps) => { + const { reveal, revealLabel, onRevealChange, ...restProps } = props as RevealProps + + const [isRevealed, setIsRevealed] = useState(false) + + useEffect(() => { + setIsRevealed(isRevealed) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [reveal]) + + return ( +
{ + if (isRevealed) { + return + } + setIsRevealed(true) + onRevealChange(true) + }} + > + +
+ ) + } diff --git a/frontend/src/pages/HomePage/index.module.css b/frontend/src/pages/HomePage/index.module.css index 75fd1ad..c003dd6 100644 --- a/frontend/src/pages/HomePage/index.module.css +++ b/frontend/src/pages/HomePage/index.module.css @@ -1,2 +1,20 @@ .homePage { } + +.connectWalletText { + text-align: center; +} + +.activeMessageText { + margin-bottom: 1rem; +} + +.setMessageText { + margin: 2rem 0 1rem; +} + +.setMessageActions { + display: flex; + justify-content: center; + padding-top: 1rem; +} diff --git a/frontend/src/pages/HomePage/index.tsx b/frontend/src/pages/HomePage/index.tsx index f7a96a8..ceed0ce 100644 --- a/frontend/src/pages/HomePage/index.tsx +++ b/frontend/src/pages/HomePage/index.tsx @@ -1,12 +1,117 @@ -import { FC } from 'react' +import { FC, useEffect, useState } from 'react' import { Card } from '../../components/Card' +import { Input } from '../../components/Input' +import { Button } from '../../components/Button' import classes from './index.module.css' +import { useWeb3 } from '../../hooks/useWeb3' +import { RevealInput } from '../../components/Input/RevealInput' +import { Message } from '../../types' +import { StringUtils } from '../../utils/string.utils' export const HomePage: FC = () => { + const { + state: { isConnected, isSapphire, isInteractingWithChain, account }, + getMessage: web3GetMessage, + setMessage: web3SetMessage, + } = useWeb3() + const [message, setMessage] = useState(null) + const [messageValue, setMessageValue] = useState('') + const [messageRevealLabel, setMessageRevealLabel] = useState() + const [messageError, setMessageError] = useState(null) + const [messageValueError, setMessageValueError] = useState() + + const fetchMessage = async () => { + setMessageRevealLabel('Please sign message and wait...') + + try { + const retrievedMessage = await web3GetMessage() + setMessage(retrievedMessage) + setMessageRevealLabel(undefined) + } catch (ex) { + setMessageError((ex as Error).message) + setMessageRevealLabel('Something went wrong!') + } + } + + useEffect(() => { + if (isSapphire === null) { + return + } + + if (!isSapphire) { + fetchMessage() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isSapphire]) + + const handleRevealChanged = () => { + if (!isSapphire) { + return + } + fetchMessage() + } + + const handleSetMessage = async () => { + setMessageValueError(undefined) + + if (!messageValue) { + setMessageValueError('Message is required!') + + return + } + + try { + const retrievedMessage = await web3SetMessage(messageValue) + setMessage(retrievedMessage) + setMessageValue('') + } catch (ex) { + setMessageValueError((ex as Error).message) + } + } + return (
- -

Demo starter

+ Demo starter}> + {isConnected && ( + <> +
+

Active message

+

Current message set in message box.

+
+ + {messageError &&

{StringUtils.truncate(messageError)}

} +
+

Set message

+

Set your new message by filling the message field bellow.

+
+ +
+ +
+ + )} + {!isConnected && ( + <> +
+

Please connect your wallet to get started.

+
+ + )}
) diff --git a/frontend/src/providers/Web3Context.ts b/frontend/src/providers/Web3Context.ts index c83249d..af2cbca 100644 --- a/frontend/src/providers/Web3Context.ts +++ b/frontend/src/providers/Web3Context.ts @@ -1,5 +1,6 @@ import { createContext } from 'react' -import { BrowserProvider, TransactionResponse } from 'ethers' +import { BrowserProvider, JsonRpcProvider, TransactionResponse } from 'ethers' +import { Message } from '../types' export interface Web3ProviderState { isConnected: boolean @@ -13,7 +14,9 @@ export interface Web3ProviderState { decimals: number } | null isInteractingWithChain: boolean + isSapphire: boolean | null chainId: bigint | null + provider: JsonRpcProvider } export interface Web3ProviderContext { @@ -23,6 +26,8 @@ export interface Web3ProviderContext { getTransaction: (txHash: string) => Promise getGasPrice: () => Promise isProviderAvailable: () => Promise + getMessage: () => Promise + setMessage: (message: string) => Promise } export const Web3Context = createContext({} as Web3ProviderContext) diff --git a/frontend/src/providers/Web3Provider.tsx b/frontend/src/providers/Web3Provider.tsx index a9a4a49..07514f9 100644 --- a/frontend/src/providers/Web3Provider.tsx +++ b/frontend/src/providers/Web3Provider.tsx @@ -1,9 +1,15 @@ import { FC, PropsWithChildren, useCallback, useEffect, useState } from 'react' +import * as sapphire from '@oasisprotocol/sapphire-paratime' import { CHAINS, VITE_NETWORK } from '../constants/config' import { handleKnownErrors, handleKnownEthersErrors, UnknownNetworkError } from '../utils/errors' import { Web3Context, Web3ProviderContext, Web3ProviderState } from './Web3Context' import { useEIP1193 } from '../hooks/useEIP1193' -import { BrowserProvider, EthersError } from 'ethers' +import { BrowserProvider, EthersError, JsonRpcProvider } from 'ethers' +import { MessageBox__factory } from '@oasisprotocol/demo-starter-backend' +import { retry } from '../utils/promise.utils' +import { Message } from '../types' + +const { VITE_MESSAGE_BOX_ADDR } = import.meta.env let EVENT_LISTENERS_INITIALIZED = false @@ -16,6 +22,10 @@ const web3ProviderInitialState: Web3ProviderState = { chainId: null, nativeCurrency: null, isInteractingWithChain: false, + provider: new JsonRpcProvider(import.meta.env.VITE_WEB3_GATEWAY, undefined, { + staticNetwork: true, + }), + isSapphire: null, } export const Web3ContextProvider: FC = ({ children }) => { @@ -34,7 +44,6 @@ export const Web3ContextProvider: FC = ({ children }) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [state.account]) - // @ts-ignore TS6133: Later usage const interactingWithChainWrapper = useCallback( (fn: (...args: Args) => Promise) => async (...args: Args): Promise => { @@ -75,7 +84,7 @@ export const Web3ContextProvider: FC = ({ children }) => { const _setNetworkSpecificVars = (chainId: bigint, browserProvider = state.browserProvider!): void => { if (!browserProvider) { - throw new Error('[Web3Context] Sapphire provider is required!') + throw new Error('[Web3Context] Browser provider is required!') } if (!CHAINS.has(chainId)) { @@ -137,6 +146,7 @@ export const Web3ContextProvider: FC = ({ children }) => { browserProvider, account, chainId, + isSapphire: !!sapphire.NETWORKS[Number(chainId)], })) _addEventListenersOnce(window.ethereum) @@ -199,6 +209,46 @@ export const Web3ContextProvider: FC = ({ children }) => { return (await browserProvider.getFeeData()).gasPrice ?? 0n } + const _getSigner = async () => { + const { isSapphire, browserProvider } = state + + if (isSapphire) { + const signer = await browserProvider!.getSigner() + return sapphire.wrap(signer) + } + + return await browserProvider!.getSigner() + } + + const getMessage = async () => { + const signer = await _getSigner() + const messageBox = MessageBox__factory.connect(VITE_MESSAGE_BOX_ADDR, signer) + + const [message, author] = await Promise.all([messageBox.message(), messageBox.author()]) + + return { message, author } + } + + const setMessage = async (message: string): Promise => { + const signer = await _getSigner() + const messageBox = MessageBox__factory.connect(VITE_MESSAGE_BOX_ADDR, signer) + + await messageBox.setMessage(message) + + await retry>(getMessage, retrievedMessage => { + if (retrievedMessage?.message !== message) { + throw new Error('Unable to determine if the new message has been correctly set!') + } + + return retrievedMessage + }) + + return { + author: await signer.getAddress(), + message, + } + } + const providerState: Web3ProviderContext = { state, isProviderAvailable, @@ -206,6 +256,8 @@ export const Web3ContextProvider: FC = ({ children }) => { switchNetwork, getTransaction, getGasPrice, + getMessage, + setMessage: interactingWithChainWrapper(setMessage), } return {children} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 77bece3..b34bff7 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1,2 +1,3 @@ export * from './icon-size' export * from './icon-props' +export * from './message' diff --git a/frontend/src/types/message.ts b/frontend/src/types/message.ts new file mode 100644 index 0000000..66c96ff --- /dev/null +++ b/frontend/src/types/message.ts @@ -0,0 +1,4 @@ +export interface Message { + author: string + message: string +} diff --git a/frontend/src/utils/promise.utils.ts b/frontend/src/utils/promise.utils.ts new file mode 100644 index 0000000..13126fb --- /dev/null +++ b/frontend/src/utils/promise.utils.ts @@ -0,0 +1,24 @@ +function rejectDelay(reason: string) { + return new Promise(function (_, reject) { + setTimeout(reject.bind(null, reason), 5000) + }) +} + +export async function retry>( + attempt: () => T, + tryCb: (value: Awaited) => void = () => {}, + maxAttempts = 10 +): Promise T>> { + let p: Promise> = Promise.reject() + + for (let i = 0; i < maxAttempts; i++) { + p = p + .catch(attempt) + .then(value => { + return tryCb(value) + }) + .catch(rejectDelay) as Promise> + } + + return p +} diff --git a/frontend/src/utils/string.utils.ts b/frontend/src/utils/string.utils.ts index 162db64..6aba2f1 100644 --- a/frontend/src/utils/string.utils.ts +++ b/frontend/src/utils/string.utils.ts @@ -2,7 +2,6 @@ import { NETWORK_NAMES } from '../constants/config' const truncateEthRegex = /^(0x[a-zA-Z0-9]{4})[a-zA-Z0-9]+([a-zA-Z0-9]{4})$/ const truncateOasisRegex = /^(oasis1[a-zA-Z0-9]{4})[a-zA-Z0-9]+([a-zA-Z0-9]{4})$/ -export const amountPattern = '^[0-9]*[.]?[0-9]{0,9}$' export abstract class StringUtils { static truncateAddress = (address: string, type: 'eth' | 'oasis' = 'eth') => { diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index e718612..d7f29da 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -6,6 +6,8 @@ declare const BUILD_DATETIME: number interface ImportMetaEnv { VITE_NETWORK: string + VITE_WEB3_GATEWAY: string + VITE_MESSAGE_BOX_ADDR: string } interface ImportMeta { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19c6328..35d8816 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,6 +93,12 @@ importers: '@metamask/jazzicon': specifier: ^2.0.0 version: 2.0.0 + '@oasisprotocol/demo-starter-backend': + specifier: workspace:^ + version: link:../backend + '@oasisprotocol/sapphire-paratime': + specifier: 1.3.2 + version: 1.3.2 ethers: specifier: ^6.10.0 version: 6.10.0 @@ -1308,7 +1314,6 @@ packages: dependencies: bsaes: 0.0.2 uint32: 0.2.1 - dev: true /@oasisprotocol/sapphire-contracts@0.2.7: resolution: {integrity: sha512-jMCRA/l9dWKDmvjYzfK+u87/GhTYrEdnmTxG924KHNTAGeJs9y9rpihPEVMhQfe8DeHEdaLULormCsebJVxfdA==} @@ -1338,7 +1343,6 @@ packages: transitivePeerDependencies: - bufferutil - utf-8-validate - dev: true /@openzeppelin/contracts@4.8.1: resolution: {integrity: sha512-xQ6eUZl+RDyb/FiZe1h+U7qr/f4p/SrTSQcTPH2bjur3C5DbuW/zFgCU/b1P/xcIaEqJep+9ju4xDRi3rmChdQ==} @@ -2363,7 +2367,6 @@ packages: resolution: {integrity: sha512-iVxJFMOvCUG85sX2UVpZ9IgvH6Jjc5xpd/W8pALvFE7zfCqHkV7hW3M2XZtpg9biPS0K4Eka96bbNNgLohcpgQ==} dependencies: uint32: 0.2.1 - dev: true /buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -2435,7 +2438,6 @@ packages: /cborg@1.10.2: resolution: {integrity: sha512-b3tFPA9pUr2zCUiCfRd2+wok2/LBSNUMKOuRRok+WlvvAgEt/PlbgPTsZUcwCOs53IJvLgTp0eotwtosE6njug==} hasBin: true - dev: true /chai@4.3.7: resolution: {integrity: sha512-HLnAzZ2iupm25PlN0xFreAlBA5zaBSv3og0DdeGA4Ar6h6rJ3A0rolRUKJhSF2V10GZKDgWF/VmAEsNWjCRB+A==} @@ -5626,7 +5628,6 @@ packages: /tweetnacl@1.0.3: resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} - dev: true /type-check@0.3.2: resolution: {integrity: sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==} @@ -5653,7 +5654,6 @@ packages: /type-fest@2.19.0: resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} engines: {node: '>=12.20'} - dev: true /typechain@8.3.2(typescript@4.9.5): resolution: {integrity: sha512-x/sQYr5w9K7yv3es7jo4KTX05CLxOf7TRWwoHlrjRh8H82G64g+k7VuWPJlgMo6qrjfCulOdfBjiaDtmhFYD/Q==} @@ -5746,7 +5746,6 @@ packages: /uint32@0.2.1: resolution: {integrity: sha512-d3i8kc/4s1CFW5g3FctmF1Bu2GVXGBMTn82JY2BW0ZtTtI8pRx1YWGPCFBwRF4uYVSJ7ua4y+qYEPqS+x+3w7Q==} - dev: true /unbox-primitive@1.0.2: resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==}