DeFiFoundry
20,000 USDC
View results
Submission Details
Severity: medium
Valid

Loss of tokens when auction ends with no bids

Summary

When an auction ends with no bids, the amount of tokens held by the FjordAuction contract is transferred to the owner's address. The owner address is that of the creator of the auction, i.e., the AuctionFactory contract. However, AuctionFactory contract lacks any functionality or logic to handle the transferred tokens, resulting in a complete loss of that amount of tokens.

Vulnerability Details

AuctionFactory contract is used to create a new auction by creating a FjordAuction contract. The auction constructor sets the owner as the msg.sender, which in all cases is the AuctionFactory contract address:

constructor(
address _fjordPoints,
address _auctionToken,
uint256 _biddingTime,
uint256 _totalTokens
) {
if (_fjordPoints == address(0)) {
revert InvalidFjordPointsAddress();
}
if (_auctionToken == address(0)) {
revert InvalidAuctionTokenAddress();
}
fjordPoints = ERC20Burnable(_fjordPoints);
auctionToken = IERC20(_auctionToken);
@> owner = msg.sender; // @audit - msg.sender is always the AuctionFactory contract address
auctionEndTime = block.timestamp.add(_biddingTime);
totalTokens = _totalTokens;
}

When the auction ends with no bids, all tokens are transferred to the owner of the contract (the AuctionFactory contract), as can be seen in the FjordAuction::auctionEnd function

function auctionEnd() external {
if (block.timestamp < auctionEndTime) {
revert AuctionNotYetEnded();
}
if (ended) {
revert AuctionEndAlreadyCalled();
}
ended = true;
emit AuctionEnded(totalBids, totalTokens);
@> if (totalBids == 0) {
@> auctionToken.transfer(owner, totalTokens);
return;
}
multiplier = totalTokens.mul(PRECISION_18).div(totalBids);
// Burn the FjordPoints held by the contract
uint256 pointsToBurn = fjordPoints.balanceOf(address(this));
fjordPoints.burn(pointsToBurn);
}

However, AuctionFactory contract does not contain any logic to handle the transferred tokens, resulting in a total loss.

Proof of Concept

auction.t.sol file contains a test to address the case when an auction ends with no bids. However, AuctionFactory contract is not used to create the auction, resulting in an incomplete test. For a more realistic simulation, there must be an AuctionFactory contract that creates the desired auction. This can be achieved by performing the following steps:

  1. Create the AuctionFactory contract

  2. Create the ERC20 token for the auction (it must be a mintable token)

  3. Approve the AuctionFactory contract to transfer the desired amount of tokens

  4. Create the auction

  5. Simulate the end of the auction with zero bids

  6. Check the destination of the auction tokens

Add the following imports in the auction.t.sol file

import "src/FjordAuctionFactory.sol";
import { console } from "forge-std/console.sol";
import { ERC20MintableMock } from "../mocks/ERC20MintableMock.sol";

The code for the ERC20MintableMock.sol file is simple. It must be created and allocated in the mocks directory.

// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity =0.8.21;
import { ERC20 } from "lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
import { ERC20PresetMinterPauser } from
"lib/openzeppelin-contracts/contracts/token/ERC20/presets/ERC20PresetMinterPauser.sol";
contract ERC20MintableMock is ERC20PresetMinterPauser {
constructor(string memory name_, string memory symbol_)
ERC20PresetMinterPauser(name_, symbol_)
{ }
}

In the auction.t.sol file, create the global variables shown and add the following code to the end of the setUp function.

// Global variables
AuctionFactory public auctionFactory;
address public auction2;
ERC20MintableMock public auctionToken2;
function setUp() public {
...
bytes32 salt = "0x01";
auctionFactory = new AuctionFactory(address(fjordPoints));
auctionToken2 = new ERC20MintableMock("AuctionToken2", "AUCT2");
auctionToken2.mint(address(this), totalTokens);
auctionToken2.approve(address(auctionFactory), totalTokens);
vm.recordLogs();
auctionFactory.createAuction(address(auctionToken2), biddingTime, totalTokens, salt);
Vm.Log[] memory entries = vm.getRecordedLogs();
// createAuction function does not return any value. To get the address of the
// FjordAuction contract created it is necessary to read it from the
// event emmited
auction2 = address(uint160(uint256(entries[2].topics[1])));
}

Finally, add the following test to the auction.t.sol file

function testAuctionEndWithNoBids_audit() public {
skip(biddingTime);
assertEq(auctionToken2.balanceOf(auction2), totalTokens);
assertEq(address(auctionFactory), FjordAuction(auction2).owner());
uint256 balBefore = auctionToken2.balanceOf(FjordAuction(auction2).owner());
FjordAuction(auction2).auctionEnd();
uint256 balAfter = auctionToken2.balanceOf(FjordAuction(auction2).owner());
assertEq(FjordAuction(auction2).ended(), true);
assertEq(balAfter - balBefore, totalTokens);
}

The test passes proving that the final destination of the tokens is the AuctionFactory contract.

Impact

Impact: High

Likelihood: Medium

Tools Used

Manual Review

Recommendations

It is straightforward to see that the desired address to transfer the tokens to when the auction ends with no bids is that of the owner of the AuctionFactory contract. A valid solution to the issue is to pass the desired address as a parameter to the FjordAuctioncontract constructor.

constructor(
+ address _owner,
address _fjordPoints,
address _auctionToken,
uint256 _biddingTime,
uint256 _totalTokens
) {
if (_fjordPoints == address(0)) {
revert InvalidFjordPointsAddress();
}
if (_auctionToken == address(0)) {
revert InvalidAuctionTokenAddress();
}
fjordPoints = ERC20Burnable(_fjordPoints);
auctionToken = IERC20(_auctionToken);
- owner = msg.sender;
+ owner = _owner;
auctionEndTime = block.timestamp.add(_biddingTime);
totalTokens = _totalTokens;
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 10 months ago
Submission Judgement Published
Validated
Assigned finding tags:

If no bids are placed during the auction, the `auctionToken` will be permanently locked within the `AuctionFactory`

An auction with 0 bids will get the `totalTokens` stuck inside the contract. Impact: High - Tokens are forever lost Likelihood - Low - Super small chances of happening, but not impossible

Support

FAQs

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