Skip to content

External Function Calls and Low-Level Calls In-Depth

We already talked about addresses a bit, but as we're writing a wallet here, I want to cover one more - quite complex - topic before wrapping it up. It's about sending money around, and there's more than one way to do that...

So far we've talked about addresses a little bit. You know that an address has a property .balance which gives you the balance in wei. You also know that an address has a function named .transfer(...) which lets you transfer from the contract to the address an amount in wei.

Difference .send and .transfer

There is another function called .send(...) which works like .transfer(...), but with a major difference: If the target address is a contract and the transfer fails, then .transfer will result in an exception and .send will simply return false, but the transaction won't fail.

Let's try this:

//SPDX-License-Identifier: MIT

pragma solidity 0.8.15;


contract Sender {
    receive() external payable {}

    function withdrawTransfer(address payable _to) public {
        _to.transfer(10);
    }

    function withdrawSend(address payable _to) public {
        bool sentSuccessful = _to.send(10);
    }
}

contract ReceiverNoAction {

    function balance() public view returns(uint) {
        return address(this).balance;
    }

    receive() external payable {}
}

contract ReceiverAction {
    uint public balanceReceived;

    function balance() public view returns(uint) {
        return address(this).balance;
    }

    receive() external payable {
        balanceReceived += msg.value;
    }
}

Now, let's play around with this:

  1. Deploy the Sender contract
  2. fund the Sender contract with some 100 wei (hit transact to let it go to the receive function)
  3. Deploy the ReceiverNoAction and copy the contract address
  4. Send 10 wei to the ReceiverNoAction wiht withdrawTransfer. It works, because the function receive in ReceiverNoAction doesn't do anything and doesn't use up more than 2300 gas
  5. Send 10 wei to the ReceiverNoAction with withdrawSend. It also works, because the function still does not need more than 2300 gas.
  6. Deploy the ReceiverAction Smart contract and copy the contract address
  7. Send 10 Wei to the ReceiverAction with withdrawTransfer. It fails, because the contract tries to write a storage variable which costs too much gas.
  8. Send 10 Wei to the ReceiverAction with withdrawSend. The transaction doesn't fail, but it also doesn't work, which leaves you now in an odd state. 👈🏻 That's the Problem right here.

Always check the return value of low level send functions. Ideally with an require(sentSuccessful) or so.

Pull over Push

It's always better to let users withdraw money instead of pushing the funds. Consider a game. Two players play against each other. Last round, a player wins. In the normal world, you'd directly push the funds to the winning user. But that's a bad pattern. Better to credit the user and let him withdraw (pull!) the money in a separate withdraw-function later on.

Sending More Gas to Smart Contracts

Of course, it would be great if you can call smart contracts from other smart contracts and also send a value, as a well as, more gas.

There are two ways to achieve that:

  1. External function calls on contract instances
  2. Low-Level calls on the address

Let's start with the easy one, the external function call.

External Function Calls

Sometimes you want to call another smart contract. But not just that, you also want to send eth/wei to another smart contract. You're maybe wondering, how can that be achieved?

Let's have a look at this sample smart contract:

// SPDX-License-Identifier: GPL-3.0
pragma solidity 0.8.15;

contract ContractOne {

    mapping(address => uint) public addressBalances;

    function getBalance() public view returns(uint) {
        return address(this).balance;
    }

    function deposit() public payable {
        addressBalances[msg.sender] += msg.value;
    }
}

contract ContractTwo {

    function deposit() public payable {}

    function depositOnContractOne(address _contractOne) public { 
        ContractOne one = ContractOne(_contractOne);
        one.deposit{value: 10, gas: 100000}(); 
    }
}

How do we go about this?

  1. Deploy ContractOne
  2. Deploy ContractTwo
  3. Send 1 ETH to ContractTwo.deposit()
  4. Copy ContractOne Address and sent a transaction to ContractTwo.depositOnContractOne with the address from ContractOne.
  5. You see that the ContractTwo address is the one who deposited the funds
  6. And you also see that not all 100000 gas were used. The remainder was returned

You will witness that this time it works and it doesn't error out. And the reason is, because we supply 100000 gas to the ContractOne.deposit function. This, in turn, can now successfully write the storage variable.

You can also leave the gas amount, then it will forward all gas and let the called contract execute its logic and return the remainder. Safer is to provide an upper limit, just in case.

But that only works if you know:

  1. That the receiving contract is a contract
  2. And that the receiving contract has a specific function

But what if you don't know any of these? For example, a wallet withdrawTo method should possibly also work, if the receiving address is actually a contract that writes to storage variables.

This is where address.call comes in. Let's use the same example, but instead of a named contract calls, lets do this first semi-anonymous and then completely anonymous.

Low-Level Calls on Address-Type Variables

Let's change the contract from above to:

// SPDX-License-Identifier: GPL-3.0
pragma solidity 0.8.15;

contract ContractOne {

    mapping(address => uint) public addressBalances;

    function getBalance() public view returns(uint) {
        return address(this).balance;
    }

    function deposit() public payable {
        addressBalances[msg.sender] += msg.value;
    }
}

contract ContractTwo {

    function deposit() public payable {}

    function depositOnContractOne(address _contractOne) public { 
        bytes memory payload = abi.encodeWithSignature("deposit()");
        (bool success, ) = _contractOne.call{value: 10, gas: 100000}(payload);
        require(success);
    }
}

What does it do? Exactly the same as above, but with low level calls (_contractOne.call) and therefore the typesafety is gone. We have to manually check if success returned true, otherwise there is no chance we know if the called contract errored out. Interestingly here, it needs slightly more gas as well than the version above.

But it can be even one level lower. Because, what if we don't even know the function to all at all. That means, we would need to send the funds to the fallback receive function in ContractOne.

Let's change the contract slightly:

// SPDX-License-Identifier: GPL-3.0
pragma solidity 0.8.15;

contract ContractOne {

    mapping(address => uint) public addressBalances;

    function getBalance() public view returns(uint) {
        return address(this).balance;
    }

    receive() external payable {
        addressBalances[msg.sender] += msg.value;
    }
}

contract ContractTwo {

    function deposit() public payable {}

    function depositOnContractOne(address _contractOne) public { 
        (bool success, ) = _contractOne.call{value: 10, gas: 100000}("");
        require(success);
    }
}

What changed, you might ask?

Now we generically send 10 wei to the address _contractOne. This can be a smart contract. It can be a wallet. If its a contract it will always call the fallback function. But it will provide enough gas to execute arbitrary logic.

Re-Entrancy

Be careful here with so-called re-entrency attacks. If you provide enough gas for the called contract to execute arbitary logic, then its also possible for the smart contract to call back into the calling contract and potentially change state variables.

Always try to follow the so-called checks-effects-interactions pattern, where the external smart contract interaction comes last.

Final Words

There is no right or wrong here. It depends on your personal flavor. Now that you are aware of a few of the risks involved with sending Funds around, let's directly head over to our actual wallet implementation!


Last update: September 3, 2022