You are correct, but etherscan doesn't always show the exact error-message, which has led me to speculate that this is the problem (which it is not, clearly by the other fact that you've mentioned, about two other successful transactions leading the nonce to its current value).
Anyways, comparing the failed transaction with the two successful transactions, one notable difference is the target token contract, which is:
Since your contract function calls the transfer function on the target token contract, we would want to check for any differences between the transfer function in these two token contracts.
Now, as you can see in the USDC link above, this is a Proxy contract, which makes it a little more difficult to find the relevant source code.
Luckily, etherscan knows to point us to the corresponding Implementation contract (aka Logic contract), so we can easily compare the two transfer functions...
USDT's transfer function:
function transfer(address _to, uint _value) public onlyPayloadSize(2 * 32) {
uint fee = (_value.mul(basisPointsRate)).div(10000);
if (fee > maximumFee) {
fee = maximumFee;
}
uint sendAmount = _value.sub(fee);
balances[msg.sender] = balances[msg.sender].sub(_value);
balances[_to] = balances[_to].add(sendAmount);
if (fee > 0) {
balances[owner] = balances[owner].add(fee);
Transfer(msg.sender, owner, fee);
}
Transfer(msg.sender, _to, sendAmount);
}
USDC's transfer function:
function transfer(address to, uint256 value)
external
override
whenNotPaused
notBlacklisted(msg.sender)
notBlacklisted(to)
returns (bool)
{
_transfer(msg.sender, to, value);
return true;
}
function _transfer(
address from,
address to,
uint256 value
) internal override {
require(from != address(0), "ERC20: transfer from the zero address");
require(to != address(0), "ERC20: transfer to the zero address");
require(
value <= _balanceOf(from),
"ERC20: transfer amount exceeds balance"
);
_setBalance(from, _balanceOf(from).sub(value));
_setBalance(to, _balanceOf(to).add(value));
emit Transfer(from, to, value);
}
As you can see, USDT's transfer function includes a fee, while USDC's transfer function does not.
So this is the first place for you to investigate whether or not it might be causing the problem.
However, a much more critical aspect is the fact that USDT's transfer function does not return bool, as your contract function expects it to:
IERC20 token = IERC20(tokenAddress);
bool success = token.transfer(to, amount);
Now, this is a well known problem, of non-compliant ERC20 token contracts which were mostly deployed at an early phase of the ecosystem (mid-2017ish).
And apparently, USDT happens to be one of those tokens.
In order to handle this predicament, you can use OpenZeppelin's SafeERC20 Library, which allows interacting with ERC20 token contracts without having to take that non-compliance into account.
Unfortunately for you, since you've already deployed your contract, it might be a little too late.
You can either fix and redeploy it, or settle for the fact that you cannot use it for non-compliant tokens.
In this answer on ethereum.stackexchange, you can find a detailed description of:
- Calling a bool-returning function using an interface which declares it a bool-returning function
- Calling a none-returning function using an interface which declares it a bool-returning function
- Calling a bool-returning function using an interface which declares it a none-returning function
- Calling a none-returning function using an interface which declares it a none-returning function
TLDR, the results of the actions above are:
- Transaction completes successfully
- Transaction reverts with an error
- Transaction completes successfully
- Transaction completes successfully
And when your contract function takes the address of the USDT token contract as input, it essentially attempts to do #2 above, i.e., it attempts to call a function which returns nothing, using an interface which indicates that it returns bool.