10

I want to create a payable token which includes a function transferAndCall(TokenReceiver to, uint256 amount, bytes4 selector). By calling this function, you can transfer tokens to the TokenReceiver smart contract address, and then call onTransferReceived(address from,uint tokensPaid, bytes4 selector) on the receiver, which in turn invokes a function specified in thebytes4 selector on the receiver.

Note that this is similar to/ inspired by ERC1363.

Here is a simplified version of my receivable token:

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MeowToken is ERC20 {
  constructor() ERC20("MeowToken", "MEO") {
    ERC20._mint(msg.sender, 10_000_000);
  }
  function transferAndCall(
    TokenReceiver to,
    uint256 amount,
    bytes4 selector
  ) external {
    ERC20.transfer(address(to), amount);
    to.onTransferReceived(msg.sender, amount, selector);
  }
}

And this is a token receiver:

contract TokenReceiver {
  address acceptedToken;
  event PurchaseMade(address from, uint tokensPaid);
    
  modifier acceptedTokenOnly () {
    require(msg.sender == address(acceptedToken), "Should be called only via the accepted token");
    _;
  }
    
  constructor(address _acceptedToken) {
    acceptedToken = _acceptedToken;
  }
    
  function onTransferReceived(
    address from,
    uint tokensPaid,
    bytes4 selector
  ) public acceptedTokenOnly {
    (bool success,) = address(this).call(abi.encodeWithSelector(selector, from, tokensPaid));
    require(success, "Function call failed");
  }

  function purchase(address from, uint tokensPaid) public acceptedTokenOnly {
    emit PurchaseMade(from, tokensPaid);
  }
}

I want to make sure that public functions on the receiver are only called via the payable token. For this reason I added acceptedTokenOnly modifier to both of them. However after adding the modifier my test began to fail:

it('Transfer Tokens and call Purchase', async () => {
    const tokenAmount = 100;
    const tx = meowToken.transferAndCall(
      tokenReceiver.address,
      tokenAmount,
      tokenReceiver.interface.getSighash('purchase'),
    );
    await expect(tx)
      .to.emit(tokenReceiver, 'PurchaseMade')
      .withArgs(deployer.address, tokenAmount);
});
1) Transfer and call
       Transfer Tokens and call Purchase:
     Error: VM Exception while processing transaction: reverted with reason string 'Function call failed'

Why does this happen? How to make sure the receiver's functions are invoked only by the accepted token?

For reference, I am developing and testing smart contracts in Hardhat and deploying on RSK.

Aleks Shenshin
  • 2,117
  • 5
  • 18

3 Answers3

9

When you're doing this:

(bool success,) = address(this).call(abi.encodeWithSelector(selector, from, tokensPaid));

you're making an external call, meaning that msg.sender will become address(this).

Now the modifier acceptedTokenOnly during function purchase will fail since msg.sender isn't the token anymore.

Suggested changing the function to this:

  function purchase(address from, uint tokensPaid) public {
    require(msg.sender == address(this), "wrong sender");
    emit PurchaseMade(from, tokensPaid);
  }
0xSanson
  • 718
  • 1
  • 2
  • 11
7

The problem is, you are using low level call method, here: ​

    (bool success,) = address(this).call(abi.encodeWithSelector(selector, from, tokensPaid));

​ This changes the value of msg.sender inside onTransferReceived from the accepted token to the receiver itself.

Here is one way to achieve what you want: ​ Replace call with delegatecall. This will solve your problem instantly. Unlike call, the delegatecall will invoke your function on behalf of the caller smart contract: ​

  function onTransferReceived(
    address from,
    uint tokensPaid,
    bytes4 selector
  ) public acceptedTokenOnly {
    (bool success,) = address(this).delegatecall(abi.encodeWithSelector(selector, from, tokensPaid));
    require(success, "Function call failed");
  }
5

Apart from switching from call to delegatecall, as mentioned in @Juan's answer, there is a more "manual" approach: ​ Do not use call altogether, and instead invoke the functions by name. This can be accomplished using an if ... else control structure that compares the selector parameter with the intended function selector (purchase): ​

  function onTransferReceived(
    address from,
    uint tokensPaid,
    bytes4 selector
  ) public acceptedTokenOnly {
    if (selector == this.purchase.selector) {
      purchase(from, tokensPaid);
    } else {
      revert("Call of an unknown function");
    }
  }

​ While this is more tedious to do, it might be preferable from a security point of view. For example, if you wish to white-list the functions that you allow to be called through this mechanism. Note that the approach using call/ delegatecall exposes a potential vulnerability for arbitrary (and possibly unintended) function execution.

Gino Osahon
  • 399
  • 1
  • 6