Skip to main content

How to Create an ETH-backed Stablecoin with Foundry

Updated on
Feb 15, 2025

20 min read

warning

The code in this guide is strictly for educational and demonstration purposes. It is not production-ready and should not be used in live environments.

Overview​

While centralized stablecoins like USDC and USDT dominate the market (data: Ethereum, Solana) and make up for significant part of blockchain volume today, the need for decentralized alternatives continues to grow. This guide will walk you through creating, testing, deploying, and interacting with your own ETH-backed stablecoin using Foundry v1.0 as our smart contract development toolkit, OpenZeppelin for standardized token contracts, and Chainlink for our price oracle.

Let's get started!

What You Will Do​


  • Learn about Foundry
  • Recap the changes in Foundry v1.0 and migration tips
  • Create a QuickNode endpoint + fund your wallet (get some test funds here!)
  • Write, test, deploy, and interact with stablecoin smart contract (Foundry v1.0 compatible)

What You Will Need​


  • Basic understanding of Solidity and smart contracts
  • Foundry v1.0 installed (latest version)
  • A code editor (VSCode recommended)
  • Terminal/Command Line Interface
  • Git installed
  • Node.js (latest LTS version)

Foundry​

Foundry is a smart contract development framework for building and deploying smart contracts on EVM blockchains. It is designed to make it easier for developers of all levels to create and deploy secure and efficient smart contracts.

More recently, Foundry released v1.0 which represents a major milestone in Ethereum development tooling, bringing more efficiency and stability to how we build and test smart contracts.

Foundry v1.0 Migration Tips​


tip

If you're new to Foundry, feel free to skip over this section, as it won't relate to future smart contract development work you do.

Foundry v1.0 brings changes to how we develop smart contracts. Key changes include:

  • Solc Optimizer Changes: The optimizer is now disabled by default to prevent potential optimization bugs. This means you'll need to explicitly enable it in your foundry.toml.

  • Testing Framework Updates:

    • Removed support for testFail prefix (use vm.expectRevert() instead)
    • Changed behavior of expectRevert on internal calls
    • New assertion methods and testing utilities
  • Remapping and Dependencies:

    • Stricter handling of conflicting remappings
    • New approach to managing project dependencies
    • More explicit source file handling

You won't have to worry about migrating code in this guide as it will all be v1.0 compliant. To learn more, check out this migration post from Foundry.

Project Prerequisite: Create a QuickNode Endpoint​

To communicate with the blockchain, you need access to a node. While we could run our own node, here at QuickNode, we make it quick and easy to fire up blockchain nodes. You can register for an account here.

Once logged in, create an endpoint for the Ethereum Sepolia testnet blockchain. Keep the HTTP URL handy, it should look like this:

Screenshot of Quicknode mainnet endpoint

tip

This guide is EVM-compatible. If you want to deploy your agent on another chain, pick another EVM-compatible chain (e.g., Optimism, Arbitrum, etc.) and update the wallet and RPC URL's accordingly. You can also add the Chain Prism add-on to your endpoint so you can access multiple blockchain RPC URLs within the same endpoint.

Project Prerequisite: Get ETH from QuickNode Multi-Chain Faucet​

In order to conduct activity on-chain, you'll need ETH to pay for gas fees. Since we're using the Sepolia testnet, we can get some test ETH from the Multi-Chain QuickNode Faucet.

Navigate to the Multi-Chain QuickNode Faucet and connect your wallet (e.g., MetaMask, Coinbase Wallet) or paste in your wallet address to retrieve test ETH. Note that there is a mainnet balance requirement of 0.001 ETH on Ethereum Mainnet to use the EVM faucets. You can also tweet or log in with your QuickNode account to get a bonus!

QuickNode Faucet

Stablecoin Types and Architectures​

Stablecoins have evolved from simple fiat-backed tokens like USDT (2014) to more complex mechanisms like FRAX (algorithmic-based). While USDC and USDT remain dominant in their market share (data: Ethereum, Solana), decentralized alternatives like DAI (2017; now USDS) pioneered crypto-collateralization. Notably, other algorithmic experiments like UST (2020) demonstrated the risks of untested stabilization mechanisms after its collapse in 2022.

