Multi-signature vault
Blockchain technology has enabled us to decentralise more than just digital asset management. Another popular field of study is that of decentralised governance. Participating in a vote of any kind can be a very opaque process. Whether it is a vote for the most popular song on the radio or for an elected government official, participants cannot verify whether the process is fair or that the results are genuine. DAOs (Decentralised Autonomous Organisations) can change all that. A DAO is a smart contract that organises some type of decision-making power, usually on behalf of its members.
DAOs can be very complex, featuring multiple levels of management, asset delegation, and member management. Some even have their own tokens that function as an ownership stake or right to access! Most, if not all, aspects of a conventional corporate structure can be translated into a set of smart contracts that mandate corporate bylaws with the integrity that a blockchain provides. The potential of DAOs cannot be underestimated.
For this project, we will create a simplified DAO that allows its members to vote on which principal is allowed to withdraw the DAO's token balance. The DAO will be initialised once when deployed, after which members can vote in favour or against specific principals.
Features
The contract deployer will only have the ability to initialise the contract and will then run its course. The initialising call will define the members (a list of principals) and the number of votes required to be allowed to withdraw the balance.
The voting mechanism will work as follows:
- Members can issue a yes/no vote for any principal.
- Voting for the same principal again replaces the old vote.
- Anyone can check the status of a vote.
- Anyone can tally all the votes for a specific principal.
Once a principal reaches the number of votes required, it may withdraw the tokens.
Constants & variables
We begin with the usual constants to define the contract owner and error codes. When it comes to errors, we can foresee three failures on the initialising step.
- Someone other than the owner is trying to initialise.
- The vault is already locked.
- The initialising call specifies an amount of votes required that is larger the number of members.
The voting process itself will only fail if a non-member tries to vote. Finally, the withdrawal function will only succeed if the voting threshold has been reached.
;; Owner
(define-constant contract-owner tx-sender)
;; Errors
(define-constant err-owner-only (err u100))
(define-constant err-already-locked (err u101))
(define-constant err-more-votes-than-members-required (err u102))
(define-constant err-not-a-member (err u103))
(define-constant err-votes-required-not-met (err u104))
The members will be stored in a list with a given maximum length. The votes themselves will be stored in a map that uses a tuple key with two values: the principal of the member issuing the vote and the principal being voted for.
;; Variables
(define-data-var members (list 100 principal) (list))
(define-data-var votes-required uint u1)
(define-map votes {member: principal, recipient: principal} {decision: bool})
For a simple voting contract, storing the members in a list is acceptable. It also allows us to practice iterating over a list in a few interesting ways. However, it is important to note that such member lists are not sufficient for larger projects as they can quickly become expensive to use. The chapter on best practices covers some uses and possible misuses of lists.
Implementing start
The start
function will be called by the contract owner to initialise the
vault. It is a simple function that updates the two variables with the proper
guards in place.
(define-public (start (new-members (list 100 principal)) (new-votes-required uint))
(begin
(asserts! (is-eq contract-caller contract-owner) err-owner-only)
(asserts! (is-eq (len (var-get members)) u0) err-already-locked)
(asserts! (>= (len new-members) new-votes-required) err-more-votes-than-members-required)
(var-set members new-members)
(var-set votes-required new-votes-required)
(ok true)
)
)
Implementing vote
The vote function is even more straightforward. All we have to do is make sure
the contract-caller
is one of the members. We can do that by checking if the
contract-caller
is present in the members list by using the built-in index-of
function. It returns an optional type,
so we can simply check if it returns a (some ...)
, rather than a none
.
(define-public (vote (recipient principal) (decision bool))
(begin
(asserts! (is-some (index-of (var-get members) contract-caller)) err-not-a-member)
(ok (map-set votes {member: tx-sender, recipient: recipient} {decision: decision}))
)
)
While we are at it, let us also add a read-only function to retrieve a vote. If
a member never voted for a specific principal before, we will default to a
negative vote of false
.
(define-read-only (get-vote (member principal) (recipient principal))
(default-to false (get decision (map-get? votes {member: member, recipient: recipient})))
)
There is a lot going on in this function. Here is what happens step by step:
- Use
map-get?
to retrieve the vote tuple. The function will return asome
or anone
. get
returns the value of the specified key in a tuple. Ifget
is supplied with a(some tuple)
, it will return a(some value)
. Ifget
is suppliednone
, it returnsnone
.default-to
attempts to unwrap the result ofget
. If it is asome
, it returns the wrapped value. If it isnone
, it returns the default value, in this casefalse
.
Tallying the votes
The challenge now is to create a function that can calculate the number of
positive votes for a principal. We will have to iterate over the members,
retrieve their votes, and increment a counter if the vote equals true
. Since
Clarity is non-Turing complete, unbounded for-loops are impossible. In the
chapter on sequences we learned that the only
two ways to iterate over a list are by using the map
or fold
function.
The choice of whether to use map
or fold
comes down to a simple question:
should the result be another list or a singular value?
We want to reduce the list of members to a number that represents the total
amount of positive votes; meaning, we need fold
. First we look at the function
signature again.
(fold accumulator-function input-list initial-value)
fold
will iterate over input-list
, calling accumulator-function
for every
element in the list. The accumulator function receives two parameters: the next
member in the list and the previous accumulator value. The value returned by the
accumulator function is used as the input for the next accumulator call.
Since we want to count the number of positive votes, we should increment the
accumulator value only when the vote for the principal is true
. There is no
built-in function that can do that so we have to create a custom accumulator as
a private function.
(define-private (tally (member principal) (accumulator uint))
(if (get-vote member tx-sender) (+ accumulator u1) accumulator)
)
(define-read-only (tally-votes)
(fold tally (var-get members) u0)
)
The tally-votes
function returns the result of folding over the members list.
Our custom accumulator function tally
calls the get-vote
read-only function
we created earlier with the current current member from the list and the
tx-sender
. The result of this call will be either true
or false
. If the
result is true
, then tally
returns the accumulator incremented by one.
Otherwise, it returns just the current accumulator value.
Unpacking the if
expression:
(if
(get-vote member tx-sender) ;; The condition (boolean expression).
(+ accumulator u1) ;; Value to return if the condition is true.
accumulator ;; Value to return if the condition is false.
)
Since tally-votes
is a read-only function, it can be called with any
tx-sender
principal without having to send a transaction. Very convenient.
Implementing withdraw
We have everything we need to create the withdraw function. It will tally the
votes for tx-sender
and check if it is larger than or equal to the number of
votes required. If the transaction sender passes the bar, the contract shall
transfer all its holdings to the tx-sender
.
(define-public (withdraw)
(let
(
(recipient tx-sender)
(total-votes (tally-votes))
)
(asserts! (>= total-votes (var-get votes-required)) err-votes-required-not-met)
(try! (as-contract (stx-transfer? (stx-get-balance tx-sender) tx-sender recipient)))
(ok total-votes)
)
)
The total votes are returned for convenience so that it can be recorded on the blockchain and perhaps used by the calling application.
Deposit convenience function
Finally, we will add a convenience function to deposit tokens into the contract. It is definitely not required as users can transfer tokens to a contract principal directly. The function will be useful when writing unit tests later.
(define-public (deposit (amount uint))
(stx-transfer? amount tx-sender (as-contract tx-sender))
)
Unit tests
It is about time we start making our unit tests a bit more manageable by adding
reusable parts. We will define a bunch of standard values and create a setup
function to initialise the contract. The function can then be called at the
beginning of various tests to take care of calling start
and making an initial
STX token deposit by calling deposit
.
In our example, we named the contract multisig-vault
. Make sure to change the
contractName
variable below to your contract name if you gave your contract
a different name.
const contractName = "multisig-vault";
const defaultStxVaultAmount = 5000;
const defaultMembers = [
"deployer",
"wallet_1",
"wallet_2",
"wallet_3",
"wallet_4",
];
const defaultVotesRequired = defaultMembers.length - 1;
type InitContractOptions = {
chain: Chain;
accounts: Map<string, Account>;
members?: Array<string>;
votesRequired?: number;
stxVaultAmount?: number;
};
function initContract({
chain,
accounts,
members = defaultMembers,
votesRequired = defaultVotesRequired,
stxVaultAmount = defaultStxVaultAmount,
}: InitContractOptions) {
const deployer = accounts.get("deployer")!;
const contractPrincipal = `${deployer.address}.${contractName}`;
const memberAccounts = members.map((name) => accounts.get(name)!);
const nonMemberAccounts = Array.from(accounts.keys())
.filter((key) => !members.includes(key))
.map((name) => accounts.get(name)!);
const startBlock = chain.mineBlock([
Tx.contractCall(
contractName,
"start",
[
types.list(
memberAccounts.map((account) => types.principal(account.address))
),
types.uint(votesRequired),
],
deployer.address
),
Tx.contractCall(
contractName,
"deposit",
[types.uint(stxVaultAmount)],
deployer.address
),
]);
return {
deployer,
contractPrincipal,
memberAccounts,
nonMemberAccounts,
startBlock,
};
}
Testing start
Let us get the tests for start
out of the way first:
- The contract owner can initialise the vault.
- Nobody else can initialise the vault.
- The vault can only be initialised once.
Clarinet.test({
name: "Allows the contract owner to initialise the vault",
async fn(chain: Chain, accounts: Map<string, Account>) {
const deployer = accounts.get("deployer")!;
const memberB = accounts.get("wallet_1")!;
const votesRequired = 1;
const memberList = types.list([
types.principal(deployer.address),
types.principal(memberB.address),
]);
const block = chain.mineBlock([
Tx.contractCall(
contractName,
"start",
[memberList, types.uint(votesRequired)],
deployer.address
),
]);
block.receipts[0].result.expectOk().expectBool(true);
},
});
Clarinet.test({
name: "Does not allow anyone else to initialise the vault",
async fn(chain: Chain, accounts: Map<string, Account>) {
const deployer = accounts.get("deployer")!;
const memberB = accounts.get("wallet_1")!;
const votesRequired = 1;
const memberList = types.list([
types.principal(deployer.address),
types.principal(memberB.address),
]);
const block = chain.mineBlock([
Tx.contractCall(
contractName,
"start",
[memberList, types.uint(votesRequired)],
memberB.address
),
]);
block.receipts[0].result.expectErr().expectUint(100);
},
});
Clarinet.test({
name: "Cannot start the vault more than once",
async fn(chain: Chain, accounts: Map<string, Account>) {
const deployer = accounts.get("deployer")!;
const memberB = accounts.get("wallet_1")!;
const votesRequired = 1;
const memberList = types.list([
types.principal(deployer.address),
types.principal(memberB.address),
]);
const block = chain.mineBlock([
Tx.contractCall(
contractName,
"start",
[memberList, types.uint(votesRequired)],
deployer.address
),
Tx.contractCall(
contractName,
"start",
[memberList, types.uint(votesRequired)],
deployer.address
),
]);
block.receipts[0].result.expectOk().expectBool(true);
block.receipts[1].result.expectErr().expectUint(101);
},
});
Clarinet.test({
name: "Cannot require more votes than members",
async fn(chain: Chain, accounts: Map<string, Account>) {
const { startBlock } = initContract({
chain,
accounts,
votesRequired: defaultMembers.length + 1,
});
startBlock.receipts[0].result.expectErr().expectUint(102);
},
});
Testing vote
Only members should be allowed to successfully call vote
. It should also
return the right error response if a non-member calls the function.
Clarinet.test({
name: "Allows members to vote",
async fn(chain: Chain, accounts: Map<string, Account>) {
const { memberAccounts, deployer } = initContract({ chain, accounts });
const votes = memberAccounts.map((account) =>
Tx.contractCall(
contractName,
"vote",
[types.principal(deployer.address), types.bool(true)],
account.address
)
);
const block = chain.mineBlock(votes);
block.receipts.map((receipt) => receipt.result.expectOk().expectBool(true));
},
});
Clarinet.test({
name: "Does not allow non-members to vote",
async fn(chain: Chain, accounts: Map<string, Account>) {
const { nonMemberAccounts, deployer } = initContract({ chain, accounts });
const votes = nonMemberAccounts.map((account) =>
Tx.contractCall(
contractName,
"vote",
[types.principal(deployer.address), types.bool(true)],
account.address
)
);
const block = chain.mineBlock(votes);
block.receipts.map((receipt) => receipt.result.expectErr().expectUint(103));
},
});
Testing get-vote
get-vote
is a simple read-only function that returns the boolean vote status
for a member-recipient combination.
Clarinet.test({
name: "Can retrieve a member's vote for a principal",
async fn(chain: Chain, accounts: Map<string, Account>) {
const { memberAccounts, deployer } = initContract({ chain, accounts });
const [memberA] = memberAccounts;
const vote = types.bool(true);
chain.mineBlock([
Tx.contractCall(
contractName,
"vote",
[types.principal(deployer.address), vote],
memberA.address
),
]);
const receipt = chain.callReadOnlyFn(
contractName,
"get-vote",
[types.principal(memberA.address), types.principal(deployer.address)],
memberA.address
);
receipt.result.expectBool(true);
},
});
Testing withdraw
The withdraw
function returns an ok
response containing the total number of
votes for the tx-sender
if the threshold is met. Otherwise it returns an
(err u104)
(err-votes-required-not-met
).
Clarinet.test({
name: "Principal that meets the vote threshold can withdraw the vault balance",
async fn(chain: Chain, accounts: Map<string, Account>) {
const { contractPrincipal, memberAccounts } = initContract({
chain,
accounts,
});
const recipient = memberAccounts.shift()!;
const votes = memberAccounts.map((account) =>
Tx.contractCall(
contractName,
"vote",
[types.principal(recipient.address), types.bool(true)],
account.address
)
);
chain.mineBlock(votes);
const block = chain.mineBlock([
Tx.contractCall(contractName, "withdraw", [], recipient.address),
]);
block.receipts[0].result.expectOk().expectUint(votes.length);
block.receipts[0].events.expectSTXTransferEvent(
defaultStxVaultAmount,
contractPrincipal,
recipient.address
);
},
});
Clarinet.test({
name: "Principals that do not meet the vote threshold cannot withdraw the vault balance",
async fn(chain: Chain, accounts: Map<string, Account>) {
const { memberAccounts, nonMemberAccounts } = initContract({
chain,
accounts,
});
const recipient = memberAccounts.shift()!;
const [nonMemberA] = nonMemberAccounts;
const votes = memberAccounts
.slice(0, defaultVotesRequired - 1)
.map((account) =>
Tx.contractCall(
contractName,
"vote",
[types.principal(recipient.address), types.bool(true)],
account.address
)
);
chain.mineBlock(votes);
const block = chain.mineBlock([
Tx.contractCall(contractName, "withdraw", [], recipient.address),
Tx.contractCall(contractName, "withdraw", [], nonMemberA.address),
]);
block.receipts.map((receipt) => receipt.result.expectErr().expectUint(104));
},
});
Testing changing votes
Members have the ability to change their votes at any time. We will therefore add a final test where a vote change causes a recipient to no longer be eligible to claim the balance.
Clarinet.test({
name: "Members can change votes at-will, thus making an eligible recipient uneligible again",
async fn(chain: Chain, accounts: Map<string, Account>) {
const { memberAccounts } = initContract({ chain, accounts });
const recipient = memberAccounts.shift()!;
const votes = memberAccounts.map((account) =>
Tx.contractCall(
contractName,
"vote",
[types.principal(recipient.address), types.bool(true)],
account.address
)
);
chain.mineBlock(votes);
const receipt = chain.callReadOnlyFn(
contractName,
"tally-votes",
[],
recipient.address
);
receipt.result.expectUint(votes.length);
const block = chain.mineBlock([
Tx.contractCall(
contractName,
"vote",
[types.principal(recipient.address), types.bool(false)],
memberAccounts[0].address
),
Tx.contractCall(contractName, "withdraw", [], recipient.address),
]);
block.receipts[0].result.expectOk().expectBool(true);
block.receipts[1].result.expectErr().expectUint(104);
},
});
The full source code of the project can be found here: https://github.com/clarity-lang/book/tree/main/projects/multisig-vault.