Two-Sum-Casino: A Resource Efficient Smart Contract
December 15, 2023
In the realm of blockchain-based systems, addressing security concerns is of paramount importance. A common approach to modelling and testing potential vulnerabilities is through the lens of betting games. These games provide a controlled environment to explore various attack vectors that real-world systems may face, ranging from malicious actors attempting to steal funds from contracts to exploiting insecure code that could lead to infinite callback loops, eventually draining a contract’s resources due to the associated transaction costs.
Once a smart contract is deployed on the blockchain, its core logic becomes immutable, making it crucial to thoroughly test and validate the contract’s behaviour against potential attacks before deployment. With this motivation, I worked on a project that involved designing a two-player betting game using Solidity, the programming language for Ethereum smart contracts.
The game, called TwoSumCasino, is a simple yet effective simulation of a decentralised casino environment. In this game, two players, A and B, each pick a number between 1 and 100. The winner is determined by the parity of the sum of their chosen numbers, with the prize being awarded in Wei, the smallest unit of Ether.
To ensure fairness and prevent cheating, the game employs a commit-reveal scheme. In this scheme, players first submit a hash of their chosen number along with a secret salt value. This step, called committing, keeps their actual guesses hidden until both players have committed their choices. Once both players have committed, they proceed to the reveal phase, where they unveil their chosen numbers and the secret salt values. By requiring players to commit before revealing, the game prevents either player from changing their guess based on the other’s choice, maintaining a level playing field.
Unlike traditional non-blockchain programs, storage on the blockchain is expensive, and there is a per-transaction cost involved each time we read from storage. Considering the inability to change contract code once deployed, it can save a lot of overhead costs if considerations are made to optimize for resource efficiency.
In Solidity smart contracts, storage is allocated in 32-byte slots. If a variable’s size is smaller than 32 bytes, it is often packed with other variables to minimize storage usage. However, if variables are not carefully arranged, they may consume more storage slots than necessary, leading to higher gas costs.
For example, consider the following variable declarations:
uint8 a;
uint256 b;
uint8 c;
uint8 d;
In this arrangement, a
and b
would each occupy a separate 32-byte storage slot, while c
and d
would be packed together in a third slot. This arrangement consumes a total of three storage slots.
However, by rearranging the variables like this:
uint8 a;
uint8 c;
uint8 d;
uint256 b;
We can optimize storage usage. In this case, a
, c
, and d
would be packed together in a single 32-byte slot, and b
would occupy a second slot. This arrangement consumes only two storage slots, reducing the storage footprint and the associated gas costs.
While the above example addresses costs associated with storage, it’s crucial not to overlook the costs of reading these values. I specifically found it interesting that bit manipulation operations are significantly cheaper compared to reading from storage. I used this idea to explore the possibility of packing multiple values into a single variable and utilizing bit operations to unpack them efficiently to save on read operation costs.
For instance, suppose we need to store four boolean values. Instead of using four separate bool
variables, we can pack them into a single uint8
variable like this:
uint8 packedBools;
To set and retrieve the individual boolean values, we can use bit operations:
// Set the boolean values
packedBools |= (uint8(bool1) << 0) | (uint8(bool2) << 1) | (uint8(bool3) << 2) | (uint8(bool4) << 3);
// Retrieve the boolean values
bool bool1 = (packedBools & (1 << 0)) != 0;
bool bool2 = (packedBools & (1 << 1)) != 0;
bool bool3 = (packedBools & (1 << 2)) != 0;
bool bool4 = (packedBools & (1 << 3)) != 0;
The further reduction in storage blocks used comes from the use of only 128 bits to represent the deadline block (normally 256 bits). Using the block number is quite common in blockchain applications to track things like game state or manage deadlines. In my case, it was used to enforce a time limit for players to reveal their chosen numbers. By setting a deadline based on the block number, the contract ensures that players cannot indefinitely delay the game resolution.
However, storing the full 256-bit block number would be inefficient, as the Ethereum block number is not expected to overflow within our lifetime, considering the current block generation rate. To optimize storage, I decided to store only the lower 128 bits of the block number, which is more than sufficient for practical purposes. By doing so, I could save on storage costs by packing it with other game state variables in a single 256-bit int, while still maintaining the necessary functionality.
It’s worth noting that I still added a failsafe mechanism to handle the unlikely scenario of a block number overflow.
This is then coupled with additional safety techniques, such as using a state machine approach to control actions that are valid in a given state. This is illustrated below.
Lastly, I the popular pull over push approach to prevent possible reentrancy attacks in addition to ordering validity checks in a way that minimizes potential costs if, for example, a function call is bound to fail.
One instance of the “bound to fail” scenario is demonstrated in the following code snippet:
function reveal(bytes32 secretSalt, uint8 number) public inState(State.Reveal) {
require(now <= deadline, "Reveal deadline has passed");
require(number <= 100, "Chosen number must be less than or equal to 100");
bytes32 commit = keccak256(abi.encodePacked(msg.sender, secretSalt, number));
require(commits[msg.sender] == commit, "Commit does not match");
// ... rest of the function logic ...
}
In this example, the function first checks if the current state is State.Reveal using the inState modifier. If the state is incorrect, the function execution will be halted, saving gas that would have been consumed by the subsequent checks and logic. The next check verifies that the reveal deadline has not passed. If the deadline has passed, the function will revert, preventing unnecessary computations.
The third check ensures that the revealed number is within the valid range (less than or equal to 100). Again, if this condition is not met, the function will revert early.
Finally, the function verifies that the provided commit matches the one stored in the commits mapping. If the commit does not match, the function will revert, avoiding any further execution.
By ordering these checks in a way that handles the most likely failure scenarios first, the contract can minimize gas consumption in cases where a function call is expected to fail due to invalid input or state.
It’s important to note that while variable packing can provide significant benefits, it also introduces additional complexity in the contract code. I think the increased complexity could likely hide bugs and reduce maintainability, but it’s worth evaluating on a case-by-case basis if this would be beneficial. Scenarios with high transaction frequency will likely benefit more from this approach, as the gas savings accumulated over numerous transactions can be substantial. However, for contracts with lower transaction volumes, the added complexity might not justify the optimization efforts. If interested in a deeper dive, have a look at Awesome Solidity Gas-Optimization on Github.
To request a detailed report on the TwoSumCasino project, please visit this link.