50889 sc low arctokenpurchase withdrawunsoldarctokens fails to reduce totalamountforsale leaving availability counters wrong

Submitted on Jul 29th 2025 at 11:56:37 UTC by @Paludo0x for Attackathon | Plume Network

  • Report ID: #50889

  • Report Type: Smart Contract

  • Report severity: Low

  • Target: https://github.com/immunefi-team/attackathon-plume-network/blob/main/arc/src/ArcTokenPurchase.sol

  • Impacts:

    • Theft of gas

Description

Vulnerability Details

ArcTokenPurchase::withdrawUnsoldArcTokens() allows the token admin to pull unsold ArcTokens out of the contract without decreasing totalAmountForSale.

The helper getMaxNumberOfTokens() is intended to report how many ArcToken base-units are still available in an ongoing sale:

getMaxNumberOfTokens
function getMaxNumberOfTokens(
    address _tokenContract
) external view returns (uint256) {
    TokenInfo storage info = _getPurchaseStorage().tokenInfo[_tokenContract];
    return info.totalAmountForSale - info.amountSold;
}

Because withdrawUnsoldArcTokens() does not decrement totalAmountForSale when transferring tokens out, front-ends that call getMaxNumberOfTokens() will display inflated availability. Honest buyers may then submit buy() transactions which revert with ContractBalanceInsufficient, wasting gas.

Impact Details

Adjust accounting whenever unsold tokens are withdrawn. Example patch:

Suggested fix
function withdrawUnsoldArcTokens(...)
    external onlyTokenAdmin(_tokenContract)
{
    TokenInfo storage info = ps.tokenInfo[_tokenContract];

    if (amount > (info.totalAmountForSale - info.amountSold)) {
        revert InsufficientUnsoldTokens();
    }

    info.totalAmountForSale -= amount;      // ← keep quota consistent
    token.safeTransfer(to, amount);
}

Proof of Concept

Relevant snippet from the contract showing current behavior:

withdrawUnsoldArcTokens (current)

```solidity /** * @dev Allows the token admin to withdraw unsold ArcTokens after a sale (or if disabled). * @param _tokenContract The ArcToken contract address. * @param to The address to send the tokens to. * @param amount The amount of ArcTokens to withdraw. */ function withdrawUnsoldArcTokens( address _tokenContract, address to, uint256 amount ) external onlyTokenAdmin(_tokenContract) { if (to == address(0)) { revert CannotWithdrawToZeroAddress(); } if (amount == 0) { revert AmountMustBePositive(); }

ArcToken token = ArcToken(_tokenContract);
uint256 contractBalance = token.balanceOf(address(this));
if (contractBalance < amount) {
    revert InsufficientUnsoldTokens();
}

bool success = token.transfer(to, amount);
if (!success) {
    revert ArcTokenWithdrawalFailed();
}

}

Was this helpful?