As you may already know, The Ethernaut, from OpenZeppelin, is a series of challenges designed to teach and test your understanding of smart contract security. Well, today was the time for Ethernaut Level 18, titled “Magic Number”. Let’s walk through this code challenge.
Objective
The objective of the Magic Number challenge is to deploy a smart contract that returns the number 42 when the whatIsTheMeaningOfLife()
function is called. The contract must be as small as possible, occupying no more than 10 opcodes.
The Instinctive Approach
My first thought was to write a simple contract ‘Approach’ with the inquired function ‘whatIsTheMeaningOfLife’ that returns 42.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Approach {
function whatIsTheMeaningOfLife() external pure returns (uint256) {
return 42;
}
}
But it was too easy to be right. Actually, when I checked the compiled bytecode of the contract, it was way too big, blowing past the 10-opcode limit. Then, it became clear that the trick here was in the 10-opcode limit of the contract.
Therefore, we had to find a strategy to generate a really tiny code. One way to tackle it is by crafting the contract directly in EVM bytecode, to keep it super minimal and efficient.
Just in case, let’s review what is an opcode and how they are generated in Solidity.
What Are Opcodes?
Opcodes, or operation codes, are the low-level instructions executed by the Ethereum Virtual Machine (EVM). Each opcode performs a specific operation, such as adding numbers, storing data, or interacting with contracts. In Solidity, high-level code is compiled into these opcodes, which the EVM can then execute.
For example, a simple addition in Solidity translates to several opcodes that handle pushing values to the stack, performing the addition, and storing the result. Understanding opcodes is crucial for optimizing and writing minimalistic contracts directly in EVM bytecode.
Our EVM Bytecode
Let me now back to the issue of writing the contract directly in EVM bytecode. This involves knowing the sequence of opcodes that the contract will require. In our case, we need a runtime bytecode that runs these operations:
- Push the value 42 onto the stack.
- Store it in memory.
- Return it.
This translates to the following opcodes:
- PUSH1 0x2a (Push 42 onto the stack) –> ’60 2a’
- PUSH1 0x00 (Push 0 onto the stack)–> ’60 00′
- MSTORE (Store 42 in memory at position 0)–> ’52’
- PUSH1 0x20 (Push 32, length of return data, onto the stack)–> ’60 20′
- PUSH1 0x00 (Push 0, the start position of return data, onto the stack)–> ’60 00′
- RETURN (Return 32 bytes from memory starting at position 0) –> ‘f3’
Then, the corresponding runtime bytecode is: ‘602a60005260206000f3’.
But, the deployment bytecode needed to deploy the contract on the blockchain (which includes both the constructor of the contract and the runtime bytecode) is the following:
- PUSH10 0x602a60005260206000f3 (Push the runtime bytecode onto the stack) –> ’69 602a60005260206000f3′
- PUSH1 0x00 (Push 0 onto the stack)–> ’60 00′
- MSTORE (Store the runtime bytecode in memory at position 0)–> ’52’
- PUSH1 0x0a (Push 10, length of runtime bytecode, onto the stack)–> ’60 0a’
- PUSH1 0x16 (Push 22, position where the runtime bytecode starts, onto the stack)–> ’60 16′
- RETURN (Return 10 bytes from memory starting at position 0) –> ‘f3’
Then, the corresponding deployment bytecode is: ‘69602a60005260206000f3600052600a6016f3’.
Coding the Solution
Once the deployment bytecode was found, it was time to deploy this minimal contract on the blockchain. Then, the DeployAndSetSolver contract was implemented. This contract consists of its constructor, which is responsible for deploying the minimalistic solver contract and then setting it in the MagicNum contract. It includes the runtime bytecode to return 42 and handles deploying this bytecode and registering the contract’s address with MagicNum.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "./MagicNum.sol";
contract DeployAndSetSolver {
constructor(address magicNumAddress) {
// Deployment bytecode: 69602a60005260206000f3600052600a6016f3
bytes memory deploymentBytecode = hex"69602a60005260206000f3600052600a6016f3";
address minimalContractAddress;
// Deploy the minimal contract manually using assembly
assembly {
minimalContractAddress := create(0, add(deploymentBytecode, 0x20), mload(deploymentBytecode))
}
// Set the solver in the MagicNum contract
MagicNum(magicNumAddress).setSolver(minimalContractAddress);
}
}
The MagicNum contract provided by the challenge had a simple purpose: to store the address of another contract (the solver). For this reason, it includes the setSolver function to set this address. This separation allows us to deploy a minimalistic solver contract and then register its address in MagicNum.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract MagicNum {
address public solver;
function setSolver(address _solver) public {
solver = _solver;
}
}
Deployment and Testing
In my case, the implementation of the contracts was carried out in the VSCode IDE and for the deployment I used Hardhat and the Polygon Amoy testnet, so I leave below the step by step of how to do it. However, you can use REMIX to compile and deploy the contracts and use the MagicNumber contract address of the challenge.
Deployment with Hardhat
1. Install Hardhat in your project:
npm install --save-dev hardhat
2. Install hardhat-waffle in your project:
npm install --save-dev @nomiclabs/hardhat-waffle
3. Create a Hardhat project:
npx hardhat init
4. Configure Hardhat. Select Create an empty hardhat.config.js:
Now, go to file named hardhat.config.js and add the necessary configuration. In may case, I setted polygon_amoy as network and I copied the Infura URL for polygon-amoy with my Infura Api Key, and also the private key of my Metamask account. :
require("@nomiclabs/hardhat-waffle");
module.exports = {
solidity: "0.8.20",
networks: {
hardhat: {},
polygon_amoy: {
url: [INFURA_URL],
accounts: [YOUR_PRIVATE_KEY]
}
}
};
5. Create the deployment script in scripts/deploy.js:
The deploy.js script starts by deploying the MagicNum contract, which serves as the registry for our solver contract. Next, it deploys the DeployAndSetSolver contract, which handles deploying the minimalistic solver and setting its address in the MagicNum contract. The addresses of both deployed contracts are logged for verification.
const hre = require("hardhat");
async function main() {
//--------------- DEPLOYMENT ---------------
const [deployer] = await hre.ethers.getSigners();
console.log("Deploying contracts with the account:", deployer.address);
console.log("Account balance:", (await deployer.getBalance()).toString());
// Deploy MagicNum contract
const MagicNum = await hre.ethers.getContractFactory("MagicNum");
const magicNum = await MagicNum.deploy();
await magicNum.deployed();
console.log("MagicNum contract deployed at address:", magicNum.address);
// Deploy DeployAndSetSolver contract
const DeployAndSetSolver = await hre.ethers.getContractFactory("DeployAndSetSolver");
const deployAndSetSolver = await DeployAndSetSolver.deploy(magicNum.address);
await deployAndSetSolver.deployed();
console.log("DeployAndSetSolver contract deployed at address:", deployAndSetSolver.address);
//--------------------- TESTING -----------------------
// Test the solver contract
// ABI of the MagicNum contract
const MagicNumABI = [
"function solver() public view returns (address)"
];
// Create a new instance of the MagicNum contract
const magicNumContract = new hre.ethers.Contract(magicNum.address, MagicNumABI, deployer);
// Get the solver address
const solverAddress = await magicNumContract.solver();
console.log("Solver contract address:", solverAddress);
// ABI of the solver contract
const SolverABI = [
"function whatIsTheMeaningOfLife() public view returns (uint256)"
];
// Create a new instance of the solver contract
const solverContract = new hre.ethers.Contract(solverAddress, SolverABI, deployer);
// Call the function whatIsTheMeaningOfLife
const result = await solverContract.whatIsTheMeaningOfLife();
console.log("The meaning of life is:", result.toString()); // Should log 42
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
6. Compile the contracts:
npx hardhat compile
7. Deploy and test the contracts on the Polygon Amoy test network:
npx hardhat run scripts/deploy.js --network polygon_amoy
Reviewing the deploy.js code, it can be observed that after deployment, we conduct a quick test to check if we passed the challenge. Then, it was retrieved the solver contract’s address from the MagicNum contract. Consequently, it was created an instance of the solver contract using its address and the ABI. Finally, it called the whatIsTheMeaningOfLife function on the solver contract and logged the result, which was 42.
Console logs after running deploy.js script.
Conclusion: Ethernaut Level 18 – Magic Number
Solving the Ethernaut Challenge 18: Magic Number requires a deep understanding of EVM bytecode and smart contract deployment mechanics. By leveraging low-level opcodes, we can create a minimal contract that meets the challenge’s requirements.
FAQs
Why is minimizing the number of opcodes important in this challenge?
- The challenge specifically requires the contract to be as minimal as possible, using no more than 10 opcodes. This constraint tests the ability to optimize and write efficient low-level code.
Why is understanding opcodes and bytecode important for Ethereum developers?
- Understanding opcodes and bytecode is crucial for optimizing smart contracts, writing highly efficient code, and ensuring security. It allows developers to have greater control over the execution and performance of their contracts on the Ethereum blockchain.
What is the difference between runtimeBytecode and deploymentBytecode?
- runtimeBytecode is the code that runs when the contract is deployed and functions are called. deploymentBytecode includes both the constructor that runs when the contract is deployed and the runtimeBytecode that will be stored on the blockchain for future execution.
How do you deploy the minimal contract and set it as the solver?
- We deploy the minimal contract using the DeployAndSetSolver contract, which includes the runtimeBytecode and sets it as the solver in the MagicNum contract by calling the setSolver function with the deployed contract’s address.
How can I verify that the contract returns 42?
- After deploying the contracts, use a script to call the whatIsTheMeaningOfLife() function from the solver contract and check the returned value. The script should log the result, confirming that the contract returns 42.