Learn how to create a hands-on USDC checkout system with invoices, wallet payments, smart-contract verification, backend confirmation, and a simple payment status flow.
Stablecoins are becoming one of the most important payment rails in blockchain. Unlike Bitcoin or Ether, which can move sharply in price, a dollar stablecoin is designed to track the value of the U.S. dollar. That makes stablecoins useful for payments, remittances, subscriptions, cross-border settlement, DeFi applications, gaming marketplaces, and enterprise blockchain workflows.
But accepting a stablecoin payment is not as simple as showing a wallet address and waiting for money.
A real checkout system must answer several questions:
- Which blockchain is the payment happening on?
- Which token contract represents the stablecoin?
- How much should the user pay?
- How many decimals does the token use?
- How will the merchant know which invoice was paid?
- What happens if the user sends the wrong amount?
- How many confirmations are enough?
- How does the backend mark the order as paid?
- How should refunds, disputes, and expired invoices be handled?
This tutorial will help students understand those questions by building a working USDC checkout demo.
We will use USDC on Base Sepolia testnet because it is fast, inexpensive to test, and officially listed in Circle’s testnet contract address documentation. Circle’s documentation describes USDC as a dollar-backed stablecoin that runs on multiple blockchains, and it lists Base Sepolia testnet USDC at 0x036CbD53842c5426634e7929541eC2318f3dCF7e. Circle also states that testnet USDC has no financial value and is not backed by real U.S. dollars.
What You Will Build
You will build a small checkout system with four parts:
- A USDC checkout smart contract
The contract receives invoice-based payments and emits an event containing the invoice ID, payer, amount, and timestamp. - A backend invoice server
The backend creates invoices, stores pending payment records, and listens for payment events from the blockchain. - A simple frontend checkout page
The user connects a wallet, approves USDC, pays the invoice, and sees payment status. - A verification flow
The backend confirms the transaction and marks the invoice as paid.
The final demo will behave like this:
- A student or merchant creates a new invoice for
$19.99. - The backend creates a unique invoice ID.
- The frontend displays the invoice.
- The user connects MetaMask on Base Sepolia.
- The user approves the checkout contract to spend
19.99testnet USDC. - The user calls
payInvoice. - The smart contract transfers USDC from the user to the merchant wallet.
- The contract emits an
InvoicePaidevent. - The backend sees the event and marks the invoice as paid.
- The frontend shows a success message.
This is a real learning project. It is not a production payment processor. It is designed to help students understand the mechanics behind stablecoin checkout systems.
Why Not Just Ask Users to Send USDC to a Wallet?
The simplest version of stablecoin checkout is this:
“Send 19.99 USDC to this wallet address.”
That works for informal transfers, but it is weak for checkout.
The problem is reconciliation. If ten users send 19.99 USDC to the same wallet, how does the merchant know which payment belongs to which order? Traditional banking systems solve this with references, account numbers, invoice IDs, card processor metadata, and settlement files. A direct ERC-20 token transfer does not automatically include a human-readable invoice note.
There are three common solutions:
| Approach | How it works | Pros | Cons |
|---|---|---|---|
| Unique deposit address | Generate a new wallet address per invoice | Easy to reconcile | Requires wallet/key management |
| Unique amount | Ask users to pay slightly different amounts | Simple | Bad UX and weak for real commerce |
| Checkout contract | User pays through a contract method with invoice ID | Clear event trail | Requires smart contract and gas |
In this tutorial, we will use the checkout contract approach because it teaches the most important concepts: token approvals, smart-contract calls, events, backend indexing, and invoice reconciliation.
Why Use USDC?
USDC is widely used in crypto payments and DeFi because it is designed to track the U.S. dollar and is available across multiple networks. Circle’s developer documentation also provides workflows for accepting stablecoin payments, receiving pay-ins, transferring USDC, moving USDC across chains, sponsoring gas fees, and paying gas with USDC.
For students, USDC is useful because it behaves like a normal ERC-20 token on EVM chains, but it represents a familiar unit: dollars.
Important detail: USDC uses 6 decimals, not 18 decimals like many ERC-20 tokens.
That means:
| Human amount | On-chain amount |
|---|---|
1 USDC |
1,000,000 |
19.99 USDC |
19,990,000 |
100 USDC |
100,000,000 |
This decimal difference is one of the most common mistakes students make when building stablecoin apps.
Architecture of the Demo
Our checkout system will have this structure:
User Wallet
|
| 1. approve USDC spending
v
USDC Token Contract
|
| 2. payInvoice(invoiceId, amount)
v
USDCCheckout Smart Contract
|
| 3. transferFrom(user -> merchant)
| 4. emit InvoicePaid event
v
Backend Listener
|
| 5. mark invoice as paid
v
Frontend Checkout Page
The smart contract does not create invoices. The backend creates invoices. The contract only records payment execution and emits proof that a specific invoice ID was paid.
This split is common in blockchain apps:
- off-chain systems handle product, customer, cart, tax, shipping, and order logic;
- on-chain contracts handle payment movement and verifiable events;
- backend listeners connect the two worlds.
What Students Need Before Starting
You should understand:
- basic JavaScript;
- basic Solidity;
- how ERC-20 tokens work;
- how MetaMask signs transactions;
- what a testnet is;
- how to use a terminal.
Install:
- Node.js 20 or later;
- npm;
- Git;
- VS Code;
- MetaMask;
- a Base Sepolia RPC endpoint from Alchemy, QuickNode, Coinbase Developer Platform, or another provider;
- testnet ETH on Base Sepolia;
- testnet USDC on Base Sepolia.
You will use:
- Hardhat;
- Solidity;
- OpenZeppelin;
- ethers.js;
- Express;
- a simple in-memory invoice store;
- Base Sepolia USDC.
Step 1: Create the Project
Create a new folder:
mkdir usdc-checkout-demo
cd usdc-checkout-demo
npm init -y
Install the dependencies:
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npm install @openzeppelin/contracts ethers express cors dotenv uuid
Initialize Hardhat:
npx hardhat init
Choose a JavaScript project.
Your structure should look like this:
usdc-checkout-demo/
contracts/
USDCCheckout.sol
scripts/
deploy.js
server/
index.js
frontend/
index.html
app.js
hardhat.config.js
package.json
.env
Step 2: Understand the USDC Payment Flow
Before writing the contract, understand the ERC-20 payment pattern.
Native ETH payments usually look like this:
payable(merchant).transfer(msg.value);
ERC-20 payments are different. A smart contract cannot automatically take tokens from a user unless the user first approves it.
A typical ERC-20 checkout flow has two wallet transactions:
Transaction 1: Approve
The user tells the USDC contract:
“I allow the checkout contract to spend up to 19.99 USDC from my wallet.”
Transaction 2: Pay
The user calls the checkout contract:
“Pay invoice ABC using the USDC allowance I approved.”
Then the checkout contract calls:
usdc.transferFrom(user, merchant, amount);
This two-step approval process is important. It protects users from contracts moving unlimited tokens without permission, but it also adds friction to the checkout experience.
In production, teams often improve this with account abstraction, permit-style approvals, embedded wallets, session keys, or gas sponsorship. For a beginner demo, the approve-and-pay pattern is the clearest way to learn.
Step 3: Write the USDC Checkout Smart Contract
Create contracts/USDCCheckout.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract USDCCheckout is Ownable {
using SafeERC20 for IERC20;
IERC20 public immutable usdc;
address public merchantWallet;
mapping(bytes32 => bool) public paidInvoices;
event InvoicePaid(
bytes32 indexed invoiceId,
address indexed payer,
address indexed merchant,
uint256 amount,
uint256 paidAt,
uint256 chainId
);
event MerchantWalletUpdated(
address indexed oldMerchantWallet,
address indexed newMerchantWallet
);
constructor(address _usdc, address _merchantWallet) Ownable(msg.sender) {
require(_usdc != address(0), "Invalid USDC address");
require(_merchantWallet != address(0), "Invalid merchant wallet");
usdc = IERC20(_usdc);
merchantWallet = _merchantWallet;
}
function payInvoice(bytes32 invoiceId, uint256 amount) external {
require(invoiceId != bytes32(0), "Invalid invoice ID");
require(amount > 0, "Invalid amount");
require(!paidInvoices[invoiceId], "Invoice already paid");
paidInvoices[invoiceId] = true;
usdc.safeTransferFrom(msg.sender, merchantWallet, amount);
emit InvoicePaid(
invoiceId,
msg.sender,
merchantWallet,
amount,
block.timestamp,
block.chainid
);
}
function updateMerchantWallet(address newMerchantWallet) external onlyOwner {
require(newMerchantWallet != address(0), "Invalid merchant wallet");
address oldMerchantWallet = merchantWallet;
merchantWallet = newMerchantWallet;
emit MerchantWalletUpdated(oldMerchantWallet, newMerchantWallet);
}
}
What this contract does
The contract accepts an invoiceId and an amount.
If the invoice has not already been paid, the contract transfers USDC from the payer to the merchant wallet and emits an event.
The paidInvoices mapping prevents the same invoice from being paid twice through the contract.
The InvoicePaid event gives the backend a clean on-chain record:
- invoice ID;
- payer address;
- merchant wallet;
- amount;
- timestamp;
- chain ID.
This is exactly what a checkout backend needs for reconciliation.
Step 4: Why We Use SafeERC20
OpenZeppelin’s SafeERC20 handles token-transfer behavior more safely than calling transferFrom directly.
Some ERC-20 tokens return true or false. Some revert on failure. Some older tokens do not return a value. SafeERC20 helps normalize this behavior.
USDC is a widely used token, but students should learn safe patterns early. Payment contracts should be written defensively.
Step 5: Add Hardhat Configuration
Create or update hardhat.config.js:
require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config();
const BASE_SEPOLIA_RPC_URL = process.env.BASE_SEPOLIA_RPC_URL || "";
const DEPLOYER_PRIVATE_KEY = process.env.DEPLOYER_PRIVATE_KEY || "";
module.exports = {
solidity: "0.8.24",
networks: {
baseSepolia: {
url: BASE_SEPOLIA_RPC_URL,
accounts: DEPLOYER_PRIVATE_KEY ? [DEPLOYER_PRIVATE_KEY] : [],
chainId: 84532,
},
},
};
Create .env:
BASE_SEPOLIA_RPC_URL=YOUR_BASE_SEPOLIA_RPC_URL
DEPLOYER_PRIVATE_KEY=YOUR_TESTNET_PRIVATE_KEY
MERCHANT_WALLET=YOUR_TESTNET_MERCHANT_WALLET
Never use a mainnet wallet or real private key in a tutorial project. Use a dedicated testnet wallet only.
Also add .gitignore:
node_modules
.env
artifacts
cache
Step 6: Deploy the Checkout Contract
Create scripts/deploy.js:
const hre = require("hardhat");
require("dotenv").config();
async function main() {
const merchantWallet = process.env.MERCHANT_WALLET;
if (!merchantWallet) {
throw new Error("Missing MERCHANT_WALLET in .env");
}
// Official Circle testnet USDC address for Base Sepolia.
// Always verify against Circle's latest documentation before deploying.
const BASE_SEPOLIA_USDC = "0x036CbD53842c5426634e7929541eC2318f3dCF7e";
const USDCCheckout = await hre.ethers.getContractFactory("USDCCheckout");
const checkout = await USDCCheckout.deploy(BASE_SEPOLIA_USDC, merchantWallet);
await checkout.waitForDeployment();
console.log("USDCCheckout deployed to:", await checkout.getAddress());
console.log("USDC token:", BASE_SEPOLIA_USDC);
console.log("Merchant wallet:", merchantWallet);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Deploy:
npx hardhat run scripts/deploy.js --network baseSepolia
After deployment, save the contract address.
You will need it for the frontend and backend.
Step 7: Create the Invoice Backend
The backend will:
- create an invoice;
- store the invoice in memory;
- expose an API to fetch invoice status;
- listen to blockchain events;
- mark an invoice as paid when an
InvoicePaidevent appears.
In production, you would use PostgreSQL, MySQL, MongoDB, Redis, or another persistent database. For this tutorial, we will use an in-memory object so students can focus on the payment logic.
Create server/index.js:
require("dotenv").config();
const express = require("express");
const cors = require("cors");
const { ethers } = require("ethers");
const { v4: uuidv4 } = require("uuid");
const app = express();
app.use(cors());
app.use(express.json());
const PORT = process.env.PORT || 4000;
const BASE_SEPOLIA_RPC_URL = process.env.BASE_SEPOLIA_RPC_URL;
const CHECKOUT_CONTRACT_ADDRESS = process.env.CHECKOUT_CONTRACT_ADDRESS;
if (!BASE_SEPOLIA_RPC_URL) {
throw new Error("Missing BASE_SEPOLIA_RPC_URL");
}
if (!CHECKOUT_CONTRACT_ADDRESS) {
throw new Error("Missing CHECKOUT_CONTRACT_ADDRESS");
}
const provider = new ethers.JsonRpcProvider(BASE_SEPOLIA_RPC_URL);
const checkoutAbi = [
"event InvoicePaid(bytes32 indexed invoiceId,address indexed payer,address indexed merchant,uint256 amount,uint256 paidAt,uint256 chainId)",
"function paidInvoices(bytes32 invoiceId) view returns (bool)",
];
const checkout = new ethers.Contract(
CHECKOUT_CONTRACT_ADDRESS,
checkoutAbi,
provider
);
const invoices = {};
function toUSDCUnits(amountUsdString) {
return ethers.parseUnits(amountUsdString, 6).toString();
}
function fromUSDCUnits(amount) {
return ethers.formatUnits(amount, 6);
}
app.post("/api/invoices", (req, res) => {
const { amount, description } = req.body;
if (!amount || Number(amount) <= 0) {
return res.status(400).json({ error: "Invalid amount" });
}
const invoiceUuid = uuidv4();
const invoiceIdBytes32 = ethers.id(invoiceUuid);
const amountUnits = toUSDCUnits(amount);
const invoice = {
id: invoiceUuid,
invoiceIdBytes32,
amount,
amountUnits,
description: description || "USDC checkout demo invoice",
status: "pending",
chainId: 84532,
checkoutContract: CHECKOUT_CONTRACT_ADDRESS,
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
txHash: null,
payer: null,
};
invoices[invoiceIdBytes32] = invoice;
res.json(invoice);
});
app.get("/api/invoices/:invoiceIdBytes32", (req, res) => {
const invoice = invoices[req.params.invoiceIdBytes32];
if (!invoice) {
return res.status(404).json({ error: "Invoice not found" });
}
res.json(invoice);
});
checkout.on(
"InvoicePaid",
(invoiceId, payer, merchant, amount, paidAt, chainId, event) => {
const invoiceKey = invoiceId;
const invoice = invoices[invoiceKey];
if (!invoice) {
console.log("Payment event found for unknown invoice:", invoiceKey);
return;
}
const paidAmount = amount.toString();
if (paidAmount !== invoice.amountUnits) {
invoice.status = "amount_mismatch";
invoice.payer = payer;
invoice.txHash = event.log.transactionHash;
invoice.paidAmount = fromUSDCUnits(amount);
console.log("Amount mismatch for invoice:", invoice.id);
return;
}
invoice.status = "paid";
invoice.payer = payer;
invoice.txHash = event.log.transactionHash;
invoice.paidAt = new Date(Number(paidAt) * 1000).toISOString();
console.log("Invoice paid:", invoice.id, "tx:", invoice.txHash);
}
);
app.listen(PORT, () => {
console.log(`Invoice backend running on http://localhost:${PORT}`);
});
Update .env:
BASE_SEPOLIA_RPC_URL=YOUR_BASE_SEPOLIA_RPC_URL
DEPLOYER_PRIVATE_KEY=YOUR_TESTNET_PRIVATE_KEY
MERCHANT_WALLET=YOUR_TESTNET_MERCHANT_WALLET
CHECKOUT_CONTRACT_ADDRESS=YOUR_DEPLOYED_CHECKOUT_CONTRACT
PORT=4000
Run the backend:
node server/index.js
Create a test invoice:
curl -X POST http://localhost:4000/api/invoices \
-H "Content-Type: application/json" \
-d '{"amount":"19.99","description":"Blockchain DIY Course Access"}'
You should receive JSON like this:
{
"id": "4bd1a280-56c7-4cc8-a43d-6bb96b786ddb",
"invoiceIdBytes32": "0x...",
"amount": "19.99",
"amountUnits": "19990000",
"description": "Blockchain DIY Course Access",
"status": "pending",
"chainId": 84532,
"checkoutContract": "0x...",
"createdAt": "2026-07-28T...",
"expiresAt": "2026-07-28T...",
"txHash": null,
"payer": null
}
Notice that 19.99 USDC becomes 19990000 because USDC uses 6 decimals.
Step 8: Create a Minimal Frontend
Create frontend/index.html:
<!DOCTYPE html>
<html>
<head>
<title>USDC Checkout Demo</title>
<meta charset="UTF-8" />
<style>
body {
font-family: Arial, sans-serif;
max-width: 760px;
margin: 40px auto;
padding: 20px;
line-height: 1.6;
}
button {
padding: 10px 16px;
margin: 8px 0;
cursor: pointer;
}
input {
padding: 8px;
width: 100%;
margin: 6px 0 14px;
}
.card {
border: 1px solid #ddd;
border-radius: 12px;
padding: 18px;
margin-top: 20px;
}
.success {
color: green;
font-weight: bold;
}
.pending {
color: #a66a00;
font-weight: bold;
}
.error {
color: red;
font-weight: bold;
}
code {
word-break: break-all;
}
</style>
</head>
<body>
<h1>USDC Checkout Demo</h1>
<p>
This demo creates a testnet USDC invoice, lets a user approve USDC,
pays through a checkout contract, and checks backend payment status.
</p>
<button id="connectWallet">Connect Wallet</button>
<p>Wallet: <code id="walletAddress">Not connected</code></p>
<div class="card">
<h2>Create Invoice</h2>
<label>Amount in USDC</label>
<input id="amount" value="19.99" />
<label>Description</label>
<input id="description" value="Blockchain DIY Course Access" />
<button id="createInvoice">Create Invoice</button>
</div>
<div class="card" id="invoiceCard" style="display:none;">
<h2>Invoice</h2>
<p>ID: <code id="invoiceId"></code></p>
<p>Bytes32 ID: <code id="invoiceBytes32"></code></p>
<p>Amount: <strong id="invoiceAmount"></strong> USDC</p>
<p>Status: <span id="invoiceStatus" class="pending">pending</span></p>
<button id="approveUSDC">1. Approve USDC</button>
<button id="payInvoice">2. Pay Invoice</button>
<button id="checkStatus">Check Status</button>
<p>Transaction: <code id="txHash"></code></p>
</div>
<script type="module" src="./app.js"></script>
</body>
</html>
Step 9: Add the Frontend JavaScript
Create frontend/app.js:
import { ethers } from "https://cdn.jsdelivr.net/npm/ethers@6.13.4/+esm";
const BACKEND_URL = "http://localhost:4000";
// Circle's official Base Sepolia testnet USDC address.
// Verify against Circle docs before using in new deployments.
const USDC_ADDRESS = "0x036CbD53842c5426634e7929541eC2318f3dCF7e";
// Replace this after deployment.
const CHECKOUT_CONTRACT_ADDRESS = "PASTE_YOUR_CHECKOUT_CONTRACT_ADDRESS";
const BASE_SEPOLIA_CHAIN_ID_HEX = "0x14A34"; // 84532
const usdcAbi = [
"function approve(address spender,uint256 amount) returns (bool)",
"function allowance(address owner,address spender) view returns (uint256)",
"function balanceOf(address account) view returns (uint256)",
"function decimals() view returns (uint8)"
];
const checkoutAbi = [
"function payInvoice(bytes32 invoiceId,uint256 amount) external",
"function paidInvoices(bytes32 invoiceId) view returns (bool)"
];
let provider;
let signer;
let connectedAddress;
let currentInvoice;
const walletAddressEl = document.getElementById("walletAddress");
const invoiceCardEl = document.getElementById("invoiceCard");
const invoiceIdEl = document.getElementById("invoiceId");
const invoiceBytes32El = document.getElementById("invoiceBytes32");
const invoiceAmountEl = document.getElementById("invoiceAmount");
const invoiceStatusEl = document.getElementById("invoiceStatus");
const txHashEl = document.getElementById("txHash");
async function switchToBaseSepolia() {
await window.ethereum.request({
method: "wallet_switchEthereumChain",
params: [{ chainId: BASE_SEPOLIA_CHAIN_ID_HEX }],
});
}
document.getElementById("connectWallet").onclick = async () => {
if (!window.ethereum) {
alert("Please install MetaMask.");
return;
}
provider = new ethers.BrowserProvider(window.ethereum);
await provider.send("eth_requestAccounts", []);
try {
await switchToBaseSepolia();
} catch (error) {
alert("Please add or switch to Base Sepolia in your wallet.");
console.error(error);
return;
}
signer = await provider.getSigner();
connectedAddress = await signer.getAddress();
walletAddressEl.innerText = connectedAddress;
};
document.getElementById("createInvoice").onclick = async () => {
const amount = document.getElementById("amount").value;
const description = document.getElementById("description").value;
const response = await fetch(`${BACKEND_URL}/api/invoices`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ amount, description }),
});
currentInvoice = await response.json();
if (currentInvoice.error) {
alert(currentInvoice.error);
return;
}
invoiceCardEl.style.display = "block";
invoiceIdEl.innerText = currentInvoice.id;
invoiceBytes32El.innerText = currentInvoice.invoiceIdBytes32;
invoiceAmountEl.innerText = currentInvoice.amount;
invoiceStatusEl.innerText = currentInvoice.status;
invoiceStatusEl.className = "pending";
txHashEl.innerText = "";
};
document.getElementById("approveUSDC").onclick = async () => {
if (!signer || !currentInvoice) {
alert("Connect wallet and create invoice first.");
return;
}
const usdc = new ethers.Contract(USDC_ADDRESS, usdcAbi, signer);
const userBalance = await usdc.balanceOf(connectedAddress);
if (userBalance < BigInt(currentInvoice.amountUnits)) {
alert("Insufficient testnet USDC.");
return;
}
const tx = await usdc.approve(
CHECKOUT_CONTRACT_ADDRESS,
currentInvoice.amountUnits
);
txHashEl.innerText = tx.hash;
await tx.wait();
alert("USDC approved.");
};
document.getElementById("payInvoice").onclick = async () => {
if (!signer || !currentInvoice) {
alert("Connect wallet and create invoice first.");
return;
}
const checkout = new ethers.Contract(
CHECKOUT_CONTRACT_ADDRESS,
checkoutAbi,
signer
);
const tx = await checkout.payInvoice(
currentInvoice.invoiceIdBytes32,
currentInvoice.amountUnits
);
txHashEl.innerText = tx.hash;
await tx.wait();
alert("Payment transaction confirmed. Checking backend status next.");
};
document.getElementById("checkStatus").onclick = async () => {
if (!currentInvoice) {
alert("Create invoice first.");
return;
}
const response = await fetch(
`${BACKEND_URL}/api/invoices/${currentInvoice.invoiceIdBytes32}`
);
const invoice = await response.json();
invoiceStatusEl.innerText = invoice.status;
txHashEl.innerText = invoice.txHash || "";
if (invoice.status === "paid") {
invoiceStatusEl.className = "success";
} else if (invoice.status === "amount_mismatch") {
invoiceStatusEl.className = "error";
} else {
invoiceStatusEl.className = "pending";
}
};
To run the frontend, use any simple static server. For example:
npx serve frontend
Open the local URL in your browser.
Step 10: Test the Complete Flow
Follow this checklist:
- Deploy the checkout contract.
- Add the checkout contract address to
.env. - Add the checkout contract address to
frontend/app.js. - Start the backend.
- Start the frontend.
- Connect MetaMask.
- Switch to Base Sepolia.
- Create an invoice for
19.99 USDC. - Approve USDC.
- Pay the invoice.
- Wait for confirmation.
- Click “Check Status.”
- Confirm the backend marks the invoice as paid.
If successful, you have built a working stablecoin checkout demo.
Step 11: What Happens On-Chain?
When the user clicks “Approve USDC,” the wallet sends a transaction to the USDC contract.
That transaction says:
“Allow this checkout contract to spend 19.99 USDC from my wallet.”
When the user clicks “Pay Invoice,” the wallet sends a transaction to your checkout contract.
Your checkout contract then calls:
usdc.safeTransferFrom(msg.sender, merchantWallet, amount);
The USDC contract checks:
- whether the user has enough balance;
- whether the checkout contract has enough allowance;
- whether the token transfer is valid.
If everything passes, USDC moves from the user to the merchant wallet.
Then your checkout contract emits:
event InvoicePaid(...)
The backend sees that event and updates the invoice.
This is the key lesson: the payment processor is not only the smart contract. The payment processor is the full system: invoice database, contract, wallet, event listener, and reconciliation logic.
Step 12: Handling Decimals Correctly
USDC uses 6 decimals. That means you should never treat 19.99 as a raw blockchain amount.
Use:
ethers.parseUnits("19.99", 6)
This returns:
19990000
To display it again:
ethers.formatUnits(19990000, 6)
This returns:
19.99
Students should make this a habit:
- store raw token values as integers;
- display human-readable values only in the UI;
- never use JavaScript floating-point math for token accounting;
- use
BigInt,parseUnits, andformatUnits.
Bad:
const amount = 19.99 * 1000000;
Better:
const amount = ethers.parseUnits("19.99", 6);
Financial software should avoid floating-point rounding mistakes.
Step 13: Add Invoice Expiry
The backend already stores an expiresAt field, but the smart contract does not enforce it. For a student project, backend enforcement is enough to learn the concept. For production, expiry can be enforced on-chain.
One approach:
function payInvoice(
bytes32 invoiceId,
uint256 amount,
uint256 deadline
) external {
require(block.timestamp <= deadline, "Invoice expired");
}
But this introduces a new problem: the contract must know the deadline is legitimate. Otherwise, users could pass any deadline.
A better production design would include a merchant signature over:
- invoice ID;
- amount;
- deadline;
- chain ID;
- checkout contract address.
Then the contract verifies that the merchant authorized the invoice.
That upgrade teaches students how off-chain invoices can be cryptographically authorized on-chain.
Step 14: Add Confirmations
The demo marks an invoice as paid as soon as it sees the event. Production systems usually wait for a number of block confirmations.
Why?
Because blockchains can occasionally reorganize. A transaction that appears confirmed might be replaced in rare cases. Waiting for confirmations reduces this risk.
A simple backend improvement:
const receipt = await provider.getTransactionReceipt(txHash);
if (receipt && receipt.confirmations >= 3) {
invoice.status = "paid";
}
In ethers v6, you can also fetch the current block number and compare it with the transaction’s block number.
const currentBlock = await provider.getBlockNumber();
const confirmations = currentBlock - event.log.blockNumber + 1;
if (confirmations >= 3) {
invoice.status = "paid";
}
For low-value testnet demos, one confirmation is fine. For production payments, confirmation policy should depend on the chain, amount, and business risk.
Step 15: Add a Better Payment State Machine
A real checkout system should not only say “pending” or “paid.”
Use a richer status model:
| Status | Meaning |
|---|---|
created |
Invoice created but not shown to user |
pending |
Waiting for payment |
approval_pending |
User has not approved USDC yet |
payment_submitted |
User submitted payment transaction |
confirming |
Transaction found, waiting for confirmations |
paid |
Correct amount confirmed |
underpaid |
User paid too little |
overpaid |
User paid too much |
expired |
Payment was not received in time |
refunded |
Merchant returned funds |
failed |
Payment failed |
This teaches an important lesson: blockchain payments are not just transfers. They are workflows.
Step 16: Add a Payment Verification Endpoint
Add this endpoint to the backend:
app.post("/api/invoices/:invoiceIdBytes32/verify", async (req, res) => {
const invoice = invoices[req.params.invoiceIdBytes32];
if (!invoice) {
return res.status(404).json({ error: "Invoice not found" });
}
const paidOnChain = await checkout.paidInvoices(invoice.invoiceIdBytes32);
res.json({
invoiceId: invoice.id,
invoiceIdBytes32: invoice.invoiceIdBytes32,
backendStatus: invoice.status,
paidOnChain,
});
});
Now students can compare:
- backend status;
- on-chain contract state.
This is useful because backend systems can crash, miss events, or lose memory. The blockchain remains the source of truth for whether the contract recorded the invoice as paid.
Step 17: Why the Backend Still Matters
If the blockchain is the source of truth, why use a backend at all?
Because checkout requires business context.
A smart contract does not know:
- what product was purchased;
- whether the customer email is valid;
- whether the item is in stock;
- whether tax applies;
- whether the customer is in a restricted country;
- whether the order needs shipping;
- whether the user is eligible for a refund;
- whether a subscription should be activated.
The blockchain proves payment. The backend connects payment to business logic.
That is why production stablecoin checkout systems usually combine on-chain settlement with off-chain order management.
Step 18: Refund Design
A basic refund can be handled manually from the merchant wallet.
A more advanced contract can include refund logic:
event InvoiceRefunded(
bytes32 indexed invoiceId,
address indexed recipient,
uint256 amount,
uint256 refundedAt
);
But refunds require careful design. If the contract immediately sends USDC to the merchant wallet, the contract no longer holds funds. That means the contract cannot automatically refund unless the merchant sends funds back or gives the contract allowance.
Two refund architectures are common:
Direct settlement
User pays and funds go directly to merchant.
- simple;
- low contract custody risk;
- refunds handled by merchant separately.
Escrow settlement
User pays and funds stay in contract until order is completed.
- enables automatic refunds;
- creates custody risk;
- requires more security review;
- may trigger more compliance complexity.
For a beginner checkout demo, direct settlement is safer and easier.
Step 19: Security Risks Students Must Understand
A stablecoin checkout app touches money, even if this tutorial uses testnet tokens. Students should learn the risks early.
Wrong token address
Attackers can create fake tokens named USDC. Always verify the official token contract address for the selected chain.
Wrong chain
USDC on Base Sepolia is not the same as USDC on Ethereum Sepolia or Polygon Amoy. The chain ID matters.
Decimal mistakes
USDC uses 6 decimals. Treating it like an 18-decimal token can create massive overpayment or underpayment errors.
Unlimited approvals
Users often approve unlimited token allowances. This is convenient but risky. Checkout apps should request only the amount needed where possible.
Event-only verification
Events are useful, but backend listeners can fail. Always keep a way to rescan logs.
Private-key risk
Merchant wallets and relayer wallets must be protected. Blockgeni has covered how stolen private keys, not broken blockchains, account for a major share of crypto losses. Internal teams should treat key management as a first-order security issue.
Compliance risk
Stablecoin payments may involve sanctions screening, transaction monitoring, refunds, tax reporting, consumer protection, and jurisdiction-specific licensing questions. A student demo should not be treated as legal or financial advice.
Step 20: Add Event Rescanning
A production backend should be able to recover missed events.
Example rescan logic:
app.post("/api/rescan", async (req, res) => {
const { fromBlock, toBlock } = req.body;
const filter = checkout.filters.InvoicePaid();
const events = await checkout.queryFilter(
filter,
Number(fromBlock),
toBlock ? Number(toBlock) : "latest"
);
for (const event of events) {
const { invoiceId, payer, amount, paidAt } = event.args;
const invoice = invoices[invoiceId];
if (!invoice) continue;
if (amount.toString() === invoice.amountUnits) {
invoice.status = "paid";
invoice.payer = payer;
invoice.txHash = event.transactionHash;
invoice.paidAt = new Date(Number(paidAt) * 1000).toISOString();
}
}
res.json({
rescanned: events.length,
});
});
This teaches a critical production lesson: event listeners are not enough. You also need replay and recovery tools.
Step 21: Add Unique Merchant References
Our smart contract uses a bytes32 invoice ID. The backend generates it from a UUID:
const invoiceUuid = uuidv4();
const invoiceIdBytes32 = ethers.id(invoiceUuid);
This works for a demo, but production systems should store both:
- human-readable invoice ID;
- bytes32 on-chain invoice ID;
- customer ID;
- order ID;
- amount;
- token;
- chain ID;
- contract address;
- expiry;
- transaction hash;
- payer wallet;
- payment status.
Example production invoice record:
{
"invoiceId": "INV-2026-000841",
"invoiceIdBytes32": "0x...",
"customerId": "cus_123",
"orderId": "ord_987",
"amount": "19.99",
"token": "USDC",
"tokenDecimals": 6,
"chainId": 84532,
"checkoutContract": "0x...",
"status": "paid",
"payer": "0x...",
"merchantWallet": "0x...",
"txHash": "0x...",
"createdAt": "2026-07-28T10:00:00Z",
"paidAt": "2026-07-28T10:03:00Z"
}
Good payment systems are mostly good accounting systems.
Step 22: Add a Direct Transfer Mode
Some apps do not want users to approve and call a smart contract. They simply ask users to transfer USDC to a merchant address.
You can support that too, but reconciliation is harder.
A backend can listen directly to USDC Transfer events:
const usdcAbi = [
"event Transfer(address indexed from,address indexed to,uint256 value)"
];
const usdc = new ethers.Contract(
USDC_ADDRESS,
usdcAbi,
provider
);
usdc.on("Transfer", (from, to, value, event) => {
if (to.toLowerCase() !== MERCHANT_WALLET.toLowerCase()) {
return;
}
console.log("Incoming USDC:", {
from,
to,
amount: ethers.formatUnits(value, 6),
txHash: event.log.transactionHash,
});
});
Direct transfer mode is useful for teaching, but it has a weakness: there is no invoice ID in the ERC-20 transfer event.
That is why checkout contracts, unique deposit addresses, or signed payment intents are better for real checkout systems.
Step 23: How This Connects to Account Abstraction
The previous Blockgeni DIY topic — building a gasless token transfer app using account abstraction — connects directly to this checkout demo.
In this tutorial, users still need native gas to approve USDC and pay the invoice.
In a more advanced checkout:
- the app could sponsor gas;
- the user could pay gas in USDC;
- a smart account could batch approval and payment;
- a paymaster could pay transaction fees;
- a wallet could hide chain complexity;
- a merchant could offer one-click checkout.
Circle’s developer documentation includes payment and gas workflows such as accepting stablecoin payments, receiving pay-ins, sponsoring gas fees, and paying gas with USDC. It also describes CCTP as a permissionless burn-and-mint protocol for native USDC transfers across supported blockchains, while Gateway is designed for unified USDC balances across chains.
That is where stablecoin checkout is heading: fewer visible blockchain steps, more programmable payment logic, and better user experience.
Step 24: Production Checklist
Before turning this into a real product, add:
Smart-contract controls
- audit the contract;
- restrict upgrade permissions;
- verify token addresses;
- test underpayment and replay cases;
- add emergency pause if custody is involved;
- use OpenZeppelin libraries;
- avoid unnecessary custody.
Backend controls
- use a persistent database;
- store block numbers;
- rescan missed events;
- wait for confirmations;
- reconcile on-chain and off-chain status;
- log payment attempts;
- add idempotent update logic;
- monitor RPC failures.
Frontend controls
- show chain name clearly;
- show token address or verified token symbol;
- warn users before approving;
- request exact allowance;
- show transaction hash;
- explain pending/paid/failed states;
- handle wallet rejection cleanly.
Business controls
- create refund policy;
- define expiry windows;
- manage tax reporting;
- screen high-risk wallets if required;
- comply with local regulations;
- maintain customer support records;
- never promise stablecoin settlement is risk-free.
Step 25: Student Exercises
Here are practical exercises to deepen understanding.
Exercise 1: Add invoice expiry on-chain
Add a deadline field to payInvoice and reject expired payments.
Exercise 2: Add merchant-signed invoices
Make the backend sign invoice data and require the smart contract to verify the merchant signature.
Exercise 3: Add partial payment detection
Allow the backend to classify invoices as underpaid, overpaid, or paid.
Exercise 4: Add event rescan from deployment block
Store the contract deployment block and allow the backend to rebuild payment status from logs.
Exercise 5: Add a React frontend
Replace the simple HTML frontend with React, wagmi, and viem.
Exercise 6: Add QR code support
Generate a QR code containing the checkout URL or payment request.
Exercise 7: Add account abstraction
Let users pay without holding native gas by using a smart account and paymaster.
Exercise 8: Add multichain support
Support Ethereum Sepolia, Base Sepolia, and Polygon Amoy using official USDC testnet addresses.
Exercise 9: Add refunds
Create a refund workflow and compare direct settlement versus escrow settlement.
Exercise 10: Add database persistence
Replace the in-memory invoice object with PostgreSQL or SQLite.
Step 26: Common Mistakes
Mistake 1: Using the wrong USDC contract
Always verify the token address from official documentation.
Mistake 2: Treating USDC as 18 decimals
USDC uses 6 decimals on EVM networks. Use parseUnits(amount, 6).
Mistake 3: Forgetting allowance
ERC-20 transfers through a contract require approval first.
Mistake 4: Trusting frontend status
The frontend is not the source of truth. The backend and blockchain must verify payment.
Mistake 5: Not storing block numbers
Without block numbers, rescanning events becomes harder.
Mistake 6: Ignoring failed transactions
A submitted transaction is not the same as a confirmed payment.
Mistake 7: Building without compliance awareness
Stablecoin payments may look simple technically, but real payments involve regulatory and operational obligations.
Step 27: Why This Project Matters
A stablecoin checkout demo is one of the best beginner-to-intermediate blockchain projects because it touches almost every important part of practical Web3 development:
- tokens;
- wallets;
- approvals;
- smart contracts;
- events;
- backend indexing;
- invoice logic;
- frontend UX;
- security;
- compliance;
- reconciliation.
It also shows why blockchain payments are not only about moving coins. They are about building reliable financial workflows.
Stablecoins are becoming part of the broader financial infrastructure conversation. Blockgeni has covered this shift in articles about USDC, Circle’s regulatory progress, stablecoin infrastructure, and the crypto industry’s move from speculation toward payment plumbing. A checkout demo helps students connect those market-level stories to actual code.
Final Takeaway
Building a USDC checkout demo teaches students how stablecoin payments work at the application layer.
The user does not simply “send crypto.” The user interacts with a token contract, grants allowance, pays through a checkout contract, triggers an on-chain event, and waits for backend confirmation. The merchant does not simply “receive money.” The merchant must reconcile invoices, confirm amounts, handle refunds, monitor events, protect wallets, and build operational controls.
That is the real lesson.
Stablecoin checkout is not just a smart contract problem. It is a full-stack payment engineering problem.
Once students understand this demo, they can move toward more advanced systems: gasless checkout, account abstraction, paymasters, multichain USDC transfers, CCTP, stablecoin subscriptions, micropayments, and enterprise-grade payment infrastructure.
The future of blockchain payments will not be built only by traders. It will be built by developers who understand both code and settlement.
This project is a practical first step.











