Formally Proven Smart Contract Tutorial

By and for LIGO.

Introduction

This tutorial will show how to write a very simple auction smart contract in Coqueligot, then write a formal specification for some of the behaviour of that contract.

Finally, this tutorial will explain the proof techniques used to ensure that the contract meets the specification for any possible sequence of on-chain calls to that contract.

The contract

As a first step, we will write a very simple auction smart contract. The principe of this contract is that :

  1. At first, there is an available item for sale.
  2. People can place a bid on the item, each one bidding higher than the previous one.
  3. At the end, the highest bidder can claim their item.

This means that the smart contract can be in 3 possible states (the state will be in the smart contract storage)

Available
The item is available but there have been no bids so far;
CurrentBid price
The current bid value is price;
Sold price
The object has been sold for price, no more bids can be placed.

Therefore the type of the state will be :

(* Declare enum for the storage (three states) *)
Inductive storage :=
| Available  : storage
| CurrentBid : nat -> storage
| Sold       : nat -> storage.

To run the code above, click to place the cursor after period marking the end of a Coq sentence, and press Alt+Right.

In order to see the proof state, click the icon in the top right.

Users of the contract can either place a bid or claim the object, which leads to the transaction type being :

(* Declare enum for the parameter (two possible actions) *)
Inductive param :=
| Bid   : nat -> param
| Claim : param.
When the smart contract receives a transaction, it can handle 4 cases:
  • Someone bids, and it's the first bid
  • In this case, the contracts saves the bid, whatever the price provided.
  • Someone bids, and it's NOT the first bid
  • In that case, the contracts checks that the bid is higher than the current price. if not, then the transaction is invalid.
  • Someone claims their item
  • The contract checks that the claimer is the highest bidder and hasn't claimed already. Otherwise, the transaction is invalid.
  • In all other cases, the transaction is invalid
