In this article, I'll describe how you can modularise your smart contracts using something called the target pattern. Using standard Solidity, you will learn how to rewrite your brittle, tighly-coupled calls to clean modules and separation of concerns, using abi.encodeSelector
and target.call(data)
. To do this, we're going to look at HumanityDAO as an example - and how they separated governance from the registry, using the target pattern. Let's dig in!
HumanityDAO: an introduction
HumanityDAO is a DAO whose purpose is to certify humans - ie. maintain a registry that says "you're real". It's existentially reassuring and also quite useful in combatting Sybil attacks. In my current work, I'm building something like decentralised reddit - using the registry, you can piggyback off of work done by other DAO's to combat spam (as a basic example). You can see how an entire ecosystem can flourish once we start adopting these patterns.
But aside from HumanityDAO being a great dapp, it is also the first example of modular Solidity design I've seen in the wild. But what does modular refer to in this context? And how do we achieve it?
Modules in HumanityDAO
HumanityDAO maintains a registry of humans which you add to and query, called HumanityRegistry
. The registry itself only operates over a single mapping, mapping (address => bool) public humans
. Here's the main function for adding entries:
function add(address who) public {
require(msg.sender == governance, "HumanityRegistry::add: Only governance can add an identity");
require(humans[who] == false, "HumanityRegistry::add: Address is already on the registry");
_reward(who);
humans[who] = true;
}
Governance is the next module we will look at, which deals with proposals and voting. This is the basic flow:
propose(address target, bytes memory data)
begins a new proposal. It creates aProposal
, adds it to the list, and emits an event.- Users call
voteYes(uint proposalId)
andvoteNo(uint proposalId)
to stake their tokens in passing/failing the proposal - When the voting period has elapsed,
finalize(uint proposalId)
can be called. Ifproposal.yesCount > proposal.noCount
, thentarget
is called withdata
This last step I'm going to refer in this guide as the target pattern for simplicity. It's not a new paradigm of programming - but if we're going to engineer well, then we have to have common language.
EVM: calling contracts, sending messages
To explain briefly - Ethereum contracts execute in the EVM, which is a message-passing execution environment. So when you send 10 wei to an address, you're sending a message that says { from: me, to: 0x123456789, value: 10 }
. And when we're executing a call on a contract, we're doing the same, except attaching data
- so now the message looks like { from: me, to: 0x123456789, data: "0xcafebabe", value: 10 }
. What is this data? Well, it's a call encoded according to the Solidity application binary interface (or ABI, for short).
The message construct is nice, because it means sending ether and calling a contract are no different. Sending money to a user's wallet address, and sending money to a contract, look the same - the only difference is, the contract's address isn't generated from a public key (rather from a nonce).
Because we're in blockchain, and we'd rather refer to things immutably, methods in contracts are referred to by their selector. The selector is just a portion of the hash of the function's signature.
The target
pattern
The target pattern is quite simple, but hasn't been widely used because Solidity has these special names for things. It can also be called dynamic dispatch (Ruby, Obj-C), callbacks (JS), whatever you want ;P
It basically consists of:
- the module you're building on eg. governance
- the thing you're implementing eg. a registry
- the target integration point - ie. passing a proposal will add to the registry
We're going to learn how to achieve this, from the example of HumanityDAO.
To recap, our goal: to add an entry to the registry, after it has been voted in by governance.
So here are the two pieces of the puzzle, the governance and registry modules, before we put them together:
contract Registry {
mapping (address => bool) public humans
function add(address who) public {
require(msg.sender == governance, "HumanityRegistry::add: Only governance can add an identity");
humans[who] = true;
}
}
contract Governance {
function propose(address target, bytes memory data) public returns (uint) {
uint proposalId = proposals.length;
Proposal memory proposal;
proposal.target = target;
proposal.data = data;
proposals.push(proposal);
}
function vote() public { /* ... */ }
function finalize(uint proposalId) public {
Proposal storage proposal = proposals[proposalId];
require(proposal.result == Result.Pending, "Governance::finalize: Proposal is already finalized");
if (proposal.yesCount > proposal.noCount) {
// Call the target contract with the data
proposal.target.call(proposal.data);
}
}
}
We can add to the registry using Registry.add
. Governance is based on submitting a proposal with propose
, voting on it, and then calling finalise
and passing/failing it. If the proposal passes, we call
the target
contract with the calldata in data
.
So how do we construct this calldata? You would think it's very complex, being Solidity, with maybe a sprinkle of assembly and some obscure errors.
Nope! We can do this very simply with something called abi.encodeWithSelector
:
bytes memory data = abi.encodeWithSelector(registry.add.selector, who);
governance.propose(address(registry), data)
Just like that, the target is set to the Registry
contract, and a call that looks like Registry.add(who)
is encoded and stored in the proposal. It's really that simple.
Using a wrapper
One more thing - since we're already talking about good design and composability - I would advise wrapping this call in a method in a separate contract. This wrapper contract is then a suitably simple method that can be called in the frontend.
What do we name the code flow that proposes and maybe adds someone to the registry? HumanityDAO calls it HumanityApplicant
:
contract HumanityApplicant {
Governance governance;
Registry registry;
constructor(Governance _Governance, Registry _Registry) {
governance = Governance(_Governance);
registry = Registry(_Registry);
}
function addToRegistry(address who) {
bytes memory data = abi.encodeWithSelector(registry.add.selector, who);
return governance.propose(msg.sender, address(registry), data);
}
}
Conclusion
And that's it! Using this, you can take the Governance contract and use it in your own designs, with minimal effort.
ℹ️Questions/discussions? Please, leave a comment :)
👉Follow me on Twitter and GitHub.
👉Check out the HumanityDAO smart contracts: github.com/marbleprotocol/humanity
👉Take a read of awesome-solidity-patterns and EthHub for more high-quality information