Building a Project with SE-2 | Crowd Fund | Part Four | Multisig Contract

WebSculpt
10 min readJan 10, 2024

--

Image from Shubham Dhage on Unsplash

If you are here for the first time, then you may want to start at the beginning of this series.
Other posts in the series:
V1’s Smart Contract
V1’s Components

View code on GitHub
View live site

***Note that the readme file has a demo video to better-convey some of these points.

What is this Smart Contract doing?

The Smart Contract will offer multisig capabilities (completely on-chain) for the learning purposes of understanding the inner-workings before we go off-chain for v3.

What has changed since V1?

V1 was a bare-bones crowdfunding contract, but V2 will move into a multisig implementation — one where a “Fund Run” can have more than one owner. This means that — instead of a simple, one-time Owner Withdrawal — funds will be withdrawn via “Proposals” that every owner must support (before funds can be sent).

Here is an example:

An owner can create a proposal, but all of the other owners must support this proposal before any funds can be sent.

The process of interacting with a proposal

  1. The first thing to do is generate ABI encoded data for the Proposal’s details
    – Amount
    – To
    – Proposed By
    – Reason
  2. Then, generate ABI non-standard packed encoded data using the ABI encoded data (from step one) as well as a ‘Nonce’ (which is simply an incrementing number that comes from the contract)
  3. Then, calculate the Keccak256 hash of the packed/encoded data (from step two)
  4. Then, we will Sign the Message using the hash (that has been encoded to a byte array)
    – Which will return the signature hash that we will use to verify the message later
  5. We then send the following data to the createMultisigProposal function on the Smart Contract:
    – Signature Hash (from step four)
    – Fund Run ID
    – Proposal’s Details

encode vs. encodePacked

abi.encode is designed to handle (most of the) static types in solidity, as it applies ABI Encoding rules (via Contract ABI Specification); therefore, the elementary types are padded to 32 bytes and the dynamic arrays will also include their length. This means that (if your types are known), you can decode this data again (using abi.decode).

abi.encodePacked is useful when performing UNPADDED encoding, as it offers a non-standard packed mode where:

  • types shorter than 32 bytes are not padded
  • dynamic types are encoded with their length
  • array elements ARE padded, but still encoded in-place
  • **uses minimal required memory to encode data
  • ***EX: encoding uint8 will only use 1 byte

When to use which?

abi.encode: will prevent collision when working with more than one dynamic data-type.

abi.encodePacked: works with all data-types and takes an arbitrary amount of input.

A note about ethers.js and viem

  • ethers.js library aims to be a complete and compact library for interacting with the Ethereum Blockchain and its ecosystem.”
  • viem is a TypeScript interface for Ethereum that provides low-level stateless primitives for interacting with Ethereum.”

I have provided examples for how to interact with your smart contract using BOTH ethers.js and viem.
The test file uses ethers.js
The components are using viem

The pertinent ethers.js _> viem migration notes (for this project):
arrayify becomes: toBytes
abiCoder.encode becomes: encodeAbiParameters
solidityPack becomes: encodePacked

**The biggest difference (in my opinion) is making the shift from Big Number to (browser-native) Big Int. Here are the full docs for doing an ethers.js _> viem migration.

Signing a message offline

For our purposes in this contract, it is important that we know which user is interacting with a proposal, because — naturally — we only want users who have the correct permissions interacting with it.

Signing and Verifying Ethereum Messages

Ethereum’s underlying cryptography allows for two parties to offer/prove consent (meaning: two entities can agree to some data without the EVM [meaning: no gas/money spent]).
Messages signed using a private key will return a raw signature that anyone can use (along with the unsigned message) to validate the account that (originally) signed the message (using the corresponding public key).
When it comes to security: Unsigned messages can not be extracted from the raw signature without the corresponding private key.
These are off-chain interactions that are safe to settle on-chain.
Why?
Because we know who signed what.
Want to dive deeper? Learn more about ECDSAThe signature algorithm that Ethereum has built-in support for: Elliptic Curve Digital Signature Algorithm (ECDSA).

