I ended up making something a bit different (and hopefully smarter) than what was originally requested by the assignment as I wanted to achieve something a little more flexible, I hope this is not a problem!
Here is a summary of the features I have implemented:
-
The contract acts like a bank and allows any number of multisig accounts to be created. Each account has a unique ID (uint) and a custom alias (string).
-
The default minimum signatures is set to 2 via constructor, but this requirement can be changed on each account if ALL of its members agree to do so.
-
Any address can create an account (and automatically become a member of it).
-
Account members can candidate new members to be added (the function “signAccountOption” can be used to approve them).
-
As long as the account has only one member, every operation (deposit, withdraw, transfer, add member, change account settings) doesn’t require additional signatures and is executed right away.
-
After a second member has been added to the account, every action (except deposits) requires additional signatures.
-
New account members are not allowed to sign any pending transaction/operation requested before they were added to the account.
-
Owner can: pause/unpause the contract (view functions are not paused), transfer the contract ownership (the new owner must accept), set a new default for minimum signatures (which affects only new accounts).
-
Anyone can: create a new account, check the balance and members of any account, check the settings (such as minimum signatures required) of any account, check the signatories (addresses allowed to sign) of any transaction/operation of any account, check the list and state of pending transactions of any account.
-
Account members can: deposit, request transfers/withdraws, request account changes, sign all pending operations requested after they joined the account
The code is a bit long, so here is the link to the github repository I created to work on it:
github.com/vale-dinap/multisig_wallet
File1: type_definitions.sol
// Custom data types
pragma solidity ^0.8.9;
import "./utility_functions.sol"; // Utility functions used in the contract
library multisig_dataTypes{
struct Signature{ // Data type to store pending signatures
address[] signatories; // this will be filled with the addresses allowed to sign: after an allowed address signs, the "count" grows by 1 and the address is removed from the array (so that it won't be able to sign again)
uint count;
}
struct Transaction{ // Data type to store pending transactions
address payee;
uint value;
Signature signature;
}
enum OptionType {addMember, changeSignaturesRequired, changeLabel}
struct AccountOption{ // Data type to store pending account changes
OptionType option;
address addressData; // address data container, the contents can vary depending on the specific account option
uint uintData; // uint data container, the contents can vary depending on the specific account option
string stringData; // string data container, the contents can vary depending on the specific account option
Signature signature;
}
// NOTE: The following functions allow to output the data from the structs above as a human-readable formatted string. They are meant for debug purposes only as string manipulation uses a lot of resources.
/*
function decodeTransactionData(Transaction memory transaction) pure internal returns (string memory result){ // SLOW, using for debug purposes
string memory signatories;
for(uint i=0; i<transaction.signature.signatories.length; ++i){
if(transaction.signature.signatories[i] != address(0)){
signatories=string(abi.encodePacked(signatories, ", ", utilityLib.addressToString(transaction.signature.signatories[i])));
}
}
result=string(abi.encodePacked("Send ", utilityLib.uintToString(transaction.value), " wei to address ", utilityLib.addressToString(transaction.payee), ". Signed ", utilityLib.uintToString(transaction.signature.count), " times. Can be signed by ", signatories));
}
function decodeAccountOptionData(AccountOption memory accOption) pure internal returns (string memory result){ // SLOW, using for debug purposes
string memory optionDescr;
if(uint(accOption.option) == 0){ // Add a member
optionDescr = string(abi.encodePacked("Add member, address: ", utilityLib.addressToString(accOption.addressData) ));
}
else if(uint(accOption.option) == 1){ // Change number of signatures required for transactions
optionDescr = string(abi.encodePacked("Change amount of required signatures to: ", utilityLib.uintToString(accOption.uintData) ));
}
else if(uint(accOption.option) == 2){ // Change the group label
optionDescr = string(abi.encodePacked("Change account label to: ", accOption.stringData ));
}
string memory signatories;
for(uint i=0; i<accOption.signature.signatories.length; ++i){
if(accOption.signature.signatories[i] != address(0)){
signatories=string(abi.encodePacked(signatories, ", ", utilityLib.addressToString(accOption.signature.signatories[i])));
}
}
result=string(abi.encodePacked(optionDescr, ". Signed ", utilityLib.uintToString(accOption.signature.count), " times. Can be signed by ", signatories));
}
*/
}
File 2: utility_functions.sol
// Utility functions
pragma solidity ^0.8.9;
library utilityLib{
// looks for the given address in the given array, if found returns the index of the first match, otherwise -1
function find(address[] memory list, address member) internal pure returns(int){
int index=-1;
for(uint i=0; i<list.length; i++){
if(member == list[i]){
return int(i);
}
}
return index;
}
// wrapper of "find", returns a bool (true if found); I could have avoided this but helps making the main contract's code cleaner and easier to read/amend
function inList(address[] memory list, address member) internal pure returns(bool found){
found = find(list, member)>=0;
}
// finds and removes a known element from a dynamic address array (by actually rebuilding it)
function removeItem(address[] memory list, address member) internal pure returns(address[] memory newList){
if(inList(list, member)){
uint k;
for(uint i=0; i<list.length; i++){
if(member != list[i]){
newList[k]=list[i];
k++;
}
}
}
else{newList=list;}
}
// Minimum between two numbers
function min(uint _valueA, uint _valueB) internal pure returns(uint result){
result = (_valueA<_valueB ? _valueA : _valueB);
}
// Convert uint to string - source: StackOverflow website
function uintToString(uint _i) internal pure returns (string memory str){
if (_i == 0){return "0";}
uint j = _i;
uint length;
while (j != 0){
length++;
j /= 10;
}
bytes memory bstr = new bytes(length);
uint k = length;
j = _i;
while (j != 0){
bstr[--k] = bytes1(uint8(48 + j % 10));
j /= 10;
}
str = string(bstr);
}
// Convert address to string - source: StackOverflow website
function addressToString(address addr) internal pure returns(string memory) {
bytes32 value = bytes32(uint256(uint160(addr)));
bytes memory alphabet = "0123456789abcdef";
bytes memory str = new bytes(51);
str[0] = "0";
str[1] = "x";
for (uint i = 0; i < 20; i++) {
str[2+i*2] = alphabet[uint(uint8(value[i + 12] >> 4))];
str[3+i*2] = alphabet[uint(uint8(value[i + 12] & 0x0f))];
}
return string(str);
}
}
File 3: ownable.sol
// Base contract allowing owner functions
pragma solidity ^0.8.9;
contract Ownable{
address owner;
address ownerProposed;
// constructor
constructor(){
owner = msg.sender;
}
// modifiers
modifier onlyOwner {
require(msg.sender == owner, "Only contract owner can do this");
_;
}
function offerOwnership(address newOwner) public onlyOwner { // Candidate new address for ownership
ownerProposed = newOwner;
}
function acceptOwnership() public { // Accept contract ownership
require(ownerProposed == msg.sender, "You are not allowed to claim the ownership of the contract");
owner = msg.sender;
ownerProposed = address(0);
}
}
File 4: pausable.sol
// Pausable contract (owner only)
pragma solidity ^0.8.9;
import "./ownable.sol";
contract Pausable is Ownable{
bool paused;
// Constructor
constructor(){
paused = false;
}
// Functions to pause and unpause the contract
function pauseContract() public onlyOwner {
paused=true;
}
function unpauseContract() public onlyOwner {
paused=false;
}
// Modifier to run functions only if the contract is not paused
modifier notPaused {
require(paused==false, "This function has been temporarily paused by the admin");
_;
}
}
File 5: accountable.sol
pragma solidity ^0.8.9;
pragma abicoder v2;
import "./pausable.sol"; // Base contract making the contract pausable and adding admin controls (as Pausable is also Ownable)
import {multisig_dataTypes as MultisigType} from "./type_definitions.sol"; // Custom data types are defined in a separate library file
import "./utility_functions.sol"; // Utility functions used in the contract
/*
Terms used in variables:
-Account: account on this bank, can have any given number of wallets as its members. Accounts are identified by ID.
-Member: wallet belonging to a sepcific account. A wallet can be member of multiple accounts.
-Transaction: any operation involving transfer of funds (deposit, withdraw, transfer). If the account has more than one member, withdraw and transfer remain pending until enough signatures are provided.
-Pending transactions: each account has an array working as a buffer of transactions that have been requested by one of its members but haven been signed enough times.
-Transaction ID: the index of a transaction within the array of pending transactions.
-Signatories: wallet addresses allowed to sign a specific transaction; their list is embedded in each pending transaction by the time the transaction is created.
This is important to prevent a new wallet added to the account from signing any transactions created earlier.
*/
contract Accountable is Pausable{ // Pausable is also Ownable by multi-level inheritance
uint public numberOfAccounts; //Initialize accounts counter
uint defaultSignaturesRequired; //Initialize global state variable for minimum number of signatures
constructor(){
defaultSignaturesRequired = 2; //Define default minimum number of signatures required to run a Tx
}
// MAPPINGS
mapping (uint => address[]) members; // store an array of members for each account
mapping (uint => MultisigType.Transaction[]) pendingTransactions; // store an array of pending Tx for each account (hopefully it should be a good optimization in both terms of data structure and gas cost)
mapping (uint => uint) balance; // store account balance
mapping (uint => string) label; // store an optional account label for easy recognition
mapping (uint => uint) signaturesRequired; // store per-account override on required amount of signatures
mapping (uint => MultisigType.AccountOption[]) pendingSettings; // store an array of pending settings changes for each account
// MODIFIERS
modifier existingAccount(uint accountId){ // This ensures that functions are executed only on existing accounts
require(accountId < numberOfAccounts, "This account ID does not exist");
_;
}
modifier onlyMember(uint accountId){ // This ensures that sender can run functions only on accounts he/she is member of
require(utilityLib.inList(members[accountId], msg.sender), "Your cannot do this on accounts you are not member of");
_;
}
modifier existingAccountSettingProposal(uint accountId, uint pendingOptionId){ // This ensures that functions are executed only on existing account amendments proposals
require(pendingOptionId < pendingSettings[accountId].length, "This account setting proposal ID does not exist");
_;
}
// SIGNATURE FUNCTION
function sign(MultisigType.Signature storage signature) internal notPaused {
require(utilityLib.inList(signature.signatories, msg.sender), "You are not allowed to sign this (or you have already signed)");
int index = utilityLib.find(signature.signatories, msg.sender);
delete signature.signatories[uint(index)];
signature.count+=1;
}
/* //Alternative sign function (generates errors in some cases, needs debug, will use the other for now)
function sign(MultisigType.Signature storage signature) internal notPaused {
require(utilityLib.inList(signature.signatories, msg.sender), "You are not allowed to sign this (or you have already signed)");
signature.signatories=utilityLib.removeItem(signature.signatories, msg.sender);
signature.count+=1;
}*/
// PRIVATE FUNCTIONS
///////////////////////////// The function below is overloaded /////////////////////////////
function buildAccountOption(uint accountId, MultisigType.OptionType option, address data) private notPaused existingAccount(accountId) onlyMember(accountId) returns(uint) { // Function overload for "address" input
uint curOptAmount = pendingSettings[accountId].length;
MultisigType.AccountOption memory newOption;
newOption.option = option;
newOption.addressData = data;
newOption.signature.signatories=members[accountId];
newOption.signature.count=0;
pendingSettings[accountId].push(newOption);
uint newOptionId = pendingSettings[accountId].length-1;
assert(curOptAmount == (pendingSettings[accountId].length-1));
return newOptionId;
}
function buildAccountOption(uint accountId, MultisigType.OptionType option, uint data) private notPaused existingAccount(accountId) onlyMember(accountId) returns(uint) { // Function overload for "uint" input
uint curOptAmount = pendingSettings[accountId].length;
MultisigType.AccountOption memory newOption;
newOption.option = option;
newOption.uintData = data; // Specific data type for this overloaded function, the rest of the function body is unchanged
newOption.signature.signatories=members[accountId];
newOption.signature.count=0;
pendingSettings[accountId].push(newOption);
uint newOptionId = pendingSettings[accountId].length-1;
assert(curOptAmount == (pendingSettings[accountId].length-1));
return newOptionId;
}
function buildAccountOption(uint accountId, MultisigType.OptionType option, string memory data) private notPaused existingAccount(accountId) onlyMember(accountId) returns(uint) { // Function overload for "string" input
uint curOptAmount = pendingSettings[accountId].length;
MultisigType.AccountOption memory newOption;
newOption.option = option;
newOption.stringData = data; // Specific data type for this overloaded function, the rest of the function body is unchanged
newOption.signature.signatories=members[accountId];
newOption.signature.count=0;
pendingSettings[accountId].push(newOption);
uint newOptionId = pendingSettings[accountId].length-1;
assert(curOptAmount == (pendingSettings[accountId].length-1));
return newOptionId;
}
///////////////////////////// End of function overload /////////////////////////////////////
function changeAccountOption(uint accountId, uint settingProposalId) private notPaused existingAccount(accountId) onlyMember(accountId) existingAccountSettingProposal(accountId, settingProposalId) {
// Check signatures needed to perform the option change
uint requiredSignatures;
uint optionType = uint(pendingSettings[accountId][settingProposalId].option);
if(optionType == 0){requiredSignatures=signaturesRequired[accountId];} // Signatures needed to add a member
else if(optionType == 1){requiredSignatures=members[accountId].length;} // Signatures needed to change number of signatures required for transactions - full consensus!
else if(optionType == 2){requiredSignatures=signaturesRequired[accountId];} // Signatures needed to change the group label
requiredSignatures = utilityLib.min(requiredSignatures, members[accountId].length); // Now override the number of signatures needed IF the account contains fewer members than required
require(pendingSettings[accountId][settingProposalId].signature.count>=requiredSignatures, "Not enough signatures");
// Perform change
if(optionType == 0){ // Add a member
address addressData = pendingSettings[accountId][settingProposalId].addressData;
require(utilityLib.inList(members[accountId], addressData) == false, "The wallet you are attempting to add is already a member of the selected account");
members[accountId].push(addressData);}
else if(optionType == 1){ // Change number of signatures required for transactions
uint uintData = pendingSettings[accountId][settingProposalId].uintData;
signaturesRequired[accountId]=uintData;}
else if(optionType == 2){ // Change the group label
string memory stringData = pendingSettings[accountId][settingProposalId].stringData;
label[accountId]=stringData;}
}
// INTERFACE FUNCTIONS
function signAccountOption(uint _accountId, uint _settingProposalId) public notPaused existingAccount(_accountId) onlyMember(_accountId) existingAccountSettingProposal(_accountId, _settingProposalId) {
sign(pendingSettings[_accountId][_settingProposalId].signature); // Do sign
// Check signatures needed to also perform the option change
uint requiredSignatures;
uint optionType = uint(pendingSettings[_accountId][_settingProposalId].option);
if(optionType == 0){requiredSignatures=signaturesRequired[_accountId];} // Signatures needed to add a member
if(optionType == 1){requiredSignatures=members[_accountId].length;} // Signatures needed to change number of signatures required for transactions - needs full consensus
if(optionType == 2){requiredSignatures=signaturesRequired[_accountId];} // Signatures needed to change the group label
requiredSignatures = utilityLib.min(signaturesRequired[_accountId], members[_accountId].length); // Now override the number of signatures required IF the account contains fewer members
// Finally perform the change (if enough signatures)
if(pendingSettings[_accountId][_settingProposalId].signature.count >= requiredSignatures){
changeAccountOption(_accountId, _settingProposalId);
}
}
function createAccount(string memory _label) public notPaused { // Creates a new account, the wallet creating it becomes automatically its first member
uint newAccountId = numberOfAccounts;
members[newAccountId].push(msg.sender);
balance[newAccountId] = 0;
label[newAccountId] = _label;
signaturesRequired[newAccountId] = defaultSignaturesRequired; // Initialize with global value; this can be later amended by the account owners (with full consensus) - I HAVEN'T WRITTEN YET THE FUNCTION TO DO THIS
numberOfAccounts+=1;
}
function addMember(uint _accountId, address _newMemberAddress) public notPaused existingAccount(_accountId) onlyMember(_accountId) {
require(utilityLib.inList(members[_accountId], _newMemberAddress) == false, "The wallet you are attempting to add is already a member of the selected account");
uint optionId = buildAccountOption(_accountId, MultisigType.OptionType(0), _newMemberAddress);
signAccountOption(_accountId, optionId);
}
function getMembers(uint _accountId) public view existingAccount(_accountId) returns(address[] memory){ // Return all members of the account id specified
return members[_accountId];
}
function setAccountLabel(uint _accountId, string memory _newLabel) public notPaused existingAccount(_accountId) onlyMember(_accountId) {
uint optionId = buildAccountOption(_accountId, MultisigType.OptionType(2), _newLabel);
signAccountOption(_accountId, optionId);
}
function getAccountLabel(uint _accountId) public view existingAccount(_accountId) returns(string memory){ // Return the label of the given account ID
return label[_accountId];
}
function setSignaturesRequired(uint _accountId, uint _newAmount) public notPaused existingAccount(_accountId) onlyMember(_accountId) {
uint optionId = buildAccountOption(_accountId, MultisigType.OptionType(1), _newAmount);
signAccountOption(_accountId, optionId);
}
function setDefaultSignaturesRequired(uint _newAmount) public onlyOwner {
defaultSignaturesRequired = _newAmount;
}
function getSignaturesRequired(uint _accountId) view public existingAccount(_accountId) returns(uint){
uint amount = signaturesRequired[_accountId];
return amount;
}
// SIGNATURE LOOKUP
function countOptionChangeSignatures(uint _accountId, uint _settingProposalId) public view existingAccount(_accountId) existingAccountSettingProposal(_accountId, _settingProposalId) returns(uint){
uint count = pendingSettings[_accountId][_settingProposalId].signature.count;
return count;
}
function getOptionChangeSignatories(uint _accountId, uint _settingProposalId) public view existingAccount(_accountId) existingAccountSettingProposal(_accountId, _settingProposalId) returns(address[] memory){
address[] memory signatories = pendingSettings[_accountId][_settingProposalId].signature.signatories;
return signatories;
}
// HUMAN-READABLE OPTION LOOKUP (outputs all info formatted as a string) - slow, using for debug purposes - to use them, also uncomment the last part of the file "type_definitions"
/*
function printSettingProposalData(uint _accountId, uint _settingProposalId) public view existingAccount(_accountId) existingAccountSettingProposal(_accountId, _settingProposalId) returns(string memory propData){
propData = string(abi.encodePacked(
"Account ID: ", utilityLib.uintToString(_accountId),
", proposal ID: ", utilityLib.uintToString(_settingProposalId), ". ",
MultisigType.decodeAccountOptionData(pendingSettings[_accountId][_settingProposalId])
));
}
*/
}
File 6: multisig_bank.sol
pragma solidity ^0.8.9;
pragma abicoder v2;
import "./pausable.sol"; // Base contract making the contract pausable and adding admin controls (as Pausable is also Ownable)
import {multisig_dataTypes as MultisigType} from "./type_definitions.sol"; // Custom data types are defined in a separate library file
import "./utility_functions.sol"; // Utility functions used in the contract
import "./accountable.sol"; // Base contract adding account management functionalities, making the contract pausable and adding admin controls (Accountable is Pausable, Pausable is Ownable)
/*
Terms used in variables:
-Account: account on this bank, can have any given number of wallets as its members. Accounts are identified by ID.
-Member: wallet belonging to a sepcific account. A wallet can be member of multiple accounts.
-Transaction: any operation involving transfer of funds (deposit, withdraw, transfer). If the account has more than one member, withdraw and transfer remain pending until enough signatures are provided.
-Pending transactions: each account has an array working as a buffer of transactions that have been requested by one of its members but haven been signed enough times.
-Transaction ID: the index of a transaction within the array of pending transactions.
-Signatories: wallet addresses allowed to sign a specific transaction; their list is embedded in each pending transaction by the time the transaction is created.
This is important to prevent a new wallet added to the account from signing any transactions created earlier.
*/
contract MultisigBank is Accountable{ // Accountable is also Pausable and Ownable by multi-level inheritance
// MODIFIERS
modifier existingTransaction(uint accountId, uint transactionId){ // This ensures that functions are executed only on existing transactions
require(transactionId < pendingTransactions[accountId].length, "This transaction ID does not exist");
_;
}
modifier hasEnoughFunds(uint accountId, uint value){ // This ensures that the account has enough founds to perform the action.
require(value<=getBalance(accountId), "The account has not enough funds to execute this transaction");
_;
}
// PRIVATE FUNCTIONS
function buildTransaction(uint accountId, address payee, uint value) private notPaused existingAccount(accountId) onlyMember(accountId) hasEnoughFunds(accountId, value) returns(uint) {
MultisigType.Transaction memory newTransaction;
newTransaction.payee = payee;
newTransaction.value = value;
newTransaction.signature.signatories=members[accountId];
newTransaction.signature.count=0;
pendingTransactions[accountId].push(newTransaction);
uint newTransactionId = pendingTransactions[accountId].length-1;
return newTransactionId;
}
function runTransaction(uint accountId, uint transactionId) private notPaused hasEnoughFunds(accountId, pendingTransactions[accountId][transactionId].value) {
uint requiredSignatures = utilityLib.min(signaturesRequired[accountId], members[accountId].length);
require(pendingTransactions[accountId][transactionId].signature.count>=requiredSignatures, "Not enough signatures");
uint origAccountBalance = balance[accountId]; //used in assert stataments
uint balanceSum = pendingTransactions[accountId][transactionId].payee.balance + address(this).balance; //used in assert stataments
balance[accountId] -= pendingTransactions[accountId][transactionId].value;
payable(pendingTransactions[accountId][transactionId].payee).transfer(pendingTransactions[accountId][transactionId].value);
assert(balance[accountId] == origAccountBalance - pendingTransactions[accountId][transactionId].value); //check new account balance
assert(balanceSum == pendingTransactions[accountId][transactionId].payee.balance + address(this).balance); //zero sum check
delete pendingTransactions[accountId][transactionId];
}
// TRANSACTIONS
function getBalance(uint _accountId) view public existingAccount(_accountId) returns(uint){
uint value = balance[_accountId];
return value;
}
function listPendingTransactions(uint _accountId) view public existingAccount(_accountId) returns(MultisigType.Transaction[] memory){
MultisigType.Transaction[] memory list = pendingTransactions[_accountId];
return list;
}
function signTransaction(uint _accountId, uint _transactionId) public notPaused existingAccount(_accountId) onlyMember(_accountId) existingTransaction(_accountId, _transactionId) hasEnoughFunds(_accountId, pendingTransactions[_accountId][_transactionId].value){
sign(pendingTransactions[_accountId][_transactionId].signature);
uint requiredSignatures = utilityLib.min(signaturesRequired[_accountId], members[_accountId].length);
if(pendingTransactions[_accountId][_transactionId].signature.count >= requiredSignatures){
runTransaction(_accountId, _transactionId);
}
}
function deposit(uint _accountId) public payable notPaused existingAccount(_accountId) onlyMember(_accountId) {
uint origAccountBalance = balance[_accountId]; //used in assert stataments
uint balanceSum = msg.sender.balance + address(this).balance; //used in assert stataments
balance[_accountId]+=msg.value; //add deposit to account balance
assert(balance[_accountId] == origAccountBalance + msg.value); //check new account balance
assert(balanceSum == msg.sender.balance + address(this).balance); //zero sum check
}
function withdraw(uint _accountId, uint _value) public notPaused existingAccount(_accountId) onlyMember(_accountId) hasEnoughFunds(_accountId, _value){
uint transactionId = buildTransaction(_accountId, msg.sender, _value);
signTransaction(_accountId, transactionId);
}
function transfer(uint _accountId, address _payee, uint _value) public notPaused existingAccount(_accountId) onlyMember(_accountId) hasEnoughFunds(_accountId, _value){
uint transactionId = buildTransaction(_accountId, _payee, _value);
signTransaction(_accountId, transactionId);
}
// SIGNATURE LOOKUP
function countTransactionSignatures(uint _accountId, uint _transactionId) public view existingAccount(_accountId) existingTransaction(_accountId, _transactionId) returns(uint){
uint count = pendingTransactions[_accountId][_transactionId].signature.count;
return count;
}
function getTransactionSignatories(uint _accountId, uint _transactionId) public view existingAccount(_accountId) existingTransaction(_accountId, _transactionId) returns(address[] memory){
address[] memory signatories = pendingTransactions[_accountId][_transactionId].signature.signatories;
return signatories;
}
// HUMAN-READABLE TRANSACTION LOOKUP (outputs all info formatted as a string) - slow, using for debug purposes - to use them, also uncomment the last part of the file "type_definitions"
/*
function printTransactionData(uint _accountId, uint _transactionId) public view existingAccount(_accountId) existingTransaction(_accountId, _transactionId) returns(string memory txData){
txData = string(abi.encodePacked(
"Account ID: ", utilityLib.uintToString(_accountId),
", transaction ID: ", utilityLib.uintToString(_transactionId), ". ",
MultisigType.decodeTransactionData(pendingTransactions[_accountId][_transactionId])
));
}
*/
}
I have to say I had a very good time coding this! 
Any feedbacks/suggestions would be massively appreciated.