Creating a 2-of-3 multisig with raw transactions on EOSIO

Want to help support this blog? Try out Oh Dear, the best all-in-one monitoring tool for your entire website, co-founded by me (the guy that wrote this blogpost). Start with a 10-day trial, no strings attached.

We offer uptime monitoring, SSL checks, broken links checking, performance & cronjob monitoring, branded status pages & so much more. Try us out today!

Profile image of Mattias Geniar

Mattias Geniar, May 05, 2020

Follow me on Twitter as @mattiasgeniar

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? ¯\(ツ)/¯



Want to subscribe to the cron.weekly newsletter?

I write a weekly-ish newsletter on Linux, open source & webdevelopment called cron.weekly.

It features the latest news, guides & tutorials and new open source projects. You can sign up via email below.

No spam. Just some good, practical Linux & open source content.