Three months ago, my startup's CTO walked into our dev room and said, "We need our own stablecoin for the payments platform. How hard could it be?"
I laughed. Then I spent the next 48 hours straight debugging why my token contract kept reverting on transfers. Spoiler alert: I had no idea what I was doing.
Today, I'm running that same stablecoin in production, processing over $2M in transactions monthly. Here's exactly how I built it using OpenZeppelin 5.0, including every mistake that made me question my career choices.
Why I Chose OpenZeppelin 5.0 for Stablecoin Development
When I started this project, I considered building everything from scratch. After all, how complex could a stablecoin be? It's just an ERC20 token that maintains a $1 peg, right?
Wrong. So incredibly wrong.
After my first attempt resulted in a contract that could mint infinite tokens (yes, I tested this on mainnet with $50 of my own money), I realized I needed battle-tested code. OpenZeppelin 5.0 became my lifeline for three reasons:
- Security audits: My homegrown access control had more holes than Swiss cheese
- Gas optimization: My initial contract cost 2x more gas than necessary
- Upgradability: I learned the hard way that you can't fix smart contract bugs with a simple deployment
Understanding Stablecoin Architecture: What I Wish I Knew First
Before diving into code, let me share the mental model that finally clicked for me after weeks of confusion.
A stablecoin isn't just a token—it's a financial system with three critical components:
1. The Token Contract
This handles transfers, balances, and basic ERC20 functionality. I initially thought this was 90% of the work. It's actually about 30%.
2. The Minting/Burning Controller
This manages token supply based on collateral deposits. I spent 3 days debugging why my minting function worked in tests but failed in production (hint: it was a rounding error that only appeared with real ETH amounts).
3. The Price Oracle Integration
This maintains the $1 peg through algorithmic or collateralized mechanisms. I learned this the expensive way when my test stablecoin lost its peg and traded at $0.73 for two hours.
Setting Up Your Development Environment
Here's my current setup after trying every possible configuration:
# I use Node 18.17.0 - higher versions caused weird Hardhat issues
npm install --save-dev hardhat @openzeppelin/hardhat-upgrades
npm install @openzeppelin/contracts@5.0.0
# This combination saved me from dependency hell
npm install --save-dev @nomicfoundation/hardhat-toolbox
npm install dotenv
Pro tip from my painful experience: Pin your OpenZeppelin version to exactly 5.0.0. I spent 6 hours debugging compilation errors because I used ^5.0.0 and it pulled in 5.0.2, which had breaking changes.
Building the Core Stablecoin Contract
Let me walk you through my final contract architecture. This is version 4—the first three versions had critical flaws I'll explain as we go.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";
import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20BurnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20PausableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract MyStableCoin is
Initializable,
ERC20Upgradeable,
ERC20BurnableUpgradeable,
ERC20PausableUpgradeable,
AccessControlUpgradeable
{
// I learned to define roles as constants after accidentally
// typing "MINTOR_ROLE" instead of "MINTER_ROLE" in production
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE");
// This mapping saved my sanity during compliance audits
mapping(address => bool) public blacklisted;
event Blacklisted(address indexed account);
event UnBlacklisted(address indexed account);
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
function initialize(
string memory name,
string memory symbol,
address defaultAdmin,
address minter,
address pauser
) public initializer {
__ERC20_init(name, symbol);
__ERC20Burnable_init();
__ERC20Pausable_init();
__AccessControl_init();
_grantRole(DEFAULT_ADMIN_ROLE, defaultAdmin);
_grantRole(MINTER_ROLE, minter);
_grantRole(PAUSER_ROLE, pauser);
// I forgot this in version 1 and couldn't pause the contract in an emergency
_grantRole(PAUSER_ROLE, defaultAdmin);
}
Why I Chose This Inheritance Pattern
Initially, I tried to build a minimal contract with just ERC20. Big mistake. Here's what each extension solved for me:
- ERC20BurnableUpgradeable: My first version had no burn function. When users wanted to redeem USDC for my stablecoin, I had no way to remove tokens from circulation
- ERC20PausableUpgradeable: Saved me during a critical bug discovery. I could pause all transfers while deploying a fix
- AccessControlUpgradeable: My original "onlyOwner" pattern was too rigid. I needed granular permissions for different operations
Implementing Minting Logic with Collateral Management
Here's where things get interesting. My minting function evolved through four major versions:
// Version 1: Naive approach (DO NOT USE)
function mint(address to, uint256 amount) public onlyRole(MINTER_ROLE) {
_mint(to, amount);
// This was it. No collateral check, no limits. I'm embarrassed.
}
// Version 4: Production-ready (what I actually use now)
function mint(address to, uint256 amount)
public
onlyRole(MINTER_ROLE)
whenNotPaused
notBlacklisted(to)
{
require(to != address(0), "Cannot mint to zero address");
require(amount > 0, "Amount must be greater than zero");
// This check prevented a $50K mistake during testing
require(
totalSupply() + amount <= maxSupply,
"Exceeds maximum supply"
);
_mint(to, amount);
emit Minted(to, amount);
}
The evolution from version 1 to 4 happened because:
- Version 1: No safeguards. Anyone with minting rights could create infinite tokens
- Version 2: Added basic amount validation but forgot address validation
- Version 3: Added all validations but missed the supply cap check
- Version 4: Finally got it right after learning from each costly mistake
Building the Collateral Management System
This is where I spent most of my debugging time. My stablecoin is backed by ETH, and managing collateral ratios nearly broke my brain:
contract CollateralManager is AccessControlUpgradeable {
using SafeMath for uint256;
// I use 150% collateralization ratio after testing showed
// 120% led to too many liquidations during market volatility
uint256 public constant COLLATERAL_RATIO = 150;
uint256 public constant LIQUIDATION_THRESHOLD = 130;
mapping(address => uint256) public deposits;
mapping(address => uint256) public borrowed;
IStableCoin public stableCoin;
AggregatorV3Interface public priceFeed;
function depositAndMint(uint256 stablecoinAmount)
external
payable
nonReentrant
{
require(msg.value > 0, "Must deposit ETH");
require(stablecoinAmount > 0, "Must specify stablecoin amount");
uint256 ethPrice = getLatestPrice();
uint256 collateralValue = msg.value.mul(ethPrice).div(1e18);
uint256 requiredCollateral = stablecoinAmount.mul(COLLATERAL_RATIO).div(100);
require(
collateralValue >= requiredCollateral,
"Insufficient collateral"
);
deposits[msg.sender] = deposits[msg.sender].add(msg.value);
borrowed[msg.sender] = borrowed[msg.sender].add(stablecoinAmount);
stableCoin.mint(msg.sender, stablecoinAmount);
emit Deposited(msg.sender, msg.value, stablecoinAmount);
}
The Price Oracle Integration That Nearly Killed Me
Getting accurate ETH prices was harder than I expected. My first attempt used a simple API call, which failed spectacularly during a flash crash:
// My disaster of a price feed (Version 1)
function getETHPrice() public view returns (uint256) {
// This returned stale data during network congestion
return priceOracle.latestAnswer();
}
// What I use now (Version 3)
function getLatestPrice() public view returns (uint256) {
(
uint80 roundID,
int price,
uint startedAt,
uint timeStamp,
uint80 answeredInRound
) = priceFeed.latestRoundData();
// These checks saved me from using stale price data
require(timeStamp > 0, "Round not complete");
require(price > 0, "Invalid price feed");
require(block.timestamp - timeStamp < 3600, "Price too old");
return uint256(price);
}
I learned about price staleness the hard way when my contract used 6-hour-old price data during a market crash. Users were depositing ETH at outdated prices, essentially getting free stablecoins.
Implementing Blacklist Functionality for Compliance
This feature wasn't in my original spec, but our compliance team insisted on it after week 3. Adding it taught me about the importance of planning for regulatory requirements:
modifier notBlacklisted(address account) {
require(!blacklisted[account], "Account is blacklisted");
_;
}
function blacklist(address account) public onlyRole(DEFAULT_ADMIN_ROLE) {
require(account != address(0), "Cannot blacklist zero address");
require(!blacklisted[account], "Account already blacklisted");
blacklisted[account] = true;
emit Blacklisted(account);
}
function _beforeTokenTransfer(
address from,
address to,
uint256 amount
) internal override(ERC20Upgradeable, ERC20PausableUpgradeable) {
require(!blacklisted[from], "Sender is blacklisted");
require(!blacklisted[to], "Recipient is blacklisted");
super._beforeTokenTransfer(from, to, amount);
}
Pro tip: Plan for compliance from day one. Retrofitting these features after deployment using upgradeable contracts is possible but stressful.
Testing Strategy That Actually Works
My testing approach evolved from "run it and see" to a comprehensive suite after my first mainnet bug:
describe("Stablecoin Integration Tests", function () {
let stablecoin, collateralManager, mockPriceFeed;
let owner, minter, user1, user2;
beforeEach(async function () {
// I deploy fresh contracts for each test after learning
// that shared state caused intermittent test failures
[owner, minter, user1, user2] = await ethers.getSigners();
// Mock price feed for consistent testing
mockPriceFeed = await deployMockContract(owner, aggregatorV3Interface);
await mockPriceFeed.mock.latestRoundData.returns(
1, // roundId
200000000000, // price ($2000 ETH with 8 decimals)
block.timestamp,
block.timestamp,
1 // answeredInRound
);
});
it("should handle edge case: depositing with exact collateral ratio", async function () {
// This test caught a rounding error that caused reverts
const ethAmount = ethers.utils.parseEther("1");
const expectedStablecoin = ethers.utils.parseEther("1333.33");
await expect(
collateralManager.connect(user1).depositAndMint(
expectedStablecoin,
{ value: ethAmount }
)
).to.not.be.reverted;
});
});
Deployment and Upgrade Strategy
Deploying upgradeable contracts was my biggest learning curve. Here's my current deployment script that includes all the lessons I learned:
async function main() {
const [deployer] = await ethers.getSigners();
console.log("Deploying with account:", deployer.address);
console.log("Account balance:", ethers.utils.formatEther(await deployer.getBalance()));
// Deploy the implementation
const StableCoin = await ethers.getContractFactory("MyStableCoin");
// I use this specific proxy pattern after trying others and failing
const stablecoin = await upgrades.deployProxy(
StableCoin,
[
"My Stable Coin",
"MSC",
deployer.address, // admin
deployer.address, // minter
deployer.address // pauser
],
{
initializer: 'initialize',
kind: 'uups' // This saves gas compared to transparent proxies
}
);
await stablecoin.deployed();
console.log("StableCoin deployed to:", stablecoin.address);
// Verify on Etherscan automatically
if (network.name !== "hardhat") {
console.log("Waiting for block confirmations...");
await stablecoin.deployTransaction.wait(6);
try {
await hre.run("verify:verify", {
address: stablecoin.address,
constructorArguments: [],
});
} catch (error) {
console.log("Verification failed:", error.message);
}
}
}
Gas Optimization Lessons I Learned the Expensive Way
During my first week in production, I was spending $200+ per day in gas fees. Here are the optimizations that brought it down to $20:
// Expensive version (what I deployed first)
function batchTransfer(address[] memory recipients, uint256[] memory amounts) public {
for (uint i = 0; i < recipients.length; i++) {
transfer(recipients[i], amounts[i]); // Each call costs ~21k gas
}
}
// Optimized version (what I use now)
function batchTransfer(address[] calldata recipients, uint256[] calldata amounts) external {
require(recipients.length == amounts.length, "Array length mismatch");
// Cache balance to avoid repeated SLOAD operations
uint256 senderBalance = balanceOf(msg.sender);
uint256 totalAmount = 0;
// Calculate total first to fail fast
for (uint256 i = 0; i < amounts.length;) {
totalAmount += amounts[i];
unchecked { ++i; } // Gas optimization for trusted loop
}
require(senderBalance >= totalAmount, "Insufficient balance");
// Batch the transfers
for (uint256 i = 0; i < recipients.length;) {
_transfer(msg.sender, recipients[i], amounts[i]);
unchecked { ++i; }
}
}
Key optimizations that made a difference:
- Using
calldatainstead ofmemoryfor function parameters - Caching storage variables in memory
- Using
uncheckedfor trusted arithmetic operations - Failing fast on validation checks
Security Lessons: What I Got Wrong
Security wasn't just about preventing hacks—it was about preventing accidents. Here are the critical issues I discovered:
1. Reentrancy in Withdrawal Function
// Vulnerable version
function withdraw(uint256 amount) external {
require(deposits[msg.sender] >= amount, "Insufficient deposit");
deposits[msg.sender] -= amount;
payable(msg.sender).transfer(amount); // DANGEROUS: external call before state update
}
// Secure version
function withdraw(uint256 amount) external nonReentrant {
require(deposits[msg.sender] >= amount, "Insufficient deposit");
deposits[msg.sender] -= amount; // State change first
(bool success, ) = payable(msg.sender).call{value: amount}("");
require(success, "Transfer failed");
}
2. Integer Overflow in Collateral Calculation
My first version used Solidity 0.8.19 but didn't account for extreme edge cases:
// This caused overflow with very large ETH amounts
uint256 collateralValue = msg.value * ethPrice / 1e18;
// Fixed with SafeMath for extra protection
uint256 collateralValue = msg.value.mul(ethPrice).div(1e18);
Real-World Performance: Three Months Later
After three months in production, here are the metrics that matter:
- Total Volume: $2.1M processed across 15,000 transactions
- Average Gas Cost: 65,000 gas for minting, 45,000 for transfers
- Uptime: 99.97% (downtime was planned maintenance)
- Liquidations: 12 positions liquidated, all handled smoothly
- Price Stability: Maintained $0.998 - $1.002 peg 99.8% of the time
The biggest surprise? Users care more about transaction speed than gas costs. My focus on gas optimization was important, but implementing instant confirmations through proper event handling had a bigger impact on user experience.
Common Pitfalls and How to Avoid Them
Based on debugging sessions with other developers in our Discord, here are the mistakes I see repeatedly:
1. Not Testing Edge Cases
Test with weird amounts: 1 wei, maximum uint256, and everything in between. My contract failed when someone tried to mint 0.000000000000000001 tokens.
2. Forgetting About Front-Running
MEV bots found and exploited my price arbitrage opportunities within 2 blocks of deployment. Implement commit-reveal schemes for sensitive operations.
3. Inadequate Price Feed Validation
Always check for stale data, negative prices, and extreme price movements. A 50% price jump in one block is probably an oracle malfunction, not market reality.
4. Ignoring Upgrade Path Planning
Plan your upgrade strategy before deployment. I didn't think about storage layout compatibility and had to create workarounds for my first upgrade.
What I'm Building Next
My stablecoin is working, but I'm already planning v2 with features I wish I had included:
- Multi-collateral support: ETH, WBTC, and other major assets
- Automated rebalancing: Smart liquidation bots to maintain peg
- Yield farming integration: Let users earn interest on deposited collateral
- Cross-chain support: Bridge to Polygon and Arbitrum for lower fees
The most important lesson? Start simple, test everything, and iterate based on real user feedback. My perfect v1 would have taken 6 months to build and might not have addressed actual user needs.
Ready to Build Your Own?
Building a stablecoin taught me more about DeFi, smart contract security, and financial systems than any course could have. The mistakes were expensive but invaluable.
If you're starting your own stablecoin project, focus on these fundamentals first:
- Solid collateral management
- Reliable price feeds
- Comprehensive testing
- Security audits
- Regulatory compliance planning