What‘s in a Proposal?

Here is an image from a Fund Run’s Vault (deployed to Sepolia testnet)

The object that initially gets encoded 👇👇👇

const proposalExample = { 
amount, //amount to send
to, //wallet address sending to
proposedBy, //wallet address creating this proposal
reason //reason for this transaction
}

☝️☝️☝️the resulting (PADDED encoded parameters) data is then (non-standard packed) encoded along with an incrementing nonce (1 … 2 … 3).

👉👉 The final “digest” is a calculated (keccak256) hash. 👈👈

☝️☝️☝️You’re Signing a Message with THAT “digest”.☝️☝️☝️

REMEMBER: I have provided examples for how to interact with your smart contract using BOTH ethers.js and viem.
The test file uses ethers.js
The components are using viem

Here is the digest-creation code in ethers.js

const encoded = ethers.utils.defaultAbiCoder.encode(
["tuple(uint256,address,address,string)"],
[[tx.amount, tx.to, tx.proposedBy, tx.reason]],
);
const encodedWithNonce = ethers.utils.solidityPack(["bytes", "uint256"], [encoded, nonce]);
const digest = ethers.utils.keccak256(encodedWithNonce);

And here is the digest-creation code in viem

encodeAbiParameters(abi_struct[0].inputs, [ //param type and name data (in place of tuple in ethers example)
{
amount,
to,
proposedBy,
reason,
},
]);
const encodedWithNonce = encodePacked(["bytes", "uint256"], [encoded, nonce]);
const digest = keccak256(encodedWithNonce);

Something isn’t quite right…

Before we go any further, let’s talk about this object 👇👇👇

const proposalExample = { 
amount, //amount to send
to, //wallet address sending to
proposedBy, //wallet address creating this proposal
reason //reason for this transaction
}

☝️☝️☝️Note that this proposal is going to get stored on the contract (not just signed). Each of them will go into a mapping that we will use to display to the user later on, and they are going to be passed around. This is a great example for learning what the overall goal is (for a multisig contract), but it isn’t totally ideal.

Note: V3 is going to shift this contract closer to a real-world scenario, where we will query this data from a subgraph instead of saving ALL of this on our contract.

Regarding the article you are currently reading (for V2) — this serves as a stepping stone — you’ll see how these proposals work before we start querying them from The Graph in V3. BUT … V2 is not totally practical due to the amount of data we would eventually be storing.
There is simply a better way to do it, but…
I feel strongly that V2 is the Walk-Before-You-Run to V3. V2 also requires a lot of (otherwise unnecessary) solidity to keep all of our Proposals and Fund Runs separated.

Reviewing what a user needs to create or support a proposal

The first thing to do is obtain a “Digest”👇👇👇

Steps to obtain a “Digest” in both viem and ethers.js

Then, you need to use the digest to Sign the Message 👇👇👇

How to sign your message with viem or ethers.js

Then, you send the Fund Run ID, your new ☝️SIGNATURE, and the Proposal object to the Smart Contract (function: createMultisigProposal)
Here’s an example from the CreateProposal component:

const { writeAsync, isLoading } = useScaffoldContractWrite({
contractName: "CrowdFund",
functionName: "createMultisigProposal",
args: [
creationSignature,
fundRun?.fundRunId,
{
amount: parseEther(transferInput),
to: toAddressInput,
proposedBy: userAddress.address,
reason: reasonInput,
},
],
onBlockConfirmation: txnReceipt => {
console.log("📦 Transaction blockHash", txnReceipt.blockHash);
},
onError: err => {
console.log("Transaction Error Message", err?.message);
},
});

Need a refresher for contract-interaction with SE-2? Here you go.

What are we storing on the contract?

After it is asserted that this particular user can create a Proposal (in this particular Fund Run), then the createMultisigProposal function is going to store all the data we will need to:

  • display Proposals to the user
  • retrieve Proposals for support
  • retrieve Proposal Signatures for verification

