OrderBook

First Flight #43
Beginner FriendlySolidity
100 EXP
View results
Submission Details
Impact: high
Likelihood: high
Invalid

Token Decimals Not Properly Normalized During Order Creation or Execution

Description

The OrderBook lacks proper token decimals normalization, which leads to mispriced orders and opens the door for deceptive trading attacks. An attacker can list low-decimal tokens at high prices, tricking buyers into overpaying. This is a critical issue for any DeFi protocol that handles multiple ERC20s with varying decimals and must be addressed both in contract logic and frontend UI.

Risk

Likelihood:

  • High – Any ERC20 with uncommon decimals (like WBTC or USDC) can trigger this issue.

Impact:

  • High – Victims can lose significant funds by overpaying in mismatched trades.

  • Critical in Real Usage – Especially if user interfaces or bots don't validate decimals.

Proof of Concept

This test highlights how the OrderBook treats all token amounts as raw integers without adjusting for token decimals. For example:

  • WETH and WSOL have 18 decimals.

  • WBTC has 8 decimals.

All are given a rawAmount = 100, which represents vastly different real values depending on the token:

  • 100 WETH = 1e-16 ETH

  • 100 WBTC = 1e-6 BTC

Despite this, the contract treats them equally. This leads to economic inconsistencies and opens the door for manipulation or unintentional overpayment.

function test_tokenDecimalsNormalizationError() public { //medium
// All tokens have different decimals: WETH (18), WBTC (8), WSOL (18)
// We'll use the same raw amount for each, but their real values differ by orders of magnitude
uint256 rawAmount = 100; // 100 units, but decimals differ
uint256 price = 1e6; // 1 USDC (6 decimals)
uint256 deadline = 1 days;
// Mint enough tokens to alice
weth.mint(alice, rawAmount);
wbtc.mint(alice, rawAmount);
wsol.mint(alice, rawAmount);
// Approve the book for all tokens
vm.startPrank(alice);
weth.approve(address(book), rawAmount);
wbtc.approve(address(book), rawAmount);
wsol.approve(address(book), rawAmount);
// Create sell orders for each token with the same raw amount
uint256 wethOrderId = book.createSellOrder(address(weth), rawAmount, price, deadline);
uint256 wbtcOrderId = book.createSellOrder(address(wbtc), rawAmount, price, deadline);
uint256 wsolOrderId = book.createSellOrder(address(wsol), rawAmount, price, deadline);
vm.stopPrank();
// Fetch orders
(OrderBook.Order memory orderWETH) = book.getOrder(wethOrderId);
(OrderBook.Order memory orderWBTC) = book.getOrder(wbtcOrderId);
(OrderBook.Order memory orderWSOL) = book.getOrder(wsolOrderId);
// Log the amounts and decimals for clarity
console2.log("WETH order amount:", orderWETH.amountToSell, "decimals:", weth.decimals());
console2.log("WBTC order amount:", orderWBTC.amountToSell, "decimals:", wbtc.decimals());
console2.log("WSOL order amount:", orderWSOL.amountToSell, "decimals:", wsol.decimals());
// Assert that the contract treats all as '100', but in reality, their values are vastly different
assertEq(orderWETH.amountToSell, rawAmount);
assertEq(orderWBTC.amountToSell, rawAmount);
assertEq(orderWSOL.amountToSell, rawAmount);
// This exposes the normalization error: 100 WBTC (8 decimals) << 100 WETH/WSOL (18 decimals)
}

This is an active exploit built on the flaw above.

  • Alice (attacker) lists 100 WBTC (8 decimals) for sale at a price suitable for 100 WETH (18 decimals).

  • Dan (victim) buys it, thinking he's getting 100 WETH worth of value, due to the displayed price.

  • But 100 WBTC (8 decimals) = 0.000001 WBTC in 18-decimal format.

  • Alice receives full payment (300,000 USDC), while Dan receives a negligible amount of actual value.

This is a successful value extraction attack via misrepresented token amounts caused by lack of decimal normalization.

