A beginner's guide to Ethereum development
So to be fair, I still consider myself quite new to the entire ecosystem so take everything I say with a grain of salt, but most of everything I write here is based on my personal experience with building in Web3 and thus, results may vary.
Liftoff
If you haven't read some Ethereum docs already, I highly suggest you do so, a lot of what I write here will consist of random ramblings and some code you might or might not understand. Here are some helpful links anyway:
- https://ethereum.org/en/developers/
- https://docs.soliditylang.org/en/latest/index.html
- https://remix.ethereum.org/
- https://ethereum.org/en/glossary/ (hear a word you don't understand? Look here!)
Now that we have that covered, let's talk about building something simple so we get how it works!
Storing and retrieving values from smart contracts
This is the most basic application and the very basis of decentralized applications. While it's theoretically possible to have smart contracts for other purposes (idk tbh), chances are this is what all of them are meant for (a gross oversimplification, live with it).
Let's take this contract for example (I told you, you'll need to know a bit of Solidity):
pragma solidity ^0.8.6;
contract SimpleStorage {
uint256 number;
constructor() {
number = 42; // sets the named variable as 42
}
function set(uint256 newNumber) external {
number = newNumber;
}
function get() external view returns (uint256) {
return number;
}
}
The idea here is pretty simple, the contract holds a number (we initialize it with 42), and anyone can thereafter use set()
to change it to any new number and alternatively, use get()
to read the currently stored number. Not much complexity going on here, but again, relatively not that useful (why not use a notepad?) for storing numbers, but it's an important stepping stone to understanding the complexities that come with writing smart contracts.
Let's introduce a simple feature now, what if I want to ensure that only I can set the number? You might choose to write something like this now:
pragma solidity ^0.8.6;
contract SimpleStorage {
uint256 number;
address owner;
constructor() {
number = 42; // sets the named variable as 42
owner = msg.sender; // an available global variable, look this up!
}
function set(uint256 newNumber) external {
require(owner == msg.sender);
number = newNumber;
}
function get() external view returns (uint256) {
// no require here means that everyone can still access!
return number;
}
}
Before you roast me for not using modifiers, let's talk a bit about using require
and assert
, my simple answer has always been to use require
all the time (not to imply that it's a good practice) but the reason that it's used much, much more than assert
is that it refunds gas, and for all that is holy, gas, at least on Ethereum mainnet is super expensive. Imagine paying periodically for your laptop to run? I'd hate that, but hey, apparently that's how all of this works. Oh and assert
doesn't really let you communicate the error with a string, so that too. Here's your required reading: https://docs.soliditylang.org/en/latest/control-structures.html#error-handling-assert-require-revert-and-exceptions (if you read through that, you'll also realize that not giving an error string is bad practice, like I did above)
Also, since I mentioned modifiers above, something you'd want if there's a condition that is checked in multiple functions (you know, code reuse principles and all), you could whip up something like this and use it in functions that need it:
modifier onlyOwner() {
require(msg.sender == owner, "ONLY_OWNER");
_;
}
Wondering about the _;
? Me too (unpaywalled version).
But given the vast expansion that this ecosystem has gone through in the past couple of years, access control is hardly as simple as owner == msg.sender
, and truth be told, even I haven't wrapped my head around a lot of the new approaches coming up but some super-common ones would be specialized governance contracts, upgradeable contracts (y'know, it's quite common for devs to make oopsies), various proxy patterns to go along with that, timelocks, timelock controllers, so on and so forth.
Let's talk about money
Now, this is where things start getting dicey. As soon as you put money into the equation, the reasonable expectations of security goes way highhhhh, high up.
Before I start, look up "checks-effects-interactions pattern", no seriously, do it.
Now, let's try writing a contract for a simple bank and see how it works out (for reasonable complexity, we will assume that addresses are the same thing as account numbers):
// DO NOT USE THIS FOR PRODUCTION!
// THERE IS A MAJOR FLAW IN THIS CONTRACT.
pragma solidity ^0.8.6;
contract SimpleBank {
mapping(address => uint256) balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function depositTo(address dest) external payable {
balances[dest] += msg.value;
}
function withdraw() external {
msg.sender.call{value: balances[msg.sender]};
balances[msg.sender] = 0;
}
function withdrawTo(address dest) external {
dest.call{value: balances[msg.sender]};
balances[msg.sender] = 0;
}
}
If the flaw hasn't jumped at you already, we are using some super-dangerous external calls there. One of the biggest things new people miss (at least me) is that contracts can interact with each other and not all parties interacting with a contract is going to be an individual on an EOA.
If a malicious contract was written in a way to repeatedly call our SimpleBank
contract's withdraw()
or withdrawTo()
, it would be able to siphon off all the funds before our contract ever reached the step to subtract the balances - and that is where the pattern becomes absolutely necessary. Something prettier would be:
// Not audited, use at your own risk.
pragma solidity ^0.8.6;
contract BetterSimpleBank {
mapping(address => uint256) balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function depositTo(address dest) external payable {
balances[dest] += msg.value;
}
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount); // checks
balances[msg.sender] -= amount; // effects
msg.sender.call{value: amount}; // interactions
}
function withdrawTo(address dest, uint256 amount) external {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
dest.call{value: amount};
}
}
In this version of our bank contract, it doesn't matter what the external call does, since the state of our contract can never be altered after it. While most of the Solidity community now prefers call
over transfer
, I think there's a decent use-case when you know the contract is meant to be used for EOAs - but keep in mind that you're probably also intentionally cutting off interaction with smart contracts now or in the future, and in case gas costs change. In turn, you don't need to be worried about reentrancy attacks because of the tiny gas stipend. I believe the Solidity documentation still recommends transfer
so make of that what you will.
Learning about checks-effects-interactions and making effective usage of it will help you quite a lot more than delving hours into Yul and literally having your head explode (but no kidding, do that as well).
Parting words
This checks-effects-interactions thing is really hard, not in the way that you need a ton of effort to learn it but in the sense that if you're used to writing traditional applications, APIs, services, this is just not how it works - at all.
You fire off your reply as soon as possible, then deal with the other stuff, it's a bit different in Solidity and that takes a while to accept. The good thing is that the language is maturing, and each version brings iterative, better ways to do things. Even if better things mean that all the overflow hacks and unsafe code can no longer be used to do more cool, albeit slightly dangerous things.
But for a beginner, the best idea would be to learn from the best, and yeah, please don't try to reinvent the wheel. 🎡