👇 Let’s take a look at how a Fund Run’s Proposals are stored 👇

//      fundRunId
mapping(uint16 => MultiSigVault[]) public vaults;

struct MultiSigVault {
uint16 proposalId;
uint256 amount;
address to;
address proposedBy;
string reason;
ProposalStatus status;
}

👇 These mappings store Signers and Signatures 👇

 //      proposalId
mapping(uint16 => bytes[]) public signatureList;
// proposalId
mapping(uint16 => address[]) public signerList;

And each Fund Run will have its own (incrementing) nonce

//      fundRunId
mapping(uint16 => uint256) public vaultNonces;

So, when a new proposal is being created, the Signer and Signature are pushed into their respective arrays:

signatureList[numberOfMultisigProposals].push(_signature);
signerList[numberOfMultisigProposals].push(msg.sender);

And then the Proposal’s details are stored:

MultiSigVault memory vault = MultiSigVault({
proposalId: numberOfMultisigProposals,
amount: _tx.amount,
to: _tx.to,
proposedBy: _tx.proposedBy,
reason: _tx.reason,
status: ProposalStatus(0)
});

vaults[_fundRunId].push(vault);

This allows for Proposals to be retrieved later, like this:

MultiSigVault[] storage vaultsList = vaults[_fundRunId];

Supporting Proposals

With our current set-up, we have everything we need to retrieve a Proposal. Now, we mainly want to determine that a certain user has the permissions to support this Proposal. The Smart Contract has the modifiers necessary to also determine that the transaction has yet to go through, that the user is an owner of the Fund Run, and that the Fund Run is actually a multisig Fund Run.
The supportMultisigProposal function is such that — once inside it — it is pretty much safe (aside from preventing a double-signature) to push the Signer and Signature to their respective arrays:

function supportMultisigProposal(
bytes calldata _signature,
uint16 _fundRunId,
uint16 _proposalId
)
external
isMultisig(_fundRunId, true)
ownsThisFundRun(_fundRunId, msg.sender, true)
createdProposal(_proposalId, _fundRunId, msg.sender, false)
txNotSent(_proposalId, _fundRunId)
{
require(
!userHasSigned(msg.sender, _proposalId),
"This user has already supported this proposal."
);
changeProposalStatus(_fundRunId, _proposalId, 1);
signatureList[_proposalId].push(_signature);
signerList[_proposalId].push(msg.sender);
emit ProposalSupported(msg.sender, _fundRunId, _proposalId);
}

Multisig Withdrawals — Finalizing Proposals

This is the most complicated aspect of this Smart Contract. The idea is to ensure that:

  • enough signers have signed
  • the correct/expected signers have signed

To ensure that the CORRECT signers have signed (before we start moving funds around on their behalf), we need to utilize ECDSA … specifically OpenZeppelin’s ECDSA Smart Contract. With the help of OpenZeppelin, all we will need to recover the address that signed the message is the digest and the signature.
👇 👇 👇Check it out 👇 👇 👇

import { ECDSA } from "../node_modules/@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

address signer = ECDSA.recover(digest, signature);

Then we can verify (for the final time) that this signer has rights to move these funds:

function isOwnerOfFundRun(
address _addr,
uint16 _id
) private view returns (bool) {
for (uint16 i = 0; i < fundRuns[_id].owners.length; i++) {
if (fundRuns[_id].owners[i] == _addr) return true;
}
return false;
}

Here is a blown-up image of the solidity code-snippets for a Multisig Withdrawal…

A view of solidity snippets to help you visualize the flow-of-logic for a multisig withdrawal

Here is a much more simplified view of the flow-of-logic…

A simplified view representing the flow-of-logic for a multisig withdrawal

Looking deeper

How do nonces help with security?

You can think of it like this: each time someone declares that funds should be moved, the nonce will increment. This is actually annoyingly secure, because this contract is currently designed such that each Proposal will need to be finalized before another one can (be finalized).
Take a look at _processMultisigRequest

