Solidity Best Practices (Part 2) that will turn you into a smart-contract ninja 🥷🏽
Dive into the nuances of Solidity
Hey there, this is an extension to Solidity Best Practices (Part 1). To understand the limitation of Solidity and why this even matters, highly recommend checking out part 1 here, before continuing.
Now that you've internalized the first set of best practices, let's dive into part 2!
1. Scale up during integer division
Solidity is not the best at dealing with floating numbers and rounds DOWN any floating point number to the nearest integer possible. This can be undesirable when dealing with real value. Imagine you receive 2 ETH instead of 2.4 ETH you should have received, just because Solidity rounded it off to 2!🚫
To deal with this deficiency of Solidity, the best practice is to introduce a precision element aka. scaling factor (a multiplier) when undergoing any operation that can result in a floating point value eg. division operation (5/2). The scaling factor is used to scale the numerator to prevent a floating point result.
💡 Please note that the result of the operation will be a scaled-up value, which then should be divided by the scaling factor, off-chain, to get the actual result.
// bad
uint x = 5 / 2;
/*
Solidity with throw this error:
TypeError: Type rational_const 5 / 2 is not implicitly convertible to expected type uint256. Try converting to type ufixed8x1 or use an explicit conversion.
--> contracts/Fallback.sol:7:1:
|
7 | uint x = 5 / 2; // Result is 2, all integer division rounds DOWN to the nearest integer
| ^^^^^^^^^^^^^^
*/
// good
uint multiplier = 10;
uint x = (5 * multiplier) / 2;
Generally, when working with ETH, you can use 1e18 as a scaling factor/multiplier. This is to convert your result into WEI (10^18) and WEI being the lowest possible unit of ETH, will generate the most accurate value.
uint valueToBeTransfered = (5* 1e18)/2; // 2500000000000000000 = 2.5e18 = 2.5 ETH
2. receive() is fallback()’s first line of defence!
The receive()
function in Solidity is used to capture any value being transferred to the contract directly and not through any function (remember contract.call{value: 2 ether}(””)).
contract Receiver {
event Received(address, uint);
receive() external payable {
emit Received(msg.sender, msg.value);
}
}
Whereas, fallback()
is triggered when:
a function that isn’t defined in the contract is called, OR/AND,
value is sent to the contract directly and not through any function.
💡 The
fallback
should be payable toreceive
value.
contract Test {
uint x;
// This function is called for all messages sent to
// this contract (there is no other function).
fallback() external payable { x = 1; }
}
Hmm.. Then the question that arises is that if fallback
can receive value as well, then isn’t receive
pretty much useless? Well, not exactly!
Since receive
is specifically designed to handle plain Ether transfers, it's more gas-efficient than the fallback
in doing so.
Secondly, it helps with the separation of concerns: Relying solely on a payable fallback()
function can lead to ambiguity. If the fallback function executes, it's unclear whether a user intended to send Ether or accidentally called a non-existent function. Hence, defining a receive
function along with fallback
is considered a best practice and provides clarity in the following ways:
Ether transfers without data activates the
receive
function, which is more gas-efficient for the job.Calls to non-existent functions, or Ether transfers WITH data, activates the
fallback
function.
contract Receiver {
event Received(address, uint);
uint x;
receive() external payable {
emit Received(msg.sender, msg.value);
}
// Does not have to be payable anymore as receive() will be triggered on value transfers
fallback() external { x = 1; }
}
Hence, receive
and fallback
are good buddies in the world of Solidity!👯
3. Freezing your pragmas (Solidity versions)
It’s a good practice to generally specify the exact Solidity compiler version (solc) to be used when deploying your contracts on the mainnet, instead of keeping it floating. This is because the floating version like ^0.8.20 will allow your contracts to be deployed with the latest nightly release nightly-eb214.. , which may be susceptible to undiscovered bugs!
// bad
pragma solidity ^0.8.20;
// good
pragma solidity 0.8.20;
Therefore, freezing your Solidity versions will ensure that your contracts don’t get deployed with a version of Solidity you didn’t intend to deploy with.
💡 It’s okay to leave the Solidity version floating in case your contracts are meant to be used by other devs as it will make your contracts compatible with whatever Solidity version they are using.
4. Log events like you mean it!
I cannot understate the importance of logging important events that take place in your contracts like state changes, transfers, etc. Events will:
help you and your team monitor and analyze your contract’s activities using not just the inputs but any desired parameters, like the actual state changes being made (which don’t get logged on-chain by default).
help the protocol clients with their event-dependent operations as clients can listen to events and take actions accordingly,
overall keep the users informed on the activities taking place, and
can also log internal function calls that don’t get logged, by default.
Events increase transparency and improve the overall user experience.
// bad
pragma solidity 0.8.22;
contract Charity {
mapping(address => uint) balances;
function donate() payable public {
balances[msg.sender] += msg.value;
}
}
contract Purchase {
Charity charity;
constructor(){
charity = new Charity();
}
function buyCoins() payable public {
// 5% goes to charity
uint256 charityAmt = (msg.value / 20);
charity.donate{value: charityAmt}();
}
}
pragma solidity 0.8.22;
contract Charity {
event Donated(address indexed donar,uint256 indexed value);
mapping(address => uint) balances;
function donate() payable public {
balances[msg.sender] += msg.value;
emit Donated(msg.sender, msg.value); // logging internal contract call explicitly along with state change
}
}
contract Purchase {
event Purchased(address indexed buyer, uint256 indexed value);
Charity charity;
constructor(){
charity = new Charity();
}
function buyCoins() payable public {
require(msg.value > 0);
emit Purchased(msg.sender, msg.value); // logging value received event
// 5% goes to charity
uint256 charityAmt = (msg.value / 20);
charity.donate{value: charityAmt}(); // Internal call. Doesn't get logged by default
}
}
Well, with this and Part 1 of Solidity best practices, we’ve covered the most important Solidity-specific best practices. Hurray🎉, you’re among the good Solidity devs now, who know and implement these best practices in their codebases!
The last part of this series will focus on security-related precautionary best practices to prevent hackers from exploiting our contracts and even if they do, these precautionary practices should at least help us fail gracefully.
Hope you learnt a ton from this. If you did, do consider liking and sharing this with your network. Stay tuned! 🥷🏽
References:
https://consensys.github.io/smart-contract-best-practices/development-recommendations/