This behaviour is directly translated by the contract's main function :
(* Behaviour of the contract. *)
Definition main_option (ps : param * storage) : option ((list operation) * storage) :=
  match ps with
  (* Start a bid *)
  | (Bid newAmount, Available)                 => Some ([], CurrentBid newAmount)
  (* New bid (must be higher than the existing *)
  | (Bid newAmount, CurrentBid existingAmount) =>
    if existingAmount <? newAmount then
      Some ([], CurrentBid newAmount)
    else
      None
  (* Finish the auction *)
  | (Claim,         CurrentBid existingAmount) => Some ([transaction tt (mutez 0) destination_account], Sold existingAmount)
  (* All other cases are errors. *)
  | (_,             _storage)                  => None
  end.

Note : Several checks are not done here, like checking if the user claiming the sale has the right permissions to do that, for example.
This contract is indeed just an example, not production-ready.
We wanted the contract to remain simple in order to focus on illustrating the potential of formally proving properties with Coqueligot.

Finally, we declare what should be the initial storage of the contract when deployed.

Definition initial_storage := Available.

(* ********************************************* *)
(*                End of Contract                *)
(* ********************************************* *)

Specification

Random tests vs. formal proofs

When writing a program, it is good practice to write tests. Instead of jotting down a few test cases for specific inputs, we will write a formal specification describing some aspects of the expected behaviour. That specification will indicate if a behaviour is correct, for any possible input. This is a common practice when using a test framework which randomizes the inputs: the test specification compares the input and output of the main function, and asserts that the output is correct for the given input. Instead of checking that this property holds for a random subset of possible inputs, Coqueligot allows us to check that the specification holds for all possible inputs.

The properties to prove (i.e. our specification)

The property we will try to prove is that the object is sold for an amount equal to the maximum bid, or phrased differently, that all bids are less than or equal to the sold-for amount. These two phrasings are not strictly equivalent, and we chose to include both statements in the specification. Having redundancy in the specification, with statements which look intuitively correct, is an easy way to have a more convincingly correct specification.

Our specification will thus consist in these two properties to prove :

All bids are less than or equal to the sold-for amount.

(* If after some operations, the object is sold for an amount,
   it will be sold for an amount >= any bid.

   In other words, the object is never sold for less than
   the amount of a (valid) bid. *)
Definition spec_bids_are_less_than_sold_for_amount :=
  forall (l : list param) (amount : nat),
    forall (Hcontains : contains_bid l amount),
      match run_multiple_calls l with
      | Sold sold_for_amount => 
          amount <= sold_for_amount
      | _ => True
      end.

The storage is equal to the max of the bids seen so far

Definition spec_max :=
  forall (l : list param),
    Pmax (run_multiple_calls l) (list_max (get_bids (valid_ops' l))).

Redundancy in specification

Care should be taken, however, to ensure that the specification is strict enough, and does not allow for incorrect behaviour.
For example, "all bids are less than or equal to the sold-for amount" actually allows the object to be sold for an amount greater than any of the bids.
The follwoing sequence of states :

Available
CurrentBid 1
CurrentBid 3
CurrentBid 8
instinctively looks incorrect (why is the item sold for 23 when the last bid was 8?), but would satisfy that part of the specification.

The "sold for an amount equal to the maximum bid" part of the specification, on the other hand, sets precise bounds on the sold-for price:
it should be exactly equal to the maximum of all bids, no more, no less.
In that sense, that property gives us a stronger guarantee on the correctness of the contract.

Induction on blockchain transactions

The induction principle

So far, we wrote the contract, and wrote the specification (the two properties to prove). Now we would like to prove them.

Notice that those properties reason on the contract state over a list of transactions, whereas our smart contract is a function (main) operating on one transaction.
It decides what should be state n + 1 given a state n and a transaction.

More generally, writing the contract implies a per-transaction reasoning, while writing the specification often requires an over-all-transactions reasoning.

So, the specification contains properties over transaction lists, but we would prefer to prove properties over a single transaction with the code of main, so we need a way to "reduce" proofs over a transaction list to proofs over a single transaction; we need an induction principle over blockchain transactions, and this is essentially what Coqueligot's fold_invariant_aux theorem provides.

Provide a proof that the main function of the smart contract preserves an invariant P over the state, and that it holds for the initial state, and this induction principle will provide you a proof that P holds for the state after any sequence of transactions.

Parallel with a list fold

In its core idea, this is just a fold over a list. The list_fold function takes

  • l : a list of elements
  • f: a function operating over the elements
  • accu_init : an initial value for the accumulator
and folds over the list, providing the final value for the accumulator.

Here
  • l is our list of transactions
  • f is the main function of the smart contract
  • the accumulator is the state (+ some auxiliaty data)
  • accu_init is the initial value of the contract storage
  • the final acccumulator value is the state of the contract storage after executing the list of transaction.

Note : The fold_invariant_aux doesn't just fold over a list, it also :
  1. Filters invalid transactions (provided a predicate telling when a transaction is valid or not)
  2. Allows for carrying auxliary cross-transaction data to reason about in our proofs.
    It is used when we need some information to be saved over transaction calls (so we can reason about them in our proofs) but that are not part of the contract so we don't want them in the actual on-chain storage.

Signature of the lemma

So, here it is, the signature of the induction principle over blockchain transactions :
Lemma fold_invariant_aux :
  forall {state         : Type}                           (* The type of the contract storage, for us its 'storage_state' *)
    {aux           : Type}                           (* The type of the auxiliaty cross-transaction data *)
    (initial_value : state)                          (* Value of the storage before the first transaction *)
    (initial_aux   : aux)                            (* Same as above for auxiliary data *)
    {arg           : Type}                           (* Type of a transaction, for us its 'param' *)
    (f             : state -> arg -> state)            (* The code of the contract's main function, 'main_option' *)
    (g             : aux -> arg -> aux)                (* Function describing what to do with aux data during a transaction *)
    (l             : list arg)                       (* The list of blockchain transactions *)
    (valid         : state -> arg -> bool)             (* The predicate saying when a transaction is valid or not *)
    (filter_valid
        := filter_args valid f initial_value)
    (P             : state -> aux -> Prop)                (* The over-all-transaction property we want to prove *)

    (Invalid_is_noop :                               (* A proof that invalid transactions lead to no change of state *)
      forall state arg,
        valid state arg = false ->
        f state arg = state)

    (Hind : forall xarg xstate xaux,                      (* A proof that P is preserved over one call to the contract *)
              P xstate xaux ->
              (*In xarg l ->*)
              valid xstate xarg = true ->
              P (f xstate xarg) (g xaux xarg))

    (H0 : P initial_value initial_aux),              (* A proof that P is true in the first place, at the start *)

    P (fold_left f               l  initial_value)   (* The return type, aka the proof that P hold all the time *)
      (fold_left g (filter_valid l) initial_aux).
This proof is part of Coqueligot, and can be re-used to prove other contracts than our very simple auction contract. It is included here for completeness, but does not need to be carefully examined. When faced with properties over lists of transactions, apply this lemma to reduce it into a simpler proof over 1 transaction. The problem will be reduced to 3 simpler properties to prove :
  1. Invaid_is_noop (the main function is a no-op when a parameter is invalid)
  2. Induction property : Hind : P(x, extra_context) -> P(f(x), g(extra_context)
  3. Property on the initial state : H0: P(initial_x, initial_extra_context)

This lemma allows us to strengthen an invariant of the main function iterated over lists of valid parameters, to an invariant on the main function iterated over lists of any parameters.

This lemma also allows us to reason about the history of past parameters (e.g. the running maximum of the bids seen so far), by using an auxiliary value starting at initial_aux, and stepped using the function g over only the valid parameters.

These aspects of the lemma allow us to leverage local reasoning about invariants of the main function, in the "happy path" case of valid parameters, in order to obtain global properties on the iterated application of main to a list of parameters, i.e. it allows us to guarantee a property on any sequence of contract calls, using an invariant that reasons only about a single call to the main function using valid parameters only.

In the next section, we'll see an example of application of this lemma, when we look at the proof for one of the properties of the above specification.

A proof for the specification, using our lemma

Now that we wrote the contract, the specification, and introduced the induction-over-transaction lemma, let's get to the core part : proving our specification.
We'll walk through the proof of spec_max.
As a reminder, here's the definition of spec_max :
Print spec_max.
(* spec_max =
    forall l : list param,
      Pmax (run_multiple_calls l) (list_max (get_bids (valid_ops' l)))
    : Prop *)

Step 1 : Decomposing with the proof goal using the fold_invariant_aux lemma

Now for the proof, the first part consists essentially in applying the lemma to decompose it into 3 subproofs :
Theorem unit_test_max : spec_max.
intros l.

unfold list_max.
rewrite <- fold_symmetric; cycle 1. 
- intros x y z.
  rewrite Nat.max_assoc.
  reflexivity.
- intros y.
  rewrite Nat.max_0_l.
  rewrite Nat.max_0_r.
  reflexivity.
- rewrite fold_get_bids.
  unfold valid_ops'.
  unfold valid_ops.
  unfold run_multiple_calls.
  unfold run_multiple_calls_.
  apply fold_invariant_aux.
  + intros state arg Hinvalid.
    rewrite invalid_is_noop_on_main; auto.
  + apply main_preserves_invariant_max.
  + compute; auto.
Qed.

Step 2 : Subproof that invalid transactions don't change the state

Property to prove :
Theorem invalid_is_noop_on_main :
  forall storage x,
    valid_op storage x = false -> main_get_storage storage x = storage.
The proof consists in a case analysis over the value of the transaction parameter and state. The truth table of transaction validity is summarized in the table below :

param \ state Available CurrentBid m
Sold m
Bid n true m < n false
Claim false true false

And the proof consists essentially in the case analysis with rewrites and unfolds for every case.

On valid cases, the precondition valid_op storage x = false evaluates to true = false and the inversion tactic is used to prove these cases by contradiction.

On invalid cases, the postcondition main_get_storage storage x = storage becomes trivial enough so it can be solved using the reflexivity tactic, after expanding some parts of the code.

intros s x Hinvalid.
unfold main_get_storage.
unfold main.
case_eq x.
- case_eq s.
  + intros Hs n Hx.
    rewrite Hs in Hinvalid.
    rewrite Hx in Hinvalid.
    unfold valid_op in Hinvalid.
    inversion Hinvalid.
  + intros n Hs n0 Hx.
    rewrite Hs in Hinvalid.
    rewrite Hx in Hinvalid.
    unfold valid_op in Hinvalid.
    case_eq (n <? n0).
    * intros Hless.
      rewrite Hless in Hinvalid.
      inversion Hinvalid.
    * intros Hmore.
      unfold main_option.
      rewrite Hmore.
      reflexivity.
  + intros n Hs n0 Hx.
    reflexivity.
- case_eq s.
  + intros Hs Hx.
    reflexivity.
  + intros n Hs Hx.
    rewrite Hs in Hinvalid.
    rewrite Hx in Hinvalid.
    unfold valid_op in Hinvalid.
    inversion Hinvalid.
  + intros n Hs Hx.
    reflexivity.
Qed.

Step 3 : Subproof that a call to the contract preserves the invariant

For the induction hypothesis, the same strategy applies.
It's a case analysis over the parameter and state values, with some preliminary rewrite and unfold on each case.
In this proof, we assume that the transaction is valid (if it is invalid, we have already proved that the main function is a no-op). Therefore, the cases for invalid transactions are discarded using inversion on Hvalid.
The valid cases are simplified enough so they can be proven by executing as much code as possible, using compute, or by noticing trivial equalities, via reflexivity.
Lemma main_preserves_invariant_max :
  forall (xarg : param)
         (xstate : storage)
         (xaux : nat)
         (Pprev : Pmax xstate xaux)
         (Hvalid : valid_op xstate xarg = true),
    Pmax (main_get_storage xstate xarg) (u_max xaux xarg).
intros xarg xstate xaux Pprev Hvalid.
case_eq xarg.
- case_eq xstate.
  * intros Hstate n Harg.
    cut (xaux = 0).
    + intros Haux.
      unfold u_max.
      rewrite Haux.
      rewrite Nat.max_0_r.
      compute.
      auto.
    + unfold Pmax in Pprev.
      rewrite Hstate in Pprev.
      assumption.
  * intros n Hstate n0 Harg.
    rewrite Hstate in Hvalid.
    rewrite Harg in Hvalid.
    unfold valid_op in Hvalid.
    unfold Pmax in Pprev.
    rewrite Hstate in Pprev.
    unfold main_get_storage.
    unfold u_max.
    unfold main.
    unfold main_option.
    rewrite Hvalid.
    rewrite <- Pprev.
    unfold snd.
    unfold Pmax.
    rewrite Nat.max_comm.
    rewrite Nat.max_r.
    + reflexivity.
    + apply Nat.leb_le in Hvalid.
      lia.
  * intros n Hstate n0 Harg.
    rewrite Hstate in Hvalid.
    rewrite Harg in Hvalid.
    unfold valid_op in Hvalid.
    inversion Hvalid.
- case_eq xstate.
  * intros Hstate Harg.
    rewrite Hstate, Harg in Hvalid.
    unfold valid_op in Hvalid.
    inversion Hvalid.
  * intros n Hstate Harg.
    unfold main_get_storage.
    unfold u_max.
    unfold main.
    unfold main_option.
    unfold snd.
    unfold Pmax.
    rewrite Hstate in Pprev.
    unfold Pmax in Pprev.
    rewrite Pprev in *.
    reflexivity.
  * intros n Hstate Harg.
    unfold main_get_storage.
    unfold u_max.
    unfold main.
    unfold main_option.
    unfold fail_in_main.
    unfold snd.
    unfold Pmax.
    rewrite Hstate in Pprev.
    unfold Pmax in Pprev.
    rewrite Pprev in *.
    reflexivity.
Qed.
Aside from some simple auxiliary lemmas, the proof of spec_max is now done.
Proofs for other theorems will often follow the same strategy :
  • Simplifying the problem down with the fold_invariant_aux lemma
  • Decomposing the proofs by case analysis over the parameter and state values
  • Then, for each case :
    • Stepping into the goal to prove with some rewrite and unfold
    • The rest depends on the actual goal, but as we saw, it can be trivial enough to be solved with reflexivity
In the last section, we'll see the tooling used to actually build our contract and check for the proofs.

Extraction

We link a few Coq definitions to their equivalent in OCaml / CameLIGO, and use Extraction "file.ml" main. to translate the main function to OCaml. Before doing that, however, we will check the assumptions made in these proofs, i.e. the axioms and LIGO built-in opaque functions that have been used. In this case, the only "axioms" used are the LIGO built-in function transaction, the LIGO built-in types operation and mutez, as well as destination_account which is a thin wrapper around the LIGO built-in Tezos.get_sender. This can take some time as some of the Coq standard library proofs need to be downloaded in order to check their own assumptions.
Definition checkAssumptions := (main, unit_test_max).
Print Assumptions checkAssumptions.
(*
Axioms:
transaction : ∀ param : Type, param → tez → contract param → operation
operation  : Type
mutez  : nat → tez
destination_account  : contract unit
*)
We now know that our proven program relies on the correct specification of its tests (e.g. spec_max), and on the correct implementation of these LIGO built-ins, as well as the correct implementation of the LIGO language itself and of the thin OCaml → CameLIGO translation step. This means that aside from these fragments, we have not used some unproven axiom in our proof of correctness for the spec_max specification. We can now extract the code:
(* Compile Coq code to OCaml *)
Extraction "contract.1.ocaml.ml" main.
This code can then be copy-pasted into an OCaml project on the LIGO WebIDE, and deployed to the Tezos blockchain.

Tooling

The build.sh script performs the following tasks:

  • Extract the coq source code into OCaml code, using coqc
  • Convert the OCaml code to CameLIGO code (a few minor compatibility details)
  • Runs ligo compile contract to produce Michelson code
The Michelson code obtained that way can then be used to originate the contract on-chain. Furthermore, the build.sh script performs these checks:

For each Definition spec_XYZ := … in specification.v, it checks that there is a proof_XYZ file containing the proof Theorem unit_test_XYZ : spec_XYZ. [...] Qed..

Furthermore, the build.sh script prints the axioms used by each proof, i.e. it shows which assumptions were made without being proven. Ideally, only some axioms describing some opaque types like the Tezos Operation type would appear in that list of assumptions.

This allows us to have a clean separation between the specification and the proofs, while ensuring that every part of the specification is indeed proven.

Thanks to this organization, in order to understand what aspects of the contract's behaviour have been formally proven, it suffices to read the specification, as well as the definitions and axioms transitively used by the specification. The checks performed by build.sh ensure that the specification is proven, and its success is essentially equivalent to an all-green when writing conventional unit tests. It is worth noting that it is not necessary to read the proofs, as they are automatically checked.

Further reading