Calling The Generic Multisig In Pure Shell

Because search engines do not report enough PACK-ed and signed Michelson lambdas …

This is a quite low-level step-by-step example of calling a contract with arguments from a generic-multisig contract — hence building a Michelson lambda, serializing it, and signing it. All of it in pure (POSIX) shell with tezos-client.

There are higher-level, and likely more usable versions, like github.com/TessellatedGeometry/multisig-command-compiler using SmartPy, or the merge-request tezos/tezos!1857 adding commands to tezos-client.

The full shell script is in the following gist: gist.github.com/smondet/0ea3f22375a309892fa5855cb7d7c1d2, it includes the Markdown prose as comments too.

Let's setup a silent sandbox, and configure tezos-client for it:

docker run --rm --name my-sandbox --detach -p 20000:20000 \
    tqtezos/flextesa:20201214 delphibox start
tezos-client --endpoint http://localhost:20000 config update
tezos-client import secret \
    key alice unencrypted:edsk3QoqBuvdamxouPhin7swCvkQNgq4jP5KZPbwWNnwdZpSpJiEbq --force
tezos-client import secret key \
    bob unencrypted:edsk3RFfvaFaxbHx8BMtEW1rKQcPtDML3LXjNqMNLCzC3wLC1bWbAt --force

(don't forget to docker kill my-sandbox when you're done).

This is the contract that we want to call from the multisig:

parameter string;
storage (option (pair address string));
code { CAR; SENDER; PAIR; SOME; NIL operation; PAIR };

it just stores the sender and the argument it is called with, let's call it “target”:

tezos-client originate contract target transferring 0 from alice \
             running "$target_code" --init None --burn-cap 1 --force


The multisig contract we want to use is there:


Check-out also generic_multisig.v, its formalization and correctness proofs in Coq.

Let's download it:

wget "$multisig_code"

And originate it:

tezos-client originate contract msig transferring 0 from alice \
    running generic.tz \
    --init '(Pair 0 (Pair 1 {"edpkurPsQ8eUApnLUJ9ZPDvu98E8VNj4KtJa1aZr16Cr5ow5VHKnz4"}))' \
    --burn-cap 1 \


Now the fun part, let's build, pack, and sign a Michelson expression that instructs the multisig to call the “target” contract.

We need Base58 (“KT1”) addresses, because we cannot just use tezos-client aliases in Michelson expressions:

target_kt1=$(tezos-client show known contract target)
msig_kt1=$(tezos-client show known contract msig)

The first time we “know” that the replay-protection counter is 0:


But in general we want to get it from the contract storage, so, very elegantly, we sed it out:

    tezos-client get contract storage for msig \
        | sed 's/Pair \([0-9]*\).*$/\1/'

OK, here is the meat^Wprotein — a Michelson expression of type (lambda unit (list operation)):

cat > /tmp/lambda.tz <<EOF
  DROP;   # This is the Unit value the lambda is called on.
  # We build a list with CONS, so we start with the empty one:
  NIL operation;
  # One call to TRANSFER_TOKENS to build an operation:
  {  # ← this pair of braces is just for esthetics.
     PUSH address "$target_kt1";    # The target address,
     CONTRACT string;               # transformed into a contract of target's type.
     ASSERT_SOME;                   # CONTRACT returns an option, we want the value.
     PUSH mutez 0;                  # The transfer amount is 0 mutez.
     PUSH string "hello-$counter";  # The argument passed to the target contract.
  CONS; # Finally, we build the list of one operation, leave it on the stack.

We remove the comments, and “flatten” all of the above to avoid dealing with tezos-client's extreme pedantism about the indentation of Michelson:

lambda="$(sed 's/#.*//' /tmp/lambda.tz | tr -d '\n')" 

The argument passed to the multisig's main entrypoint is the counter + the lambda as an action (the action is of type (or <action> <change-keys>)):

payload="(Pair $counter (Left $lambda))"

To avoid replay attacks on the test-chain during the 3rd voting period, the multisig also requires the chain-id to be signed:

chain_id=$(tezos-client rpc get /chains/main/chain_id | tr -d '"')

The thing to serialize and sign is hence, the chain-id, the contract address, and the payload:

topack="(Pair (Pair \"$chain_id\" \"$msig_kt1\") $payload)"

You can check that echo "$topack" shows something like:

 (Pair (Pair "NetXMFJWfpUBox7" "KT1Mfv7qCR9zfQZJcG8Bx7n6XygiWN3fHVNG") (Pair 1 (Left {DROP;   NIL operation;{PUSH address "KT1KT6smv2ivadEMPiYS2fDWVKdApsVpffts";  CONTRACT string;             ASSERT_SOME;           PUSH mutez 0;         PUSH string "hello-1";  TRANSFER_TOKENS;};CONS; })))

(here NetXMFJWfpUBox7 is the vanity chain-id of the sandbox).

Now we serialize this. We need to give the type of the expression but we can cheat a bit: since we only use the left side of the (or _ _) we can put unit on the right-side, feel free to copy the real type from generic.tz:

tezos-client hash data "$topack" of type \
 '(pair (pair chain_id address) (pair nat (or (lambda unit (list operation)) unit)))' \
  | tee /dev/stderr \
  | awk -F' ' ' /Raw packed/ { print $4 }' \
  > /tmp/bytes.hex

The command tezos-client hash data throws a lot of output, we only care about the line that looks like:

 Raw packed data: 0x050707070……

We grab the hexadecimal blob into a file and feed it to the signer:

tezos-client sign bytes $(cat /tmp/bytes.hex) for bob \
  | tee /dev/stderr \
  | cut -d' ' -f 2 > /tmp/sig.b58

The output is the Base58check-encoded signature, exactly what we need to build a Michelson literal to call the contract:

tezos-client transfer 0 from alice to msig \
    --entrypoint main \
    --arg "(Pair $payload { Some \"$(cat /tmp/sig.b58 )\" } )" \
    --burn-cap 2

We see that alice passes the payload together with a list of (option signature) values, in our case a singleton corresponding to bob's signature. If everything goes as planned, we can see in the target-contract's storage that the address of the multisig has been recorded:

tezos-client get contract storage for target

(should return the same as echo "Some (Pair \"$msig_kt1\" \"hello-$counter\")").

After 8 years of blograstination, this is post #11 of my attempt at using the #100DaysToOffload completely unrealistic “challenge” to remind me to write stuff once in a while … Let's see where this goes.