Eliminating Smart Contract Special Cases
In a few projects I’ve audited recently, I noticed that special cases were causing significant code complexity. Complexity is the enemy of security, so I’m always looking for ways to simplify things.
In this post, I’ll share some examples of how eliminating special cases can reduce code complexity and improve maintainability.
Special Maximums
A common special case is using 0 to mean “no maximum”. This special case is usually easy to eliminate.
Special Expirations
Consider this code:
uint256 expiration;
// Use 0 to mean "no expiration".
function setExpiration(uint256 newExpiration) external {
expiration = newExpiration;
}
function doSomething() external {
require(expiration == 0 || now < expiration, "Error: expired");
...
}
In this code, 0 is a special case that means “there is no expiration”. This special case is unintuitive, and it’s adding complexity to that require
statement.
The real danger, though, is when a new developer on the team misses this subtlety and fails to handle the special case of expiration == 0
. That could easily lead to lost funds or other serious issues.
The code is simpler and more obvious this way:
// Default to 2**256-1 instead.
uint256 expiration = 2**256-1;
// Use 2**256-1 to mean "no expiration".
function setExpiration(uint256 newExpiration) external {
expiration = newExpiration;
}
function doSomething() external {
require(now <= expiration, "Error: expired");
...
}
Here, instead of 0, I’ve used an expiration
of the maximum allowable uint256
, which is effectively infinite when it comes to timestamps.1 Now expiration
always means exactly what it says.
Special Maximum Ether Amounts
Here’s a very similar example, but this time involving ether:
uint256 maxWithdrawal;
// Use 0 to mean "no maximum".
function setMaxWithdrawal(uint256 newMax) external {
maxWithdrawal = newMax;
}
function withdraw(uint256 amount) external {
require(maxWithdrawal == 0 || amount <= maxWithdrawal, "Error: too much");
...
}
Again, we have an unintuitive special case, which we can do away with by using an effectively infinite value:2
// Default to 2**256-1 instead.
uint256 maxWithdrawal = 2**256-1;
// Use 2**256-1 to mean "no maximum".
function setMaxWithdrawal(uint256 newMax) external {
maxWithdrawal = newMax;
}
function withdraw(uint256 amount) external {
require(amount <= maxWithdrawal, "Error: too much");
...
}
2256-1 Is a Great Maximum
Note that this same trick generalizes to token amounts or any value at all. Because Solidity can’t represent values greater than 2256-1, it always works as an “effectively infinite” value to compare with a uint256
.3
Working Around Gas Costs
As is often the case, there’s a tradeoff here with regard to gas costs. A typical reason people end up using 0 as a default is that storing non-zero values costs gas.
If storage costs are significant for your use case, consider a trick like this:
uint256 _expiration; // 0 still means "no expiration"
...
// Properly handle the special cases in one place.
function expiration() internal view returns (uint256) {
return _expiration > 0 ? _expiration : 2**256-1;
}
function doSomething() external {
require(now < expiration(), "Error: expired");
}
In this code, the _expiration
value written to storage is 0 by default, with the same special meaning as before. But I’ve introduced a helper function expiration()
that translates a 0 into the less special value of 2256-1. This means the rest of my code doesn’t need to deal with that special case.
Consider pairing this technique with a custom linter rule that makes sure you don’t read _expiration
directly anywhere except in the expiration()
function.
Special Addresses
When it comes to addresses, there are two types of special cases I see frequently:
- Address 0 is often disallowed.
- Specific addresses, often privileged roles, are disallowed.
Special Address 0
Here’s a familiar bit of code that uses 0 as a special case:
function transfer(address to, uint256 amount) external {
require(to != address(0), "Error: can't send to 0x0");
...
}
Disallowing address 0 is typically an attempt to protect users from mistakes. Sending tokens to address 0 is usually no more disastrous than sending them to address 1, but 0 is a default value and thus much more likely to get passed in accidentally due to a buggy tool or library.
I personally dislike this type of check for address 0, but it’s rarely problematic. Unlike in the previous examples, nothing will break if a developer forgets about this special case when maintaining the code.
Special Role Addresses
This code snippet is much more troubling than the last:
address owner;
constructor() public {
owner = msg.sender;
}
function transfer(address to, uint256 amount) external {
require(to != owner, "Error: can't send to owner.");
...
}
When I see code like this, my immediate question is why the owner
address can’t receive tokens. A check like this is often an effort to put security controls in place, but it usually fails to account for Sybil attacks, where multiple addresses in the system are controlled by the same person.
In this particular example, the owner could simply receive tokens with a different address. If that violates some security assumption of the contract, then there’s a problem.
Special cases like this are a code smell, but that doesn’t mean they should always be eliminated. The important thing to do is document why this special case is needed and consider alternatives.
Summary
- Special cases lead to code complexity, which leads to bugs.
- When possible, eliminate special cases altogether.
- 2256-1 is a good replacement for maximum values.
- Special cases for address 0 are usually okay.
- Special cases for other specific addresses are a code smell.
- If you decide to use values with special meaning in your code, try to isolate the code for handling them.
Thinking about smart contract security? We can provide training, ongoing advice, and smart contract auditing. Contact us.
-
That time will occur approximately 3.67 * 1069 years from now, but that doesn’t actually matter because one second later the timestamp will wrap around to 0. We’ll call this the Y1069 bug. ↩︎
-
The rate of ether production is harder to predict than the relentless march of time, but it will take a similar amount of time for there to exist 2256 wei as it will take to reach a timestamp of 2256. Just as with time, though, the EVM won’t be able to represent ether amounts that big anyway. ↩︎
-
To be pedantic, if you are using an inequality, you might disallow exactly 2256-1, but this should never matter in practice. ↩︎