Skip to content

Mint Button

Finally, let's get into building the actual interaction that will allow the user to mint the NFT. We'll need to handle a few different states:

StateHow we handle it
No wallet is connected?Show a "Connect Wallet" button that pops up the RainbowKit modal.
Wallet connected, but the user doesn't have enough ETH to approve (if needed) and mint?Show an "Insufficient ETH" message in a disabled button.
The user has enough ETH but approval is needed?Show an "Approve" button that will call the approve function on the PYUSD contract.
The user has enough ETH and approval is done?Show a "Mint" button that will call the mint function on the HelloPYUSD contract.
Either of the above, but the user is waiting for a transaction to confirm?Show a loading message in a disabled button.

A few prerequisites

🍞 Toast

But before we get into all that, let's pull in a nice "toast" library to display temporary messages to the user. We'll use React Hot Toast, a simple and customizable toast library.

shell
npm install react-hot-toast

And we can add it into our App.tsx:

tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "react-hot-toast"; 
import { WagmiProvider } from "wagmi";

// snipped

<WagmiProvider config={wagmiConfig}>
  <QueryClientProvider client={queryClient}>
    <RainbowKitProvider
      theme={{
        lightMode: lightTheme(),
        darkMode: darkTheme(),
      }}
    >
      <HelloPyusd />
      <Toaster
        toastOptions={{
          success: {
            icon: "🌈",
          },
          error: {
            icon: "🔥",
          },
          position: "bottom-right",
          duration: 5000,
          style: {
            background: "#666",
            color: "#ccc",
          },
        }}
      />
    </RainbowKitProvider>
  </QueryClientProvider>
</WagmiProvider>;

Now we can use toast in our MintButton component to display messages to the user.

MintButtonView

We'll create a new file, src/components/MintButtonView.tsx, that will handle the display for the button. All logic will be in the parent component, MintButton.

tsx
import { ReactNode } from "react";

interface MintButtonViewProps {
  onClick?: () => void;
  disabled?: boolean;
  children: ReactNode;
}

export default function MintButtonView({
  onClick,
  disabled,
  children,
}: MintButtonViewProps) {
  return (
    <button
      className='mintButton'
      onClick={onClick}
      disabled={Boolean(disabled)}
    >
      {children}
    </button>
  );
}

With out .mintButton css class.

css
.mintButton {
  @apply w-full border-2 border-zinc-800 text-xl p-3 transition-colors;
  @apply hover:enabled:bg-zinc-800 hover:enabled:text-zinc-300;
  @apply dark:border-zinc-300 dark:hover:enabled:bg-zinc-300 dark:hover:enabled:text-zinc-800;
  @apply active:enabled:border-yellow-400 active:enabled:bg-yellow-400 active:text-zinc-800;
  @apply disabled:opacity-50;
}

Anatomy of MintButton

Now we can create our MintButton component. This will handle all the logic for the button, and use MintButtonView to display it.

We'll break it down into a few parts:

  1. Fetching relevant state
  2. Simulating and writing the PYUSD approval
  3. Simulating and writing the mint
  4. Waiting for transactions to confirm and updating the UI

Fetching relevant state

We need to grab a bunch of information – the payment token info, the mint price, total issued, balance of the user, allowance – in order to know what to display. Luckily, we've already written hooks to get most of this information!

tsx
// Get the address of the HelloPyusd contract and the payment token
const nftAddress = useHelloPyusdAddress();
const paymentTokenAddress = usePaymentTokenAddress();

// Grab the token, mint price, and total issued amount using our custom hooks
const paymentToken = useToken(paymentTokenAddress);
const mintPrice = useMintPrice();
const totalIssued = useTotalIssued();

// Get the user's balance of the payment token and the current allowance for the minting contract
const paymentTokenBalance = useTokenBalance(
  paymentTokenAddress,
  account.address
);
const allowance = useAllowance(
  paymentTokenAddress,
  account.address,
  nftAddress
);

PYUSD approval transaction

Viem and wagmi make it easy to simulate and transactions, which lets us ensure a transaction is likely to succeed before presenting the user with an option to perform it.

Let's use our simulateApprove function to check if the user is able to approve. We'll also instantiate a writeApprove function to handle the actual writing to the contract.

tsx
// Check whether the user would be able to successfully approve PYUSD for the minting contract
const simulateApprove = useSimulateApprove(
  paymentTokenAddress,
  nftAddress,
  mintPrice.data
);

// Set up for the actual writing to the contract
const writeApprove = useWriteContract();

