Solidity Best Practices For Smart Contract Security
Solidity is a programming language for creating smart contracts on the Ethereum blockchain. Knowing the security patterns of the solidity language serve as an instructive guide to giving security recommendations.
Let’s learn the fine details of Solidity in this blog.
Known Smart Contract Attacks To Be Aware Of
Reentrancy – Intra and inter-function reentrancy attacks where external contracts are called that collapse the logic of the contract.
Frontrunning – A Frontrunning attack is a condition where the malicious node validates its preferred transaction instead of the actual one.
Oracle manipulation – Oracle services supply real-time data to smart contracts based on external events, and chances are they get manipulated and thereby disrupt the functioning of the contract.
Insecure arithmetic – Integer overflows and underflows are situations where the numeric values used in smart contracts lie outside the range.
DoS attacks – Unexpected reverts and block gas limits can lead to DoS attacks that shut down the system and make it inaccessible to users.
Common Practices To Observe While Writing Ethereum Smart Contracts Using Solidity
-
Be cautious while making external calls
Making external calls poses the potential risk of causing sudden and unexpected errors. The probability is that external calls may introduce malicious code to execute. Therefore, precautions must be taken to avoid making unnecessary external calls to eliminate the security breach.
-
A clear indication of untrusted contracts
It is always a good practice to name variables or external interfaces while making external calls as it warns of the underlying potential security threats.
For example,
// bad
Bank.withdraw(100); // Unclear whether trusted or untrusted
function makeWithdrawal(uint amount) { // Isn’t clear that this function is potentially unsafe
Bank.withdraw(amount);
}
// good
UntrustedBank.withdraw(100); // untrusted external call
TrustedBank.withdraw(100); // external but trusted bank contract maintained by XYZ Corp
function makeUntrustedWithdrawal(uint amount) {
UntrustedBank.withdraw(amount);
}
-
Balance of tradeoffs between send(), transfer(), call.value()()
Functions like someAddress.send(), someAddress.transfer() and someAddress.call.value() should be carefully handled while sending Ether.
- Send() & transfer() are considered safe against reentrancy attacks
- x.transfer(y) performs the same as require(x.send(y)) that reverts back automatically when the transaction fails.
- call.value(y)() will send the required Ether and triggers the operation of the code.
-
Prefer pull over push for external calls
There are greater chances of external calls failing accidentally. Therefore to reduce the impact of failure, the external call can be separated for it to be initiated by the recipient of the call. This works better for payments where user funds can be allowed to withdraw rather than sent automatically.
-
Proper use of assert() and require()
require() is used for input validation and reverts if the condition is false, whereas assert() is used for invariants that also revert when the condition is false.
-
Lookout while rounding with integer
Generally, the integer division rounds around the nearest integer. But to achieve an accurate result, the rounding can be done using a multiplier that stores both the numerator and denominator.
Using the numerator and denominator value, the result can be calculated off-chain.
-
Check for zero balance while creating contracts
There is a possibility of the attacker sending wei to the contract address before it is created. Thus, assuming that a newly created contract has zero balance in its initial state that may lead to contract issues.
-
Use of simple fallback functions
Call for fallback function goes when the contract receives a message with no arguments or has access to only 2,300 gas when called from a .send() or .transfer(). Therefore, you can prefer to log an event in a fallback function to receive Ether from a .send() or .transfer().
-
Label the functions and state variables
Labelling the functions makes it easier to spot incorrect assumptions on who can call the function or has accessibility to the variables. Labels such as external, public, internal or private can be used for that purpose by understanding the differences between them.
For instance,
// bad
uint x; // the default is private for state variables, but it should be made explicit
function buy() { // the default is public
// public code
}
// good
uint private y;
function buy() external {
// only callable externally
}
function utility() public {
// callable externally, as well as internally: changing this code requires thinking about both cases.
}
function internalAction() internal {
// internal code
}
-
Lock pragmas to specific compiler version
It is better to deploy the contract with the same compiler version and flags which is used for testing them. Any other latest compiler would risk underlying undiscovered bugs. Thus, locking the pragma ensures they are deployed with the intended compiler version.
-
Highlighting the difference between functions and events
Functions can start with a lowercase letter, except for constructors, while events can have capitalization and prefixes in the front. This helps differentiate between the two like this,
// bad
event Transfer() {}
function transfer() {}
// good
event LogTransfer() {}
function transfer() external {}
Concluding Note
There are several other situations that have to be checked while coding for Ethereum. So, it is good to understand the underlying behavior of EVM and use the logic wisely to attain the maximum security of smart contracts.
Moreover, after the development, the code must proceed for Web3 security audits that help catch the hidden coding bugs right away. Specialized Ethereum auditing services work best for projects involving the Solidity programming language.