Gas-less way to purchase ETH for USDC

Part 4 — user interface for customer

Alexander Koval
Coinmonks
Published in
8 min readOct 22, 2023

--

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

--

--

Alexander Koval
Coinmonks

I'm full-stack web3.0 developer, dedicated to bring more justice in this world by adopting blockchain technologies