// And set up a listener for the transaction receipt.
// This only becomes enabled once the transaction has been submitted
// and the `data` field is populated with the transaction hash
const writeApproveTxnReceipt = useWaitForTransactionReceipt({
  hash: writeApprove.data,
});

Later on, we'll use use these values to determine what to display to the user.

tsx
// If we can approve, we'll display a button to approve
if (simulateApprove.isSuccess) {
  return (
    <MintButtonView
      onClick={() =>
        toastWrite(
          writeApprove.writeContractAsync(simulateApprove.data.request)
        )
      }
    >
      Mint
    </MintButtonView>
  );
}

Mint transaction

We'll do the same for the minting process. We'll simulate the mint, then write the mint, and finally wait for the transaction to confirm.

tsx
// Check whether the user would be able to successfully mint a token
const simulateMint = useSimulateMint();

// Set up for the actual writing to the contract
const writeMint = useWriteContract();

// And set up a listener for the mint transaction receipt.
const writeMintTxnReceipt = useWaitForTransactionReceipt({
  hash: writeMint.data,
});

And we'll use these values to determine what to display to the user.

tsx
// If we can mint, we'll display a button to mint
if (simulateMint.isSuccess) {
  return (
    <>
      <MintButtonView
        onClick={() =>
          toastWrite(writeMint.writeContractAsync(simulateMint.data.request))
        }
      >
        Mint HIPYUSD
      </MintButtonView>
    </>
  );
}

Confirming in the UI

We'll use those waitApproveTxnReceipt to show a loading toast message once the transaction has settled, and to reset queries so that our UI updates. For example, with a successful approval, we'll reset the queries for the simulation and the allowance, and display a success message.

tsx
// We have a custom hook for observing changes to the query result
// so we can reset our state and display a toast message
useChangeObserver(writeApproveTxnReceipt.status, "success", async () => {
  queryClient.resetQueries({ queryKey: simulateApprove.queryKey });
  queryClient.resetQueries({ queryKey: allowance.queryKey });
  toast.dismiss();
  toast.success(
    (paymentToken.data?.symbol || "Token") +
      " approved: " +
      shortHash(writeApproveTxnReceipt.data!.transactionHash)
  );

  // After a successful approval, we want to reset our simulation
  // and if its successful, we want to mint the token
  const simulate = await simulateMint.refetch();
  if (simulate.isSuccess) {
    toastWrite(writeMint.writeContractAsync(simulate.data.request));
  }
});

// We also watch for errors so we can display a toast message
useChangeObserver(writeApproveTxnReceipt.status, "error", () => {
  toast.dismiss();
  toast.error("Transaction failed");
});

Note in particular that if it is successful we will then refetch the mint simulation and attempt an immediate write of the mint. This saves the user a click and makes the process feel quicker.

Similarly, we'll do the same waiting on the minting transaction. Notice that after a successful mint, we reset all the queries that we used to display the mint button, and display a success message.

tsx
useChangeObserver(writeMintTxnReceipt.status, "success", () => {
  queryClient.resetQueries({ queryKey: simulateMint.queryKey });
  queryClient.resetQueries({ queryKey: simulateApprove.queryKey });
  queryClient.resetQueries({ queryKey: paymentTokenBalance.queryKey });
  queryClient.resetQueries({ queryKey: allowance.queryKey });
  queryClient.resetQueries({ queryKey: totalIssued.queryKey });
  queryClient.resetQueries({ queryKey: nativeBalance.queryKey });

  toast.dismiss();
  toast.success(
    "Mint success: " + shortHash(writeMintTxnReceipt.data!.transactionHash)
  );
});

useChangeObserver(writeMintTxnReceipt.status, "error", () => {
  toast.dismiss();
  toast.error("Transaction failed");
});

Other odds and ends

We won't cover every part of the file completely, but one other thing we do is calculate the costs for both the approval and minting transactions. We use this to make sure the user has enough balance to pay for gas and/or the token. If they don't, we'll display a message to the user:

tsx
const APPROVAL_GAS_REQUIREMENT = 100000n;
const MINT_GAS_REQUIREMENT = 150000n;

///...

const gasPrice = useGasPrice();

///...

const insufficientAllowance =
  simulateMint.error?.name === "ContractFunctionExecutionError" &&
  /insufficient allowance/.test(simulateMint.error.shortMessage);

// Estimate the total gas cost for the minting transaction.
// We use this just to make sure the user has enough native balance to pay for gas
const totalGasCost =
  gasPrice.data *
  (MINT_GAS_REQUIREMENT +
    (insufficientAllowance ? APPROVAL_GAS_REQUIREMENT : 0n));

