Lesson 3 - Basic anatomy of a protocol in Quint
1. Introduction
Progress: 0%
In this tutorial, we explain the standard structure of a Quint protocol. Although Quint does not impose a very rigid structure on protocol designers, we have found that following common practices helps protocol authors and their readers to cooperate more effectively.
As a running example, we are using the subcurrency smart contract that is introduced in the Solidity documentation, see Subcurrency (opens in a new tab). You do not have to know Solidity to understand this tutorial. On the contrary, you may acquire some basic understanding of Solidity contracts by reading the Quint protocol specification.
The subcurrency smart contract defines a basic protocol that has the following features:
-
A single user (the protocol creator) is assigned the "minter" role.
-
The minter may generate new coins on the balances of the users, including themselves.
-
The users may send coins to other users, provided that they have enough coins on their balance.
In this tutorial, you will see how to:
-
Specify basic type aliases.
-
Define handy functions that can be used independently of the protocol.
-
Define the structure of the protocol states.
-
Define helper functions that read the current state, but do not modify it.
-
Define actions that read and modify the current state of the protocol.
-
Define basic protocol invariants.
-
Write basic protocol tests.
-
Write protocol tests that use input non-determinism.
-
Run a basic randomized test to discover an invariant violation.
If you would like to see the complete code before diving into the details, check coin.qnt (opens in a new tab).
2. Declare a single module
Progress: 4%
Code snippet:
module coin {
Quint specifications are organized in modules, and may consist of one or many top-level module declarations. Nested module declarations are currently not supported.
In this tutorial, we declare a single module. In general, we recommend to start with a single module and introduce multiple modules only if you are going to reuse various parts of your protocol.
3. Declare types
Progress: 8%
Code snippet:
// TYPE DEFINITIONS
// encode addresses as string literals
type Addr = str
// We declare a Solidity uint (256 bit) as an alias of Quint int.
// Note that these UInt values are still integers, which can get
// arbitrarily large. Hence, we have to take care of overflows.
// In the future, Quint will have a standard library for that.
type UInt = int
Similar to programming languages, it is common in Quint to declare type aliases for the types that appear in the protocol description often. A type alias is simply a declaration of the form:
type [name] = [tp];
In the above definition, [name]
is a unique identifier that will be associated
with the type, and [tp]
is the type to be associated with the name, e.g.,
int
or Set[int]
. To see a complete description of all available types, visit the Section
Type System 1.2 (opens in a new tab)
of the language manual.
Although it is convenient to declare type aliases, it is not mandatory.
Protocol authors should decide together with their audience, whether they prefer
minimal type definitions, or they like abundance of types. Quint replaces type aliases
with the types on the right-hand sides of type
. For example, it does not distinguish between
different kinds of "integers", when they are referred to via different type aliases.
4. Declare pure functions
Progress: 13%
Code snippet:
// FUNCTIONAL LAYER:
// Values and functions that are state-independent
// the maximal value a Solidity UInt can carry
pure val MAX_UINT = 2^256 - 1
// does a big integer represent a Solidity UInt?
pure def isUInt(i: int): bool = (0 <= i and i <= MAX_UINT)
It often happens that a protocol requires auxilliary definitions that do not depend on the protocol state, but only on the values of their parameters. Such computations are often called "pure". In the above code, we define two pure definitions:
-
The pure value
MAX_UINT
. -
The pure definition
isUInt
that computes if a given integeri
is within the range from 0 to MAX_UINT (inclusive).
The main property of pure values is that they always return the same value. Pure definitions always return the same value, if they are supplied with the same arguments.
To see that no state is needed, evaluate these definitions in REPL (read-evaluate-print-loop):
echo "MAX_UINT" | quint -r coin.qnt::coin
echo "isUInt(22)" | quint -r coin.qnt::coin
echo "isUInt(-1)" | quint -r coin.qnt::coin
echo "isUInt(MAX_UINT + 1)" | quint -r coin.qnt::coin
The functional layer is actually quite powerful; a lot of protocol behavior can be defined here without referring to protocol state.
As you can see, we have omitted the type of MAX_UINT
but specified the type of
isUInt
. Most of the time, the type checker can infer the types of values and
operator definitions, and giving additional type annotations is up to you. In
rare cases, the type checker may get confused, and then explicit type
annotations will help you in figuring out the issue.
5. Declare the protocol parameters
Progress: 17%
Code snippet:
// STATE MACHINE:
// State-dependent definitions and actions
// Currently, we fix the set of all addresses to a small set.
// In the future, we will be using the commented-out constant declaration.
// For now, we are using simple aliases instead of actual Ethereum addresses.
//const ADDR: Set[Addr]
pure val ADDR = Set("null", "alice", "bob", "charlie", "eve")
In this step, we are starting to describe the protocol structure and behavior in terms of a state machine.
It often happens that protocols are parameterized in the sense that the protocol may be instantiated for different parameter values, and different instances still make sense. The typical parameters are:
-
the set of all possible addresses,
-
the address that performs a special role,
-
minimal and maximal values,
-
timeout values.
For this purpose, Quint offers const
declarations. You can see one of them
in the commented out section of the code above. You may be wondering, what is
the difference between const
and pure val
. They mean to express the same
concept: A value that stays the same for all computations. However, they differ
in the time when they are bound to a value:
-
The
pure val
values are immediately defined via an expression in the right-hand side. -
The
const
values are first declared and later they are substituted with actual values via aninstance
declaration.
Constant declarations are not fully supported yet. They will be available as soon as the issue #528 (opens in a new tab) is closed.
At the moment, we simply declare the value for a small set of addresses ADDR
,
in order to be able to iterate on the protocol specification quickly.
6. Declare the protocol state
Progress: 21%
Code snippet:
// the minter's address
var minter: Addr
// the balances in the subcurrency tokens
var balances: Addr -> UInt
As a next step, we declare state variables, which together represent a state of the protocol. In the above code, we introduce two such variables:
-
The variable
minter
to store the minter's address. The type of this variable isAddr
. -
The variable
balances
to store the balances of all users. The type of this variable isAddr -> UInt
, which means a map from values of typeAddr
to values of typeUInt
.
Variable definitions always require type. Otherwise, it may be too hard for the type checker to infer the types of the state variables.
If you compare the above variable definitions to the relevant variable declarations in the Solidity contract, you should see that our variable declarations are conceptually similar, modulo the slightly different syntax of Solidity:
// in Solidity:
address public minter;
mapping (address => uint) public balances;
7. Declare operators over states
Progress: 26%
Code snippet:
// a handy definition to query the whole state in REPL at once
val state = { minter: minter, balances: balances }
It is often convenient to define a few helper operators.
We start with the definition of state
that represents the entire
state as a record. This is what we do in the above code with the definition of
state
. Notice that the definition of state
is prefixed with val
, not pure val
.
Since state
accesses state variables is it impure.
You can try to evaluate the definition of state
in REPL right away:
echo "state" | quint -r coin.qnt::coin
If you tried that, you saw several error messages. The reason is that the state
variables are not initialized by default. We would have to introduce an
initialization action, which is usually called init
. You will see how to
do that a few steps later.
8. Declare require
and totalSupply
Progress: 30%
Code snippet:
// a helper function to make preconditions similar to Solidity
def require(cond: bool): bool = cond
// compute the total supply of coins over all balances
val totalSupply = ADDR.fold(0, (sum, a) => sum + balances.get(a))
In the above code, we introduce a helper definition require
that
evaluates its Boolean parameter. As you might have noticed, this
definition is not doing anything useful. We introduce it to make the code
a bit more accessible for first-time readers who are familiar with
Solidity.
The definition of totalSupply
may look a bit complex,
if you have never seen similar code before. Let's break it down into smaller
pieces:
-
The definition of
totalSupply
defines a value, but not apure
one. Hence, even thoughtotalSupply
does not take any parameters, it implicitly depends on the state. As a result,totalSupply
may evaluate to different values in different states, that is, in those states where the values ofbalances
differ. -
ADDR.fold(0, f)
iterates over the set of addresses in some order and for every addressa
, it appliesf(s, a)
for the accumulator values
, which is initialized with 0. In our example, the operatorf
is defined as an anonymous lambda operator:(sum, a) => sum + balances.get(a)
. For our definition ofADDR
, the computed value would be equal to:((((0 + balances.get("null")) + balances.get("alice")) + balances.get("bob")) + balances.get("charlie")) + balances.get("eve")
Note that the order of the addresses in the brackets may be different from the one above. Hence, you should not rely on a particular order when using
fold
over sets. We are fine when using commutative operators such as+
and*
.
9. Declare an initializer init
Progress: 34%
Code snippet:
// state initialization
action init: bool = {
// since the contract can be initialized by an arbitrary message sender,
// we choose the sender from the set of all addresses non-deterministically.
nondet sender = oneOf(ADDR)
As you may have seen in the previous steps, the state variables minter
and balances
are not initialized by default. In order to compute an initial state (there may be
several!), we define a special action that we call an initializer. In our
code, such an action is called init
.
This definition is essential for describing the protocol. When you read somebody else's protocol, it is one of the key parts to look at.
If we go back to the Solidity code, it looks as follows:
// in Solidity
constructor() {
minter = msg.sender;
}
In the Solidity code, the constructor is using an implicit parameter msg.sender
,
which is propagated from a user request by the Ethereum Virtual Machine. Quint
does not have any built-in mechanism for reading user input. Instead, Quint
offers a very powerful mechanism of non-deterministic choice. This is
exactly what we do with the following line of code:
nondet sender = oneOf(ADDR)
This expression non-deterministically chooses one value from the set ADDR
(assuming that the set is not empty) and binds this value to the name sender
.
The qualifier nondet
indicates that the value of sender
is special: the
name sender
is bound to a fixed value, but sender
may evaluate to two
different values when init
is called twice or is called in different runs.
This behavior may look
complicated, but this is exactly what we expect from user input, too:
The user may submit different inputs, even if the protocol resides in
two identical states.
For more details, check oneOf (opens in a new tab) in the reference manual.
10. Assign initial values to the state variables
Progress: 39%
Code snippet:
all {
minter' = sender,
balances' = ADDR.mapBy(a => 0)
}
}
The rest of init
is simple:
The value of minter
is set to the value of sender
, and the value
of balances
is set to the map that maps all addresses from ADDR
to value 0. Notice that the variables minter
and balances
are not assigned
their new values immediately; they are assigned once init
is
evaluated completely (and only if init
evaluated to true
).
Now we can call init
and evaluate an initialized state in REPL:
echo "init\n state" | quint -r coin.qnt::coin
Exercise: Call init
multiple times in REPL and evaluate state
after
each call. Did you get the same initial states every time or were some states
different?
Remember that we called init
an initializer? This is because init
only assigns new values to state variables, but does not read their previous values.
Exercise: Modify the assignment to balances
in such a way that minter
gets 1000 coins, while the other addresses get 0 coins, as before.
Hint: use if (cond) value1 else value2
.
11. Defining the action send
Progress: 43%
Code snippet:
// Sends an amount of newly created coins to an address.
// Can only be called by the minter, that is, the contract creator.
// Note that we have to add `sender` as an action parameter,
// which is accessed implicitly via `msg.sender` in Solidity.
action mint(sender: Addr, receiver: Addr, amount: UInt): bool = all {
require(sender == minter),
val newBal = balances.get(receiver) + amount
all {
// Solidity does the overflow check under the hood.
// We have to add it ourselves.
require(isUInt(newBal)),
// update the balances and keep the minter address
balances' = balances.set(receiver, newBal),
minter' = minter,
}
}
Similar to Solidity, we define the action mint
. In contrast to init
,
we have decided to avoid non-deterministic choice in mint
.
Instead we are passing the user inputs as action parameters. You will see
later that this makes debugging and testing easier.
If you understood how init
works, the behavior of mint
should be also
easy to figure out. If you wonder what get
and set
are doing, here is
the explanation:
-
balances.get(receiver)
produces the value assigned to the keyreceiver
in the mapbalances
. If the keyreceiver
has no value assigned in the mapbalances
, REPL would show a runtime error. For more details, check get (opens in a new tab) in the reference manual. -
balances.set(receiver, newBal)
produces a new map, that assigns the value ofnewBal
to the keyreceiver
, and keeps the other key-value pairs as inbalances
. For more details, check set (opens in a new tab) in the reference manual.
Now it's time to mint some coins in REPL! Try the following:
echo 'init\n mint(minter, "bob", 2023)\n state' | quint -r coin.qnt::coin
As you can see, we can mint coins for Bob.
Exercise: Mint more coins for Bob, actually, mint MAX_UINT
coins.
Do you understand what happened in this case?
12. Defining the action send
Progress: 47%
Code snippet:
// Sends an amount of existing coins from any caller (sender)
// to a receiver's address.
// Note that we have to add `sender` as an action parameter,
// which is accessed via implicit `msg.sender` in Solidity.
action send(sender: Addr, receiver: Addr, amount: UInt): bool = all {
require(not(amount > balances.get(sender))),
if (sender == receiver) {
balances' = balances
} else {
val newSenderBal = balances.get(sender) - amount
val newReceiverBal = balances.get(receiver) + amount
all {
// Again, Solidity does an automatic overflow test.
// We do it ourselves.
require(isUInt(newSenderBal)),
require(isUInt(newReceiverBal)),
balances' =
balances
.set(sender, newSenderBal)
.set(receiver, newReceiverBal)
}
},
// keep the minter unchanged
minter' = minter,
}
If you understood the mechanics of the action mint
, you should easily
figure out the behavior of send
.
Play with mint
and send
in REPL! The simplest scenario would be:
echo 'init\n mint(minter, "bob", 2023)\n send("bob", "eve", 1024)\n state' | quint -r coin.qnt::coin
13. Defining a protocol step
Progress: 52%
Code snippet:
// All possible behaviors of the protocol in one action.
action step: bool = {
nondet sender = oneOf(ADDR)
nondet receiver = oneOf(ADDR)
nondet amount = 0.to(MAX_UINT).oneOf()
// execute one of the available actions/methods
any {
mint(sender, receiver, amount),
send(sender, receiver, amount),
}
}
Finally, we can put together mint
and send
to describe every possible
transition of the protocol! To this end, we non-deterministically choose
the sender, the receiver, and the amount (from the set of integers from 0
to MAX_UINT
, inclusive). The expression any { ... }
executes one of its
arguments; if both could be executed, one of them is executed
non-deterministically.
Exercise: Run REPL, execute init
once and execute step
multiple times.
Do you understand why some of the occurrences of step
evaluate to false
and some evaluate to true
? It may help you, if you print state
after init
and step
.
We have defined the state variables, the initializer, and a step of the protocol. This is the minimal set of tasks for defining a working protocol. What we have achieved here is great! You can play with the protocol, feed it with different parameters in REPL, and experiment with your protocol.
Although you should definitely play with the protocol at this point, we urge you not to stop here! In the next steps, we show you the real magic of Quint.
14. Expected properties
Progress: 56%
Code snippet:
// INVARIANTS AND TEMPORAL PROPERTIES
Having defined the protocol behavior with init
and step
, it is time to think
about what we expect from the protocol. This is a good place for specifying
protocol invariants and temporal properties.
15. Defining the most basic invariant
Progress: 60%
Code snippet:
// One of the simplest properties is that all balances are within the uint range.
// While it is automatically guaranteed by Solidity, our specification is using
// big integers. Hence, it makes sense to check the range.
val balancesRangeInv: bool =
ADDR.forall(a => isUInt(balances.get(a)))
One of the most basic properties that we expect from the protocol is defined
by the property balancesRangeInv
. This property goes over all addresses
in ADDR
and tests, whether the value stored in balances
for every address
is in the range from 0
to MAX_UINT
(inclusive).
We can immediately check this invariant for a few states:
echo 'init\n balancesRangeInv\n mint(minter, "bob", 2023)\n balancesRangeInv\n send("bob", "eve", 1024)\n balancesRangeInv\n ' | quint -r coin.qnt::coin
Properties like balancesRangeInv
are called invariants, because we expect
them to hold in every state that could be produced by init
and a number of
step
actions. Intuitively, whatever happens, the property should hold true.
It is important to distinguish between the properties that we would like to be invariants and the properties that we have proven to be invariants. To be precise, the former properties are called invariant candidates, whereas the latter are actually called invariants.
16. Defining the total supply invariant
Progress: 65%
Code snippet:
// It is desirable that the total supply of tokens fits into UInt.
// Otherwise, a blockchain implementation or the user interface
// may run into an unexpected overflow when adding up the balances.
val totalSupplyDoesNotOverflowInv: bool = {
isUInt(totalSupply)
}
Another basic invariant that we intuitively expect to hold is defined in
totalSupplyDoesNotOverflowInv
. It is so simple that it should hold true, right?
If we have your attention now, read further!
Exercise: Extend the protocol with a state variable called totalMinted
,
initialize it with 0 and increase it in mint
with the amount
passed to mint
.
17. Temporal properties
Progress: 69%
Code snippet:
// The temporal property that says the following:
// Assume that we want to check the temporal property `NoSupplyOverflow`
// for every initial state. Then we have to check for every initial state
// that it never produces a computation that ends in a state violating
// `totalSupplyDoesNotOverflowInv`. In general, temporal properties may be
// checked against any kinds of states, not only initial ones.
temporal NoSupplyOverflow: bool = always(totalSupplyDoesNotOverflowInv)
After we have defined state invariants, we should normally think about
temporal properties in general such as safety and liveness. However,
temporal properties are an advanced topic, and smart
contracts are typically focused on safety. Thus, it is safe to skip this topic
for now. It is just important to know that temporal properties are labelled
with the qualifier temporal
.
18. Tests
Progress: 73%
Code snippet:
// TESTS
// send should not work without minting
run sendWithoutMintTest = {
init.then(send(minter, "bob", 5))
.fail()
}
So far, we have been running sequences of actions in REPL, to get basic understanding of the protocol mechanics. While REPL is capable of replaying actions one-by-one, it would be more convenient to run something similar to unit tests or integration tests, which are ubiquitous in programming languages.
We normally add tests in the very bottom of the protocol module, or in a separate module.
Quint introduces runs to express "happy paths" and tests. The above code
shows a simple test sendWithoutMintTest
.
In this test, the action init
runs first. If it evaluates to true
,
then the action send(...)
is run. Since this action is composed with
fail()
, the action send(...)
is expected to evaluate to false
.
Go ahead and see if this test goes through:
echo 'sendWithoutMintTest' | quint -r coin.qnt::coin
Exercise: Actually, if you look carefully at the code of send
,
you can find one value for amount
that makes send
work even without
prior minting. What is this value?
19. A single data point test
Progress: 78%
Code snippet:
// `mint`, then `send`
run mintSendTest = {
init.then(mint(minter, "bob", 10))
.then(send("bob", "eve", 4))
.then(all {
assert(balances.get("bob") == 6),
assert(balances.get("eve") == 4),
minter' = minter,
balances' = balances,
})
}
We can write longer tests that are similar to unit/integration tests
in normal programming languages. For instance, the above test mintSendTest
makes sure that the exact sequence of mint
and send
transactions goes
through and the resulting balances have the expected values.
echo 'mintSendTest' | quint -r coin.qnt::coin
Exercise: Change some numbers in the test, run it again in REPL and observe what happens.
Although it is better to have a test like mintSendTest
than no test at all,
mintSendTest
is testing only one data point. We can do better in Quint,
see the next step.
20. Testing with non-deterministic inputs
Progress: 82%
Code snippet:
// Mint some coins for Eve and Bob.
// Test that Eve can always send coins to Bob,
// if she has enough on her balance.
// This test may fail sometimes. Do you see why?
// If not, execute it multiple times in REPL, until it fails.
run mintTwiceThenSendError = {
// non-deterministically pick some amounts to mint and send
nondet mintEve = 0.to(MAX_UINT).oneOf()
nondet mintBob = 0.to(MAX_UINT).oneOf()
nondet eveToBob = 0.to(MAX_UINT).oneOf()
// execute a fixed sequence `init`, `mint`, `mint`, `send`
init.then(mint(minter, "eve", mintEve))
.then(mint(minter, "bob", mintBob))
.then(
if (eveToBob <= balances.get("eve")) {
// if Eve has enough tokens, send to Bob should pass
send("eve", "bob", eveToBob)
} else {
// otherwise, just ignore the test
all { minter' = minter, balances' = balances }
}
)
}
Instead of testing a sequence of transactions for a carefully crafted
single input, we could fix a sequence of transactions and let the computer
find the inputs that fail the test. This is exactly what we are doing in
the above test mintTwiceThenSendError
. The test non-deterministically
chooses the amounts of coins to mint and send and then executes the actions
mint
, mint
, and send
. As the values are chosen non-deterministically,
we know that some of the inputs should fail send
. Our hypothesis is that
send
should never fail when Eve has enough coins on her account.
If she does not, we simply ignore this choice of inputs in the else-branch.
Let's run this test:
echo 'mintTwiceThenSendError' | quint -r coin.qnt::coin
If you lucky, it fails right away. If it does not fail, run it multiple times,
until it fails. To see why it failed, evaluate state
after executing
the test. Do you understand why our hypothesis was wrong?
Exercise: Fix the condition in mintTwiceThenSendError
,
so that the test never fails.
If you carefully look at mintTwiceThenSendError
, you will see that it is still
a single data point test, though the data point (the inputs) are chosen
non-deterministically every time we run the test. In fact, REPL implements
non-determinism via random choice.
If you do not want to sit the whole day and run the test, you could integrate
it into continuous integration, so it runs from time to time for different
inputs. Quint comes with the command test
that is designed for exactly this
purpose. Try the command below. Most likely, it would find a violation
after just a few tests.
quint test --match mintTwiceThenSendError coin.qnt
Also, we had to fix the sequence of actions in our test. We can do better with Quint.
21. Testing with non-deterministic inputs and control
Progress: 86%
Code snippet:
// to run the random simulator for 10000 executions, each up to 10 actions,
// execute the following in the command line:
//
// $ quint run --invariant totalSupplyDoesNotOverflowInv coin.qnt
It is often hard to find a good sequence of actions that breaks an invariant. Similar to how we let the computer to find the right inputs, we can use the computing power to look for bad sequences of actions.
Quint REPL actually supports random search for sequences of actions that violate an invariant. Its UX is a bit ad hoc at the moment. We will improve it in the future. You can try it right away:
quint run --invariant totalSupplyDoesNotOverflowInv coin.qnt
The above command in REPL randomly produces sequences of steps,
starting with init
and continuing with step
, up to 20 steps. It checks the
invariant totalSupplyDoesNotOverflowInv
after every step. If the invariant is
violated, the random search stops and returns false
. If no invariant violaion
is found, the search returns true
after enumerating the specified number of runs,
which is 10000 in our example. The search parameters such as the number of
runs and steps can be tuned. Check the options of run
:
quint run --help
22. Does random testing always find bugs?
Progress: 91%
To be honest, our example is relatively simple, and we were quite lucky
that the invariant totalSupplyDoesNotOverflowInv
was often violated by
a completely random search. For more complex protocols, random search
often gets stuck while looking for non-interesting inputs or sequences
of actions. Hence, if you have a hunch about where an error could potentially
be, you could restrict the scope of the search by:
-
Restricting the scope of non-determinstic choices, e.g., by making every set in
oneOf(S)
smaller. -
Restricting the choice of actions, e.g., by removing non-essential actions from
step
.
Although the above tricks may help you in detecting some bugs, it is well known that there is always a probability of missing a bug with random search.
If you are looking for better guarantees of correctness, Quint will be soon integrated with the Apalache model checker (opens in a new tab). The model checker looks for counterexamples more exhaustively, by solving equations. In some cases, it may even give you a guarantee that there is no bug, if it has not found any.
23. Suming it up
Progress: 95%
This was a long tutorial. We have specified the whole protocol, experimented with it, wrote several tests and even found some surprising behavior of this protocol. We hope that by going through this tutorial you have got an idea of how you could specify your own protocol in Quint and make Quint useful for solving your problems with protocols!
We have used a Solidity contract as the running example. Actually, there is not much special about Solidity in this coin protocol. A similar protocol could be implemented in Cosmos SDK (opens in a new tab) or Cosmwasm (opens in a new tab).
We are experimenting with different kinds of tutorials. It would be great to learn whether you liked this tutorial format, or not. Please vote in the discussion (opens in a new tab).
The end
You have made it!