diff --git a/docs/smart-contracts/multisig.md b/docs/smart-contracts/multisig.md index 5964d3877..e54a555b4 100644 --- a/docs/smart-contracts/multisig.md +++ b/docs/smart-contracts/multisig.md @@ -2,7 +2,10 @@ title: Multi-signature contracts authors: Tim McMackin last_update: - date: 2 February 2024 + date: 23 January 2025 +dependencies: + octez: 21.2 + smartpy: 0.20.0 --- Multi-signature (or multisig) contracts require multiple accounts to authorize operations before running them. @@ -18,10 +21,130 @@ The contract originator does not even need to be one of the accounts that can au ## Using proposals One common way to create a multisig contract is to allow authorized users to submit a proposal that other authorized users can vote on. -For example [this multisig contract](https://github.com/onedebos/multisig/blob/main/multisig.py) stores tez and allows users to propose and vote on the account that should receive the tez. -It stores a big-map of proposals, each with an amount to pay, the account to pay, and information about who has voted for the proposal: +For example, this multisig contract stores tez and allows users to propose and vote on the account that should receive the tez: + +```smartpy +import smartpy as sp + + +@sp.module +def main(): + proposal_type: type = sp.big_map[ + sp.int, + sp.record( + paymentAmt=sp.mutez, + receiver=sp.address, + voters=sp.set[sp.address], + votingComplete=sp.bool, + ), + ] + + class MultiSigContract(sp.Contract): + def __init__(self, members, requiredVotes): + # Keep track of all the proposals submitted to the multisig + self.data.proposals = sp.cast(sp.big_map(), proposal_type) + self.data.activeProposalId = 0 + self.data.members = sp.cast(members, sp.set[sp.address]) + self.data.requiredVotes = sp.cast(requiredVotes, sp.nat) + + @sp.entrypoint + def deposit(self): + assert self.data.members.contains(sp.sender), "Not a Member of MultiSig" + + @sp.entrypoint + def submit_proposal(self, params): + """ + Submit a new proposal/lambda for members + of the MultiSig to vote for. + """ + assert self.data.members.contains(sp.sender), "Not a Member of MultiSig" + assert ( + params.paymentAmt <= sp.balance + ), "The MultiSig does not have enough funds for this proposal" + self.data.activeProposalId += ( + 1 # submitting a new proposal inactivates the last one + ) + self.data.proposals[self.data.activeProposalId] = sp.record( + paymentAmt=params.paymentAmt, + receiver=params.receiver, + voters={sp.sender}, + votingComplete=False, + ) + + @sp.entrypoint + def vote_on_proposal(self): + assert self.data.members.contains(sp.sender), "Not a Member of MultiSig" + # check if the user has previously voted on the proposal + assert not self.data.proposals[self.data.activeProposalId].voters.contains( + sp.sender + ), "Member has voted on this proposal" + self.data.proposals[self.data.activeProposalId].voters.add(sp.sender) + if ( + sp.len(self.data.proposals[self.data.activeProposalId].voters) + == self.data.requiredVotes + ): + sp.send( + self.data.proposals[self.data.activeProposalId].receiver, + self.data.proposals[self.data.activeProposalId].paymentAmt, + ) + self.data.proposals[self.data.activeProposalId].votingComplete = True + + +@sp.add_test() +def test(): + scenario = sp.test_scenario("Multisig test", main) + alice = sp.test_account("alice") + bob = sp.test_account("bob") + charlie = sp.test_account("charlie") + dani = sp.test_account("dani") + earl = sp.test_account("earl") + scenario.h3("MultiSig Proposal Contract") + members = sp.set() + members.add(alice.address) + members.add(bob.address) + members.add(charlie.address) + members.add(earl.address) + + contract = main.MultiSigContract(members, 3) + scenario += contract + + scenario.h3("Members can add funds to the contract") + contract.deposit(_sender=alice.address, _amount=sp.tez(50)) + + scenario.h3( + "Members can submit a proposal for funds to be sent to an address - Proposal 1." + ) + contract.submit_proposal( + sp.record(paymentAmt=sp.tez(30), receiver=dani.address), + _sender=alice.address + ) + + scenario.h3("Non-members cannot vote on proposals") + contract.vote_on_proposal(_valid=False, _sender=dani.address) + + scenario.h3("Member 2 can vote on proposal") + contract.vote_on_proposal(_sender=bob.address) + + scenario.h3("Member 3 can vote on proposal") + contract.vote_on_proposal(_sender=charlie.address) + + scenario.h3("Contract balance should drop to 20tez after transfer") + scenario.verify(contract.balance == sp.tez(20)) + + scenario.h3("A New proposal can be created") + contract.submit_proposal( + sp.record(paymentAmt=sp.tez(20), receiver=dani.address), + _sender=alice.address + ) + + scenario.h3("New proposal can be voted on") + contract.vote_on_proposal(_sender=charlie.address) + +``` + +This contract stores a big-map of proposals, each with an amount to pay, the account to pay, and information about who has voted for the proposal: -```python +```smartpy proposal_type: type = sp.big_map[ sp.int, sp.record( @@ -35,32 +158,33 @@ proposal_type: type = sp.big_map[ The `submit_proposal` entrypoint allows authorized users to submit a payment amount and an account address, which adds a proposal to the storage: -```python +```smartpy self.data.proposals[self.data.activeProposalId] = sp.record( paymentAmt=params.paymentAmt, receiver=params.receiver, - voters=sp.set(sp.sender), + voters={sp.sender}, votingComplete=False, ) ``` Authorized accounts call the `vote_on_proposal` entrypoint to vote for the currently active proposal: -```python -assert self.data.members.contains(sp.sender), "Not a Member of MultiSig" -# Check if the user has previously voted on the proposal -assert not self.data.proposals[self.data.activeProposalId].voters.contains( - sp.sender -), "Member has voted on this proposal" -# Add the user's vote for the proposal -self.data.proposals[self.data.activeProposalId].voters.add(sp.sender) +```smartpy +@sp.entrypoint +def vote_on_proposal(self): + assert self.data.members.contains(sp.sender), "Not a Member of MultiSig" + # check if the user has previously voted on the proposal + assert not self.data.proposals[self.data.activeProposalId].voters.contains( + sp.sender + ), "Member has voted on this proposal" + self.data.proposals[self.data.activeProposalId].voters.add(sp.sender) ``` Accounts that don't want to vote for the proposal don't need to do anything. When the necessary number of votes have been reached, the `vote_on_proposal` entrypoint automatically sends the tez to the account in the proposal: -```python +```smartpy if ( sp.len(self.data.proposals[self.data.activeProposalId].voters) == self.data.requiredVotes