These instructions can be followed to create a 2-out-of-3 multisignature address on the EOS blockchain (or any derivative thereof). This means you will need at least 2 out of the 3 keyholders to sign of on a transaction before any funds can be spent.
Note: these are the raw, pure API calls that happen behind the scenes. I sincerely hope you’re using a GUI to create and manage your multisig accounts. This was an excercise in understanding accounts, permissions, proposals and multi-signature on-chain voting.
Change address ownership to a 2/3 multisig
You start by changing the ownership of an existing address, so that it’s equally split between 3 holders. Then you define that there’s a threshold of 2 account owners that are needed before signing transactions.
This uses the cleos
binary to send transactions across the chain. Cleos stands for ‘Command Line interface for EOS’, its docs can be found here.
The example below will modify the address y4w3dtilu2b1
to a 2/3 multisig, passing ownership to:
3en33fu1uocu
(owner 1)fi51upjh2kdr
(owner 2)qpf4oaqaiky4
(owner 3)
In order to change ownership, you need the private keys of the account y4w3dtilu2b1
you’re modifying. You will need to send some funds to those 3 new addresses listed above, or they won’t be accepted as inputs further down (aka: they can be brand new addresses, but unless you send some funds to them, the chain doesn’t know about them).
$ cleos \
-u http://127.0.0.1:8888 \
--wallet-url http://127.0.0.1:3000 \
push action eosio updateauth \
'{
"account": "y4w3dtilu2b1",
"permission": "owner",
"parent": "",
"auth": {
"threshold": 2,
"keys": [],
"waits": [],
"accounts": [
{
"permission": {
"actor": "3en33fu1uocu",
"permission": "active"
},
"weight": 1
},
{
"permission": {
"actor": "fi51upjh2kdr",
"permission": "active"
},
"weight": 1
},
{
"permission": {
"actor": "qpf4oaqaiky4",
"permission": "active"
},
"weight": 1
}
]
},
"max_fee": 1800000000
}' \
--permission y4w3dtilu2b1@owner
You might need to tweak the max_fee
setting to account for changes in fee schedules. You’ll need at least some funds on the y4w3dtilu2b1
account that you’re modifying to pay for this transaction.
Once execute, you should see something similar like this:
warning: transaction executed locally, but may not be confirmed by the network yet
You can check any block explorer to see if the transaction has been confirmed.
Change the active permissions on the new 2/3 multisig
Now that address y4w3dtilu2b1
was modified, we need to grant the new accounts active permissions as well, to allow them to transfer any funds.
This transaction will update those active permissions and they’ll be signed by the active permission of the main y4w3dtilu2b1
account.
$ cleos \
-u http://127.0.0.1:8888 \
--wallet-url http://127.0.0.1:3000 \
push action eosio updateauth \
'{
"account": "y4w3dtilu2b1",
"permission": "active",
"parent": "owner",
"auth": {
"threshold": 2,
"keys": [],
"waits": [],
"accounts": [
{
"permission": {
"actor": "3en33fu1uocu",
"permission": "active"
},
"weight": 1
},
{
"permission": {
"actor": "fi51upjh2kdr",
"permission": "active"
},
"weight": 1
},
{
"permission": {
"actor": "qpf4oaqaiky4",
"permission": "active"
},
"weight": 1
}
]
},
"max_fee": 1800000000
}' \
--permission 'y4w3dtilu2b1@active'
If all went according to plan, you should see something similar as output:
warning: transaction executed locally, but may not be confirmed by the network yet
Again, check your block explorer to see if the transaction got confirmed.
Good news: the address y4w3dtilu2b1
has now been converted to a 2/3 multisig!
Spending funds from a multisig address
Now, how do you actually send any transactions from a multisig? Quite cumbersome via CLI, but here are the steps.
(These steps are needed to prevent replay attacks on transactions. GUI applications will abstract the complexities away, but behind the scenes a lot is going on.)
Create a spending transaction
First, you create an on-chain proposal to send the funds. This means you’ll be making a transaction, that will be published on-chain, where others can vote on to be executed.
In this proposal, you decide which of the 2 multi-sig addresses will be allowed to vote.
The proposal is initiated by “Owner 1”, who has account 3en33fu1uocu
.
In the proposal, it is requested that account 3en33fu1uocu
(Owner 1, the person who created the proposal) and fi51upjh2kdr
(Owner 2) approve of the transaction.
Since Owner 3 isn’t mentioned, he can’t vote on this proposal.
$ cleos \
-u http://127.0.0.1:8888 \
--wallet-url http://127.0.0.1:3000 \
push action eosio.msig propose \
'{
"proposer": "3en33fu1uocu",
"proposal_name": "prop1",
"requested": [
{
"actor": "3en33fu1uocu",
"permission": "active"
},
{
"actor": "fi51upjh2kdr",
"permission": "active"
}
],
"trx": {
"chain_id": "b20901380af44ef59c5918439a1f9a41d83669020319a80574b804a5f95cbd7e",
"expiration": "2020-05-06T21:00:00",
"ref_block_num": 9649065,
"ref_block_prefix": 4029905493,
"max_net_usage_words": 0,
"max_cpu_usage_ms": 0,
"delay_sec": 0,
"context_free_actions": [],
"actions": [
{
"account": "eos.token",
"name": "transfer",
"authorization": [
{
"actor": "y4w3dtilu2b1",
"permission": "active"
}
],
"data": "3546494f366a79746b3956625438444166713267796957686632716f47734451324a384b4341594a505137667170537a596a76544e7900ca9a3b000000000094357700000000108ed0d1e53438f100"
}
],
"transaction_extensions": []
},
"max_fee": 1500000000
}' \
--permission '3en33fu1uocu@active'
This proposal is signed (the --permission
flag) by Owner 1, not the multisig address.
If all went accordinging to plan, you’ll again see this output:
warning: transaction executed locally, but may not be confirmed by the network yet
Some of these parameters require a bit of extra commands, to get the right info. Let’s dive into that.
Proposer and Proposal name
The proposer
is one of the 3 multisig accounts that can spend the funds. After all, one of you is making the proposal to spend the funds.
The proposal_name
can be anything, but must be unique for that proposer
. I suggest a sensible naming scheme with incrementing numbers.
The requested
array contains a list of accounts that is being requested to vote on this proposal. They’re the only ones that can vote for this proposal to be executed. In other words: if Owner 1 made the proposal, Owner 2 and Owner 3 are in the requested
field.
Get the chain ID
You’ll need to get the chainid of the currently running chain. The chainid is a hash of all the parameters in the genesis.json
file. In other words: what were the settings that bootstrapped this network? That’s the chain ID.
$ curl http://127.0.0.1:8888/v1/chain/get_info | jq .chain_id
"b20901380af44ef59c5918439a1f9a41d83669020319a80574b804a5f95cbd7e"
This queries the local /v1/chain/get_info
endpoint and extracts the chain_id
from the JSON.
Expiration
When would this proposal expire? A proposal can’t be valid forever, this is date that should be in the future, in a reasonable time for the other requested voters to act.
I’d recommend putting this +12 hours in the future.
ref_block_num & ref_block_prefix
These are unnecessary confusing, I believe. Here’s how to retrieve both of these required parameters.
First, the ref_block_num
refers to the last irreversible block number on the chain.
$ curl http://127.0.0.1:8888/v1/chain/get_info | jq .last_irreversible_block_num
9649065
The ref_block_prefix
is the block prefix of that last irreversible block number. You can query for details about that block number like this:
$ curl --data '{"block_num_or_id": "9649065"}' http://127.0.0.1:8888/v1/chain/get_block | jq .ref_block_prefix
4029905493
Now you have the 2 required pieces: the ref_block_num
(in our example: 9649065) and ref_block_prefix
(in our example: 4029905493).
Create the transaction data
The on-chain proposal contains a string in the data
field. I find the easiest way to generate this string, is to create a manual transaction using cleos
and output that as JSON, instead of broadcasting it on-chain.
You can show the JSON of a transaction by using the -s -j -d
parameters in the push action
command.
$ cleos \
-u http://127.0.0.1:8888 \
--wallet-url http://127.0.0.1:3000 \
push action -s -j -d eos.token transfer \
'{
"payee_public_key": "EOS6jytk9VbT8DAfq2gyiWhf2qoGsDQ2J8KCAYJPQ7fqpSzYjvTNy",
"amount": "1000000000",
"max_fee": 2000000000,
"tpid": "",
"actor": "y4w3dtilu2b1"
}' \
--permission y4w3dtilu2b1@owner
This transaction is signed by the y4w3dtilu2b1
account, the multisig address. After all, that’s the account with the funds on it you want to spend.
Its output might look like this:
{
"expiration": "2020-05-05T20:06:19",
"ref_block_num": 16761,
"ref_block_prefix": 73894353,
"max_net_usage_words": 0,
"max_cpu_usage_ms": 0,
"delay_sec": 0,
"context_free_actions": [],
"actions": [{
"account": "eos.token",
"name": "transfer",
"authorization": [{
"actor": "y4w3dtilu2b1",
"permission": "owner"
}
],
"data": "3546494f366a79746b3956625438444166713267796957686632716f47734451324a384b4341594a505137667170537a596a76544e7900ca9a3b000000000094357700000000108ed0d1e53438f100"
}
],
"transaction_extensions": [],
"signatures": [],
"context_free_data": []
}
We need the long string in the data
field for our proposal.
Confirm the spending
Now that there’s a proposal out there, others can vote on it to be executed. Even though Owner 1 made the proposal, he still needs to vote for it as well.
Review the proposal
First, let’s make sure the proposal is accurate. Retrieve the multisig proposal for review, for this you’ll need the proposer
name and the proposal_name
, as we used them above.
$ cleos \
-u http://localhost:8888 \
--wallet-url http://127.0.0.1:3000 \
multisig review 3en33fu1uocu prop1
Owner 1 approves the transaction
If that JSON output looks legit, approve it:
$ cleos \
-u http://localhost:8888 \
--wallet-url http://127.0.0.1:3000 \
multisig approve 3en33fu1uocu prop1 \
'{
"actor": "3en33fu1uocu",
"permission": "active"
}' \
400000000 \
--permission 3en33fu1uocu
The above approves the proposal named prop1
by 3en33fu1uocu
, as the account 3en33fu1uocu
. Remember, the account that made the proposal still has to vote for it, which is why the signer (the --permission
) is the same as the actor.
Owner 2 approves the transaction
Owner 2, who was also allowed to vote, does the same. But he’ll use his actor name instead.
$ cleos \
-u http://localhost:8888 \
--wallet-url http://127.0.0.1:3000 \
multisig approve 3en33fu1uocu prop1 \
'{
"actor": "fi51upjh2kdr",
"permission": "active"
}' \
400000000 \
--permission fi51upjh2kdr
Owner 2, who has account fi51upjh2kdr
, has now also approved this proposal.
See the proposal approvers
For bonus points, you can ask who already approved this proposal.
$ cleos \
-u http://localhost:8888 \
--wallet-url http://127.0.0.1:3000 \
get table eosio.msig 3en33fu1uocu approvals2 -L prop1 -l 1
The output will be similar to this:
{
"rows": [{
"version": 1,
"proposal_name": "prop2",
"requested_approvals": [],
"provided_approvals": [{
"level": {
"actor": "3en33fu1uocu",
"permission": "active"
},
"time": "2020-05-05T20:34:42.000"
},{
"level": {
"actor": "fi51upjh2kdr",
"permission": "active"
},
"time": "2020-05-05T20:39:52.000"
}
]
}
],
"more": false
}
The provided_approvals
array will show you which accounts have approved this.
Execute the proposal
Now that there are sufficient approvers, you can execute the proposal.
The syntax is:
$ cleos multisig exec [OPTIONS] proposer proposal_name max_fee [executer]
In our example, this becomes:
$ cleos \
-u http://localhost:8888 \
--wallet-url http://127.0.0.1:3000 \
multisig exec 3en33fu1uocu prop2 400000000 3en33fu1uocu
This execution will be signed of by 3en33fu1uocu
(the very last parameter), the person who created the proposal in the first place - Owner 1. Since there were sufficient approvals, this could also have been executed by Owner 2 instead.
And you’re done
Easy peasy, right? ¯\(ツ)/¯