Gas-less way to purchase ETH for USDC
Part 4 — user interface for customer
Previous parts
Source code:
UI and backend API :
Smart contract:
Disclaimer
This tutorial is about developing web3.0 app, not about UX, building fancy GUI and working with styles, fonts and layouts isn’t something I enjoy, I always prefer CLI over GUI, that’s why the UI in this project would be super basic — just good enough for MVP
The functionality
We already have Gas Broker smart-contract — the main part of the system, this could be enough for advanced Customers and Gas providers to make orders and fulfill the orders by interacting directly with smart contract, they just have to agree where to publish orders. But advanced users almost never end up with 0 ETH in their wallet and the purpose of this project is to help new and unexperienced users — that’s why we need to build a UI.
UI will consist of 2 pages — /order
— for Customers and /fulfill
—for Gas providers. Let’s start from the first page.
Order page will consist of single form where user have to provide following input:
- token address
- value — the amount of tokens to exchange
- reward for Gas provider
- lifetime — how long the order will be valid
Once form is submitted the user will be offered to sign two messages using his web3 wallet, once messages are signed the order would be sent to backend API
Other than order form there needs to be some interface to connect web3 wallet and web application.
Choosing right tools
Good software developers are lazy (however opposite statement is not always true) that’s why instead of coding wallet connection I prefer to use wagmi template and adjust it for our needs:
next-rainbowkit
template has all functionality for wallet connection and interactions with blockchain, let’s kick-start the project with command:
pnpm create wagmi --template next-rainbowkit
Here is what we’ll see on root page once nextjs app is launched
Now we have wallet connect widget, interaction with smart contracts and signing typed messages functionality right outside of a box without writing a single line of code — isn’t it amazing?
Building custom components
Let’s create a new page /order
:
import '@rainbow-me/rainbowkit/styles.css'
import { createTheme, ThemeProvider } from '@mui/material/styles'
import { OrderForm } from '../../components/OrderForm'
import { ConnectButton } from '../../components/ConnectButton'
import { Connected } from '../../components/Connected'
import { Providers } from '../../app/providers'
// TODO remove, this demo shouldn't need to reset the theme.
const defaultTheme = createTheme();
export default function Order() {
return (
<ThemeProvider theme={defaultTheme}>
<Providers>
<ConnectButton />
<Connected>
<OrderForm />
</Connected>
</Providers>
</ThemeProvider>
);
}
This page contains ConnectButton
component as on demo page and OrderForm
component
Let’s build OrderForm
component — it should consist of input fields and submit button. I’d like it to look a bit more modern than demo page but I’m lazy to code styles and layouts that’s why I took a free template from MeterialUI as a foundation:
Free templates page:
The template I choose:
That’s what order page looks like after some customization:
Her is the code for OrderForm
component:
import { useState, useRef } from 'react';
import Avatar from '@mui/material/Avatar';
import Button from '@mui/material/Button';
import Snackbar from '@mui/material/Snackbar';
import Alert from '@mui/material/Alert';
import CssBaseline from '@mui/material/CssBaseline';
import TextField from '@mui/material/TextField';
import FormControlLabel from '@mui/material/FormControlLabel';
import Checkbox from '@mui/material/Checkbox';
import Link from '@mui/material/Link';
import Grid from '@mui/material/Grid';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Container from '@mui/material/Container';
import PermitMessageSigner from './PermitMessageSigner'
import RewardMessageSigner from './RewardMessageSigner'
export function OrderForm() {
const [orderData, setOrderData] = useState(null)
const [permitSignature, setPermitSignature] = useState('')
const [permitMessage, setPermitMessage] = useState(null)
const [successTabOpened, setSuccessTabOpened] = useState(false)
const [errorTabOpened, setErrorTabOpened] = useState(false)
const orderForm = useRef();
const handleSubmit = event => {
event.preventDefault()
const data = new FormData(event.currentTarget)
setOrderData({
token: data.get('token'),
value: data.get('value'),
reward: data.get('reward'),
lifetime: data.get('lifetime')
})
};
const onPermitSigned = (message, signature) => {
setPermitMessage(message)
setPermitSignature(signature)
}
const onRewardSigned = async (message, rewardSignature) => {
const { token, value, reward } = orderData
const order = {
signer: permitMessage.owner,
token,
value,
deadline: permitMessage.deadline.toString(),
reward,
permitSignature,
rewardSignature
}
const response = await fetch('/api/order', {
method: 'POST',
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(order),
})
if (response.ok) {
setSuccessTabOpened(true)
orderForm.current.reset()
setOrderData(null)
setPermitMessage(null)
setPermitSignature('')
} else {
setOrderData(null)
setErrorTabOpened(true)
}
}
const closeSuccessTab = () => {
setSuccessTabOpened(false)
}
const closeErrorTab = () => {
setErrorTabOpened(false)
}
return (
<Container component="main" maxWidth="xs">
{ orderData && <>
<PermitMessageSigner token={orderData.token} value={orderData.value} lifetime={orderData.lifetime} onSuccess={onPermitSigned} />
<RewardMessageSigner permitSignature={permitSignature} value={orderData.reward} onSuccess={onRewardSigned} />
</>
}
<CssBaseline />
<Box
sx={{
marginTop: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Typography component="h1" variant="h5">
Make an order
</Typography>
<Box ref={orderForm} component="form" onSubmit={handleSubmit} noValidate sx={{ mt: 1 }}>
<TextField
margin="normal"
required
fullWidth
id="token"
label="Token address"
name="token"
autoComplete="token"
autoFocus
/>
<TextField
margin="normal"
required
fullWidth
id="token"
label="Value"
name="value"
autoComplete="value"
autoFocus
/>
<TextField
margin="normal"
required
fullWidth
id="reward"
label="Reward"
name="reward"
autoComplete="value"
autoFocus
/>
<TextField
margin="normal"
required
fullWidth
id="lifetime"
label="Lifetime"
name="lifetime"
autoComplete="value"
autoFocus
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
>
Sign and publish
</Button>
</Box>
</Box>
<Snackbar
open={successTabOpened}
autoHideDuration={6000}
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
onClose={closeSuccessTab}
>
<Alert onClose={closeSuccessTab} severity="success" sx={{ width: '100%' }}>
Order is published
</Alert>
</Snackbar>
<Snackbar
open={errorTabOpened}
autoHideDuration={6000}
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
onClose={closeErrorTab}
>
<Alert onClose={closeErrorTab} severity="error" sx={{ width: '100%' }}>
There was an error
</Alert>
</Snackbar>
</Container>
)
}
Upon form submission orderData
object is constructed, this object is used as an input for PermitMessageSigner
and RewardMessageSigner
components:
{ orderData && <>
<PermitMessageSigner token={orderData.token} value={orderData.value} lifetime={orderData.lifetime} onSuccess={onPermitSigned} />
<RewardMessageSigner permitSignature={permitSignature} value={orderData.reward} onSuccess={onRewardSigned} />
</>
}
These components are getting signatures by interacting with user’s web3 wallet. First Permit message signature is taken and then it passed to second component and reward signature message is taken. Once both signatures are ready, order
object is constructed and sent to Bakend API endpoint
Here is a code for PermitMessageSigner
component:
import { useAccount, useSignTypedData } from 'wagmi'
import { useEffect } from 'react'
import permitTypes from '../resources/permitTypes.json' assert { type: 'json' }
import usePermitMessage from '../hooks/usePermitMessage'
import useDomain from '../hooks/useDomain'
const MessageSignerWithAddress = ({ token, value, lifetime, onSuccess }) => {
const { address, isConnected } = useAccount()
return (
address && token &&
<MessageSigner address={address} value={value} token={token} lifetime={lifetime} onSuccess={onSuccess} />
)
}
const MessageSigner = ({ address, token, value, lifetime, onSuccess }) => {
const domain = useDomain(token)
const message = usePermitMessage(address, token, value, lifetime)
const { data, isError, isLoading, isSuccess, signTypedData } =
useSignTypedData({
domain,
primaryType: 'Permit',
types: permitTypes,
message
})
useEffect(() => {
if (isSuccess) {
onSuccess(message, data);
}
}, [isSuccess])
useEffect(() => {
if (domain && message && !isLoading) {
signTypedData()
}
}, [domain, message])
return null
}
export default MessageSignerWithAddress
In order to sign typed message useSignTypedData
hook from wagmi is used. domain
data and message
have to be provided as an inout. Domain data is taken from useDomain
hook and message is provided by usePermitMessage
hook
Here is the code for useDomain
hook:
import { useState, useEffect } from 'react';
import { useContractReads } from 'wagmi'
import domainABI from '../resources/domainABI.json' assert { type: 'json' }
function useDomain(address) {
const [domain, setDomain] = useState(null)
const { data, error, isError, isLoading } = useContractReads({
contracts: [
{
address: address,
abi: domainABI,
functionName: 'name'
},
{
address: address,
abi: domainABI,
functionName: 'version'
}
]
})
useEffect(() => {
if (!data) return;
const [name, version] = data.map(response => response.result)
setDomain({
name,
version,
chainId: 1,
verifyingContract: address
})
}, [data, error])
return domain
}
export default useDomain
useContractReads
hook is used to read the return values of name
and version
view functions of token contract. Then domain object is constructed
Here is the code for usePermitMessage
hook:
import { useAccount, useContractRead } from 'wagmi'
import { useState, useEffect } from 'react'
import useBlock from './useBlock'
import { GAS_BROKER_ADDRESS } from '../config'
import noncesABI from '../resources/noncesABI.json' assert { type: 'json' }
function usePermitMessage(address, token, value, lifetime) {
const block = useBlock()
const { data: nonce } = useContractRead({
address: token,
abi: noncesABI,
functionName: "nonces",
args: [address]
})
const [ message, setMessage ] = useState(null)
useEffect(() => {
if (!address || !block || (nonce === undefined)) return
setMessage({
owner: address,
spender: GAS_BROKER_ADDRESS,
value,
nonce,
deadline: block.timestamp + BigInt(lifetime)
})
},
[address, block, nonce, value])
return message
}
export default usePermitMessage
This hook constructs a Permit message, this message contains nonce
value that needs to be read from token contract with signer
as an argument
Reactive programming makes signing of typed message more difficult as logic have to be spread across multiple hooks and components.
Here is a diagram that might help to understand interactions betwen hooks and components:
If you’d like to see more linear logic of signing typed message using JS code from backend script, I referring you to my previous article — you’ll find the script at the end:
The code of RewardMessageSigner
component:
import { useEffect } from "react"
import { keccak256 } from '@ethersproject/keccak256'
import { useAccount, useSignTypedData } from 'wagmi'
import { GAS_BROKER_ADDRESS } from '../config'
import useDomain from '../hooks/useDomain'
import rewardTypes from '../resources/rewardTypes.json' assert { type: 'json' }
const RewardMessageSignerWithAddress = ({permitSignature, value, onSuccess}) => {
const { address, isConnected } = useAccount()
return (
address &&
<RewardMessageSigner
address={address}
permitSignature={permitSignature}
value={value}
onSuccess={onSuccess}
/>
)
}
const RewardMessageSigner = ({address, permitSignature, value, onSuccess}) => {
const domain = useDomain(GAS_BROKER_ADDRESS)
const message = {
value,
permitHash: permitSignature && keccak256(permitSignature)
}
const { data, isError, isLoading, isSuccess, signTypedData } =
useSignTypedData({
domain,
primaryType: 'Reward',
types: rewardTypes,
message
})
useEffect(() => {
if (isSuccess) {
onSuccess(message, data);
}
}, [isSuccess])
useEffect(() => {
if (permitSignature && value) {
signTypedData()
}
}, [value, permitSignature])
return null
}
export default RewardMessageSignerWithAddress
Here Domain data is taken from Gas Broker smart contract
The demonstration
That’s what the process of creating order looks like for user:
Since we are using typed messages compatible with EIP-712 standard Metamask shows us actual data instead of byte string for both signature requests:
Once both messages are signed the following object will be sent to /api/order
endpoint:
{
"signer": "0xd733dE10b28D6AEe6C54B452D1C6856AC34234e4",
"token": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
"value": "150",
"deadline": "1697931835",
"reward": "10",
"permitSignature": "0x2d42a02c9e39a34ca6b5d5ebaebaa6a1cb71ddda17346dfa72faa658128a26960edc5f24829c3cda70410faeb06c960b9d6299db1f96d451fc9f7b3f228788301c",
"rewardSignature": "0xf9067602ee8f39d1b9ba505cb8308c95bead0ad3d68e5f189676b33c1a69793a6574add86b26323857da923c03fa15c593f36f038ab462170c87621e18c9f9851c"
}
This data is sufficient for Gas Provider to make a transaction to Gas broker smart contract and to fulfill the order, the UI for Gas providers will be implemented in following articles
To be continue
In the next part backend API will be implemented — it should validate and store incoming orders and provide endpoint for fetching active orders