Formally Proven Smart Contract Tutorial
By Suzanne Soy and Nicolas Phan 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 :
- At first, there is an available item for sale.
- People can place a bid on the item, each one bidding higher than the previous one.
- 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
(* 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 8instinctively 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 elementsf
: a function operating over the elementsaccu_init
: an initial value for the accumulator
- 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.
fold_invariant_aux
doesn't just fold over a list, it also :
- Filters invalid transactions (provided a predicate telling when a transaction is valid or not)
- 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 :
- Invaid_is_noop (the main function is a no-op when a parameter is invalid)
- Induction property :
Hind : P(x, extra_context) -> P(f(x), g(extra_context)
- 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.
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 |
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
andstate
values - Then, for each case :
- Stepping into the goal to prove with some
rewrite
andunfold
- The rest depends on the actual goal, but as we saw, it can be trivial enough to be solved with
reflexivity
Extraction
We link a few Coq definitions to their equivalent in OCaml / CameLIGO, and useExtraction "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
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.