Using Solidity’s ‘Return’ Opcode
Learn to use the `return` opcode with the contract fallback function to hack Solidity’s compiler
Through this post we will cover some interesting nuances of the Solidity return
keyword and opcode, and how it can be used to implement different smart contract implementations.
Let’s dive right in.
If you are a developer and have some experience working with Solidity, then you probably already know what the following contract does when calling add_1(1, 2)
or add_2(1, 2)
.
pragma solidity ^0.5.0;
contract Test {
constructor() public {}
function add_1(uint256 a, uint256 b) public pure returns (uint256) {
return a + b;
}
function add_2(uint256 a, uint256 b) public pure returns (uint256 res) {
res = a + b;
}
}
Just as we expected, it returns 3
, but what happens when we call add_3(2, 1)
from this contract?
pragma solidity ^0.5.0;
contract Test {
constructor() public {}
function add_3(uint256 a, uint256 b) public pure returns (uint256) {
assembly {
let res:= mload(0x40)
mstore(res, add(a,b))
return(res, 0x20)
}
return a - b;
}
}
The documentation from Solidity explains:
return(p, s) - end execution, return data mem[p…(p+s))
So, when you call add_3(2, 1)
it returns 3
, because return a - b;
won’t be executed. The execution will stop right at: return(res, 0x20)
.
Let’s deconstruct the assembly section to see if we can understand it a little better. My goal today isn’t to explain opcodes in a lot of detail, but I do want you to fully understand the entire contract.
let res:= mload(0x40)
mload(p) - mem[p…(p+32))
Assign to res
the 32-byte word located at the memory address 0x40. According to how Solidity manages memory, there is a “free memory pointer” at position 0x40 in memory. If you want to allocate memory, just use the memory starting from this pointer and update it accordingly.
mstore(res, add(a,b))
mstore(p, v) - mem[p…(p+32)) := v
add(a,b) - a + b
Store the result of a + b at position [res...res+32]
(free memory pointer).
return(res, 0x20)
This will end the execution returning the value stored in memory starting at position res
to res + 0x20
(res + 32 //0x20 is the hexa of 32
).
For example, suppose that mload(0x40)
returns 0x50
, which points to the end of the currently allocated memory.
Position:
0x50
value:0x00000000000000000000000000000000
.
It stores the result of a + b // 2 + 1 = 3
to that position:
Position:
0x50
value:0x00000000000000000000000000000003
.
Returning the value starting at position 0x50
+ 32-byte which is 3
.
// mem[0x50...0x51] = 0x00000000000000000000000000000003
return mem[0x50...0x51]
Why is this useful?
Now, we all know about the existence of the return
opcode, but let’s see how it can be used to hack the compiler.
The Solidity compiler doesn’t allow you to return a value when the method declaration doesn’t specify a return.
function doSomething(uint256 val) public pure {
return val;
}
TypeError: Different number of arguments in return statement than in returns declaration.
Also, as you may know, every contract in Solidity has a fallback function.
In Solidity, a contract may have precisely one unnamed function, which cannot have arguments, nor return anything. Fallback functions are executed if a contract is called and no other function matches the specified function identifier, or if no data is supplied. These functions are also executed whenever a contract would receive plain Ether, without any data.
Using the return
opcode with the contract fallback function we can have different contract implementation designs. For example, at Decentraland we are using both the Pass-Through and Proxy implementations
Pass-Through contract implementation
The pass-through implementation provides a way for us to call a target contract method by impersonating its implementation.
Suppose you are one of several owners of an ERC721, but you don’t want any of the owners to call methods like safeTransferFrom
, setApprovalForAll
, or updateOperator
.
The owners can all agree to send the token to a new contract (Pass-Through) which has a fallback function, a mapping to store disabled methods, and can only be operated by the token owners. Every time you call this contract, it will check to see if the sender is an operator and that the method is allowed before calling the token contract method.
The token is then held by the pass-through contract with the owners as operators.
At Decentraland, we use a similar implementation of a pass-through contract to help district leaders manage their district’s LAND while forbidding the transfer of that LAND. The fallback function can be like:
function() external {
require(
isOperator(msg.sender) && isMethodAllowed(msg.sig) || isOwner(),
"Method not allowed"
)
bytes memory _calldata = msg.data;
address _dst = target; // contract storage variable
assembly {
let result := call(sub(gas, 10000), _dst, 0, add(_calldata, 0x20), calldatasize, 0, 0)
let size := returndatasize
let ptr := mload(0x40)
returndatacopy(ptr, 0, size)
// revert instead of invalid() bc if the underlying call failed with invalid() it already wasted gas.
// if the call returned error data, forward it
if iszero(result) { revert(ptr, size) }
return(ptr, size)
}
}
Proxy contract implementation
This implementation provides a way to call a target contract method in the context of the contract who is calling it by its fallback function. The main objective of this implementation is to allow the upgradability of smart contracts without breaking any dependencies.
A proxy contract uses the delegatecall opcode to forward function calls to a target contract which can be updated. As delegatecall retains the state of the function call, the target contract’s logic can be updated and the state will remain in the proxy contract for the updated target contract’s logic to use. As with delegatecall, the msg.sender will remain that of the caller of the proxy contract.
If you want to read more about the proxy implementation, check out this post from Zeppelin.
At Decentraland, we use the proxy implementation in the LANDRegistry and EstateRegistry smart contracts:
function() external {
bytes memory _calldata = msg.data;
address _dst = target; // contract storage variable
assembly {
let result := delegatecall(sub(gas, 10000), _dst, add(_calldata, 0x20), calldatasize, 0, 0)
let size := returndatasize
let ptr := mload(0x40)
returndatacopy(ptr, 0, size)
// revert instead of invalid() bc if the underlying call failed with invalid() it already wasted gas.
// if the call returned error data, forward it
if iszero(result) { revert(ptr, size) }
return(ptr, size)
}
}
I hope this article has helped you to better understand how some smart contract implementations use this in useful and interesting ways. Thanks for reading!