Skip to main content

Step 5: Create a purchase action

Understand purchase logic

The final step is to create the PurchaseAction which contains all the buy logic.

This is a large step in terms of chunk of code size, but if you break it down piece by piece, it's fairly easy to understand.

There are two steps involved in a crypto transaction using any decentralized exchange:

  1. The first time that you initiate a purchase, you must approve the ERC-20 payment token to use for this transaction. This action grants permission to the TreumExchange contract to transfer up to a certain amount of your token (called an allowance) from your wallet.
  2. When the token is approved, you proceed to the PurchaseForm, where you use the approved token to fill an order. In this stage, you validate the form data if the user has enough token balance, then submit the order.

First, copy the /abis and /hooks folders from the source code to your project.

/src
...
...
|---- /abis: contains the ABI (Application Binary Interface) of deployed smart contracts that is understood by the Ethereum virtual machine.
|---- /hooks: for sharing wallet and order state across app components.

In the root directory, run:

yarn typechain --target ethers-v5 --out-dir src/contracts 'src/abis/*.abi'

This generates the TypeScript-safe typings compatible with the ethers.js library for the smart contracts used in /src/abis. These files are autogenerated under the src/contracts folder.

/src
...
...
|---- /abis: contains the ABI (Application Binary Interface) of deployed smart contracts that is understood by the Ethereum virtual machine.
|---- /contracts: contains autogenerated typings from smart contracts.
|---- /hooks: for sharing wallet and order state across app components.

The hooks needed are:

  • useApproveTokenForOrder - The umbrella function in which you submit order and networkId and ask for approvalState.
  • useApprovalCallback - Called inside useApproveTokenForOrder and takes the order token, amount, spender as parameters, and returns the approveState and async functions to approve and removeApproval.
  • useTokenAllowance - Checks the current allowance and approval status. If allowance amount is larger than transfer amount, there's no need to approve.
  • useCurrency - Contains functions to translate order amount and token address into ERC-20 currencies.
  • useExchangeContract - Loads the TremExchange contract. Returns function to fillOrder and the exchange contract address.
  • useTokenBalance - Checks the balance of the payment token in the wallet. Prevents the user from submitting an order if there's insufficient balance.
  • useTokenContract - Loads the token contract with the given token address.

With some additional contractHelpers, begin to construct the PurchaseAction:

/src/utils/contractHelpers.ts
import { isAddress } from "ethers/lib/utils";

export const checkDeployment = async (
provider: any,
contractAddress: string,
): Promise<boolean> => {
if (!isAddress(contractAddress)) return false;
const bytecode = await provider.getCode(contractAddress);
return bytecode !== "0x";
};
/src/components/PurchaseAction.tsx

import { Alert, Button, Modal, Typography } from "@mui/material";
import { useState } from "react";
import { useWallet } from "./WalletContext";
import PurchaseWizard from "./PurchaseWizard";
import { Item } from "../utils/types";
import { networkName } from "../config";

export interface PurchaseActionsProps {
item: Item;
}

const PurchaseAction: React.FC<PurchaseActionsProps> = ({ item }) => {
const { address, isRightNetwork } = useWallet();

const [cryptoBuyOpen, setCryptoBuyOpen] = useState<boolean>(false);

const { token_contract, listing } = item;

return (
<>
<Button
onClick={() => setCryptoBuyOpen(true)}
disabled={!isRightNetwork || !address}
color="secondary"
variant="outlined"
sx={{ borderRadius: 5, px: 18, float: "right" }}
>
<Typography variant="button" sx={ fontSize: "1rem" }>
Buy now
</Typography>
</Button>

{!isRightNetwork && token_contract && address && (
<Alert severity="warning">
Switch your network to ${networkName} to purchase.
</Alert>
)}

{!address && (
<Alert severity="warning">
You must connect your wallet to purchase.
</Alert>
)}

<Modal
open={cryptoBuyOpen}
onClose={() => setCryptoBuyOpen(false)}
sx={{ display: "flex", alignItems: "center", justifyContent: "center" }}
>
<PurchaseWizard
onDone={() => setCryptoBuyOpen(false)}
tokenContract={token_contract}
listing={listing}
/>
</Modal>
</>
);
};