Stablecoins typically maintain their peg through collateralization (like USDC's 1:1 USD backing), over-collateralization (like DAI's 150% ETH backing), or algorithmic (e.g., FRAX) methods. Each approach trades off between centralization, capital efficiency, and stability risks. In this guide, we'll make an ETH-backed Stablecoin. This stablecoin will not implement any collateralization thresholds or liquidation triggers and will act like this:

If ETH price = $2000 (fetched from a Chainlink oracle):


  • To mint 100 stablecoins ($100)
  • You need to deposit 0.05 ETH ($100 worth)
  • If you need to get ETH back, call the burn function and send tokens

However, if ETH price changes to $1800:


  • The 0.05 ETH in the stablecoin smart contract is now worth $90
  • Your $100 stablecoins is now technically undercollateralized (and in a production environment would cause liquidation to your position and selling pressure on the stablecoin potentially depegging it off $1)

Overcollateralized (DAI Model)​

While in other scenarios, such as DAI:

Same ETH drop from $2000 to $1800:


  • Each $1 of DAI still backed by overcollateralized amount of ETH (i.e., %150)
  • Market maintains confidence in $1 peg
  • If ETH keeps dropping, liquidations kick in at certain threshold
  • Liquidators buy discounted ETH by burning DAI
  • This process helps maintain the peg by:
    • Removing weak positions before they threaten peg
    • Creating DAI buy pressure during liquidations
    • Ensuring system always stays overcollateralized