function test_normalizationAttack_WBTCvsWETH() public { //medium
// Setup: attacker (alice) and victim (dan)
// Attacker will sell 100 WBTC (8 decimals) at a price that would be fair for 100 WETH (18 decimals)
// Victim will buy, thinking they're getting 100 WETH worth
// Assume 1 WETH = 3,000 USDC, so 100 WETH = 300,000 USDC
// But 100 WBTC (8 decimals) is only 0.000001 WBTC (in 18 decimals)
uint256 wbtcSellAmount = 100; // 100 WBTC (8 decimals)
uint256 price = 300_000e6; // 300,000 USDC (6 decimals)
uint256 deadline = 1 days;
// Mint WBTC to attacker (alice), USDC to victim (dan)
wbtc.mint(alice, wbtcSellAmount);
usdc.mint(dan, price);
// Approve and create sell order
vm.startPrank(alice);
wbtc.approve(address(book), wbtcSellAmount);
uint256 orderId = book.createSellOrder(address(wbtc), wbtcSellAmount, price, deadline);
vm.stopPrank();
// Log balances before
console2.log("Before buy:");
console2.log("alice USDC:", usdc.balanceOf(alice));
console2.log("dan USDC:", usdc.balanceOf(dan));
console2.log("dan WBTC:", wbtc.balanceOf(dan));
// Victim buys the order
vm.startPrank(dan);
usdc.approve(address(book), price);
book.buyOrder(orderId);
vm.stopPrank();
// Log balances after
console2.log("After buy:");
console2.log("alice USDC:", usdc.balanceOf(alice));
console2.log("dan USDC:", usdc.balanceOf(dan));
console2.log("dan WBTC:", wbtc.balanceOf(dan));
// Assert that dan received only 100 WBTC (8 decimals), which is much less than 100 WETH (18 decimals)
assertEq(wbtc.balanceOf(dan), wbtcSellAmount);
// Assert that alice received the full USDC payment
assert(usdc.balanceOf(alice) > 0);
// This demonstrates the normalization attack: dan paid as if for 100 WETH, but got 100 WBTC (8 decimals)
}

Recommended Mitigation

  • Token Decimals Normalization

  • UI + Smart Contract Warnings

  • Minimum Value Checks

function createSellOrder(
address _tokenToSell,
uint256 _amountToSell,
uint256 _priceInUSDC,
uint256 _deadlineDuration
) public returns (uint256) {
if (!allowedSellToken[_tokenToSell]) revert InvalidToken(); //token whitelist check
if (_amountToSell == 0) revert InvalidAmount();
if (_priceInUSDC == 0) revert InvalidPrice();
if (_deadlineDuration == 0 || _deadlineDuration > MAX_DEADLINE_DURATION)
revert InvalidDeadline();
+ int8 tokenDecimals = IERC20Metadata(_tokenToSell).decimals();
// Normalize to 18 decimals if token is not native 18
+ uint256 normalizedAmount = _amountToSell;
+ if (tokenDecimals < 18) {
+ uint256 factor = 10 ** (18 - tokenDecimals);
+ normalizedAmount = _amountToSell * factor;
}
uint256 deadlineTimestamp = block.timestamp + _deadlineDuration;
uint256 orderId = _nextOrderId++;
IERC20(_tokenToSell).safeTransferFrom(
msg.sender,
address(this),
_amountToSell
);
// Store the order
orders[orderId] = Order({
id: orderId,
seller: msg.sender,
tokenToSell: _tokenToSell,
+ amountToSell: normalizedAmount, // always 18-decimal scale
priceInUSDC: _priceInUSDC,
deadlineTimestamp: deadlineTimestamp,
isActive: true
});
//--------------code ----------//
}

Inside buyOrder() denormalize it before transfer :-

uint8 tokenDecimals = IERC20Metadata(order.tokenToSell).decimals();
uint256 actualAmount = order.amountToSell;
if (tokenDecimals < 18) {
uint256 factor = 10 ** (18 - tokenDecimals);
actualAmount = order.amountToSell / factor;
} else if (tokenDecimals > 18) {
revert("Unsupported: token > 18 decimals");
}
IERC20(order.tokenToSell).safeTransfer(msg.sender, actualAmount);
Updates

Lead Judging Commences

yeahchibyke Lead Judge 9 days ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.