export default PurchaseAction;

Create a two-step action

In this PurchaseAction, build a Buy Now button that opens up a modal to PurchaseWizard. When the user is in the wrong network or isn't connected to a wallet, the button is disabled and alerts are given.

/src/components/PurchaseWizard.tsx
import { Box, Step, StepLabel, Stepper } from "@mui/material";
import React from "react";
import { useState } from "react";
import { Listing, TokenContract } from "../utils/types";
import ApproveToken from "./ApproveToken";
import PurchaseForm from "./PurchaseForm";

export interface PurchaseWizardProps {
listing: Listing;
tokenContract: TokenContract;
onDone: any;
}

const steps = ["Approve", "Select quantity"];

const PurchaseWizard = React.forwardRef(
(props: PurchaseWizardProps, ref: any) => {
const [activeStep, setActiveStep] = useState<number>(0);

const { listing, tokenContract, onDone } = props;

const order = listing.data.order;

return (
<Box
ref={ref}
sx={{
p: 5,
backgroundColor: "#ffffff",
borderRadius: 5,
margin: "auto",
}}>
<Stepper activeStep={activeStep}>
{steps.map((label, index) => {
const stepProps = {};
const labelProps = {};

return (
<Step key={label} {...stepProps}>
<StepLabel {...labelProps}>{label}</StepLabel>
</Step>
);
})}
</Stepper>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
pt: 4,
}}>
{activeStep === 0 && (
<ApproveToken
order={order}
tokenContract={tokenContract}
onDone={() => setActiveStep(1)}
/>
)}
{activeStep === 1 && (
<PurchaseForm onDone={onDone} listing={listing} />
)}
</Box>
</Box>
);
},
);

export default PurchaseWizard;

Here, use a Stepper to guide the two-step transaction process.

On step 1, ApproveToken.

/src/components/ApproveToken.tsx
import { Button } from "@mui/material";
import { useEffect } from "react";
import { ApprovalState } from "../hooks/useApproveCallback";
import { useApproveTokenForOrder } from "../hooks/useApproveTokenForOrder";
import { Order, TokenContract } from "../utils/types";

export interface ApproveTokenProps {
onDone: any;
order: Order;
tokenContract: TokenContract;
}

const ApproveToken: React.FC<ApproveTokenProps> = ({
onDone,
order,
tokenContract,
}) => {
const { approvalState, approve } = useApproveTokenForOrder(
order,
tokenContract.network_id,
{
refetchInterval: 8000,
},
);

const hasPendingTokenApproval = approvalState === ApprovalState.PENDING;

useEffect(() => {
if (approvalState === ApprovalState.APPROVED) {
onDone();
}
}, [approvalState, onDone]);

return (
<>
{approvalState !== ApprovalState.APPROVED && (
<Button onClick={approve} disabled={hasPendingTokenApproval}>
Approve Token
</Button>
)}
</>
);
};

export default ApproveToken;

On step 2, submit the PurchaseForm:

/src/components/PurchaseForm.tsx

import { Listing } from "../utils/types";
import { useWallet } from "./WalletContext";
import * as yup from "yup";
import useExchangeContract from "../hooks/useExchangeContract";
import { useCurrency } from "../hooks/useCurrency";
import { useTokenBalance } from "../hooks/useTokenBalance";
import { useCallback, useState } from "react";
import { BigNumber, BigNumberish, ethers } from "ethers";
import { TRANSACTION_SUCCESS } from "../utils/constants";
import { Alert, Box, Button, Typography } from "@mui/material";
import { Field, Form, Formik } from "formik";
import { formatPrice } from "../utils/market";

export interface PurchaseFormProps {
onDone?: any;
listing: Listing;
}

const validationSchema = yup.object().shape({
quantity: yup
.number()
.min(1, "Quantity must be larger than 0")
.required(`Please enter the quantity to purchase`),
});

