Hello there. I’ve tried to dig deep and code all the solution by myself. I’m pretty sure I will find that there were way easier solutions, and I look forward to check how Filip addressed the job!
Considerations:
I’ve really struggled against how mappings and arrays expecially are handled. Not being able to iterate the mapping and having to keep an array of keys (which is gas consuming if has actually to be synced via .pop() ) is really though, but maybe I just have to get used to it.
To mess with arrays I felt like I needed some kind of library to speed up developing, and I created 2 additional contracts for this job.
I feel like for the sake of readability I’ve pushed too far, and there is really a lot of code which make me think if this could really be a realistic approach, since calling a lot of private functions has a gas impact.
Solution
My implementation of a X of Y MultiSig distinguish between an owner and Y signers.
-
Permissions
-
Owner
- Set X and Y (once, when deploying) [they’re uint8 btw]
- Add a signer
- Remove a signer
- Remove a proposal
- Remove a signer approvals
- Remove all approvals
- Destroy the wallet and rug pull
-
Signer
- Add a proposal
- Approve a proposal
- Revoke his approval
-
Public
- Check the approvals for a proposal
- Check the signers
- Deposit
-
Details
-
For tracking signers
- Signers addresses are saved in an array
- Each time a signer is removed the length of the array diminish (I prefer to
pop()
instead of delete
+ filter off empty addresses
- The orders of the signers may change due to how
_rmFromAddressArray()
works.
-
For tracking proposals
- All proposals consist basically of a recipient and an amount. That struct Transferral is held by the smart contract inside a mapping called “proposals” which maps the struct id with the actual instance of the struct. I generate the id hashing the struct instance members packed togheter (cfr.
_transferralId()
). In this way when I have recipient and amount i can get their id and retrieve back the actual storage instance. Since for each bytes32 (proposals key) I would automatically get an empty instance of a Transferral, I consider legit just those instances where key == keccak256(value)
check _getTransferralSafe()
which implement that logic.
- Signers approvals are saved inside a mapping called “approvals” which maps their address to an array of bytes32, aka the transferrals id.
- When a signer is removed his approvals are popped until length == 0 to avoid keeping his approvals in case he would be re-added as a signer after some time.
- When a proposal is either removed by the owner or after being executed all the signers approvals are whiped out, to avoid a replay attack.
-
Code
- ./abc/AddressArrayAux.sol
pragma solidity 0.7.5;
contract AddressArrayAux {
function _rmFromAddressArray(uint idx, address[] storage arrPtr) internal
returns(address)
/*
remove the element at index replacing the indexed element with the last element
order of the array is NOT preserved
*/
{
require(idx < arrPtr.length, "IndexError");
address ret = arrPtr[idx];
arrPtr[idx] = arrPtr[arrPtr.length - 1];
arrPtr.pop();
return ret;
}
function _popFromAddressArray(uint idx, address[] storage arrPtr) internal
returns(address)
/*
remove the element at index and reassign each element just like in python arr.pop(idx)
order of the array IS preserved
*/
{
require(idx < arrPtr.length, "IndexError");
address ret = arrPtr[idx];
for (uint i = idx; i < arrPtr.length - 1; i++){
arrPtr[i] = arrPtr[i + 1];
}
arrPtr.pop();
return ret;
}
function _findInAddressArray(address data, address[] storage arrPtr) internal view
returns(bool, uint)
/*
find the first index for which data == arrPtr[index]
the first returned element is meant to be "found"
in case of not found the index will be the last index (arrPtr.length-1)
call like:
(bool found, uint index) = _findIn<T>Array(data, stateMember);
*/
{
bool found = false;
uint index;
for (index; index < arrPtr.length; index++){
if(arrPtr[index] == data){
found = true;
break;
}
}
return (found, index);
}
}
- ./abc/Bytes32ArrayAux.sol
pragma solidity 0.7.5;
contract Bytes32ArrayAux {
function _rmFromBytes32Array(uint idx, bytes32[] storage arrPtr) internal
returns(bytes32)
/*
remove the element at index replacing the indexed element with the last element
order of the array is NOT preserved
*/
{
require(idx < arrPtr.length, "IndexError");
bytes32 ret = arrPtr[idx];
arrPtr[idx] = arrPtr[arrPtr.length - 1];
arrPtr.pop();
return ret;
}
function _popFromBytes32Array(uint idx, bytes32[] storage arrPtr) internal
returns(bytes32)
/*
remove the element at index and reassign each element just like in python arr.pop(idx)
order of the array IS preserved
*/
{
require(idx < arrPtr.length, "IndexError");
bytes32 ret = arrPtr[idx];
for (uint i = idx; i < arrPtr.length - 1; i++){
arrPtr[i] = arrPtr[i + 1];
}
arrPtr.pop();
return ret;
}
function _findInBytes32Array(bytes32 data, bytes32[] storage arrPtr) internal view
returns(bool, uint)
/*
find the first index for which data == arrPtr[index]
the first returned element is meant to be "found"
in case of not found the index will be the last index (arrPtr.length-1)
call like:
(bool found, uint index) = _findIn<T>Array(data, stateMember);
*/
{
bool found = false;
uint index;
for (index; index < arrPtr.length; index++){
if(arrPtr[index] == data){
found = true;
break;
}
}
return (found, index);
}
}
pragma solidity 0.7.5;
contract Ownable {
address internal owner;
constructor(){
owner = msg.sender;
}
modifier onlyOwner {
require(msg.sender == owner, "Owner only");
_;
}
}
- ./abc/DestroyableByOwner.sol
pragma solidity 0.7.5;
import "./Ownable.sol";
contract DestroyableByOwner is Ownable {
function destroy() public onlyOwner {
selfdestruct(payable(owner));
}
}
pragma solidity 0.7.5;
pragma abicoder v2;
import "./abc/Ownable.sol";
import "./abc/DestroyableByOwner.sol";
import "./abc/Bytes32ArrayAux.sol";
import "./abc/AddressArrayAux.sol";
contract XOfYMultiSig is
Ownable,
DestroyableByOwner,
Bytes32ArrayAux,
AddressArrayAux {
/*
Implementation of a X of Y MultiSig
*/
address[] public signers;
uint8 X;
uint8 Y;
mapping(address => bytes32[]) approvals;
mapping(bytes32 => Transferral) proposals;
struct Transferral {
address recipient;
uint amount;
}
constructor (uint8 _X, uint8 _Y) Ownable() {
// super() in function header
require(_X > 0 && _Y > _X, "Wrong parameters");
X = _X;
Y = _Y;
}
/***
* Signers mgmt
*/
modifier signersNotFull {
require(signers.length < Y, "Too many signers");
_;
}
modifier signersNotEmpty {
require(signers.length > 0, "No signer");
_;
}
modifier callerIsSigner {
require(_isSigner(msg.sender), "Signers only");
_;
}
modifier signersOrOwnerOnly {
require(
msg.sender == owner || _isSigner(msg.sender),
"Signers or Owner only"
);
_;
}
event signerAdded(address indexed signer);
event signerRemoved(address indexed signer);
event signerApprovalsCleared(address indexed signer);
// isSigner
function _isSigner(address addr) private view
returns(bool){
(bool ret, uint idx) = _findInAddressArray(addr, signers);
return ret;
}
// rmSigner
function _rmSigner(uint idx) private {
address signer = signers[idx];
uint preSignersLen = signers.length;
// if it's ok to mess with array order:
_rmFromAddressArray(idx, signers);
// if array order matters (more gas consumed)
// _popFromAddressArray(idx, signers);
assert(signers.length == preSignersLen - 1);
assert(!_isSigner(signer));
}
function _rmSigner(address signer) private {
(bool isSigner, uint signerIdx) = _findInAddressArray(signer, signers);
require(isSigner, "Not a signer");
_rmSigner(signerIdx);
}
// hasApproved
function _hasApproved(address signer, bytes32 tfid) private view
returns(bool){
for (uint i = 0; i < approvals[signer].length; i++){
if (approvals[signer][i] == tfid){
return true;
}
}
return false;
}
// clearSignerApprovals
function _clearSignerApprovals(address signer) private {
bytes32[] storage _approvals = approvals[signer];
while(_approvals.length > 0){
_approvals.pop();
}
}
//
// Public methods
//
function addSigner(address signer) public onlyOwner signersNotFull {
require(!_isSigner(signer), "Already a signer");
signers.push(signer);
emit signerAdded(signer);
assert(signers.length <= Y);
}
function rmSigner(address signer) public onlyOwner signersNotEmpty {
(bool isSigner, uint signerIdx) = _findInAddressArray(signer, signers);
require(isSigner, "Not a signer");
_rmSigner(signerIdx);
emit signerRemoved(signer);
// gas consuming, but needed to avoid retrieving back old approvals
// if the signer is re-added
_clearSignerApprovals(signer);
emit signerApprovalsCleared(signer);
}
/***
* Transferrals mgmt
*/
event transferralProposed(
address indexed proposer,
address indexed recipient,
uint amount
);
event transferralApproved(
address indexed signer,
address indexed recipient,
uint amount
);
event approvalRevoked(
address indexed signer,
address indexed recipient,
uint amount
);
event quorumReached(
address indexed recipient,
uint amount
);
event proposalRemoved(
address indexed recipient,
uint amount
);
// getTransferralSafe
// has require checks
function _getTransferralSafe(bytes32 tfid) private view
returns(Transferral storage, bytes32){
require(_transferralInProposals(tfid), "Transferral not yet proposed");
Transferral storage transferral = proposals[tfid];
require(_transferralIsValid(transferral), "Transferral not valid");
return (transferral, tfid);
}
function _getTransferralSafe(address recipient, uint amount) private view
returns(Transferral storage, bytes32){
return _getTransferralSafe(_transferralId(Transferral(recipient, amount)));
}
// transferralId
function _transferralId(address recipient, uint amount) private pure
returns(bytes32){
return keccak256(abi.encodePacked(recipient, amount));
}
function _transferralId(Transferral memory transferral) private pure
returns(bytes32){
return _transferralId(transferral.recipient, transferral.amount);
}
// transferralIsValid
function _transferralIsValid(Transferral memory transferral) private pure
returns(bool){
return transferral.amount > 0;
}
// transferralIsBacked
function _transferralIsBacked(Transferral memory transferral) private view
returns(bool){
return transferral.amount < address(this).balance;
}
// transferralInProposals
function _transferralInProposals(bytes32 tfid) private view
returns(bool){
return _transferralId(proposals[tfid]) == tfid;
}
function _transferralInProposals(Transferral memory transferral) private view
returns(bool){
return _transferralInProposals(_transferralId(transferral));
}
// transferralHasQuorum
function _transferralHasQuorum(bytes32 tfid) private view
returns(bool){
uint8 _approvals;
for (uint i = 0; i < signers.length; i++){
if (_hasApproved(signers[i], tfid)){
_approvals++;
}
}
return _approvals >= X;
}
function _transferralHasQuorum(Transferral memory transferral) private view
returns(bool){
return _transferralHasQuorum(_transferralId(transferral));
}
// approveTransferral
function _approveTransferral(bytes32 tfid) private callerIsSigner {
approvals[msg.sender].push(tfid);
assert(_hasApproved(msg.sender, tfid));
}
function _approveTransferral(Transferral memory transferral) private {
return _approveTransferral(_transferralId(transferral));
}
// revokeApproval
function _revokeApproval(bytes32 tfid) private callerIsSigner {
(bool hasApproved, uint approvalIdx) = _findInBytes32Array(tfid, approvals[msg.sender]);
require(hasApproved, "Signer hasn't approved this Transferral");
// if it's ok to mess with array order:
_rmFromBytes32Array(approvalIdx, approvals[msg.sender]);
// if array order matters (more gas consumed)
// _popFromBytes32Array(approvalIdx, approvals[msg.sender]);
assert(!_hasApproved(msg.sender, tfid));
}
function _revokeApproval(Transferral storage transferral) private {
return _revokeApproval(_transferralId(transferral));
}
// clearTransferralApprovals
function _clearTransferralApprovals(bytes32 tfid) private {
for (uint i=0; i < signers.length; i++){
(bool hasApproved, uint approvalIdx) = _findInBytes32Array(tfid, approvals[signers[i]]);
if (hasApproved){
_rmFromBytes32Array(approvalIdx, approvals[signers[i]]);
}
}
assert (!_hasApprovals(tfid));
}
// rmProposal
function _rmProposal(bytes32 tfid) private {
delete proposals[tfid];
assert (!_transferralInProposals(tfid));
}
// getApprovals
function _getApprovals(bytes32 tfid) private view
returns(uint8){
uint8 ret;
for(uint i=0; i<signers.length; i++){
if(_hasApproved(signers[i], tfid)){
ret++;
}
}
return ret;
}
// hasApprovals
function _hasApprovals(bytes32 tfid) private view
returns(bool){
for(uint i=0; i<signers.length; i++){
if(_hasApproved(signers[i], tfid)){
return true;
}
}
return false;
}
//
// Public methods
//
function approveTransferral(address recipient, uint amount) public callerIsSigner {
(Transferral storage transferral, bytes32 tfid) = _getTransferralSafe(recipient, amount);
require(_transferralIsBacked(transferral), "Wallet Balance doesn't cover Transferral amount");
require(!_hasApproved(msg.sender, tfid), "Signer has already approved this Transferral");
_approveTransferral(tfid);
emit transferralApproved(msg.sender, recipient, amount);
// check if quorum is reached
if (_transferralHasQuorum(tfid)){
emit quorumReached(recipient, amount);
// execute the transfer
bool sent = _transfer(payable(recipient), amount);
require(sent, "Transfer failed");
emit trasferExecuted(recipient, amount);
// revokeApprovals to avoid repay attacks
_clearTransferralApprovals(tfid);
_rmProposal(tfid);
// not emitting a signal here
// assume that a trasferExecuted signal also signal that
// current signers approvals of the same Transferral
// has been cleared
}
}
function revokeApproval(address recipient, uint amount) public callerIsSigner {
(Transferral storage transferral, bytes32 tfid) = _getTransferralSafe(recipient, amount);
_revokeApproval(tfid);
emit approvalRevoked(msg.sender, recipient, amount);
}
function proposeTransferral(address recipient, uint amount) public callerIsSigner {
Transferral memory tf = Transferral(recipient, amount);
bytes32 tfid = _transferralId(tf);
require(!_transferralInProposals(tfid), "Transferral already proposed");
require(_transferralIsValid(tf), "Transferral not valid");
require(_transferralIsBacked(tf), "Wallet Balance doesn't cover Transferral amount");
proposals[tfid] = tf;
emit transferralProposed(msg.sender, recipient, amount);
// auto approve when proposing
_approveTransferral(tfid);
emit transferralApproved(msg.sender, recipient, amount);
}
function getApprovals(address recipient, uint amount) public view
returns(uint8){
(Transferral storage transferral, bytes32 tfid) = _getTransferralSafe(recipient, amount);
return _getApprovals(tfid);
}
/***
* Owner additional mgmt
*/
function clearAllApprovals() public onlyOwner signersNotEmpty {
for (uint i=0; i < signers.length; i++){
_clearSignerApprovals(signers[i]);
emit signerApprovalsCleared(signers[i]);
}
}
function clearSignerApprovals(address signer) public onlyOwner signersNotEmpty {
require(_isSigner(signer), "Not a signer");
_clearSignerApprovals(signer);
emit signerApprovalsCleared(signer);
}
function rmProposal(address recipient, uint amount) public onlyOwner {
(Transferral storage transferral, bytes32 tfid) = _getTransferralSafe(recipient, amount);
_clearTransferralApprovals(tfid);
_rmProposal(tfid);
emit proposalRemoved(recipient, amount);
}
/***
* Ether IO
*/
event depositCredited(
address indexed donor,
uint amount
);
event trasferExecuted(
address indexed recipient,
uint amount
);
function deposit() public payable {
emit depositCredited(msg.sender, msg.value);
}
function _transfer(address payable to, uint amount) private signersOrOwnerOnly
returns(bool){
(bool sent, bytes memory data) = to.call{value: amount}("");
return(sent);
}
}
Final thoughts and questions
As I said I’m looking forward to check the actual solution and look for a shorter implementation. When coding the aux contracts for handling arrays I really was asking myself if there wasn’t some kind of templating (like cpp) to avoid to basically duplicate all the code just for sake of the array type (would be awesome to write some template function like _findInArray(<T> needle, <T>[] haystack) returns(uint)
and use it with multiple types.
I will really appreciate your feedback and your advice about this implementation.
This course was really good, I literally just ate it in 48h and I’m just getting hungrier 