A simple implementation of a multi-signature wallet built in Solidity and cooked in some πΆοΈ foundry sauce.
Think of a multi-signature wallet as a supercharged safe or a high-tech joint bank account. Instead of just one key, multiple keys are needed to open it. It's all about sharing the responsibility, like many friends each having a piece of a secret code. More keys mean more security. And hey, if my description made zero sense π€·ββοΈ, check out this awesome article to get the full scoop.
What can you do with it? (for now)
- π«Έ submitting a new transaction (to be reviewed and approved by the wallet owners)
- π approving the submitted transaction (and waiting for other owners to approve)
- π ββοΈ revoking an approval vote on a transaction (if you changed your mind)
- βοΈ executing the transaction (once the approval quorum has been reached)
The main purpose of this repository is to provide some examples of what concerns:
- Solidity best practices and style guide
- Foundry test cases (faithful with UncleBob's approach - 3 laws of TDD - evergreen article)
- Foundry script instructions
- Foundry quick deploy&run
- Foundry contract interactions
You will get to know the entire foundry suite and feel like you're the boss of your crypto! (I mean, imagine that? π)
- solc - solidity compiler
- solc-select - manages installing and setting different solc compiler versions (recommended)
- foundry (see below π)
Make sure your solidity compiler matches with the minimum specific version or version range defined in the contracts.
Foundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.
Foundry consists of:
- Forge: Ethereum testing framework (like Truffle, Hardhat and DappTools).
- Cast: Swiss army knife for interacting with EVM smart contracts, sending transactions and getting chain data.
- Anvil: Local Ethereum node, akin to Ganache, Hardhat Network.
- Chisel: Fast, utilitarian, and verbose solidity REPL.
Download and install foundryup
:
curl -L https://foundry.paradigm.xyz | bash
Run it to install the full suite:
foundryup
If you are running into errors, just check out the book right here --> ποΈπ
Setting up the environment variables:
cp .env.example .env
source .env
The default values contained in this file will work just fine, but you are free to make any change you like (e.g. modifying the addresses, changing the number of owners, etc.), keeping into account a few things: see comments in the .env.example
file.
To build the contracts simply run:
forge build
By default, this will compile all the contracts contained in the lib
, script
, src
, test
directories and store the artifacts ABIs in the out
folder.
If you prefer to build only the files contained in a specific folder (e.g. the src
), you can simply run it with the -C <PATH>
flag:
forge build -C src
Easy as:
forge test
This will compile and run all files within the test
folder. To test a specific contract use the flag --match-path
as follows:
forge test --match-path test/<CONTRACT>
You can set different levels of verbosity simply by adding thooooousands v
s as trailing parameters, like that:
forge test -vvvv
From now on, I would recommend opening a side tab in the terminal as we will need to execute a local client - anvil - which is the cool-runner and younger brother of ganache and hardhat-node's family.
So, let's run it in one tab:
anvil
and keep it open!
In the second tab (we will refer to this as the mess-things-up-tab) run:
forge script script/MultiSigWallet.s.sol:MultiSigWalletScript --rpc-url ${LOCAL_RPC_URL} --private-key ${PRIVATE_KEY} --broadcast
If our deployment is successful, we will see something similar to this:
β
[Success]Hash: 0xb8a1fe732721d8896cbd12fad87c3657e62831ab9e86f570595732a57ebe7c40
Contract Address: 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0
Block: 1
Paid: 0.003781237914220259 ETH (1000253 gas * 3.780281503 gwei)
==========================
ONCHAIN EXECUTION COMPLETE & SUCCESSFUL.
Total Paid: 0.003781237914220259 ETH (1000253 gas * avg 3.780281503 gwei)
Grab the contract address and let's store it in an environment variable as we will need it later:
export CONTRACT="0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0"
And now, the so-long-awaited fun π - let's make some contract calls, shall we?
In the mess-things-up-tab, we will start with a simple query:
cast call ${CONTRACT} "owners(uint256)(address)" 0
If things go as planned, you will see the address of the first owner (if not...guess it is bug hunting time! ππ)
Alright, now that we have gotten our feet wet, let's dive deep and see this multisig in action!
A trio of crypto enthusiasts, united under a DAO, to gather people to invest in their project, have crafted a series of intricate Solidity puzzles. They presented these challenges on their Discord platform with a very generous offer: the first user to decode all the puzzles within 24 hours would be rewarded with 1 ETH. A week later, they proudly announced the lucky winner, and they are ready to transfer the bounty from their multisig account.
0 . π° fund
As OWNER1
, funds the contract with 5 ether
:
cast send --private-key ${OWNERS_PK[1]} ${CONTRACT} --value 5ether
I recommend prefixing the command body with the --private-key
flag, so that we know right away who is executing what (like the subject/persona of a story).
Verify the contract is now funded:
cast balance ${CONTRACT}
1 . π«Έ submit
Submit (only propose) the transaction which will send:
1 ether
- to
RECIPIENT
- setting expiration time to
86400
seconds (24 hours)- Execute this command to assign this value to an environment variable:
EXPIRATION=$(($(cast block latest -f timestamp) + 86400))
- Execute this command to assign this value to an environment variable:
- with message
gm
(hexed)- you can use
cast from-utf8 <text>
command
- you can use
cast send --private-key ${OWNERS_PK[1]} ${CONTRACT} "submit(address,uint256,uint256,bytes)" ${RECIPIENT} 1ether ${EXPIRATION} 0x676d
Note that we are using cast send
to sign and publish a transaction (this will alter the world state).
Let's check our transaction was correctly inserted:
cast call ${CONTRACT} "transactions(uint256)(address,uint256,uint256,bytes,bool)" 0
2 . π approve
As the default threshold policy is set to the uint(numberOfOwners/2 + 1)
(ceiling), for 3
owners we will require uint(3/2 + 1) = 2
approvals (*spoiler: nevertheless, as any well-respected story, there will be a twist, get ready πΏ):
OWNER1
approves:
cast send --private-key ${OWNERS_PK[1]} ${CONTRACT} "approve(uint256)" 0
Let's verify:
cast call ${CONTRACT} "approved(uint256,address)(bool)" 0 ${OWNER1}
Let's move ahead approving the transaction also with OWNER2
:
cast send --private-key ${OWNERS_PK[2]} ${CONTRACT} "approve(uint256)" 0
And let's verify it was approved:
cast call ${CONTRACT} "approved(uint256,address)(bool)" 0 ${OWNER2}
Alright, we hit our mark and were about to seal the deal, but...hold up! What's going on here? π³
3 . π ββοΈ revoke
Out of nowhere, OWNER2
gets cold feet. Instead of moving forward, they pull the plug and take back their okay (seriously, not cool π‘):
cast send --private-key ${OWNERS_PK[2]} ${CONTRACT} "revoke(uint256)" 0
Quick check to see the damage:
cast call ${CONTRACT} "approved(uint256,address)(bool)" 0 ${OWNER2}
And yup, it is as bad as we thought. Our lucky π° might not get their prize, and people might just lose faith in the DAO project: a total disaster π.
And, when we think everything is lost...a masked hero π¦ΈπΏ comes to the rescue...it's OWNER3
! (what a plot twist! π):
cast send --private-key ${OWNERS_PK[3]} ${CONTRACT} "approve(uint256)" 0
cast call ${CONTRACT} "approved(uint256,address)(bool)" 0 ${OWNER3}
4 . βοΈ execute
OWNER1
can finally execute the transaction.
But before jumping there, let's check first the initial balance of the recipient:
cast balance ${RECIPIENT}
and now let's execute:
cast send --private-key ${OWNERS_PK[1]} ${CONTRACT} "execute(uint256)" 0
And there it is:
cast call ${CONTRACT} "transactions(uint256)(address,uint256,uint256,bytes,bool)" 0
And our lucky π° can count their money π€:
cast balance ${RECIPIENT}
Happy ever after β¨
dadadadaaann --- THE END (closing credits...)
If you are unhappy with your "prettifier" or just tangled in indentation hell, look no further, foundry has a nice π for you:
forge fmt
If you are working on gas optimization and want to check the before-after effect of your hopefully-well-rewarded work, try these out:
# slower but more comprehensive
forge test --gas-report
# faster and stored in a file
forge snapshot
More on gas tracking.
The user is encouraged to mess-things-up (not just on a tab), break everything apart and make it work again - the best way of learning! (a wise π¨ said).
- foundry (v0.2.0, at the time of writing) does not fully support solidity custom errors (yet), either in testing or logging error messages
- an example of account abstraction multisig (by zksync team)
- awesome-foundry - a collection of projects built on/around and resources about foundry