This article presumes at least an entry-level understanding of how blockchain technologies work. We will use a sandboxed Tezos network for development of our smart contract, but the same process applies to other networks such as Alphanet or Mainnet. Let’s get started!
🚨 Disclaimer
The following contract shall be used for educational purposes only, it has not been audited or reviewed, please use with caution.
If you want to fast forward a bit, you can find the final smart contract’s source here. 🏎
Multi-signature is a paradigm, used to divide responsibility over funds. In our case, we want two users, to have shared control over their ꜩ (tezzies — native currency of Tezos).
To develop our smart contract, we’ll start by setting up the tezos-environment-manager.
You can read a more detailed introduction to tezos-environment manager here.👈
Setup is pretty straightforward:
⏰ Please be patient while your docker images are built, in a future release, those images will be available at hub.docker.com
To achieve multi-sig capabilities, we first need to know who controls the account (contract). In our case, it should be two different accounts (a.k.a. wallets). Our sandboxed environment already provides a handful of accounts available, they’re aliased as bootstrap1 up to bootstrap5.
Account aliased as bootstrap1 and it’s keys
Account aliased as bootstrap2 and it’s keys
Our owners shall be bootstrap1 & bootstrap2, which in practical terms, means that those accounts (private keys) are usually held by two separate people — who can then engage in a multi-sig transaction together via the smart contract we’re developing.
⚠️ Our smart contract is written in ReasonML, but it’s transpiled to Liquidity, and from there it’s compiled to Michelson
We’ll expose four entry points in our contract: create_proposition
, sign_proposition
, delete_proposition
, execute_proposition
. We’ll walk through them as we progress in our tutorial.
Flow should go as the following:
Optionally, you can delete a proposition half-way through the process, in order to be able to create a new one instead.
Smart contracts can store data on the chain, via their storage, which is defined as a type, and initialized with default values before deployment.
This allows us to deploy countless multi-sig contracts, which are owned by different combinations of accounts.
Our storage will consist of the following attributes:
🔬 On a side note — each contract must specify version of the Liquidty language it uses, so let’s do that right at the top of our file
Let’s start coding by creating a file under src/contracts
and we can call it multisig.re
. All the contract related code below should end up in our contract file.
For the sake of simplicity, let’s initialise our storage right away as well. We’ll use bootstrap1
& bootstrap2
to set the owners
in our storage. And we’ll leave out the other storage attributes ‘blank’, so a variant None
and an empty Set
.
Our smart contract needs to limit access to certain entry points. We can do that by comparing the address of the person, who called our contract via a transaction, with address of owners that we’ve specified as initial storage.
Let’s define a method that we can re-use, we’ll call it can_call
, and it will accept a single parameter, which is the current storage value. Additionally, we’ll implement a helper method fail_with_wrong_ownership
that makes the current contract transaction fail — we’ll use it when can_call
evaluates to false
.
To propose a new transaction within our multi-sig contract, we’ll use a method called create_proposition
, which takes two arguments, one is of type proposition
and the second one is of type storage
.
Flow of this method is relatively straight forward, firstly check if the current transaction was initiated by one of the owners, if not — fail because of ownership issues. In case an actual multi-sig contract owner is calling this entry point, check if there is an existing proposition already, if there is one, fail because we don’t want to override it and loose it’s data. If there’s no existing propositions, save our new proposition as the current one.
It’s always nice to have a way out, in our case, we allow the predefined owners to delete a pending proposition, prior to creating a new one. There’s no parameter for our delete_proposition
entry point, hence we use unit
.
Again we check who’s calling the contract entry point, if our ACL passes, we delete the existing pending proposition, if not — we abort the operation.
In order to execute the proposed transaction, we must sign it first. We’ll have an entry point available to do just that, and it will be called by each owner independently to provide a proposition signature unique to the owner.
First we check the ACL as we did in delete_proposition
, if it passes, we’ll add a signature based on the Current.sender()
→ which is the address of the account which called our entry point in a transaction — and that’s one of the contract owners.
_Set.add_
only adds values to the set if they are unique, that means if you call the_sign_proposition_
entry point multiple times, with the same owner signing the contract call transaction, you won’t create a second signature, as the addresses in our Set are unique.
After all the owners have signed the proposition, we can proceed by executing the proposition.
We begin by verifying our ACL, if it passes, we check if the number of existing signatures, is matching the number of multi-sig contract owners. If we have sufficient (100%) signature coverage, we proceed by creating an Account.transfer
transaction, with destination & amount taken from our proposed transaction.
We end the entry point execution by returning a cleaned up storage thanks to delete_proposition
helper function defined earlier, that resets our storage back to the initial state with no existing proposition & signatures. And we specify a transaction/operation to be executed as a result of our call — which is the proposed transaction to the destination account with a given amount.
🎉 Congratulations, your smart contract is now ready to be deployed into the blockchain! 🎉
You can find the full contract’s source here.
We’ll now go back to our client shell, that we’ve started at the beginning by running make client
.
Firstly we have to transpile/translate our ReasonML code, to Liquidity code, we can do that using refmt
— a tool used to convert ReasonML to OCaml and vice versa, and since Liquidity is a subset of OCaml, we can use it for our contract as well.
Now we’ve got a file called
multisig.liq
, you can check it out with your editor, syntax should be OCaml, so don’t forget to configure your editor of choice if it doesn’t recognize.liq
straight away
We will now forge a deployment operation that we can sign. Before we do so, let’s examine what exactly will go down.
We’re using Liquidity’s CLI, with our local sandboxed node as $NODE_URL
Amount of 1000tz
will be consumed by our contract, when executing proposed transactions.
Specified fee of 1tz
, serves as an incentive for the bakers to include our operation in a new block.
Source is the address of an account that’s facilitating the deployment — in our case it’s bootstrap1
, who also happens to be an owner of the multi-sig contract and a baker in our sandboxed environment.
We’ve now got operation bytes to sign, so let’s do that by running the following command. We’ll sign as bootstrap1
.
Make sure to save the resulting signature, we’ll use it in the next step.
Next step in the deployment process is to inject an operation, using the bytes & signature generated earlier.
Please note that your generated signature may vary, make sure to copy the real signature to the command below.
At this point, our contract is not yet included in a new block, because we haven’t baked a new block ourselves yet. Let’s make sure we can’t see our contract yet on TzScan, by visiting http://localhost:8000/contracts in your browser.
No contracts available at this time
In order to finish the deployment process in our sandboxed environment, we must bake a new block so the deployment operation is included.
New block has been baked, including our smart contract.
🎉 You can now check TzScan again, and you’ll see our multi-sig contract deployed 🎉
Contract has been deployed successfully
Before we begin interacting with our smart contract, let’s check out how it looks like on TzScan.
We can see the current storage status of our contract, and right now there’s nothing else but our predefined set of owners — bootstrap1
& bootstrap2
.
Storage is initialised correctly
There are no transactions yet, and the balance is 1000tz
One of the possible interactions with our smart contract, is to call one of the entry points using an account, that’s not listed as an owner in our contract.
Let’s see how that ends up, we’ll use bootstrap3
account from our client, which is not used anywhere in our contract.
Retrieving the keys for bootstrap3
Then we copy our deployed smart contract’s address from here:
In my case, the contract’s address is KT1Fm94xt2ujgtpYSJKvc54thx8Aq5pk7RkK
.
We can now use bootstrap3
‘s keys and our smart contract address to perform a contract entry point call. We’ll be proposing a transaction to bootstrap4
→tz1b7tUupMgCNw2cCLpKTkSD1NZzB5TkP2sv
with amount of 0.01tz
.
⚠️ When calling
create_proposition
we also have to specify the parameter, in this case ournew_proposition
. Syntax used to specify the parameter value is Liquidity/Ocaml.
Failed contract call
This call will inevitably fail, because that’s how our ACL logic is set up — don’t allow anyone, except the predefined owners, to create transaction propositions.
If we sign our call using bootstrap1
‘s keys, we’re able to call the contract successfully and create a proposition.
Successful contract call
For our contract call to take effect, we must bake a new block in our sandboxed environment again: tezos-client bake for bootstrap1
.
Baking a new block to include our contract call
We can verify that our call/transaction was included in the chain, using TzScan:
Transaction has been included
Storage has been updated with the proposed transaction
Before we can execute our proposed transaction, we must sign it by both of the contract owners, let’s call sign_proposition
in two separate calls with different owners to sign and seal our proposition. We’ll use bootstrap1
and bootstrap2
to sign our proposition.
👉 If you’re feeling adventurous, you can try calling
sign_proposition
using an account, that’s not specified as an owner. It should fail thanks to our ACL restrictions.
Two independent sign_proposition calls successfully executed
Now we just have to bake a new block, to include our calls.
Going back to TzScan to verify our calls were included:
Two new transactions are visible, one for each sign_proposition call
Two signatures have been added to our contract’s storage
This is it, the time has finally come. We’re going to execute a transaction, that has been signed by both multi-sig contract’s owners. We’ll use bootstrap1
to seal the deal, and ask the contract to execute_proposition
.
Successful call to execute_proposition
Once again, bake a new block to include our operations.
Going back to TzScan, we will notice that the contract’s storage is back at it’s original state, with nothing but owners defined, and 0.01tz
has been transfered from out contract to bootstrap4
.
Contract storage has been reset
0.01tz has been transferred to tz1b7tUupMgCNw2cCLpKTkSD1NZzB5TkP2sv → bootstrap4
Bootstrap4 has indeed received our multi-sig transaction
🎉 That’s it! We’ve successfully deployed a multi-sig smart contract, and we’ve been able to use it to propose a transaction, sign it and then execute it, resulting into balance updates in our contract and the target account.🎉
Do you have any questions or comments? Trouble running the code? Have you spotted a mistake?
I’d love to hear your thoughts, you can reach out to me at t.me/maht0rz or [email protected]
You can donate at:ETH: 0x56ba1a8681DB66DD7F3158A5e2623577D2fE7ec2.
BTC: bc1qcc7j72wl7l8muepr3rewjfn3hdzq7h9j4rpr50
XTZ: tz1NjgqAjNLK6aXrpj78fdwTjc68Y9CLCrvf
Edit: thanks to Arthur Breitman for pointing out possible vulnerabilities in the contract, you can see the revision history here.
Glossary: https://zeronet.tzscan.io/glossary
ReasonML docs: https://reasonml.github.io/docs/en/what-and-why
TQGroup’s learn: https://learn.tqgroup.io/
Liquidity reference: http://www.liquidity-lang.org/doc/reference/liquidity.html