function _processMultisigRequest(
MultiSigRequest calldata _tx,
uint256 _nonce
) private pure returns (bytes32 _digest) {
bytes memory encoded = abi.encode(_tx);
_digest = keccak256(abi.encodePacked(encoded, _nonce));
_digest = keccak256(abi.encodePacked(MSG_PREFIX, _digest));
}

The digest is being encoded along with the nonce. If the first Proposal is created with a nonce of 1, then the next Proposal will have a nonce of 2. This means that two Proposals can not have the same nonce, and that the nonce (at the time of signing) is necessary to move funds (later on). This contract is easily debuggable so these nonces are not hidden (you can easily view them as you test to see how all of this works).

Input a Fund Run’s ID to see the current nonce

Nonces are helpful for many reasons:

  • ensuring transactions occur in a specific order
  • protection from replay attacks
  • controlling execution/sequence

What’s with the Message Prefix?

If you are wondering about this line👇

string constant private MSG_PREFIX = "\x19Ethereum Signed Message:\n32"; 

This is ensuring that this signature can not (later) be used for nefarious reasons (out-of-context or outside of Ethereum).

The signature, itself, can be notated as {r, s, v}

  • 32 bytes for r (integer)
  • 32 bytes for s (integer)
  • 1 byte for v (ethereum-specific recover identifier)

Learn more here.

Can you still make a single-owner Fund Run?

Yes, the functionality from V1 is all still in place, with the distinction being in the createFundRun function (which now takes an array of addresses), and the FundRun struct:

struct FundRun {
uint16 id;
address[] owners; //now an array
string title;
string description;
.....
...
}

To create a single-owner Fund Run, simply create it using an array of addresses that only contains the single-owner’s wallet address.
Here is the new createFundRun function 👇

function createFundRun(
string memory _title,
string memory _description,
uint256 _target,
uint16 _deadline,
address[] memory _owners
) external {
uint256 fundRunDeadline = block.timestamp + _deadline * 60;
require(
fundRunDeadline > block.timestamp,
"The deadline would ideally be a date in the future there, Time Traveler."
);
bytes32 baseCompare = keccak256("");
bytes32 titleCompare = keccak256(bytes(_title));
bytes32 descriptionCompare = keccak256(bytes(_description));
require(
titleCompare != baseCompare && descriptionCompare != baseCompare,
"Title and Description are both required fields."
);
require(_target > 0, "Your money target must be greater than 0.");

FundRun storage fundRun = fundRuns[numberOfFundRuns];
fundRun.id = numberOfFundRuns;
fundRun.owners = _owners;
fundRun.title = _title;
fundRun.description = _description;
fundRun.target = _target;
fundRun.deadline = fundRunDeadline;
fundRun.amountCollected = 0;
fundRun.amountWithdrawn = 0;
fundRun.status = FundRunStatus(0);
numberOfFundRuns++;

emit FundRunCreated(fundRun.id, fundRun.owners, fundRun.title, fundRun.target);
}

What happens when you Revoke a Proposal?

Revoked Proposals are currently just deleted mapping values 👇

function revokeMultisigProposal(
uint16 _fundRunId,
uint16 _proposalId
)
external
isMultisig(_fundRunId, true)
ownsThisFundRun(_fundRunId, msg.sender, true)
createdProposal(_proposalId, _fundRunId, msg.sender, true)
txNotSent(_proposalId, _fundRunId)
{
MultiSigVault[] storage vaultsList = vaults[_fundRunId];
for (uint16 i = 0; i < vaultsList.length; i++) {
if (vaultsList[i].proposalId == _proposalId) {
emit ProposalRevoked(
_fundRunId,
_proposalId,
vaults[_fundRunId][i].to,
vaults[_fundRunId][i].reason
);
delete vaults[_fundRunId][i]; //delete occurs here
break;
}
}
}

Ready to see the front-end components in action? Click here for Part Five.

--

--

WebSculpt

Blockchain Development, coding on Ethereum. Condensed notes for learning to code in Solidity faster.