Summary
FjordAuction::auctionEnd
and FjordAuction::claimTokens
will revert due to the contract token balance being less than the attempted transfer amount.
Vulnerability Details
The following is from the known issue of the contract which I believe does not cover this situation:
Additional Fees: Some ERC20 tokens include mechanisms like transfer fees, burn rates, or automatic redistribution to holders. When such a token is used in the auction, these fees could be inadvertently passed on to users, resulting in higher-than-expected costs.
PoC
Place the following contract into mocks
folder with the filename ERC20FeeOnTransferMock.sol
pragma solidity =0.8.21;
import { ERC20 } from "lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
contract ERC20FeeOnTransferMock is ERC20 {
address public constant FEE_ADDRESS = 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045;
constructor() ERC20("Fee Token", "FOTM") { }
function transferFrom(address from, address to, uint256 amount)
public
virtual
override
returns (bool)
{
address spender = _msgSender();
_spendAllowance(from, spender, amount);
amount -= 100 wei;
_transfer(from, FEE_ADDRESS, 100 wei);
_transfer(from, to, amount);
return true;
}
function transfer(address to, uint256 amount) public virtual override returns (bool) {
address owner = _msgSender();
amount -= 100 wei;
_transfer(owner, FEE_ADDRESS, 100 wei);
_transfer(owner, to, amount);
return true;
}
}
Place the following test and import into auction.t.sol
import { ERC20FeeOnTransferMock } from "../mocks/ERC20FeeOnTransferMock.sol";
import { AuctionFactory } from "src/FjordAuctionFactory.sol";
The following test shows what happens when there are no bids:
function test_AuctionTokensGetStuckNoBids() public {
ERC20FeeOnTransferMock feeOnTransferToken = new ERC20FeeOnTransferMock();
AuctionFactory auctionFactory = new AuctionFactory(address(fjordPoints));
deal(address(feeOnTransferToken), address(this), totalTokens);
feeOnTransferToken.approve(address(auctionFactory), totalTokens);
uint256 auctionBiddingTime = 0;
bytes32 salt = bytes32(uint256(1234));
vm.recordLogs();
auctionFactory.createAuction(
address(feeOnTransferToken), auctionBiddingTime, totalTokens, salt
);
Vm.Log[] memory entries = vm.getRecordedLogs();
console.log("The length of the recorded events is: ", entries.length);
console.log(
"The address of the deployed auction is: ",
address(uint160(uint256(entries[2].topics[1])))
);
address newAuctionAddress = address(uint160(uint256(entries[3].topics[1])));
feeOnTransferToken.balanceOf(newAuctionAddress);
FjordAuction newAuction = FjordAuction(newAuctionAddress);
skip(1);
vm.expectRevert("ERC20: transfer amount exceeds balance");
newAuction.auctionEnd();
}
Next the following test show what will happen when there are bids and user claim their tokens after an auction:
function test_AuctionTokensGetStuckWithBidder() public {
ERC20FeeOnTransferMock feeOnTransferToken = new ERC20FeeOnTransferMock();
AuctionFactory auctionFactory = new AuctionFactory(address(fjordPoints));
deal(address(feeOnTransferToken), address(this), totalTokens);
feeOnTransferToken.approve(address(auctionFactory), totalTokens);
uint256 auctionBiddingTime = 5 hours;
bytes32 salt = bytes32(uint256(1234));
address[] memory bidders = new address[](4);
bidders[0] = makeAddr("bidder1");
bidders[1] = makeAddr("bidder2");
bidders[2] = makeAddr("bidder3");
bidders[3] = makeAddr("bidder4");
uint256 pointAmount = 100 ether;
vm.recordLogs();
auctionFactory.createAuction(
address(feeOnTransferToken), auctionBiddingTime, totalTokens, salt
);
Vm.Log[] memory entries = vm.getRecordedLogs();
console.log("The length of the recorded events is: ", entries.length);
console.log(
"The address of the deployed auction is: ",
address(uint160(uint256(entries[2].topics[1])))
);
address newAuctionAddress = address(uint160(uint256(entries[3].topics[1])));
feeOnTransferToken.balanceOf(newAuctionAddress);
FjordAuction newAuction = FjordAuction(newAuctionAddress);
for (uint256 i = 0; i < bidders.length; i++) {
deal(address(fjordPoints), bidders[i], pointAmount);
vm.startPrank(bidders[i]);
fjordPoints.approve(address(newAuctionAddress), pointAmount);
newAuction.bid(pointAmount);
vm.stopPrank();
}
skip(5.1 hours);
newAuction.auctionEnd();
vm.prank(bidders[0]);
newAuction.claimTokens();
vm.prank(bidders[1]);
newAuction.claimTokens();
vm.prank(bidders[2]);
newAuction.claimTokens();
vm.startPrank(bidders[3]);
vm.expectRevert("ERC20: transfer amount exceeds balance");
newAuction.claimTokens();
}
Run the test with: forge test --mt test_AuctionTokensGetStuck
Impact
Auction Tokens will be locked in the contract.
If the totalBids == 0
and the block.timestamp < auctionEndTime
the auction will never be ended
and the owner will not receive a refund of the auction tokens.
After an auction with bids claimTokens
will always revert for the last user to claim tokens.
Tools Used
Foundry and Manual Review
Recommendations
In the constructor of FjordAuction
set the totalTokens
to current balance of the contract
- totalTokens = _totalTokens;
+ totalTokens = auctionToken.getBalance(address(this));
This is will prevent the tokens from being stuck etc. but users will still receive less tokens which is a known issue.