export function isBalanceEnough(
balance?: BigNumberish,
requiredAmount?: BigNumberish
) {
if (!balance || !requiredAmount) {
return false;
} else {
return BigNumber.from(balance).gte(requiredAmount);
}
}

const PurchaseForm: React.FC<PurchaseFormProps> = ({
onDone,
listing,
}: PurchaseFormProps) => {
const { notify, network, address } = useWallet();
const { fillOrder } = useExchangeContract();
const { order, signature } = listing.data;

const currency = useCurrency(network!, order.takerToken.token);
const balance = useTokenBalance(currency!, address);

const [purchased, setPurchased] = useState<boolean>(false);

const handleBuy = useCallback(
async ({ quantity }) => {
try {
await fillOrder(
quantity,
order,
signature,
currency?.address === ethers.constants.AddressZero,
(tx: any) => {
setPurchased(true);
return TRANSACTION_SUCCESS;
}
);
} catch (error: any) {
console.error(error);
notify.notification({
eventCode: "error",
type: "error",
message: error.message,
autoDismiss: 5000,
});
} finally {
if (onDone) onDone();
}
},
[order, signature, fillOrder, notify, currency, onDone]
);

return (
<Formik
initialValues={
quantity: 1,
}
onSubmit={handleBuy}
validationSchema={validationSchema}
validateOnChange
validateOnBlur
>
{({ values, errors, touched, isValidating, isSubmitting }) => {
console.log("errors", errors);
console.log("isvalidating", isValidating);
console.log("touched", touched);
console.log("currency", currency);

return (
<Form>
<Typography variant="body1" align="center">
<Box width="100%">
Unit Price: {currency ? formatPrice(order, currency) : ""}
</Box>
<Box width="100%" mt={2}>
Select quantity (max {listing.data.quantity_remaining})
</Box>
<Field
type="number"
name="quantity"
placeholder="1"
disabled={isSubmitting}
validate={(quantity: number) => {
if (quantity < 1) {
return "Quantity must be higher than 0";
}

if (!balance) {
return "Checking token balance";
}

const hasEnough = isBalanceEnough(
balance!,
BigNumber.from(order.takerToken.amount).mul(quantity)
);

if (!hasEnough) {
return `You do not have enough ${currency!.symbol}`;
}

if (quantity > listing.data.quantity_remaining) {
return "You can only buy within the max quantity";
}
}}
/>

{touched.quantity && errors.quantity && (
<Alert severity="error">{errors.quantity}</Alert>
)}
{values.quantity > 0 && (
<Box width="100%" mt={2}>
Total Price:{" "}
{currency && values.quantity
? formatPrice(order, currency, values.quantity)
: ""}
</Box>
)}
<Box>
{purchased && <Button disabled={true}>Sold</Button>}
{!purchased && (
<Button
disabled={isSubmitting || !!errors.quantity}
type="submit"
sx={{ px: 3, borderRadius: 5 }}
variant="outlined"
>
Buy now
</Button>
)}
</Box>
</Typography>
</Form>
);
}}
</Formik>
);
};

export default PurchaseForm;

In this form, you validate if the quantity entered was too high, too low, or above balance. The buy button is disabled in these scenarios.

Finally, wire up this PurchaseAction to the ItemPage.

/src/pages/ItemPage.tsx
    ...
import PurchaseAction from "../components/PurchaseAction";
...
...
{isSuccess && (
<Page>
<Box sx={{ my: 4 }}>
{item.listing && <PurchaseAction item={item} />}
<Typography variant="h2" align="center" gutterBottom>
<ItemDetails item={item} />
</Typography>
</Box>
</Page>
)}

The buy button now displays and is disabled when the wallet isn't connected.

tutorial-1-connect-wallet-to-buy

Select it to proceed to the approval and order process.

tutorial-1-make-purchase

Follow through and complete the transaction. The item is marked SOLD instead of showing the price.

tutorial-1-sold

You've created your first NFT drop!

The entire source code of this project is available to download here.