1:1 Backed (USDC Model)​


  • Each USDC backed by $1 in Circle's bank accounts
  • Monthly attestations prove backing
  • Users can always redeem 1 USDC for $1 (if KYC'd)
  • Peg issues can occur when there is loss of backing asset confidence and banking/reserve issues. For example, in March 2023, USDC briefly depegged to $0.87 during the SVB crisis. Why? The market feared USDC's backing dollars in SVB might be lost.

Algorithmic (FRAX Model)​

FRAX introduced a hybrid approach, combining collateralized and algorithmic stability mechanisms. Where:

  • Partially backed by collateral (USDC, USDT, etc.).
  • Partially stabilized algorithmically via FRAX’s governance token, FXS.
  • The collateralization ratio adjusts dynamically based on market conditions.

Now let's move onto the technical coding portion of the guide.

Set Up The Project​

Now that we have an understanding of Foundry v1.0, let's get coding.

If you don't have Foundry yet, install it now. You can run the foundryup command to install the latest stable version.

Next, open your terminal command window in the directory of your choice and run the following command:

forge init my-foundry-project
cd my-foundry-project

This will create the following directory structure:

.
β”œβ”€β”€ README.md
β”œβ”€β”€ foundry.toml
β”œβ”€β”€ lib
β”œβ”€β”€ script
β”œβ”€β”€ src
└── test

Where:


  • foundry.toml: Acts as the configuration file
  • lib: Holds libraries like @openzeppelin
  • script: Store scripts such as smart contract interactions, deployments, etc.
  • src: Source code for your smart contracts (e.g., written in Solidity, Yul)
  • test: Store and run tests (one of the most important steps!)

Let's install the required library dependencies:

forge install foundry-rs/forge-std --no-commit
forge install OpenZeppelin/openzeppelin-foundry-upgrades --no-commit
forge install OpenZeppelin/openzeppelin-contracts-upgradeable --no-commit
forge install smartcontractkit/chainlink-brownie-contracts@1.1.1 --no-commit

Then, create a file called remappings.txt in the projects main directory and set these values:

@openzeppelin/contracts/=lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/
@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/
@chainlink/contracts/=lib/chainlink-brownie-contracts/contracts/

Now, before we get into coding, let's cover exactly what we'll be building next.

Writing the Stablecoin Smart Contract​

info

The Stablecoin smart contract demonstrated in this guide is not meant for production usage. Use at your own risk! It is highly recommended that you audit any smart contract code before deploying it to production environments.

Now, let's get into building. Navigate into your src folder and create a file called Stablecoin.sol.

Let's start adding each code block while explaining along the way.

Imports​

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";

We're using OpenZeppelin for the smart contract code, this includes ERC-20 implementation, ReentrancyGuard to prevent reetrancy, Pausable (i.e., stop contract activity), Ownable (access-control) and Chainlink's AggregatorV3Interface (to get price feeds for ETH/USD).

Contract Initialization + State Variables​

In our contract initialization, we set up the basic structure by inheriting from ERC20 and ReentrancyGuard. We declare our Chainlink price feed interface and define events that will help track minting and burning activities. The constructor initializes our token with the name "Simple USD" and symbol "sUSD", while also setting up our price feed connection.

contract SimpleStablecoin is ERC20, ReentrancyGuard {
AggregatorV3Interface public priceFeed;

event Minted(address user, uint256 ethAmount, uint256 tokensAmount);
event Burned(address user, uint256 tokenAmount, uint256 ethAmount);

constructor(address _priceFeed) ERC20("Simple USD", "sUSD") {
priceFeed = AggregatorV3Interface(_priceFeed);
}
}

The getEthPrice function is crucial for maintaining our stablecoin's peg to USD. It queries Chainlink's price feed for the latest ETH/USD price, ensuring we receive valid data by checking that the price is greater than zero. The function returns the price in Chainlink's standard format with 8 decimal places, converting it to uint256 for our calculations.

// Get ETH/USD price from Chainlink
function getEthPrice() public view returns (uint256) {
(, int256 price,,,) = priceFeed.latestRoundData();
require(price > 0, "Invalid price");
return uint256(price);
}

Mint Stablecoins​

The mint function allows users to create new stablecoins by depositing ETH. It's marked as payable to accept ETH deposits and includes the nonReentrant modifier for security. The function calculates the appropriate amount of tokens to mint based on the current ETH/USD price. After minting the tokens to the sender's address, it emits an event to record the transaction details.

    // Mint stablecoins by depositing ETH
function mint() external payable nonReentrant {
require(msg.value > 0, "Need ETH");

// Calculate tokens to mint (1 token = 1 USD)
uint256 ethPrice = getEthPrice();
uint256 tokensToMint = (msg.value * ethPrice) / 1e8;

_mint(msg.sender, tokensToMint);
emit Minted(msg.sender, msg.value, tokensToMint);
}

Burn Stablecoins​

The burn function complements our minting mechanism by allowing users to return their stablecoins in exchange for ETH. It includes several safety checks: verifying the token amount is valid, ensuring the user has sufficient tokens, and confirming the contract has enough ETH to complete the transaction. The function calculates the amount of ETH to return based on the current price feed data, burns the tokens, and then transfers the ETH back to the user. Like the mint function, it's protected against reentrancy attacks and emits an event to track the burn transaction.

    // Burn stablecoins to get ETH back
function burn(uint256 tokenAmount) external nonReentrant {
require(tokenAmount > 0, "Amount too low");
require(balanceOf(msg.sender) >= tokenAmount, "Not enough tokens");

// Calculate ETH to return
uint256 ethPrice = getEthPrice();
uint256 ethToReturn = (tokenAmount * 1e8) / ethPrice;

require(address(this).balance >= ethToReturn, "Not enough ETH");

_burn(msg.sender, tokenAmount);

(bool success,) = msg.sender.call{value: ethToReturn}("");
require(success, "ETH transfer failed");

emit Burned(msg.sender, tokenAmount, ethToReturn);
}

Receive() ETH​

Finally, we implement a receive function that enables our contract to accept ETH transfers. This simple but essential function is required for our contract to maintain an ETH balance, which is crucial for the burn function to operate properly. Without it, users wouldn't be able to receive ETH back when they burn their tokens.

receive() external payable {}

Then, let's call the build command to compile the contract:

forge build

Next, we'll continue to write tests (very important!) to ensure all functions and safeguards work as intended.

Writing Tests​

Create a file in your test directory called Stablecoin.t.sol and input the following code snippets in order:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "forge-std/Test.sol";
import "../src/Stablecoin.sol";
import "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";

contract MockV3Aggregator is AggregatorV3Interface {
int256 private _price;
uint8 private _decimals;

constructor(uint8 decimals_, int256 initialPrice) {
_decimals = decimals_;
_price = initialPrice;
}

function setPrice(int256 price) external {
_price = price;
}

function decimals() external view override returns (uint8) {
return _decimals;
}

function description() external pure override returns (string memory) {
return "Mock V3 Aggregator";
}

function version() external pure override returns (uint256) {
return 1;
}

function getRoundData(uint80)
external
view
override
returns (uint80, int256, uint256, uint256, uint80)
{
return (0, _price, block.timestamp, block.timestamp, 0);
}

function latestRoundData()
external
view
override
returns (uint80, int256, uint256, uint256, uint80)
{
return (0, _price, block.timestamp, block.timestamp, 0);
}
}

contract SimpleStablecoinTest is Test {
SimpleStablecoin public stablecoin;
MockV3Aggregator public mockPriceFeed;

address public user1 = address(1);
address public user2 = address(2);

uint8 public constant DECIMALS = 8;
int256 public constant INITIAL_PRICE = 2000e8; // $2000 per ETH
uint256 public constant INITIAL_ETH_BALANCE = 100 ether;

event Minted(address user, uint256 ethAmount, uint256 tokensAmount);
event Burned(address user, uint256 tokenAmount, uint256 ethAmount);

function setUp() public {
// Deploy mock price feed and stablecoin
mockPriceFeed = new MockV3Aggregator(DECIMALS, INITIAL_PRICE);
stablecoin = new SimpleStablecoin(address(mockPriceFeed));

// Setup test accounts
vm.deal(user1, INITIAL_ETH_BALANCE);
vm.deal(user2, INITIAL_ETH_BALANCE);
}

function test_InitialState() public {
assertEq(stablecoin.name(), "Simple USD");
assertEq(stablecoin.symbol(), "sUSD");
assertEq(address(stablecoin.priceFeed()), address(mockPriceFeed));
}

function test_Mint() public {
uint256 ethToMint = 1 ether;
uint256 expectedTokens = 2000e18; // 1 ETH = $2000

vm.startPrank(user1);

vm.expectEmit(true, true, true, true);
emit Minted(user1, ethToMint, expectedTokens);

stablecoin.mint{value: ethToMint}();

assertEq(stablecoin.balanceOf(user1), expectedTokens);
assertEq(address(stablecoin).balance, ethToMint);

vm.stopPrank();
}

function test_Burn() public {
// First mint some tokens
uint256 ethToMint = 1 ether;
uint256 tokensToReceive = 2000e18;

vm.startPrank(user1);
stablecoin.mint{value: ethToMint}();

// Now burn half the tokens
uint256 tokensToBurn = tokensToReceive / 2;
uint256 expectedEthReturn = 0.5 ether;

uint256 initialEthBalance = user1.balance;

vm.expectEmit(true, true, true, true);
emit Burned(user1, tokensToBurn, expectedEthReturn);

stablecoin.burn(tokensToBurn);

assertEq(stablecoin.balanceOf(user1), tokensToReceive - tokensToBurn);
assertEq(user1.balance, initialEthBalance + expectedEthReturn);
assertEq(address(stablecoin).balance, ethToMint - expectedEthReturn);

vm.stopPrank();
}

function test_PriceUpdateAffectsMinting() public {
// Set new price: $3000 per ETH (god knows when we'll get 10k)
int256 newPrice = 3000e8;
mockPriceFeed.setPrice(newPrice);

uint256 ethToMint = 1 ether;
uint256 expectedTokens = 3000e18; // 1 ETH = $3000

vm.startPrank(user1);
stablecoin.mint{value: ethToMint}();

assertEq(stablecoin.balanceOf(user1), expectedTokens);
vm.stopPrank();
}

function test_RevertWhen_MintingZeroEth() public {
vm.prank(user1);
vm.expectRevert("Need ETH");
stablecoin.mint{value: 0}();
}

function test_RevertWhen_BurningMoreThanBalance() public {
vm.startPrank(user1);
stablecoin.mint{value: 1 ether}();
vm.expectRevert("Not enough tokens");
stablecoin.burn(2001e18);
vm.stopPrank();
}

function test_RevertWhen_ContractHasInsufficientEth() public {
// First mint some tokens
vm.prank(user1);
stablecoin.mint{value: 1 ether}();

// Simulate ETH being drained from contract
vm.prank(address(stablecoin));
payable(address(0)).transfer(0.9 ether);

// Try to burn all tokens
vm.prank(user1);
vm.expectRevert("Not enough ETH");
stablecoin.burn(2000e18);
}

function test_RevertWhen_PriceFeedIsInvalid() public {
// Set price to 0
mockPriceFeed.setPrice(0);

vm.prank(user1);
vm.expectRevert("Invalid price");
stablecoin.mint{value: 1 ether}();
}

receive() external payable {}
}

To run the test, run the command:

forge test

Test results:

Ran 8 tests for test/Stablecoin.t.sol:SimpleStablecoinTest
[PASS] test_Burn() (gas: 105640)
[PASS] test_InitialState() (gas: 25011)
[PASS] test_Mint() (gas: 84326)
[PASS] test_PriceUpdateAffectsMinting() (gas: 86619)
[PASS] test_RevertWhen_BurningMoreThanBalance() (gas: 83360)
[PASS] test_RevertWhen_ContractHasInsufficientEth() (gas: 119161)
[PASS] test_RevertWhen_MintingZeroEth() (gas: 16544)
[PASS] test_RevertWhen_PriceFeedIsInvalid() (gas: 33477)
Suite result: ok. 8 passed; 0 failed; 0 skipped; finished in 4.53ms (1.89ms CPU time)

Now that our are contracts are working as intended, let's deploy them to a testnet such as Sepolia.

Deploying the Stablecoin​

In order to deploy this to a remote testnet such as Sepolia, we'll need to configure a few things first.

  1. Ensure your wallet has enough ETH to deploy and interact with your stablecoin contract
  2. Have your wallet private key and QuickNode endpoint URL handy (as you'll be using in soon)

Next, create a file in your script folder called DeployStablecoin.s.sol, and add the following code:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "forge-std/Script.sol";
import "../src/Stablecoin.sol";

contract DeployStablecoin is Script {
function run() external {
// Sepolia ETH/USD Price Feed
address priceFeedAddress = 0x694AA1769357215DE4FAC081bf1f309aDC325306;

// Start broadcasting transactions
vm.broadcast();

// Deploy the contract
SimpleStablecoin stablecoin = new SimpleStablecoin(priceFeedAddress);

console.log("Stablecoin deployed to:", address(stablecoin));
console.log("Price Feed Address:", priceFeedAddress);
}
}

This script uses Chainlink's price feed and deploys the contract to Sepolia testnet.

To deploy the contract, run the following command:

forge script script/DeployStablecoin.s.sol:DeployStablecoin --rpc-url your_rpc_url --private-key your_private_key_here --broadcast -vvvv

-vvvv is used for more verbose logging

In the end, you'll see an output such as:

##### sepolia
βœ… [Success] Hash: 0x36d0a7d8f79c305cb53ad352af0ebdaa9885b11197ff34ee23b975bf5e8fc4a0
Contract Address: 0x85eA75e5CC0d2bB78f486d0563A3CcF9b9d97968
Block: 7707830
Paid: 0.0017479095428201 ETH (1540550 gas * 1.134600982 gwei)

βœ… Sequence #1 on sepolia | Total Paid: 0.0017479095428201 ETH (1540550 gas * avg 1.134600982 gwei)


==========================

ONCHAIN EXECUTION COMPLETE & SUCCESSFUL.

Transactions saved to: /Users/ferhat/Documents/foundry/my-foundry-project/broadcast/DeployStablecoin.s.sol/11155111/run-latest.json

Sensitive values saved to: /Users/ferhat/Documents/foundry/my-foundry-project/cache/DeployStablecoin.s.sol/11155111/run-latest.json

Let's move onto the next steps which will be minting sUSD by depositing ETH. Then, we'll be burning the sUSD to retrieve back ETH.

Mint and Burn Stablecoins​

Create a file in your script directory called MintAndBurn.s.sol and input the following code:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "forge-std/Script.sol";
import "../src/Stablecoin.sol";

contract MintAndBurn is Script {
SimpleStablecoin stablecoin;
address constant STABLECOIN_ADDRESS = YOUR_CONTRACT_ADDRESS; // Replace with your contract address

function setUp() public {
stablecoin = SimpleStablecoin(payable(STABLECOIN_ADDRESS));
}

function mint() public {
vm.broadcast();
uint256 mintAmount = 0.01 ether; // Change as needed
stablecoin.mint{value: mintAmount}();

uint256 balance = stablecoin.balanceOf(msg.sender);
console.log("Minted tokens with", mintAmount, "ETH");
console.log("Current balance:", balance);
}

function burn() public {
uint256 currentBalance = stablecoin.balanceOf(msg.sender);
require(currentBalance > 0, "No tokens to burn");

uint256 burnAmount = currentBalance / 2; // Burn half of balance

vm.broadcast();
stablecoin.burn(burnAmount);

uint256 newBalance = stablecoin.balanceOf(msg.sender);
console.log("Burned", burnAmount, "tokens");
console.log("New balance:", newBalance);
}

function run(string memory action) external {
require(
keccak256(bytes(action)) == keccak256(bytes("mint")) ||
keccak256(bytes(action)) == keccak256(bytes("burn")),
"Invalid action. Use 'mint' or 'burn'"
);

if (keccak256(bytes(action)) == keccak256(bytes("mint"))) {
mint();
} else {
burn();
}
}
}

Fundamentally, this script demonstrates two token operations: minting (creating sUSD by depositing ETH) and burning (destroying sUSD and retrieving back ETH).

To actually use these functions, run the following command line instructions in a terminal window within the root directory of your project.

To Mint​

forge script script/MintAndBurn.s.sol:MintAndBurn \
--sig "run(string)" "mint" \
--rpc-url https://your-quicknode-endpoint \
--private-key your_private_key_here \
--broadcast \
-vvvv

Example transaction.

To Burn​

forge script script/MintAndBurn.s.sol:MintAndBurn \
--sig "run(string)" "burn" \
--rpc-url https://your-quicknode-endpoint \
--private-key your_private_key_here \
--broadcast \
-vvvv

Example transaction.

Next Steps​


  • Overcollaterization mechanism to create a buffer in case of ETH price drops
  • Liquidation systems: Trigger positions once they drop below a given collateral ratio (e.g., %120)
  • Oracle dependency: Create systems around using multiple oracles to prevent reliability on solely one.
  • Interest rates: Implement interest rate systems to encourage and discourage minting in given times
  • Write production-ready code. You will need to explore different vulnerabilities, such as (oracle dependency, volatile price movements, etc.). It is recommended to look at existing Stablecoin projects, review their code, see what you can learn from them, and implement them in your own.
  • More tests. Then audit.

If you want to see part 2 of this guide where we implement some of the next steps highlighed above, please leave some feedback below!

Final Thoughts​

That's it! We just showed you how to create your own simple ETH-backed stablecoin using Foundry for smart contract development and testing.

If you have any questions or need help, feel free to reach out to us on our Discord or Twitter.

We ❀️ Feedback!

Let us know if you have any feedback or requests for new topics. We'd love to hear from you.

Share this guide