// If the user doesn't have enough native balance to pay for gas, we'll display a message
if (nativeBalance.data.value < totalGasCost) {
  return (
    <MintButtonView disabled>
      Insufficient {nativeBalance.data.symbol} for gas
    </MintButtonView>
  );
}

// If the user doesn't have enough of the payment token balance to pay for the mint price,
// we'll display a message
if (paymentTokenBalance.data < mintPrice.data) {
  return (
    <MintButtonView disabled>
      Insufficient {paymentToken.data.symbol}
    </MintButtonView>
  );
}

The complete MintButton

Here's our final MintButton.tsx:

tsx
import { useConnectModal } from "@rainbow-me/rainbowkit";
import { useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import {
  useAccount,
  useBalance,
  useGasPrice,
  useWaitForTransactionReceipt,
  useWriteContract,
} from "wagmi";
import { WriteContractData } from "wagmi/query";
import { useChangeObserver } from "../hooks/changeObserver";
import {
  useAllowance,
  useSimulateApprove,
  useToken,
  useTokenBalance,
} from "../hooks/erc20";
import {
  useHelloPyusdAddress,
  useMintPrice,
  useSimulateMint,
  useTotalIssued,
} from "../hooks/helloPyusd";
import { usePaymentTokenAddress } from "../hooks/paymentToken";
import { shortHash } from "../util/format";
import MintButtonView from "./MintButtonView";

const APPROVAL_GAS_REQUIREMENT = 100000n;
const MINT_GAS_REQUIREMENT = 150000n;

export default function MintButton() {
  // Get the query client. We'll use this to reset queries after a successful write
  const queryClient = useQueryClient();

  // Get the current connected account, if any
  const account = useAccount();

  // Get a function to open the connect modal, if needed
  const { openConnectModal } = useConnectModal();

  // Get the address of the HelloPyusd contract and the payment token
  const nftAddress = useHelloPyusdAddress();
  const paymentTokenAddress = usePaymentTokenAddress();

  // Grab the token, mint price, and total issued amount using our custom hooks
  const paymentToken = useToken(paymentTokenAddress);
  const mintPrice = useMintPrice();
  const totalIssued = useTotalIssued();

  // Get the user's balance of the payment token and the current allowance for the minting contract
  const paymentTokenBalance = useTokenBalance(
    paymentTokenAddress,
    account.address
  );
  const allowance = useAllowance(
    paymentTokenAddress,
    account.address,
    nftAddress
  );

  // Check whether the user would be able to successfully approve PYUSD for the minting contract
  const simulateApprove = useSimulateApprove(
    paymentTokenAddress,
    nftAddress,
    mintPrice.data
  );

  // Set up for the actual writing to the contract
  const writeApprove = useWriteContract();

  // And set up a listener for the transaction receipt.
  // This only becomes enabled once the transaction has been submitted
  // and the `data` field is populated with the transaction hash
  const writeApproveTxnReceipt = useWaitForTransactionReceipt({
    hash: writeApprove.data,
  });

  // Check whether the user would be able to successfully mint a token
  const simulateMint = useSimulateMint();

  // Set up for the actual writing to the contract
  const writeMint = useWriteContract();

  // And set up a listener for the mint transaction receipt.
  const writeMintTxnReceipt = useWaitForTransactionReceipt({
    hash: writeMint.data,
  });

  // get user's native balance and current gas price so we can calculate whethr they'll have enough to
  // pay gas for the approval / minting transaction
  const nativeBalance = useBalance({
    address: account.address,
  });
  const gasPrice = useGasPrice();

  // We want to consistently display the same toast messages for all writes, so we'll use a helper function
  // that takes a promise (via writeContractAsync) and displays a toast message based on promise resolution status
  const toastWrite = (p: Promise<WriteContractData>) => {
    toast.promise(
      p,
      {
        loading: "Submitting txn...",
        success: "Txn submitted...",
        error: "Canceled",
      },
      {
        loading: {},
        success: {
          icon: "🚀",
        },
        error: {
          icon: "❌",
        },
      }
    );
  };

  // We have a custom hook for observing changes to the query result
  // so we can reset our state and display a toast message
  useChangeObserver(writeApproveTxnReceipt.status, "success", async () => {
    queryClient.resetQueries({ queryKey: simulateApprove.queryKey });
    queryClient.resetQueries({ queryKey: allowance.queryKey });
    toast.dismiss();
    toast.success(
      (paymentToken.data?.symbol || "Token") +
        " approved: " +
        shortHash(writeApproveTxnReceipt.data!.transactionHash)
    );

    // After a successful approval, we want to reset our simulation
    // and if its successful, we want to mint the token
    const simulate = await simulateMint.refetch();
    if (simulate.isSuccess) {
      toastWrite(writeMint.writeContractAsync(simulate.data.request));
    }
  });

  // We have a custom hook for observing changes to the transaciton
  // receipt query so we can reset our state and display a toast message
  useChangeObserver(writeMintTxnReceipt.status, "success", () => {
    queryClient.resetQueries({ queryKey: simulateMint.queryKey });
    queryClient.resetQueries({ queryKey: simulateApprove.queryKey });
    queryClient.resetQueries({ queryKey: paymentTokenBalance.queryKey });
    queryClient.resetQueries({ queryKey: allowance.queryKey });
    queryClient.resetQueries({ queryKey: totalIssued.queryKey });
    queryClient.resetQueries({ queryKey: nativeBalance.queryKey });

    toast.dismiss();
    toast.success(
      "Mint success: " + shortHash(writeMintTxnReceipt.data!.transactionHash)
    );
  });

  // We also watch for errors so we can display a toast message
  useChangeObserver(writeApproveTxnReceipt.status, "error", () => {
    toast.dismiss();
    toast.error("Transaction failed");
  });
  useChangeObserver(writeMintTxnReceipt.status, "error", () => {
    toast.dismiss();
    toast.error("Transaction failed");
  });

  /// Rendering logic

  // If the user isn't connected, we'll display a button to connect
  if (!account.isConnected) {
    return (
      <MintButtonView onClick={openConnectModal}>
        Connect to Mint
      </MintButtonView>
    );
  }

  // If any of the queries are still loading, we'll display a loading message
  if (
    nativeBalance.isError ||
    mintPrice.isError ||
    paymentToken.isError ||
    paymentTokenBalance.isError ||
    gasPrice.isError
  ) {
    return <MintButtonView disabled>Error loading data...</MintButtonView>;
  }

  // If any of the queries are still loading, we'll display a loading message
  if (
    !nativeBalance.isSuccess ||
    !mintPrice.isSuccess ||
    !paymentToken.isSuccess ||
    !paymentTokenBalance.isSuccess ||
    !gasPrice.isSuccess
  ) {
    return <MintButtonView disabled>Loading...</MintButtonView>;
  }

  // Check if the mint simulation failed due to insufficient allowance
  const insufficientAllowance =
    simulateMint.error?.name === "ContractFunctionExecutionError" &&
    /insufficient allowance/.test(simulateMint.error.shortMessage);

  // Estimate the total gas cost for the minting transaction.
  // We use this just to make sure the user has enough native balance to pay for gas
  const totalGasCost =
    gasPrice.data *
    (MINT_GAS_REQUIREMENT +
      (insufficientAllowance ? APPROVAL_GAS_REQUIREMENT : 0n));

  // If the user doesn't have enough native balance to pay for gas, we'll display a message
  if (nativeBalance.data.value < totalGasCost) {
    return (
      <MintButtonView disabled>
        Insufficient {nativeBalance.data.symbol} for gas
      </MintButtonView>
    );
  }

  // If the user doesn't have enough of hte payment token balance to pay for the mint price,
  // we'll display a message
  if (paymentTokenBalance.data < mintPrice.data) {
    return (
      <MintButtonView disabled>
        Insufficient {paymentToken.data?.symbol}
      </MintButtonView>
    );
  }

  // If the minting is pending or we're fetching the transaction receipt, we'll display a loading message
  if (writeMint.isPending || writeMintTxnReceipt.isFetching) {
    return <MintButtonView disabled>Minting...</MintButtonView>;
  }

  // If the approval is pending or we're fetching the transaction receipt, we'll display a loading message
  if (writeApprove.isPending || writeApproveTxnReceipt.isFetching) {
    return <MintButtonView disabled>Approving...</MintButtonView>;
  }

  // If we can mint, we'll display a button to mint
  if (simulateMint.isSuccess) {
    return (
      <>
        <MintButtonView
          onClick={() =>
            toastWrite(writeMint.writeContractAsync(simulateMint.data.request))
          }
        >
          Mint HIPYUSD
        </MintButtonView>
      </>
    );
  }

  // If we can approve, we'll display a button to approve
  if (simulateApprove.isSuccess) {
    return (
      <MintButtonView
        onClick={() =>
          toastWrite(
            writeApprove.writeContractAsync(simulateApprove.data.request)
          )
        }
      >
        Mint
      </MintButtonView>
    );
  }
}